@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,312 @@
1
+ /**
2
+ * Chrome's on-device model (Gemini Nano, through the Prompt API), the way
3
+ * every extension here uses it, following
4
+ * https://developer.chrome.com/docs/ai/prompt-api:
5
+ *
6
+ * - `availability()` is asked with the options `create()` gets, and only an
7
+ * 'available' model is used: nothing here downloads it, except
8
+ * {@link downloadModel}, which a page calls from the person's click (Chrome
9
+ * only downloads from a user gesture) and which reports progress;
10
+ * - one base session, with the instructions and examples, is made once and
11
+ * kept; each question goes to a `clone()` of it, so answers don't leak into
12
+ * each other;
13
+ * - each answer has a time limit: an `AbortSignal` stops the answer once
14
+ * it's late, and the caller gets a reason instead of a hang;
15
+ * - the first session after Chrome starts can take ~18 s while the model
16
+ * loads (a cold start, M3 Max). A question with a shorter limit gets
17
+ * 'late', and the session goes on loading for the next one. Nothing loads
18
+ * it ahead of time: that would load the model on every start for work that
19
+ * may never come.
20
+ *
21
+ * Nothing here touches `chrome.*` or the global itself: the Prompt API comes
22
+ * in as `model` (read when used, since Chrome may add it after start-up), so
23
+ * tests hand it the kit's fake (`fakeLanguageModel`, `/testing`) at exactly
24
+ * this edge. TypeScript's DOM library doesn't declare the Prompt API yet, so
25
+ * the parts used are typed here.
26
+ *
27
+ * @module ai/model
28
+ */
29
+
30
+ import { within } from '../time/time.js';
31
+
32
+ /**
33
+ * The model's answer to `availability()`: 'available', 'downloadable',
34
+ * 'downloading' or 'unavailable'; and, from {@link modelAvailability},
35
+ * 'missing' when this Chrome has no Prompt API.
36
+ *
37
+ * @typedef {'available' | 'downloadable' | 'downloading' | 'unavailable' | 'missing'} Availability
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} PromptOptions
42
+ * @property {AbortSignal} [signal] Stops the answer.
43
+ * @property {object} [responseConstraint] A JSON Schema (or RegExp) the answer must follow.
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} LanguageModelSession
48
+ * @property {(prompt: string, options?: PromptOptions) => Promise<string>} prompt The whole answer.
49
+ * @property {(options?: {signal?: AbortSignal}) => Promise<LanguageModelSession>} clone A copy with the same history.
50
+ * @property {() => void} destroy Let it go.
51
+ */
52
+
53
+ /**
54
+ * @typedef {{role: 'system' | 'user' | 'assistant', content: string}} InitialPrompt
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} LanguageModelOptions
59
+ * @property {{type: string, languages: string[]}[]} [expectedInputs] What it's given.
60
+ * @property {{type: string, languages: string[]}[]} [expectedOutputs] What it answers in.
61
+ * @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
62
+ * @property {(monitor: EventTarget) => void} [monitor] Hears `downloadprogress` (`loaded`, 0–1) while the model downloads.
63
+ * @property {AbortSignal} [signal] Stops creating it.
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} LanguageModelStatic
68
+ * @property {(options?: LanguageModelOptions) => Promise<Availability>} availability Whether it can run here.
69
+ * @property {(options?: LanguageModelOptions) => Promise<LanguageModelSession>} create A session (downloads the model first, from a click).
70
+ */
71
+
72
+ /**
73
+ * Chrome's `LanguageModel`, or undefined when this Chrome has none.
74
+ *
75
+ * @param {object} [scope] - Where to look. Default `globalThis` (a page, or the service worker).
76
+ * @returns {LanguageModelStatic | undefined} The Prompt API.
77
+ * @example
78
+ * const sessions = createModelSession({ model: chromeLanguageModel, options: ENGLISH });
79
+ */
80
+ export function chromeLanguageModel(scope = globalThis) {
81
+ return /** @type {{LanguageModel?: LanguageModelStatic}} */ (scope).LanguageModel;
82
+ }
83
+
84
+ /**
85
+ * The model's state, never throwing: 'missing' without a Prompt API, and
86
+ * 'unavailable' when asking fails.
87
+ *
88
+ * @param {LanguageModelStatic | undefined} model - The Prompt API.
89
+ * @param {LanguageModelOptions} options - The options sessions are created with.
90
+ * @returns {Promise<Availability>} Its state.
91
+ * @example
92
+ * await modelAvailability(chromeLanguageModel(), ENGLISH); // 'available'
93
+ */
94
+ export async function modelAvailability(model, options) {
95
+ if (!model) return 'missing';
96
+ try {
97
+ return await model.availability(options);
98
+ } catch {
99
+ return 'unavailable';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Download the model, from the person's click: `create()` with a monitor,
105
+ * the session let go once made. Call it first in the click's handler, with
106
+ * nothing awaited before it, or Chrome refuses (no user activation).
107
+ *
108
+ * @param {LanguageModelStatic | undefined} model - The Prompt API.
109
+ * @param {LanguageModelOptions} options - The options sessions are created with.
110
+ * @param {{onProgress?: (loaded: number) => void, signal?: AbortSignal}} [handlers] - `onProgress`: how much is
111
+ * downloaded, 0–1, as Chrome reports it; `signal`: stops it.
112
+ * @returns {Promise<Availability>} The state afterwards ('available' once it's here).
113
+ * @throws {Error} When there's no Prompt API, or Chrome couldn't download it.
114
+ * @example
115
+ * button.addEventListener('click', () => {
116
+ * downloadModel(chromeLanguageModel(), ENGLISH, { onProgress: (loaded) => { bar.value = loaded; } })
117
+ * .then(show, () => show('downloadable', 'The download didn’t finish. Try again.'));
118
+ * });
119
+ */
120
+ export async function downloadModel(model, options, { onProgress, signal } = {}) {
121
+ if (!model) throw new Error('This Chrome has no Prompt API');
122
+ const session = await model.create({
123
+ ...options,
124
+ ...(signal && { signal }),
125
+ monitor: (monitor) => monitor.addEventListener('downloadprogress', (event) => {
126
+ onProgress?.(Number(/** @type {ProgressEvent} */ (event).loaded) || 0);
127
+ }),
128
+ });
129
+ session.destroy();
130
+ return modelAvailability(model, options);
131
+ }
132
+
133
+ /**
134
+ * The settings page's words for a state: standard message keys (the
135
+ * extension's `_locales` names them alike), as `SITE_LIST_MESSAGES` does
136
+ * for a site list.
137
+ */
138
+ export const MODEL_MESSAGES = Object.freeze({
139
+ /** “Chrome’s built-in AI”: the row's name. */
140
+ name: 'optionsModel',
141
+ /** “Checking…” before the state is known. */
142
+ checking: 'optionsModelChecking',
143
+ /** “Ready”. */
144
+ ready: 'optionsModelReady',
145
+ /** “Needs download”. */
146
+ needsDownload: 'optionsModelNeedsDownload',
147
+ /** “Chrome downloads its model once and shares it with every site and extension that uses it”. */
148
+ downloadHelp: 'optionsModelDownloadHelp',
149
+ /** “Download”: the button. */
150
+ download: 'optionsModelDownload',
151
+ /** “Downloading… $PERCENT$%” (content `$1`). */
152
+ downloading: 'optionsModelDownloading',
153
+ /** “The download didn’t finish. Try again.” */
154
+ downloadFailed: 'optionsModelDownloadFailed',
155
+ /** “Not supported on this computer”. */
156
+ unsupported: 'optionsModelUnsupported',
157
+ /** What a computer needs to run it. */
158
+ requirements: 'optionsModelRequirements',
159
+ /** “This Chrome doesn’t have it”. */
160
+ missing: 'optionsModelMissing',
161
+ /** What to do about that, and that the extension works without it. */
162
+ missingHelp: 'optionsModelMissingHelp',
163
+ });
164
+
165
+ /**
166
+ * The model's state, as a settings page shows it.
167
+ *
168
+ * @typedef {object} ModelStatus
169
+ * @property {'ready' | 'pending' | 'off'} state Ready to use, can be downloaded, or can't run here.
170
+ * @property {string} status The status's message key ({@link MODEL_MESSAGES}).
171
+ * @property {string} help The help's message key ('' for none).
172
+ * @property {boolean} canDownload Whether to offer Download.
173
+ */
174
+
175
+ /**
176
+ * Describe an availability for a settings page. Pure.
177
+ *
178
+ * @param {string} availability - 'available', 'downloadable', 'downloading', 'unavailable' or 'missing'.
179
+ * @returns {ModelStatus} How to show it.
180
+ * @example
181
+ * describeModel('downloadable'); // { state: 'pending', status: 'optionsModelNeedsDownload', help: 'optionsModelDownloadHelp', canDownload: true }
182
+ */
183
+ export function describeModel(availability) {
184
+ switch (availability) {
185
+ case 'available':
186
+ return { state: 'ready', status: MODEL_MESSAGES.ready, help: '', canDownload: false };
187
+ case 'downloadable':
188
+ case 'downloading':
189
+ return { state: 'pending', status: MODEL_MESSAGES.needsDownload, help: MODEL_MESSAGES.downloadHelp, canDownload: true };
190
+ case 'missing':
191
+ return { state: 'off', status: MODEL_MESSAGES.missing, help: MODEL_MESSAGES.missingHelp, canDownload: false };
192
+ default:
193
+ return { state: 'off', status: MODEL_MESSAGES.unsupported, help: MODEL_MESSAGES.requirements, canDownload: false };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * What a question got: the answer, or why there's none: the model isn't
199
+ * on this computer ('unavailable'), the time ran out first ('late', a cold
200
+ * start among them), the caller's signal stopped it ('aborted'), or the model
201
+ * failed ('failed').
202
+ *
203
+ * @typedef {{text: string, reason: 'answered'} | {text: null, reason: 'unavailable' | 'late' | 'aborted' | 'failed'}} ModelAnswer
204
+ */
205
+
206
+ /**
207
+ * @typedef {object} AskOptions
208
+ * @property {number} timeoutMs The most the answer may take, from the moment it's asked: the base
209
+ * session (if it must be made) and the answer.
210
+ * @property {AbortSignal} [signal] Stops it sooner (the person cancelled).
211
+ * @property {object} [responseConstraint] A JSON Schema the answer must follow.
212
+ */
213
+
214
+ /**
215
+ * @typedef {object} ModelSession
216
+ * @property {() => Promise<Availability>} availability The model's state, or 'missing'.
217
+ * @property {(prompt: string, options: AskOptions) => Promise<ModelAnswer>} ask Ask a copy of the base session.
218
+ * @property {() => void} release Let the base session go (the next question makes it again).
219
+ */
220
+
221
+ /**
222
+ * A base session over a Prompt API, made when first needed and kept, each
223
+ * question asked of a `clone()` within its time limit.
224
+ *
225
+ * @param {{model: () => LanguageModelStatic | undefined, options: LanguageModelOptions, initialPrompts?: InitialPrompt[]}} setup -
226
+ * `model`: the Prompt API, read when used; `options`: what sessions are made (and availability asked)
227
+ * with; `initialPrompts`: the instructions and examples every question starts from.
228
+ * @returns {ModelSession} The session.
229
+ * @example
230
+ * const sessions = createModelSession({
231
+ * model: chromeLanguageModel,
232
+ * options: { expectedInputs: [{ type: 'text', languages: ['en'] }], expectedOutputs: [{ type: 'text', languages: ['en'] }] },
233
+ * initialPrompts: [{ role: 'system', content: 'You name downloaded files.' }],
234
+ * });
235
+ * const { text } = await sessions.ask('Site: acme.com', { timeoutMs: 2500 }); // null when late
236
+ */
237
+ export function createModelSession({ model, options, initialPrompts = [] }) {
238
+ /** @type {Promise<LanguageModelSession> | null} */
239
+ let base = null;
240
+
241
+ /**
242
+ * The base session, made once; a failed one isn't kept, so the next question tries again.
243
+ *
244
+ * @param {LanguageModelStatic} api - The Prompt API, ready.
245
+ * @returns {Promise<LanguageModelSession>} The session.
246
+ */
247
+ function baseSession(api) {
248
+ if (!base) {
249
+ const made = api.create({ ...options, ...(initialPrompts.length && { initialPrompts }) });
250
+ base = made;
251
+ made.catch(() => {
252
+ if (base === made) base = null;
253
+ });
254
+ }
255
+ return base;
256
+ }
257
+
258
+ /**
259
+ * Ask a copy of the base session; the copy is let go after.
260
+ *
261
+ * @param {LanguageModelStatic} api - The Prompt API, ready.
262
+ * @param {string} prompt - The question.
263
+ * @param {AbortSignal} signal - Stops it once it's late or the caller stops it.
264
+ * @param {object | undefined} responseConstraint - The answer's shape.
265
+ * @returns {Promise<string>} The answer.
266
+ */
267
+ async function ask(api, prompt, signal, responseConstraint) {
268
+ const session = await (await baseSession(api)).clone({ signal });
269
+ try {
270
+ return await session.prompt(prompt, { signal, ...(responseConstraint && { responseConstraint }) });
271
+ } finally {
272
+ session.destroy();
273
+ }
274
+ }
275
+
276
+ return {
277
+ availability: () => modelAvailability(model(), options),
278
+ async ask(prompt, { timeoutMs, signal, responseConstraint }) {
279
+ const started = Date.now();
280
+ if (signal?.aborted) return { text: null, reason: 'aborted' };
281
+ const api = model();
282
+ const state = await within(modelAvailability(api, options), timeoutMs, /** @type {Availability | 'late'} */ ('late'));
283
+ if (state === 'late') return { text: null, reason: 'late' };
284
+ if (state !== 'available' || !api) return { text: null, reason: 'unavailable' };
285
+ const stop = new AbortController();
286
+ const onAbort = () => stop.abort();
287
+ signal?.addEventListener('abort', onAbort, { once: true });
288
+ try {
289
+ // A string is the answer; false, it failed; null, it's late.
290
+ const outcome = await within(
291
+ ask(api, prompt, stop.signal, responseConstraint).catch(() => /** @type {false} */ (false)),
292
+ Math.max(0, timeoutMs - (Date.now() - started)),
293
+ /** @type {string | false | null} */ (null),
294
+ );
295
+ if (typeof outcome === 'string') return { text: outcome, reason: 'answered' };
296
+ if (signal?.aborted) return { text: null, reason: 'aborted' };
297
+ if (outcome === null) {
298
+ stop.abort();
299
+ return { text: null, reason: 'late' };
300
+ }
301
+ return { text: null, reason: 'failed' };
302
+ } finally {
303
+ signal?.removeEventListener('abort', onAbort);
304
+ }
305
+ },
306
+ release() {
307
+ const held = base;
308
+ base = null;
309
+ held?.then((session) => session.destroy(), () => {});
310
+ },
311
+ };
312
+ }
@@ -10,19 +10,27 @@
10
10
  * Playwright at `geometry(zoom).content`, then screenshot `canvas({caption, chrome:
11
11
  * windowChrome({tabs, url}), content})` at {@link W}×{@link H}.
12
12
  *
13
+ * The window itself is `frame/window.js`, in the runtime half, so an extension page can draw it
14
+ * around a capture too; it's re-exported here.
15
+ *
13
16
  * @module build/store-frame
14
17
  */
18
+ export { THEMES, letterIcon, windowChrome, windowChromeSvg } from '../frame/window.js';
19
+ export type Theme = import('../frame/window.js').Theme;
20
+ export type Tab = import('../frame/window.js').Tab;
21
+ /** @typedef {import('../frame/window.js').Theme} Theme */
22
+ /** @typedef {import('../frame/window.js').Tab} Tab */
15
23
  /** The store screenshot's width. */
16
24
  export declare const W = 1280;
17
25
  /** The store screenshot's height. */
18
26
  export declare const H = 800;
19
27
  /** Where the window sits: its left edge, top edge, full width, and tab strip and toolbar heights. */
20
28
  export declare const WIN: {
29
+ strip: 42;
30
+ toolbar: 46;
21
31
  x: number;
22
32
  y: number;
23
33
  w: number;
24
- strip: number;
25
- toolbar: number;
26
34
  };
27
35
  export type Geometry = {
28
36
  /**
@@ -57,95 +65,6 @@ export type Geometry = {
57
65
  * @returns {Geometry} The window's unscaled width and the size to capture page content at.
58
66
  */
59
67
  export declare function geometry(zoom?: number): Geometry;
60
- /**
61
- * A letter favicon for made-up sites (no real brands).
62
- *
63
- * @param {string} letter - The letter.
64
- * @param {string} bg - Background colour.
65
- * @param {string} [fg] - Letter colour. Default white.
66
- * @returns {string} A data: URL of the SVG.
67
- */
68
- export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
69
- /** Chrome's light and dark window colours, and the canvas behind. */
70
- export declare const THEMES: {
71
- light: {
72
- bg: string;
73
- caption: string;
74
- sub: string;
75
- frame: string;
76
- tabText: string;
77
- tabTextInactive: string;
78
- tabSep: string;
79
- activeTab: string;
80
- toolbar: string;
81
- toolbarIcon: string;
82
- omnibox: string;
83
- url: string;
84
- urlDim: string;
85
- contentLine: string;
86
- shadow: string;
87
- };
88
- dark: {
89
- bg: string;
90
- caption: string;
91
- sub: string;
92
- frame: string;
93
- tabText: string;
94
- tabTextInactive: string;
95
- tabSep: string;
96
- activeTab: string;
97
- toolbar: string;
98
- toolbarIcon: string;
99
- omnibox: string;
100
- url: string;
101
- urlDim: string;
102
- contentLine: string;
103
- shadow: string;
104
- };
105
- };
106
- export type Theme = keyof typeof THEMES;
107
- export type Tab = {
108
- /**
109
- * Its title.
110
- */
111
- title: string;
112
- /**
113
- * Its favicon's URL.
114
- */
115
- icon: string;
116
- /**
117
- * The selected tab.
118
- */
119
- active?: boolean;
120
- /**
121
- * Playing sound.
122
- */
123
- audible?: boolean;
124
- /**
125
- * Muted.
126
- */
127
- muted?: boolean;
128
- };
129
- /**
130
- * The window's tab strip and toolbar, as HTML. Positions are window-relative.
131
- *
132
- * @param {object} options - What to draw.
133
- * @param {readonly Tab[]} options.tabs - The tabs, left to right.
134
- * @param {string} options.url - The address bar's text.
135
- * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
136
- * @param {Theme} [options.theme] - Default light.
137
- * @param {number} [options.w] - The window's width. Default the full window.
138
- * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
139
- * @returns {string} The HTML, with its own styles.
140
- */
141
- export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
142
- tabs: readonly Tab[];
143
- url: string;
144
- urlHost?: string;
145
- theme?: Theme;
146
- w?: number;
147
- ext?: readonly string[];
148
- }): string;
149
68
  /**
150
69
  * The macOS arrow pointer.
151
70
  *
@@ -10,16 +10,26 @@
10
10
  * Playwright at `geometry(zoom).content`, then screenshot `canvas({caption, chrome:
11
11
  * windowChrome({tabs, url}), content})` at {@link W}×{@link H}.
12
12
  *
13
+ * The window itself is `frame/window.js`, in the runtime half, so an extension page can draw it
14
+ * around a capture too; it's re-exported here.
15
+ *
13
16
  * @module build/store-frame
14
17
  */
15
18
 
19
+ import { THEMES, WINDOW_BARS, WINDOW_W } from '../frame/window.js';
20
+
21
+ export { THEMES, letterIcon, windowChrome, windowChromeSvg } from '../frame/window.js';
22
+
23
+ /** @typedef {import('../frame/window.js').Theme} Theme */
24
+ /** @typedef {import('../frame/window.js').Tab} Tab */
25
+
16
26
  /** The store screenshot's width. */
17
27
  export const W = 1280;
18
28
  /** The store screenshot's height. */
19
29
  export const H = 800;
20
30
  const CAPTION_H = 112;
21
31
  /** Where the window sits: its left edge, top edge, full width, and tab strip and toolbar heights. */
22
- export const WIN = { x: 64, y: CAPTION_H, w: 1152, strip: 42, toolbar: 46 };
32
+ export const WIN = { x: 64, y: CAPTION_H, w: WINDOW_W, ...WINDOW_BARS };
23
33
 
24
34
  /**
25
35
  * How a shot's window is laid out.
@@ -42,164 +52,6 @@ export function geometry(zoom = 1) {
42
52
  return { zoom, w, content: { width: w, height: Math.round((H - WIN.y) / zoom) - WIN.strip - WIN.toolbar } };
43
53
  }
44
54
 
45
- /**
46
- * @param {unknown} s - Text.
47
- * @returns {string} It, escaped for HTML text and attributes.
48
- */
49
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
50
-
51
- /** Material icon paths (24×24). */
52
- const P = {
53
- back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
54
- forward: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
55
- reload: 'M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
56
- close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
57
- add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
58
- more: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
59
- puzzle: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z',
60
- star: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
61
- tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z',
62
- sound: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
63
- muted: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z',
64
- person: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
65
- };
66
-
67
- /**
68
- * A Material icon as inline SVG, in the text colour.
69
- *
70
- * @param {keyof typeof P} name - Which icon.
71
- * @param {number} [size] - Width and height in px. Default 20.
72
- * @returns {string} The SVG markup.
73
- */
74
- const icon = (name, size = 20) =>
75
- `<svg viewBox="0 0 24 24" width="${size}" height="${size}"><path fill="currentColor" d="${P[name]}"/></svg>`;
76
-
77
- /**
78
- * A letter favicon for made-up sites (no real brands).
79
- *
80
- * @param {string} letter - The letter.
81
- * @param {string} bg - Background colour.
82
- * @param {string} [fg] - Letter colour. Default white.
83
- * @returns {string} A data: URL of the SVG.
84
- */
85
- export const letterIcon = (letter, bg, fg = '#fff') =>
86
- `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${bg}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${fg}">${letter}</text></svg>`)}`;
87
-
88
- /** Chrome's light and dark window colours, and the canvas behind. */
89
- export const THEMES = {
90
- light: {
91
- bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
92
- frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
93
- toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
94
- contentLine: '#d3d7dc', shadow: '0 24px 60px rgba(31,45,70,.20), 0 0 0 .5px rgba(0,0,0,.18)',
95
- },
96
- dark: {
97
- bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
98
- frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
99
- toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
100
- contentLine: '#2a2a2a', shadow: '0 24px 60px rgba(0,0,0,.55), 0 0 0 .5px rgba(255,255,255,.14)',
101
- },
102
- };
103
-
104
- /** @typedef {keyof typeof THEMES} Theme */
105
-
106
- /**
107
- * One tab in the drawn window.
108
- *
109
- * @typedef {object} Tab
110
- * @property {string} title Its title.
111
- * @property {string} icon Its favicon's URL.
112
- * @property {boolean} [active] The selected tab.
113
- * @property {boolean} [audible] Playing sound.
114
- * @property {boolean} [muted] Muted.
115
- */
116
-
117
- const TAB_H = 34;
118
-
119
- /**
120
- * Lay out tabs like Chrome: equal widths up to 240px, shrinking to fit.
121
- *
122
- * @param {readonly Tab[]} tabs - The tabs.
123
- * @param {number} w - The window's width.
124
- * @returns {{ x: number, width: number }[]} Each tab's window-relative x and width.
125
- */
126
- function tabLayout(tabs, w) {
127
- const left = 84;
128
- const right = 60;
129
- const max = 240;
130
- const avail = w - left - right - 36;
131
- const width = Math.min(max, Math.floor(avail / tabs.length));
132
- return tabs.map((_, i) => ({ x: left + i * width, width }));
133
- }
134
-
135
- /**
136
- * The window's tab strip and toolbar, as HTML. Positions are window-relative.
137
- *
138
- * @param {object} options - What to draw.
139
- * @param {readonly Tab[]} options.tabs - The tabs, left to right.
140
- * @param {string} options.url - The address bar's text.
141
- * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
142
- * @param {Theme} [options.theme] - Default light.
143
- * @param {number} [options.w] - The window's width. Default the full window.
144
- * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
145
- * @returns {string} The HTML, with its own styles.
146
- */
147
- export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WIN.w, ext = [] }) {
148
- const t = THEMES[theme];
149
- const layout = tabLayout(tabs, w);
150
- const tabHtml = tabs.map((tab, i) => {
151
- const { x, width } = layout[i];
152
- const sepAfter = i < tabs.length - 1 && !tab.active && !tabs[i + 1].active;
153
- const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
154
- const showClose = tab.active || width >= 120;
155
- return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
156
- <img class="fav" src="${tab.icon}" width="16" height="16" alt="">
157
- <span class="title">${esc(tab.title)}</span>
158
- ${alert ? `<span class="alert">${alert}</span>` : ''}
159
- ${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
160
- </div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
161
- }).join('');
162
- const last = layout[layout.length - 1];
163
- const plusX = last.x + last.width + 4;
164
- const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px">`).join('');
165
- return `
166
- <div class="strip">
167
- <span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
168
- ${tabHtml}
169
- <span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
170
- </div>
171
- <div class="toolbar">
172
- <span class="tb">${icon('back')}</span><span class="tb dim">${icon('forward')}</span><span class="tb">${icon('reload')}</span>
173
- <div class="omni"><span class="site">${icon('tune', 16)}</span><span class="url">${urlHost ? `${esc(urlHost)}<span class="dim">${esc(url.slice(urlHost.length))}</span>` : esc(url)}</span><span class="star">${icon('star', 18)}</span></div>
174
- ${extIcons}<span class="tb">${icon('puzzle')}</span>
175
- <span class="avatar">${icon('person', 18)}</span><span class="tb">${icon('more')}</span>
176
- </div>
177
- <style>
178
- .strip { position: relative; height: ${WIN.strip}px; background: ${t.frame}; }
179
- .lights { position: absolute; left: 16px; top: 15px; display: flex; gap: 8px; }
180
- .lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
181
- .tab { position: absolute; bottom: 0; height: ${TAB_H}px; display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; box-sizing: border-box; color: ${t.tabTextInactive}; font: 12px/16px system-ui; }
182
- .tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
183
- .tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
184
- .tab.active::before { left: -10px; background: radial-gradient(circle at 0 0, transparent 10px, ${t.activeTab} 10.5px); }
185
- .tab.active::after { right: -10px; background: radial-gradient(circle at 100% 0, transparent 10px, ${t.activeTab} 10.5px); }
186
- .tab .fav { flex: none; }
187
- .tab .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
188
- .tab .alert, .tab .x { flex: none; display: flex; color: ${t.toolbarIcon}; }
189
- .sep { position: absolute; bottom: 9px; width: 1px; height: 18px; background: ${t.tabSep}; }
190
- .plus { position: absolute; top: 11px; display: flex; color: ${t.toolbarIcon}; padding: 2px; }
191
- .toolbar { height: ${WIN.toolbar}px; background: ${t.toolbar}; display: flex; align-items: center; gap: 4px; padding: 0 8px; box-sizing: border-box; border-bottom: 1px solid ${t.contentLine}; }
192
- .tb { width: 34px; height: 34px; display: grid; place-items: center; color: ${t.toolbarIcon}; flex: none; }
193
- .tb.dim { opacity: .45; }
194
- .omni { flex: 1; min-width: 0; height: 34px; margin: 0 8px; border-radius: 17px; background: ${t.omnibox}; display: flex; align-items: center; gap: 10px; padding: 0 12px; color: ${t.url}; font: 14px system-ui; }
195
- .omni .site { display: flex; color: ${t.toolbarIcon}; }
196
- .omni .url { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
197
- .omni .dim { color: ${t.urlDim}; }
198
- .omni .star { display: flex; color: ${t.toolbarIcon}; }
199
- .avatar { flex: none; width: 26px; height: 26px; margin: 0 4px; border-radius: 50%; background: #7cacf8; color: #fff; display: grid; place-items: end center; overflow: hidden; }
200
- </style>`;
201
- }
202
-
203
55
  /**
204
56
  * The macOS arrow pointer.
205
57
  *
@@ -28,7 +28,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
28
28
  const KIT = '@arraypress/extension-kit';
29
29
 
30
30
  /** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
31
- export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
31
+ export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'ai', 'commands', 'content', 'dom', 'frame', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
32
32
 
33
33
  /** The kit's stylesheet, an absolute path in the installed package. */
34
34
  export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');