@open-agent-toolkit/cli 0.2.20 → 0.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/assets/docs/workflows/projects/artifacts.md +1 -1
  2. package/assets/docs/workflows/skills/explainer-kit.md +188 -23
  3. package/assets/docs/workflows/skills/index.md +1 -1
  4. package/assets/public-package-versions.json +4 -4
  5. package/assets/skills/explainer-kit/SKILL.md +87 -25
  6. package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
  7. package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
  8. package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
  9. package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
  10. package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
  11. package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
  12. package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
  13. package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
  14. package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
  15. package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
  16. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
  17. package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
  18. package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
  19. package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
  20. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
  21. package/assets/skills/explainer-kit/references/contracts.md +45 -19
  22. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
  23. package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
  24. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
  25. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
  26. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
  27. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
  28. package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +687 -0
  29. package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
  30. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +313 -14
  31. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +314 -41
  32. package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
  33. package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
  34. package/assets/skills/explainer-kit/scripts/render-qa.mjs +152 -2
  35. package/assets/skills/explainer-kit/scripts/run.mjs +789 -272
  36. package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
  37. package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
  38. package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
  39. package/assets/skills/explainer-kit/templates/house-style.html +82 -0
  40. package/assets/skills/oat-brainstorm/SKILL.md +1 -1
  41. package/assets/skills/oat-brainstorm/scripts/helper.js +18 -11
  42. package/assets/skills/oat-brainstorm/scripts/server.cjs +109 -55
  43. package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
  44. package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
  45. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
  46. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
  47. package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
  48. package/assets/skills/oat-project-complete/SKILL.md +18 -2
  49. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  50. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
  51. package/assets/skills/oat-wave-execute/SKILL.md +12 -19
  52. package/assets/skills/oat-wave-program/SKILL.md +12 -13
  53. package/dist/commands/init/tools/index.d.ts.map +1 -1
  54. package/dist/commands/init/tools/index.js +4 -4
  55. package/dist/engine/compute-plan.js +3 -3
  56. package/package.json +2 -2
  57. package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
  58. package/assets/skills/explainer-kit/schemas/author-result.schema.json +0 -65
@@ -30,13 +30,25 @@ claims, sources, overrides, and the freshness policy. It returns:
30
30
  No provider name, command, credential, or dispatch protocol is part of this
31
31
  contract.
32
32
 
33
- Every unattended run also requires a provider-neutral author callback. An
34
- in-process caller supplies `options.author(request)`; a JSON-only CLI caller
35
- uses `--author-module author.mjs`. The core invokes it once per recipe artifact
36
- with an `AuthorRequestV1` containing the exact narrative outline, reconciled
37
- fact base, and bounded-discovery context. It must return an `AuthorResultV1`
38
- with every required section in order, non-empty prose, and non-secret
39
- provenance. The executable callback is never persisted in `run-request.json`.
33
+ Every run, interactive or unattended, also requires a provider-neutral author
34
+ callback; a run without one fails `E_AUTHOR_REQUIRED`. An in-process caller
35
+ supplies `options.author(request)`; a JSON-only CLI caller uses
36
+ `--author-module author.mjs`. The core invokes it once per resolved artifact
37
+ with an `explainer-kit.author-request/v2` payload containing the artifact
38
+ identity and type, the artifact's authoring path, the inlined brief, the
39
+ reconciled fact base, the resolved theme, the shell source for artistic
40
+ artifacts, the required narrative sections for narrative floor artifacts, and
41
+ bounded-discovery context. It must return an `explainer-kit.author-result/v2`
42
+ carrying exactly one of `content.markdown` or `content.html`, matching the
43
+ artifact's declared authoring path, plus non-secret provenance. A floor result
44
+ may also carry `proposedArtifacts` of `{id, profileId, rationale}`; the
45
+ referenced expansion profile supplies the type, authoring path, brief, and
46
+ shell. The executable callback is never persisted in `run-request.json`.
47
+
48
+ Unknown profile IDs and unsafe, duplicate, or floor-colliding artifact IDs are
49
+ hard errors. Proposals beyond a profile's `maxCount` or the recipe's
50
+ `expansion.limits.maxArtifacts` are rejected with a warning and the run
51
+ continues.
40
52
 
41
53
  ## Explicit source forms
42
54
 
@@ -57,7 +69,8 @@ provenance. The executable callback is never persisted in `run-request.json`.
57
69
  An unattended request asserts that its explicit source artifacts are already
58
70
  approved. It does not prompt, and it fails before narrative serialization when
59
71
  the author is absent, returns an invalid result, or copies excessive verbatim
60
- source text. Interactive review and same-run approval/resume remain unchanged.
72
+ source text. It auto-approves with `auto-drafted` marking once the artifacts are
73
+ built and checked.
61
74
 
62
75
  ## Pipeline and retained package
63
76
 
@@ -65,19 +78,30 @@ The core executes:
65
78
 
66
79
  1. validate request and recipe
67
80
  2. reconcile or check the fact base
68
- 3. apply bounded discovery and obtain or create recipe content
81
+ 3. apply bounded discovery
69
82
  4. resolve one theme
70
- 5. render typed artifacts
71
- 6. run structural and optional browser QA
72
- 7. write the manifest and build record
83
+ 5. author each floor artifact against its brief, evaluate expansion proposals,
84
+ and author each accepted expansion artifact
85
+ 6. render typed artifacts through the narrative renderer or validate
86
+ agent-composed HTML, per each artifact's declared authoring path
87
+ 7. run structural, guideline, and optional browser QA
88
+ 8. resolve content approval — the interactive gate pauses here, after render and
89
+ QA and before anything is published or persisted externally
90
+ 9. write the manifest and build record
73
91
 
74
92
  The run package retains the privacy-safe `run-request.json`,
75
93
  `source/content-approval.json`, `source/fact-base.json`,
76
- `source/fact-base.md`, `source/content/*.md`, optional structured
77
- `source/author/*.json` results, `theme.resolved.json`, rendered `site/` files,
78
- `manifest.json`, and `build-record.json` as far as each stage succeeds. A stage
79
- failure records a structured error and recovery action without deleting earlier
80
- outputs. Raw art direction is omitted unless the request explicitly opts in.
94
+ `source/fact-base.md`, authored content under `source/content/*.md` and
95
+ `source/content/*.html`, structured `source/author/*.json` results,
96
+ `theme.resolved.json`, rendered `site/` files, `manifest.json`, and
97
+ `build-record.json` as far as each stage succeeds. A stage failure records a
98
+ structured error and recovery action without deleting earlier outputs. Raw art
99
+ direction is omitted unless the request explicitly opts in.
100
+
101
+ Build-record stages are terminal once `passed` or `warned`. A rejected run that
102
+ is later approved reopens the render and QA stages through a narrowly guarded
103
+ record-level reset so the corrected sources are re-rendered and re-validated,
104
+ leaving an auditable trail rather than approving stale artifacts.
81
105
 
82
106
  When a caller supplies `discover({ round, recipe, factBase })`, the callback
83
107
  returns the findings added in that round. The core stops after two consecutive
@@ -93,7 +117,7 @@ Durability and publishing are never implicit.
93
117
  `record-durability.mjs`; the core never creates commits. The first evidence
94
118
  commit must contain every path and byte hash in `manifest.immutableHashes`:
95
119
  the privacy-safe request, content approval, fact-base JSON and Markdown,
96
- declared author results, all content Markdown, the resolved theme, and every
120
+ declared author results, all authored content, the resolved theme, and every
97
121
  built artifact retained in the package. Mutable `manifest.json` and
98
122
  `build-record.json` remain excluded for the separate evidence update.
99
123
  Schema-v1 manifests created before complete-package coverage are rejected
@@ -110,7 +134,9 @@ verified. Publishing remains independently human-gated by the caller.
110
134
  ## Result
111
135
 
112
136
  `runExplainer` returns the run root, manifest path, build-record path, outcome,
113
- warnings, and bounded-discovery summary. Input validation and unsupported
137
+ warnings, approval status and marking, and bounded-discovery summary. The
138
+ marking rides in the result and the approval record only; `manifest/v1` stays
139
+ frozen and carries no marking field. Input validation and unsupported
114
140
  recipes reject before output mutation. Failures after initialization return a
115
141
  `failed` result with paths to the retained record and intermediates.
116
142
 
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "explainer-kit.author-request/v2",
4
+ "title": "AuthorRequestV2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "artifactId",
10
+ "artifactType",
11
+ "authoring",
12
+ "brief",
13
+ "factBase",
14
+ "theme"
15
+ ],
16
+ "properties": {
17
+ "schemaVersion": { "const": "explainer-kit.author-request/v2" },
18
+ "artifactId": { "type": "string", "minLength": 1 },
19
+ "artifactType": {
20
+ "enum": ["hub", "diagram", "explainer", "deck", "catalog"]
21
+ },
22
+ "authoring": { "enum": ["markdown", "html"] },
23
+ "brief": { "type": "string", "minLength": 1 },
24
+ "factBase": { "$ref": "explainer-kit.fact-base/v1" },
25
+ "shell": { "type": "string", "minLength": 1 },
26
+ "theme": { "$ref": "explainer-kit.theme/v1" },
27
+ "floor": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": ["requiredNarrative"],
31
+ "properties": {
32
+ "requiredNarrative": {
33
+ "type": "array",
34
+ "items": { "type": "string", "minLength": 1 },
35
+ "minItems": 1,
36
+ "uniqueItems": true
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "explainer-kit.author-result/v2",
4
+ "title": "AuthorResultV2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "artifactId", "content", "provenance"],
8
+ "properties": {
9
+ "schemaVersion": { "const": "explainer-kit.author-result/v2" },
10
+ "artifactId": { "type": "string", "minLength": 1 },
11
+ "content": {
12
+ "type": "object",
13
+ "additionalProperties": false,
14
+ "oneOf": [{ "required": ["markdown"] }, { "required": ["html"] }],
15
+ "properties": {
16
+ "markdown": { "type": "string", "minLength": 1 },
17
+ "html": { "type": "string", "minLength": 1 }
18
+ }
19
+ },
20
+ "provenance": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "required": ["authorId", "generatedAt"],
24
+ "properties": {
25
+ "authorId": { "type": "string", "minLength": 1 },
26
+ "generatedAt": { "type": "string", "format": "date-time" },
27
+ "method": { "type": "string", "minLength": 1 },
28
+ "trust": {
29
+ "description": "Stamped by the core on the retained record only. An author callback that asserts it is rejected.",
30
+ "enum": ["caller-bound", "self-asserted"]
31
+ }
32
+ }
33
+ },
34
+ "proposedArtifacts": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["id", "profileId", "rationale"],
40
+ "properties": {
41
+ "id": {
42
+ "type": "string",
43
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
44
+ },
45
+ "profileId": { "type": "string", "minLength": 1 },
46
+ "rationale": { "type": "string", "minLength": 1 }
47
+ }
48
+ },
49
+ "uniqueItems": true
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,442 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+
5
+ // Locations a headless Chromium may already exist at on a developer or CI
6
+ // machine. The core never installs a browser; it only uses one that is there.
7
+ const CHROMIUM_CANDIDATES = Object.freeze([
8
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
9
+ '/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
10
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
11
+ '/usr/bin/google-chrome',
12
+ '/usr/bin/google-chrome-stable',
13
+ '/usr/bin/chromium',
14
+ '/usr/bin/chromium-browser',
15
+ ]);
16
+ const PROBE_REQUEST_FIELDS = new Set([
17
+ 'artifact',
18
+ 'disableAnimations',
19
+ 'evaluate',
20
+ 'injectedCss',
21
+ 'javascriptEnabled',
22
+ 'keyboard',
23
+ 'media',
24
+ 'reducedMotion',
25
+ 'scenario',
26
+ 'themeToggle',
27
+ 'viewport',
28
+ 'wideContent',
29
+ ]);
30
+ const DISABLE_ANIMATIONS_CSS = `*, *::before, *::after {
31
+ animation: none !important;
32
+ animation-duration: 0s !important;
33
+ transition: none !important;
34
+ transition-duration: 0s !important;
35
+ scroll-behavior: auto !important;
36
+ }`;
37
+
38
+ export const RUNTIME_UNAVAILABLE_REASONS = Object.freeze({
39
+ driverMissing: 'browser-driver-not-installed',
40
+ executableMissing: 'no-installed-chromium-executable',
41
+ disabled: 'disabled-by-configuration',
42
+ });
43
+
44
+ export const HEADLESS_PROBE_ENV = 'EXPLAINER_KIT_HEADLESS_PROBE';
45
+
46
+ /**
47
+ * Operators may switch probe resolution off for hermetic unit runs. The opt-out
48
+ * is reported separately from a capability failure so it never reads as
49
+ * "no runtime exists here".
50
+ */
51
+ export function headlessProbeDisabled(env = process.env) {
52
+ return ['off', 'false', '0'].includes(
53
+ String(env[HEADLESS_PROBE_ENV] ?? '').toLowerCase(),
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Detect whether this machine can actually drive a headless browser. The
59
+ * driver import is dynamic so the bundled core stays installable without it.
60
+ */
61
+ export async function resolveHeadlessRuntime({
62
+ loadDriver = () => import('@playwright/test'),
63
+ fileExists = existsSync,
64
+ env = process.env,
65
+ } = {}) {
66
+ if (headlessProbeDisabled(env)) {
67
+ return { available: false, reason: RUNTIME_UNAVAILABLE_REASONS.disabled };
68
+ }
69
+
70
+ let chromium;
71
+ try {
72
+ ({ chromium } = await loadDriver());
73
+ } catch {
74
+ return {
75
+ available: false,
76
+ reason: RUNTIME_UNAVAILABLE_REASONS.driverMissing,
77
+ };
78
+ }
79
+ if (
80
+ typeof chromium?.launch !== 'function' ||
81
+ typeof chromium?.executablePath !== 'function'
82
+ ) {
83
+ return {
84
+ available: false,
85
+ reason: RUNTIME_UNAVAILABLE_REASONS.driverMissing,
86
+ };
87
+ }
88
+
89
+ let bundled;
90
+ try {
91
+ bundled = chromium.executablePath();
92
+ } catch {
93
+ bundled = undefined;
94
+ }
95
+ const executablePath = [
96
+ env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
97
+ bundled,
98
+ ...CHROMIUM_CANDIDATES,
99
+ ].find((candidate) => candidate && fileExists(candidate));
100
+ if (!executablePath) {
101
+ return {
102
+ available: false,
103
+ reason: RUNTIME_UNAVAILABLE_REASONS.executableMissing,
104
+ };
105
+ }
106
+
107
+ return {
108
+ available: true,
109
+ name: 'chromium',
110
+ executablePath,
111
+ launch: () => chromium.launch({ headless: true, executablePath }),
112
+ };
113
+ }
114
+
115
+ export async function launchInstalledChromium(options = {}) {
116
+ const runtime = await resolveHeadlessRuntime(options);
117
+ if (!runtime.available) {
118
+ throw new Error(
119
+ `No headless browser runtime is available (${runtime.reason}).`,
120
+ );
121
+ }
122
+ return runtime.launch();
123
+ }
124
+
125
+ /**
126
+ * Materialize a browser probe callback backed by a real headless browser.
127
+ * Returns an unavailable descriptor instead of throwing when no runtime
128
+ * exists, so callers can record an accurate capability skip.
129
+ */
130
+ export async function createBrowserProbeSession(options = {}) {
131
+ const runtime = await resolveHeadlessRuntime(options);
132
+ if (!runtime.available) {
133
+ return { available: false, reason: runtime.reason };
134
+ }
135
+
136
+ const browser = await runtime.launch();
137
+ const pages = new Map();
138
+ const server = createServer((request, response) => {
139
+ const html = pages.get(request.url);
140
+ if (html === undefined) {
141
+ response.writeHead(404).end('not found');
142
+ return;
143
+ }
144
+ response.writeHead(200, {
145
+ 'content-type': 'text/html; charset=utf-8',
146
+ 'cache-control': 'no-store',
147
+ });
148
+ response.end(html);
149
+ });
150
+
151
+ let address;
152
+ try {
153
+ address = await listen(server);
154
+ } catch (cause) {
155
+ await Promise.allSettled([browser.close(), closeServer(server)]);
156
+ throw cause;
157
+ }
158
+
159
+ return {
160
+ available: true,
161
+ runtime: { name: runtime.name, version: browser.version() },
162
+ probe: async (probeRequest) => {
163
+ const route = `/${randomBytes(12).toString('hex')}.html`;
164
+ pages.set(route, probeRequest.artifact.html);
165
+ try {
166
+ return await probeRenderedPage(
167
+ browser,
168
+ `http://127.0.0.1:${address.port}${route}`,
169
+ probeRequest,
170
+ );
171
+ } finally {
172
+ pages.delete(route);
173
+ }
174
+ },
175
+ close: async () => {
176
+ await Promise.allSettled([browser.close(), closeServer(server)]);
177
+ },
178
+ };
179
+ }
180
+
181
+ export async function probeRenderedPage(browser, url, request) {
182
+ assertProbeRequestFields(request);
183
+ const page = await browser.newPage({
184
+ viewport: request.viewport,
185
+ reducedMotion: request.reducedMotion ?? 'reduce',
186
+ javaScriptEnabled: request.javascriptEnabled !== false,
187
+ });
188
+ try {
189
+ if (request.media === 'print') {
190
+ await page.emulateMedia({
191
+ media: 'print',
192
+ reducedMotion: request.reducedMotion ?? 'reduce',
193
+ });
194
+ }
195
+ const stylesheet = probeStylesheet(request);
196
+ if (stylesheet) {
197
+ await page.route(url, async (route) => {
198
+ const response = await route.fetch();
199
+ const html = await response.text();
200
+ await route.fulfill({
201
+ response,
202
+ body: injectStylesheet(html, stylesheet),
203
+ });
204
+ });
205
+ }
206
+ await page.goto(url, { waitUntil: 'load' });
207
+ if (request.wideContent) {
208
+ await page.evaluate(({ containerSelector, width }) => {
209
+ const container = document.querySelector(containerSelector);
210
+ if (!container) return;
211
+ const probe = document.createElement('div');
212
+ probe.dataset.releaseWideContent = 'true';
213
+ probe.style.width = `${width}px`;
214
+ if (matchMedia('print').matches) probe.style.maxWidth = '100%';
215
+ probe.style.height = '1px';
216
+ container.append(probe);
217
+ }, request.wideContent);
218
+ }
219
+
220
+ const layout = await page.evaluate(request.evaluate);
221
+ const keyboard = await probeKeyboard(page, request);
222
+ const themeToggle = request.themeToggle
223
+ ? await probeThemeToggle(page, request.themeToggle)
224
+ : layout.themeToggle;
225
+ return {
226
+ ...layout,
227
+ keyboard,
228
+ ...(themeToggle && { themeToggle }),
229
+ };
230
+ } finally {
231
+ await page.close();
232
+ }
233
+ }
234
+
235
+ function assertProbeRequestFields(request) {
236
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
237
+ throw new TypeError('Browser probe request must be an object.');
238
+ }
239
+ const unsupported = Object.keys(request).filter(
240
+ (field) => !PROBE_REQUEST_FIELDS.has(field),
241
+ );
242
+ if (unsupported.length > 0) {
243
+ throw new TypeError(
244
+ `Browser probe request has unsupported fields: ${unsupported.join(', ')}.`,
245
+ );
246
+ }
247
+ if (
248
+ request.disableAnimations !== undefined &&
249
+ typeof request.disableAnimations !== 'boolean'
250
+ ) {
251
+ throw new TypeError('Browser probe disableAnimations must be a boolean.');
252
+ }
253
+ if (
254
+ request.injectedCss !== undefined &&
255
+ typeof request.injectedCss !== 'string'
256
+ ) {
257
+ throw new TypeError('Browser probe injectedCss must be a string.');
258
+ }
259
+ }
260
+
261
+ function probeStylesheet(request) {
262
+ return [
263
+ request.injectedCss?.trim(),
264
+ request.disableAnimations ? DISABLE_ANIMATIONS_CSS : undefined,
265
+ ]
266
+ .filter(Boolean)
267
+ .join('\n');
268
+ }
269
+
270
+ function injectStylesheet(html, stylesheet) {
271
+ const style = `<style data-explainer-probe-controls>${stylesheet}</style>`;
272
+ return /<\/head\s*>/i.test(html)
273
+ ? html.replace(/<\/head\s*>/i, `${style}</head>`)
274
+ : `${style}${html}`;
275
+ }
276
+
277
+ export async function primeKeyboardFocus(page) {
278
+ await page.bringToFront();
279
+ await page.mouse.click(1, 1);
280
+ await page.evaluate(() => {
281
+ window.focus();
282
+ const body = document.body;
283
+ const originalTabIndex = body.getAttribute('tabindex');
284
+ try {
285
+ body.setAttribute('tabindex', '-1');
286
+ body.focus({ preventScroll: true });
287
+ } finally {
288
+ if (originalTabIndex === null) {
289
+ body.removeAttribute('tabindex');
290
+ } else {
291
+ body.setAttribute('tabindex', originalTabIndex);
292
+ }
293
+ }
294
+ });
295
+ }
296
+
297
+ export async function pressTabFromDocument(
298
+ page,
299
+ { pressTab = () => page.keyboard.press('Tab') } = {},
300
+ ) {
301
+ for (let attempt = 0; attempt < 2; attempt += 1) {
302
+ await primeKeyboardFocus(page);
303
+ await pressTab();
304
+ const advanced = await page.evaluate(() => {
305
+ const focused =
306
+ document.activeElement !== document.body &&
307
+ document.activeElement !== document.documentElement;
308
+ const visibleFocusable = [
309
+ ...document.querySelectorAll(
310
+ 'a[href], button, input, select, textarea, [tabindex]',
311
+ ),
312
+ ].some((element) => {
313
+ const style = getComputedStyle(element);
314
+ return (
315
+ style.display !== 'none' &&
316
+ style.visibility !== 'hidden' &&
317
+ element.getClientRects().length > 0
318
+ );
319
+ });
320
+ return focused || !visibleFocusable;
321
+ });
322
+ if (advanced) return true;
323
+ await page.waitForTimeout(25);
324
+ }
325
+ return page.evaluate(() => {
326
+ const visibleControls = [
327
+ ...document.querySelectorAll(
328
+ 'a[href], button, input, select, textarea, [tabindex]',
329
+ ),
330
+ ].filter((element) => {
331
+ if (!(element instanceof HTMLElement) || element.matches(':disabled')) {
332
+ return false;
333
+ }
334
+ const style = getComputedStyle(element);
335
+ return style.display !== 'none' && style.visibility !== 'hidden';
336
+ });
337
+ return (
338
+ visibleControls.length === 0 ||
339
+ visibleControls.some((element) => element.tabIndex >= 0)
340
+ );
341
+ });
342
+ }
343
+
344
+ export async function probeArrowKey(page, key) {
345
+ const positive = key === 'ArrowRight' || key === 'ArrowDown';
346
+ const makeRoomKey = positive ? 'ArrowLeft' : 'ArrowRight';
347
+ await page.bringToFront();
348
+ let before = await deckPosition(page);
349
+ for (let attempt = 0; attempt < 5; attempt += 1) {
350
+ const hasRoom = positive
351
+ ? before.current < before.total
352
+ : before.current > 1;
353
+ if (hasRoom) break;
354
+ await page.keyboard.press(makeRoomKey);
355
+ before = await deckPosition(page);
356
+ if (positive ? before.current < before.total : before.current > 1) {
357
+ break;
358
+ }
359
+ await page.waitForTimeout(25);
360
+ before = await deckPosition(page);
361
+ }
362
+ if (positive ? before.current >= before.total : before.current <= 1) {
363
+ return false;
364
+ }
365
+ for (let attempt = 0; attempt < 3; attempt += 1) {
366
+ await page.keyboard.press(key);
367
+ let after = await deckPosition(page);
368
+ if (
369
+ positive ? after.current > before.current : after.current < before.current
370
+ ) {
371
+ return true;
372
+ }
373
+ await page.waitForTimeout(25);
374
+ after = await deckPosition(page);
375
+ if (
376
+ positive ? after.current > before.current : after.current < before.current
377
+ ) {
378
+ return true;
379
+ }
380
+ }
381
+ return false;
382
+ }
383
+
384
+ async function probeKeyboard(page, request) {
385
+ const tab = await pressTabFromDocument(page);
386
+ if (!request.keyboard?.arrows) return { tab };
387
+
388
+ const arrows = {};
389
+ for (const key of request.keyboard.arrows) {
390
+ arrows[key] = await probeArrowKey(page, key);
391
+ }
392
+ return { tab, arrows };
393
+ }
394
+
395
+ async function probeThemeToggle(page, themeToggle) {
396
+ const locator = page.locator(themeToggle.selector);
397
+ const present = (await locator.count()) === 1;
398
+ if (!present) return { present: false };
399
+ const initialMode = await page.evaluate(
400
+ () => document.documentElement.dataset.themeMode,
401
+ );
402
+ await locator.focus();
403
+ await page.keyboard.press('Enter');
404
+ const toggledMode = await page.evaluate(
405
+ () => document.documentElement.dataset.themeMode,
406
+ );
407
+ await page.reload({ waitUntil: 'load' });
408
+ const persistedMode = await page.evaluate(
409
+ () => document.documentElement.dataset.themeMode,
410
+ );
411
+ return {
412
+ present: true,
413
+ initialMode,
414
+ toggledMode,
415
+ keyboardOperable: toggledMode !== initialMode,
416
+ persisted: persistedMode === toggledMode,
417
+ };
418
+ }
419
+
420
+ async function deckPosition(page) {
421
+ return page.evaluate(() => {
422
+ const text = document.querySelector('#deck-counter')?.textContent ?? '';
423
+ const [current, total] = text
424
+ .split('/')
425
+ .map((value) => Number.parseInt(value, 10));
426
+ return { current, total };
427
+ });
428
+ }
429
+
430
+ function listen(server) {
431
+ return new Promise((resolveListen, reject) => {
432
+ server.once('error', reject);
433
+ server.listen(0, '127.0.0.1', () => {
434
+ server.off('error', reject);
435
+ resolveListen(server.address());
436
+ });
437
+ });
438
+ }
439
+
440
+ function closeServer(server) {
441
+ return new Promise((resolveClose) => server.close(resolveClose));
442
+ }