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.
- package/README.md +64 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +70 -2
- package/schemas/run.v1.json +33 -2
- package/schemas/scenario.v1.json +130 -17
- package/skills/kaloko/SKILL.md +18 -5
- package/skills/kaloko/reference/acceptance.md +70 -1
- package/skills/kaloko/reference/apps.md +103 -8
- package/skills/kaloko/reference/ci.md +3 -1
- package/skills/kaloko/reference/design.md +39 -0
- package/skills/kaloko/reference/devices.md +109 -0
- package/skills/kaloko/reference/environments.md +6 -0
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/mcp.md +1 -0
- package/skills/kaloko/reference/recording.md +95 -0
- package/src/cli.mjs +381 -167
- package/src/lib/access.mjs +5 -2
- package/src/lib/anim.mjs +125 -0
- package/src/lib/apps/devices.mjs +187 -0
- package/src/lib/apps/drivers.mjs +11 -3
- package/src/lib/apps/ios-device.mjs +78 -0
- package/src/lib/apps/recording-native.mjs +212 -0
- package/src/lib/apps/tree.mjs +9 -1
- package/src/lib/apps/webview.mjs +54 -0
- package/src/lib/apps/windows.mjs +108 -0
- package/src/lib/apps/windows.ps1 +78 -0
- package/src/lib/assertions.mjs +300 -0
- package/src/lib/build.mjs +63 -4
- package/src/lib/capture.mjs +146 -119
- package/src/lib/ci.mjs +8 -4
- package/src/lib/design-cli.mjs +35 -6
- package/src/lib/design.mjs +3 -1
- package/src/lib/devices.mjs +85 -0
- package/src/lib/devspec.mjs +3 -2
- package/src/lib/dimensions.mjs +326 -0
- package/src/lib/doctor.mjs +35 -6
- package/src/lib/drift.mjs +8 -4
- package/src/lib/evaluate.mjs +25 -7
- package/src/lib/import.mjs +3 -2
- package/src/lib/jev.mjs +3 -1
- package/src/lib/manifest-core.mjs +12 -3
- package/src/lib/probe.mjs +60 -0
- package/src/lib/recheck.mjs +6 -1
- package/src/lib/recording.mjs +373 -0
- package/src/lib/scenario.mjs +3 -0
- package/src/lib/stability-core.mjs +4 -2
- package/src/lib/trace.mjs +109 -0
- package/src/lib/video-cli.mjs +209 -0
- package/src/lib/video.mjs +427 -0
- package/src/lib/walk-dimensions.mjs +83 -0
- package/src/lib/webm.mjs +144 -0
- package/viewer/index.html +467 -25
package/src/lib/capture.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
await
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
- |
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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}/${
|
|
76
|
-
const results = await captureStep({ page, node, locale: rev.locale, viewports:
|
|
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:
|
|
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
|
package/src/lib/design.mjs
CHANGED
|
@@ -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
|
-
|
|
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 } : {}) })
|
package/src/lib/devspec.mjs
CHANGED
|
@@ -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
|
-
|
|
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()`
|