kaloko 0.4.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +64 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +70 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +130 -17
  6. package/skills/kaloko/SKILL.md +18 -5
  7. package/skills/kaloko/reference/acceptance.md +70 -1
  8. package/skills/kaloko/reference/apps.md +103 -8
  9. package/skills/kaloko/reference/ci.md +3 -1
  10. package/skills/kaloko/reference/design.md +39 -0
  11. package/skills/kaloko/reference/devices.md +109 -0
  12. package/skills/kaloko/reference/environments.md +6 -0
  13. package/skills/kaloko/reference/execution.md +6 -0
  14. package/skills/kaloko/reference/mcp.md +1 -0
  15. package/skills/kaloko/reference/recording.md +95 -0
  16. package/src/cli.mjs +381 -167
  17. package/src/lib/access.mjs +5 -2
  18. package/src/lib/anim.mjs +125 -0
  19. package/src/lib/apps/devices.mjs +187 -0
  20. package/src/lib/apps/drivers.mjs +11 -3
  21. package/src/lib/apps/ios-device.mjs +78 -0
  22. package/src/lib/apps/recording-native.mjs +212 -0
  23. package/src/lib/apps/tree.mjs +9 -1
  24. package/src/lib/apps/webview.mjs +54 -0
  25. package/src/lib/apps/windows.mjs +108 -0
  26. package/src/lib/apps/windows.ps1 +78 -0
  27. package/src/lib/assertions.mjs +300 -0
  28. package/src/lib/build.mjs +63 -4
  29. package/src/lib/capture.mjs +146 -119
  30. package/src/lib/ci.mjs +8 -4
  31. package/src/lib/design-cli.mjs +35 -6
  32. package/src/lib/design.mjs +3 -1
  33. package/src/lib/devices.mjs +85 -0
  34. package/src/lib/devspec.mjs +3 -2
  35. package/src/lib/dimensions.mjs +326 -0
  36. package/src/lib/doctor.mjs +35 -6
  37. package/src/lib/drift.mjs +8 -4
  38. package/src/lib/evaluate.mjs +25 -7
  39. package/src/lib/import.mjs +3 -2
  40. package/src/lib/jev.mjs +3 -1
  41. package/src/lib/manifest-core.mjs +12 -3
  42. package/src/lib/probe.mjs +60 -0
  43. package/src/lib/recheck.mjs +6 -1
  44. package/src/lib/recording.mjs +373 -0
  45. package/src/lib/scenario.mjs +3 -0
  46. package/src/lib/stability-core.mjs +4 -2
  47. package/src/lib/trace.mjs +109 -0
  48. package/src/lib/video-cli.mjs +209 -0
  49. package/src/lib/video.mjs +427 -0
  50. package/src/lib/walk-dimensions.mjs +83 -0
  51. package/src/lib/webm.mjs +144 -0
  52. package/viewer/index.html +467 -25
@@ -22,13 +22,14 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
22
22
  g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
23
23
  g.fillStyle = '#151514'; g.font = `700 ${14 * scale}px system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(String(m.n), x, y + scale)
24
24
  }
25
- const blob = (c, type, q) => new Promise((res, rej) => c.toBlob((b) => (b ? b.arrayBuffer().then((buf) => res(Array.from(new Uint8Array(buf)))) : rej(new Error('encode failed'))), type, q))
25
+ // the blob's own type: WebKit cannot write WebP and silently returns PNG
26
+ const blob = (c, type, q) => new Promise((res, rej) => c.toBlob((b) => (b ? b.arrayBuffer().then((buf) => res({ bytes: Array.from(new Uint8Array(buf)), type: b.type })) : rej(new Error('encode failed'))), type, q))
26
27
  const k = Math.min(1, thumbWidth / img.width)
27
28
  const t = document.createElement('canvas'); t.width = Math.round(img.width * k); t.height = Math.round(img.height * k)
28
29
  t.getContext('2d').drawImage(full, 0, 0, t.width, t.height)
29
30
  return { image: await blob(full, format === 'png' ? 'image/png' : 'image/webp', quality), thumb: await blob(t, 'image/webp', thumbQuality) }
30
31
  }, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks })
31
- return { image: Buffer.from(result.image), thumb: Buffer.from(result.thumb) }
32
+ return { image: Buffer.from(result.image.bytes), thumb: Buffer.from(result.thumb.bytes), imageType: result.image.type, thumbType: result.thumb.type }
32
33
  }
33
34
 
34
35
  /** WebP encoding for the canvas (kept for callers of the old name). */
@@ -39,9 +40,9 @@ export const toWebp = (page, png, opts = {}) => encodeImage(page, png, { ...opts
39
40
  * { format: png, scale: 2, mask: [selectors], annotate: true }; daily acceptance keeps the WebP default.
40
41
  */
41
42
  /** The page spec (src/lib/devspec.mjs), kept under ~250 kB so it fits a design revision's capture record. */
42
- export async function stepSpec (page, tokens = null) {
43
+ export async function stepSpec (page, tokens = null, mode = null) {
43
44
  try {
44
- const spec = await page.evaluate(pageSpecScript(tokens))
45
+ const spec = await page.evaluate(pageSpecScript(tokens, 300, mode))
45
46
  while (spec.elements.length > 20 && JSON.stringify(spec).length > 250000) spec.elements = spec.elements.slice(0, Math.floor(spec.elements.length * 0.8))
46
47
  return spec
47
48
  } catch { return null }
@@ -68,6 +69,8 @@ import { readFields } from './stack.mjs'
68
69
  import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
69
70
  import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
70
71
  import { repeatRegions, resolveIgnore } from './stability.mjs'
72
+ import { applyVariant, engineOf, markEngineGaps, variantFields } from './dimensions.mjs'
73
+ import { liveValueChecks } from './assertions.mjs'
71
74
 
72
75
  /** Probe arguments derived from the node's criteria. */
73
76
  export function probeArgs (node, maxChars, design = false) {
@@ -118,127 +121,151 @@ export async function captureStep ({ page, node, locale, viewports, run, console
118
121
  const original = page.viewportSize()
119
122
  const results = []
120
123
  let netResults = null
124
+ // batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
125
+ const engine = engineOf(page)
126
+ const scope = run.meta?.scope
121
127
  for (const [viewport, [width, height]] of Object.entries(viewports)) {
122
128
  const now = page.viewportSize()
123
129
  if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
124
- await settle(page)
125
- const html = paths.step(stepId, locale, viewport, 'html')
126
- await rm(paths.step(stepId, locale, viewport, 'eval.json'), { force: true })
127
- await mkdir(dirname(html), { recursive: true })
128
- // PII selectors are covered by a solid box; at 2× Chrome renders the full page at double scale (CDP), and
129
- // the boxes are drawn while encoding because Playwright's own mask only works at the page's pixel ratio
130
- const maskRects = opts.mask.length ? await page.evaluate((sels) => sels.flatMap((sel) => { try { return [...document.querySelectorAll(sel)] } catch { return [] } }).map((el) => { const r = el.getBoundingClientRect(); return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height } }).filter((r) => r.w > 0 && r.h > 0), opts.mask) : []
131
- let shot
132
- if (opts.scale === 2) {
133
- const cdp = await page.context().newCDPSession(page)
134
- try {
135
- const doc = await page.evaluate(() => ({ w: Math.ceil(document.documentElement.scrollWidth), h: Math.ceil(document.documentElement.scrollHeight) }))
136
- const r = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.fullPage, clip: { x: 0, y: 0, width: opts.fullPage ? doc.w : width, height: opts.fullPage ? doc.h : height, scale: 2 } })
137
- shot = Buffer.from(r.data, 'base64')
138
- } finally { await cdp.detach().catch(() => {}) }
139
- } else shot = await page.screenshot({ fullPage: opts.fullPage, ...(opts.mask.length ? { mask: opts.mask.map((sel) => page.locator(sel)), maskColor: '#1f1f1d' } : {}) })
140
- // secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
141
- await writeFile(html, redact(await page.content()))
142
- let result
143
- for (let attempt = 0; ; attempt++) {
144
- try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
145
- // the page navigated under us (redirect, Turbo); wait once and probe the new document
146
- if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
147
- throw error
148
- }
149
- }
150
- // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
151
- if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens)
152
- // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
153
- const ignore = await resolveIgnore(page, node, locale, viewport)
154
- // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
155
- const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
156
- // numbered marks on the highlighted elements, in criteria order (first box of each)
157
- const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
158
- let image = paths.step(stepId, locale, viewport, opts.format)
159
- const thumb = paths.step(stepId, locale, viewport, 'thumb.webp')
160
- let thumbWritten = true
130
+ const undoVariant = await applyVariant(page, scope?.variants?.[viewport], scope?.devices?.[scope?.variants?.[viewport]?.device] || null)
161
131
  try {
162
- const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: opts.scale, masks: opts.scale === 2 ? maskRects : [] })
163
- await writeFile(image, enc.image)
164
- await writeFile(thumb, enc.thumb)
165
- } catch (error) {
166
- // never store an unmasked image when masks were asked for
167
- if (opts.scale === 2 && maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
168
- image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
169
- await writeFile(image, shot)
170
- }
171
- const url = page.url()
172
- const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
173
- const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
174
- // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
175
- // an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
176
- const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
177
- const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
178
- const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
179
- // network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
180
- const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
181
- if (net.length) {
182
- netResults ||= await networkChecks(page, net, url, { run, locale })
183
- Object.assign(result.checks, netResults)
184
- }
185
- for (const c of node.criteria || []) {
186
- if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
187
- result.checks[c.id] = collected === null
188
- ? { ok: null, evidence: 'Console was not observed.' }
189
- : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
132
+ await settle(page)
133
+ const html = paths.step(stepId, locale, viewport, 'html')
134
+ await rm(paths.step(stepId, locale, viewport, 'eval.json'), { force: true })
135
+ await mkdir(dirname(html), { recursive: true })
136
+ // PII selectors are covered by a solid box; at 2× Chrome renders the full page at double scale (CDP), and
137
+ // the boxes are drawn while encoding because Playwright's own mask only works at the page's pixel ratio
138
+ const maskRects = opts.mask.length ? await page.evaluate((sels) => sels.flatMap((sel) => { try { return [...document.querySelectorAll(sel)] } catch { return [] } }).map((el) => { const r = el.getBoundingClientRect(); return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height } }).filter((r) => r.w > 0 && r.h > 0), opts.mask) : []
139
+ let shot
140
+ // a device preset's pixel ratio (3 on an iPhone) shapes the layout, not the image: captures stay in CSS pixels
141
+ // (or 2× for documentation), so the canvas, boxes and diffs work the same for every device
142
+ const dpr = await page.evaluate(() => devicePixelRatio).catch(() => 1)
143
+ let shotScale = opts.scale
144
+ if (opts.scale === 2 && engine === 'chromium') {
145
+ const cdp = await page.context().newCDPSession(page)
146
+ try {
147
+ const doc = await page.evaluate(() => ({ w: Math.ceil(document.documentElement.scrollWidth), h: Math.ceil(document.documentElement.scrollHeight) }))
148
+ const r = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.fullPage, clip: { x: 0, y: 0, width: opts.fullPage ? doc.w : width, height: opts.fullPage ? doc.h : height, scale: 2 / (dpr || 1) } })
149
+ shot = Buffer.from(r.data, 'base64')
150
+ } finally { await cdp.detach().catch(() => {}) }
151
+ } else {
152
+ // WebKit and Firefox have no 2× capture of their own: 2× when the context renders at 2× or more, else 1×
153
+ // (exactly 2×: a 3× or 2.625× device would give an image the masks and marks are not drawn for)
154
+ if (opts.scale === 2 && dpr !== 2) shotScale = 1
155
+ shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css', ...(opts.mask.length && shotScale === 1 ? { mask: opts.mask.map((sel) => page.locator(sel)), maskColor: '#1f1f1d' } : {}) })
190
156
  }
191
- }
192
- // keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
193
- // the console was read (focus handlers of the page must not count as its errors)
194
- let keyboard = null
195
- // stack steps walk the keyboard on their first few pages only (one template, hundreds of pages)
196
- const kbKey = `${run.dir}|${node.id}|${viewport}`
197
- const kbSampled = !item || (STACK_KEYBOARD.set(kbKey, (STACK_KEYBOARD.get(kbKey) || 0) + 1).get(kbKey) <= STACK_KEYBOARD_PAGES)
198
- if (needsKeyboard(node) && !kbSampled) {
199
- for (const c of node.criteria) if (KEYBOARD_ASSERTS.has(c.check.assert)) result.checks[c.id] = { ok: null, evidence: `keyboard walk runs on the first ${STACK_KEYBOARD_PAGES} pages of a stack step` }
200
- }
201
- if (needsKeyboard(node) && kbSampled) {
202
- const kb = await keyboardChecks(page, node.criteria, {
203
- saveShot: async (n, png) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
204
- })
205
- Object.assign(result.checks, kb.checks)
206
- result.boxes = { ...result.boxes, ...kb.boxes }
207
- keyboard = kb.keyboard
208
- for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}`, file: s.file, at: new Date().toISOString() })
209
- }
210
- // experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
211
- let view = null
212
- if (htmlView) {
157
+ // secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
158
+ await writeFile(html, redact(await page.content()))
159
+ let result
160
+ for (let attempt = 0; ; attempt++) {
161
+ try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
162
+ // the page navigated under us (redirect, Turbo); wait once and probe the new document
163
+ if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
164
+ throw error
165
+ }
166
+ }
167
+ // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
168
+ if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.color_scheme || null)
169
+ // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
170
+ const ignore = await resolveIgnore(page, node, locale, viewport)
171
+ // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
172
+ const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
173
+ // numbered marks on the highlighted elements, in criteria order (first box of each)
174
+ const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
175
+ let image = paths.step(stepId, locale, viewport, opts.format)
176
+ let thumb = paths.step(stepId, locale, viewport, 'thumb.webp')
177
+ let thumbWritten = true
213
178
  try {
214
- // the same PII masks as the screenshot (#27), applied to the text of the copy
215
- const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
216
- await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
217
- view = { revision: rev.id, entry: rev.entry }
218
- } catch (error) { view = { error: String(error.message).slice(0, 200) } }
219
- }
220
- const record = {
221
- schema: 'qawalk.capture.v1',
222
- ...(view ? { view } : {}),
223
- node: node.id, locale, viewport, url,
224
- captured_at: new Date().toISOString(),
225
- criteria_hash: criteriaHash(node),
226
- console_errors: errors, blocked_requests: blocked,
227
- ...result,
228
- ...extra,
229
- http_status: extra.http_status ?? result.http_status ?? null,
230
- capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
231
- ...(ignore.length ? { ignore } : {}),
232
- ...(unstable ? { unstable } : {}),
233
- ...(keyboard ? { keyboard } : {}),
234
- ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
235
- ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
236
- files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
237
- }
238
- const file = paths.step(stepId, locale, viewport, 'capture.json')
239
- await writeJson(file, redactDeep(record))
240
- await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
241
- results.push(record)
179
+ const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: shotScale, masks: shotScale === 2 ? maskRects : [] })
180
+ // the extension follows the bytes (WebKit encodes PNG), so the service accepts the file
181
+ if (enc.imageType === 'image/png') image = paths.step(stepId, locale, viewport, 'png')
182
+ if (enc.thumbType === 'image/png') thumb = paths.step(stepId, locale, viewport, 'thumb.png')
183
+ await writeFile(image, enc.image)
184
+ await writeFile(thumb, enc.thumb)
185
+ } catch (error) {
186
+ // never store an unmasked image when masks were asked for
187
+ if (shotScale === 2 && maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
188
+ image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
189
+ await writeFile(image, shot)
190
+ }
191
+ const url = page.url()
192
+ // URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the keyboard walk moves focus
193
+ await liveValueChecks({ page, node, result, url, tokens })
194
+ const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
195
+ const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
196
+ // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
197
+ // an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
198
+ const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
199
+ const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
200
+ const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
201
+ // network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
202
+ const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
203
+ if (net.length) {
204
+ netResults ||= await networkChecks(page, net, url, { run, locale })
205
+ Object.assign(result.checks, netResults)
206
+ }
207
+ for (const c of node.criteria || []) {
208
+ if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
209
+ result.checks[c.id] = collected === null
210
+ ? { ok: null, evidence: 'Console was not observed.' }
211
+ : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
212
+ }
213
+ }
214
+ // keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
215
+ // the console was read (focus handlers of the page must not count as its errors)
216
+ let keyboard = null
217
+ // checks another engine cannot evaluate like Chromium (accessibility tree over CDP, layout shifts, WebKit's Tab)
218
+ // are "not evaluated" with the reason; the keyboard walk takes the rest
219
+ const kbNode = engine === 'chromium' ? node : { ...node, criteria: markEngineGaps(engine, node.criteria, result.checks) }
220
+ // stack steps walk the keyboard on their first few pages only (one template, hundreds of pages)
221
+ const kbKey = `${run.dir}|${node.id}|${viewport}`
222
+ const kbSampled = !item || (STACK_KEYBOARD.set(kbKey, (STACK_KEYBOARD.get(kbKey) || 0) + 1).get(kbKey) <= STACK_KEYBOARD_PAGES)
223
+ if (needsKeyboard(kbNode) && !kbSampled) {
224
+ for (const c of kbNode.criteria) if (KEYBOARD_ASSERTS.has(c.check.assert)) result.checks[c.id] = { ok: null, evidence: `keyboard walk runs on the first ${STACK_KEYBOARD_PAGES} pages of a stack step` }
225
+ }
226
+ if (needsKeyboard(kbNode) && kbSampled) {
227
+ const kb = await keyboardChecks(page, kbNode.criteria, {
228
+ saveShot: async (n, png) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
229
+ })
230
+ Object.assign(result.checks, kb.checks)
231
+ result.boxes = { ...result.boxes, ...kb.boxes }
232
+ keyboard = kb.keyboard
233
+ for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}`, file: s.file, at: new Date().toISOString() })
234
+ }
235
+ // experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
236
+ let view = null
237
+ if (htmlView) {
238
+ try {
239
+ // the same PII masks as the screenshot (#27), applied to the text of the copy
240
+ const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
241
+ await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
242
+ view = { revision: rev.id, entry: rev.entry }
243
+ } catch (error) { view = { error: String(error.message).slice(0, 200) } }
244
+ }
245
+ const record = {
246
+ schema: 'qawalk.capture.v1',
247
+ ...(view ? { view } : {}),
248
+ node: node.id, locale, viewport, url,
249
+ ...variantFields(scope, viewport),
250
+ captured_at: new Date().toISOString(),
251
+ criteria_hash: criteriaHash(node),
252
+ console_errors: errors, blocked_requests: blocked,
253
+ ...result,
254
+ ...extra,
255
+ http_status: extra.http_status ?? result.http_status ?? null,
256
+ capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
257
+ ...(ignore.length ? { ignore } : {}),
258
+ ...(unstable ? { unstable } : {}),
259
+ ...(keyboard ? { keyboard } : {}),
260
+ ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
261
+ ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
262
+ files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
263
+ }
264
+ const file = paths.step(stepId, locale, viewport, 'capture.json')
265
+ await writeJson(file, redactDeep(record))
266
+ await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
267
+ results.push(record)
268
+ } finally { await undoVariant() }
242
269
  }
243
270
  const last = page.viewportSize()
244
271
  if (original && (!last || last.width !== original.width || last.height !== original.height)) await page.setViewportSize(original)
package/src/lib/ci.mjs CHANGED
@@ -113,7 +113,7 @@ function walkScript ({ scenarios, env, affected, k }) {
113
113
  }
114
114
 
115
115
  /** .github/workflows/kaloko.yml */
116
- export function githubWorkflow ({ scenarios, env = 'preview', trigger = 'deployment', affected = true, pm = 'npm', dependency = true, version = 'latest', node = 22 }) {
116
+ export function githubWorkflow ({ scenarios, env = 'preview', trigger = 'deployment', affected = true, pm = 'npm', dependency = true, version = 'latest', node = 22, browsers = ['chromium'] }) {
117
117
  const { install, kaloko: k } = runner({ pm, dependency, version })
118
118
  const indent = (lines, n) => lines.map((l) => ' '.repeat(n) + l).join('\n')
119
119
  const on = trigger === 'deployment'
@@ -153,7 +153,7 @@ ${guard} runs-on: ubuntu-latest
153
153
  - uses: actions/setup-node@v4
154
154
  with:
155
155
  node-version: ${node}
156
- ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''} - run: ${k} browser install --with-deps # the Chromium of kaloko's own Playwright
156
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''} - run: ${k} browser install --with-deps${engineFlag(browsers)} # the ${engineWords(browsers)} of kaloko's own Playwright
157
157
  - name: Preview deployment
158
158
  # the deployment's URL, the pull request and its base; waits for the preview when it is still building
159
159
  run: ${k} ci preview-url --wait 600 --github-env
@@ -163,8 +163,12 @@ ${indent(walkScript({ scenarios, env, affected, k }), 10)}
163
163
  `
164
164
  }
165
165
 
166
+ /** `--browsers webkit,…` when a scenario walks more than Chromium (cross-browser runs install their engines). */
167
+ const engineFlag = (browsers = ['chromium']) => (browsers.some((b) => b !== 'chromium') ? ` --browsers ${browsers.join(',')}` : '')
168
+ const engineWords = (browsers = ['chromium']) => (browsers.some((b) => b !== 'chromium') ? 'browser engines' : 'Chromium')
169
+
166
170
  /** kaloko.gitlab-ci.yml, included from .gitlab-ci.yml */
167
- export function gitlabJob ({ scenarios, env = 'preview', affected = true, pm = 'npm', dependency = true, version = 'latest', playwright = '1.55.0' }) {
171
+ export function gitlabJob ({ scenarios, env = 'preview', affected = true, pm = 'npm', dependency = true, version = 'latest', playwright = '1.55.0', browsers = ['chromium'] }) {
168
172
  const { install, kaloko: k } = runner({ pm, dependency, version })
169
173
  const indent = (lines, n) => lines.map((l) => ' '.repeat(n) + l).join('\n')
170
174
  return `# Kaloko: walk the scenarios a merge request affects on its review app and comment on the MR.
@@ -182,7 +186,7 @@ kaloko:
182
186
  KALOKO_AGENT: gitlab-ci
183
187
  # KALOKO_PREVIEW_URL: https://$CI_COMMIT_REF_SLUG.review.example.com # when the review app URL is known
184
188
  script:
185
- ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - corepack enable\n' : ''} - ${install}\n` : ''} - ${k} browser install
189
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - corepack enable\n' : ''} - ${install}\n` : ''} - ${k} browser install${engineFlag(browsers)}
186
190
  - export KALOKO_PREVIEW_URL="$(${k} ci preview-url --wait 600)"
187
191
  - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
188
192
  - |
@@ -11,6 +11,7 @@ import { loadTokens } from './drift.mjs'
11
11
  import { layout } from './scenario.mjs'
12
12
  import { launchEphemeral } from './browser.mjs'
13
13
  import { observeConsole } from './console.mjs'
14
+ import { contextOptionsOf } from './dimensions.mjs'
14
15
  import { slimCapture } from './manifest-core.mjs'
15
16
  import { captureKey, sha256Hex, extOf, TEXT_TYPES, RENDERER } from './design-core.mjs'
16
17
  import { designDir, revisionOf, serveDesign, copyLive, fromCache, toCache, restoreCached, readBase, writeBase, headKey, tokensRef } from './design.mjs'
@@ -50,8 +51,16 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
50
51
  const tokens = await loadTokens({ client: null, config, root, designDir: dir })
51
52
  let rendered = 0, reused = 0
52
53
  try {
53
- const page = await (await browser.newContext()).newPage()
54
- observeConsole(page)
54
+ // one page per device profile (batch E): a phone preset renders with touch, its pixel ratio and user agent, so
55
+ // (pointer: coarse) and (hover: none) apply to the design as on the phone
56
+ const pages = new Map()
57
+ const scope = run.meta.scope
58
+ const pageFor = async (vp) => {
59
+ const emulation = scope?.devices?.[scope?.variants?.[vp]?.device]?.emulation || null
60
+ const k = emulation?.key || ''
61
+ if (!pages.has(k)) { const p = await (await browser.newContext(contextOptionsOf({ engine: 'chromium', emulation }))).newPage(); observeConsole(p); pages.set(k, p) }
62
+ return pages.get(k)
63
+ }
55
64
  for (const [key, rev] of Object.entries(fresh)) {
56
65
  const node = scenario.nodes.find((n) => n.id === rev.node)
57
66
  revisions[key] = rev
@@ -70,10 +79,21 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
70
79
  reused++
71
80
  } else todo[vp] = size
72
81
  }
73
- if (Object.keys(todo).length) {
82
+ // dark captures render <step>/dark/index.html when the step has one; otherwise the page itself in the dark
83
+ // scheme (prefers-color-scheme emulated): one page load per entry
84
+ const darkEntry = rev.entry.replace(/index\.html$/, 'dark/index.html')
85
+ const groups = new Map()
86
+ for (const [vp, size] of Object.entries(todo)) {
87
+ const entry = run.meta.scope?.variants?.[vp]?.color_scheme === 'dark' && rev.files[darkEntry] ? darkEntry : rev.entry
88
+ const page = await pageFor(vp)
89
+ const g = `${entry}|${[...pages.values()].indexOf(page)}`
90
+ if (!groups.has(g)) groups.set(g, { entry, page, vps: {} })
91
+ groups.get(g).vps[vp] = size
92
+ }
93
+ for (const { entry, page, vps } of groups.values()) {
74
94
  const since = new Date().toISOString()
75
- await page.goto(`${server.url}/${rev.entry}`, { waitUntil: 'load' })
76
- const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true, tokens })
95
+ await page.goto(`${server.url}/${entry}`, { waitUntil: 'load' })
96
+ const results = await captureStep({ page, node, locale: rev.locale, viewports: vps, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live: entry === rev.entry ? live : { ...live, entry } }, designSummary: true, tokens })
77
97
  for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
78
98
  }
79
99
  say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
@@ -88,6 +108,15 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
88
108
  return { rendered, reused, revisions }
89
109
  }
90
110
 
111
+ /**
112
+ * The scope the draft keeps: locales and capture keys, and (batch E) what the keys mean and the device presets, so
113
+ * versions on the service get the scheme switcher and device frames. User agents stay local.
114
+ */
115
+ export function syncScope (scope, locales) {
116
+ const devices = scope.devices ? Object.fromEntries(Object.entries(scope.devices).map(([k, { emulation: _e, ...d }]) => [k, d])) : null
117
+ return { locales, viewports: scope.viewports, ...(scope.variants ? { variants: scope.variants } : {}), ...(scope.dimensions ? { dimensions: scope.dimensions } : {}), ...(devices ? { devices } : {}) }
118
+ }
119
+
91
120
  /** Scenario snapshot the service keeps with the draft (so it can compose versions without the CLI). */
92
121
  export function snapshotOf (scenario, run) {
93
122
  const pos = Object.fromEntries(layout(scenario).map((p) => [p.id, { col: p.col, lane: p.lane }]))
@@ -246,7 +275,7 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
246
275
  await uploadBlobs({ client, org, files, say })
247
276
  const heads = toPush.map((key) => ({ node: local[key].node, locale: local[key].locale, state: local[key].state, revision: local[key].id, base: remoteHeads[key] || null }))
248
277
  try {
249
- result = await client.request('POST', `/orgs/${org}/scenarios/${sid}/draft`, { branch, snapshot, scope: { locales, viewports: run.meta.scope.viewports }, environment: env.name, git_branch: run.meta.git?.branch || null, revisions, heads })
278
+ result = await client.request('POST', `/orgs/${org}/scenarios/${sid}/draft`, { branch, snapshot, scope: syncScope(run.meta.scope, locales), environment: env.name, git_branch: run.meta.git?.branch || null, revisions, heads })
250
279
  } catch (e) {
251
280
  if (/HTTP 409/.test(e.message)) { say(`${e.message}\nSomeone changed these steps a moment ago; sync again to see them.`); process.exitCode = 1; return { conflicts: true } }
252
281
  throw e
@@ -49,7 +49,9 @@ export async function scanStep ({ dir, node, locale, locales }) {
49
49
  // the default page or another locale's); everything else comes in through references
50
50
  const entryDir = entry.slice(0, entry.lastIndexOf('/') + 1)
51
51
  const beside = (entryDir === `${node}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
52
- const roots = [entry, ...beside.map((p) => entryDir + p)]
52
+ // a page of its own for the dark colour scheme (<step>/dark/index.html, or per locale): part of the revision
53
+ const dark = own.includes(`${entryDir.slice(node.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
54
+ const roots = [entry, ...dark, ...beside.map((p) => entryDir + p)]
53
55
  const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
54
56
  const files = []
55
57
  for (const path of paths) {
@@ -0,0 +1,85 @@
1
+ // Device presets (batch E): named devices a scenario's `viewports:` may use instead of a bare size, with the pixel
2
+ // ratio, user agent, touch, safe areas and the frame the canvas and Kaloko Design draw around the capture.
3
+ // screen the whole display in CSS pixels, portrait for phones and tablets
4
+ // ui [top, bottom] browser UI and status bar a web page does not get (Safari's address bar, Chrome's toolbar);
5
+ // web walks use screen minus ui, Design and installed PWAs (display mode standalone) use the whole screen
6
+ // safe safe-area insets of the screen (notch, Dynamic Island, home indicator, punch-hole camera)
7
+ // Values are the common ones of each device class, not a lab measurement: good enough to catch layout bugs, not a
8
+ // replacement for a real device. `<preset>-landscape` turns any phone, foldable or tablet preset sideways.
9
+
10
+ const IOS_UA = (os = '18_0', v = '18.0', kind = 'iPhone') => `Mozilla/5.0 (${kind}; CPU ${kind === 'iPad' ? 'OS' : 'iPhone OS'} ${os} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${v} Mobile/15E148 Safari/604.1`
11
+ const ANDROID_UA = (model, mobile = true) => `Mozilla/5.0 (Linux; Android 14; ${model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 ${mobile ? 'Mobile ' : ''}Safari/537.36`
12
+
13
+ export const DEVICES = {
14
+ 'iphone-se': { title: 'iPhone SE', kind: 'phone', os: 'ios', screen: [375, 667], ui: [20, 94], dpr: 2, mobile: true, touch: true, ua: IOS_UA(), safe: { top: 20, bottom: 0 }, frame: 'home-button' },
15
+ 'iphone-15': { title: 'iPhone 15', kind: 'phone', os: 'ios', screen: [393, 852], ui: [59, 134], dpr: 3, mobile: true, touch: true, ua: IOS_UA(), safe: { top: 59, bottom: 34 }, frame: 'island' },
16
+ 'iphone-16': { title: 'iPhone 16', kind: 'phone', os: 'ios', screen: [393, 852], ui: [59, 134], dpr: 3, mobile: true, touch: true, ua: IOS_UA(), safe: { top: 59, bottom: 34 }, frame: 'island' },
17
+ 'iphone-16-pro-max': { title: 'iPhone 16 Pro Max', kind: 'phone', os: 'ios', screen: [440, 956], ui: [62, 134], dpr: 3, mobile: true, touch: true, ua: IOS_UA(), safe: { top: 62, bottom: 34 }, frame: 'island' },
18
+ 'pixel-8': { title: 'Pixel 8', kind: 'phone', os: 'android', screen: [412, 915], ui: [52, 24], dpr: 2.625, mobile: true, touch: true, ua: ANDROID_UA('Pixel 8'), safe: { top: 24, bottom: 24 }, frame: 'punch-hole' },
19
+ 'pixel-8-pro': { title: 'Pixel 8 Pro', kind: 'phone', os: 'android', screen: [448, 998], ui: [52, 24], dpr: 3, mobile: true, touch: true, ua: ANDROID_UA('Pixel 8 Pro'), safe: { top: 24, bottom: 24 }, frame: 'punch-hole' },
20
+ 'galaxy-s24': { title: 'Galaxy S24', kind: 'phone', os: 'android', screen: [360, 780], ui: [52, 24], dpr: 3, mobile: true, touch: true, ua: ANDROID_UA('SM-S921B'), safe: { top: 24, bottom: 24 }, frame: 'punch-hole' },
21
+ 'fold-folded': { title: 'Galaxy Z Fold (folded)', kind: 'foldable', os: 'android', screen: [344, 882], ui: [52, 24], dpr: 2.625, mobile: true, touch: true, ua: ANDROID_UA('SM-F946B'), safe: { top: 24, bottom: 24 }, frame: 'punch-hole' },
22
+ 'fold-open': { title: 'Galaxy Z Fold (open)', kind: 'foldable', os: 'android', screen: [690, 829], ui: [52, 24], dpr: 2.625, mobile: true, touch: true, ua: ANDROID_UA('SM-F946B'), safe: { top: 24, bottom: 24 }, frame: 'fold' },
23
+ 'ipad-portrait': { title: 'iPad', kind: 'tablet', os: 'ios', screen: [820, 1180], ui: [74, 0], dpr: 2, mobile: true, touch: true, ua: IOS_UA('18_0', '18.0', 'iPad'), safe: { top: 24, bottom: 20 }, frame: 'tablet' },
24
+ 'ipad-landscape': { title: 'iPad (landscape)', kind: 'tablet', os: 'ios', screen: [1180, 820], ui: [74, 0], dpr: 2, mobile: true, touch: true, ua: IOS_UA('18_0', '18.0', 'iPad'), safe: { top: 24, bottom: 20 }, frame: 'tablet' },
25
+ 'ipad-pro-portrait': { title: 'iPad Pro 12.9″', kind: 'tablet', os: 'ios', screen: [1024, 1366], ui: [74, 0], dpr: 2, mobile: true, touch: true, ua: IOS_UA('18_0', '18.0', 'iPad'), safe: { top: 24, bottom: 20 }, frame: 'tablet' },
26
+ 'ipad-pro-landscape': { title: 'iPad Pro 12.9″ (landscape)', kind: 'tablet', os: 'ios', screen: [1366, 1024], ui: [74, 0], dpr: 2, mobile: true, touch: true, ua: IOS_UA('18_0', '18.0', 'iPad'), safe: { top: 24, bottom: 20 }, frame: 'tablet' },
27
+ 'android-tablet-portrait': { title: 'Android tablet', kind: 'tablet', os: 'android', screen: [800, 1280], ui: [80, 48], dpr: 2, mobile: true, touch: true, ua: ANDROID_UA('SM-X710', false), safe: { top: 24, bottom: 48 }, frame: 'tablet' },
28
+ 'android-tablet-landscape': { title: 'Android tablet (landscape)', kind: 'tablet', os: 'android', screen: [1280, 800], ui: [80, 48], dpr: 2, mobile: true, touch: true, ua: ANDROID_UA('SM-X710', false), safe: { top: 24, bottom: 48 }, frame: 'tablet' },
29
+ laptop: { title: 'Laptop 1440', kind: 'laptop', os: null, screen: [1440, 900], ui: [0, 0], dpr: 2, mobile: false, touch: false, ua: null, safe: null, frame: 'laptop' },
30
+ 'desktop-hd': { title: 'Desktop 1920', kind: 'desktop', os: null, screen: [1920, 1080], ui: [0, 0], dpr: 1, mobile: false, touch: false, ua: null, safe: null, frame: 'monitor' },
31
+ 'desktop-wide': { title: 'Wide 2560', kind: 'desktop', os: null, screen: [2560, 1440], ui: [0, 0], dpr: 1, mobile: false, touch: false, ua: null, safe: null, frame: 'monitor' },
32
+ 'tv-1080p': { title: 'TV 1080p', kind: 'tv', os: null, screen: [1920, 1080], ui: [0, 0], dpr: 1, mobile: false, touch: false, ua: 'Mozilla/5.0 (SMART-TV; LINUX; Tizen 7.0) AppleWebKit/537.36 (KHTML, like Gecko) 94.0.4606.31/7.0 TV Safari/537.36', safe: { top: 27, bottom: 27, left: 48, right: 48 }, frame: 'tv' }
33
+ }
34
+
35
+ /** Other names people use for the same preset. */
36
+ export const ALIASES = {
37
+ 'small-phone': 'iphone-se', 'large-phone': 'iphone-16-pro-max', 'android-phone': 'pixel-8', 'ipad': 'ipad-portrait',
38
+ 'tablet-portrait': 'ipad-portrait', 'tablet-landscape': 'ipad-landscape', 'fold-closed': 'fold-folded', 'foldable': 'fold-open',
39
+ 'laptop-1440': 'laptop', 'desktop-1920': 'desktop-hd', 'wide': 'desktop-wide', 'wide-2560': 'desktop-wide', 'tv': 'tv-1080p'
40
+ }
41
+
42
+ const ROTATES = new Set(['phone', 'foldable', 'tablet'])
43
+
44
+ /**
45
+ * A preset by name (aliases and `-landscape` included), with `name` and `size`: the CSS viewport a walk uses.
46
+ * `fullScreen` (Design, installed PWA, app mockups) uses the whole screen instead of screen minus browser UI.
47
+ * Returns null for an unknown name.
48
+ */
49
+ export function devicePreset (name, { fullScreen = false } = {}) {
50
+ const key = String(name || '').trim().toLowerCase()
51
+ let id = ALIASES[key] || key
52
+ let base = DEVICES[id]
53
+ let landscape = false
54
+ if (!base && id.endsWith('-landscape')) {
55
+ const portrait = ALIASES[id.slice(0, -10)] || id.slice(0, -10)
56
+ if (DEVICES[portrait] && ROTATES.has(DEVICES[portrait].kind) && DEVICES[portrait].screen[0] < DEVICES[portrait].screen[1]) { base = DEVICES[portrait]; landscape = true; id = `${portrait}-landscape` }
57
+ }
58
+ if (!base) return null
59
+ const d = JSON.parse(JSON.stringify(base))
60
+ if (landscape) {
61
+ d.title = `${d.title} (landscape)`
62
+ d.screen = [d.screen[1], d.screen[0]]
63
+ // the notch and the home indicator move to the sides; the browser keeps a top bar
64
+ if (d.safe) d.safe = { top: 0, bottom: d.safe.bottom ? 21 : 0, left: d.safe.top > 24 ? d.safe.top : 0, right: d.safe.top > 24 ? d.safe.top : 0 }
65
+ d.ui = [Math.min(d.ui[0], 50), 0]
66
+ }
67
+ const ui = fullScreen ? [0, 0] : d.ui
68
+ return { name: id, ...d, size: [d.screen[0], Math.max(200, d.screen[1] - ui[0] - ui[1])], full_screen: !!fullScreen }
69
+ }
70
+
71
+ /** Every preset name (for help texts, validation messages and the skill). */
72
+ export const presetNames = () => Object.keys(DEVICES)
73
+
74
+ /**
75
+ * What a capture needs to emulate the preset in a browser context: pixel ratio, mobile viewport, touch and user
76
+ * agent. Desktop presets emulate only the pixel ratio. The profile key groups variants that can share one context.
77
+ */
78
+ export function emulationOf (preset) {
79
+ if (!preset) return null
80
+ const profile = { dpr: preset.dpr || 1, mobile: !!preset.mobile, touch: !!preset.touch, ua: preset.ua || null }
81
+ return { ...profile, key: `${profile.dpr}|${profile.mobile ? 'm' : 'd'}|${profile.touch ? 't' : ''}|${profile.ua || ''}` }
82
+ }
83
+
84
+ /** What the canvas and the manifest keep of a preset (no user agent: it is long and only the walk needs it). */
85
+ export const presetSummary = (p) => p && ({ name: p.name, title: p.title, kind: p.kind, os: p.os, screen: p.screen, size: p.size, dpr: p.dpr, touch: p.touch, safe: p.safe, frame: p.frame, ui: p.full_screen ? [0, 0] : p.ui, ...(p.full_screen ? { full_screen: true } : {}) })
@@ -206,7 +206,8 @@ export function kalokoDev (opts) {
206
206
  if (/width$/.test(prop)) return { kind: 'dim', fit: (e) => e.type === 'borderWidth' || /border|stroke/i.test(e.name) }
207
207
  return null
208
208
  }
209
- const pref = (list) => list.find((e) => !e.mode) || list[0]
209
+ // the capture's colour scheme picks the token mode (light, dark) when the set has it (batch E)
210
+ const pref = (list) => (o.mode && list.find((e) => e.mode === o.mode)) || list.find((e) => !e.mode) || list[0]
210
211
  const tokOut = (m, e, extra) => Object.assign({ m, name: e.name, value: e.css }, e.mode ? { mode: e.mode } : {}, extra || {})
211
212
  /**
212
213
  * The token of one computed value: { m: 'exact', name, value, mode? } or { m: 'nearest', name, value } for a
@@ -818,4 +819,4 @@ export function driftText (findings, { title = '' } = {}) {
818
819
  }
819
820
 
820
821
  /** Expression for page.evaluate that returns the page spec (async: linked stylesheets may need a fetch). */
821
- export const pageSpecScript = (tokens, maxElements = 300) => `(${kalokoDev.toString()})(${JSON.stringify({ tokens: tokens || null, maxElements })}).pageSpec()`
822
+ export const pageSpecScript = (tokens, maxElements = 300, mode = null) => `(${kalokoDev.toString()})(${JSON.stringify({ tokens: tokens || null, maxElements, ...(mode ? { mode } : {}) })}).pageSpec()`