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/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 a browser page.')
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
- if (method === 8) return inflateRawSync(data)
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
- while (this.enc.encodeQueueSize > 6) await new Promise((resolve) => setTimeout(resolve, 2))
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) { try { await this.enc.flush() } catch (e) { this.error = String(e?.message || e) } this.enc.close() }
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 }
@@ -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
  }
@@ -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 res = await fetch(commentsBase(scope) + path, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined });
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 = (v) => { const x = v == null ? '' : String(v); return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; };
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 = (v) => { const x = v == null ? '' : String(v); return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; };
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"></textarea>
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"><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>` : ''}
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 res = await fetch(url, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
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"></textarea></label>
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
- 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>`;
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"></textarea>
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
- toast(T('rvApprovedToast'));
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
- toast(T('rvReturnedToast'));
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
- for (const c of n.criteria) { 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'; }
5154
- n.by_locale = Object.fromEntries(RUN.scope.locales.map((l) => [l, statusOf(n.criteria.map((c) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }), !!n.captures?.[l])]));
5155
- n.status = statusOf(n.criteria.filter((c) => c.verdict !== 'not_reached').map((c) => c.verdict), Object.keys(n.captures || {}).length > 0);
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 fetch(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
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
  }