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 +24 -8
- package/package.json +1 -1
- package/skills/kaloko/SKILL.md +9 -8
- package/skills/kaloko/reference/docs.md +8 -1
- package/skills/kaloko/reference/mcp.md +1 -0
- package/src/cli.mjs +34 -2
- package/src/lib/changelog.mjs +70 -0
- package/src/lib/keyboard.mjs +42 -12
- package/src/lib/onboarding.mjs +3 -1
- package/viewer/index.html +316 -61
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
|
|
12
|
-
-
|
|
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
|
|
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).
|
|
301
|
-
|
|
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
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -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.
|
|
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,
|
|
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,
|
|
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))
|
|
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)
|
package/src/lib/changelog.mjs
CHANGED
|
@@ -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}` } : {}
|
package/src/lib/keyboard.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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')
|
package/src/lib/onboarding.mjs
CHANGED
|
@@ -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}`)
|