kaloko 0.7.1 → 0.9.0
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 +3 -1
- package/package.json +2 -2
- package/schemas/scenario.v1.json +15 -1
- package/skills/kaloko/reference/acceptance.md +32 -2
- package/src/cli.mjs +30 -21
- package/src/lib/access.mjs +1 -1
- package/src/lib/apps/drivers.mjs +1 -1
- package/src/lib/apps/webview.mjs +3 -3
- package/src/lib/browser.mjs +14 -2
- package/src/lib/build.mjs +8 -5
- package/src/lib/changelog.mjs +30 -9
- package/src/lib/design-export.mjs +2 -2
- package/src/lib/dimensions.mjs +2 -1
- package/src/lib/docs-core.mjs +9 -6
- package/src/lib/docs.mjs +4 -2
- package/src/lib/doctor.mjs +72 -10
- package/src/lib/evaluate.mjs +11 -6
- package/src/lib/figma.mjs +1 -1
- package/src/lib/integrations-cli.mjs +3 -3
- package/src/lib/login.mjs +1 -1
- package/src/lib/mail.mjs +1 -1
- package/src/lib/manifest-core.mjs +11 -6
- package/src/lib/pack-help.mjs +5 -0
- package/src/lib/packs.mjs +32 -8
- package/src/lib/run.mjs +1 -1
- package/src/lib/scenario.mjs +63 -1
- package/src/lib/service.mjs +39 -9
- package/src/lib/stability.mjs +2 -2
- package/src/lib/stack.mjs +11 -1
- package/src/lib/unzip.mjs +22 -4
- package/src/lib/util.mjs +6 -0
- package/src/lib/video.mjs +11 -2
- package/src/lib/vision.mjs +1 -1
- package/templates/scenario.yml +1 -0
- package/viewer/index.html +154 -28
package/src/lib/stack.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
|
|
|
66
66
|
if (first.index) { locs = []; for (const u of first.locs.slice(0, 50)) locs.push(...sitemapLocs(await get(u)).locs) }
|
|
67
67
|
raw = locs.map((u) => ({ path: u }))
|
|
68
68
|
} else if (src.crawl) {
|
|
69
|
-
if (!page) throw new UserError('items.crawl needs
|
|
69
|
+
if (!page) throw new UserError('items.crawl needs the browser. Walk the step with kaloko walk (not offline), or list the pages with items.file or items.sitemap.')
|
|
70
70
|
let url = toUrl(src.crawl.from)
|
|
71
71
|
for (let i = 0; i < (src.crawl.pages || 1) && url; i++) {
|
|
72
72
|
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
|
@@ -129,3 +129,13 @@ export function readFields (specs) {
|
|
|
129
129
|
}
|
|
130
130
|
return out
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/** "about 4 min left" after `done` of `total` pages took `ms`; '' before the first page or after the last. */
|
|
134
|
+
export function timeLeft (done, total, ms) {
|
|
135
|
+
if (!(done > 0) || done >= total || !(ms >= 0)) return ''
|
|
136
|
+
const s = Math.round((ms / done) * (total - done) / 1000)
|
|
137
|
+
if (s < 60) return `about ${Math.max(5, Math.round(s / 5) * 5)} s left`
|
|
138
|
+
if (s < 3600) return `about ${Math.round(s / 60)} min left`
|
|
139
|
+
const h = Math.floor(s / 3600); const m = Math.round((s % 3600) / 60)
|
|
140
|
+
return `about ${h} h${m ? ` ${m} min` : ''} left`
|
|
141
|
+
}
|
package/src/lib/unzip.mjs
CHANGED
|
@@ -2,30 +2,48 @@
|
|
|
2
2
|
// deflated entries from the central directory. No ZIP64; entries are read when asked for.
|
|
3
3
|
import { inflateRawSync } from 'node:zlib'
|
|
4
4
|
|
|
5
|
+
// a zip comes from someone else (a trace, a downloaded export): an entry inflates to at most what the central directory
|
|
6
|
+
// declares, the declarations have a ceiling, and entries may not share their data (overlapping entries multiply a small
|
|
7
|
+
// deflate stream, the "non-recursive zip bomb")
|
|
8
|
+
export const MAX_ENTRY_BYTES = 1024 * 1024 * 1024
|
|
9
|
+
export const MAX_TOTAL_BYTES = 4 * 1024 * 1024 * 1024
|
|
10
|
+
|
|
5
11
|
/** Entries of a zip archive: Map name → () => Buffer. */
|
|
6
12
|
export function readZip (buf) {
|
|
7
13
|
let eocd = -1
|
|
8
14
|
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
|
|
9
15
|
if (eocd < 0) throw new Error('not a zip archive (no end of central directory)')
|
|
16
|
+
const broken = (why) => new Error(`broken zip archive: ${why}`)
|
|
10
17
|
const count = buf.readUInt16LE(eocd + 10)
|
|
11
18
|
let at = buf.readUInt32LE(eocd + 16)
|
|
12
19
|
const out = new Map()
|
|
20
|
+
const spans = []
|
|
21
|
+
let total = 0
|
|
13
22
|
for (let n = 0; n < count; n++) {
|
|
14
|
-
if (buf.readUInt32LE(at) !== 0x02014b50) throw new Error('broken zip central directory')
|
|
23
|
+
if (at + 46 > eocd || buf.readUInt32LE(at) !== 0x02014b50) throw new Error('broken zip central directory')
|
|
15
24
|
const method = buf.readUInt16LE(at + 10)
|
|
16
25
|
const size = buf.readUInt32LE(at + 20)
|
|
26
|
+
const declared = buf.readUInt32LE(at + 24)
|
|
17
27
|
const nameLen = buf.readUInt16LE(at + 28); const extraLen = buf.readUInt16LE(at + 30); const commentLen = buf.readUInt16LE(at + 32)
|
|
18
28
|
const local = buf.readUInt32LE(at + 42)
|
|
29
|
+
if (at + 46 + nameLen > eocd) throw broken('a name runs past the central directory')
|
|
19
30
|
const name = buf.subarray(at + 46, at + 46 + nameLen).toString('utf8')
|
|
20
31
|
at += 46 + nameLen + extraLen + commentLen
|
|
21
32
|
if (name.endsWith('/')) continue
|
|
33
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) throw broken(`${name} has no local header`)
|
|
34
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
35
|
+
if (start + size > buf.length) throw broken(`${name} runs past the end`)
|
|
36
|
+
if (declared > MAX_ENTRY_BYTES || (total += declared) > MAX_TOTAL_BYTES) throw broken(`${name} would inflate beyond the size we accept`)
|
|
37
|
+
spans.push([start, start + size, name])
|
|
22
38
|
out.set(name, () => {
|
|
23
|
-
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
24
39
|
const data = buf.subarray(start, start + size)
|
|
25
|
-
if (method === 0) return Buffer.from(data)
|
|
26
|
-
|
|
40
|
+
if (method === 0) { if (size !== declared) throw broken(`${name}: stored size differs from its declared size`); return Buffer.from(data) }
|
|
41
|
+
// inflating stops at the declared size: an entry that claims little and inflates to more is refused
|
|
42
|
+
if (method === 8) { try { return inflateRawSync(data, { maxOutputLength: Math.max(1, declared) }) } catch (e) { throw broken(`${name}: ${e.code === 'ERR_BUFFER_TOO_LARGE' ? 'inflates beyond its declared size' : 'cannot be inflated'}`) } }
|
|
27
43
|
throw new Error(`${name}: unsupported compression method ${method}`)
|
|
28
44
|
})
|
|
29
45
|
}
|
|
46
|
+
spans.sort((a, b) => a[0] - b[0])
|
|
47
|
+
for (let k = 1; k < spans.length; k++) if (spans[k][0] < spans[k - 1][1]) throw broken(`${spans[k][2]} overlaps ${spans[k - 1][2]}`)
|
|
30
48
|
return out
|
|
31
49
|
}
|
package/src/lib/util.mjs
CHANGED
|
@@ -67,6 +67,12 @@ export function globToRegExp (glob) {
|
|
|
67
67
|
|
|
68
68
|
export class UserError extends Error {}
|
|
69
69
|
|
|
70
|
+
/** Every CLI error ends with a way to a person (docs/HARDENING.md phase 6, item 12). */
|
|
71
|
+
export const SUPPORT_EMAIL = 'support@sinfin.cz'
|
|
72
|
+
/** No `org:` in the config: where the slug comes from. */
|
|
73
|
+
export const NO_ORG = 'No organization in kaloko.config.yml. Add org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko login.'
|
|
74
|
+
export const SUPPORT_LINE = `Stuck? Write to ${SUPPORT_EMAIL} with the command you ran and this message.`
|
|
75
|
+
|
|
70
76
|
/**
|
|
71
77
|
* Tracks consecutive step failures per session. When `limit` steps in a row fail with the same first
|
|
72
78
|
* error line, the remaining steps of that session are almost certainly failing on a shared
|
package/src/lib/video.mjs
CHANGED
|
@@ -188,7 +188,12 @@ function install () {
|
|
|
188
188
|
if (this.enc) {
|
|
189
189
|
const f = new VideoFrame(this.canvas, { timestamp: Math.round(tk.t * 1000), duration: Math.round(1e6 / c.fps) })
|
|
190
190
|
this.enc.encode(f, { keyFrame: this.n % c.keyEvery === 0 }); f.close(); this.n++
|
|
191
|
-
|
|
191
|
+
// a stalled encoder (seen under heavy load) must not hold the walk: after 20 s the recording gives up
|
|
192
|
+
const until = Date.now() + 20000
|
|
193
|
+
while (this.enc && this.enc.encodeQueueSize > 6) {
|
|
194
|
+
if (Date.now() > until) { this.error = 'the video encoder stopped responding'; try { this.enc.close() } catch {} this.enc = null; break }
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
196
|
+
}
|
|
192
197
|
}
|
|
193
198
|
if (tk.preview) {
|
|
194
199
|
this.pg.drawImage(this.canvas, 0, 0, c.pw, c.ph)
|
|
@@ -199,7 +204,11 @@ function install () {
|
|
|
199
204
|
return { ...out, chunks: this.chunks.splice(0), error: this.error }
|
|
200
205
|
},
|
|
201
206
|
async finish () {
|
|
202
|
-
if (this.enc) {
|
|
207
|
+
if (this.enc) {
|
|
208
|
+
// flush has no time limit of its own: a stalled encoder ends the recording with an error instead of hanging
|
|
209
|
+
try { await Promise.race([this.enc.flush(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('the video encoder did not finish within 30 s')), 30000))]) } catch (e) { this.error = String(e?.message || e) }
|
|
210
|
+
try { this.enc.close() } catch {}
|
|
211
|
+
}
|
|
203
212
|
for (const bm of this.bitmaps.values()) bm.close()
|
|
204
213
|
this.bitmaps.clear()
|
|
205
214
|
return { chunks: this.chunks.splice(0), error: this.error }
|
package/src/lib/vision.mjs
CHANGED
|
@@ -120,7 +120,7 @@ export function hostedVisionSender (config, report) {
|
|
|
120
120
|
try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no vision key (ANTHROPIC_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
|
|
121
121
|
try { return { response: await client.request('POST', `/orgs/${config.org}/semantic/vision`, payload) } } catch (e) {
|
|
122
122
|
const m = String(e.message || e)
|
|
123
|
-
if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
|
|
123
|
+
if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
|
|
124
124
|
return { error: m }
|
|
125
125
|
}
|
|
126
126
|
}
|
package/templates/scenario.yml
CHANGED
|
@@ -30,6 +30,7 @@ nodes:
|
|
|
30
30
|
title: Landing page
|
|
31
31
|
path: /
|
|
32
32
|
purpose: A visitor understands the offer and can start the flow.
|
|
33
|
+
page: landing # landing | work | list | settings | form | doc: the usability questions fit the type
|
|
33
34
|
# packs: [seo, usability] # adds the SEO basics (AC101…) and usability questions (AC201…) to this step
|
|
34
35
|
execution:
|
|
35
36
|
strategy: playwright # playwright | agent | manual
|
package/viewer/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
@font-face{font-family:"Instrument Sans Fallback";src:local("Arial Bold"),local("Arial");font-weight:700;size-adjust:98.1%;ascent-override:99%;descent-override:25%;line-gap-override:0%}
|
|
15
15
|
:root{
|
|
16
16
|
--ground:#ecece8; --dots:#c9c9c3; --panel:#ffffff; --panel-2:#f5f5f2;
|
|
17
|
-
--ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514;
|
|
17
|
+
--ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514; --field:#7a7a73;
|
|
18
18
|
--accent:#ffe452; --accent-ink:#151514; --focus:#151514;
|
|
19
19
|
--pass:#1d8249; --fail:#c8102e; --partial:#b86e00; --skip:#8d8d86;
|
|
20
20
|
--pass-bg:#e3f3e9; --fail-bg:#fbe5e8; --partial-bg:#fbf0dc; --skip-bg:#ececea;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
|
|
27
27
|
color-scheme:dark;
|
|
28
28
|
--ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
|
|
29
|
-
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
|
|
29
|
+
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --field:#7d7d76; --line-strong:#f0f0ec;
|
|
30
30
|
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
31
31
|
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
32
32
|
--pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
:root[data-theme="dark"]{
|
|
36
36
|
color-scheme:dark;
|
|
37
37
|
--ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
|
|
38
|
-
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
|
|
38
|
+
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --field:#7d7d76; --line-strong:#f0f0ec;
|
|
39
39
|
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
40
40
|
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
41
41
|
--pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
|
|
@@ -124,6 +124,8 @@ svg.i{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stro
|
|
|
124
124
|
.picker .kchip{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:var(--accent);color:var(--accent-ink);flex:none}
|
|
125
125
|
.picker .kchip--flow{background:var(--ink);color:var(--panel)}
|
|
126
126
|
.picker kbd,.kbd{font-family:var(--mono);font-size:10.5px;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--muted);flex:none}
|
|
127
|
+
/* a key inside a button takes the button's text colour: grey on the yellow primary button is unreadable */
|
|
128
|
+
.btn .kbd{color:inherit}
|
|
127
129
|
.tog{width:30px;height:30px;display:inline-grid;place-items:center;border-radius:6px;border:1px solid var(--line);background:var(--panel)}
|
|
128
130
|
.tog[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
129
131
|
.tog svg.i{width:16px;height:16px}
|
|
@@ -294,6 +296,10 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
294
296
|
}
|
|
295
297
|
|
|
296
298
|
.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:999px;font-size:13px;z-index:30;box-shadow:var(--shadow)}
|
|
299
|
+
.sess-lost{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--fail);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
|
|
300
|
+
.net-bar{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:59;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--partial);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
|
|
301
|
+
.net-bar .note{color:var(--muted)}
|
|
302
|
+
.sess-lost ~ .net-bar{top:76px}
|
|
297
303
|
|
|
298
304
|
@media (max-width:1100px){
|
|
299
305
|
.main{grid-template-columns:minmax(0,1fr)}
|
|
@@ -783,7 +789,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
783
789
|
.c-note{margin:4px 0 2px;font-size:13.5px;overflow-wrap:anywhere}.c-at{color:var(--ink);background:color-mix(in srgb,var(--accent,#ffe452) 45%,transparent);border-radius:3px;padding:0 2px;font-weight:600}
|
|
784
790
|
.c-acts{display:flex;gap:10px;font-size:12px}.c-acts button,.linkish{all:unset;cursor:pointer;color:var(--muted);text-decoration:underline;text-underline-offset:3px}.c-acts button:hover,.linkish:hover{color:var(--ink)}
|
|
785
791
|
.c-pin{all:unset;cursor:pointer;min-width:18px;height:18px;padding:0 4px;border-radius:9px 9px 9px 2px;background:#e4572e;color:#fff;font:600 10.5px/18px system-ui,sans-serif;text-align:center}
|
|
786
|
-
.c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
|
|
792
|
+
.c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--field,var(--line));border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
|
|
787
793
|
.c-hint{margin-left:auto;font-size:11.5px}@media (hover:none) and (pointer:coarse){.c-hint{display:none}}.c-pinrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}.c-pinned{display:inline-flex;gap:5px;align-items:center}.c-pinbtn[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
788
794
|
.c-tabs{margin:0 0 10px}.cmt{display:inline-flex;align-items:center;gap:3px;font:600 11px/1 system-ui,sans-serif;color:var(--ink);padding:2px 6px;border-radius:999px;background:color-mix(in srgb,var(--partial) 22%,transparent)}.cmt .i{width:12px;height:12px}
|
|
789
795
|
.lb__img.is-pinning,.lb__img.is-pinning img{cursor:crosshair}.pinmark.pin-xy{border:0;cursor:pointer;transform:translate(-2px,-100%)}.pinmark.pin-xy.is-done{background:#8a8a84}.pinmark.pin-xy.is-new{background:var(--ink)}.pinmark.flash{animation:pinflash 1s 2}
|
|
@@ -868,6 +874,10 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
868
874
|
.rvm__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
|
|
869
875
|
.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)}
|
|
870
876
|
.rvm__foot .btn .kbd,.rvm__top .kbd{margin-left:4px}
|
|
877
|
+
.rvm__flash{flex:1 1 100%;margin:0;padding:8px 12px;border-radius:8px;font-size:14px;animation:rvflash .25s ease-out}
|
|
878
|
+
.rvm__flash--approved{background:var(--pass-bg);color:var(--pass-ink)}.rvm__flash--returned{background:var(--fail-bg);color:var(--fail-ink)}
|
|
879
|
+
@keyframes rvflash{from{opacity:0;transform:translateY(6px)}}
|
|
880
|
+
@media (prefers-reduced-motion:reduce){.rvm__flash{animation:none}}
|
|
871
881
|
.rvm__why{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
|
|
872
882
|
.rvm__dec{display:grid;gap:6px}
|
|
873
883
|
.rvm__dec p{margin:0}
|
|
@@ -882,6 +892,8 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
882
892
|
#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}
|
|
883
893
|
.badge.rv-carry{margin-left:auto}
|
|
884
894
|
@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}}
|
|
895
|
+
/* a phone: the decisions sit in the bottom row, under the thumb (Approve on the right, wide), moving on above them */
|
|
896
|
+
@media (max-width:560px){.rvm__foot--act{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-template-areas:"flash flash flash" "prev cmt next" "ret ok ok"}.rvm__foot--act .rvm__flash{grid-area:flash}.rvm__foot--act #rvPrev{grid-area:prev}.rvm__foot--act #rvCmt{grid-area:cmt}.rvm__foot--act #rvNext{grid-area:next}.rvm__foot--act #rvRet{grid-area:ret}.rvm__foot--act #rvOk{grid-area:ok}.rvm__foot--act #rvRet,.rvm__foot--act #rvOk{min-height:52px;font-size:16px}.rvm__foot .btn{min-height:44px}.rvm__foot--act .btn{min-width:0}}
|
|
885
897
|
@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}}
|
|
886
898
|
@media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
|
|
887
899
|
@media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}
|
|
@@ -1099,6 +1111,14 @@ Object.assign(I18N.cs, { dim_browser:'Prohlížeč', dim_color_scheme:'Barevné
|
|
|
1099
1111
|
Object.assign(I18N.en, { dim_browser:'Browser', dim_color_scheme:'Colour scheme', dim_reduced_motion:'Motion', dim_forced_colors:'Forced colours', dim_display_mode:'Display mode', frame:'Frame', frameTitle:'Show the capture in the device frame', safeAreas:'Safe areas', safeTitle:'Show the notch, the home indicator and where the first screen ends', safeArea:'Safe area: keep content out of it', firstScreenEnd:'end of the first screen', altCompare:'Beside:', pickSearch:'Search…', pickNone:'Nothing matches.', pickFailing:'How many values have failing steps', keyLoc:'L picks the locale, [ and ] move to the previous and next' });
|
|
1100
1112
|
Object.assign(I18N.cs, { devMode:'Dev mode', devTab:'Dev', devTitle:'Míry, tokeny a stavy prvků pro vývoj', devTabEl:'Prvek', devTabTokens:'Tokeny', devEmpty:'Najeďte myší na prvek v náhledu a klikněte na něj. Shift + šipky procházejí strom prvků, se stisknutým Alt uvidíte vzdálenosti k dalšímu prvku.', devStart:'Vybrat hlavní obsah', devNavLabel:'Procházet prvky', navParent:'Rodič', navChild:'Potomek', navPrev:'Předchozí', navNext:'Další', g_type:'Typografie', g_color:'Barvy', g_space:'Mezery', g_size:'Rozměry', g_shape:'Tvar', g_layout:'Rozložení', notToken:'není token', nearest:'nejbližší', typedRaw:'Hodnota je v CSS napsaná natvrdo, i když token existuje; použijte proměnnou.', typedRawShort:'natvrdo', copyTokenPath:'Kopírovat cestu tokenu', contrast:'Kontrast', aaPass:'splňuje AA', aaFail:'nesplňuje AA', aaNeed:'potřeba', largeText:'velký text', contrastApprox:'Pod textem je obrázek, kontrast je přibližný.', states:'Stavy', stDefault:'výchozí', stNone:'Styly stránky pro tento prvek žádné stavy nedefinují.', changedInState:'Zvýrazněné hodnoty se v tomto stavu mění.', distances:'Vzdálenosti', toParent:'K rodiči', toTarget:'K prvku', measure:'Měřit', measureHint:'Podržte Alt a najeďte na jiný prvek, nebo zapněte Měřit a klepněte na něj.', assets:'Obrázky a ikony', download:'Stáhnout', assetFail:'Soubor se nepodařilo stáhnout.', copyCssVars:'Kopírovat CSS s proměnnými', copyTokens:'Kopírovat tokeny (JSON)', copyPrompt:'Kopírovat pokyn pro agenta', copyLine:'Kopírovat pokyn k této hodnotě', pinFromDev:'Komentovat tento prvek', tokUsed:'Použité na tomto kroku', tokOff:'Hodnoty mimo sadu tokenů', tokHard:'V CSS natvrdo místo tokenu', tokUnused:'Nepoužité', tokNone:'Krok nemá sadu tokenů (tokens.json).', tokHlN:'Zvýrazněno prvků:', tokHint:'Klikněte na token a náhled zvýrazní prvky, které ho používají.', devShotNote:'Míry a tokeny jsou ze snímku, jak ho zachytilo CLI; stavy a stahování obrázků potřebují živou stránku.', devNoSpec:'Tento snímek nemá spec. Projděte krok znovu aktuálním CLI (kaloko walk).', devLocalLive:'Ze souboru na disku čte Dev mode snímek; živou stránku měří na kaloko.app.', devAgentHint:'Totéž dostane agent: kaloko spec %N nebo MCP get_step_spec.', devLoading:'Načítám…', keyDev:'Dev mode v detailu kroku: míry, tokeny a stavy prvků (M měří, Shift + šipky procházejí prvky)', promptHead:'Postavte %E podle schváleného návrhu v Kaloku (krok %S).', promptUse:'Pro %W použijte token %T.', promptOff:'%P %V není token; nejbližší je %T. Pevnou hodnotu použijte jen po domluvě s designérem.', promptOffNone:'%P %V není token.', promptSpec:'Spec celého kroku: kaloko spec %N' });
|
|
1101
1113
|
Object.assign(I18N.en, { devMode:'Dev mode', devTab:'Dev', devTitle:'Measurements, tokens and states of elements for development', devTabEl:'Element', devTabTokens:'Tokens', devEmpty:'Hover an element in the preview and click it. Shift + arrows move through the element tree; hold Alt to see the distances to another element.', devStart:'Select the main content', devNavLabel:'Move through elements', navParent:'Parent', navChild:'Child', navPrev:'Previous', navNext:'Next', g_type:'Typography', g_color:'Colors', g_space:'Spacing', g_size:'Size', g_shape:'Shape', g_layout:'Layout', notToken:'not a token', nearest:'nearest', typedRaw:'The CSS has this value typed raw although a token exists; use the custom property.', typedRawShort:'typed raw', copyTokenPath:'Copy the token path', contrast:'Contrast', aaPass:'passes AA', aaFail:'fails AA', aaNeed:'needs', largeText:'large text', contrastApprox:'There is an image behind the text; the contrast is approximate.', states:'States', stDefault:'default', stNone:'The page styles define no states for this element.', changedInState:'Highlighted values change in this state.', distances:'Distances', toParent:'To the parent', toTarget:'To', measure:'Measure', measureHint:'Hold Alt and hover another element, or turn on Measure and tap it.', assets:'Images and icons', download:'Download', assetFail:'The file could not be downloaded.', copyCssVars:'Copy CSS with variables', copyTokens:'Copy tokens (JSON)', copyPrompt:'Copy prompt for an agent', copyLine:'Copy the prompt line for this value', pinFromDev:'Comment on this element', tokUsed:'Used on this step', tokOff:'Values outside the token set', tokHard:'Typed raw in the CSS instead of a token', tokUnused:'Not used', tokNone:'This step has no token set (tokens.json).', tokHlN:'Elements highlighted:', tokHint:'Click a token to highlight the elements that use it.', devShotNote:'Measurements and tokens come from the screenshot as the CLI captured it; states and image downloads need the live page.', devNoSpec:'This capture has no spec. Walk the step again with the current CLI (kaloko walk).', devLocalLive:'From disk, Dev mode reads the screenshot; the live page is measured on kaloko.app.', devAgentHint:'An agent gets the same: kaloko spec %N or MCP get_step_spec.', devLoading:'Loading…', keyDev:'Dev mode in the step detail: measurements, tokens and states of elements (M measures, Shift + arrows move through elements)', promptHead:'Build %E as approved in Kaloko (step %S).', promptUse:'Use token %T for the %W.', promptOff:'%P %V is not a token; the nearest is %T. Use the raw value only if the designer agrees.', promptOffNone:'%P %V is not a token.', promptSpec:'Spec of the whole step: kaloko spec %N' });
|
|
1114
|
+
Object.assign(I18N.cs, { sessionLost:'Přihlášení vypršelo. Přihlaste se znovu, rozepsaný text tu na vás počká.', signInAgain:'Přihlásit se znovu' });
|
|
1115
|
+
Object.assign(I18N.en, { sessionLost:'Your session has expired. Sign in again; what you were writing waits here.', signInAgain:'Sign in again' });
|
|
1116
|
+
// the connection drops or the service fails while the canvas is open (NET, netSend)
|
|
1117
|
+
Object.assign(I18N.cs, { netOffline:'Jste offline. Co napíšete, tu počká, než se připojení vrátí.', netOfflineN:(n) => `Jste offline. ${n === 1 ? '1 změna čeká' : n < 5 ? `${n} změny čekají` : `${n} změn čeká`} tady a odešle se, až se připojení vrátí.`, netFailedN:(n, why) => `Kaloko ${n === 1 ? '1 změnu' : n < 5 ? `${n} změny` : `${n} změn`} zatím neuložilo (${why}). Nic se neztratilo.`, netNoAnswer:'služba neodpovídá', netError:(c) => c === 429 ? 'příliš mnoho požadavků' : `chyba ${c}`, netAgainIn:(s) => `Zkusím to znovu za ${s} s.`, netRetry:'Zkusit znovu', netSending:'Odesílám…', netBack:'Připojení je zpět, všechno se odeslalo.', netLeave:'Některé změny ještě nejsou odeslané.' });
|
|
1118
|
+
Object.assign(I18N.en, { netOffline:'You are offline. What you write waits here until the connection is back.', netOfflineN:(n) => `You are offline. ${n === 1 ? '1 change waits here and goes' : `${n} changes wait here and go`} out when the connection is back.`, netFailedN:(n, why) => `Kaloko has not saved ${n === 1 ? '1 change' : `${n} changes`} yet (${why}). Nothing is lost.`, netNoAnswer:'the service does not answer', netError:(c) => c === 429 ? 'too many requests' : `error ${c}`, netAgainIn:(s) => `Trying again in ${s} s.`, netRetry:'Try again', netSending:'Sending…', netBack:'Back online; everything has been sent.', netLeave:'Some changes have not been sent yet.' });
|
|
1119
|
+
// back after signing in again: the session expired on the canvas (sessionLost), the server kept the path, but the
|
|
1120
|
+
// sign-in mail cannot carry the fragment (the open step, the comments), so the canvas stored it for this moment
|
|
1121
|
+
(() => { try { const r = JSON.parse(localStorage.getItem('qawalk-return') || 'null'); if (!r) return; localStorage.removeItem('qawalk-return'); if (r.path === location.pathname && r.hash && !location.hash && Date.now() - r.at < 864e5) history.replaceState(null, '', location.pathname + location.search + r.hash); } catch {} })();
|
|
1102
1122
|
const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
|
|
1103
1123
|
// #lang=cs|en in a link forces the language of the canvas for that page (a shared link, a walk in another locale):
|
|
1104
1124
|
// it wins over the account, the cookie and the browser, and stays in the address while the canvas rewrites it
|
|
@@ -1357,7 +1377,7 @@ const sessColor = (key) => SESSION_COLORS[Math.max(0, sessions().indexOf(key)) %
|
|
|
1357
1377
|
const sessTitle = (key) => key ? loc(RUN.sessions?.[key]?.title) || key : '';
|
|
1358
1378
|
const sessChip = (key) => key && RUN.sessions?.[key] ? `<span class="sess" style="--sc:${sessColor(key)}" title="${esc(key)}"><i></i>${esc(sessTitle(key))}</span>` : '';
|
|
1359
1379
|
const nodeSt = (n) => { const status = n.by_locale?.[S.loc] || n.status; return ['pass', 'partial', 'fail', 'skip'].includes(status) ? status : 'skip'; };
|
|
1360
|
-
const critVerdict = (c, l = S.loc) => { if (c.counts) return c.verdict || 'not_reached'; const vs = viewports().map((v) => c.by_locale?.[l]?.[v.name]?.verdict || 'not_reached'); return vs.every((v) => v === 'not_reached') ? 'not_reached' : worst(vs.map((v) => v === 'not_reached' ? 'not_evaluated' : v)); };
|
|
1380
|
+
const critVerdict = (c, l = S.loc) => { if (c.not_applicable) return 'not_reached'; if (c.counts) return c.verdict || 'not_reached'; const vs = viewports().map((v) => c.by_locale?.[l]?.[v.name]?.verdict || 'not_reached'); return vs.every((v) => v === 'not_reached') ? 'not_reached' : worst(vs.map((v) => v === 'not_reached' ? 'not_evaluated' : v)); };
|
|
1361
1381
|
const critSt = (c) => VSTAT[critVerdict(c)];
|
|
1362
1382
|
const prevRun = () => (RUN.history || []).find((h) => h.started_at < RUN.started_at);
|
|
1363
1383
|
const fmtDate = (iso) => { if (!iso) return ''; const d = new Date(iso); return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
@@ -1425,7 +1445,9 @@ function commentsBase (scope) {
|
|
|
1425
1445
|
return scope === 'flow' ? `${a.api}/orgs/${a.org}/scenarios/${encodeURIComponent(RUN.scenario.id)}/comments` : `${a.api}/orgs/${a.org}/runs/${a.run}/comments`;
|
|
1426
1446
|
}
|
|
1427
1447
|
async function cApi (scope, path, method, body) {
|
|
1428
|
-
const
|
|
1448
|
+
const init = { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined };
|
|
1449
|
+
const res = method === 'GET' ? await fetch(commentsBase(scope) + path, init) : await netSend(commentsBase(scope) + path, init, method === 'POST' && !path ? { find: commentSent(scope, body) } : {});
|
|
1450
|
+
if (res.status === 401) throw sessionLost();
|
|
1429
1451
|
const data = await res.json().catch(() => ({}));
|
|
1430
1452
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
1431
1453
|
return data;
|
|
@@ -1452,6 +1474,8 @@ function rerenderComments (all = false) {
|
|
|
1452
1474
|
const draftKey = (scope, nodeId, sub) => `qawalk-draft|${APP()?.org || 'local'}|${scope === 'flow' ? `flow:${RUN.scenario.id}` : isDraft() ? `draft:${RUN.scenario.id}` : RUN.id}|${nodeId || ''}|${sub || 'new'}`;
|
|
1453
1475
|
const loadDraft = (k) => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
|
|
1454
1476
|
const saveDraft = (k, v) => { try { if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); } catch {} };
|
|
1477
|
+
// notes outside the comment composer (verdict, return, issue) carry their draft key: typed text is kept the same way
|
|
1478
|
+
document.addEventListener('input', (e) => { const k = e.target?.dataset?.draft; if (k) saveDraft(k, e.target.value); });
|
|
1455
1479
|
const noteHtml = (s) => esc(s).replace(/(^|\s)@([\w.+-]+(?:@[\w.-]+)?)/g, '$1<b class="c-at">@$2</b>').replace(/\n/g, '<br>');
|
|
1456
1480
|
/** Numbers of the threads pinned on this step's screenshot (the same numbers as the marks on the image). */
|
|
1457
1481
|
function pinNumbers (nodeId) {
|
|
@@ -2197,9 +2221,13 @@ function closeStack (keepHash) {
|
|
|
2197
2221
|
if (S.st?.inline) { if (keepHash === true) return; const id = S.st.id; S.st = null; riRender({ focus: id }); setHash(null); return; } // folder → back to the run index
|
|
2198
2222
|
$('si').hidden = true; if (keepHash !== true) setHash(null);
|
|
2199
2223
|
}
|
|
2224
|
+
// a CSV cell: text from crawled pages that starts like a formula (= + - @ tab CR) gets a leading ' so a spreadsheet
|
|
2225
|
+
// shows it instead of running it, also after a ; or a line break (Excel with a semicolon separator splits there; the
|
|
2226
|
+
// same rule as the audit CSV, worker/src/audit-log.js); numbers stay numbers
|
|
2227
|
+
function csvCell (v) { if (typeof v === 'number') return String(v); const s = v == null ? '' : String(v); const x = (/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/([;\r\n]["\s]*)(?=[=+\-@])/g, "$1'"); return /[",\n\r]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; }
|
|
2200
2228
|
function stackCsv (n, data) {
|
|
2201
2229
|
const cols = data.fields || [];
|
|
2202
|
-
const q =
|
|
2230
|
+
const q = csvCell;
|
|
2203
2231
|
const rows = [['path', 'url', 'status', 'failing', 'review', ...cols.map((f) => f.label || f.id)], ...S.st.filtered.map((it) => [it.path, it.url, it.status, (it.fails || []).join(' '), (it.review || []).join(' '), ...cols.map((f) => it.fields?.[f.id])])];
|
|
2204
2232
|
const blob = new Blob(['' + rows.map((r) => r.map(q).join(',')).join('\n')], { type: 'text/csv;charset=utf-8' });
|
|
2205
2233
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${RUN.scenario?.id || 'run'}-${n.id}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
@@ -2359,7 +2387,7 @@ function riKey (e) {
|
|
|
2359
2387
|
return false;
|
|
2360
2388
|
}
|
|
2361
2389
|
function riCsv () {
|
|
2362
|
-
const q =
|
|
2390
|
+
const q = csvCell;
|
|
2363
2391
|
const rows = [['step', 'title', 'kind', 'path', 'status', 'criteria_passed', 'criteria', 'failing', 'http', 'console_errors', 'pages']];
|
|
2364
2392
|
for (const e of S.ri.entries || []) {
|
|
2365
2393
|
if (e.type === 'page') { rows.push([`${e.n.id}~${e.it.id}`, e.it.title || '', 'page', e.it.path, e.it.status, '', '', (e.it.fails || []).join(' '), e.it.fields?.http ?? '', '', '']); continue; }
|
|
@@ -2640,6 +2668,7 @@ function renderAll () { renderChrome(); renderNodes(); if (S.right) renderReport
|
|
|
2640
2668
|
/* Evidence of the built-in checks is a fixed English template (kept as is in the data, for agents and the API);
|
|
2641
2669
|
the canvas says it as a sentence in the reader's language. Unknown text and HTML snippets stay as they are. */
|
|
2642
2670
|
const EVIDENCE = [
|
|
2671
|
+
[/^Applies on (.+) only; this walk ran on (.+)\.$/, (m) => [`Does not apply on ${m[2]}: this check runs on ${m[1]} only`, `Na prostředí ${m[2]} se netýká: tahle kontrola běží jen na ${m[1]}`]],
|
|
2643
2672
|
[/^no visible link to the home page$/, () => ['No visible link to the home page', 'Chybí viditelný odkaz na úvodní stránku']],
|
|
2644
2673
|
[/^no visible (.+)$/, (m) => [`Nothing visible matches ${m[1]}`, `Na stránce není vidět nic, co odpovídá ${m[1]}`]],
|
|
2645
2674
|
[/^(\d+)× (.+)$/, (m) => [`Found ${m[1]}× ${m[2]}`, `Nalezeno ${m[1]}× ${m[2]}`]],
|
|
@@ -3545,9 +3574,9 @@ function renderReport () {
|
|
|
3545
3574
|
${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>` : ''}
|
|
3546
3575
|
${versionNotesHtml()}
|
|
3547
3576
|
${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>` : ''}
|
|
3548
|
-
${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"
|
|
3577
|
+
${canWrite() ? `<textarea id="rvVerdictNote" ${loadDraft(draftKey('run', '', 'verdict')) ? '' : 'hidden '}rows="2" placeholder="${T('verdictNotePh')}" data-draft="${esc(draftKey('run', '', 'verdict'))}" 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">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
|
|
3549
3578
|
<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>
|
|
3550
|
-
<div class="vmore"
|
|
3579
|
+
<div class="vmore">${loadDraft(draftKey('run', '', 'verdict')) ? '' : `<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>` : ''}${signoffHtml()}</div>` : ''}
|
|
3551
3580
|
${commentsPanel()}
|
|
3552
3581
|
<div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
|
|
3553
3582
|
<div class="stack">${['pass', 'partial', 'fail', 'skip'].map((k) => `<i style="width:${c[k] / tot * 100}%;background:var(--${k})"></i>`).join('')}</div>
|
|
@@ -3578,7 +3607,7 @@ function renderReport () {
|
|
|
3578
3607
|
const sc = $('shareCopy'); if (sc) sc.onclick = async () => { const link = $('shareUrl').textContent; try { await navigator.clipboard.writeText(link); toast(T('linkCopied')); } catch { toast(link); } };
|
|
3579
3608
|
const showNote = () => { const t = $('rvVerdictNote'); if (!t) return; t.hidden = false; $('rvNoteShow')?.remove(); t.focus(); };
|
|
3580
3609
|
if ($('rvNoteShow')) $('rvNoteShow').onclick = showNote;
|
|
3581
|
-
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3610
|
+
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3582
3611
|
$('rside').querySelectorAll('[data-public]').forEach((b) => { b.onclick = async () => { if (b.dataset.public === 'on' && !confirm(T('publicConfirm'))) return; b.disabled = true; try { await postJson('/public', { public: b.dataset.public === 'on' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3583
3612
|
$('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3584
3613
|
$('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => { if (byId(el.dataset.go || el.dataset.id)) openLb(el.dataset.go || el.dataset.id); }; });
|
|
@@ -3756,13 +3785,98 @@ const sameAsCmp = (n) => !!(S.cmp && isDesign() && n.kind !== 'decision' && revO
|
|
|
3756
3785
|
const headOf = (n, loc = S.loc) => RUN.revisions?.[`${n.id}|${loc}`] || null;
|
|
3757
3786
|
const who = (a) => a ? (a.agent ? `${a.agent} (${T('viaAgent')} ${a.email})` : a.email) : '';
|
|
3758
3787
|
|
|
3788
|
+
/**
|
|
3789
|
+
* The session ended while the canvas was open (an API call answered 401): one bar says so and links to sign in with
|
|
3790
|
+
* the way back; every unsent text is already in localStorage (drafts) and stays. Returns the error to throw.
|
|
3791
|
+
*/
|
|
3792
|
+
function sessionLost () {
|
|
3793
|
+
try { localStorage.setItem('qawalk-return', JSON.stringify({ path: location.pathname, hash: location.hash, at: Date.now() })); } catch {}
|
|
3794
|
+
if (!$('sessLost')) document.body.insertAdjacentHTML('beforeend', `<div id="sessLost" class="sess-lost" role="alert"><span>${esc(T('sessionLost'))}</span><a class="btn btn--primary" href="/login?next=${encodeURIComponent(location.pathname + location.search)}">${esc(T('signInAgain'))}</a></div>`);
|
|
3795
|
+
return new Error(T('sessionLost'));
|
|
3796
|
+
}
|
|
3759
3797
|
async function apiJson (method, url, body) {
|
|
3760
|
-
const
|
|
3798
|
+
const init = { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined };
|
|
3799
|
+
const res = method === 'GET' ? await fetch(url, init) : await netSend(url, init);
|
|
3800
|
+
if (res.status === 401) throw sessionLost();
|
|
3761
3801
|
const data = await res.json().catch(() => ({}));
|
|
3762
3802
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
3763
3803
|
return data;
|
|
3764
3804
|
}
|
|
3765
3805
|
|
|
3806
|
+
/**
|
|
3807
|
+
* Writes survive a dropped connection. A request that cannot reach the service (offline, a dropped connection) or that
|
|
3808
|
+
* the service could not handle (5xx, 429, 408) waits in a queue the bar at the top names; it goes out again when the
|
|
3809
|
+
* browser is back online, on a timer (Retry-After when the service sends one) or on "Try again". The caller's promise
|
|
3810
|
+
* only settles with a real answer, so its button stays busy and nothing is lost silently; leaving the page while
|
|
3811
|
+
* something waits asks first. `find` looks for what the first attempt may already have created (a new comment), so a
|
|
3812
|
+
* request that did arrive is not sent twice.
|
|
3813
|
+
*/
|
|
3814
|
+
const NET = { pending: [], timer: null, tick: null, was: false };
|
|
3815
|
+
const netTrouble = (res) => !res || res.status >= 500 || res.status === 429 || res.status === 408;
|
|
3816
|
+
async function netSend (url, init, { find } = {}) {
|
|
3817
|
+
const started = Date.now();
|
|
3818
|
+
for (let attempt = 0; ; attempt++) {
|
|
3819
|
+
if (attempt && find) {
|
|
3820
|
+
let hit;
|
|
3821
|
+
try { hit = await find(started); } catch { hit = undefined; }
|
|
3822
|
+
if (hit) return new Response(JSON.stringify(hit), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
3823
|
+
if (hit === undefined) { await netWait({ status: 0, attempt }); continue; } // cannot look yet: wait again
|
|
3824
|
+
}
|
|
3825
|
+
let res = null;
|
|
3826
|
+
if (navigator.onLine !== false) { try { res = await fetch(url, init); } catch { res = null; } }
|
|
3827
|
+
if (!netTrouble(res)) { if (NET.was && !NET.pending.length) { NET.was = false; toast(T('netBack')); } return res; }
|
|
3828
|
+
const after = Number(res?.headers.get('retry-after'));
|
|
3829
|
+
await netWait({ status: res?.status || 0, attempt, after: Number.isFinite(after) && after > 0 ? Math.min(after, 300) : 0 });
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
function netWait (entry) {
|
|
3833
|
+
return new Promise((resolve) => {
|
|
3834
|
+
entry.go = () => { NET.pending = NET.pending.filter((x) => x !== entry); resolve(); };
|
|
3835
|
+
// the service failed or did not answer: try again by itself a few times (3, 10, 30 s), then wait for a person
|
|
3836
|
+
const delay = entry.after ? entry.after * 1000 : [3000, 10000, 30000][entry.attempt];
|
|
3837
|
+
entry.at = navigator.onLine === false || !delay ? 0 : Date.now() + delay;
|
|
3838
|
+
NET.pending.push(entry); NET.was = true;
|
|
3839
|
+
netBar();
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
/** Sends everything waiting now (back online, "Try again", or a timer that came due). */
|
|
3843
|
+
function netFlush (only) {
|
|
3844
|
+
const due = NET.pending.filter((x) => !only || only(x));
|
|
3845
|
+
for (const x of due) x.go();
|
|
3846
|
+
netBar();
|
|
3847
|
+
}
|
|
3848
|
+
function netBar () {
|
|
3849
|
+
clearTimeout(NET.timer); clearInterval(NET.tick); NET.tick = null;
|
|
3850
|
+
const offline = navigator.onLine === false;
|
|
3851
|
+
const n = NET.pending.length;
|
|
3852
|
+
let bar = $('netBar');
|
|
3853
|
+
if (!n && !offline) { bar?.remove(); return; }
|
|
3854
|
+
if (!bar) { document.body.insertAdjacentHTML('beforeend', '<div id="netBar" class="net-bar" role="status" aria-live="polite"><span class="net-bar__msg"></span><span class="note net-bar__when"></span><button type="button" class="btn net-bar__retry"></button></div>'); bar = $('netBar'); bar.querySelector('.net-bar__retry').onclick = () => { bar.querySelector('.net-bar__when').textContent = T('netSending'); netFlush(); }; }
|
|
3855
|
+
const last = NET.pending.at(-1);
|
|
3856
|
+
bar.querySelector('.net-bar__msg').textContent = offline ? (n ? T('netOfflineN')(n) : T('netOffline')) : T('netFailedN')(n, last?.status ? T('netError')(last.status) : T('netNoAnswer'));
|
|
3857
|
+
const btn = bar.querySelector('.net-bar__retry'); btn.textContent = T('netRetry'); btn.hidden = !n;
|
|
3858
|
+
const next = offline ? 0 : Math.min(...NET.pending.map((x) => x.at || Infinity));
|
|
3859
|
+
const when = bar.querySelector('.net-bar__when');
|
|
3860
|
+
const show = () => { const s = Math.max(1, Math.ceil((next - Date.now()) / 1000)); when.textContent = T('netAgainIn')(s); };
|
|
3861
|
+
if (Number.isFinite(next) && next > 0) {
|
|
3862
|
+
show(); NET.tick = setInterval(show, 1000);
|
|
3863
|
+
NET.timer = setTimeout(() => netFlush((x) => x.at && x.at <= Date.now() + 50), Math.max(0, next - Date.now()));
|
|
3864
|
+
} else when.textContent = '';
|
|
3865
|
+
}
|
|
3866
|
+
addEventListener('online', () => { if (NET.pending.length) netFlush(); else netBar(); });
|
|
3867
|
+
addEventListener('offline', () => { if (APP()) netBar(); });
|
|
3868
|
+
addEventListener('beforeunload', (e) => { if (NET.pending.length) { e.preventDefault(); e.returnValue = T('netLeave'); return T('netLeave'); } });
|
|
3869
|
+
/** A new comment the first attempt may have created already: same step, text, status and thread, by this person. */
|
|
3870
|
+
function commentSent (scope, body) {
|
|
3871
|
+
return async (since) => {
|
|
3872
|
+
const me = APP()?.user?.email;
|
|
3873
|
+
const res = await fetch(commentsBase(scope) + (isDraft() ? branchQ() : ''), { credentials: 'same-origin' });
|
|
3874
|
+
if (!res.ok) return undefined;
|
|
3875
|
+
const list = await res.json();
|
|
3876
|
+
return (Array.isArray(list) ? list : []).find((c) => c.node_id === body.node_id && (c.note || '') === (body.note || '') && (c.status || 'comment') === (body.status || 'comment') && (c.parent_id || null) === (body.parent_id || null) && (!me || c.email === me) && Date.parse(c.created_at) >= since - 120000) || null;
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3766
3880
|
// the draft is live: re-read it when someone (a person or an agent) changes it
|
|
3767
3881
|
let reloadTimer = null;
|
|
3768
3882
|
function reloadDraftSoon () { clearTimeout(reloadTimer); reloadTimer = setTimeout(reloadDraft, 350); }
|
|
@@ -4710,7 +4824,7 @@ function issuesBlock (n, v) {
|
|
|
4710
4824
|
${trackers.length > 1 ? `<label>${esc(T('issTracker'))} <select id="issTracker">${trackers.map((t) => `<option value="${esc(t.kind)}">${esc(t.label)} · ${esc(t.target)}</option>`).join('')}</select></label>` : ''}
|
|
4711
4825
|
<label>${esc(T('issCriterion'))} <select id="issCrit"><option value="">${esc(T('issWholeStep'))}</option>${crit.map((c) => `<option value="${esc(c.id)}" ${pick && pick.id === c.id ? 'selected' : ''}>${esc(c.id)} · ${esc(String(critText(c)).slice(0, 60))}</option>`).join('')}</select></label>
|
|
4712
4826
|
<label>${esc(T('issName'))} <input id="issName" maxlength="200" placeholder="${esc(T('issNamePh'))}"></label>
|
|
4713
|
-
<label>${esc(T('issNote'))} <textarea id="issNote" rows="2"
|
|
4827
|
+
<label>${esc(T('issNote'))} <textarea id="issNote" rows="2" data-draft="${esc(draftKey('step', n.id, 'issue'))}">${esc(loadDraft(draftKey('step', n.id, 'issue')))}</textarea></label>
|
|
4714
4828
|
<div class="share__row"><button class="btn btn--primary" data-iss-send="${esc(n.id)}" data-iss-vp="${esc(v?.name || '')}">${esc(T('issSend'))}</button><button class="btn" data-iss-cancel>${esc(T('cancel'))}</button></div></div>`;
|
|
4715
4829
|
} else if (can) tail = `<div class="share__row">${trackers.map((t) => `<button class="btn" data-iss-new="${esc(n.id)}" data-iss-kind="${esc(t.kind)}">${esc(T('issNew')(t.label))}</button>`).join('')}</div>`;
|
|
4716
4830
|
return `<div class="sec iss"><h4>${esc(T('issTitle'))}</h4>${items}${tail}</div>`;
|
|
@@ -4722,9 +4836,10 @@ async function issSend (step, vp) {
|
|
|
4722
4836
|
ISS.busy = true; toast(T('issSending'));
|
|
4723
4837
|
try {
|
|
4724
4838
|
const res = await fetch(`${a.api}/orgs/${a.org}/issues`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ run_id: a.run, step, to, criterion: $('issCrit')?.value || undefined, title: $('issName')?.value.trim() || undefined, note: $('issNote')?.value.trim() || undefined, locale: S.loc, viewport: vp || undefined }) });
|
|
4839
|
+
if (res.status === 401) throw sessionLost();
|
|
4725
4840
|
const out = await res.json().catch(() => ({}));
|
|
4726
4841
|
if (!res.ok) throw new Error(out.error || `HTTP ${res.status}`);
|
|
4727
|
-
ISS.form = null;
|
|
4842
|
+
ISS.form = null; saveDraft(draftKey('step', step, 'issue'), '');
|
|
4728
4843
|
toast(T('issSent')(out.key));
|
|
4729
4844
|
await issLoad();
|
|
4730
4845
|
await refreshComments();
|
|
@@ -4759,7 +4874,7 @@ Object.assign(I18N.cs, {
|
|
|
4759
4874
|
rvCarriedWhy: 'Snímky i kritéria jsou stejné jako v přijatém běhu a automatické kontroly prošly.',
|
|
4760
4875
|
rvRevoke: 'Zrušit převzaté schválení', rvRevoked: 'Převzaté schválení je zrušené; krok čeká na kontrolu.',
|
|
4761
4876
|
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.',
|
|
4762
|
-
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í.',
|
|
4877
|
+
rvApprovedToast: 'Schváleno', rvFlashNext: 'další:', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
|
|
4763
4878
|
rvDone: 'Všechny kroky jsou rozhodnuté', rvLeft: (n) => `Na rozhodnutí ${n === 1 ? 'čeká 1 krok' : n < 5 ? `čekají ${n} kroky` : `čeká ${n} kroků`}`,
|
|
4764
4879
|
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',
|
|
4765
4880
|
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.',
|
|
@@ -4780,27 +4895,27 @@ Object.assign(I18N.en, {
|
|
|
4780
4895
|
rvCarriedWhy: 'The screenshots and criteria are the same as in the accepted run and the automatic checks passed.',
|
|
4781
4896
|
rvRevoke: 'Revoke the carried approval', rvRevoked: 'Carried approval revoked; the step waits for review.',
|
|
4782
4897
|
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.',
|
|
4783
|
-
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.',
|
|
4898
|
+
rvApprovedToast: 'Approved', rvFlashNext: 'next:', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
|
|
4784
4899
|
rvDone: 'Every step is decided', rvLeft: (n) => `${n} step${n === 1 ? '' : 's'} still wait${n === 1 ? 's' : ''} for a decision`,
|
|
4785
4900
|
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',
|
|
4786
4901
|
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.',
|
|
4787
4902
|
rvShow: 'Show', rvVerdictLead: 'Carried approvals do not count as accepting the run: a person accepts the run as a whole.',
|
|
4788
4903
|
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'
|
|
4789
4904
|
})
|
|
4790
|
-
const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [] };
|
|
4905
|
+
const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [], flash: null };
|
|
4791
4906
|
const rvService = () => !!(APP()?.user && APP()?.run && !isDraft());
|
|
4792
4907
|
const rvWritable = () => rvService() && canWrite();
|
|
4793
4908
|
const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
|
|
4794
4909
|
const rvCur = () => rvItems()[RV.idx] || null;
|
|
4795
4910
|
const RV_GROUP = { fail: 0, returned: 0, needs_review: 1, not_evaluated: 1, revoked: 1, changed: 2, new: 2, comments: 3 };
|
|
4796
4911
|
const rvDay = (iso) => (iso ? fmtDate(iso).replace(/ \d{2}:\d{2}$/, '') : '');
|
|
4797
|
-
const rvOpenCrit = (c) => Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
|
|
4912
|
+
const rvOpenCrit = (c) => !c.not_applicable && Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
|
|
4798
4913
|
|
|
4799
4914
|
/** A read-only queue from the run itself (local preview, export, public run): no carry-over, no comparison. */
|
|
4800
4915
|
function rvLocalQueue () {
|
|
4801
4916
|
const items = []; let clear = 0;
|
|
4802
4917
|
flowOrder().forEach((n, position) => {
|
|
4803
|
-
const vs = (n.criteria || []).map((c) => c.verdict).filter(Boolean);
|
|
4918
|
+
const vs = (n.criteria || []).filter((c) => !c.not_applicable).map((c) => c.verdict).filter(Boolean);
|
|
4804
4919
|
const reasons = [];
|
|
4805
4920
|
if (n.status === 'fail' || vs.includes('fail')) reasons.push('fail');
|
|
4806
4921
|
if (vs.includes('needs_review')) reasons.push('needs_review');
|
|
@@ -4948,7 +5063,9 @@ function rvFootHtml (it) {
|
|
|
4948
5063
|
const items = rvItems();
|
|
4949
5064
|
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>`
|
|
4950
5065
|
: it ? `<span class="note">${esc(T(rvService() ? 'rvReadOnly' : 'rvLocal'))}</span>` : '';
|
|
4951
|
-
|
|
5066
|
+
const f = RV.flash && Date.now() - RV.flash.at < 4000 ? RV.flash : null;
|
|
5067
|
+
const flash = f ? `<p class="rvm__flash rvm__flash--${f.state}" id="rvFlash" role="status">${f.state === 'approved' ? '✓' : '↩'} ${esc(T(f.state === 'approved' ? 'rvApprovedToast' : 'rvReturnedToast'))}: <b>${esc(f.title)}</b>${it ? ` · ${esc(T('rvFlashNext'))} ${esc(loc(byId(it.step)?.title) || it.step)}` : ''}</p>` : '';
|
|
5068
|
+
return `<div class="rvm__foot${it && rvWritable() ? ' rvm__foot--act' : ''}">${flash}<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>`;
|
|
4952
5069
|
}
|
|
4953
5070
|
function rvEndHtml () {
|
|
4954
5071
|
const d = RV.data, c = d.counts || {}, items = d.items || [];
|
|
@@ -4960,7 +5077,7 @@ function rvEndHtml () {
|
|
|
4960
5077
|
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>` : '';
|
|
4961
5078
|
const v = RUN.verdict || d.verdict || {};
|
|
4962
5079
|
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>` : ''}
|
|
4963
|
-
<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"
|
|
5080
|
+
<textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" data-draft="${esc(draftKey('run', '', 'verdict'))}" 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">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
|
|
4964
5081
|
<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>` : '';
|
|
4965
5082
|
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>`;
|
|
4966
5083
|
}
|
|
@@ -5049,10 +5166,16 @@ async function rvApprove () {
|
|
|
5049
5166
|
// the person's look answers what the evaluator could not decide: open criteria pass, then the step is approved
|
|
5050
5167
|
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: '' });
|
|
5051
5168
|
await cApi('step', '', 'POST', { node_id: n.id, status: 'approved', note: '' });
|
|
5052
|
-
|
|
5169
|
+
rvFlash('approved', n);
|
|
5053
5170
|
await rvAfter(it.step);
|
|
5054
5171
|
} catch (e) { toast(e.message); if (b) b.disabled = false; } finally { RV.busy = false; }
|
|
5055
5172
|
}
|
|
5173
|
+
/** Feedback after a decision that stays readable on a phone: a line above the buttons (with the next step), a short buzz. */
|
|
5174
|
+
function rvFlash (state, n) {
|
|
5175
|
+
RV.flash = { state, title: loc(n?.title) || '', at: Date.now() };
|
|
5176
|
+
try { if (lbPhone()) navigator.vibrate?.(state === 'approved' ? 15 : [15, 60, 15]); } catch {}
|
|
5177
|
+
clearTimeout(RV.flashT); RV.flashT = setTimeout(() => { RV.flash = null; $('rvFlash')?.remove(); }, 4000);
|
|
5178
|
+
}
|
|
5056
5179
|
function rvReturn () { if (!rvCur() || !rvWritable()) return; RV.ret = true; rvRender(); }
|
|
5057
5180
|
async function rvReturnSend () {
|
|
5058
5181
|
const it = rvCur(); const t = $('rvRetNote'); if (!it || !t || RV.busy) return;
|
|
@@ -5062,7 +5185,7 @@ async function rvReturnSend () {
|
|
|
5062
5185
|
try {
|
|
5063
5186
|
await cApi('step', '', 'POST', { node_id: it.step, status: 'rejected', note, locale: S.loc, viewport: RV.vp || null });
|
|
5064
5187
|
saveDraft(draftKey('step', it.step, 'return'), '');
|
|
5065
|
-
|
|
5188
|
+
rvFlash('returned', byId(it.step) || { title: it.title });
|
|
5066
5189
|
await rvAfter(it.step);
|
|
5067
5190
|
} catch (e) { toast(e.message); $('rvRetSend') && ($('rvRetSend').disabled = false); } finally { RV.busy = false; }
|
|
5068
5191
|
}
|
|
@@ -5088,7 +5211,7 @@ async function rvVerdict (verdict, b) {
|
|
|
5088
5211
|
const note = $('rvVNote')?.value.trim() || '';
|
|
5089
5212
|
if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
|
|
5090
5213
|
b.disabled = true;
|
|
5091
|
-
try { await postJson('/verdict', { verdict, note }); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
5214
|
+
try { await postJson('/verdict', { verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
5092
5215
|
}
|
|
5093
5216
|
/** Starts review mode support once the run is on screen: the button, its count and the hash. */
|
|
5094
5217
|
function rvInit () {
|
|
@@ -5150,16 +5273,19 @@ function applyReviewerVerdicts () {
|
|
|
5150
5273
|
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 };
|
|
5151
5274
|
for (const n of RUN.nodes) {
|
|
5152
5275
|
if (n.kind === 'decision') continue;
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5276
|
+
// a criterion limited to another environment (not_applicable) stays out of the status
|
|
5277
|
+
const live = n.criteria.filter((c) => !c.not_applicable);
|
|
5278
|
+
for (const c of live) { const vs = RUN.scope.locales.map((l) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }).filter((v) => v !== 'not_reached'); c.verdict = vs.length ? worstV(vs) : 'not_reached'; }
|
|
5279
|
+
n.by_locale = Object.fromEntries(RUN.scope.locales.map((l) => [l, statusOf(live.map((c) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }), !!n.captures?.[l])]));
|
|
5280
|
+
n.status = statusOf(live.filter((c) => c.verdict !== 'not_reached').map((c) => c.verdict), Object.keys(n.captures || {}).length > 0);
|
|
5156
5281
|
summary[n.status]++;
|
|
5157
5282
|
}
|
|
5158
5283
|
RUN.summary = { ...RUN.summary, ...summary };
|
|
5159
5284
|
}
|
|
5160
5285
|
async function postJson (path, body) {
|
|
5161
5286
|
const a = APP();
|
|
5162
|
-
const res = await
|
|
5287
|
+
const res = await netSend(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
5288
|
+
if (res.status === 401) throw sessionLost();
|
|
5163
5289
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
5164
5290
|
return res.json();
|
|
5165
5291
|
}
|