kaloko 0.6.0 → 0.7.1

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 CHANGED
@@ -8,8 +8,8 @@ e-mail of the flow, checks the criteria and lays the whole flow out on a
8
8
  canvas: screens, branches, locales and evidence. People review and approve it
9
9
  there, and the newest captures double as documentation.
10
10
 
11
- - Product and guides: <https://kaloko.app>
12
- - Service (organizations, shared canvases, reviews): <https://kaloko.app>
11
+ - Product, pricing and the service (organizations, shared canvases, reviews): <https://kaloko.app>
12
+ - Guides: <https://kaloko.app/guides>
13
13
 
14
14
  Pages are walked in Chromium, WebKit and Firefox, on phone, tablet and
15
15
  desktop presets, in light and dark mode. Native apps too: Android, iOS,
@@ -201,6 +201,18 @@ what still waits for a person, so it can pass the link on. Agents never
201
201
  approve for people: their decisions are marked and do not count as a
202
202
  person's.
203
203
 
204
+ To see what moved, compare a step with the accepted run side by side, as a
205
+ pixel diff, with *Swipe* or with *Onion skin*; `G` shows one step in every
206
+ locale and viewport at once. Comments can carry a box, an arrow or a line
207
+ drawn on the screenshot. On Business and Enterprise an accepted run has a
208
+ signed acceptance record (who approved what and when, criteria results,
209
+ screenshot hashes) as PDF and JSON:
210
+
211
+ ```bash
212
+ npx kaloko signoff <run-id> --out handover/ # download and check the record
213
+ npx kaloko signoff --verify record.pdf # check one someone handed you; no token needed
214
+ ```
215
+
204
216
  Every AI verdict comes with one sentence saying why. Criteria marked
205
217
  `vision: true`, and answers the text evaluator is unsure about, get a second
206
218
  look at the screenshot from a vision model (`ANTHROPIC_API_KEY`, or through
@@ -208,7 +220,8 @@ Kaloko); `kaloko build` lists what to act on with the score and the reason.
208
220
  The `a11y` pack walks the page with Tab (order, visible focus, traps, skip
209
221
  link) and reads names from the accessibility tree. Pixel diffs skip the
210
222
  regions a step ignores; `kaloko walk --stability 3` and `kaloko stability`
211
- find the clocks, ads and carousels worth ignoring.
223
+ find the clocks, ads and carousels worth ignoring, and `kaloko stability --pull`
224
+ brings in the regions reviewers marked on kaloko.app.
212
225
 
213
226
  ## A changelog with pictures
214
227
 
@@ -236,7 +249,9 @@ kaloko.app has a slider between before and after, switches language and
236
249
  screen size, and links each picture to its flow on the canvas. Share it with
237
250
  guests, readers from your company's domain, or publish it with a feed.
238
251
  Repositories with several apps list them under `products:` in
239
- `kaloko.config.yml`.
252
+ `kaloko.config.yml`; `npx kaloko products sync` puts them on Kaloko before the
253
+ first release, and Products in the app's menu lists each with its newest
254
+ version, changelog and docs (`npx kaloko products list` in the terminal).
240
255
 
241
256
  When release-please, changesets or semantic-release makes your releases,
242
257
  `npx kaloko ci github --release` adds a workflow that records each published
@@ -274,12 +289,13 @@ back to the step. When it is done in the tracker, the comment on the step is
274
289
  resolved. A branch or pull request title that names the issue (`WEB-12`, or
275
290
  Linear's `eng-42`) links the run to it. Slack and Teams channels are chosen per
276
291
  project in Settings → Integrations, with the events each channel wants and
277
- quiet hours.
292
+ quiet hours. Both come with the Team plan and up.
278
293
 
279
294
  ## Pages checked on a schedule
280
295
 
281
296
  A read-only scenario of public or gated pages can run on Kaloko's own
282
- browsers every morning, with nobody starting the CLI (Business plan):
297
+ browsers every morning, with nobody starting the CLI (Business: 2,000 pages a
298
+ month, Enterprise: 10,000):
283
299
 
284
300
  ```bash
285
301
  npx kaloko schedule add --scenario qa/flows/public.yml --env production --every day --at 5
@@ -297,8 +313,8 @@ Design the screens of a flow as live HTML with your own agent and skills, in the
297
313
  and QA use. A design environment (`kind: design`, a folder of steps) is walked and synced into a shared draft the
298
314
  team sees live; `kaloko share` there publishes a version of the flow (unchanged steps are stored once). An
299
315
  accepted version becomes the design reference, and `packs: [design]` checks every implementation run against
300
- it (pixels, structure, tokens, contrast). See `skills/kaloko/reference/design.md`, `docs/GLOSSARY.md` and
301
- `docs/SURFACES.md`.
316
+ it (pixels, structure, tokens, contrast). Details: `skills/kaloko/reference/design.md` in the package and the
317
+ [design guides](https://kaloko.app/guides/design-with-your-agent).
302
318
 
303
319
  ```bash
304
320
  npx kaloko start --scenario qa/flows/checkout.yml --env design
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -76,7 +76,9 @@ with `--theirs`). Move to the trash runs you uploaded in this session that
76
76
  were superseded before anyone looked at them (debug rounds: no comments, no
77
77
  verdict), and run `kaloko prune` without `--yes` to see what a clean-up would do.
78
78
  Draft and edit the Unreleased section of the changelog (`kaloko changelog draft
79
- --apply`) and add step references to it and to guides. Write, translate and fix
79
+ --apply`) and add step references to it and to guides. Put the products of the
80
+ config on Kaloko (`kaloko products sync`: it records no release) and list them
81
+ (`kaloko products list`). Write, translate and fix
80
82
  guides in the docs folder, run `kaloko docs check`, and export them for a docs
81
83
  site (`kaloko export --format …`) into a local folder.
82
84
 
@@ -118,13 +120,12 @@ they can do; never write secrets into the config, a scenario or a script.
118
120
  - Many pages of one template in one step (articles, products, an SEO
119
121
  catalogue from a sitemap or a listing), with an index on the canvas:
120
122
  [reference/stacks.md](reference/stacks.md)
121
- - Choosing playwright, agent or manual per step, and driving the shared
122
- browser with your capabilities: [reference/execution.md](reference/execution.md)
123
- - Evaluation, thresholds, the vision evaluator and its reasons, reading
124
- results: [reference/evaluation.md](reference/evaluation.md)
123
+ - Choosing playwright, agent or manual per step, driving the shared
125
124
  browser with your capabilities, walking only the steps a change reaches
126
125
  (`--affected`), several locales at once, and selectors that moved
127
126
  (`--heal`): [reference/execution.md](reference/execution.md)
127
+ - Evaluation, JEV thresholds, the vision evaluator and its reasons, reading
128
+ results: [reference/evaluation.md](reference/evaluation.md)
128
129
  - Screenshots, walkthroughs, Playwright reports and traces that an agent or a
129
130
  tool already produced, laid out as a run: [reference/import.md](reference/import.md)
130
131
  - Video of a step or a whole flow, traces (actions, requests, console) and the
@@ -132,12 +133,12 @@ they can do; never write secrets into the config, a scenario or a script.
132
133
  [reference/recording.md](reference/recording.md)
133
134
  - CI: a workflow that walks each pull request's preview deployment and
134
135
  comments on it (`kaloko ci github|gitlab`): [reference/ci.md](reference/ci.md)
135
- - Evaluation, JEV thresholds and reading results: [reference/evaluation.md](reference/evaluation.md)
136
136
  - Environments, access (gates, accounts, sign-in), e-mails, readonly and
137
137
  production rules, and what is yours to arrange for the project's stack:
138
138
  [reference/environments.md](reference/environments.md)
139
139
  - Sharing, review mode and approvals carried over unchanged steps, reviewer
140
- feedback, mentions, inbox, verdicts and baselines, and cleaning up runs
140
+ feedback, mentions, inbox, verdicts and baselines, compare views, the
141
+ signed acceptance record (`kaloko signoff`), and cleaning up runs
141
142
  (trash, prune, keep, archive):
142
143
  [reference/collaboration.md](reference/collaboration.md)
143
144
  - Reading runs, feedback and the inbox over MCP instead of the CLI:
@@ -149,7 +150,7 @@ they can do; never write secrets into the config, a scenario or a script.
149
150
  - The visual changelog and guides with step references (`kaloko:<scenario>/<step>`):
150
151
  drafting the Unreleased entry, Diátaxis templates, translations, stale sections
151
152
  and `kaloko docs check`, training guides, exports, release tools in CI,
152
- products, modules and versions, who reads it:
153
+ products, modules and versions (`kaloko products sync|list`), who reads it:
153
154
  [reference/docs.md](reference/docs.md)
154
155
  - Refreshing a manual, course or product documentation from the newest
155
156
  captures (latest URLs, changed steps, webhooks, export):
@@ -231,6 +231,13 @@ reads its entry and never rewrites the file; put your draft into the pull reques
231
231
 
232
232
  `kaloko doctor` warns when a product's version cannot be read or a scenario names no product while several exist.
233
233
 
234
+ A product shows in the app (Products in the main menu, `/<org>/products`) once it is on Kaloko. `kaloko release` and
235
+ `kaloko docs publish` put it there; to put the products of the config there before their first release, run
236
+ `kaloko products sync` (`--product p` for one, `--dry-run` to see what it would send). It records no release, and
237
+ `publish:` still needs an admin of the product's projects. `kaloko products list` shows the products on Kaloko with
238
+ their newest version and docs, and names those of the config that are not there yet. After adding or renaming a
239
+ product in the config, run `kaloko products sync` and say so in your summary.
240
+
234
241
  ## Readers
235
242
 
236
243
  The changelog page (`/<org>/products/<product>/changelog`, or `/<org>/projects/<project>/changelog`) and the docs
@@ -241,6 +248,6 @@ page. Never publish without the person asking.
241
248
 
242
249
  ## MCP
243
250
 
244
- `get_changelog` (releases with entries; `resolve: true` adds the pictures), `draft_changelog_entry` (the service's
251
+ `list_products` (the products the caller reads, with the newest release, docs and readers), `get_changelog` (releases with entries; `resolve: true` adds the pictures), `draft_changelog_entry` (the service's
245
252
  half of a draft: changed screens with before/after references), `resolve_step_image` (one reference),
246
253
  `docs_check` (what needs attention in the published docs), `get_docs` (the docs, or one page).
@@ -33,6 +33,7 @@ file.
33
33
  | `decide_step` | records your (the agent's) decision on a step: `approve`, or `return` with a `note`; marked as an agent's and not counted as a person's. Never use it to approve for a person |
34
34
  | `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
35
35
  | `get_step_assets` | documentation layer (Business): newest capture of every step of a scenario with stable image, HTML and metadata URLs (`scenario`, `step`, `locale`, `viewport`, `accepted`) |
36
+ | `list_products` | the products the caller reads: name, modules, the projects it maps that the caller sees, the newest release, published docs (versions, languages), who reads it (and the public link), whether the caller manages it, links to its page, changelog and docs |
36
37
  | `get_changelog` | the visual changelog of a product (`product`, `version`, `resolve: true` for the pictures, `limit`): releases newest first with the entry as written and its groups; see [docs.md](docs.md) |
37
38
  | `draft_changelog_entry` | what accepted runs since the last release (or `since`) show differently, as a Keep a Changelog draft with before/after step references (`product`, `since`) |
38
39
  | `resolve_step_image` | one step reference (`ref` = `kaloko:<scenario>/<step>?…`, `product`, `version`, `locale`, `viewport`) as a signed image URL with its run, how the version was found and canvas links |
package/src/cli.mjs CHANGED
@@ -259,6 +259,15 @@ Hosted runs (read-only scenarios on Kaloko's browsers, Business plan)
259
259
  schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--out file] [--print]
260
260
  A GitHub Actions workflow that walks the scenario on a schedule with the full CLI
261
261
 
262
+ Products (products: in kaloko.config.yml; without it the project is the product)
263
+ products sync [--product p] [--dry-run] [--json]
264
+ Put the products of the config on Kaloko: name, projects, modules, version
265
+ source, reference environment and default scenario, without recording a
266
+ release. They show under Products in the app with their changelog and docs;
267
+ publish: is applied only for an admin of the product's projects
268
+ products list [--json] The products on Kaloko you read: newest release, docs, who reads them, and
269
+ the products of the config that are not on Kaloko yet
270
+
262
271
  Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
263
272
  changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
264
273
  The Unreleased entry since the last release: merged pull requests and commits
@@ -970,7 +979,7 @@ const commands = {
970
979
  try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
971
980
  const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
972
981
  const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
973
- say(initNextSteps({ base, org, loggedIn: !!process.env.KALOKO_TOKEN, devServer: detected && !running ? detected : null, scenario: first && relative(cwd, first) }).join('\n'))
982
+ say(initNextSteps({ base, org, loggedIn: !!process.env.KALOKO_TOKEN, devServer: detected && !running ? detected : null, scenario: first && relative(cwd, first), products: /^products:/m.test(written) }).join('\n'))
974
983
  say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
975
984
  },
976
985
 
@@ -1187,7 +1196,12 @@ const commands = {
1187
1196
  if (lint.errors || lint.warnings) say(`\nPortable HTML: ${lint.errors} error(s), ${lint.warnings} warning(s); kaloko design lint lists them${lint.errors ? ' (errors stop kaloko sync: they break on the live step)' : ''}.`)
1188
1197
  return
1189
1198
  }
1190
- if (isApp(scenario.platform)) return walkApp({ config, root, run, scenario, env, args })
1199
+ if (isApp(scenario.platform)) {
1200
+ await walkApp({ config, root, run, scenario, env, args })
1201
+ // an app walk ends with the verdicts like a web walk (--no-evaluate skips them; kaloko evaluate redoes them)
1202
+ if (!args['no-evaluate']) await walkVerdicts({ prefetch: { done: async () => {} }, run, scenario, config, root })
1203
+ return
1204
+ }
1191
1205
  const pick = list(args.steps)
1192
1206
  const locales = list(args.locales) || run.meta.scope.locales
1193
1207
  const scope = run.meta.scope
@@ -2289,6 +2303,24 @@ const commands = {
2289
2303
  if (!r.ok || (args.strict && r.warnings)) process.exitCode = 1
2290
2304
  },
2291
2305
 
2306
+ /** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
2307
+ async products (args) {
2308
+ const sub = args._[0]
2309
+ if (!['sync', 'list'].includes(sub)) throw new UserError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
2310
+ const { config, root } = await context(args)
2311
+ const lib = await import('./lib/changelog.mjs')
2312
+ if (sub === 'list') {
2313
+ if (!config.org) throw new UserError('Set org: in the config first.')
2314
+ const r = await lib.listProducts({ root, config, client: service(config) })
2315
+ say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
2316
+ return
2317
+ }
2318
+ if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2319
+ const r = await lib.syncProducts({ root, config, client: args['dry-run'] ? null : service(config), only: typeof args.product === 'string' ? args.product : null, dryRun: !!args['dry-run'] })
2320
+ say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
2321
+ if (r.products.some((p) => p.ok === false)) process.exitCode = 1
2322
+ },
2323
+
2292
2324
  /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2293
2325
  async release (args) {
2294
2326
  const { config, root } = await context(args)
@@ -273,6 +273,76 @@ export async function productPayload (root, product, src) {
273
273
  return { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) }
274
274
  }
275
275
 
276
+ // ---------- products on the service ----------
277
+
278
+ /**
279
+ * `kaloko products sync`: every product of the config (or the one asked for) on Kaloko with its name, projects,
280
+ * modules, version source, reference environment, default scenario and who should read it (publish:, which the
281
+ * service applies only for someone who manages the product), without recording a release. A product the service
282
+ * refuses (a project it does not know or the person may not map) is reported and the others still go.
283
+ */
284
+ export async function syncProducts ({ root, config, client = null, only = null, dryRun = false }) {
285
+ const all = productsOf(config, root)
286
+ if (typeof only === 'string' && only && !all.some((p) => p.slug === only)) throw new UserError(`No product ${only} in the config (${all.map((x) => x.slug).join(', ')}).`)
287
+ const out = []
288
+ for (const product of all.filter((p) => !only || p.slug === only)) {
289
+ const src = await detectVersionSource(root, product)
290
+ const payload = await productPayload(root, product, src)
291
+ const item = { slug: product.slug, implicit: product.implicit, name: product.name, projects: product.projects, modules: product.modules, version_source: payload.version_source, version: src.version || null, payload }
292
+ if (dryRun) { out.push(item); continue }
293
+ try {
294
+ const saved = await client.request('POST', `/orgs/${config.org}/products`, payload)
295
+ out.push({ ...item, ok: true, created: saved.created ?? null, publish: saved.product?.publish || null, url: saved.page || saved.url || null, changelog: saved.url || null, notes: saved.notes || [] })
296
+ } catch (e) { out.push({ ...item, ok: false, error: String(e.message || e).split('\n')[0] }) }
297
+ }
298
+ return { dry_run: dryRun, from_config: !!Object.keys(config.products || {}).length, products: out }
299
+ }
300
+
301
+ /** What `kaloko products sync` did, for people. */
302
+ export function formatSync (r) {
303
+ const lines = []
304
+ const n = r.products.length
305
+ lines.push(r.dry_run ? `${n} product(s) would go to Kaloko; dry run, nothing sent.` : `${r.products.filter((p) => p.ok).length} of ${n} product(s) on Kaloko; no release recorded.`)
306
+ if (!r.from_config) lines.push(' No products: in kaloko.config.yml, so the project is the product (its own changelog and docs).')
307
+ for (const p of r.products) {
308
+ const what = [`${p.projects.length ? `projects ${p.projects.join(', ')}` : 'the whole organization'}`, p.modules.length ? `modules ${p.modules.join(', ')}` : null, `version from ${p.version_source.source}${p.version ? ` (${p.version})` : ''}`].filter(Boolean).join(' · ')
309
+ const name = typeof p.name === 'object' && p.name ? Object.values(p.name)[0] : p.name
310
+ lines.push(` ${p.ok === false ? '✗' : r.dry_run ? '·' : p.created ? '+' : '✓'} ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: ${what}`)
311
+ if (p.url) lines.push(` ${p.url}`)
312
+ if (p.error) lines.push(` ! ${p.error}`)
313
+ for (const x of p.notes || []) lines.push(` ! ${x}`)
314
+ }
315
+ return lines.join('\n')
316
+ }
317
+
318
+ /**
319
+ * `kaloko products list`: the products the service has that the person reads, and the products of the config the
320
+ * service does not have yet (kaloko products sync puts them there).
321
+ */
322
+ export async function listProducts ({ root, config, client }) {
323
+ const service = await client.request('GET', `/orgs/${config.org}/products`)
324
+ const local = Object.keys(config.products || {}).length ? productsOf(config, root) : []
325
+ const known = new Set(service.map((p) => p.slug))
326
+ return { products: service, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
327
+ }
328
+
329
+ /** The list for people. */
330
+ export function formatList (r) {
331
+ const lines = []
332
+ if (!r.products.length) lines.push('No product on Kaloko that you read yet. kaloko products sync puts the products of kaloko.config.yml there; kaloko release and kaloko docs publish do it too.')
333
+ for (const p of r.products) {
334
+ const name = typeof p.name === 'object' && p.name ? (p.name.en || Object.values(p.name)[0]) : p.name
335
+ const docs = p.docs ? `docs ${p.docs.versions.join(', ')}${p.docs.languages?.length ? ` (${p.docs.languages.join(', ')})` : ''}` : 'no docs yet'
336
+ const rel = p.latest_release ? `${p.latest_release.version} on ${p.latest_release.released_on}` : 'no release yet'
337
+ lines.push(`${p.slug}${name && name !== p.slug ? ` (${name})` : ''}${p.implicit ? ' · the project\'s own' : ''}: ${rel} · ${docs} · read by ${p.readers}${p.public_url ? ` (${p.public_url})` : ''}`)
338
+ if (p.modules?.length) lines.push(` modules: ${p.modules.join(', ')}`)
339
+ if (p.projects?.length) lines.push(` projects: ${p.projects.join(', ')}`)
340
+ if (p.url) lines.push(` ${p.url}`)
341
+ }
342
+ if (r.missing.length) lines.push(`In kaloko.config.yml but not on Kaloko yet: ${r.missing.join(', ')}. Run kaloko products sync.`)
343
+ return lines.join('\n')
344
+ }
345
+
276
346
  async function repoUrl (root) {
277
347
  const r = await repoSlug(root)
278
348
  return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
@@ -14,14 +14,23 @@ function describeFocus () {
14
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
15
  const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim()
16
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)
17
+ // a visually hidden radio or checkbox shows itself and its focus through its label (segmented switches)
18
+ let shown = el
19
+ const own = getComputedStyle(el); const ob = el.getBoundingClientRect()
20
+ // a small radio or checkbox inside its label: the label is the control people see, and its focus style lives there
21
+ const wrap = el.labels && [...el.labels].find((l) => l.contains(el))
22
+ if (wrap && ob.width <= 16 && ob.height <= 16) { const b = wrap.getBoundingClientRect(); if (b.width > ob.width && b.height > 0) shown = wrap }
23
+ if (shown === el && el.labels && el.labels.length && (Number(own.opacity) <= 0.05 || ob.width < 2 || ob.height < 2)) shown = [...el.labels].find((l) => { const b = l.getBoundingClientRect(); return b.width > 0 && b.height > 0 && getComputedStyle(l).visibility !== 'hidden' }) || el
24
+ const r = shown.getBoundingClientRect(); const cs = getComputedStyle(shown)
18
25
  let fixed = false
19
26
  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
27
  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
28
  const inView = r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth
22
29
  let obscured = false
23
30
  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)))
31
+ // an inline link wrapped over lines: test its first line box, not the middle of the bounding box (a neighbour may sit there)
32
+ const line = getComputedStyle(el).display === 'inline' && el.getClientRects().length > 1 ? el.getClientRects()[0] : r
33
+ const hit = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, line.left + line.width / 2)), Math.min(innerHeight - 1, Math.max(0, line.top + line.height / 2)))
25
34
  obscured = !!hit && !(hit === el || el.contains(hit) || hit.contains(el) || (el.labels && [...el.labels].some((l) => l.contains(hit))))
26
35
  }
27
36
  const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
@@ -30,7 +39,10 @@ function describeFocus () {
30
39
  desc: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${cls}`,
31
40
  x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height),
32
41
  visible, fixed, obscured, dialog: !!el.closest('dialog[open], [aria-modal=true]'), main: !!el.closest('main, [role=main]'),
33
- tabindex: el.getAttribute('tabindex')
42
+ tabindex: el.getAttribute('tabindex'),
43
+ // focus inside content the page cannot see into: an iframe, or an element that is not focusable itself (the host
44
+ // of a closed shadow tree, e.g. a third-party widget wrapping its own iframe)
45
+ embedded: el.tagName === 'IFRAME' || !el.matches('a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]:not([contenteditable=false])')
34
46
  }
35
47
  }
36
48
 
@@ -43,9 +55,18 @@ function focusByPath (key) {
43
55
  el = kids[i - 1]
44
56
  if (!el) return null
45
57
  }
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 }
58
+ // a visually hidden radio or checkbox: its label is what shows the focus (as in describeFocus)
59
+ const ob = el.getBoundingClientRect()
60
+ // a small radio or checkbox inside its label: the label (as in describeFocus)
61
+ const wrap = el.labels && [...el.labels].find((l) => l.contains(el))
62
+ const wb = wrap && ob.width <= 16 && ob.height <= 16 ? wrap.getBoundingClientRect() : null
63
+ const shown = wb && wb.width > ob.width && wb.height > 0 ? wrap : el.labels && el.labels.length && (Number(getComputedStyle(el).opacity) <= 0.05 || ob.width < 2 || ob.height < 2) ? [...el.labels].find((l) => { const b = l.getBoundingClientRect(); return b.width > 0 && b.height > 0 }) || el : el
64
+ shown.scrollIntoView({ block: 'center', inline: 'nearest' }); el.focus({ preventScroll: true })
65
+ const r = shown.getBoundingClientRect()
66
+ // an inline element wrapped over lines draws its ring around each line box: their outlines, not the bounding box
67
+ const rects = getComputedStyle(shown).display === 'inline' ? [...shown.getClientRects()].filter((q) => q.width > 0 && q.height > 0) : []
68
+ const lines = rects.length > 1 ? Math.round(rects.reduce((sum, q) => sum + 2 * (q.width + q.height), 0)) : null
69
+ return { x: r.left, y: r.top, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight, lines }
49
70
  }
50
71
 
51
72
  // In the page: static facts for the order check (positive tabindex, controls taken out of the tab order, click-only).
@@ -53,7 +74,11 @@ function orderFacts () {
53
74
  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
75
  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
76
  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]'))
77
+ // roving tabindex (WAI-ARIA composite widgets: tabs, radio groups, toolbars, menus, listboxes, grids, trees): the
78
+ // widget is one tab stop and arrow keys move inside it, so its other items rightly carry tabindex="-1"
79
+ const widget = '[role=tablist], [role=radiogroup], [role=toolbar], [role=menu], [role=menubar], [role=listbox], [role=grid], [role=treegrid], [role=tree]'
80
+ const roving = (e) => { const w = e.parentElement && e.parentElement.closest(widget); return !!w && [...w.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')].some((x) => x !== e && x.closest(widget) === w && !x.disabled && !/^-/.test(x.getAttribute('tabindex') || '') && visible(x)) }
81
+ 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]') && !roving(e))
57
82
  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
83
  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
84
  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 }
@@ -97,8 +122,9 @@ export function focusDelta (focusedPng, plainPng) {
97
122
  }
98
123
 
99
124
  /** 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))
125
+ export function focusVerdict (delta, w, h, edge = null) {
126
+ // edge: the length of the element's outline inside the viewport (a tall panel shows only its sides)
127
+ const perimeter = edge > 0 ? edge : 2 * (Math.max(1, w) + Math.max(1, h))
102
128
  if (delta.strong >= perimeter * 0.75) return 'ok'
103
129
  if (delta.changed >= perimeter * 0.5) return 'weak'
104
130
  return 'none'
@@ -136,7 +162,8 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
136
162
  const f = await page.evaluate(describeFocus).catch(() => null)
137
163
  if (!f) { keyboard.complete = true; break } // focus went back to the browser: the whole page was walked
138
164
  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
165
+ // Tab moves inside an iframe or a closed widget while the page still reports the same element: count it once
166
+ if (f.embedded) { if (stops.at(-1)?.key === f.key) { if (++iframeRun > 30) break; continue } } else iframeRun = 0
140
167
  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
168
  seen.set(f.key, stops.length)
142
169
  stops.push(f)
@@ -196,7 +223,7 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
196
223
  // visible focus: each stop focused and unfocused, the same area compared (keyboard modality stays on)
197
224
  const verdicts = new Map()
198
225
  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))
226
+ const candidates = stops.map((s, i) => ({ s, n: i + 1 })).filter(({ s }) => s.visible && !s.embedded).slice(0, Math.max(want.has('a11y_focus_visible') ? focusChecks : 0, shots))
200
227
  for (const { s, n } of candidates) {
201
228
  const pos = await page.evaluate(focusByPath, s.key).catch(() => null)
202
229
  if (!pos || !pos.w || !pos.h) continue
@@ -212,7 +239,10 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
212
239
  await page.evaluate(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; a?.blur?.() })
213
240
  const plain = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
214
241
  const delta = focusDelta(focused, plain)
215
- verdicts.set(n, { v: s.obscured ? 'obscured' : focusVerdict(delta, pos.w, pos.h), contrast: delta.contrast })
242
+ // only the edges of the element inside the viewport can show the indicator
243
+ const vw = Math.min(pos.vw, pos.x + pos.w) - Math.max(0, pos.x); const vh = Math.min(pos.vh, pos.y + pos.h) - Math.max(0, pos.y)
244
+ const edge = (pos.y >= 0 ? vw : 0) + (pos.y + pos.h <= pos.vh ? vw : 0) + (pos.x >= 0 ? vh : 0) + (pos.x + pos.w <= pos.vw ? vh : 0)
245
+ verdicts.set(n, { v: s.obscured ? 'obscured' : focusVerdict(delta, pos.w, pos.h, pos.lines || edge), contrast: delta.contrast })
216
246
  }
217
247
  if (want.has('a11y_focus_visible')) {
218
248
  const bad = [...verdicts].filter(([, x]) => x.v !== 'ok')
@@ -14,7 +14,7 @@ export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${or
14
14
  * Lines printed at the end of `kaloko init`. `devServer` ({ command, url }) when init found the app's dev server but it
15
15
  * is not answering yet; `scenario` the path of the first scenario init drafted, which then is the first real flow.
16
16
  */
17
- export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null }) {
17
+ export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null, products = false }) {
18
18
  const o = realOrg(org)
19
19
  const steps = [
20
20
  loggedIn ? 'Signed in already (KALOKO_TOKEN found).' : 'npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)'
@@ -23,6 +23,8 @@ export function initNextSteps ({ base, org, loggedIn = false, devServer = null,
23
23
  steps.push(o ? `Try a review on the sample run: ${sampleUrl(base, o)}` : 'Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.')
24
24
  if (scenario) steps.push(`Your first real flow: npx kaloko start --scenario ${scenario} --env local && npx kaloko walk`)
25
25
  else steps.push('Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:', ` "${FIRST_FLOW_PROMPT}"`)
26
+ // products: in the config show up in the app (Products) once they are on Kaloko, before any release
27
+ if (products) steps.push('Put the products of kaloko.config.yml on Kaloko: npx kaloko products sync (they show under Products with their changelog and docs)')
26
28
  const lines = ['Next:']
27
29
  let n = 0
28
30
  for (const s of steps) lines.push(s.startsWith(' "') ? ` ${s}` : ` ${++n}. ${s}`)