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.
@@ -9,6 +9,7 @@ import { gateOf, gateHeaders, accountFor } from './access.mjs'
9
9
  import { missing } from './secrets.mjs'
10
10
  import { portFor } from './browser.mjs'
11
11
  import { DEFAULTS as JEV } from './jev.mjs'
12
+ import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
12
13
  import { serviceUrl } from './service.mjs'
13
14
 
14
15
  const portFree = (port) => new Promise((done) => {
@@ -136,6 +137,12 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
136
137
  else add('evaluator', false, s ? 'hosted evaluation not included in the plan' : 'hosted evaluation unavailable', `Put ${keyEnv} into .env or ~/.config/kaloko/.env to evaluate with your own key${s ? ', or upgrade the plan' : ''}.`)
137
138
  } else add('evaluator', false, `${keyEnv} not set`, `Semantic criteria will be "not evaluated" until ${keyEnv} is in .env or ~/.config/kaloko/.env${jev?.hosted === false ? '' : `, or ${tokenEnv} lets the service evaluate`}.`)
138
139
  }
140
+ // the vision evaluator is optional: without it unsure answers simply stay with a person
141
+ const vision = visionSettings(config)
142
+ if (!vision) add('vision evaluator', true, 'off in config')
143
+ else if (env[vision.api_key_env]) add('vision evaluator', true, `${vision.api_key_env} set · your key · model ${vision.model} · ${vision.when}`)
144
+ else if (token && vision.hosted !== false) add('vision evaluator', true, `via Kaloko · model ${vision.model} · one vision question counts as ${VISION_WEIGHT}`)
145
+ else add('vision evaluator', true, `${vision.api_key_env} not set (optional)`, `Criteria with vision: true and unsure answers stay with a person until ${vision.api_key_env} is set or ${tokenEnv} lets the service ask.`)
139
146
 
140
147
  return { ok: checks.every((c) => c.ok), checks }
141
148
  }
package/src/lib/drift.mjs CHANGED
@@ -46,18 +46,24 @@ export function tokenOffenders (styles, tokens, mode = null, allowed = null) {
46
46
  return out.sort((a, b) => b.count - a.count)
47
47
  }
48
48
 
49
- /** Pixel difference ratio of two images in a browser page (the viewer's algorithm, so numbers match the canvas). */
50
- export async function pixelRatio (page, a, b, width) {
51
- return page.evaluate(async ({ a, b, W }) => {
49
+ /**
50
+ * Pixel difference ratio of two images in a browser page (the viewer's algorithm, so numbers match the canvas).
51
+ * ignore: { rects: [{x,y,w,h}] in CSS px, cssWidth } — regions left out of the count (#20, scenario `ignore`).
52
+ */
53
+ export async function pixelRatio (page, a, b, width, ignore = null) {
54
+ return page.evaluate(async ({ a, b, W, rects, cssWidth }) => {
52
55
  const load = (src) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = src })
53
56
  const [x, y] = await Promise.all([load(a), load(b)])
54
57
  const ha = Math.round(x.naturalHeight * W / x.naturalWidth), hb = Math.round(y.naturalHeight * W / y.naturalWidth), H = Math.max(ha, hb)
55
58
  const draw = (img, h) => { const c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d', { willReadFrequently: true }); g.fillStyle = '#fff'; g.fillRect(0, 0, W, H); g.drawImage(img, 0, 0, W, h); return g.getImageData(0, 0, W, H).data }
56
59
  const da = draw(x, ha), db = draw(y, hb)
57
- let n = 0
58
- for (let i = 0; i < da.length; i += 4) if (Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]) > 48) n++
59
- return { ratio: n / (W * H), heights: [ha, hb] }
60
- }, { a, b, W: width })
60
+ const k = W / (cssWidth || W)
61
+ const skip = new Uint8Array(W * H)
62
+ for (const r of rects) { const x0 = Math.max(0, Math.floor(r.x * k)); const x1 = Math.min(W, Math.ceil((r.x + r.w) * k)); for (let yy = Math.max(0, Math.floor(r.y * k)); yy < Math.min(H, Math.ceil((r.y + r.h) * k)); yy++) skip.fill(1, yy * W + x0, yy * W + x1) }
63
+ let n = 0; let counted = 0
64
+ for (let i = 0, p = 0; i < da.length; i += 4, p++) { if (skip[p]) continue; counted++; if (Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]) > 48) n++ }
65
+ return { ratio: n / Math.max(1, counted), heights: [ha, hb], ignored: W * H - counted }
66
+ }, { a, b, W: width, rects: ignore?.rects || [], cssWidth: ignore?.cssWidth || null })
61
67
  }
62
68
 
63
69
  /**
@@ -96,7 +102,9 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
96
102
  const refPath = await cachedImage(ctx, rc.image)
97
103
  const mine = join(runDir, capture.files.image || capture.files.png)
98
104
  const toData = async (p) => `data:image/${p.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(p)).toString('base64')}`
99
- const { ratio } = await pixelRatio(ctx.page, await toData(mine), await toData(refPath), Math.min(1000, capture.size?.viewport?.[0] || 1280))
105
+ // regions the scenario ignores (clocks, ads) count in neither image
106
+ const ignore = { rects: [...(capture.ignore || []), ...(rc.ignore || [])], cssWidth: capture.size?.width || capture.size?.viewport?.[0] || null }
107
+ const { ratio } = await pixelRatio(ctx.page, await toData(mine), await toData(refPath), Math.min(1000, capture.size?.viewport?.[0] || 1280), ignore)
100
108
  const max = c.check.ratio ?? 0.03
101
109
  results[c.id] = { ok: ratio <= max, evidence: `${(ratio * 100).toFixed(1)} % of pixels differ from design v${ctx.referenceVersion ?? '?'} (limit ${(max * 100).toFixed(1)} %)`, ratio }
102
110
  }
@@ -1,7 +1,10 @@
1
1
  import { join, resolve } from 'node:path'
2
2
  import { readJson, writeJson, sha256 } from './util.mjs'
3
3
  import { readCaptures, runPaths } from './run.mjs'
4
- import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES } from './jev.mjs'
4
+ import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES, purposeOf } from './jev.mjs'
5
+ import { visionSettings, visionCriteria, templateReason, VISION_PROMPT_VERSION } from './vision-core.mjs'
6
+ import { prepareImages, visionPlan, askVision, hostedVisionSender } from './vision.mjs'
7
+ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
5
8
  import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
6
9
  import { packHelp } from './pack-help.mjs'
7
10
 
@@ -17,22 +20,37 @@ export const criteriaHash = (node) => sha256(node.criteria || [])
17
20
  export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
18
21
  export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
19
22
 
20
- /** Criterion verdicts for one capture, combining deterministic results and JEV answers. */
21
- export function combine ({ node, capture, jev, thresholds }) {
23
+ /**
24
+ * Criterion verdicts for one capture, combining deterministic results, the text evaluator (JEV) and, where it was
25
+ * asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
26
+ * model's own, or a sentence saying what the text evaluator read (it returns a probability only).
27
+ */
28
+ export function combine ({ node, capture, jev, vision = null, thresholds }) {
22
29
  return (node.criteria || []).map((c) => {
23
30
  if (c.check.type === 'deterministic') {
24
31
  const r = capture.checks?.[c.id]
25
32
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
26
- const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '')
33
+ const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '') || KEYBOARD_ASSERTS.has(c.check.assert)
27
34
  return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
28
35
  }
29
36
  if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
30
- return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null }
37
+ return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null, reason: 'No console errors on this page, so there is nothing to triage.', reason_source: 'rule' }
31
38
  }
32
39
  if (c.check.type === 'semantic') {
33
40
  const answer = jev?.response?.answers?.[c.id]
34
- const score = answer?.noul ?? null
35
- return { id: c.id, type: 'semantic', verdict: verdictFor(score, thresholds), evidence: null, score, provider: answer ? 'typesafe-jev' : null, note: jev?.skipped || jev?.error || null }
41
+ const text = answer?.noul ?? null
42
+ const seen = vision?.answers?.[c.id] || null
43
+ const score = seen ? seen.probability : text
44
+ const verdict = verdictFor(score, thresholds)
45
+ const reason = seen?.reason || templateReason({ score, scope: c.check.scope, about: c.check.about, verdict })
46
+ return {
47
+ id: c.id, type: 'semantic', verdict, evidence: null, score,
48
+ provider: seen ? `${vision.provider || 'anthropic'}-vision` : answer ? 'typesafe-jev' : null,
49
+ note: score == null ? (jev?.skipped || jev?.error || vision?.skipped || vision?.error || null) : null,
50
+ ...(reason ? { reason, reason_source: seen?.reason ? 'model' : 'template' } : {}),
51
+ ...(seen && text != null ? { text_score: text } : {}),
52
+ ...(!seen && (vision?.skipped || vision?.error) && vision.ids?.includes(c.id) ? { vision_note: vision.skipped || vision.error } : {})
53
+ }
36
54
  }
37
55
  return { id: c.id, type: 'manual', verdict: 'not_evaluated', evidence: c.check.note || null }
38
56
  })
@@ -46,6 +64,12 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
46
64
  const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
47
65
  const paths = runPaths(run.dir)
48
66
  const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
67
+ // the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
68
+ const vset = visionSettings(config)
69
+ if (vset) report.vision = { model: vset.model, asked: 0, calls: 0, cached: 0, skipped: [], via: {}, hosted: null }
70
+ // with an own text-evaluator key the vision evaluator uses an own key too, unless hosted: true says otherwise
71
+ if (vset && process.env[settings.api_key_env] && config.evaluators?.vision?.hosted !== true) vset.hosted = false
72
+ const vision = vset ? visionContext({ config, root, settings: vset, report, fetchImpl }) : null
49
73
  // checks against the design reference and the token set (pack design, #42) need the service and a browser
50
74
  const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
51
75
  try {
@@ -91,10 +115,12 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
91
115
  if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
92
116
  }
93
117
  }))
94
- for (const { entry, node, capture, current, outOfSample, request, jev } of work) {
118
+ // second opinion with the screenshot for flagged and uncertain criteria
119
+ if (vision) await vision.ask(work.filter((w) => w.current && !w.outOfSample && w.request), run.dir)
120
+ for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen } of work) {
95
121
  const source = evaluationSource(node, capture)
96
122
  if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
97
- let criteria = combine({ node, capture: current ? capture : {}, jev, thresholds: settings.thresholds })
123
+ let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
98
124
  // pages outside the semantic sample carry only the deterministic results
99
125
  if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
100
126
  const evaluation = {
@@ -115,16 +141,17 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
115
141
  thresholds: settings.thresholds,
116
142
  state_chars: request ? JSON.stringify(request.state).length : null,
117
143
  ...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
144
+ ...(seen ? { vision: seen.answers ? { provider: seen.provider, via: seen.via, model: seen.model, prompt_version: VISION_PROMPT_VERSION, cached: seen.cached, usage: seen.usage || null, answers: seen.answers } : { provider: seen.provider, skipped: seen.skipped || null, error: seen.error || null } } : {}),
118
145
  criteria
119
146
  }
120
147
  await writeJson(paths.step(stepIdOf(entry), entry.locale, entry.viewport, 'eval.json'), evaluation)
121
148
  report.evaluated++
122
149
  // what did not pass, for the walk summary and agents: step, locale, viewport, criterion and why
123
- for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes` : c.note) || null, ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
150
+ for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes${c.reason_source === 'model' ? `: ${c.reason}` : ''}` : c.note) || null, ...(c.reason ? { reason: c.reason } : {}), ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
124
151
  }
125
152
  }
126
- } finally { if (drift?.browser) await drift.browser.close() }
127
- const { hostedStop, ...out } = report
153
+ } finally { if (drift?.browser) await drift.browser.close(); await vision?.close() }
154
+ const { hostedStop, visionStop, ...out } = report
128
155
  return { ...out, outdated: [...report.outdated] }
129
156
  }
130
157
 
@@ -184,6 +211,61 @@ export function semanticPrefetch ({ scenario, config, root }) {
184
211
  }
185
212
  }
186
213
 
214
+ /**
215
+ * The vision step of an evaluation: picks the criteria per capture, cuts the images in a lazily started browser
216
+ * and asks, a few captures at a time, within `max_calls` uncached requests. Fills `w.vision` on each work item.
217
+ */
218
+ function visionContext ({ config, root, settings, report, fetchImpl }) {
219
+ const cacheDir = join(resolve(root, config.output), '.cache', 'vision')
220
+ const hosted = hostedVisionSender(config, report)
221
+ let browser = null; let pagePromise = null
222
+ const page = () => (pagePromise ||= (async () => {
223
+ const { launchEphemeral } = await import('./browser.mjs')
224
+ browser = await launchEphemeral(config)
225
+ const p = await browser.newPage()
226
+ await p.setContent('<!doctype html><title>vision</title>')
227
+ return p
228
+ })())
229
+ const allowCall = () => { if (report.vision.calls >= (settings.max_calls ?? 40)) return false; report.vision.calls++; return true }
230
+ // no key and no service token: nothing to ask, so no browser either
231
+ const unavailable = process.env[settings.api_key_env] ? null : settings.hosted === false ? `${settings.api_key_env} is not set` : !process.env[config.share?.token_env || 'KALOKO_TOKEN'] ? `${settings.api_key_env} is not set and the service is not configured (KALOKO_TOKEN)` : null
232
+ return {
233
+ async ask (work, runDir) {
234
+ const queue = []
235
+ for (const w of work) {
236
+ const semantic = (w.node.criteria || []).filter((c) => c.check.type === 'semantic' && w.request.questions[c.id])
237
+ const crit = visionCriteria({ criteria: semantic, answers: w.jev?.response?.answers, settings })
238
+ if (crit.length) queue.push({ w, crit })
239
+ }
240
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 3, queue.length)) }, async () => {
241
+ for (let job = queue.shift(); job; job = queue.shift()) {
242
+ const { w, crit } = job
243
+ const ids = crit.map((c) => c.id)
244
+ const file = w.capture.files?.image || w.capture.files?.png
245
+ report.vision.asked++
246
+ if (unavailable) { w.vision = { provider: settings.provider, skipped: unavailable, ids }; report.vision.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${unavailable}`); continue }
247
+ if (!file) { w.vision = { provider: settings.provider, skipped: 'no screenshot', ids }; continue }
248
+ if (report.visionStop && !process.env[settings.api_key_env]) { w.vision = { provider: settings.provider, skipped: report.visionStop, ids }; continue }
249
+ try {
250
+ const plan = visionPlan({ criteria: crit, capture: w.capture })
251
+ const { images, failed } = await prepareImages({ page: await page(), file: join(runDir, file), cssWidth: w.capture.size?.width, crops: plan.crops, full: plan.full, settings })
252
+ for (const q of plan.questions) if (failed.includes(q.id)) delete q.image
253
+ const payload = { context: { purpose: purposeOf(w.node, w.capture), url: w.capture.url, locale: w.capture.locale, viewport: w.entry.viewport }, questions: plan.questions, images }
254
+ const r = await askVision({ payload, settings, cacheDir, fetchImpl, hosted, allowCall })
255
+ w.vision = { provider: settings.provider, ids, ...r }
256
+ if (r.answers) { if (r.cached) report.vision.cached++; report.vision.via[r.via] = (report.vision.via[r.via] || 0) + 1; if (r.hosted) report.vision.hosted = r.hosted }
257
+ else report.vision.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${r.skipped || r.error}`)
258
+ } catch (error) {
259
+ w.vision = { provider: settings.provider, error: String(error.message || error).slice(0, 300), ids }
260
+ report.vision.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.vision.error}`)
261
+ }
262
+ }
263
+ }))
264
+ },
265
+ async close () { if (browser) await browser.close().catch(() => {}) }
266
+ }
267
+ }
268
+
187
269
  /**
188
270
  * Sends a JEV request through the organization's service (hosted evaluation) when a service token is set.
189
271
  * An allowance used up or a service without hosting stops further hosted calls for this run with one message.
@@ -0,0 +1,261 @@
1
+ // Keyboard and screen-reader checks of the a11y pack (#19), measured in the live page after the capture:
2
+ // a Tab walk (focus order, visible focus with contrast, traps, skip link) and accessible names from Chrome's
3
+ // accessibility tree (CDP). The page is left as it was found apart from focus: nothing is clicked or typed.
4
+ import { decodePng } from './png.mjs'
5
+
6
+ export const KEYBOARD_ASSERTS = new Set(['a11y_focus_order', 'a11y_focus_visible', 'a11y_keyboard_trap', 'a11y_skip_link', 'a11y_ax_names'])
7
+ export const needsKeyboard = (node) => (node?.criteria || []).some((c) => c.check?.type === 'deterministic' && KEYBOARD_ASSERTS.has(c.check.assert))
8
+
9
+ // In the page: the focused element (through shadow roots), where it is and what it is called.
10
+ function describeFocus () {
11
+ let el = document.activeElement
12
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement
13
+ if (!el || el === document.body || el === document.documentElement) return null
14
+ const path = (e) => { const p = []; for (; e && e.nodeType === 1; e = e.parentElement || e.getRootNode()?.host) { let i = 1; for (let s = e.previousElementSibling; s; s = s.previousElementSibling) i++; p.unshift(`${e.tagName.toLowerCase()}:${i}`) } return p.join('/') }
15
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim()
16
+ const name = clean(el.getAttribute('aria-label') || (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean).map((i) => document.getElementById(i)?.innerText || '').join(' ') || el.innerText || el.getAttribute('alt') || el.getAttribute('title') || el.value || el.getAttribute('placeholder') || [...el.querySelectorAll('img[alt]')].map((i) => i.alt).join(' '))
17
+ const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
18
+ let fixed = false
19
+ for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const p = getComputedStyle(e).position; if (p === 'fixed' || p === 'sticky') { fixed = true; break } }
20
+ const visible = r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && Number(cs.opacity) > 0.05 && r.right + scrollX > 0 && r.bottom + scrollY > 0
21
+ const inView = r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth
22
+ let obscured = false
23
+ if (visible && inView) {
24
+ const hit = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, r.left + r.width / 2)), Math.min(innerHeight - 1, Math.max(0, r.top + r.height / 2)))
25
+ obscured = !!hit && !(hit === el || el.contains(hit) || hit.contains(el) || (el.labels && [...el.labels].some((l) => l.contains(hit))))
26
+ }
27
+ const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
28
+ return {
29
+ key: path(el), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', name: name.slice(0, 60), href: el.getAttribute('href'),
30
+ desc: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${cls}`,
31
+ x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height),
32
+ visible, fixed, obscured, dialog: !!el.closest('dialog[open], [aria-modal=true]'), main: !!el.closest('main, [role=main]'),
33
+ tabindex: el.getAttribute('tabindex')
34
+ }
35
+ }
36
+
37
+ // In the page: focuses the element at the path describeFocus produced, centred in the viewport; its viewport box.
38
+ function focusByPath (key) {
39
+ let el = document.documentElement
40
+ for (const part of key.split('/').slice(1)) {
41
+ const i = Number(part.split(':')[1])
42
+ const kids = el.shadowRoot && !el.children.length ? el.shadowRoot.children : el.children
43
+ el = kids[i - 1]
44
+ if (!el) return null
45
+ }
46
+ el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.focus({ preventScroll: true })
47
+ const r = el.getBoundingClientRect()
48
+ return { x: r.left, y: r.top, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight }
49
+ }
50
+
51
+ // In the page: static facts for the order check (positive tabindex, controls taken out of the tab order, click-only).
52
+ function orderFacts () {
53
+ const visible = (e) => { const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' }
54
+ const d = (e) => `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}${typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''}`
55
+ const pos = [...document.querySelectorAll('[tabindex]')].filter((e) => Number(e.getAttribute('tabindex')) > 0 && visible(e))
56
+ const removed = [...document.querySelectorAll('a[href][tabindex^="-"], button[tabindex^="-"], input[tabindex^="-"]:not([type=hidden]), select[tabindex^="-"], textarea[tabindex^="-"]')].filter((e) => visible(e) && !e.closest('[aria-hidden=true], [inert]'))
57
+ const clickOnly = [...document.querySelectorAll('[role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [onclick]')].filter((e) => visible(e) && !e.matches('a[href], button, input, select, textarea, summary, [tabindex], [contenteditable=true]') && !e.closest('a[href], button'))
58
+ const box = (e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } }
59
+ return { positive: pos.slice(0, 5).map(d), positiveCount: pos.length, removed: removed.slice(0, 5).map(d), removedCount: removed.length, clickOnly: clickOnly.slice(0, 5).map(d), clickOnlyCount: clickOnly.length, boxes: [...removed, ...clickOnly].slice(0, 5).map(box), innerHeight }
60
+ }
61
+
62
+ // In the page: does an in-page link lead to the main content?
63
+ function skipTarget (href) {
64
+ const id = decodeURIComponent(String(href || '').slice(1))
65
+ if (!id) return null
66
+ const t = document.getElementById(id) || document.querySelector(`[name="${CSS.escape(id)}"]`)
67
+ if (!t) return { exists: false }
68
+ return { exists: true, main: t.matches('main, [role=main]') || !!t.closest('main, [role=main]') || !!t.querySelector('main, [role=main]') }
69
+ }
70
+
71
+ const lum = (r, g, b) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) }
72
+
73
+ /**
74
+ * How visible the focus indicator is: pixels that change between the unfocused and the focused screenshot of the
75
+ * same area, and how many of them change with at least 3:1 contrast (WCAG 2.4.7 / 2.4.13).
76
+ * @returns {{ changed: number, strong: number, contrast: number }} contrast: median of the changed pixels
77
+ */
78
+ export function focusDelta (focusedPng, plainPng) {
79
+ const a = decodePng(focusedPng); const b = decodePng(plainPng)
80
+ const w = Math.min(a.width, b.width); const h = Math.min(a.height, b.height)
81
+ let changed = 0; let strong = 0
82
+ const ratios = []
83
+ for (let y = 0; y < h; y++) {
84
+ for (let x = 0; x < w; x++) {
85
+ const i = (y * a.width + x) * 4; const j = (y * b.width + x) * 4
86
+ const d = Math.abs(a.data[i] - b.data[j]) + Math.abs(a.data[i + 1] - b.data[j + 1]) + Math.abs(a.data[i + 2] - b.data[j + 2])
87
+ if (d <= 48) continue
88
+ changed++
89
+ const l1 = lum(a.data[i], a.data[i + 1], a.data[i + 2]); const l2 = lum(b.data[j], b.data[j + 1], b.data[j + 2])
90
+ const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
91
+ if (ratio >= 3) strong++
92
+ if (ratios.length < 4000) ratios.push(ratio)
93
+ }
94
+ }
95
+ ratios.sort((p, q) => p - q)
96
+ return { changed, strong, contrast: ratios.length ? Math.round(ratios[ratios.length >> 1] * 10) / 10 : 1 }
97
+ }
98
+
99
+ /** Verdict of one focus stop from its delta and size: ok, weak (below 3:1), none (no visible change). */
100
+ export function focusVerdict (delta, w, h) {
101
+ const perimeter = 2 * (Math.max(1, w) + Math.max(1, h))
102
+ if (delta.strong >= perimeter * 0.75) return 'ok'
103
+ if (delta.changed >= perimeter * 0.5) return 'weak'
104
+ return 'none'
105
+ }
106
+
107
+ const label = (s) => `${s.desc}${s.name ? ` "${s.name.slice(0, 30)}"` : ''}`
108
+
109
+ /**
110
+ * Walks the page with Tab and fills the keyboard criteria of `criteria` ({ id, check }).
111
+ * opts: maxStops (60), focusChecks (stops checked for a visible indicator, 15), shots (focus screenshots, 5),
112
+ * saveShot(n, pngBuffer) → relative file path or null.
113
+ * @returns {{ checks: Record<string, {ok, evidence}>, boxes: Record<string, Array>, keyboard: object }}
114
+ */
115
+ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChecks = 15, shots = 5, saveShot = null } = {}) {
116
+ const want = new Map(criteria.filter((c) => c.check.type === 'deterministic' && KEYBOARD_ASSERTS.has(c.check.assert)).map((c) => [c.check.assert, c.id]))
117
+ const checks = {}; const boxes = {}
118
+ if (!want.size) return { checks, boxes, keyboard: null }
119
+ const set = (assert, ok, evidence, list) => { const id = want.get(assert); if (!id) return; checks[id] = { ok, evidence }; if (list?.length) boxes[id] = list.slice(0, 5) }
120
+ const keyboard = { stops: [], complete: false, total: 0, shots: [] }
121
+ const tabbing = [...want.keys()].some((a) => a !== 'a11y_ax_names')
122
+ try {
123
+ if (tabbing) {
124
+ // start from the top of the document: a temporary, invisible focus target before everything else
125
+ await page.evaluate(() => {
126
+ const a = document.activeElement; if (a && a !== document.body) a.blur()
127
+ scrollTo(0, 0)
128
+ const s = document.createElement('span'); s.id = '__kaloko_start'; s.tabIndex = -1; s.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;overflow:hidden'
129
+ document.body.prepend(s); s.focus({ preventScroll: true })
130
+ })
131
+ const stops = []; const seen = new Map()
132
+ let trap = null; let iframeRun = 0
133
+ for (let i = 0; i < maxStops; i++) {
134
+ await page.keyboard.press('Tab')
135
+ if (i === 0) await page.evaluate(() => document.getElementById('__kaloko_start')?.remove()).catch(() => {})
136
+ const f = await page.evaluate(describeFocus).catch(() => null)
137
+ if (!f) { keyboard.complete = true; break } // focus went back to the browser: the whole page was walked
138
+ if (stops.length && f.key === stops[0].key) { keyboard.complete = true; break }
139
+ if (f.tag === 'iframe') { if (stops.at(-1)?.key === f.key) { if (++iframeRun > 30) break; continue } } else iframeRun = 0
140
+ if (seen.has(f.key)) { const from = seen.get(f.key); const cycle = stops.slice(from); trap = { at: from, size: cycle.length, dialog: cycle.every((s) => s.dialog), first: cycle[0] }; break }
141
+ seen.set(f.key, stops.length)
142
+ stops.push(f)
143
+ }
144
+ keyboard.total = stops.length
145
+
146
+ // order: hidden stops, big jumps back up the page, positive tabindex, controls left out of the tab order
147
+ if (want.has('a11y_focus_order')) {
148
+ const facts = await page.evaluate(orderFacts)
149
+ const hidden = stops.filter((s) => !s.visible)
150
+ const jumps = []
151
+ for (let i = 1; i < stops.length; i++) {
152
+ const a = stops[i - 1]; const b = stops[i]
153
+ if (a.visible && b.visible && !a.fixed && !b.fixed && b.y + b.h < a.y - facts.innerHeight) jumps.push(`${i}→${i + 1} ${label(b)} jumps ${a.y - b.y} px up`)
154
+ }
155
+ const problems = [
156
+ ...(hidden.length ? [`focus lands on ${hidden.length} invisible element(s): ${hidden.slice(0, 3).map(label).join(', ')}`] : []),
157
+ ...(jumps.length ? [`order jumps back: ${jumps.slice(0, 2).join('; ')}`] : []),
158
+ ...(facts.positiveCount ? [`positive tabindex reorders the page: ${facts.positive.join(', ')}`] : []),
159
+ ...(facts.removedCount ? [`${facts.removedCount} visible control(s) taken out of the tab order: ${facts.removed.join(', ')}`] : []),
160
+ ...(facts.clickOnlyCount ? [`${facts.clickOnlyCount} control(s) reachable only by mouse: ${facts.clickOnly.join(', ')}`] : [])
161
+ ]
162
+ const seq = stops.slice(0, 8).map((s, i) => `${i + 1} ${label(s)}`).join(' → ')
163
+ set('a11y_focus_order', problems.length === 0, problems.length ? problems.join('; ') : `${stops.length} tab stop(s)${keyboard.complete ? '' : ` (first ${maxStops} checked)`} in reading order: ${seq || 'no focusable elements'}`, [...hidden.filter((s) => s.w && s.h), ...facts.boxes])
164
+ }
165
+
166
+ if (want.has('a11y_keyboard_trap')) {
167
+ if (trap && !trap.dialog) set('a11y_keyboard_trap', false, `Tab keeps cycling through ${trap.size} element(s) from ${label(trap.first)}; the rest of the page cannot be reached`, stops.slice(trap.at, trap.at + 5))
168
+ else if (trap) set('a11y_keyboard_trap', true, `an open modal dialog keeps focus inside (${trap.size} element(s)); that is expected while it is open`)
169
+ else set('a11y_keyboard_trap', true, keyboard.complete ? `Tab goes through all ${stops.length} stop(s) and leaves the page` : `no trap in the first ${stops.length} tab stops`)
170
+ }
171
+
172
+ if (want.has('a11y_skip_link')) {
173
+ let ok = null; let evidence = 'nothing on the page takes keyboard focus'
174
+ if (stops.length) {
175
+ const first = stops.slice(0, 3)
176
+ let skip = null
177
+ for (const s of first) {
178
+ if (!s.href || !s.href.startsWith('#') || s.href.length < 2) continue
179
+ const t = await page.evaluate(skipTarget, s.href).catch(() => null)
180
+ if (t?.exists && (t.main || /skip|jump|přeskoč|preskoč|obsah|content|main|hlavní/i.test(s.name))) { skip = { s, t }; break }
181
+ if (t && !t.exists && /skip|přeskoč|obsah|content|main/i.test(s.name)) { skip = { s, t, broken: true }; break }
182
+ }
183
+ const mainAt = stops.findIndex((s) => s.main)
184
+ if (skip && !skip.broken) { ok = true; evidence = `skip link ${label(skip.s)} → ${skip.s.href}` }
185
+ else if (skip?.broken) { ok = false; evidence = `skip link ${label(skip.s)} points to ${skip.s.href}, which does not exist` }
186
+ else if (mainAt >= 0 && mainAt < 3) { ok = true; evidence = `main content is reached at tab stop ${mainAt + 1}; no skip link needed` }
187
+ else if (mainAt < 0 && !stops.some((s) => s.main)) {
188
+ const hasMain = await page.evaluate(() => !!document.querySelector('main, [role=main]'))
189
+ ok = !hasMain && stops.length <= 3 ? true : false
190
+ evidence = ok ? `only ${stops.length} tab stop(s) on the page` : hasMain ? `no skip link; main content has no focusable element in the first ${stops.length} stops` : 'no skip link and no main landmark to skip to'
191
+ } else { ok = false; evidence = `no skip link; main content is first reached at tab stop ${mainAt + 1}, after ${mainAt} stop(s) of header and navigation` }
192
+ }
193
+ set('a11y_skip_link', ok, evidence)
194
+ }
195
+
196
+ // visible focus: each stop focused and unfocused, the same area compared (keyboard modality stays on)
197
+ const verdicts = new Map()
198
+ if (want.has('a11y_focus_visible') || shots > 0) {
199
+ const candidates = stops.map((s, i) => ({ s, n: i + 1 })).filter(({ s }) => s.visible && s.tag !== 'iframe').slice(0, Math.max(want.has('a11y_focus_visible') ? focusChecks : 0, shots))
200
+ for (const { s, n } of candidates) {
201
+ const pos = await page.evaluate(focusByPath, s.key).catch(() => null)
202
+ if (!pos || !pos.w || !pos.h) continue
203
+ const clipOf = (pad) => { const x = Math.max(0, Math.floor(pos.x - pad)); const y = Math.max(0, Math.floor(pos.y - pad)); return { x, y, width: Math.max(1, Math.min(pos.vw, Math.ceil(pos.x + pos.w + pad)) - x), height: Math.max(1, Math.min(pos.vh, Math.ceil(pos.y + pos.h + pad)) - y) } }
204
+ const clip = clipOf(6)
205
+ if (clip.width < 2 || clip.height < 2) continue
206
+ const focused = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
207
+ if (keyboard.shots.length < shots && saveShot) {
208
+ const file = await saveShot(n, await page.screenshot({ clip: clipOf(48), scale: 'css', animations: 'disabled', caret: 'hide' })).catch(() => null)
209
+ if (file) keyboard.shots.push({ n, file, name: s.name || s.desc })
210
+ }
211
+ if (!want.has('a11y_focus_visible') || verdicts.size >= focusChecks) continue
212
+ await page.evaluate(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; a?.blur?.() })
213
+ const plain = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
214
+ const delta = focusDelta(focused, plain)
215
+ verdicts.set(n, { v: s.obscured ? 'obscured' : focusVerdict(delta, pos.w, pos.h), contrast: delta.contrast })
216
+ }
217
+ if (want.has('a11y_focus_visible')) {
218
+ const bad = [...verdicts].filter(([, x]) => x.v !== 'ok')
219
+ const say = ([n, x]) => { const s = stops[n - 1]; return x.v === 'none' ? `${n} ${label(s)}: no visible change on focus` : x.v === 'weak' ? `${n} ${label(s)}: indicator contrast ${x.contrast}:1 (needs 3:1)` : `${n} ${label(s)}: covered by another element when focused` }
220
+ set('a11y_focus_visible', verdicts.size ? bad.length === 0 : null, !verdicts.size ? 'no visible focus stop to check' : bad.length ? `${bad.length} of ${verdicts.size} focus stop(s) without a clear indicator: ${bad.slice(0, 4).map(say).join('; ')}` : `all ${verdicts.size} checked focus stop(s) show an indicator with at least 3:1 contrast`, bad.map(([n]) => stops[n - 1]))
221
+ }
222
+ }
223
+ keyboard.stops = stops.slice(0, 40).map((s, i) => ({ n: i + 1, x: s.x, y: s.y, w: s.w, h: s.h, name: s.name, el: s.desc, ...(s.visible ? {} : { hidden: true }), ...(verdicts.has(i + 1) ? { focus: verdicts.get(i + 1).v, contrast: verdicts.get(i + 1).contrast } : {}) }))
224
+ // leave the page as found: nothing focused, at the top
225
+ await page.evaluate(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; a?.blur?.(); scrollTo(0, 0) }).catch(() => {})
226
+ }
227
+ if (want.has('a11y_ax_names')) {
228
+ const r = await axNames(page)
229
+ set('a11y_ax_names', r.ok, r.evidence, r.boxes)
230
+ }
231
+ } catch (error) {
232
+ for (const id of want.values()) if (!checks[id]) checks[id] = { ok: null, evidence: `keyboard check error: ${String(error.message).split('\n')[0]}` }
233
+ }
234
+ return { checks, boxes, keyboard: tabbing ? keyboard : null }
235
+ }
236
+
237
+ const INTERACTIVE = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'listbox', 'treeitem'])
238
+
239
+ /** Interactive nodes of Chrome's accessibility tree without a name (what a screen reader announces). */
240
+ export async function axNames (page) {
241
+ let cdp
242
+ try { cdp = await page.context().newCDPSession(page) } catch { return { ok: null, evidence: 'the accessibility tree needs Chrome (CDP)' } }
243
+ try {
244
+ const { nodes } = await cdp.send('Accessibility.getFullAXTree')
245
+ const controls = nodes.filter((n) => !n.ignored && INTERACTIVE.has(n.role?.value))
246
+ const bad = controls.filter((n) => !String(n.name?.value || '').trim())
247
+ const boxes = []; const names = []
248
+ for (const n of bad.slice(0, 5)) {
249
+ try {
250
+ const { object } = await cdp.send('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId })
251
+ const { result } = await cdp.send('Runtime.callFunctionOn', { objectId: object.objectId, returnByValue: true, functionDeclaration: 'function () { const r = this.getBoundingClientRect(); const c = typeof this.className === "string" && this.className.trim() ? "." + this.className.trim().split(/\\s+/).slice(0, 2).join(".") : ""; return { d: this.tagName.toLowerCase() + (this.id ? "#" + this.id : "") + c, x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } }' })
252
+ const v = result.value
253
+ names.push(`${v.d} (${n.role.value})`)
254
+ if (v.w && v.h) boxes.push({ x: v.x, y: v.y, w: v.w, h: v.h })
255
+ } catch { names.push(n.role.value) }
256
+ }
257
+ return bad.length
258
+ ? { ok: false, evidence: `${bad.length} of ${controls.length} control(s) have no name in the accessibility tree: ${names.join(', ')}${bad.length > 5 ? ` (+${bad.length - 5})` : ''}`, boxes }
259
+ : { ok: true, evidence: `all ${controls.length} control(s) in the accessibility tree have a name`, boxes }
260
+ } finally { await cdp.detach().catch(() => {}) }
261
+ }
@@ -38,10 +38,14 @@ export function slimCapture (cap) {
38
38
  overflow: cap.overflow || [], meta: cap.meta, mail: cap.mail || null, metadata: cap.metadata || null, ...(cap.view ? { view: cap.view } : {}),
39
39
  ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.platform ? { platform: cap.platform, device: cap.device || null } : {}),
40
40
  ...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
41
- ...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {})
41
+ ...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {}),
42
+ ...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {})
42
43
  }
43
44
  }
44
45
 
46
+ /** The keyboard walk of a capture as the canvas draws it: tab stops (numbered boxes) and the focus screenshots. */
47
+ export const keyboardOf = (k) => k ? { complete: !!k.complete, total: k.total ?? (k.stops || []).length, stops: (k.stops || []).slice(0, 40), shots: (k.shots || []).slice(0, 10) } : null
48
+
45
49
  /**
46
50
  * One manifest node from its scenario node and captures.
47
51
  * captures: [{ locale, viewport, cap (capture record or slimCapture), ev (valid evaluation or null),
@@ -65,11 +69,15 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
65
69
  ...(cap.capture && (cap.capture.scale !== 1 || cap.capture.format === 'png' || cap.capture.masked || cap.capture.annotations?.length) ? { quality: cap.capture } : {}), size: [cap.size.width, cap.size.height],
66
70
  boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 20), blocked_list: (cap.blocked_requests || []).slice(0, 20),
67
71
  blocked: (cap.blocked_requests || []).length, overflow: (cap.overflow || []).length, meta: cap.meta,
68
- mail: cap.mail || null, eval: ev ? c.files.eval : null,
72
+ mail: cap.mail || null, eval: ev ? c.files.eval : null, ...(c.files.spec ? { spec: c.files.spec } : {}),
69
73
  // quick signals from the evaluator (probabilities: purpose met, expected language, looks broken)
70
74
  ...(ev?.signals ? { signals: ev.signals } : {}),
71
75
  ...(c.live ? { live: c.live } : {}),
72
- ...(cap.view?.revision ? { view: cap.view } : {})
76
+ ...(cap.view?.revision ? { view: cap.view } : {}),
77
+ // regions left out of pixel diffs (#20), regions that moved between repeated shots, the keyboard walk (#19)
78
+ ...(cap.ignore?.length ? { ignore: cap.ignore.slice(0, 50) } : {}),
79
+ ...(cap.unstable?.regions?.length ? { unstable: cap.unstable.regions.slice(0, 50) } : {}),
80
+ ...(cap.keyboard ? { keyboard: keyboardOf(cap.keyboard) } : {})
73
81
  }
74
82
  if (ev?.semantic || ev?.jev) semanticByKey[`${c.locale}|${c.viewport}`] = ev.semantic || ev.jev
75
83
  if (cap.metadata && !metadata[c.locale]) metadata[c.locale] = cap.metadata
@@ -80,11 +88,14 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
80
88
  const loc = (agg.by_locale[c.locale] ||= {})
81
89
  const score = r.score ?? r.jev?.noul ?? r.noul ?? null
82
90
  // semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
83
- loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && ev.semantic?.via ? { via: ev.semantic.via } : {}) }
91
+ // semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
92
+ const seen = r.provider && /-vision$/.test(r.provider)
93
+ loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
84
94
  if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
95
+ if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
85
96
  }
86
97
  }
87
- const criteria = [...perCriterion.values()].map((agg) => {
98
+ const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
88
99
  for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
89
100
  const verdicts = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp].verdict))
90
101
  return { ...agg, verdict: aggregate(verdicts) }
@@ -0,0 +1,33 @@
1
+ // What `kaloko init` and `kaloko login` print next: the organization's sample run (every new organization gets one,
2
+ // a reviewed checkout to try the canvas on) and the way to a first real flow. No service call: the sample link is a
3
+ // stable address that opens the organization's sample run, or its home page when there is none.
4
+
5
+ export const FIRST_FLOW_PROMPT = 'Use the Kaloko skill: draft a scenario for the main flow of this app on the local environment, walk it in every locale on desktop and mobile, share it and send me the canvas link.'
6
+
7
+ /** The organization slug from a config, unless it is still the template's placeholder. */
8
+ export const realOrg = (org) => (typeof org === 'string' && /^[a-z0-9][a-z0-9-]{1,40}$/.test(org) && org !== 'my-org' ? org : null)
9
+
10
+ /** Address of the organization's sample run on the service. */
11
+ export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${org}/sample`
12
+
13
+ /** Lines printed at the end of `kaloko init`. */
14
+ export function initNextSteps ({ base, org, loggedIn = false }) {
15
+ const o = realOrg(org)
16
+ return [
17
+ 'Next:',
18
+ loggedIn ? ' 1. Signed in already (KALOKO_TOKEN found).' : ' 1. npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)',
19
+ o ? ` 2. Try a review on the sample run: ${sampleUrl(base, o)}` : ' 2. Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.',
20
+ ' 3. Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:',
21
+ ` "${FIRST_FLOW_PROMPT}"`
22
+ ]
23
+ }
24
+
25
+ /** Lines printed after a successful `kaloko login`. */
26
+ export function loginNextSteps ({ base, org }) {
27
+ const o = realOrg(org)
28
+ if (!o) return []
29
+ return [
30
+ `Sample run to try a review on: ${sampleUrl(base, o)}`,
31
+ 'First real flow: npx kaloko draft <your app URL>, or ask your agent to walk the main flow and share it.'
32
+ ]
33
+ }
@@ -25,6 +25,13 @@ export const PACK_HELP = {
25
25
  AC305: { cs: 'Text splňuje kontrast WCAG AA', why: ['Low contrast text is hard to read in sunlight and for people with weaker sight.', 'Text s nízkým kontrastem se špatně čte na slunci i lidem se slabším zrakem.'], fix: ['Darken the text or lighten the background to at least 4.5 : 1 (3 : 1 for large text).', 'Ztmavte text nebo zesvětlete pozadí aspoň na 4,5 : 1 (u velkého textu 3 : 1).'] },
26
26
  AC306: { cs: 'Stránka má hlavní oblast (main)', why: ['Screen reader users jump straight to <main> to skip the navigation.', 'Uživatelé čteček skáčou rovnou na <main>, aby přeskočili navigaci.'], fix: ['Wrap the main content in <main> (once per page).', 'Hlavní obsah obalte do <main> (jednou na stránce).'] },
27
27
  AC307: { cs: 'Id prvků jsou jedinečná', why: ['Duplicate ids break labels, anchors and scripts that look elements up by id.', 'Duplicitní id rozbijí popisky, kotvy i skripty, které prvky hledají podle id.'], fix: ['Rename the repeated ids listed in the evidence (often from a copied component).', 'Přejmenujte opakovaná id z důkazu (často z kopírované komponenty).'] },
28
+ AC308: { cs: 'Pořadí tabulátoru odpovídá stránce a dojde ke každému prvku', why: ['People who use a keyboard move with Tab; focus that jumps around or skips a button leaves them lost or stuck.', 'Kdo ovládá web klávesnicí, posouvá se tabulátorem; když fokus skáče nebo tlačítko vynechá, ztratí se nebo nemůže dál.'], fix: ['Keep the DOM in reading order, remove positive tabindex, and use real buttons and links instead of clickable divs.', 'Držte DOM v pořadí čtení, odstraňte kladný tabindex a místo klikacích divů použijte skutečná tlačítka a odkazy.'] },
29
+ AC309: { cs: 'Fokus klávesnice je dobře vidět (kontrast aspoň 3 : 1)', why: ['Without a visible focus ring, keyboard users do not know where they are on the page.', 'Bez viditelného rámečku fokusu uživatel klávesnice neví, kde na stránce je.'], fix: ['Do not remove outline; style :focus-visible with an outline of at least 2 px that contrasts 3 : 1 with its surroundings.', 'Neodstraňujte outline; nastavte :focus-visible s obrysem aspoň 2 px a kontrastem 3 : 1 vůči okolí.'] },
30
+ AC310: { cs: 'Klávesnice nikde neuvízne', why: ['A widget that keeps Tab inside (an embed, a custom menu) traps keyboard users for good.', 'Prvek, který tabulátor nepustí ven (vložený přehrávač, vlastní menu), uživatele klávesnice uvězní.'], fix: ['Let Tab leave the widget; trap focus only in an open modal dialog that closes with Esc.', 'Nechte tabulátor z prvku odejít; fokus držte jen v otevřeném modálním okně, které zavře Esc.'] },
31
+ AC311: { cs: 'Odkaz pro přeskočení na obsah nebo rychlá cesta k němu', why: ['Without a skip link, keyboard and screen reader users tab through the whole menu on every page.', 'Bez odkazu „Přeskočit na obsah“ musí uživatelé klávesnice a čteček na každé stránce protabovat celé menu.'], fix: ['Add <a href="#main" class="skip-link">Skip to content</a> as the first element and show it on focus.', 'Jako první prvek přidejte <a href="#main" class="skip-link">Přeskočit na obsah</a> a při fokusu ho zobrazte.'] },
32
+ AC312: { cs: 'Ovládací prvky mají název ve stromu přístupnosti', why: ['The accessibility tree is what screen readers read; a control without a name there is announced as just "button".', 'Strom přístupnosti je to, co čtečky skutečně čtou; prvek bez názvu ohlásí jen jako „tlačítko“.'], fix: ['Give the control visible text, aria-label or aria-labelledby; check that the label is not hidden with aria-hidden.', 'Dejte prvku viditelný text, aria-label nebo aria-labelledby; ověřte, že popisek neskrývá aria-hidden.'] },
33
+ AC313: { cs: 'Orientační oblasti jsou úplné a pojmenované', why: ['Screen reader users jump between header, navigation, main and footer; duplicates without names all sound the same.', 'Uživatelé čteček skáčou mezi hlavičkou, navigací, obsahem a patičkou; neoznačené duplicity znějí všechny stejně.'], fix: ['Use one <main>, one page <header> and <footer>, and give repeated <nav> or <aside> an aria-label.', 'Použijte jeden <main>, jednu hlavičku a patičku stránky a opakovaným <nav> či <aside> dejte aria-label.'] },
34
+ AC314: { cs: 'Role a atributy ARIA jsou platné', why: ['Wrong ARIA is worse than none: screen readers trust it and announce things that are not there.', 'Chybná ARIA je horší než žádná: čtečky jí věří a ohlašují, co na stránce není.'], fix: ['Fix the listed role or attribute, point aria-labelledby to an existing id, and never put aria-hidden on focusable content.', 'Opravte uvedenou roli nebo atribut, aria-labelledby nasměrujte na existující id a aria-hidden nikdy nedávejte na prvky s fokusem.'] },
28
35
  AC401: { cs: 'Největší obsah se vykreslí do 2,5 s', why: ['LCP is how long people wait before the page looks ready; Google uses it for ranking.', 'LCP je doba, než stránka působí hotově; Google ho používá při řazení.'], fix: ['Make the hero image smaller (WebP/AVIF, right size), preload it, and cut render-blocking scripts.', 'Zmenšete hlavní obrázek (WebP/AVIF, správná velikost), přednačtěte ho a omezte blokující skripty.'] },
29
36
  AC402: { cs: 'Rozvržení se při načítání nehýbe (CLS pod 0,1)', why: ['Content that jumps while loading makes people tap the wrong thing.', 'Obsah, který při načítání poskakuje, svádí k ťuknutí vedle.'], fix: ['Give images and embeds width and height, and reserve space for banners and fonts.', 'Dejte obrázkům a vloženým prvkům šířku a výšku a rezervujte místo pro bannery a písma.'] },
30
37
  AC403: { cs: 'Server odpoví do 800 ms', why: ['A slow first byte delays everything else on the page.', 'Pomalý první bajt zdrží všechno ostatní na stránce.'], fix: ['Cache the page (CDN, full-page cache) or speed up the backend work behind it.', 'Stránku kešujte (CDN, cache celé stránky) nebo zrychlete práci backendu za ní.'] },