kaloko 0.4.7 → 0.4.8

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
@@ -64,6 +64,23 @@ npx kaloko share --pr # upload to kaloko.app, comment on the PR
64
64
  `npx kaloko help` lists every command. When something does not work (Chrome,
65
65
  token, environment URL, evaluator key), `npx kaloko doctor` says what to fix.
66
66
 
67
+ ## Review only what changed
68
+
69
+ After `kaloko share`, steps that look exactly as they did in the last accepted
70
+ run (same screenshots, same criteria, automatic checks passing) keep the
71
+ approval a person gave them, with who and when. The rest waits in review
72
+ mode: one step at a time on the canvas, `J`/`K` to move, `A` to approve, `R`
73
+ to return with a note. The share says it in one line:
74
+
75
+ ```
76
+ 12 steps unchanged, approvals carried over; 5 need review: https://kaloko.app/acme/runs/…/#review
77
+ ```
78
+
79
+ `npx kaloko review` (or `--json`, or MCP `get_review_queue`) tells an agent
80
+ what still waits for a person, so it can pass the link on. Agents never
81
+ approve for people: their decisions are marked and do not count as a
82
+ person's.
83
+
67
84
  ## Design (Kaloko Design)
68
85
 
69
86
  Design the screens of a flow as live HTML with your own agent and skills, in the same scenario that development
@@ -121,7 +138,7 @@ captured HTML, page text and console output.
121
138
  - **E-mails**: built-in Mailpit client; any other mailbox through a small
122
139
  adapter module (`provider: script`, example in `templates/mail/`), or hand a
123
140
  message over with `npx kaloko mail --step <id> --file message.eml`.
124
- - **Service and evaluator**: `KALOKO_TOKEN` for `share`, `feedback`, `inbox` and clean-up (`runs`, `trash`, `prune`, `keep`, `archive`)
141
+ - **Service and evaluator**: `KALOKO_TOKEN` for `share`, `feedback`, `review`, `inbox` and clean-up (`runs`, `trash`, `prune`, `keep`, `archive`)
125
142
  (`npx kaloko login` approves one in the browser and saves it to `~/.config/kaloko/.env`, or create it in your
126
143
  organization's settings); `TYPESAFE_API_KEY` for the
127
144
  semantic evaluator (optional). Both names can be changed in the config.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
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",
@@ -28,8 +28,9 @@ the canvas, storage and collaboration are the service.
28
28
  `kaloko capture` run `kaloko evaluate`) and `kaloko build` ran; you report the summary line,
29
29
  failing criteria with evidence, and the preview path (or shared URL).
30
30
  - When the run was shared, its URL and the summary are in the issue or PR
31
- the work belongs to, and you read `kaloko feedback` and `kaloko inbox`
32
- before continuing on that task.
31
+ the work belongs to, the person got the review-mode link with what waits
32
+ for them (`kaloko share` prints it; `kaloko review` repeats it), and you
33
+ read `kaloko feedback` and `kaloko inbox` before continuing on that task.
33
34
  - Changes to intent are written back to `ACCEPTANCE.md` (change log row).
34
35
  - On a design environment: the folder follows the bundle rules and
35
36
  `kaloko design lint` shows no error, `kaloko walk` and `kaloko sync` ran,
@@ -56,12 +57,17 @@ a step in a sync conflict (`--mine`), turning on `html_view`, a real clean-up
56
57
  (`kaloko prune --yes`), trashing someone else's run, archiving a scenario, and
57
58
  anything the environment profile blocks. Never trash an accepted run, a
58
59
  baseline, a run with review comments, or a run linked from an open PR or issue
59
- comment. Ask the person
60
+ comment. Never approve a
61
+ step or accept a run on a person's behalf; report what waits for them
62
+ (`kaloko review`, MCP `get_review_queue`) instead. Ask the person
60
63
  for secret values (passwords, tokens, gate credentials) and for sign-ins only
61
64
  they can do; never write secrets into the config, a scenario or a script.
62
65
 
63
66
  ## Where to look
64
67
 
68
+ - A new project or an organization without runs of its own (install, sign in,
69
+ the sample run, the first flow from a URL or a task, share, point the person
70
+ to the canvas): [reference/first-run.md](reference/first-run.md)
65
71
  - Writing or updating the plan and scenario: [reference/acceptance.md](reference/acceptance.md)
66
72
  - Android, iOS (Simulator), Electron and macOS apps, or screens captured by
67
73
  another tool (Maestro, Appium, XCUITest) with their UI tree:
@@ -75,8 +81,9 @@ they can do; never write secrets into the config, a scenario or a script.
75
81
  - Environments, access (gates, accounts, sign-in), e-mails, readonly and
76
82
  production rules, and what is yours to arrange for the project's stack:
77
83
  [reference/environments.md](reference/environments.md)
78
- - Sharing, reviewer feedback, mentions, inbox, verdicts and baselines, and
79
- cleaning up runs (trash, prune, keep, archive):
84
+ - Sharing, review mode and approvals carried over unchanged steps, reviewer
85
+ feedback, mentions, inbox, verdicts and baselines, and cleaning up runs
86
+ (trash, prune, keep, archive):
80
87
  [reference/collaboration.md](reference/collaboration.md)
81
88
  - Reading runs, feedback and the inbox over MCP instead of the CLI:
82
89
  [reference/mcp.md](reference/mcp.md)
@@ -4,7 +4,10 @@ A run is only worth something once the people who decide have seen it. The
4
4
  loop teams use:
5
5
 
6
6
  1. **Share the run** (`kaloko share`, tester token, after asking). The
7
- command prints the canvas URL.
7
+ command prints the canvas URL and one line on what waits for a person,
8
+ e.g. `12 steps unchanged, approvals carried over; 5 need review: <link>`.
9
+ Give the person that review-mode link (it ends in `#review`) with the
10
+ counts, not just the canvas.
8
11
  2. **Put the link where the work is tracked.** When the branch has a pull
9
12
  request and `gh` is signed in, `kaloko share --pr` posts one comment per
10
13
  scenario on it (summary line, canvas link, criteria that need attention
@@ -19,7 +22,10 @@ loop teams use:
19
22
  that waits for review and turns green or red with the verdict; do not
20
23
  report it as passed before a person accepted the run.
21
24
  3. **Read feedback before continuing.** `kaloko feedback` lists the run
22
- verdict, open rejections and comments, and failed manual criteria.
25
+ verdict, returned steps first (with the reviewer's note), open comments,
26
+ failed manual criteria and how many steps still wait for a person.
27
+ `kaloko review` (or MCP `get_review_queue`) says exactly what a person
28
+ still has to decide; see *Review mode* below.
23
29
  `kaloko inbox` lists what needs your attention across runs: mentions,
24
30
  returned runs, rejected steps in your runs, new runs of scenarios you
25
31
  reviewed, and expiry warnings. Both take `--json`; `kaloko inbox --read`
@@ -31,6 +37,34 @@ loop teams use:
31
37
  reply in the tracker with the new link. The reviewer who returned it is
32
38
  told about the new run automatically.
33
39
 
40
+ ## Review mode
41
+
42
+ Reviewers decide in **review mode** on the canvas (`Q`, the Review button,
43
+ or a link ending in `#review`): one step at a time, the steps that need a
44
+ person first (failed, returned, the AI was unsure or did not evaluate,
45
+ changed since the last accepted run, open comments), with the accepted run
46
+ beside it. `J`/`K` move, `A` approves, `R` returns with a note, `C`
47
+ comments; at the end they accept or return the run.
48
+
49
+ - **Approvals carry over unchanged steps.** When a new run is shared, a step
50
+ whose screenshots are byte-identical to the last accepted run, whose
51
+ criteria are the same and whose deterministic checks pass keeps the
52
+ approval a person gave it there, with its origin (who, when, which run).
53
+ Only changed or failing steps go to the queue. Anyone can revoke a carried
54
+ approval; the step then waits again. Organizations on Business can turn
55
+ this off (Settings → Approvals).
56
+ - A carried approval never accepts the run and never counts for required
57
+ approvers: the run verdict is always a person's decision.
58
+ - **Report what waits**, do not decide it: run `kaloko review` (add `--json`
59
+ for the data, `--run <id>` for another run) or MCP `get_review_queue` and
60
+ tell the person how many steps wait and give them `review_url`.
61
+ - **Never approve or accept on a person's behalf.** Decisions by tokens and
62
+ agents (MCP `decide_step`, `add_comment` with `approved`) are recorded as
63
+ yours, shown apart, and do not count as a person's unless the organization
64
+ allows it. Return a step only when you found a real problem, with a note.
65
+ - Returned steps are feedback: fix them, share a new run, and resolve the
66
+ thread with what changed (see below).
67
+
34
68
  ## Processing feedback
35
69
 
36
70
  Comments are threads in three places:
@@ -195,6 +229,15 @@ Settings → Storage (keep the newest N per scenario and environment).
195
229
  Everything above is available over the API with an organization token:
196
230
 
197
231
  - `GET /api/v1/orgs/<org>/feedback?run=<id|latest>[&scenario=<id>]`
232
+ - `GET /api/v1/orgs/<org>/runs/<id>/review-queue` (or
233
+ `GET /api/v1/orgs/<org>/review-queue?run=<id|latest>&scenario=<id>`):
234
+ `items` in review order with `reasons`, `decision` (approved, returned or
235
+ carried, with who and how), `unconfirmed` (an agent's or token's decision
236
+ that does not count), `changed`, criteria with evidence, `screenshots` and
237
+ `ref_screenshots`; `carried` with provenance; `counts` (`queue`,
238
+ `decided`, `pending`, `carried`, `clear`); `reference` (the accepted run);
239
+ `review_url`. Decisions use the comment and verdict routes;
240
+ `POST …/runs/<id>/carried/<step>/revoke` revokes a carried approval.
198
241
  - `GET|POST /api/v1/orgs/<org>/runs/<id>/comments` (a step: `node_id`;
199
242
  the whole run: `node_id: ""`), `GET|POST …/scenarios/<id>/comments`
200
243
  (the whole flow), and per comment `PATCH`, `DELETE`, `POST …/<cid>/resolve`
@@ -61,3 +61,8 @@ it was not evaluated.
61
61
 
62
62
  `needs_review` is not a failure: look at the screenshot and state your
63
63
  reading, or leave it to the reviewer.
64
+
65
+ After a share, steps that did not change since the last accepted run keep a
66
+ person's approval; only the rest waits in review mode. Report it as the
67
+ share prints it ("12 steps unchanged, approvals carried over; 5 need review:
68
+ <link>") and never call a carried or agent approval a person's acceptance.
@@ -0,0 +1,51 @@
1
+ # First run in a new project
2
+
3
+ Use this when the project has no `kaloko.config.yml` yet, or the organization
4
+ has no shared run of its own. The goal is one real flow on the canvas, shared,
5
+ with a person who knows where to approve it.
6
+
7
+ ## Where the organization stands
8
+
9
+ - `GET /api/v1/orgs/<org>/getting-started` (MCP tool `get_started`) answers
10
+ `stage` (`empty`, `sample`, `active`), the sample run's URL, how many real
11
+ runs exist, the next steps and a prompt to start from.
12
+ - Every new organization has a **sample run**: a tea-shop checkout in Czech
13
+ and English on desktop and mobile, with one failing step per language, one
14
+ overflow on the phone and one criterion waiting for a person. It does not
15
+ count toward the plan and expires after 30 days. `<service>/<org>/sample`
16
+ opens it; it carries a short guide for the person (open a step, approve,
17
+ return with a note, share).
18
+ - If the person has never reviewed a run, send them the sample link first:
19
+ they learn the canvas there, not on your first draft. When it is gone,
20
+ `POST /api/v1/orgs/<org>/sample` (MCP `add_sample_run`, needs an upload or
21
+ full token) adds it again. Do not delete it unless asked
22
+ (`DELETE /api/v1/orgs/<org>/sample`).
23
+
24
+ ## From zero to a first shared run
25
+
26
+ 1. `npm install --save-dev kaloko`, then
27
+ `npx kaloko init --agent claude --org <org>` (it prints the sample link
28
+ and these steps). Fill `environments` in `kaloko.config.yml` with the URL
29
+ the app runs on.
30
+ 2. `npx kaloko login`: the person approves a token in the browser. Ask them
31
+ to; never create or paste tokens yourself.
32
+ 3. `npx kaloko doctor` and fix what it marks ✕.
33
+ 4. Draft the first scenario:
34
+ - public pages: `npx kaloko draft <url> [url…]` writes a read-only
35
+ production scenario and a plan; sharpen purposes and criteria;
36
+ - a task or the main flow of the app: write `ACCEPTANCE.md` and
37
+ `qa/scenario.yml` as in [acceptance.md](acceptance.md). Pick the flow a
38
+ person would show a client (sign-up, checkout, the main form), not every
39
+ page.
40
+ 5. `npx kaloko validate qa/scenario.yml`, then
41
+ `npx kaloko start --scenario qa/scenario.yml --env local`, `kaloko walk`,
42
+ `kaloko build`. Fix scenario mechanics until every step has a capture.
43
+ 6. Ask before `kaloko share` (see *Ask first* in SKILL.md). After sharing,
44
+ give the person the canvas URL and say what to look at: the failing
45
+ criteria with their evidence and the steps that need a decision.
46
+ 7. Read `kaloko feedback` when they have looked; fix what they returned and
47
+ walk again.
48
+
49
+ Done means: the scenario validates, the run is shared, its URL and summary
50
+ line are in your reply (and in the issue or PR the work belongs to), and the
51
+ person knows whether the sample or this run is what they should open.
@@ -28,8 +28,13 @@ file.
28
28
  | `list_notifications` | the token owner's inbox (`unread_only`, `limit`) |
29
29
  | `list_projects` | projects of the organization with counts; the slug is the `project` filter of `list_runs` and the value for `project:` in a scenario |
30
30
  | `get_criteria_verdicts` | reviewer verdicts on manual or overridden criteria of a run |
31
+ | `get_review_queue` | what a person still has to decide in a run (`run_id`, or `scenario` / nothing for the newest run): steps in review order with reasons, decisions, approvals carried over unchanged steps with their origin, counts and `review_url` to give the person |
32
+ | `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 |
33
+ | `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
31
34
  | `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`) |
32
35
  | `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
36
+ | `get_started` | where the organization stands (`empty`, `sample`, `active`), its sample run, the next steps and a prompt; call it in a new project, see [first-run.md](first-run.md) |
37
+ | `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
33
38
 
34
39
  ## Comment tools
35
40
 
package/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { gzipSync } from 'node:zlib'
4
+ import { createHash } from 'node:crypto'
4
5
  import { join, resolve, relative, dirname } from 'node:path'
5
6
  import { pathToFileURL } from 'node:url'
6
7
  import yaml from 'js-yaml'
@@ -29,9 +30,11 @@ import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
29
30
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
30
31
  import { expandPacks } from './lib/packs.mjs'
31
32
  import { upsertPrComment } from './lib/pr.mjs'
33
+ import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
32
34
  import { htmlViewAllowed } from './lib/htmlview.mjs'
33
35
  import { service, serviceUrl } from './lib/service.mjs'
34
36
  import { login } from './lib/login.mjs'
37
+ import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
35
38
  import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
36
39
  import { designDir, readBase, headKey } from './lib/design.mjs'
37
40
  import { lintDesign, formatLint } from './lib/design-lint.mjs'
@@ -111,7 +114,11 @@ Results
111
114
  share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
112
115
  --pr adds or updates one comment on the branch's pull request via gh
113
116
  feedback [--run id] [--scenario id] [--json]
114
- Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies
117
+ Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
118
+ returned steps first, with their notes
119
+ review [--run id] [--scenario id] [--json]
120
+ What a person still has to decide on a shared run (failed, unsure, changed, returned,
121
+ commented steps), approvals carried over unchanged steps, and the review-mode link
115
122
  comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
116
123
  comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
117
124
  Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
@@ -563,7 +570,13 @@ const commands = {
563
570
  await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
564
571
  say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
565
572
  }
566
- say(`Next: run kaloko login to approve a token in the browser (or create one in the app under Settings → API tokens and put KALOKO_TOKEN into .env).\nScenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
573
+ // the organization's sample run and the way to a first real flow (src/lib/onboarding.mjs)
574
+ let written = ''
575
+ try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
576
+ const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
577
+ const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
578
+ say(initNextSteps({ base, org }).join('\n'))
579
+ say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
567
580
  },
568
581
 
569
582
  async validate (args) {
@@ -1000,9 +1013,10 @@ const commands = {
1000
1013
  const body = async (p) => { const raw = await readFile(join(run.dir, p)); const gz = /\.(html|json)$/.test(p) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
1001
1014
  // the file list goes first: the service checks limits once and each upload is then a plain write
1002
1015
  // (an older service without the plan answers 404/405 and every upload checks itself, as before)
1003
- if (files.size > 20) {
1016
+ // screenshots name their SHA-256: the service checks it on arrival and compares unchanged steps with it
1017
+ if (files.size) {
1004
1018
  const plan = []
1005
- for (const p of files) plan.push({ path: p, bytes: (await body(p)).data.length })
1019
+ for (const p of files) { const { data } = await body(p); plan.push({ path: p, bytes: data.length, ...(/\.(png|webp|jpe?g)$/i.test(p) ? { sha256: createHash('sha256').update(data).digest('hex') } : {}) }) }
1006
1020
  await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
1007
1021
  }
1008
1022
  const queue = [...files]
@@ -1036,6 +1050,9 @@ const commands = {
1036
1050
  meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
1037
1051
  await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
1038
1052
  say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
1053
+ // what waits for a person, with the review-mode link to give them (approvals of unchanged steps carry over)
1054
+ const review = shareReviewLine(fin.review)
1055
+ if (review) say(review)
1039
1056
  const capped = expiryNote(days, created.expires_days)
1040
1057
  if (capped) say(`Note: ${capped}`)
1041
1058
  if (args.pr) {
@@ -1132,12 +1149,38 @@ const commands = {
1132
1149
  for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
1133
1150
  }
1134
1151
  const flow = data.flow_comments || []
1152
+ // how many steps still wait for a person (review mode); an older service answers 404 and the line is left out
1153
+ const queue = await client.request('GET', `/orgs/${config.org}/review-queue?run=${encodeURIComponent(data.run.id)}`).catch(() => null)
1154
+ if (queue?.counts) say(` review: ${queue.counts.pending} of ${queue.counts.queue} step(s) wait for a person${queue.counts.carried ? `, ${queue.counts.carried} unchanged kept their approval` : ''} (kaloko review; review mode: ${queue.review_url})`)
1135
1155
  if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
1136
- for (const r of data.items) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1156
+ // returned steps first: a reviewer said what should change
1157
+ const returned = data.items.filter((r) => r.status === 'rejected')
1158
+ if (returned.length) say(`Returned steps (${returned.length}), fix these first:`)
1159
+ for (const r of returned) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1160
+ if (returned.length && data.items.length > returned.length) say('Comments:')
1161
+ for (const r of data.items.filter((x) => x.status !== 'rejected')) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1137
1162
  for (const r of flow) thread(r, 'whole flow')
1138
1163
  say(`\nAnswer with: kaloko comment --reply <id> --note "…"; close with: kaloko comment --resolve <id> --note "Fixed in <commit>"${flow.length ? ' (add --flow for flow comments)' : ''}`)
1139
1164
  },
1140
1165
 
1166
+ /**
1167
+ * What a person still has to decide on a shared run: the service's review queue (failed, unsure, changed since the
1168
+ * accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
1169
+ * agents: report it to the person; never approve on their behalf.
1170
+ */
1171
+ async review (args) {
1172
+ const { config, root } = await context(args)
1173
+ const client = service(config)
1174
+ let runId = typeof args.run === 'string' ? args.run : null
1175
+ let scenarioId = typeof args.scenario === 'string' ? args.scenario : null
1176
+ if (!runId && !scenarioId) {
1177
+ try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id; else scenarioId = r.meta.scenario.id } catch {}
1178
+ }
1179
+ const q = new URLSearchParams({ run: runId || 'latest', ...(scenarioId ? { scenario: scenarioId } : {}) })
1180
+ const data = await client.request('GET', `/orgs/${config.org}/review-queue?${q}`)
1181
+ say(args.json ? JSON.stringify(data, null, 2) : formatReviewQueue(data))
1182
+ },
1183
+
1141
1184
  /**
1142
1185
  * Comments from the CLI: on a step (--step), the whole run (default) or the whole flow (--flow); reply, resolve
1143
1186
  * (with a closing note), reopen, edit and delete by id. Targets the shared run (--run, default the current one).
@@ -1236,6 +1279,7 @@ const commands = {
1236
1279
  if (args.print) return
1237
1280
  say(`✓ Signed in to ${r.org_name || r.org} as ${r.email} (${r.scope}; expires ${String(r.expires_at || '').slice(0, 10)}). ${tokenEnv} saved to ${r.file}.`)
1238
1281
  if (config.org && r.org && config.org !== r.org) say(`! ${CONFIG_FILE} names the organization ${config.org}; the token is for ${r.org}.`)
1282
+ for (const line of loginNextSteps({ base, org: r.org })) say(line)
1239
1283
  let projectEnv = ''
1240
1284
  try { projectEnv = await readFile(join(process.cwd(), '.env'), 'utf8') } catch {}
1241
1285
  if (inShell || new RegExp(`^\\s*(export\\s+)?${tokenEnv}\\s*=`, 'm').test(projectEnv)) say(`! ${tokenEnv} is also set ${inShell ? 'in your shell' : 'in this project\'s .env'}, which wins over ~/.config/kaloko/.env. Remove it there to use the new token.`)
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ // The service's review queue in words for the terminal (`kaloko review`, `kaloko share`, `kaloko feedback`): what a
2
+ // person still has to decide on a shared run, so an agent can tell them what waits and where. Agents never decide
3
+ // for people; they point them to the review mode link.
4
+
5
+ const REASON = {
6
+ fail: 'failed',
7
+ returned: 'returned',
8
+ needs_review: 'needs a person’s judgement',
9
+ not_evaluated: 'not evaluated',
10
+ revoked: 'carried approval revoked',
11
+ changed: 'changed since the accepted run',
12
+ new: 'new',
13
+ comments: 'open comments'
14
+ }
15
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
16
+ const day = (iso) => (iso ? String(iso).slice(0, 10) : '')
17
+ const cut = (s, n = 140) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
18
+
19
+ /** One line after `kaloko share`: "12 steps unchanged, approvals carried over; 5 need review: <link>". */
20
+ export function shareReviewLine (r) {
21
+ if (!r || r.queue == null) return null
22
+ const carried = r.carried ? `${plural(r.carried, 'step', 'steps')} unchanged, approvals carried over; ` : ''
23
+ if (!r.pending) return `${carried}no step waits for a person; the run still needs its verdict: ${r.url}`
24
+ return `${carried}${r.pending} ${r.pending === 1 ? 'needs' : 'need'} review: ${r.url}`
25
+ }
26
+
27
+ /** Who decided and how, in a few words. */
28
+ function decisionText (d) {
29
+ if (!d) return 'waiting for a person'
30
+ const via = d.via === 'agent' ? ' (agent)' : d.via === 'token' ? ' (token)' : ''
31
+ if (d.state === 'carried') return `approval carried over: ${d.by}${via} approved it on ${day(d.at)} in run ${d.from_run}`
32
+ if (d.state === 'approved') return `approved by ${d.by}${via} on ${day(d.at)}`
33
+ return `returned by ${d.by}${via} on ${day(d.at)}: ${cut(d.note, 200)}`
34
+ }
35
+
36
+ /** The whole queue for the terminal. */
37
+ export function formatReviewQueue (q) {
38
+ if (!q?.run) return 'No published run found.'
39
+ const c = q.counts || {}
40
+ const out = []
41
+ out.push(`Review of ${q.run.id} (${q.run.scenario}${q.run.environment ? `, ${q.run.environment}` : ''}): ${c.decided || 0} of ${plural(c.queue || 0, 'step', 'steps')} decided, ${c.pending || 0} waiting for a person${c.carried ? `; ${plural(c.carried, 'unchanged step', 'unchanged steps')} kept ${c.carried === 1 ? 'its' : 'their'} approval` : ''}`)
42
+ out.push(q.reference
43
+ ? `Compared with the accepted run ${q.reference.id} (accepted${q.reference.accepted_by ? ` by ${q.reference.accepted_by}` : ''}${q.reference.accepted_at ? ` on ${day(q.reference.accepted_at)}` : ''})`
44
+ : 'No accepted run of this scenario yet: every step is new to the reviewers.')
45
+ if (!q.policy?.carry_over) out.push('Approvals do not carry over in this organization (Settings → Approvals).')
46
+ for (const it of q.items || []) {
47
+ const mark = !it.decision ? '○' : it.decision.state === 'returned' ? '↩' : '✓'
48
+ out.push(` ${mark} ${it.step}${it.title ? ` ${typeof it.title === 'string' ? it.title : Object.values(it.title)[0]}` : ''} — ${it.reasons.map((r) => REASON[r] || r).join(', ')}`)
49
+ if (it.changed?.captures?.length) out.push(` changed: ${it.changed.captures.map((x) => `${x.locale}/${x.viewport}${x.why !== 'pixels' ? ` (${x.why})` : ''}`).join(', ')}${it.changed.criteria ? ' · criteria changed' : ''}`)
50
+ else if (it.changed?.criteria) out.push(' changed: criteria')
51
+ for (const cr of (it.criteria || []).filter((x) => x.cells?.length).slice(0, 4)) {
52
+ const cell = cr.cells.find((x) => x.verdict === 'fail') || cr.cells[0]
53
+ out.push(` ${cr.id} [${cell.locale}/${cell.viewport}] ${cell.verdict}${cell.evidence ? `: ${cut(cell.evidence)}` : ''}`)
54
+ }
55
+ out.push(` ${decisionText(it.decision)}`)
56
+ if (it.unconfirmed) out.push(` not counted: ${decisionText(it.unconfirmed)}`)
57
+ if (it.open_comments) out.push(` ${plural(it.open_comments, 'open comment', 'open comments')} (kaloko feedback)`)
58
+ }
59
+ if (!(q.items || []).length) out.push(' Nothing waits for a person on the steps.')
60
+ if (q.carried?.length) {
61
+ out.push(`Approvals carried over (${q.carried.length}):`)
62
+ for (const x of q.carried.slice(0, 12)) out.push(` ✓ ${x.step} — ${x.approved_by}${x.approved_via && x.approved_via !== 'session' ? ` (${x.approved_via})` : ''}, ${day(x.approved_at)}, ${x.kind === 'run' ? 'accepted run' : 'step approved in'} ${x.from_run}`)
63
+ if (q.carried.length > 12) out.push(` … and ${q.carried.length - 12} more`)
64
+ }
65
+ const v = q.verdict || {}
66
+ out.push(`Run verdict: ${v.verdict || 'pending'}${v.by ? ` by ${v.by}${v.via && v.via !== 'session' ? ` (${v.via})` : ''}` : ''}${v.awaiting?.length ? ` · awaiting approval from ${v.awaiting.join(', ')}` : ''}`)
67
+ out.push(`Review mode for a person: ${q.review_url}`)
68
+ return out.join('\n')
69
+ }
package/viewer/index.html CHANGED
@@ -637,6 +637,37 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
637
637
  .lb.is-idle .lb__fbar{opacity:0;pointer-events:none}
638
638
  .lb.is-focus.is-idle .lb__fshow{display:grid}
639
639
  @media (prefers-reduced-motion:no-preference){.lb__fbar{transition:opacity .2s ease}}
640
+ /* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ---------- */
641
+ .rvm{position:fixed;inset:0;z-index:23;background:var(--ground);display:grid;grid-template-rows:auto 1fr auto}
642
+ .rvm__top{display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line-strong)}
643
+ .rvm__top .spacer,.rvm__foot .spacer{flex:1}
644
+ .rvm__prog{font-size:13px;font-variant-numeric:tabular-nums;white-space:nowrap}
645
+ .rvm__bar{display:block;width:120px;height:6px;border-radius:999px;background:var(--skip-bg);overflow:hidden}
646
+ .rvm__bar i{display:block;height:100%;background:var(--pass)}
647
+ .rvm__body{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,420px);min-height:0}
648
+ .rvm__stage{overflow:auto;padding:24px;background:var(--panel-2);touch-action:pan-y pinch-zoom}
649
+ .rvm__stage > .lb__img,.rvm__stage > .lb__win{margin:0 auto}
650
+ .rvm__stage .lb__cmp{max-width:100%}
651
+ .rvm__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
652
+ .rvm__foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 16px calc(10px + env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)}
653
+ .rvm__foot .btn .kbd,.rvm__top .kbd{margin-left:4px}
654
+ .rvm__why{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
655
+ .rvm__dec{display:grid;gap:6px}
656
+ .rvm__dec p{margin:0}
657
+ .rvm__ret textarea{width:100%;font:inherit;padding:8px;border:1px solid var(--line-strong);border-radius:6px;background:var(--panel);color:var(--ink)}
658
+ .rvm__end{width:min(720px,100%);margin:0 auto;padding:28px 16px 40px;overflow:auto}
659
+ .rvm__end h3{font-size:22px;letter-spacing:-.01em;margin:0 0 8px;text-wrap:balance}
660
+ .rvm__list{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px}
661
+ .rvm__list li{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
662
+ .rvm__list button.linkish{text-align:left}
663
+ .rvm__loading{padding:40px;text-align:center;color:var(--muted)}
664
+ .rvm__wrap{overflow:auto;min-height:0}
665
+ #rvBtn .rv-count{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin-left:4px;border-radius:999px;background:var(--ink);color:var(--panel);font-size:11px;font-weight:600}
666
+ .badge.rv-carry{margin-left:auto}
667
+ @media (max-width:900px){.rvm__body{grid-template-columns:1fr;grid-template-rows:minmax(220px,55%) 1fr}.rvm__side{border-left:0;border-top:1px solid var(--line)}.rvm__stage{padding:12px}.rvm__bar{width:72px}.rvm__keys{display:none}}
668
+ @media (max-width:560px){.rvm__foot{gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}.rvm__foot .btn{flex:1 1 auto;justify-content:center;padding:9px 8px}.rvm__foot .spacer{display:none}.rvm__top{padding:8px 10px}.rvm__top .brand small{display:none}}
669
+ @media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
670
+ @media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}@media (max-width:820px){#rvBtn{order:3}}
640
671
  </style>
641
672
  </head>
642
673
  <body>
@@ -652,6 +683,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
652
683
  <kbd>K</kbd>
653
684
  </button>
654
685
  <button class="btn" id="rpBtn" title="R"><svg class="i" viewBox="0 0 24 24"><path d="M7 5v14l11-7z"/></svg><span data-t="replay"></span></button>
686
+ <button class="btn" id="rvBtn" title="Q"><svg class="i" viewBox="0 0 24 24"><path d="M4 6h10M4 12h10M4 18h7"/><path d="m15 17 2.5 2.5L22 15"/></svg><span data-t="rvTitle"></span></button>
655
687
  <button class="btn" id="ptBtn" title="P" hidden><svg class="i" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="13" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg><span data-t="prototype"></span></button>
656
688
  <div class="dbar" id="dbar" hidden></div>
657
689
  <span class="cmpchip" id="cmpChip" hidden></span>
@@ -721,6 +753,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
721
753
  </div>
722
754
  <div class="rp" id="rp" hidden role="dialog" aria-modal="true" aria-label="Replay"></div>
723
755
  <div class="pt" id="pt" hidden role="dialog" aria-modal="true" aria-label="Prototype"></div>
756
+ <div class="rvm" id="rv" hidden role="dialog" aria-modal="true" aria-label="Review"></div>
724
757
  <div class="toast" id="toast" hidden></div>
725
758
  <script>
726
759
  /* Kaloko viewer. Data source:
@@ -751,7 +784,7 @@ const I18N = {
751
784
  onlyLocal:'Tento běh je jen lokálně; otevřete jeho složku.', linksExpired:'Odkazy na snímky vypršely. Obnovte stránku.', captured:'Zachyceno', console:'Konzole', errors:'chyb', blocked:'blokovaných požadavků', files:'Soubory',
752
785
  pagesWord:'stránek', firstScreen:'první obrazovka', layout:'Rozložení', uploadedBy:'Nahrál', addNote:'Přidat poznámku', viewOptions:'Zobrazení', keyComment:'komentář ke kroku (v detailu)', keyHelp:'tato nápověda', helpTitle:'Zkratky a nápověda', flowComments:'Komentáře k flow', runComments:'Komentáře k běhu', commentsTitle:'Komentáře', allOpen:'Otevřené', openWord:'otevřených', flowCommentsLead:'Platí pro celé flow napříč běhy: chybějící krok, obecná připomínka, nápad.', noStepComments:'Ke kroku zatím nikdo nic nenapsal.', noComments:'Zatím žádné komentáře.', showResolved:'Vyřešené', hideResolved:'Skrýt vyřešené', replyAct:'Odpovědět', reopenAct:'Znovu otevřít', editAct:'Upravit', deleteAct:'Smazat', saveAct:'Uložit', cancel:'Zrušit', edited:'upraveno', commentDeleted:'Komentář byl smazán.', viaAgent:'Napsal agent', showPin:'Ukázat na snímku', pinOnShot:'Připnout na snímek', pinClickShot:'Klikněte do snímku', pinnedOnShot:'Připnuto na snímek', replyPh:'Odpověď… (@jméno upozorní)', flowPh:'Komentář k celému flow… (@jméno upozorní)', runPh:'Komentář k celému běhu… (@jméno upozorní)', commentLabel:'Komentář', ctrlEnter:'Ctrl+Enter odešle', confirmDelete:'Smazat komentář?', deletedToast:'Smazáno', openCommentsN:'Nevyřízené komentáře', showAllN:'Zobrazit všech %N%', screenWord:'obrazovka aplikace', uiTree:'UI strom', platformWord:'Platforma', treeHint:'Klikněte na prvek: ukáže ho na snímku.', treeSearch:'Hledat v UI stromu…', treeOcr:'Text rozpoznaný ze snímku (OCR): bez rolí a ovládacích prvků.', treeLoadFail:'UI strom se nepodařilo načíst.', treeEmpty:'Strom je prázdný.', mapView:'Mapa', indexView:'Index', searchSteps:'Hledat v krocích, kritériích a stránkách…', criteriaCol:'Kritéria', notesCol:'Poznámky', noStepsMatch:'Žádný krok neodpovídá filtru.', riKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · I mapa', consoleWord:'chyb v konzoli', loadingPages:'hledám i ve stránkách stacků…', sortTitle:'Název', runWord:'Běh', openIndex:'Otevřít index', pagesNeedAttention:'stránek chce pozornost', allPagesPass:'všechny stránky prošly', stackLoadFail:'Index stránek se nepodařilo načíst', semanticSample:'sémantický vzorek', downloadCsv:'Stáhnout CSV', allWord:'Vše', criterionFilter:'Nesplněné kritérium', anyCriterion:'Jakékoli kritérium', searchPages:'Hledat v titulku, URL a polích…', sortBy:'Řadit podle', sortOrder:'Pořadí', sortStatus:'Stav (nejhorší první)', sortDir:'Směr řazení', pageWord:'Stránka', statusWord:'Stav', failingCriteria:'Neprošlo', stackKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · Esc zavřít', listView:'Seznam', gridView:'Mřížka', noPagesMatch:'Filtru neodpovídá žádná stránka.', backToIndex:'Zpět na index',
753
786
  subject:'Předmět', from:'Od', to:'Komu', received:'Přijato', mailBeforeRun:'přišel před začátkem běhu', noCapture:'Bez snímku', viewEval:'eval.json', decision:'rozhodnutí', mainLane:'Hlavní průchod',
754
- downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
787
+ downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', viaToken:'přes token', viaAgent:'přes agenta', approvedByTokens:'přes token nebo agenta', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
755
788
  compare:'Porovnat', cmpWith:'Porovnáno s', cmpStop:'Ukončit porovnání', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
756
789
  reviewTitle:'Akceptace a komentáře', approve:'Schválit', reject:'Zamítnout', comment:'Komentovat', notePh:'Co je potřeba změnit, nebo poznámka · @jméno pošle kolegovi upozornění', approved:'schváleno', rejected:'zamítnuto', commented:'komentář', resolved:'vyřešeno', resolve:'Vyřešit', openFeedback:'Otevřená zpětná vazba', noFeedback:'Žádná otevřená zpětná vazba.', saved:'Uloženo', noteRequired:'Napište, co je potřeba změnit.', expires:'Platí do' },
757
790
  en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
@@ -775,7 +808,7 @@ const I18N = {
775
808
  onlyLocal:'This run exists locally only; open its folder.', linksExpired:'Links to the captures expired. Reload the page.', captured:'Captured', console:'Console', errors:'errors', blocked:'blocked requests', files:'Files',
776
809
  pagesWord:'pages', firstScreen:'first screen', layout:'Layout', uploadedBy:'Uploaded by', addNote:'Add a note', viewOptions:'View', keyComment:'comment on the step (in the detail)', keyHelp:'this help', helpTitle:'Shortcuts and help', flowComments:'Flow comments', runComments:'Run comments', commentsTitle:'Comments', allOpen:'Open', openWord:'open', flowCommentsLead:'For the whole flow across runs: a missing step, a general remark, an idea.', noStepComments:'No comments on this step yet.', noComments:'No comments yet.', showResolved:'Resolved', hideResolved:'Hide resolved', replyAct:'Reply', reopenAct:'Reopen', editAct:'Edit', deleteAct:'Delete', saveAct:'Save', cancel:'Cancel', edited:'edited', commentDeleted:'This comment was deleted.', viaAgent:'Written by an agent', showPin:'Show on the screenshot', pinOnShot:'Pin on the screenshot', pinClickShot:'Click on the screenshot', pinnedOnShot:'Pinned on the screenshot', replyPh:'Reply… (@name notifies)', flowPh:'Comment on the whole flow… (@name notifies)', runPh:'Comment on the whole run… (@name notifies)', commentLabel:'Comment', ctrlEnter:'Ctrl+Enter sends', confirmDelete:'Delete this comment?', deletedToast:'Deleted', openCommentsN:'Open comments', showAllN:'Show all %N%', screenWord:'app screen', uiTree:'UI tree', platformWord:'Platform', treeHint:'Click an element to show it on the screen.', treeSearch:'Search the UI tree…', treeOcr:'Text recognized from the screenshot (OCR): no roles or controls.', treeLoadFail:'The UI tree could not be loaded.', treeEmpty:'The tree is empty.', mapView:'Map', indexView:'Index', searchSteps:'Search steps, criteria and pages…', criteriaCol:'Criteria', notesCol:'Notes', noStepsMatch:'No step matches the filter.', riKeys:'/ search · ↑↓ select · Enter open · I map', consoleWord:'console errors', loadingPages:'also searching the pages of stacks…', sortTitle:'Title', runWord:'Run', openIndex:'Open index', pagesNeedAttention:'pages need attention', allPagesPass:'all pages pass', stackLoadFail:'The page index could not be loaded', semanticSample:'semantic sample', downloadCsv:'Download CSV', allWord:'All', criterionFilter:'Failing criterion', anyCriterion:'Any criterion', searchPages:'Search title, URL and fields…', sortBy:'Sort by', sortOrder:'Order', sortStatus:'Status (worst first)', sortDir:'Sort direction', pageWord:'Page', statusWord:'Status', failingCriteria:'Failing', stackKeys:'/ search · ↑↓ select · Enter open · Esc close', listView:'List', gridView:'Grid', noPagesMatch:'No page matches the filter.', backToIndex:'Back to index',
777
810
  subject:'Subject', from:'From', to:'To', received:'Received', mailBeforeRun:'arrived before the run started', noCapture:'No capture', viewEval:'eval.json', decision:'decision', mainLane:'Main path',
778
- downloadZip:'Download ZIP', downloadZipNote:'Every file in open formats and the canvas, which opens offline from disk', filesWord:'files', publicAccess:'public link', orgAccess:'access after signing in to the organization', projectAll:'All projects', packNote:'Criterion from a check pack (packs in the scenario)', coverage:'Plan coverage', coverageLine:'criteria from ACCEPTANCE.md have a check', coverageExtra:'checks outside the plan', coverageFull:'Every planned criterion has a check.', coverageSteps:'Steps in the plan without a node', flakyTitle:'Flaky criteria', flaky:'flaky', flakyNote:'The verdict differs between runs on the same commit and environment.', people:'People in the flow', peopleHint:'Click a person to highlight their steps; dashed arrows are hand-offs between people.', awaiting:'Awaiting approval from', approvedBy:'approved by', approvedByAll:'Every required approver has approved.', publicRun:'Public run', publicNote:'The canvas is readable without sign-in; reviewer names are hidden.', makePublic:'Make public', makePrivate:'Make private', publicHint:'Only for scenarios without personal data', publicConfirm:'Make this run public? Anyone with the link can read it without signing in. Use it only for scenarios without personal data.', openInApp:'Open in Kaloko', signInLink:'Sign in', replay:'Replay', rpStep:'Step', rpNext:'Next', rpPrev:'Back', rpEnd:'End of the walkthrough.', rpRestart:'Start over', rpChoose:'Which way?', rpOr:'or', rpKeys:'← → step · digits pick a branch · Esc closes', rpDecision:'Decision', rpBranch:'branch', rpAltOnly:'other branch',
811
+ downloadZip:'Download ZIP', downloadZipNote:'Every file in open formats and the canvas, which opens offline from disk', filesWord:'files', publicAccess:'public link', orgAccess:'access after signing in to the organization', projectAll:'All projects', packNote:'Criterion from a check pack (packs in the scenario)', coverage:'Plan coverage', coverageLine:'criteria from ACCEPTANCE.md have a check', coverageExtra:'checks outside the plan', coverageFull:'Every planned criterion has a check.', coverageSteps:'Steps in the plan without a node', flakyTitle:'Flaky criteria', flaky:'flaky', flakyNote:'The verdict differs between runs on the same commit and environment.', people:'People in the flow', peopleHint:'Click a person to highlight their steps; dashed arrows are hand-offs between people.', awaiting:'Awaiting approval from', approvedBy:'approved by', approvedByAll:'Every required approver has approved.', viaToken:'by token', viaAgent:'by an agent', approvedByTokens:'by token or agent', publicRun:'Public run', publicNote:'The canvas is readable without sign-in; reviewer names are hidden.', makePublic:'Make public', makePrivate:'Make private', publicHint:'Only for scenarios without personal data', publicConfirm:'Make this run public? Anyone with the link can read it without signing in. Use it only for scenarios without personal data.', openInApp:'Open in Kaloko', signInLink:'Sign in', replay:'Replay', rpStep:'Step', rpNext:'Next', rpPrev:'Back', rpEnd:'End of the walkthrough.', rpRestart:'Start over', rpChoose:'Which way?', rpOr:'or', rpKeys:'← → step · digits pick a branch · Esc closes', rpDecision:'Decision', rpBranch:'branch', rpAltOnly:'other branch',
779
812
  compare:'Compare', cmpWith:'Compared with', cmpStop:'Stop comparing', cmpOff:'nothing', cmpBaseline:'baseline', cmpPrev:'previous', cmpTitle:'Differences from', cmpNone:'Statuses, verdicts and evidence are the same.', pxChanged:'pixels changed', cmpSide:'Side by side', cmpDiff:'Diff', cmpLoadErr:'Could not load the compared run.', pxUnavail:'Pixel diff works in the service only (same-origin images).', was:'was', thisRun:'this run', cmpNoCap:'no capture in the compared run', cmpAdded:'new steps', cmpRemoved:'steps that disappeared', cmpHint:'Ctrl/⌘ + click a step to open the comparison',
780
813
  reviewTitle:'Sign-off and comments', approve:'Approve', reject:'Reject', comment:'Comment', notePh:'What should change, or a note · @name notifies a colleague', approved:'approved', rejected:'rejected', commented:'comment', resolved:'resolved', resolve:'Resolve', openFeedback:'Open feedback', noFeedback:'No open feedback.', saved:'Saved', noteRequired:'Write what should change.', expires:'Expires' }
781
814
  };
@@ -926,6 +959,7 @@ function rerenderComments (all = false) {
926
959
  if (S.right) { const top = $('rside').scrollTop; renderReport(); $('rside').scrollTop = top; }
927
960
  if (S.open && !$('lb').hidden) { const side = $('lb').querySelector('.lb__side'); const top = side?.scrollTop || 0; openLb(S.open, S.lbVp, { keepFrame: true }); const s2 = $('lb').querySelector('.lb__side'); if (s2) s2.scrollTop = top; }
928
961
  if (S.rp && !$('rp').hidden) renderReplay();
962
+ if (RV.open) { const top = $('rvSide')?.scrollTop || 0; rvRender(); if ($('rvSide')) $('rvSide').scrollTop = top; if (all) rvLoad().then(() => { if (RV.open) rvRender(); }); }
929
963
  if (all && S.mode === 'index' && !S.st?.inline && $('riList')) riPaint();
930
964
  }
931
965
  // unsent text survives re-renders, navigation and reloads (per run or flow, step, and reply or edit)
@@ -1313,7 +1347,7 @@ function renderNodes () {
1313
1347
  ${n.session ? sessChip(n.session) : ''}<div class="node__head"><span class="kind">${ICON[n.kind]}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${httpChip(n)}${esc(loc(n.path) || n.captures?.[S.loc]?.[d?.name]?.mail?.subject || '')}</div></div><span class="badge st-${s}">${MARK[s]} ${s === 'skip' ? '—' : `${ok}/${n.criteria.length}`}</span></div>
1314
1348
  <button class="shots ${S.vp === 'mobile' ? 'shots--mbig' : ''}" data-open="${esc(n.id)}" aria-label="${esc(loc(n.title))}">${S.vp !== 'mobile' && d ? shot(n, d, 256, 160) : ''}${S.vp !== 'desktop' && m ? shot(n, m, 74, 160) : ''}</button>
1315
1349
  ${jevBlock(n)}
1316
- <div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` : ''}</div>
1350
+ <div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` : rvNodeBadge(n.id)}</div>
1317
1351
  </div>`;
1318
1352
  }).join('');
1319
1353
  const tallest = Math.max(0, ...[...$('nodes').querySelectorAll('.node:not(.node--decision)')].map((el) => el.offsetHeight));
@@ -1924,6 +1958,7 @@ function renderChrome () {
1924
1958
  document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
1925
1959
  $('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
1926
1960
  $('hint').innerHTML = `<ul>${T('hint').split(' · ').map((x) => `<li>${x}</li>`).join('')}<li><span class="kbd">C</span> ${esc(T('keyComment'))}</li><li><span class="kbd">?</span> ${esc(T('keyHelp'))}</li></ul>`;
1961
+ $('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', rvHintItem()); // review mode (Q)
1927
1962
  const ib = $('inboxBtn');
1928
1963
  if (app?.user) { ib.hidden = false; ib.href = `/${app.org}/inbox`; ib.innerHTML = `<svg class="i" viewBox="0 0 24 24"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>${app.unread ? `<b>${esc(app.unread)}</b>` : ''}`; ib.title = 'Inbox'; ib.setAttribute('aria-label', `Inbox${app.unread ? ` (${app.unread})` : ''}`); }
1929
1964
  else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
@@ -2418,6 +2453,9 @@ function setHash (p) {
2418
2453
  }
2419
2454
  function applyHash () {
2420
2455
  const p = new URLSearchParams(location.hash.slice(1));
2456
+ // review mode: #review (the first undecided step), #review=<step>, #review=end (the summary)
2457
+ if (p.has('review')) { const t = p.get('review') || null; if (!RV.open || (t && t !== 'end' && rvCur()?.step !== t)) rvOpen(t); return; }
2458
+ if (RV.open) { RV.open = false; $('rv').hidden = true; }
2421
2459
  if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
2422
2460
  const cmp = p.get('cmp');
2423
2461
  if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
@@ -2532,7 +2570,7 @@ function renderReport () {
2532
2570
  $('rside').innerHTML = `
2533
2571
  <div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
2534
2572
  ${APP() && isDraft() ? `<div class="sec"><h4>${T('draft')} #${esc(RUN.draft.seq)}</h4><p class="note">${T('draftInfo')}</p><div id="draftChanges" class="note"></div></div>` : ''}
2535
- ${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}</p>` : ''}
2573
+ ${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)}${RUN.verdict.via === 'token' || RUN.verdict.via === 'agent' ? ` <span class="badge st-skip">${T(RUN.verdict.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : ''} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}${RUN.verdict.approved_by_tokens?.length ? ` · ${T('approvedBy')} ${T('approvedByTokens')}: ${RUN.verdict.approved_by_tokens.map((a) => esc(a.email)).join(', ')}` : ''}</p>` : ''}
2536
2574
  ${canWrite() ? `<textarea id="rvVerdictNote" hidden rows="2" placeholder="${T('verdictNotePh')}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"></textarea>
2537
2575
  <div class="share__row"><button class="btn btn--primary" data-verdict="accepted">${isDesign() ? T('acceptVersion') : T('accept')}</button><button class="btn" data-verdict="returned">${isDesign() ? T('returnVersion') : T('ret')}</button></div>
2538
2576
  <div class="vmore"><button class="linkish" id="rvNoteShow">${T('addNote')}</button>${RUN.verdict?.verdict === 'accepted' && !isDesign() ? `<button class="linkish" data-baseline="${RUN.verdict.baseline ? 'unset' : 'set'}">${RUN.verdict.baseline ? T('unsetBaseline') : T('setBaseline')}</button>` : ''}${APP().user?.role === 'admin' ? `<button class="linkish" data-public="${RUN.public ? 'off' : 'on'}" title="${T('publicHint')}">${RUN.public ? T('makePrivate') : isDesign() ? T('makePublicVersion') : T('makePublic')}</button>` : ''}</div>` : ''}${RUN.public ? `<p class="note" style="margin:6px 0 0"><span class="badge st-partial">${T('publicRun')}</span> ${T('publicNote')}</p>` : ''}</div>` : ''}
@@ -2924,6 +2962,352 @@ addEventListener('resize', () => { if (!$('pt').hidden) fitPt(); });
2924
2962
  document.addEventListener('fullscreenchange', () => { const b = $('ptFull'); if (b) b.setAttribute('aria-pressed', String(!!document.fullscreenElement)); setTimeout(fitPt, 60); });
2925
2963
 
2926
2964
  /* ---------- boot ---------- */
2965
+ /* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ----------
2966
+ The queue comes from the service (GET …/runs/<id>/review-queue: order, reasons, decisions, approvals carried over
2967
+ unchanged steps, the accepted run to compare with); a local preview or a public run builds a read-only queue from
2968
+ the run itself. Keys: J/K (→/←) next and previous, A approve, R return with a note, C comment, V this run / side
2969
+ by side / the accepted run, Esc leaves; on a phone a swipe and the buttons do the same. Kept apart from the
2970
+ lightbox (its own overlay #rv and functions rv*), so both can change independently. */
2971
+ Object.assign(I18N.cs, {
2972
+ rvTitle: 'Kontrola', rvBtnTitle: 'Kontrola: krok po kroku to, co čeká na člověka', rvDecided: 'rozhodnuto', rvCarriedN: 'převzato',
2973
+ rvKeys: 'J K další a předchozí · A schválit · R vrátit · C komentář · V verze · Esc konec', rvExit: 'Ukončit kontrolu',
2974
+ rvPrev: 'Předchozí', rvNext: 'Další', rvApprove: 'Schválit', rvReturn: 'Vrátit', rvReturnTitle: 'Co se má změnit?',
2975
+ rvReturnPh: 'Napište, co je špatně a jak to má být. Autor běhu to uvidí hned.', rvReturnSend: 'Vrátit krok',
2976
+ rvAccepted: 'přijatý běh', rvViewThis: 'Tento běh', rvViewSide: 'Vedle sebe', rvViewRef: 'Přijatý běh',
2977
+ rvNoRef: 'V přijatém běhu tento snímek není.', rvRefLoading: 'Načítám přijatý běh…', rvLoading: 'Načítám, co čeká na kontrolu…',
2978
+ rvR_fail: 'Selhalo', rvR_returned: 'Vráceno', rvR_needs_review: 'Čeká na posouzení', rvR_not_evaluated: 'Nevyhodnoceno', rvR_revoked: 'Převzetí zrušeno',
2979
+ rvR_changed: 'Změna od přijatého běhu', rvR_new: 'Nový krok', rvR_comments: 'Otevřené komentáře', rvR_carried: 'Schválení převzato',
2980
+ rvWaiting: 'Čeká na rozhodnutí', rvApprovedBy: 'Schváleno', rvReturnedBy: 'Vráceno',
2981
+ rvCarriedStep: (run, by, at) => `Převzato z běhu ${run}, schválil(a) ${by} ${at}.`, rvCarriedRun: (run, by, at) => `Převzato z běhu ${run}, který ${by} přijal(a) ${at}.`,
2982
+ rvCarriedWhy: 'Snímky i kritéria jsou stejné jako v přijatém běhu a automatické kontroly prošly.',
2983
+ rvRevoke: 'Zrušit převzaté schválení', rvRevoked: 'Převzaté schválení je zrušené; krok čeká na kontrolu.',
2984
+ rvUnconfirmed: 'Rozhodnutí agenta nebo tokenu; za rozhodnutí člověka se nepočítá', rvChangedIn: 'Změněné snímky', rvCritChanged: 'Kritéria se od přijatého běhu změnila.',
2985
+ rvApprovedToast: 'Schváleno', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
2986
+ rvDone: 'Všechny kroky jsou rozhodnuté', rvLeft: (n) => `Na rozhodnutí ${n === 1 ? 'čeká 1 krok' : n < 5 ? `čekají ${n} kroky` : `čeká ${n} kroků`}`,
2987
+ rvNothing: 'Nic nečeká na člověka: kroky prošly a od přijatého běhu se nezměnily.', rvSumApproved: 'schváleno', rvSumReturned: 'vráceno', rvSumClear: 'beze změny a bez připomínek',
2988
+ rvCarriedTitle: 'Převzatá schválení', rvCarriedLead: 'Tyto kroky vypadají stejně jako v přijatém běhu a jejich automatické kontroly prošly. U každého je, kdo ho schválil.',
2989
+ rvShow: 'Zobrazit', rvVerdictLead: 'Převzatá schválení se za přijetí běhu nepočítají: běh jako celek přijímá člověk.',
2990
+ rvReturnedHint: 'Vrácené kroky uvidí autor běhu v inboxu a agent v kaloko feedback.', rvCarriedShort: 'převzato', rvToEnd: 'Souhrn', rvHint: 'kontrola krok po kroku', rvQueue: 'Kroky ke kontrole', rvRefFailed: 'Přijatý běh se nepodařilo načíst.', rvComparedWith: 'Porovnáno s přijatým během'
2991
+ })
2992
+ Object.assign(I18N.en, {
2993
+ rvTitle: 'Review', rvBtnTitle: 'Review: one step at a time, what waits for a person', rvDecided: 'decided', rvCarriedN: 'carried over',
2994
+ rvKeys: 'J K next and previous · A approve · R return · C comment · V version · Esc leave', rvExit: 'Leave review',
2995
+ rvPrev: 'Previous', rvNext: 'Next', rvApprove: 'Approve', rvReturn: 'Return', rvReturnTitle: 'What should change?',
2996
+ rvReturnPh: 'Say what is wrong and how it should be. The author of the run sees it right away.', rvReturnSend: 'Return the step',
2997
+ rvAccepted: 'accepted run', rvViewThis: 'This run', rvViewSide: 'Side by side', rvViewRef: 'Accepted run',
2998
+ rvNoRef: 'The accepted run has no such screenshot.', rvRefLoading: 'Loading the accepted run…', rvLoading: 'Loading what waits for review…',
2999
+ rvR_fail: 'Failed', rvR_returned: 'Returned', rvR_needs_review: 'Needs a person’s judgement', rvR_not_evaluated: 'Not evaluated', rvR_revoked: 'Carried approval revoked',
3000
+ rvR_changed: 'Changed since the accepted run', rvR_new: 'New step', rvR_comments: 'Open comments', rvR_carried: 'Approval carried over',
3001
+ rvWaiting: 'Waiting for a decision', rvApprovedBy: 'Approved', rvReturnedBy: 'Returned',
3002
+ rvCarriedStep: (run, by, at) => `Carried over from run ${run}: approved by ${by} on ${at}.`, rvCarriedRun: (run, by, at) => `Carried over from run ${run}, which ${by} accepted on ${at}.`,
3003
+ rvCarriedWhy: 'The screenshots and criteria are the same as in the accepted run and the automatic checks passed.',
3004
+ rvRevoke: 'Revoke the carried approval', rvRevoked: 'Carried approval revoked; the step waits for review.',
3005
+ rvUnconfirmed: 'An agent’s or token’s decision; it does not count as a person’s', rvChangedIn: 'Changed screenshots', rvCritChanged: 'The criteria changed since the accepted run.',
3006
+ rvApprovedToast: 'Approved', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
3007
+ rvDone: 'Every step is decided', rvLeft: (n) => `${n} step${n === 1 ? '' : 's'} still wait${n === 1 ? 's' : ''} for a decision`,
3008
+ rvNothing: 'Nothing waits for a person: the steps passed and did not change since the accepted run.', rvSumApproved: 'approved', rvSumReturned: 'returned', rvSumClear: 'unchanged, nothing to look at',
3009
+ rvCarriedTitle: 'Carried-over approvals', rvCarriedLead: 'These steps look the same as in the accepted run and their automatic checks passed. Each says who approved it.',
3010
+ rvShow: 'Show', rvVerdictLead: 'Carried approvals do not count as accepting the run: a person accepts the run as a whole.',
3011
+ rvReturnedHint: 'The author of the run sees returned steps in the inbox, the agent in kaloko feedback.', rvCarriedShort: 'carried over', rvToEnd: 'Summary', rvHint: 'review step by step', rvQueue: 'Steps to review', rvRefFailed: 'The accepted run could not be loaded.', rvComparedWith: 'Compared with the accepted run'
3012
+ })
3013
+ const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [] };
3014
+ const rvService = () => !!(APP()?.user && APP()?.run && !isDraft());
3015
+ const rvWritable = () => rvService() && canWrite();
3016
+ const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
3017
+ const rvCur = () => rvItems()[RV.idx] || null;
3018
+ const RV_GROUP = { fail: 0, returned: 0, needs_review: 1, not_evaluated: 1, revoked: 1, changed: 2, new: 2, comments: 3 };
3019
+ const rvDay = (iso) => (iso ? fmtDate(iso).replace(/ \d{2}:\d{2}$/, '') : '');
3020
+ const rvOpenCrit = (c) => Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
3021
+
3022
+ /** A read-only queue from the run itself (local preview, export, public run): no carry-over, no comparison. */
3023
+ function rvLocalQueue () {
3024
+ const items = []; let clear = 0;
3025
+ flowOrder().forEach((n, position) => {
3026
+ const vs = (n.criteria || []).map((c) => c.verdict).filter(Boolean);
3027
+ const reasons = [];
3028
+ if (n.status === 'fail' || vs.includes('fail')) reasons.push('fail');
3029
+ if (vs.includes('needs_review')) reasons.push('needs_review');
3030
+ if (vs.includes('not_evaluated')) reasons.push('not_evaluated');
3031
+ const st = reviewState(n.id);
3032
+ if (st === 'rejected') reasons.unshift('returned');
3033
+ const k = APP() ? stepOpenCount(n.id) : 0;
3034
+ if (k) reasons.push('comments');
3035
+ if (!reasons.length) { clear++; return; }
3036
+ items.push({ step: n.id, title: n.title, reasons, position, open_comments: k, decision: st ? { state: st === 'approved' ? 'approved' : 'returned' } : null, changed: null, ref_captures: null });
3037
+ });
3038
+ const g = (it) => Math.min(...it.reasons.map((r) => RV_GROUP[r] ?? 4));
3039
+ items.sort((a, b) => g(a) - g(b) || a.position - b.position);
3040
+ const decided = items.filter((it) => it.decision).length;
3041
+ return { local: true, run: { id: RUN.id }, reference: null, policy: { carry_over: false }, counts: { steps: screens().length, queue: items.length, decided, pending: items.length - decided, carried: 0, clear }, items, carried: [], verdict: RUN.verdict || null };
3042
+ }
3043
+
3044
+ async function rvLoad () {
3045
+ if (!rvService()) { RV.data = rvLocalQueue(); rvButton(); return RV.data; }
3046
+ const a = APP();
3047
+ const had = (RV.data?.carried || []).length;
3048
+ try {
3049
+ const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}/review-queue`, { credentials: 'same-origin' });
3050
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
3051
+ RV.data = await res.json();
3052
+ } catch (e) { if (!RV.data) RV.data = rvLocalQueue(); }
3053
+ rvButton();
3054
+ if ((RV.data.carried || []).length || had) renderNodes(); // the carried-over badges on the canvas
3055
+ return RV.data;
3056
+ }
3057
+ function rvButton () {
3058
+ const b = $('rvBtn'); if (!b) return;
3059
+ const n = RV.data?.counts?.pending || 0;
3060
+ b.title = `${T('rvBtnTitle')} (Q)`;
3061
+ b.querySelector('.rv-count')?.remove();
3062
+ if (n) b.insertAdjacentHTML('beforeend', `<b class="rv-count" aria-label="${esc(T('rvLeft')(n))}">${n}</b>`);
3063
+ }
3064
+ /** The canvas card of a step whose approval carried over from the accepted run. */
3065
+ function rvNodeBadge (id) {
3066
+ const c = (RV.data?.carried || []).find((x) => x.step === id); if (!c) return '';
3067
+ const why = (c.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(c.from_run, c.approved_by, rvDay(c.approved_at));
3068
+ return `<span class="badge st-pass rv-carry" title="${esc(why)}">✓ ${esc(T('rvCarriedShort'))}</span>`;
3069
+ }
3070
+ const rvHintItem = () => `<li><span class="kbd">Q</span> ${esc(T('rvHint'))}</li>`;
3071
+ /** An item for a step outside the queue (a carried or clear step opened from the summary or a link). */
3072
+ function rvExtraFor (step) {
3073
+ const n = byId(step); if (!n || n.kind === 'decision') return null;
3074
+ const c = (RV.data?.carried || []).find((x) => x.step === step);
3075
+ return { step, title: n.title, reasons: c ? ['carried'] : [], extra: true, decision: c ? { state: 'carried', by: c.approved_by, via: c.approved_via, at: c.approved_at, from_run: c.from_run } : null, carried: c || null, ref_captures: null };
3076
+ }
3077
+
3078
+ async function rvOpen (target = null) {
3079
+ if (!RUN) return;
3080
+ if (!$('lb').hidden) closeLb();
3081
+ if (S.rp) rpClose();
3082
+ if (!$('br').hidden) closeBrowser();
3083
+ RV.open = true; $('rv').hidden = false;
3084
+ if (!RV.data) { $('rv').innerHTML = `<p class="rvm__loading">${esc(T('rvLoading'))}</p>`; await rvLoad(); }
3085
+ if (!RV.open) return;
3086
+ let items = rvItems();
3087
+ if (target === 'end') RV.idx = items.length;
3088
+ else if (target && target !== '1') {
3089
+ let k = items.findIndex((it) => it.step === target);
3090
+ if (k < 0) { const x = rvExtraFor(target); if (x) { RV.extra.push(x); items = rvItems(); k = items.length - 1; } }
3091
+ RV.idx = k >= 0 ? k : 0;
3092
+ } else { const k = items.findIndex((it) => !it.decision); RV.idx = k >= 0 ? k : items.length; }
3093
+ rvRender();
3094
+ $('rvClose')?.focus({ preventScroll: true });
3095
+ }
3096
+ function rvClose () {
3097
+ RV.open = false; RV.ret = false; RV.extra = [];
3098
+ $('rv').hidden = true; $('rv').innerHTML = '';
3099
+ setHash(null);
3100
+ $('rvBtn')?.focus({ preventScroll: true });
3101
+ }
3102
+ function rvGo (d) {
3103
+ const n = rvItems().length;
3104
+ const k = Math.max(0, Math.min(n, RV.idx + d));
3105
+ if (k === RV.idx) return;
3106
+ RV.idx = k; RV.ret = false; rvRender();
3107
+ }
3108
+ /** The accepted run's files: a signed base like compare uses, loaded once per run. */
3109
+ function rvRefBase (id) {
3110
+ if (id in RV.refBase) return RV.refBase[id];
3111
+ RV.refBase[id] = undefined;
3112
+ cmpBase(id).then((b) => { RV.refBase[id] = b; }).catch(() => { RV.refBase[id] = null; }).then(() => { if (RV.open) rvRender(); });
3113
+ return undefined;
3114
+ }
3115
+ const rvRefSrc = (base, p) => base && typeof p === 'string' && /^(steps|attachments)\/[A-Za-z0-9._\/-]+\.(png|jpe?g|webp)$/.test(p) && !p.includes('..') ? base + p : null;
3116
+
3117
+ function rvDecisionHtml (it) {
3118
+ const d = it.decision;
3119
+ const via = (x) => x?.via === 'agent' || x?.via === 'token' ? ` <span class="badge st-skip">${T(x.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : '';
3120
+ let h;
3121
+ if (!d) h = `<p><span class="badge st-skip">○ ${esc(T('rvWaiting'))}</span></p>`;
3122
+ else if (d.state === 'carried') {
3123
+ const c = it.carried || { kind: 'step', from_run: d.from_run, approved_by: d.by, approved_at: d.at };
3124
+ h = `<p><span class="badge st-pass">✓ ${esc(T('rvR_carried'))}</span>${via(d)}</p><p class="note">${esc((c.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(c.from_run, c.approved_by, rvDay(c.approved_at)))} ${esc(T('rvCarriedWhy'))}</p>${rvWritable() ? `<p><button class="linkish" id="rvRevoke">${esc(T('rvRevoke'))}</button></p>` : ''}`;
3125
+ } else h = `<p><span class="badge st-${d.state === 'approved' ? 'pass' : 'fail'}">${d.state === 'approved' ? '✓' : '↩'} ${esc(T(d.state === 'approved' ? 'rvApprovedBy' : 'rvReturnedBy'))}</span>${via(d)} <span class="note">${esc([d.by, rvDay(d.at)].filter(Boolean).join(' · '))}</span></p>${d.note ? `<p class="note">${noteHtml(d.note)}</p>` : ''}`;
3126
+ if (it.unconfirmed) h += `<p class="note">${esc(T('rvUnconfirmed'))}: ${esc(it.unconfirmed.state === 'approved' ? T('rvApprovedBy') : T('rvReturnedBy'))} · ${esc(it.unconfirmed.by || '')}${it.unconfirmed.agent ? ` (${esc(it.unconfirmed.agent)})` : ''}</p>`;
3127
+ return `<div class="sec rvm__dec">${h}</div>`;
3128
+ }
3129
+
3130
+ function rvRender () {
3131
+ const box = $('rv'); if (!box || !RV.data) return;
3132
+ const d = RV.data, items = rvItems(), total = d.items.length, c = d.counts || {};
3133
+ const top = `<div class="rvm__top"><span class="brand">Kaloko <small>${esc(T('rvTitle'))}</small></span>
3134
+ <span class="rvm__prog" role="status" aria-live="polite"><b>${esc(c.decided || 0)}</b> ${esc(T('of'))} ${esc(total)} ${esc(T('rvDecided'))}</span><span class="rvm__bar" aria-hidden="true"><i style="width:${total ? Math.round((c.decided || 0) / total * 100) : 100}%"></i></span>
3135
+ ${c.carried ? `<span class="badge st-pass" title="${esc(T('rvCarriedLead'))}">✓ ${esc(c.carried)} ${esc(T('rvCarriedN'))}</span>` : ''}<span class="spacer"></span>
3136
+ <div class="seg" id="rvLoc"></div><div class="seg" id="rvVp"></div><div class="seg" id="rvView"></div><span class="note rvm__keys">${esc(T('rvKeys'))}</span>
3137
+ <button class="icon-btn" id="rvClose" aria-label="${esc(T('rvExit'))}" title="${esc(T('rvExit'))} (Esc)">${ICON.close}</button></div>`;
3138
+ const it = items[RV.idx];
3139
+ if (!it) { box.innerHTML = top + `<div class="rvm__wrap">${rvEndHtml()}</div>${rvFootHtml(null)}`; rvWire(null); setHash({ review: 'end' }); return; }
3140
+ const n = byId(it.step);
3141
+ if (!n) { RV.idx = Math.min(RV.idx + 1, items.length); return rvRender(); }
3142
+ const vps = viewports().filter((x) => n.captures?.[S.loc]?.[x.name]);
3143
+ if (!RV.vp || !vps.some((x) => x.name === RV.vp)) RV.vp = (vps.find((x) => x.name === (S.vp === 'mobile' ? mVp() : dVp())?.name) || vps[0] || viewports()[0]).name;
3144
+ const v = viewports().find((x) => x.name === RV.vp) || viewports()[0];
3145
+ const cap = n.captures?.[S.loc]?.[v.name];
3146
+ const ref = d.reference; const refPath = it.ref_captures?.[S.loc]?.[v.name] || null;
3147
+ const base = ref && refPath ? rvRefBase(ref.id) : null;
3148
+ const refSrc = rvRefSrc(base, refPath);
3149
+ const view = refPath ? (RV.view || (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this')) : 'this';
3150
+ const shotHtml = n.kind === 'stack'
3151
+ ? `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(n.stack?.count || 0)} ${T('pagesWord')}</div><div class="stack-fan" style="max-width:520px;height:220px;margin:12px auto">${(n.stack?.sample || []).slice(0, 3).map((x) => x.thumb ? `<img src="${esc(asset(x.thumb))}" alt="" style="height:180px">` : '<span class="ph" style="height:180px"></span>').join('')}</div><button class="btn btn--primary" id="rvStack">${T('openIndex')}</button></div>`
3152
+ : cap ? frameImg(cap, v, `<img src="${esc(asset(cap.image || cap.png))}" alt="${esc(loc(n.title))}">`, 'rvImg') : `<p class="lb__empty">${T('notReached')}</p>`;
3153
+ const refHtml = refSrc ? `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}" style="max-width:${v.mobile ? v.w + 16 : v.w}px"><img src="${esc(refSrc)}" alt="${esc(T('rvAccepted'))}"></div>` : `<p class="note">${esc(base === null ? T('rvRefFailed') : T('rvRefLoading'))}</p>`;
3154
+ const refCap = ref ? `<b>${esc(T('rvViewRef'))}</b> ${fmtDate(ref.started_at)}${ref.accepted_by ? ` · ${esc(ref.accepted_by)}` : ''}` : '';
3155
+ const stage = view === 'side' ? `<div class="lb__cmp"><figure><figcaption><b>${esc(T('rvViewThis'))}</b> ${fmtDate(RUN.started_at)}</figcaption>${shotHtml}</figure><figure><figcaption>${refCap}</figcaption>${refHtml}</figure></div>`
3156
+ : view === 'ref' ? `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption>${refCap}</figcaption>${refHtml}</figure></div>` : shotHtml;
3157
+ const s = nodeSt(n);
3158
+ const chips = it.reasons.map((r) => `<span class="badge st-${['fail', 'returned'].includes(r) ? 'fail' : ['needs_review', 'not_evaluated', 'revoked', 'changed'].includes(r) ? 'partial' : r === 'carried' ? 'pass' : 'skip'}">${esc(T('rvR_' + r))}</span>`).join('');
3159
+ const changed = it.changed ? `<div class="sec"><h4>${esc(T('rvChangedIn'))}</h4><p class="note">${(it.changed.captures || []).map((x) => `${esc(x.locale)}/${esc(x.viewport)}`).join(', ') || '—'}${it.changed.criteria ? `<br>${esc(T('rvCritChanged'))}` : ''}</p></div>` : '';
3160
+ const crit = n.kind === 'decision' ? '' : `<div class="sec"><h4>${T('criteria')} ${n.kind === 'stack' ? '' : critCounts(n, v)}</h4><ul class="ac">${(n.kind === 'stack' ? n.criteria : critOrder(n, v)).map((x) => { const r = x.by_locale?.[S.loc]?.[v.name] || {}; const st = n.kind === 'stack' ? critSt(x) : VSTAT[r.verdict || 'not_reached']; const ev = r.evidence || r.note || ''; return `<li class="st-${st}"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(x.id)}</span>${esc(critText(x))}${r.score != null ? ` <span class="id">${esc(Number(r.score).toFixed(2))}</span>` : ''}</span>${ev ? (humanEvidence(ev) ? `<span class="ev ev--text">${esc(humanEvidence(ev))}</span>` : `<code class="ev">${esc(ev)}</code>`) : ''}</li>`; }).join('') || `<li class="st-pass"><span class="mark">–</span><span>—</span></li>`}</ul></div>`;
3161
+ const retBox = RV.ret && rvWritable() ? `<div class="sec rvm__ret"><h4><label for="rvRetNote">${esc(T('rvReturnTitle'))}</label></h4><textarea id="rvRetNote" rows="3" placeholder="${esc(T('rvReturnPh'))}">${esc(loadDraft(draftKey('step', n.id, 'return')))}</textarea><div class="share__row" style="margin-top:6px"><button class="btn btn--primary" id="rvRetSend">${esc(T('rvReturnSend'))}</button><button class="btn" id="rvRetCancel">${esc(T('cancel'))}</button><span class="note">${T('ctrlEnter')}</span></div></div>` : '';
3162
+ box.innerHTML = top + `<div class="rvm__body"><div class="rvm__stage" id="rvStage">${stage}</div>
3163
+ <aside class="rvm__side" id="rvSide"><div class="lb__head"><div class="lb__row"><span class="kind">${ICON[n.kind] || ''}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${esc(RV.idx + 1)} / ${esc(items.length)}</span></div>
3164
+ <h3 id="rvStepTitle">${esc(loc(n.title))}</h3><div class="node__path">${esc(cap?.url || loc(n.path) || '')}</div><div class="rvm__why">${chips}</div></div>
3165
+ ${retBox}${rvDecisionHtml(it)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
3166
+ ${n.kind !== 'stack' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}</aside></div>${rvFootHtml(it)}`;
3167
+ rvWire(it, n, v);
3168
+ setHash({ review: it.step });
3169
+ }
3170
+ function rvFootHtml (it) {
3171
+ const items = rvItems();
3172
+ const act = it && rvWritable() ? `<button class="btn" id="rvRet">${esc(T('rvReturn'))} <span class="kbd">R</span></button><button class="btn" id="rvCmt">${ICON.comment} ${esc(T('comment'))} <span class="kbd">C</span></button><button class="btn btn--primary" id="rvOk">${esc(T('rvApprove'))} <span class="kbd">A</span></button>`
3173
+ : it ? `<span class="note">${esc(T(rvService() ? 'rvReadOnly' : 'rvLocal'))}</span>` : '';
3174
+ return `<div class="rvm__foot"><button class="btn" id="rvPrev" ${RV.idx <= 0 ? 'disabled' : ''}>${ICON.prev} ${esc(T('rvPrev'))} <span class="kbd">K</span></button><span class="spacer"></span>${act}${RV.idx < items.length ? `<button class="btn" id="rvNext">${esc(RV.idx === items.length - 1 ? T('rvToEnd') : T('rvNext'))} ${ICON.next} <span class="kbd">J</span></button>` : ''}</div>`;
3175
+ }
3176
+ function rvEndHtml () {
3177
+ const d = RV.data, c = d.counts || {}, items = d.items || [];
3178
+ const approved = items.filter((it) => it.decision?.state === 'approved' || it.decision?.state === 'carried').length;
3179
+ const returned = items.filter((it) => it.decision?.state === 'returned').length;
3180
+ const head = !items.length ? T('rvNothing') : c.pending ? T('rvLeft')(c.pending) : T('rvDone');
3181
+ const sum = [`${approved} ${T('rvSumApproved')}`, `${returned} ${T('rvSumReturned')}`, c.carried ? `${c.carried} ${T('rvCarriedN')}` : '', c.clear ? `${c.clear} ${T('rvSumClear')}` : ''].filter(Boolean).join(' · ');
3182
+ const list = items.length ? `<div class="sec"><h4>${esc(T('rvQueue'))}</h4><ul class="rvm__list">${items.map((it, k) => `<li><span class="badge st-${!it.decision ? 'skip' : it.decision.state === 'returned' ? 'fail' : 'pass'}">${!it.decision ? '○' : it.decision.state === 'returned' ? '↩' : '✓'}</span><button class="linkish" data-rv-go="${k}">${esc(loc(byId(it.step)?.title) || it.step)}</button><span class="note">${it.reasons.map((r) => esc(T('rvR_' + r))).join(', ')}</span></li>`).join('')}</ul></div>` : '';
3183
+ const carried = (d.carried || []).length ? `<div class="sec"><h4>${esc(T('rvCarriedTitle'))}</h4><p class="note">${esc(T('rvCarriedLead'))}</p><ul class="rvm__list">${d.carried.map((x) => `<li><span class="badge st-pass">✓</span><button class="linkish" data-rv-show="${esc(x.step)}">${esc(loc(byId(x.step)?.title) || x.step)}</button><span class="note">${esc((x.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(x.from_run, x.approved_by, rvDay(x.approved_at)))}</span>${rvWritable() ? `<button class="linkish" data-rv-revoke="${esc(x.step)}">${esc(T('rvRevoke'))}</button>` : ''}</li>`).join('')}</ul></div>` : '';
3184
+ const v = RUN.verdict || d.verdict || {};
3185
+ const verdict = rvWritable() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${T('runVerdict')}</span><span class="badge st-${v.verdict === 'accepted' ? 'pass' : v.verdict === 'returned' ? 'fail' : 'skip'}">${T(v.verdict || 'pending')}</span></h4><p class="note">${esc(T('rvVerdictLead'))}${returned ? ` ${esc(T('rvReturnedHint'))}` : ''}</p>${v.approvers ? `<p class="note">${v.awaiting?.length ? `${T('awaiting')}: <b>${v.awaiting.map(esc).join(', ')}</b>` : T('approvedByAll')}</p>` : ''}
3186
+ <textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"></textarea>
3187
+ <div class="share__row"><button class="btn btn--primary" data-rv-verdict="accepted">${T('accept')}</button><button class="btn" data-rv-verdict="returned">${T('ret')}</button></div></div>` : '';
3188
+ return `<div class="rvm__end"><h3>${esc(head)}</h3><p class="note">${esc(sum)}</p>${d.reference ? `<p class="note">${esc(T('rvComparedWith'))}: <a class="pr" href="${runHref(d.reference.id)}">${fmtDate(d.reference.started_at)}${d.reference.accepted_by ? ` · ${esc(d.reference.accepted_by)}` : ''}</a></p>` : ''}${list}${carried}${verdict}</div>`;
3189
+ }
3190
+ function rvWire (it, n, v) {
3191
+ const box = $('rv');
3192
+ seg($('rvLoc'), RUN.scope.locales.map((l) => [l, l]), S.loc, (l) => { S.loc = l; renderAll(); rvRender(); });
3193
+ $('rvLoc').hidden = RUN.scope.locales.length < 2;
3194
+ if (n && n.kind !== 'stack') { const vps = viewports().filter((x) => n.captures?.[S.loc]?.[x.name]); seg($('rvVp'), vps.map((x) => [x.name, vpLabel(x)]), RV.vp, (nv) => { RV.vp = nv; rvRender(); }); $('rvVp').hidden = vps.length < 2; } else $('rvVp').hidden = true;
3195
+ const hasRef = !!(it && it.ref_captures?.[S.loc]?.[RV.vp]);
3196
+ if (hasRef) seg($('rvView'), [['this', T('rvViewThis')], ['side', T('rvViewSide')], ['ref', T('rvViewRef')]], RV.view || (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this'), (m) => { RV.view = m; rvRender(); });
3197
+ $('rvView').hidden = !hasRef;
3198
+ $('rvClose').onclick = rvClose;
3199
+ $('rvPrev').onclick = () => rvGo(-1);
3200
+ if ($('rvNext')) $('rvNext').onclick = () => rvGo(1);
3201
+ if ($('rvOk')) $('rvOk').onclick = rvApprove;
3202
+ if ($('rvRet')) $('rvRet').onclick = rvReturn;
3203
+ if ($('rvCmt')) $('rvCmt').onclick = rvComment;
3204
+ if ($('rvRevoke')) $('rvRevoke').onclick = () => rvRevoke(it.step);
3205
+ if ($('rvStack')) $('rvStack').onclick = () => { const id = it.step; rvClose(); openStack(id); };
3206
+ if ($('rvRetSend')) {
3207
+ const t = $('rvRetNote'), k = draftKey('step', it.step, 'return');
3208
+ t.addEventListener('input', () => saveDraft(k, t.value));
3209
+ t.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); rvReturnSend(); } });
3210
+ $('rvRetSend').onclick = rvReturnSend;
3211
+ $('rvRetCancel').onclick = () => { RV.ret = false; rvRender(); };
3212
+ }
3213
+ box.querySelectorAll('[data-rv-go]').forEach((b) => { b.onclick = () => { RV.idx = Number(b.dataset.rvGo); rvRender(); }; });
3214
+ box.querySelectorAll('[data-rv-show]').forEach((b) => { b.onclick = () => rvOpen(b.dataset.rvShow); });
3215
+ box.querySelectorAll('[data-rv-revoke]').forEach((b) => { b.onclick = () => rvRevoke(b.dataset.rvRevoke); });
3216
+ box.querySelectorAll('[data-rv-verdict]').forEach((b) => { b.onclick = () => rvVerdict(b.dataset.rvVerdict, b); });
3217
+ if (!box.dataset.rvWired) {
3218
+ box.dataset.rvWired = '1';
3219
+ wireComments(box);
3220
+ // a phone: a swipe sideways on the screenshot moves to the next or previous step (not when it pans or zooms)
3221
+ let sw = null;
3222
+ box.addEventListener('touchstart', (e) => { sw = e.touches.length === 1 && e.target.closest('#rvStage') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
3223
+ box.addEventListener('touchend', (e) => {
3224
+ const s0 = sw; sw = null; if (!s0) return;
3225
+ const t = e.changedTouches[0], dx = t.clientX - s0.x, dy = t.clientY - s0.y, sc = $('rvStage');
3226
+ if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - s0.t > 900) return;
3227
+ if (sc && sc.scrollWidth > sc.clientWidth + 2) return;
3228
+ rvGo(dx < 0 ? 1 : -1);
3229
+ }, { passive: true });
3230
+ }
3231
+ if (RV.ret) requestAnimationFrame(() => $('rvRetNote')?.focus());
3232
+ $('rvStage')?.scrollTo?.(0, 0);
3233
+ }
3234
+ function rvKey (e) {
3235
+ const k = e.key;
3236
+ if (k === 'Escape') { e.preventDefault(); if (RV.ret) { RV.ret = false; rvRender(); } else rvClose(); return; }
3237
+ if (k === 'j' || k === 'J' || k === 'ArrowRight') { e.preventDefault(); rvGo(1); return; }
3238
+ if (k === 'k' || k === 'K' || k === 'ArrowLeft') { e.preventDefault(); rvGo(-1); return; }
3239
+ if (!rvCur()) return;
3240
+ if (k === 'a' || k === 'A') { e.preventDefault(); rvApprove(); }
3241
+ else if (k === 'r' || k === 'R') { e.preventDefault(); rvReturn(); }
3242
+ else if (k === 'c' || k === 'C') { e.preventDefault(); rvComment(); }
3243
+ else if ((k === 'v' || k === 'V') && $('rvView') && !$('rvView').hidden) { e.preventDefault(); const order = ['this', 'side', 'ref']; const cur = $('rvView').querySelector('[aria-pressed="true"]')?.dataset.v || 'this'; RV.view = order[(order.indexOf(cur) + 1) % 3]; rvRender(); }
3244
+ }
3245
+ /** After a decision: the queue and the run again, then the next step nobody decided yet. */
3246
+ async function rvAfter (step) {
3247
+ await Promise.all([reloadRun().catch(() => {}), rvLoad()]);
3248
+ if (!RV.open) return;
3249
+ const items = rvItems(); const at = items.findIndex((it) => it.step === step);
3250
+ const next = items.findIndex((it, k) => k > at && !it.decision);
3251
+ const first = items.findIndex((it) => !it.decision);
3252
+ RV.idx = next >= 0 ? next : first >= 0 ? first : items.length;
3253
+ RV.ret = false; rvRender();
3254
+ }
3255
+ async function rvApprove () {
3256
+ const it = rvCur(); if (!it || !rvWritable() || RV.busy) return;
3257
+ const n = byId(it.step); if (!n) return;
3258
+ RV.busy = true; const b = $('rvOk'); if (b) b.disabled = true;
3259
+ try {
3260
+ // the person's look answers what the evaluator could not decide: open criteria pass, then the step is approved
3261
+ if (n.kind !== 'stack') for (const c of (n.criteria || []).filter((x) => x.type !== 'deterministic' && rvOpenCrit(x))) await postJson('/criteria', { node_id: n.id, criterion_id: c.id, verdict: 'pass', note: '' });
3262
+ await cApi('step', '', 'POST', { node_id: n.id, status: 'approved', note: '' });
3263
+ toast(T('rvApprovedToast'));
3264
+ await rvAfter(it.step);
3265
+ } catch (e) { toast(e.message); if (b) b.disabled = false; } finally { RV.busy = false; }
3266
+ }
3267
+ function rvReturn () { if (!rvCur() || !rvWritable()) return; RV.ret = true; rvRender(); }
3268
+ async function rvReturnSend () {
3269
+ const it = rvCur(); const t = $('rvRetNote'); if (!it || !t || RV.busy) return;
3270
+ const note = t.value.trim();
3271
+ if (!note) { toast(T('noteRequired')); t.focus(); return; }
3272
+ RV.busy = true; $('rvRetSend').disabled = true;
3273
+ try {
3274
+ await cApi('step', '', 'POST', { node_id: it.step, status: 'rejected', note, locale: S.loc, viewport: RV.vp || null });
3275
+ saveDraft(draftKey('step', it.step, 'return'), '');
3276
+ toast(T('rvReturnedToast'));
3277
+ await rvAfter(it.step);
3278
+ } catch (e) { toast(e.message); $('rvRetSend') && ($('rvRetSend').disabled = false); } finally { RV.busy = false; }
3279
+ }
3280
+ function rvComment () {
3281
+ const t = $('rv').querySelector('.c-main .c-text'); if (!t) return;
3282
+ if (lbPhone()) t.scrollIntoView({ behavior: 'smooth', block: 'center' });
3283
+ t.focus({ preventScroll: !lbPhone() });
3284
+ }
3285
+ async function rvRevoke (step) {
3286
+ if (!rvWritable() || RV.busy) return;
3287
+ RV.busy = true;
3288
+ try {
3289
+ await postJson(`/carried/${encodeURIComponent(step)}/revoke`, {});
3290
+ toast(T('rvRevoked'));
3291
+ RV.extra = RV.extra.filter((x) => x.step !== step);
3292
+ await rvLoad();
3293
+ const k = rvItems().findIndex((it) => it.step === step);
3294
+ if (k >= 0) RV.idx = k;
3295
+ rvRender();
3296
+ } catch (e) { toast(e.message); } finally { RV.busy = false; }
3297
+ }
3298
+ async function rvVerdict (verdict, b) {
3299
+ const note = $('rvVNote')?.value.trim() || '';
3300
+ if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
3301
+ b.disabled = true;
3302
+ try { await postJson('/verdict', { verdict, note }); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
3303
+ }
3304
+ /** Starts review mode support once the run is on screen: the button, its count and the hash. */
3305
+ function rvInit () {
3306
+ $('rvBtn').onclick = () => rvOpen(null);
3307
+ // the count on the button and the carried-over badges follow the first paint
3308
+ if (rvService()) setTimeout(() => { rvLoad(); }, 0); else { RV.data = rvLocalQueue(); rvButton(); }
3309
+ }
3310
+
2927
3311
  let tt; function toast (msg) { const el = $('toast'); el.textContent = msg; el.hidden = false; clearTimeout(tt); tt = setTimeout(() => { el.hidden = true; }, 2600); }
2928
3312
  async function load () {
2929
3313
  if (EMBEDDED) return EMBEDDED;
@@ -3018,6 +3402,7 @@ load().then((data) => {
3018
3402
  $('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
3019
3403
  $('rpBtn').onclick = () => rpOpen(null);
3020
3404
  $('ptBtn').hidden = !isDesign(); $('ptBtn').onclick = () => ptOpen(null);
3405
+ rvInit(); // review mode (Q)
3021
3406
  wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
3022
3407
  // small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
3023
3408
  // wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
@@ -3041,6 +3426,7 @@ load().then((data) => {
3041
3426
  // (the draft stays); the next Escape closes the overlay. Search boxes keep their own arrow and Enter keys.
3042
3427
  const field = e.target.closest?.('textarea,select,[contenteditable="true"],input:not([type=checkbox]):not([type=radio]):not([type=button])');
3043
3428
  if (field && !['siQ', 'riQ', 'brSearch'].includes(field.id)) { if (e.key === 'Escape') { e.preventDefault(); field.blur(); } return; }
3429
+ if (RV.open) { if (!e.metaKey && !e.ctrlKey && !e.altKey) rvKey(e); return; }
3044
3430
  if (!$('pt').hidden) { if (!e.metaKey && !e.ctrlKey && !e.altKey) ptKey(e); return; }
3045
3431
  if (!$('si').hidden) { if (stackKey(e)) return; if (e.target.closest('input,select,textarea')) return; return; }
3046
3432
  if (S.rp && !$('rp').hidden) { if (!e.target.closest('input,select,textarea')) { e.preventDefault(); rpKey(e); } return; }
@@ -3059,6 +3445,7 @@ load().then((data) => {
3059
3445
  if (S.mode === 'index' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (S.st?.inline ? stackKey(e) : riKey(e)) return; }
3060
3446
  if (e.target.closest('input,select,textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
3061
3447
  if (e.key === 'i' || e.key === 'I') { setMode(S.mode === 'index' ? 'map' : 'index'); return; }
3448
+ if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); rvOpen(null); return; }
3062
3449
  if (e.key === '?') { e.preventDefault(); $('helpBtn').click(); return; }
3063
3450
  if (e.key === 'f' || e.key === 'F') { const any = S.left || S.right; S.left = S.right = !any; setPanels(); }
3064
3451
  if (e.key === 'k' || e.key === 'K' || (e.key === '/' && S.mode !== 'index')) { e.preventDefault(); openBrowser(); }