kaloko 0.4.7 → 0.4.9

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/src/lib/packs.mjs CHANGED
@@ -43,7 +43,7 @@ export const PACKS = {
43
43
  },
44
44
  a11y: {
45
45
  base: 300,
46
- title: 'Accessibility basics (WCAG 2.2 AA subset): alt texts, names, labels, headings, contrast, landmarks, unique ids',
46
+ title: 'Accessibility (WCAG 2.2 AA subset): alt texts, names, labels, headings, contrast, landmarks, unique ids, plus a keyboard walk (tab order, visible focus, traps, skip link) and the accessibility tree (names, landmarks, ARIA)',
47
47
  criteria: () => [
48
48
  { id: 'AC301', text: 'Images have alternative text', check: det('a11y_images_alt') },
49
49
  { id: 'AC302', text: 'Links and buttons have an accessible name', check: det('a11y_names') },
@@ -51,7 +51,14 @@ export const PACKS = {
51
51
  { id: 'AC304', text: 'Heading levels are not skipped', check: det('a11y_headings') },
52
52
  { id: 'AC305', text: 'Text meets WCAG AA contrast', check: det('a11y_contrast') },
53
53
  { id: 'AC306', text: 'The page has a main landmark', check: det('a11y_main') },
54
- { id: 'AC307', text: 'Element ids are unique', check: det('a11y_unique_ids') }
54
+ { id: 'AC307', text: 'Element ids are unique', check: det('a11y_unique_ids') },
55
+ { id: 'AC308', text: 'Tab order follows the page and reaches every control', check: det('a11y_focus_order') },
56
+ { id: 'AC309', text: 'Keyboard focus is clearly visible (at least 3:1 contrast)', check: det('a11y_focus_visible') },
57
+ { id: 'AC310', text: 'No keyboard trap', check: det('a11y_keyboard_trap') },
58
+ { id: 'AC311', text: 'A skip link or an early way into the main content', check: det('a11y_skip_link') },
59
+ { id: 'AC312', text: 'Controls have a name in the accessibility tree', check: det('a11y_ax_names') },
60
+ { id: 'AC313', text: 'Landmarks are complete and labelled', check: det('a11y_landmarks') },
61
+ { id: 'AC314', text: 'ARIA roles and attributes are valid', check: det('a11y_aria') }
55
62
  ]
56
63
  },
57
64
  perf: {
@@ -0,0 +1,61 @@
1
+ // Minimal PNG codec (no native dependency): decodes the 8-bit, non-interlaced PNGs that Chrome screenshots are
2
+ // (truecolour, truecolour with alpha, greyscale, palette) into RGBA, and encodes RGBA for tests and crops.
3
+ import { inflateSync, deflateSync, crc32 } from 'node:zlib'
4
+
5
+ const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
6
+
7
+ /** @returns {{ width: number, height: number, data: Uint8ClampedArray }} RGBA pixels */
8
+ export function decodePng (buf) {
9
+ buf = Buffer.from(buf)
10
+ if (!buf.subarray(0, 8).equals(SIG)) throw new Error('not a PNG')
11
+ let width = 0; let height = 0; let depth = 0; let type = 0; let interlace = 0; let palette = null; let trns = null
12
+ const idat = []
13
+ for (let at = 8; at < buf.length;) {
14
+ const len = buf.readUInt32BE(at); const kind = buf.toString('latin1', at + 4, at + 8); const data = buf.subarray(at + 8, at + 8 + len)
15
+ if (kind === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); depth = data[8]; type = data[9]; interlace = data[12] }
16
+ else if (kind === 'PLTE') palette = data
17
+ else if (kind === 'tRNS') trns = data
18
+ else if (kind === 'IDAT') idat.push(data)
19
+ else if (kind === 'IEND') break
20
+ at += 12 + len
21
+ }
22
+ if (depth !== 8 || interlace) throw new Error(`unsupported PNG (bit depth ${depth}, interlace ${interlace})`)
23
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type]
24
+ if (!channels) throw new Error(`unsupported PNG colour type ${type}`)
25
+ const raw = inflateSync(Buffer.concat(idat))
26
+ const stride = width * channels
27
+ const px = Buffer.alloc(stride * height)
28
+ for (let y = 0; y < height; y++) {
29
+ const f = raw[y * (stride + 1)]; const src = y * (stride + 1) + 1; const dst = y * stride
30
+ for (let x = 0; x < stride; x++) {
31
+ const a = x >= channels ? px[dst + x - channels] : 0
32
+ const b = y ? px[dst - stride + x] : 0
33
+ const c = y && x >= channels ? px[dst - stride + x - channels] : 0
34
+ let v = raw[src + x]
35
+ if (f === 1) v += a
36
+ else if (f === 2) v += b
37
+ else if (f === 3) v += (a + b) >> 1
38
+ else if (f === 4) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c }
39
+ px[dst + x] = v & 255
40
+ }
41
+ }
42
+ const data = new Uint8ClampedArray(width * height * 4)
43
+ for (let i = 0, j = 0; i < width * height; i++, j += channels) {
44
+ const o = i * 4
45
+ if (type === 6) { data[o] = px[j]; data[o + 1] = px[j + 1]; data[o + 2] = px[j + 2]; data[o + 3] = px[j + 3] }
46
+ else if (type === 2) { data[o] = px[j]; data[o + 1] = px[j + 1]; data[o + 2] = px[j + 2]; data[o + 3] = 255 }
47
+ else if (type === 0 || type === 4) { data[o] = data[o + 1] = data[o + 2] = px[j]; data[o + 3] = type === 4 ? px[j + 1] : 255 }
48
+ else { const k = px[j] * 3; data[o] = palette[k]; data[o + 1] = palette[k + 1]; data[o + 2] = palette[k + 2]; data[o + 3] = trns && px[j] < trns.length ? trns[px[j]] : 255 }
49
+ }
50
+ return { width, height, data }
51
+ }
52
+
53
+ /** RGBA → PNG (filter 0, colour type 6). */
54
+ export function encodePng ({ width, height, data }) {
55
+ const stride = width * 4
56
+ const raw = Buffer.alloc((stride + 1) * height)
57
+ for (let y = 0; y < height; y++) Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1)
58
+ const chunk = (kind, body) => { const len = Buffer.alloc(4); len.writeUInt32BE(body.length); const td = Buffer.concat([Buffer.from(kind, 'latin1'), body]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0); return Buffer.concat([len, td, crc]) }
59
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6
60
+ return Buffer.concat([SIG, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])
61
+ }
package/src/lib/probe.mjs CHANGED
@@ -195,6 +195,63 @@ export function probe ({ checks = [], highlights = [], scopes = [], maxChars = 9
195
195
  }
196
196
  return { ok: bad.length === 0, evidence: bad.length ? `${bad.length} text(s) below WCAG AA contrast: ${bad.slice(0, 4).join('; ')}` : `${checked} texts meet WCAG AA contrast` }
197
197
  }
198
+ if (kind === 'landmarks') {
199
+ // landmark structure as a screen reader lists it: one main, one top-level banner and contentinfo, repeated
200
+ // landmarks told apart by a label
201
+ const roleOf = (e) => {
202
+ const r = e.getAttribute('role'); if (r) return ['banner', 'navigation', 'main', 'contentinfo', 'complementary', 'search', 'form', 'region'].includes(r) ? r : null
203
+ const t = e.tagName
204
+ if (t === 'MAIN') return 'main'; if (t === 'NAV') return 'navigation'; if (t === 'ASIDE') return 'complementary'; if (t === 'SEARCH') return 'search'
205
+ // header/footer are banner/contentinfo only outside article, aside, main, nav and section
206
+ if (t === 'HEADER' || t === 'FOOTER') return e.parentElement?.closest('article, aside, main, nav, section') ? null : t === 'HEADER' ? 'banner' : 'contentinfo'
207
+ if ((t === 'SECTION' || t === 'FORM') && (e.getAttribute('aria-label') || e.getAttribute('aria-labelledby'))) return t === 'FORM' ? 'form' : 'region'
208
+ return null
209
+ }
210
+ const all = [...document.querySelectorAll('main, nav, aside, header, footer, search, section, form, [role]')].filter((e) => !e.closest('[aria-hidden=true]') && visible(e)).map((e) => ({ e, role: roleOf(e) })).filter((x) => x.role)
211
+ const by = {}; for (const x of all) (by[x.role] ||= []).push(x.e)
212
+ const bad = []
213
+ if (!by.main) bad.push('no main landmark')
214
+ else if (by.main.length > 1) bad.push(`${by.main.length} main landmarks`)
215
+ for (const r of ['banner', 'contentinfo']) if ((by[r] || []).length > 1) bad.push(`${by[r].length} ${r} landmarks`)
216
+ for (const [r, list] of Object.entries(by)) {
217
+ if (list.length < 2 || r === 'main') continue
218
+ const labels = list.map((e) => clean(e.getAttribute('aria-label') || (e.getAttribute('aria-labelledby') || '').split(/\s+/).map((i) => document.getElementById(i)?.innerText || '').join(' ')))
219
+ if (labels.some((l) => !l) || new Set(labels).size < labels.length) bad.push(`${list.length} ${r} landmarks without distinct labels (aria-label)`)
220
+ }
221
+ const summary = Object.entries(by).map(([r, list]) => `${r}${list.length > 1 ? ` ×${list.length}` : ''}`).join(', ')
222
+ return { ok: bad.length === 0, evidence: bad.length ? `${bad.join('; ')} (landmarks: ${summary || 'none'})` : `landmarks: ${summary}` }
223
+ }
224
+ if (kind === 'aria') {
225
+ // ARIA that misleads a screen reader: unknown roles, misspelt attributes, references to missing ids, hidden
226
+ // focusable content, boolean attributes with other values
227
+ const ROLES = 'alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img insertion link list listbox listitem log main marquee math meter menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem doc-abstract doc-acknowledgments doc-afterword doc-appendix doc-backlink doc-biblioentry doc-bibliography doc-biblioref doc-chapter doc-colophon doc-conclusion doc-cover doc-credit doc-credits doc-dedication doc-endnote doc-endnotes doc-epigraph doc-epilogue doc-errata doc-example doc-footnote doc-foreword doc-glossary doc-glossref doc-index doc-introduction doc-noteref doc-notice doc-pagebreak doc-pagelist doc-part doc-preface doc-prologue doc-pullquote doc-qna doc-subtitle doc-tip doc-toc graphics-document graphics-object graphics-symbol'.split(' ')
228
+ const ATTRS = 'activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colindextext colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(' ')
229
+ const BOOL = ['atomic', 'busy', 'disabled', 'hidden', 'modal', 'multiline', 'multiselectable', 'readonly', 'required']
230
+ const TRI = { expanded: ['true', 'false', 'undefined'], selected: ['true', 'false', 'undefined'], checked: ['true', 'false', 'mixed', 'undefined'], pressed: ['true', 'false', 'mixed', 'undefined'] }
231
+ // rendered on screen, whatever aria-hidden says (visible() treats aria-hidden content as gone)
232
+ const rendered = (e) => typeof e.checkVisibility === 'function' ? e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) : getComputedStyle(e).display !== 'none'
233
+ const REFS = ['labelledby', 'describedby', 'controls', 'owns', 'activedescendant', 'errormessage', 'flowto', 'details']
234
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex^="-"]), [contenteditable=true]'
235
+ const issues = []
236
+ const add = (e, text) => { if (issues.length < 40) issues.push(`${describe(e)}: ${text}`) }
237
+ for (const e of document.querySelectorAll('body [role], body *')) {
238
+ if (issues.length >= 40) break
239
+ const role = e.getAttribute('role')
240
+ if (role != null && role.trim() && !role.trim().split(/\s+/).some((r) => ROLES.includes(r))) add(e, `unknown role "${role}"`)
241
+ for (const a of e.attributes) {
242
+ if (!a.name.startsWith('aria-')) continue
243
+ const n = a.name.slice(5)
244
+ if (!ATTRS.includes(n)) { add(e, `unknown attribute ${a.name}`); continue }
245
+ if (BOOL.includes(n) && !['true', 'false', ''].includes(a.value.trim())) add(e, `${a.name}="${a.value}" (true or false)`)
246
+ if (TRI[n] && a.value.trim() && !TRI[n].includes(a.value.trim())) add(e, `${a.name}="${a.value}" (${TRI[n].join(', ')})`)
247
+ if (REFS.includes(n)) { const missing = a.value.split(/\s+/).filter(Boolean).filter((i) => !document.getElementById(i)); if (missing.length) add(e, `${a.name} points to missing id ${missing.slice(0, 2).map((i) => '#' + i).join(', ')}`) }
248
+ }
249
+ if (e.getAttribute('aria-hidden') === 'true' && rendered(e)) { const f = e.matches(FOCUSABLE) ? e : e.querySelector(FOCUSABLE); if (f && rendered(f) && !f.closest('[inert]')) add(e, `aria-hidden="true" hides focusable ${describe(f)} from screen readers`) }
250
+ if (/^(presentation|none)$/.test(role || '') && e.matches(FOCUSABLE)) add(e, `role="${role}" on a focusable element`)
251
+ }
252
+ const shown = [...new Set(issues)]
253
+ return { ok: shown.length === 0, evidence: shown.length ? `${shown.length} ARIA problem(s): ${shown.slice(0, 5).join('; ')}` : 'roles and aria-* attributes are valid' }
254
+ }
198
255
  return { ok: null, evidence: `unknown a11y check ${kind}` }
199
256
  }
200
257
  const buffered = (type) => { try { const po = new PerformanceObserver(() => {}); po.observe({ type, buffered: true }); const r = po.takeRecords(); po.disconnect(); return r } catch { return null } }
@@ -12,8 +12,10 @@ import { probeArgs } from './capture.mjs'
12
12
  import { contextOptions, prepareContext } from './access.mjs'
13
13
  import { launchEphemeral } from './browser.mjs'
14
14
  import { treeChecks } from './apps/tree.mjs'
15
+ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
15
16
 
16
- export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || assert === 'links_ok' || assert === 'hreflang_pairs' || /^perf_/.test(assert)
17
+ // keyboard walk and accessibility tree (#19) need the live page too
18
+ export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || assert === 'links_ok' || assert === 'hreflang_pairs' || /^perf_/.test(assert) || KEYBOARD_ASSERTS.has(assert)
17
19
 
18
20
  const withBase = (html, url) => {
19
21
  if (!url || /<base\s/i.test(html)) return html
@@ -0,0 +1,69 @@
1
+ // The service's review queue in words for the terminal (`kaloko review`, `kaloko share`, `kaloko feedback`): what a
2
+ // person still has to decide on a shared run, so an agent can tell them what waits and where. Agents never decide
3
+ // for people; they point them to the review mode link.
4
+
5
+ const REASON = {
6
+ fail: 'failed',
7
+ returned: 'returned',
8
+ needs_review: 'needs a person’s judgement',
9
+ not_evaluated: 'not evaluated',
10
+ revoked: 'carried approval revoked',
11
+ changed: 'changed since the accepted run',
12
+ new: 'new',
13
+ comments: 'open comments'
14
+ }
15
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
16
+ const day = (iso) => (iso ? String(iso).slice(0, 10) : '')
17
+ const cut = (s, n = 140) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
18
+
19
+ /** One line after `kaloko share`: "12 steps unchanged, approvals carried over; 5 need review: <link>". */
20
+ export function shareReviewLine (r) {
21
+ if (!r || r.queue == null) return null
22
+ const carried = r.carried ? `${plural(r.carried, 'step', 'steps')} unchanged, approvals carried over; ` : ''
23
+ if (!r.pending) return `${carried}no step waits for a person; the run still needs its verdict: ${r.url}`
24
+ return `${carried}${r.pending} ${r.pending === 1 ? 'needs' : 'need'} review: ${r.url}`
25
+ }
26
+
27
+ /** Who decided and how, in a few words. */
28
+ function decisionText (d) {
29
+ if (!d) return 'waiting for a person'
30
+ const via = d.via === 'agent' ? ' (agent)' : d.via === 'token' ? ' (token)' : ''
31
+ if (d.state === 'carried') return `approval carried over: ${d.by}${via} approved it on ${day(d.at)} in run ${d.from_run}`
32
+ if (d.state === 'approved') return `approved by ${d.by}${via} on ${day(d.at)}`
33
+ return `returned by ${d.by}${via} on ${day(d.at)}: ${cut(d.note, 200)}`
34
+ }
35
+
36
+ /** The whole queue for the terminal. */
37
+ export function formatReviewQueue (q) {
38
+ if (!q?.run) return 'No published run found.'
39
+ const c = q.counts || {}
40
+ const out = []
41
+ out.push(`Review of ${q.run.id} (${q.run.scenario}${q.run.environment ? `, ${q.run.environment}` : ''}): ${c.decided || 0} of ${plural(c.queue || 0, 'step', 'steps')} decided, ${c.pending || 0} waiting for a person${c.carried ? `; ${plural(c.carried, 'unchanged step', 'unchanged steps')} kept ${c.carried === 1 ? 'its' : 'their'} approval` : ''}`)
42
+ out.push(q.reference
43
+ ? `Compared with the accepted run ${q.reference.id} (accepted${q.reference.accepted_by ? ` by ${q.reference.accepted_by}` : ''}${q.reference.accepted_at ? ` on ${day(q.reference.accepted_at)}` : ''})`
44
+ : 'No accepted run of this scenario yet: every step is new to the reviewers.')
45
+ if (!q.policy?.carry_over) out.push('Approvals do not carry over in this organization (Settings → Approvals).')
46
+ for (const it of q.items || []) {
47
+ const mark = !it.decision ? '○' : it.decision.state === 'returned' ? '↩' : '✓'
48
+ out.push(` ${mark} ${it.step}${it.title ? ` ${typeof it.title === 'string' ? it.title : Object.values(it.title)[0]}` : ''} — ${it.reasons.map((r) => REASON[r] || r).join(', ')}`)
49
+ if (it.changed?.captures?.length) out.push(` changed: ${it.changed.captures.map((x) => `${x.locale}/${x.viewport}${x.why !== 'pixels' ? ` (${x.why})` : ''}`).join(', ')}${it.changed.criteria ? ' · criteria changed' : ''}`)
50
+ else if (it.changed?.criteria) out.push(' changed: criteria')
51
+ for (const cr of (it.criteria || []).filter((x) => x.cells?.length).slice(0, 4)) {
52
+ const cell = cr.cells.find((x) => x.verdict === 'fail') || cr.cells[0]
53
+ out.push(` ${cr.id} [${cell.locale}/${cell.viewport}] ${cell.verdict}${cell.evidence ? `: ${cut(cell.evidence)}` : ''}`)
54
+ }
55
+ out.push(` ${decisionText(it.decision)}`)
56
+ if (it.unconfirmed) out.push(` not counted: ${decisionText(it.unconfirmed)}`)
57
+ if (it.open_comments) out.push(` ${plural(it.open_comments, 'open comment', 'open comments')} (kaloko feedback)`)
58
+ }
59
+ if (!(q.items || []).length) out.push(' Nothing waits for a person on the steps.')
60
+ if (q.carried?.length) {
61
+ out.push(`Approvals carried over (${q.carried.length}):`)
62
+ for (const x of q.carried.slice(0, 12)) out.push(` ✓ ${x.step} — ${x.approved_by}${x.approved_via && x.approved_via !== 'session' ? ` (${x.approved_via})` : ''}, ${day(x.approved_at)}, ${x.kind === 'run' ? 'accepted run' : 'step approved in'} ${x.from_run}`)
63
+ if (q.carried.length > 12) out.push(` … and ${q.carried.length - 12} more`)
64
+ }
65
+ const v = q.verdict || {}
66
+ out.push(`Run verdict: ${v.verdict || 'pending'}${v.by ? ` by ${v.by}${v.via && v.via !== 'session' ? ` (${v.via})` : ''}` : ''}${v.awaiting?.length ? ` · awaiting approval from ${v.awaiting.join(', ')}` : ''}`)
67
+ out.push(`Review mode for a person: ${q.review_url}`)
68
+ return out.join('\n')
69
+ }
@@ -0,0 +1,90 @@
1
+ // `kaloko spec` and `kaloko drift` (#42 Dev mode for agents): what to build for a step as it was approved, and how
2
+ // an implementation run differs from it, in the same vocabulary (elements by role and name, styles → DTCG tokens,
3
+ // interaction states). The spec comes from the service (design reference, draft, a version or a shared run) or from
4
+ // a local run's captures; drift compares the local implementation run with the reference from the service.
5
+ import { join } from 'node:path'
6
+ import { readJson, UserError } from './util.mjs'
7
+ import { readCaptures } from './run.mjs'
8
+ import { latestCaptures } from './evaluate.mjs'
9
+ import { specText, specDrift, driftText } from './devspec.mjs'
10
+
11
+ const enc = encodeURIComponent
12
+
13
+ /** The local run's specs: [{ node, locale, viewport, spec }] (newest capture of each). */
14
+ export async function localSpecs (run, { step, locale, viewport } = {}) {
15
+ const out = []
16
+ for (const c of latestCaptures(await readCaptures(run.dir))) {
17
+ if (c.item || (step && c.node !== step) || (locale && c.locale !== locale) || (viewport && c.viewport !== viewport)) continue
18
+ const rec = await readJson(join(run.dir, c.file), null)
19
+ out.push({ node: c.node, locale: c.locale, viewport: c.viewport, spec: rec?.spec || null })
20
+ }
21
+ return out
22
+ }
23
+
24
+ const pickOne = (list, viewport) => list.find((x) => x.viewport === viewport) || list.find((x) => x.viewport === 'desktop') || list[0]
25
+
26
+ export async function specCmd ({ config, client, run, scenario, scenarioId, args, say }) {
27
+ const step = typeof args._[0] === 'string' ? args._[0] : typeof args.step === 'string' ? args.step : null
28
+ const locale = typeof args.locale === 'string' ? args.locale : null
29
+ const viewport = typeof args.viewport === 'string' ? args.viewport : null
30
+ const steps = step ? [step] : (scenario?.nodes || []).filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id)
31
+ if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]')
32
+ const out = []
33
+ for (const s of steps) {
34
+ if (args.local || !client) {
35
+ if (!run) throw new UserError('--local needs a run: pass --run <id> or start one.')
36
+ const list = (await localSpecs(run, { step: s, locale, viewport })).filter((x) => locale || x.locale === (run.meta.scope?.locales?.[0] || x.locale))
37
+ const one = pickOne(list, viewport)
38
+ if (!one) continue
39
+ out.push({ node: s, locale: one.locale, viewport: one.viewport, ref: 'local', spec: one.spec })
40
+ } else {
41
+ const ref = typeof args.ref === 'string' ? args.ref : typeof args.version === 'string' || typeof args.version === 'number' ? `v${args.version}` : 'design'
42
+ const q = new URLSearchParams(Object.entries({ ref, locale, viewport }).filter(([, v]) => v))
43
+ try {
44
+ const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(s)}/spec?${q}`)
45
+ out.push({ node: s, locale: d.locale, viewport: d.viewport, ref: d.ref, spec: d.spec })
46
+ } catch (e) { if (step) throw e; out.push({ node: s, error: e.message.split('\n')[0] }) }
47
+ }
48
+ }
49
+ if (args.json) { say(JSON.stringify(step ? out[0] || null : out, null, 2)); return }
50
+ if (!out.length) throw new UserError(`No capture of ${step || 'any step'} in this run.`)
51
+ say(out.map((o) => o.error ? `${o.node}: ${o.error}` : specText(o.spec, { title: `${o.node} · ${o.locale} · ${o.viewport} · ${o.ref}` })).join('\n\n'))
52
+ }
53
+
54
+ /**
55
+ * Drift of the local implementation run (or, with --remote, a shared run) from the approved design. Exit code 1
56
+ * when anything differs, so an agent can loop: implement → walk → drift → fix.
57
+ */
58
+ export async function driftCmd ({ config, client, run, scenarioId, args, say }) {
59
+ const step = typeof args._[0] === 'string' ? args._[0] : typeof args.step === 'string' ? args.step : null
60
+ const ref = typeof args.ref === 'string' ? args.ref : typeof args.version === 'string' || typeof args.version === 'number' ? `v${args.version}` : 'design'
61
+ const locale = typeof args.locale === 'string' ? args.locale : null
62
+ const viewport = typeof args.viewport === 'string' ? args.viewport : null
63
+ if (!client) throw new UserError('kaloko drift compares with the approved design on the service: set org and a token (kaloko login).')
64
+ const results = []
65
+ if (args.remote) {
66
+ if (!step) throw new UserError('Usage: kaloko drift <step> --remote <run id|latest>')
67
+ const q = new URLSearchParams(Object.entries({ run: typeof args.remote === 'string' ? args.remote : 'latest', ref, locale, viewport }).filter(([, v]) => v))
68
+ const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(step)}/drift?${q}`)
69
+ results.push({ node: step, locale: d.locale, viewport: d.viewport, ref: d.ref, findings: d.findings, text: d.text })
70
+ } else {
71
+ if (!run) throw new UserError('No implementation run here: walk the scenario first (packs: [design] on the steps), or pass --remote <run id>.')
72
+ if (run.meta.environment?.kind === 'design') throw new UserError('This is a design run; drift compares an implementation run with the design. Pass --run <implementation run>.')
73
+ const git = run.meta.git?.branch
74
+ const mine = (await localSpecs(run, { step, locale, viewport }))
75
+ if (!mine.length) throw new UserError(`No capture${step ? ` of ${step}` : ''} in run ${run.meta.id}.`)
76
+ for (const m of mine) {
77
+ if (!m.spec) { results.push({ node: m.node, locale: m.locale, viewport: m.viewport, note: 'no spec in this capture: add packs: [design] to the step and walk again' }); continue }
78
+ const q = new URLSearchParams(Object.entries({ ref, locale: m.locale, viewport: m.viewport, git }).filter(([, v]) => v))
79
+ let d
80
+ try { d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(m.node)}/spec?${q}`) } catch (e) { results.push({ node: m.node, locale: m.locale, viewport: m.viewport, note: e.message.split('\n')[0] }); continue }
81
+ if (!d.spec) { results.push({ node: m.node, locale: m.locale, viewport: m.viewport, note: 'the design has no spec for this step yet (walk and sync the design with the current CLI)' }); continue }
82
+ const findings = specDrift(m.spec, d.spec)
83
+ results.push({ node: m.node, locale: m.locale, viewport: m.viewport, ref: d.ref, findings, text: driftText(findings, { title: `${m.node} · ${m.locale} · ${m.viewport} vs ${d.ref}` }) })
84
+ }
85
+ }
86
+ const differing = results.filter((r) => r.findings?.length).length
87
+ if (args.json) say(JSON.stringify(results.map(({ text, ...r }) => r), null, 2))
88
+ else say(results.map((r) => r.text || `${r.node} · ${r.locale} · ${r.viewport}: ${r.note}`).join('\n\n') + `\n\n${differing ? `${differing} of ${results.length} capture(s) differ from the approved design.` : `${results.filter((r) => r.findings).length} capture(s) match the approved design.`}`)
89
+ if (differing) process.exitCode = 1
90
+ }
@@ -0,0 +1,114 @@
1
+ // Unstable regions (#20): parts of a screen that change between captures of the same state (clocks, ads,
2
+ // carousels, avatars, "3 minutes ago"). Pure functions without Node APIs: `unstableRegions` also runs inside a
3
+ // browser page (the CLI injects its source to compare WebP captures there), so it must stay self-contained.
4
+
5
+ /**
6
+ * Regions that differ in most consecutive pairs of `images` (RGBA arrays of the same width × height).
7
+ * The images are split into cells; a cell changes in a pair when at least `cellFill` of its pixels differ by more
8
+ * than `threshold` (sum of RGB differences, the canvas diff's rule). A cell is unstable when it changed in at least
9
+ * `minShare` of the pairs. Neighbouring unstable cells (gap up to `gap` cells) become one rectangle.
10
+ * When more than `maxArea` of the screen is unstable the page itself changed: no regions are suggested.
11
+ * @returns {{ regions: Array<{x:number,y:number,w:number,h:number,share:number}>, changed: number, pairs: number, too_much?: boolean }}
12
+ */
13
+ export function unstableRegions ({ images, width, height, threshold = 48, cell = 8, cellFill = 0.02, minShare = 0.66, gap = 2, pad = 4, maxArea = 0.35, minCells = 2 }) {
14
+ const pairs = images.length - 1
15
+ if (pairs < 1 || !width || !height) return { regions: [], changed: 0, pairs: Math.max(0, pairs) }
16
+ const cw = Math.ceil(width / cell); const ch = Math.ceil(height / cell)
17
+ const hits = new Uint16Array(cw * ch)
18
+ const count = new Uint32Array(cw * ch)
19
+ for (let p = 0; p < pairs; p++) {
20
+ const a = images[p]; const b = images[p + 1]
21
+ count.fill(0)
22
+ for (let y = 0; y < height; y++) {
23
+ const row = (y / cell | 0) * cw
24
+ for (let x = 0; x < width; x++) {
25
+ const i = (y * width + x) * 4
26
+ if (Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]) > threshold) count[row + (x / cell | 0)]++
27
+ }
28
+ }
29
+ for (let c = 0; c < count.length; c++) {
30
+ const cx = c % cw; const cy = (c / cw) | 0
31
+ const area = (Math.min(cell, width - cx * cell)) * (Math.min(cell, height - cy * cell))
32
+ if (count[c] >= Math.max(1, area * cellFill)) hits[c]++
33
+ }
34
+ }
35
+ const flagged = new Uint8Array(cw * ch)
36
+ let total = 0
37
+ for (let c = 0; c < hits.length; c++) if (hits[c] / pairs >= minShare) { flagged[c] = 1; total++ }
38
+ const changed = total / (cw * ch)
39
+ if (changed > maxArea) return { regions: [], changed, pairs, too_much: true }
40
+ // connected components over flagged cells, bridging gaps of up to `gap` cells
41
+ const label = new Int32Array(cw * ch).fill(-1)
42
+ const boxes = []
43
+ for (let c = 0; c < flagged.length; c++) {
44
+ if (!flagged[c] || label[c] >= 0) continue
45
+ const box = { x0: c % cw, y0: (c / cw) | 0, x1: c % cw, y1: (c / cw) | 0, cells: 0, hits: 0 }
46
+ const stack = [c]; label[c] = boxes.length
47
+ while (stack.length) {
48
+ const k = stack.pop(); const kx = k % cw; const ky = (k / cw) | 0
49
+ box.cells++; box.hits += hits[k]
50
+ if (kx < box.x0) box.x0 = kx; if (kx > box.x1) box.x1 = kx; if (ky < box.y0) box.y0 = ky; if (ky > box.y1) box.y1 = ky
51
+ for (let dy = -gap - 1; dy <= gap + 1; dy++) {
52
+ for (let dx = -gap - 1; dx <= gap + 1; dx++) {
53
+ const nx = kx + dx; const ny = ky + dy
54
+ if (nx < 0 || ny < 0 || nx >= cw || ny >= ch) continue
55
+ const n = ny * cw + nx
56
+ if (flagged[n] && label[n] < 0) { label[n] = boxes.length; stack.push(n) }
57
+ }
58
+ }
59
+ }
60
+ boxes.push(box)
61
+ }
62
+ const regions = boxes.filter((b) => b.cells >= minCells).map((b) => {
63
+ const x = Math.max(0, b.x0 * cell - pad); const y = Math.max(0, b.y0 * cell - pad)
64
+ return { x, y, w: Math.min(width, (b.x1 + 1) * cell + pad) - x, h: Math.min(height, (b.y1 + 1) * cell + pad) - y, share: Math.round((b.hits / b.cells / pairs) * 100) / 100 }
65
+ })
66
+ return { regions: mergeRects(regions), changed, pairs }
67
+ }
68
+
69
+ /** Merges overlapping rectangles ({x,y,w,h}); keeps the highest share. */
70
+ export function mergeRects (rects, slack = 0) {
71
+ const out = rects.map((r) => ({ ...r }))
72
+ for (let merged = true; merged;) {
73
+ merged = false
74
+ for (let i = 0; i < out.length && !merged; i++) {
75
+ for (let j = i + 1; j < out.length; j++) {
76
+ const a = out[i]; const b = out[j]
77
+ if (a.x - slack <= b.x + b.w && b.x - slack <= a.x + a.w && a.y - slack <= b.y + b.h && b.y - slack <= a.y + a.h) {
78
+ const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y)
79
+ out[i] = { ...a, x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y, ...(a.share != null || b.share != null ? { share: Math.max(a.share ?? 0, b.share ?? 0) } : {}) }
80
+ out.splice(j, 1); merged = true; break
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * The ignore entries of a step that apply to one locale and viewport. Scenario form:
90
+ * ignore: [{ selector: '.clock' }, { rect: [x, y, w, h], viewport: desktop }, { selector: '#ad', locales: [cs] }]
91
+ */
92
+ export function ignoreFor (node, locale, viewport) {
93
+ return (Array.isArray(node?.ignore) ? node.ignore : []).filter((e) => {
94
+ if (!e || typeof e !== 'object') return false
95
+ if (e.viewport && e.viewport !== viewport) return false
96
+ if (Array.isArray(e.viewports) && e.viewports.length && !e.viewports.includes(viewport)) return false
97
+ if (Array.isArray(e.locales) && e.locales.length && !e.locales.includes(locale)) return false
98
+ return !!(e.selector || (Array.isArray(e.rect) && e.rect.length === 4))
99
+ })
100
+ }
101
+
102
+ /** Is pixel (x, y) (CSS px of the page) inside one of the rectangles? For diff loops, prefer `ignoreMask`. */
103
+ export const inRects = (rects, x, y) => rects.some((r) => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)
104
+
105
+ /** A W × H mask (1 = ignored) from rectangles in CSS px scaled by k (diff width / page width). */
106
+ export function ignoreMask (rects, W, H, k = 1) {
107
+ const m = new Uint8Array(W * H)
108
+ for (const r of rects || []) {
109
+ const x0 = Math.max(0, Math.floor(r.x * k)); const y0 = Math.max(0, Math.floor(r.y * k))
110
+ const x1 = Math.min(W, Math.ceil((r.x + r.w) * k)); const y1 = Math.min(H, Math.ceil((r.y + r.h) * k))
111
+ for (let y = y0; y < y1; y++) m.fill(1, y * W + x0, y * W + x1)
112
+ }
113
+ return m
114
+ }