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 +18 -1
- package/package.json +1 -1
- package/skills/kaloko/SKILL.md +12 -5
- package/skills/kaloko/reference/collaboration.md +45 -2
- package/skills/kaloko/reference/evaluation.md +5 -0
- package/skills/kaloko/reference/first-run.md +51 -0
- package/skills/kaloko/reference/mcp.md +5 -0
- package/src/cli.mjs +49 -5
- package/src/lib/onboarding.mjs +33 -0
- package/src/lib/review.mjs +69 -0
- package/viewer/index.html +391 -4
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
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -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,
|
|
32
|
-
|
|
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.
|
|
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,
|
|
79
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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>` :
|
|
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(); }
|