@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.
- package/README.md +117 -4
- package/package.json +2 -2
- package/src/ai/model.d.ts +317 -0
- package/src/ai/model.js +312 -0
- package/src/build/store-frame.d.ts +10 -91
- package/src/build/store-frame.js +11 -159
- package/src/build/vendor.js +1 -1
- package/src/content/slices.d.ts +63 -0
- package/src/content/slices.js +80 -0
- package/src/content/watch.d.ts +71 -0
- package/src/content/watch.js +95 -0
- package/src/frame/window.d.ts +120 -0
- package/src/frame/window.js +197 -0
- package/src/index.d.ts +36 -4
- package/src/index.js +25 -4
- package/src/settings/saver.d.ts +13 -0
- package/src/settings/saver.js +11 -0
- package/src/styles/chrome-ui.css +11 -0
- package/src/testing/e2e.d.ts +15 -0
- package/src/testing/e2e.js +26 -1
- package/src/testing/fake-model.d.ts +87 -0
- package/src/testing/fake-model.js +142 -0
- package/src/testing/fake-storage.d.ts +2 -0
- package/src/testing/fake-storage.js +15 -3
- package/src/testing/heartbeat.d.ts +23 -0
- package/src/testing/heartbeat.js +43 -0
- package/src/testing/index.d.ts +13 -3
- package/src/testing/index.js +10 -3
- package/src/testing/messages.d.ts +33 -0
- package/src/testing/messages.js +51 -0
- package/src/testing/prompt.d.ts +20 -0
- package/src/testing/prompt.js +29 -0
- package/src/ui/model-row.d.ts +111 -0
- package/src/ui/model-row.js +108 -0
- package/src/ui/text-dialog.d.ts +44 -7
- package/src/ui/text-dialog.js +54 -16
package/src/ai/model.js
ADDED
|
@@ -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
|
*
|
package/src/build/store-frame.js
CHANGED
|
@@ -10,16 +10,26 @@
|
|
|
10
10
|
* Playwright at `geometry(zoom).content`, then screenshot `canvas({caption, chrome:
|
|
11
11
|
* windowChrome({tabs, url}), content})` at {@link W}×{@link H}.
|
|
12
12
|
*
|
|
13
|
+
* The window itself is `frame/window.js`, in the runtime half, so an extension page can draw it
|
|
14
|
+
* around a capture too; it's re-exported here.
|
|
15
|
+
*
|
|
13
16
|
* @module build/store-frame
|
|
14
17
|
*/
|
|
15
18
|
|
|
19
|
+
import { THEMES, WINDOW_BARS, WINDOW_W } from '../frame/window.js';
|
|
20
|
+
|
|
21
|
+
export { THEMES, letterIcon, windowChrome, windowChromeSvg } from '../frame/window.js';
|
|
22
|
+
|
|
23
|
+
/** @typedef {import('../frame/window.js').Theme} Theme */
|
|
24
|
+
/** @typedef {import('../frame/window.js').Tab} Tab */
|
|
25
|
+
|
|
16
26
|
/** The store screenshot's width. */
|
|
17
27
|
export const W = 1280;
|
|
18
28
|
/** The store screenshot's height. */
|
|
19
29
|
export const H = 800;
|
|
20
30
|
const CAPTION_H = 112;
|
|
21
31
|
/** Where the window sits: its left edge, top edge, full width, and tab strip and toolbar heights. */
|
|
22
|
-
export const WIN = { x: 64, y: CAPTION_H, w:
|
|
32
|
+
export const WIN = { x: 64, y: CAPTION_H, w: WINDOW_W, ...WINDOW_BARS };
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* How a shot's window is laid out.
|
|
@@ -42,164 +52,6 @@ export function geometry(zoom = 1) {
|
|
|
42
52
|
return { zoom, w, content: { width: w, height: Math.round((H - WIN.y) / zoom) - WIN.strip - WIN.toolbar } };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
/**
|
|
46
|
-
* @param {unknown} s - Text.
|
|
47
|
-
* @returns {string} It, escaped for HTML text and attributes.
|
|
48
|
-
*/
|
|
49
|
-
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
50
|
-
|
|
51
|
-
/** Material icon paths (24×24). */
|
|
52
|
-
const P = {
|
|
53
|
-
back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
|
|
54
|
-
forward: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
|
|
55
|
-
reload: 'M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
|
|
56
|
-
close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
|
57
|
-
add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
|
|
58
|
-
more: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
|
|
59
|
-
puzzle: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z',
|
|
60
|
-
star: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
|
|
61
|
-
tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z',
|
|
62
|
-
sound: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
|
|
63
|
-
muted: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z',
|
|
64
|
-
person: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* A Material icon as inline SVG, in the text colour.
|
|
69
|
-
*
|
|
70
|
-
* @param {keyof typeof P} name - Which icon.
|
|
71
|
-
* @param {number} [size] - Width and height in px. Default 20.
|
|
72
|
-
* @returns {string} The SVG markup.
|
|
73
|
-
*/
|
|
74
|
-
const icon = (name, size = 20) =>
|
|
75
|
-
`<svg viewBox="0 0 24 24" width="${size}" height="${size}"><path fill="currentColor" d="${P[name]}"/></svg>`;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* A letter favicon for made-up sites (no real brands).
|
|
79
|
-
*
|
|
80
|
-
* @param {string} letter - The letter.
|
|
81
|
-
* @param {string} bg - Background colour.
|
|
82
|
-
* @param {string} [fg] - Letter colour. Default white.
|
|
83
|
-
* @returns {string} A data: URL of the SVG.
|
|
84
|
-
*/
|
|
85
|
-
export const letterIcon = (letter, bg, fg = '#fff') =>
|
|
86
|
-
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${bg}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${fg}">${letter}</text></svg>`)}`;
|
|
87
|
-
|
|
88
|
-
/** Chrome's light and dark window colours, and the canvas behind. */
|
|
89
|
-
export const THEMES = {
|
|
90
|
-
light: {
|
|
91
|
-
bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
|
|
92
|
-
frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
|
|
93
|
-
toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
|
|
94
|
-
contentLine: '#d3d7dc', shadow: '0 24px 60px rgba(31,45,70,.20), 0 0 0 .5px rgba(0,0,0,.18)',
|
|
95
|
-
},
|
|
96
|
-
dark: {
|
|
97
|
-
bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
|
|
98
|
-
frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
|
|
99
|
-
toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
|
|
100
|
-
contentLine: '#2a2a2a', shadow: '0 24px 60px rgba(0,0,0,.55), 0 0 0 .5px rgba(255,255,255,.14)',
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
/** @typedef {keyof typeof THEMES} Theme */
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* One tab in the drawn window.
|
|
108
|
-
*
|
|
109
|
-
* @typedef {object} Tab
|
|
110
|
-
* @property {string} title Its title.
|
|
111
|
-
* @property {string} icon Its favicon's URL.
|
|
112
|
-
* @property {boolean} [active] The selected tab.
|
|
113
|
-
* @property {boolean} [audible] Playing sound.
|
|
114
|
-
* @property {boolean} [muted] Muted.
|
|
115
|
-
*/
|
|
116
|
-
|
|
117
|
-
const TAB_H = 34;
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Lay out tabs like Chrome: equal widths up to 240px, shrinking to fit.
|
|
121
|
-
*
|
|
122
|
-
* @param {readonly Tab[]} tabs - The tabs.
|
|
123
|
-
* @param {number} w - The window's width.
|
|
124
|
-
* @returns {{ x: number, width: number }[]} Each tab's window-relative x and width.
|
|
125
|
-
*/
|
|
126
|
-
function tabLayout(tabs, w) {
|
|
127
|
-
const left = 84;
|
|
128
|
-
const right = 60;
|
|
129
|
-
const max = 240;
|
|
130
|
-
const avail = w - left - right - 36;
|
|
131
|
-
const width = Math.min(max, Math.floor(avail / tabs.length));
|
|
132
|
-
return tabs.map((_, i) => ({ x: left + i * width, width }));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The window's tab strip and toolbar, as HTML. Positions are window-relative.
|
|
137
|
-
*
|
|
138
|
-
* @param {object} options - What to draw.
|
|
139
|
-
* @param {readonly Tab[]} options.tabs - The tabs, left to right.
|
|
140
|
-
* @param {string} options.url - The address bar's text.
|
|
141
|
-
* @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
|
|
142
|
-
* @param {Theme} [options.theme] - Default light.
|
|
143
|
-
* @param {number} [options.w] - The window's width. Default the full window.
|
|
144
|
-
* @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
|
|
145
|
-
* @returns {string} The HTML, with its own styles.
|
|
146
|
-
*/
|
|
147
|
-
export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WIN.w, ext = [] }) {
|
|
148
|
-
const t = THEMES[theme];
|
|
149
|
-
const layout = tabLayout(tabs, w);
|
|
150
|
-
const tabHtml = tabs.map((tab, i) => {
|
|
151
|
-
const { x, width } = layout[i];
|
|
152
|
-
const sepAfter = i < tabs.length - 1 && !tab.active && !tabs[i + 1].active;
|
|
153
|
-
const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
|
|
154
|
-
const showClose = tab.active || width >= 120;
|
|
155
|
-
return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
|
|
156
|
-
<img class="fav" src="${tab.icon}" width="16" height="16" alt="">
|
|
157
|
-
<span class="title">${esc(tab.title)}</span>
|
|
158
|
-
${alert ? `<span class="alert">${alert}</span>` : ''}
|
|
159
|
-
${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
|
|
160
|
-
</div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
|
|
161
|
-
}).join('');
|
|
162
|
-
const last = layout[layout.length - 1];
|
|
163
|
-
const plusX = last.x + last.width + 4;
|
|
164
|
-
const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px">`).join('');
|
|
165
|
-
return `
|
|
166
|
-
<div class="strip">
|
|
167
|
-
<span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
|
|
168
|
-
${tabHtml}
|
|
169
|
-
<span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
|
|
170
|
-
</div>
|
|
171
|
-
<div class="toolbar">
|
|
172
|
-
<span class="tb">${icon('back')}</span><span class="tb dim">${icon('forward')}</span><span class="tb">${icon('reload')}</span>
|
|
173
|
-
<div class="omni"><span class="site">${icon('tune', 16)}</span><span class="url">${urlHost ? `${esc(urlHost)}<span class="dim">${esc(url.slice(urlHost.length))}</span>` : esc(url)}</span><span class="star">${icon('star', 18)}</span></div>
|
|
174
|
-
${extIcons}<span class="tb">${icon('puzzle')}</span>
|
|
175
|
-
<span class="avatar">${icon('person', 18)}</span><span class="tb">${icon('more')}</span>
|
|
176
|
-
</div>
|
|
177
|
-
<style>
|
|
178
|
-
.strip { position: relative; height: ${WIN.strip}px; background: ${t.frame}; }
|
|
179
|
-
.lights { position: absolute; left: 16px; top: 15px; display: flex; gap: 8px; }
|
|
180
|
-
.lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
|
|
181
|
-
.tab { position: absolute; bottom: 0; height: ${TAB_H}px; display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; box-sizing: border-box; color: ${t.tabTextInactive}; font: 12px/16px system-ui; }
|
|
182
|
-
.tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
|
|
183
|
-
.tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
|
|
184
|
-
.tab.active::before { left: -10px; background: radial-gradient(circle at 0 0, transparent 10px, ${t.activeTab} 10.5px); }
|
|
185
|
-
.tab.active::after { right: -10px; background: radial-gradient(circle at 100% 0, transparent 10px, ${t.activeTab} 10.5px); }
|
|
186
|
-
.tab .fav { flex: none; }
|
|
187
|
-
.tab .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
|
|
188
|
-
.tab .alert, .tab .x { flex: none; display: flex; color: ${t.toolbarIcon}; }
|
|
189
|
-
.sep { position: absolute; bottom: 9px; width: 1px; height: 18px; background: ${t.tabSep}; }
|
|
190
|
-
.plus { position: absolute; top: 11px; display: flex; color: ${t.toolbarIcon}; padding: 2px; }
|
|
191
|
-
.toolbar { height: ${WIN.toolbar}px; background: ${t.toolbar}; display: flex; align-items: center; gap: 4px; padding: 0 8px; box-sizing: border-box; border-bottom: 1px solid ${t.contentLine}; }
|
|
192
|
-
.tb { width: 34px; height: 34px; display: grid; place-items: center; color: ${t.toolbarIcon}; flex: none; }
|
|
193
|
-
.tb.dim { opacity: .45; }
|
|
194
|
-
.omni { flex: 1; min-width: 0; height: 34px; margin: 0 8px; border-radius: 17px; background: ${t.omnibox}; display: flex; align-items: center; gap: 10px; padding: 0 12px; color: ${t.url}; font: 14px system-ui; }
|
|
195
|
-
.omni .site { display: flex; color: ${t.toolbarIcon}; }
|
|
196
|
-
.omni .url { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
197
|
-
.omni .dim { color: ${t.urlDim}; }
|
|
198
|
-
.omni .star { display: flex; color: ${t.toolbarIcon}; }
|
|
199
|
-
.avatar { flex: none; width: 26px; height: 26px; margin: 0 4px; border-radius: 50%; background: #7cacf8; color: #fff; display: grid; place-items: end center; overflow: hidden; }
|
|
200
|
-
</style>`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
55
|
/**
|
|
204
56
|
* The macOS arrow pointer.
|
|
205
57
|
*
|
package/src/build/vendor.js
CHANGED
|
@@ -28,7 +28,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
28
28
|
const KIT = '@arraypress/extension-kit';
|
|
29
29
|
|
|
30
30
|
/** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
|
|
31
|
-
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', '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');
|