kaloko 0.7.1 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "scripts": {
35
- "test": "node --test --test-timeout=900000 test/*.test.mjs worker/test/*.test.mjs"
35
+ "test": "node scripts/test.mjs"
36
36
  },
37
37
  "dependencies": {
38
38
  "ajv": "^8.20.0",
@@ -84,6 +84,10 @@
84
84
  "default": "web",
85
85
  "description": "What the flow runs on. web: pages in a browser (an installed PWA too: display_modes). android/ios/electron/desktop/windows: an app, driven by the environment's app block (adb, iOS Simulator or device, Playwright Electron, macOS window, Windows UI Automation) or captured with kaloko capture --image. Node paths are deep links for apps."
86
86
  },
87
+ "page": {
88
+ "enum": ["landing", "work", "list", "settings", "form", "doc"],
89
+ "description": "Default page type of every step (a step may set its own). landing: a public or marketing page; work: an app screen where people work (overview, canvas, inbox); list: a list or table of items; settings: settings or billing; form: a form to fill in; doc: documentation or long text. The usability pack picks its questions by type. Not set: landing for steps with the seo pack or scenarios allowed on production, work otherwise."
90
+ },
87
91
  "tags": {
88
92
  "type": "array",
89
93
  "items": {
@@ -307,6 +311,10 @@
307
311
  ],
308
312
  "description": "For `kaloko walk --affected`: globs of repository files (relative to the config) that shape this step, next to the routes and imports Kaloko finds itself; `always` walks the step on every affected walk (sign-in, consent)."
309
313
  },
314
+ "page": {
315
+ "enum": ["landing", "work", "list", "settings", "form", "doc"],
316
+ "description": "What kind of page this step is (see the scenario's page); the usability pack asks per type, e.g. one primary call to action only on landing pages."
317
+ },
310
318
  "packs": {
311
319
  "type": "array",
312
320
  "items": {
@@ -321,7 +329,7 @@
321
329
  "design"
322
330
  ]
323
331
  },
324
- "description": "Check packs whose criteria are added to this node (ids AC1xx seo, AC2xx usability, AC3xx a11y, AC4xx perf, AC5xx console, AC6xx design: checks against the design reference)."
332
+ "description": "Check packs whose criteria are added to this node (ids AC1xx seo, AC2xx usability, AC3xx a11y, AC4xx perf, AC5xx console, AC6xx errors, AC7xx app, AC8xx design: checks against the design reference)."
325
333
  },
326
334
  "capture": {
327
335
  "$ref": "#/$defs/capture"
@@ -528,6 +536,12 @@
528
536
  "pack": {
529
537
  "type": "string",
530
538
  "description": "Set by the CLI on criteria that came from a pack."
539
+ },
540
+ "environments": {
541
+ "type": "array",
542
+ "minItems": 1,
543
+ "items": { "type": "string" },
544
+ "description": "Environments where this criterion applies, e.g. [production] for hreflang to the live domain, the live demo or the sitemap. On any other environment the walk reports it as not applicable (not_evaluated with na: environment) and it does not make the step partial."
531
545
  }
532
546
  }
533
547
  },
@@ -42,12 +42,42 @@ sharpen each purpose and add the criteria the page must really meet.
42
42
  criteria (ids `AC1xx` for `seo`, `AC2xx` for `usability`) at load time:
43
43
  `seo` checks one H1, title and description length, canonical, hreflang for
44
44
  every locale, Open Graph, indexing, viewport, `html lang`, overflow and
45
- console; `usability` asks the evaluator about purpose, one primary action
46
- and link texts and checks unlabeled inputs. A node's own criterion with the
45
+ console; `usability` checks unlabeled inputs and link texts and asks the
46
+ evaluator questions that fit the step's page type. A node's own criterion with the
47
47
  same id wins. Pack criteria are marked `pack` on the canvas and do not count
48
48
  as drift in `--coverage`; the plan may still mention them by id. Use packs
49
49
  for public pages and landings; task flows keep their own criteria.
50
50
 
51
+ Set `page` on every step (or once on the scenario) so the usability pack
52
+ asks the right questions:
53
+
54
+ | `page` | for | asks |
55
+ | --- | --- | --- |
56
+ | `landing` | public and marketing pages | purpose in the first screen (`AC201`), exactly one primary call to action (`AC202`) |
57
+ | `work` | app screens where people work: overview, canvas, detail, inbox item | the heading says what the screen is for (`AC205`) |
58
+ | `list` | lists and tables: runs, projects, inbox | `AC205`, each item says what it is and how to open it (`AC207`) |
59
+ | `settings` | settings, billing, tokens | `AC205`, each setting says what it changes and how it is saved (`AC208`) |
60
+ | `form` | sign-up, sign-in, a form to fill in | `AC205`, how to send it and what happens next (`AC206`) |
61
+ | `doc` | documentation, guides, terms | `AC201`, headings and short paragraphs (`AC209`) |
62
+
63
+ Every type also gets `AC203` and `AC204`. Without `page` a step with the
64
+ `seo` pack, or any step of a scenario allowed on production, is `landing`;
65
+ everything else is `work`. Do not put `landing` on signed-in screens: "one
66
+ primary call to action" is a marketing rule, and a work screen rightly offers
67
+ several actions.
68
+
69
+ A criterion that only makes sense on production (hreflang pairs pointing at
70
+ the live domain, an embedded live demo, the production sitemap) carries
71
+ `environments: [production]`. Walked anywhere else it is reported as not
72
+ applicable to that environment (`not_evaluated` with `not_applicable:
73
+ environment`) and does not make the step partial.
74
+
75
+ When a scenario walks several locales, a text check (`text_matches`,
76
+ `title_matches`, …) must match every language: write
77
+ `pattern: "(Your data|Vaše data)"`. `kaloko validate` warns when a pattern
78
+ reads in one language only (it looks for diacritics and common Czech or
79
+ English words; one plain word such as a brand name is left alone).
80
+
51
81
  - `a11y` (`AC3xx`): images with alt, links and buttons with a name, labelled
52
82
  fields, heading levels not skipped, WCAG AA text contrast, a `main`
53
83
  landmark, unique ids (`AC301`–`AC307`, read from the page). After the
package/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
6
6
  import { availableParallelism } from 'node:os'
7
7
  import yaml from 'js-yaml'
8
8
  import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
9
- import { loadScenario, validateScenario } from './lib/scenario.mjs'
9
+ import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
10
10
  import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
11
11
  import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
12
12
  import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
@@ -233,7 +233,7 @@ Results
233
233
  signoff --verify <file.json|file.pdf> [--key file|url] [--json]
234
234
  Check a record someone handed over (no token needed); exit 1 when it does not verify
235
235
  stability --pull [--scenario id] [--json]
236
- Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
236
+ Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
237
237
  region) go into the scenario's ignore: of their steps; walk them again after
238
238
  screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
239
239
  [--status pass|partial|fail|skip] [--environment e] [--designs] [--all] [--json] [--cursor c]
@@ -987,10 +987,12 @@ const commands = {
987
987
  if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
988
988
  let failed = 0
989
989
  for (const file of args._) {
990
- const scenario = yaml.load(await readFile(file, 'utf8'))
990
+ let scenario
991
+ try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); continue }
991
992
  const problems = validateScenario(scenario)
992
993
  if (!problems.length) expandPacks(scenario)
993
994
  if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
995
+ if (!problems.length) for (const w of scenarioWarnings(scenario)) say(` ! ${w}`)
994
996
  if (args.coverage) {
995
997
  // the plan is scenario.source (relative to the working directory or to the scenario file), or --source
996
998
  const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
@@ -1223,7 +1225,7 @@ const commands = {
1223
1225
  const parallel = concurrency > 1 && locales.length > 1
1224
1226
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
1225
1227
  // semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
1226
- const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
1228
+ const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null })
1227
1229
  const walked = await readCaptures(run.dir)
1228
1230
  // recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
1229
1231
  const recSettings = recordSettings({ args, config, scenario })
@@ -138,10 +138,22 @@ export async function currentPage (context, match) {
138
138
  return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
139
139
  }
140
140
 
141
+ /**
142
+ * Launches a browser with a time limit and one more try: on a busy machine a browser now and then starts but never
143
+ * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out.
144
+ * KALOKO_LAUNCH_TIMEOUT (seconds, default 45) sets the limit.
145
+ */
146
+ export async function launchWithRetry (browserType, options = {}) {
147
+ const timeout = (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
148
+ try { return await browserType.launch({ timeout, ...options }) } catch (e) {
149
+ if (!/Timeout/i.test(String(e?.name) + String(e?.message))) throw e
150
+ return browserType.launch({ timeout, ...options })
151
+ }
152
+ }
153
+
141
154
  export async function launchEphemeral (config) {
142
155
  const { chromium } = await import('playwright')
143
- const browser = await chromium.launch({ headless: true, channel: config.browser.channel || undefined })
144
- return browser
156
+ return launchWithRetry(chromium, { headless: true, channel: config.browser.channel || undefined })
145
157
  }
146
158
 
147
159
  /**
package/src/lib/build.mjs CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { readJson, writeJson } from './util.mjs'
6
6
  import { readCaptures, runPaths, listRuns } from './run.mjs'
7
- import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
7
+ import { latestCaptures, evaluationSource, stepIdOf, appliesOn } from './evaluate.mjs'
8
8
  import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
9
9
  import { layout } from './scenario.mjs'
10
10
  import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
@@ -149,7 +149,10 @@ async function runMedia ({ run, entries }) {
149
149
  async function buildStack ({ run, node, base, captures, attachments }) {
150
150
  const paths = runPaths(run.dir)
151
151
  const fields = fieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
152
- const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null }))
152
+ const environment = run.meta?.environment?.name || null
153
+ const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOn(c, environment) ? {} : { not_applicable: 'environment' }) }))
154
+ // criteria limited to other environments do not decide the status of a page
155
+ const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
153
156
  const byItem = new Map()
154
157
  for (const c of captures.filter((x) => x.node === node.id && x.item)) {
155
158
  const cap = await readJson(join(run.dir, c.file))
@@ -165,10 +168,10 @@ async function buildStack ({ run, node, base, captures, attachments }) {
165
168
  const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
166
169
  const per = Object.values(it.results)
167
170
  const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
168
- const verdicts = crit.map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
171
+ const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
169
172
  const status = statusOf(verdicts, per.length > 0)
170
- const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.values(r).map((x) => x[0])), true)]))
171
- return { ...it, status, by_locale, fails: crit.filter((c) => verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
173
+ const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
174
+ return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
172
175
  })
173
176
  const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
174
177
  for (const it of items) summary[it.status]++
@@ -361,14 +361,35 @@ export function docsConfigBlock ({ products = null, full = false } = {}) {
361
361
  return `${lines.join('\n')}\n`
362
362
  }
363
363
 
364
- /** Apps of a monorepo that look like products (apps/* with a package.json), for kaloko init. */
364
+ /**
365
+ * Apps of a monorepo that look like products, for kaloko init: the folders of the workspaces (package.json
366
+ * `workspaces`, pnpm-workspace.yaml `packages`), apps/* and packages/*. A folder needs a package.json; outside apps/*
367
+ * it must also run as an app (a dev, start or serve script), so shared libraries and configs are left out. Only
368
+ * offered when there are at least two.
369
+ */
365
370
  export async function detectProducts (root) {
366
- const out = []
367
- for (const base of ['apps']) {
368
- const names = await readdir(join(root, base)).catch(() => [])
369
- for (const n of names.sort()) {
370
- const pkg = await readJson(join(root, base, n, 'package.json'))
371
- if (pkg) out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir: `${base}/${n}`, path: `${base}/${n}/package.json` })
371
+ const globs = new Set(['apps/*', 'packages/*'])
372
+ const rootPkg = await readJson(join(root, 'package.json'))
373
+ const ws = Array.isArray(rootPkg?.workspaces) ? rootPkg.workspaces : Array.isArray(rootPkg?.workspaces?.packages) ? rootPkg.workspaces.packages : []
374
+ for (const g of ws) globs.add(String(g))
375
+ const pnpm = await readFile(join(root, 'pnpm-workspace.yaml'), 'utf8').catch(() => '')
376
+ for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)) globs.add(m[1].trim())
377
+ const out = []; const seen = new Set()
378
+ for (const g of [...globs].sort()) {
379
+ if (g.startsWith('!')) continue
380
+ // "dir/*" (one level) or a plain folder; deeper patterns are left to the person
381
+ const m = g.replace(/\/+$/, '').match(/^([^*]+?)(\/\*)?$/)
382
+ if (!m) continue
383
+ const dirs = m[2] ? (await readdir(join(root, m[1])).catch(() => [])).sort().map((n) => `${m[1]}/${n}`) : [m[1]]
384
+ for (const dir of dirs) {
385
+ if (seen.has(dir)) continue
386
+ const pkg = await readJson(join(root, dir, 'package.json'))
387
+ if (!pkg) continue
388
+ const runs = ['dev', 'start', 'serve'].some((k) => pkg.scripts?.[k])
389
+ if (!dir.startsWith('apps/') && !runs) continue
390
+ seen.add(dir)
391
+ const n = basename(dir)
392
+ out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir, path: `${dir}/package.json` })
372
393
  }
373
394
  }
374
395
  return out.length > 1 ? out : []
@@ -8,6 +8,7 @@
8
8
  // `scope.variants` names the dimensions of each key; `scope.dimensions` lists the values for the canvas switchers.
9
9
  import { UserError } from './util.mjs'
10
10
  import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
11
+ import { launchWithRetry } from './browser.mjs'
11
12
 
12
13
  export const BROWSERS = ['chromium', 'webkit', 'firefox']
13
14
  export const COLOR_SCHEMES = ['light', 'dark']
@@ -318,7 +319,7 @@ export async function launchEngine (engine, { channel, headless = true } = {}) {
318
319
  const type = pw[engine]
319
320
  if (!type) throw new UserError(`Unknown browser engine ${engine}. Use: ${BROWSERS.join(', ')}.`)
320
321
  try {
321
- return await type.launch({ headless, ...(engine === 'chromium' && channel ? { channel } : {}) })
322
+ return await launchWithRetry(type, { headless, ...(engine === 'chromium' && channel ? { channel } : {}) })
322
323
  } catch (error) {
323
324
  if (/Executable doesn't exist|install/i.test(error.message)) throw new UserError(`${engine} is not installed for Playwright. Run: kaloko browser install --browsers ${engine}`)
324
325
  throw error
@@ -288,11 +288,12 @@ export const escapeHtml = esc
288
288
 
289
289
  /** Inline Markdown: code, bold, italics, links (safe schemes; `link(href)` may rewrite or drop them). Images are removed. */
290
290
  export function inlineHtml (s, { link = null } = {}) {
291
- let out = esc(String(s).replace(/!\[[^\]]*\]\([^)]*\)/g, ''))
291
+ let out = esc(String(s).replace(/!\[[^[\]]*\]\([^()]*\)/g, ''))
292
292
  const codes = []
293
293
  out = out.replace(/`([^`]+)`/g, (_, c) => { codes.push(`<code>${c}</code>`); return `\u0000${codes.length - 1}\u0000` })
294
294
  out = out.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/__([^_]+)__/g, '<b>$1</b>').replace(/(^|[\s(])_([^_]+)_(?=$|[\s).,;:!?])/g, '$1<i>$2</i>').replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=$|[\s).,;:!?])/g, '$1<i>$2</i>')
295
- out = out.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_, t, href) => {
295
+ // no [ inside the brackets, at most one pair of ( ) in the target: a long run of [ stays linear
296
+ out = out.replace(/\[([^[\]]+)\]\(([^()[\]\s]+(?:\([^()\s]*\)[^()[\]\s]*)?)(?:\s+&quot;[^&]*&quot;)?\)/g, (_, t, href) => {
296
297
  const raw = href.replace(/&amp;/g, '&')
297
298
  const target = link ? link(raw) : raw
298
299
  if (target == null) return t
@@ -302,7 +303,7 @@ export function inlineHtml (s, { link = null } = {}) {
302
303
  }
303
304
 
304
305
  const LIST = /^(\s{0,3})([-*+]|\d{1,9}[.)])\s+(.*)$/
305
- const IMG = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
306
+ const IMG = /!\[([^[\]]*)\]\(([^()[\]\s]+)(?:\s+"[^"]*")?\)/g
306
307
 
307
308
  /**
308
309
  * Markdown to HTML for docs: headings (with ids), paragraphs, lists (nested by indentation), fenced code,
@@ -361,16 +362,18 @@ function blocks (lines, o) {
361
362
  continue
362
363
  }
363
364
  if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(l)) { flush(); out.push('<hr>'); i++; continue }
364
- if (/^\s{0,3}>/.test(l)) {
365
+ // quotes nest at most 16 deep; deeper > are text (100 000 levels would overflow the stack)
366
+ if (/^\s{0,3}>/.test(l) && (o.quoteDepth || 0) < 16) {
365
367
  flush()
366
368
  const buf = []
367
369
  while (i < lines.length && /^\s{0,3}>/.test(lines[i])) buf.push(lines[i++].replace(/^\s{0,3}>\s?/, ''))
368
- out.push(`<blockquote>${blocks(buf, { ...o, notice: null })}</blockquote>`)
370
+ out.push(`<blockquote>${blocks(buf, { ...o, notice: null, quoteDepth: (o.quoteDepth || 0) + 1 })}</blockquote>`)
369
371
  continue
370
372
  }
371
373
  if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(lines[i + 1])) {
372
374
  flush()
373
- const row = (x) => x.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
375
+ // GFM: `\|` is a pipe inside a cell (also inside code), not a column break
376
+ const row = (x) => x.trim().replace(/^\||(?<!\\)\|$/g, '').split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'))
374
377
  const head = row(l); i += 2
375
378
  const rows = []
376
379
  while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(row(lines[i++]))
package/src/lib/docs.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { readFile, readdir, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
6
6
  import { execFile } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
- import { join, resolve, relative, dirname } from 'node:path'
8
+ import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path'
9
9
  import { tmpdir } from 'node:os'
10
10
  import { UserError, sha256 } from './util.mjs'
11
11
  import { findStepRefs, cleanVersion } from './changelog-core.mjs'
@@ -210,7 +210,9 @@ export async function unpack (buf, dir) {
210
210
  for (const [name, read] of entries) {
211
211
  if (name.endsWith('/')) continue
212
212
  const target = resolve(dir, name)
213
- if (!target.startsWith(resolve(dir) + '/')) throw new UserError(`The export holds a path outside its folder: ${name}`)
213
+ // relative() rather than a '/' prefix test, so the check also holds with Windows separators (a\..\..\x)
214
+ const rel = relative(resolve(dir), target)
215
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || name.includes('\0')) throw new UserError(`The export holds a path outside its folder: ${name}`)
214
216
  await mkdir(dirname(target), { recursive: true })
215
217
  await writeFile(target, read())
216
218
  n++
@@ -17,6 +17,8 @@ export function latestCaptures (entries) {
17
17
  }
18
18
 
19
19
  export const criteriaHash = (node) => sha256(node.criteria || [])
20
+ /** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
21
+ export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
20
22
  /** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
21
23
  export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
22
24
  export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
@@ -26,8 +28,9 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
26
28
  * asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
27
29
  * model's own, or a sentence saying what the text evaluator read (it returns a probability only).
28
30
  */
29
- export function combine ({ node, capture, jev, vision = null, thresholds }) {
31
+ export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
30
32
  return (node.criteria || []).map((c) => {
33
+ if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
31
34
  if (c.check.type === 'deterministic') {
32
35
  const r = capture.checks?.[c.id]
33
36
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
@@ -64,6 +67,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
64
67
  const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
65
68
  const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
66
69
  const paths = runPaths(run.dir)
70
+ // criteria limited to other environments (environments: [production]) are reported as not applicable here
71
+ const environment = run.meta?.environment?.name || null
67
72
  const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
68
73
  // the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
69
74
  const vset = visionSettings(config)
@@ -101,7 +106,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
101
106
  const current = capture.criteria_hash === criteriaHash(node)
102
107
  // console triage needs the evaluator only when the capture has console errors
103
108
  const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
104
- const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
109
+ const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
105
110
  // quick signals ride along on every captured page state (also steps without semantic criteria)
106
111
  const signals = settings.signals !== false && !outOfSample && !!capture.state
107
112
  // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
@@ -126,7 +131,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
126
131
  const source = evaluationSource(node, capture)
127
132
  if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
128
133
  if (styleTokens?.tokens && current && tokenCriteria(node).length) capture.checks = { ...capture.checks, ...storedValueChecks(node, capture, { ...styleTokens, recorded: !!capture.rechecked_at, only: (c) => !!c.check.token && STYLE_ASSERTS.has(c.check.assert) }) }
129
- let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
134
+ let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds, environment })
130
135
  // pages outside the semantic sample carry only the deterministic results
131
136
  if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
132
137
  const evaluation = {
@@ -175,7 +180,7 @@ function signalsOf (jev) {
175
180
  return Object.keys(out).length ? out : null
176
181
  }
177
182
 
178
- export function semanticPrefetch ({ scenario, config, root }) {
183
+ export function semanticPrefetch ({ scenario, config, root, environment = null }) {
179
184
  const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
180
185
  const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
181
186
  const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
@@ -197,7 +202,7 @@ export function semanticPrefetch ({ scenario, config, root }) {
197
202
  for (const e of [].concat(entries || [])) {
198
203
  if (e?.type !== 'capture' || e.item) continue
199
204
  const node = nodes.get(e.node)
200
- const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic')
205
+ const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment))
201
206
  if (!semantic.length && settings.signals === false) continue
202
207
  queue.push(async () => {
203
208
  const capture = await readJson(join(runDir, e.file))
@@ -99,24 +99,29 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
99
99
  // semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
100
100
  // semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
101
101
  const seen = r.provider && /-vision$/.test(r.provider)
102
- loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
102
+ loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(r.not_applicable ? { not_applicable: r.not_applicable } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
103
103
  if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
104
104
  if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
105
105
  }
106
106
  }
107
107
  const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
108
108
  for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
109
- const verdicts = counted(applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp])))
110
- return { ...agg, verdict: aggregate(verdicts) }
109
+ const entries = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp]))
110
+ const verdicts = counted(entries)
111
+ // a criterion limited to other environments (environments: [production]) is not applicable on this walk:
112
+ // reported as such, and it does not make the step partial
113
+ const offEnv = entries.some((e) => e.not_applicable) && entries.every((e) => e.not_applicable || e.verdict === 'not_reached')
114
+ return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: 'environment' } : {}) }
111
115
  })
116
+ const counting = criteria.filter((c) => !c.not_applicable)
112
117
  const byLocale = {}
113
118
  for (const l of locales) {
114
119
  const captured = !!capturesOut[l]
115
- const vs = criteria.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
120
+ const vs = counting.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
116
121
  if (applicable.includes(l) && viewportNames.some((vp) => !capturesOut[l]?.[vp])) vs.push('not_reached')
117
122
  byLocale[l] = statusOf(vs, captured)
118
123
  }
119
- const verdicts = criteria.map((c) => c.verdict)
124
+ const verdicts = counting.map((c) => c.verdict)
120
125
  if (applicable.some((l) => viewportNames.some((vp) => !capturesOut[l]?.[vp]))) verdicts.push('not_reached')
121
126
  return {
122
127
  ...base,
@@ -137,7 +142,7 @@ export function summarize (nodes, sessions = null) {
137
142
  const screens = nodes.filter((n) => n.kind !== 'decision')
138
143
  const summary = { pass: 0, partial: 0, fail: 0, skip: 0, criteria: {} }
139
144
  for (const n of screens) summary[n.status]++
140
- for (const n of screens) for (const c of n.criteria) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
145
+ for (const n of screens) for (const c of n.criteria) if (!c.not_applicable) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
141
146
  if (sessions) {
142
147
  summary.by_session = {}
143
148
  for (const key of Object.keys(sessions)) summary.by_session[key] = { pass: 0, partial: 0, fail: 0, skip: 0 }
@@ -18,6 +18,11 @@ export const PACK_HELP = {
18
18
  AC202: { cs: 'Jedna jasná hlavní akce', why: ['Several equally loud buttons make people hesitate; none leaves them without a next step.', 'Několik stejně výrazných tlačítek lidi zdrží; žádné je nechá bez dalšího kroku.'], fix: ['Give one action the primary style; make the others secondary or links.', 'Jedné akci dejte hlavní styl, ostatní udělejte vedlejší nebo jako odkazy.'] },
19
19
  AC203: { cs: 'Formulářová pole mají viditelný popisek', why: ['A placeholder disappears as people type; they forget what the field was.', 'Placeholder při psaní zmizí a lidé zapomenou, co do pole patří.'], fix: ['Add a <label for="…"> (or aria-label) to every visible input.', 'Ke každému viditelnému poli doplňte <label for="…"> (nebo aria-label).'] },
20
20
  AC204: { cs: 'Odkazy a tlačítka říkají, co udělají', why: ['"Click here" or "More" does not say where it leads, especially out of context or to a screen reader.', '„Klikněte zde“ nebo „Více“ neřekne, kam vede, zvlášť mimo kontext nebo ve čtečce.'], fix: ['Name the result: "Download the price list", "Continue to payment".', 'Pojmenujte výsledek: „Stáhnout ceník“, „Pokračovat k platbě“.'] },
21
+ AC205: { cs: 'Z nadpisu je jasné, k čemu obrazovka je', why: ['People switch between screens all day; a vague heading makes them check where they are.', 'Lidé mezi obrazovkami přepínají celý den; s mlhavým nadpisem musí hledat, kde vlastně jsou.'], fix: ['Name the screen after what it shows or does: "Runs of the checkout flow", not "Dashboard".', 'Pojmenujte obrazovku podle toho, co ukazuje nebo dělá: „Běhy flow pokladny“, ne „Nástěnka“.'] },
22
+ AC206: { cs: 'Je jasné, jak formulář odeslat a co bude dál', why: ['When the send button or the next step is unclear, people submit twice or give up.', 'Když není jasné odesílací tlačítko nebo další krok, lidé odešlou dvakrát nebo to vzdají.'], fix: ['Label the button with the result ("Send the link") and say in one sentence what happens next.', 'Tlačítko pojmenujte podle výsledku („Poslat odkaz“) a jednou větou řekněte, co bude dál.'] },
23
+ AC207: { cs: 'Každá položka seznamu říká, co je zač a jak ji otevřít', why: ['A row with only an id or a date makes people open items one by one to find the right one.', 'Řádek jen s identifikátorem nebo datem nutí lidi otevírat položky jednu po druhé.'], fix: ['Show a name and a state in every row and make the name the link.', 'V každém řádku ukažte název a stav a z názvu udělejte odkaz.'] },
24
+ AC208: { cs: 'Každé nastavení říká, co změní a jak se uloží', why: ['People leave settings unsure whether the change took effect.', 'Lidé odcházejí z nastavení a nevědí, jestli se změna projevila.'], fix: ['Add a short hint under each setting and either a save button or a visible "Saved".', 'Pod každé nastavení přidejte krátkou nápovědu a buď tlačítko Uložit, nebo viditelné „Uloženo“.'] },
25
+ AC209: { cs: 'Text jde rychle projít: nadpisy a krátké odstavce', why: ['Readers of documentation scan for their question; a wall of text hides the answer.', 'Čtenář dokumentace hledá svou otázku očima; souvislý text odpověď schová.'], fix: ['Split the text under headings that name the question and keep paragraphs to a few sentences.', 'Rozdělte text pod nadpisy, které pojmenují otázku, a odstavce držte na pár větách.'] },
21
26
  AC301: { cs: 'Obrázky mají alternativní text', why: ['Without alt text, blind visitors and search engines do not know what an image shows.', 'Bez alt textu nevidomí ani vyhledávače nevědí, co obrázek ukazuje.'], fix: ['Add alt="…" describing the image; decorative images get alt="".', 'Doplňte alt="…" s popisem obrázku; dekorativní obrázky dostanou alt="".'] },
22
27
  AC302: { cs: 'Odkazy a tlačítka mají přístupný název', why: ['An icon button without a name is read as just "button".', 'Tlačítko s ikonou bez názvu čtečka přečte jen jako „tlačítko“.'], fix: ['Add visible text or aria-label to icon-only links and buttons.', 'Odkazům a tlačítkům jen s ikonou doplňte text nebo aria-label.'] },
23
28
  AC303: { cs: 'Formulářová pole mají popisky', why: ['Screen reader users hear only "edit text" for a field without a label.', 'Uživatel čtečky uslyší u pole bez popisku jen „editační pole“.'], fix: ['Connect a <label> to each field (for/id) or use aria-label / aria-labelledby.', 'Ke každému poli připojte <label> (for/id) nebo použijte aria-label / aria-labelledby.'] },
package/src/lib/packs.mjs CHANGED
@@ -33,13 +33,23 @@ export const PACKS = {
33
33
  },
34
34
  usability: {
35
35
  base: 200,
36
- title: 'Usability: purpose, primary action, forms, feedback',
37
- criteria: () => [
38
- { id: 'AC201', text: 'The purpose of the page is clear within the first screen', check: { type: 'semantic', question: 'Is the purpose of this page clear from its heading and first paragraph alone?', scope: 'main, body' } },
39
- { id: 'AC202', text: 'One clear primary action', check: { type: 'semantic', question: 'Is there exactly one clearly primary call to action on the page?', scope: 'main, body' } },
40
- { id: 'AC203', text: 'Form fields have visible labels', check: det('count', { selector: 'input:not([type=hidden]):not([type=submit]):not([aria-label]):not([id])', min: 0, max: 0 }) },
41
- { id: 'AC204', text: 'Links and buttons say what they do', check: { type: 'semantic', question: 'Do the link and button texts describe what happens when they are used, rather than generic words like "click here"?', scope: 'main, body' } }
42
- ]
36
+ title: 'Usability by page type (`page` on the step or scenario): purpose, a primary action on landing pages, forms, lists, settings, link texts',
37
+ criteria: ({ page = 'landing' } = {}) => {
38
+ const sem = (question) => ({ type: 'semantic', question, scope: 'main, body' })
39
+ const on = (...types) => types.includes(page)
40
+ return [
41
+ ...(on('landing', 'doc') ? [{ id: 'AC201', text: 'The purpose of the page is clear within the first screen', check: sem('Is the purpose of this page clear from its heading and first paragraph alone?') }] : []),
42
+ ...(on('work', 'list', 'settings', 'form') ? [{ id: 'AC205', text: 'The heading says what the screen is for', check: sem('Is it clear from the heading and the content of this screen what it is for?') }] : []),
43
+ // one loud call to action is a landing-page rule; work screens, lists and settings rightly offer several actions
44
+ ...(on('landing') ? [{ id: 'AC202', text: 'One clear primary action', check: sem('Is there exactly one clearly primary call to action on the page?') }] : []),
45
+ ...(on('form') ? [{ id: 'AC206', text: 'It is clear how to send the form and what happens next', check: sem('Is it clear which button sends the form and what happens after it is sent?') }] : []),
46
+ ...(on('list') ? [{ id: 'AC207', text: 'Each item in the list says what it is and how to open it', check: sem('Does each item in the list show what it is (for example a name and its state) and how to open it?') }] : []),
47
+ ...(on('settings') ? [{ id: 'AC208', text: 'Each setting says what it changes and how it is saved', check: sem('Does each setting say what it changes, and is it clear whether a change is saved by itself or with a button?') }] : []),
48
+ ...(on('doc') ? [{ id: 'AC209', text: 'The text can be scanned: headings and short paragraphs', check: sem('Is the text split into headings and short paragraphs that a reader can scan?') }] : []),
49
+ { id: 'AC203', text: 'Form fields have visible labels', check: det('count', { selector: 'input:not([type=hidden]):not([type=submit]):not([aria-label]):not([id])', min: 0, max: 0 }) },
50
+ { id: 'AC204', text: 'Links and buttons say what they do', check: sem('Do the link and button texts describe what happens when they are used, rather than generic words like "click here"?') }
51
+ ]
52
+ }
43
53
  },
44
54
  a11y: {
45
55
  base: 300,
@@ -103,6 +113,20 @@ export const PACKS = {
103
113
  }
104
114
  }
105
115
 
116
+ /** Page types a step (or the whole scenario) declares with `page`; the usability pack asks per type. */
117
+ export const PAGE_TYPES = ['landing', 'work', 'list', 'settings', 'form', 'doc']
118
+
119
+ /**
120
+ * The page type of a node: its own `page`, then the scenario's `page`, else inferred. A step with the seo pack or a
121
+ * scenario allowed on production is a public page (landing); anything else is an app screen (work).
122
+ */
123
+ export function pageTypeOf (node, scenario = {}) {
124
+ if (node?.page) return node.page
125
+ if (scenario?.page) return scenario.page
126
+ if ((node?.packs || []).includes('seo') || (scenario?.environments || []).includes('production')) return 'landing'
127
+ return 'work'
128
+ }
129
+
106
130
  /**
107
131
  * Adds the criteria of each pack listed on a node (`node.packs`) that the node does not already define.
108
132
  * Mutates and returns the scenario; safe to call twice.
@@ -117,7 +141,7 @@ export function expandPacks (scenario) {
117
141
  for (const name of node.packs) {
118
142
  const pack = PACKS[name]
119
143
  if (!pack) throw new Error(`node "${node.id}": unknown pack "${name}" (known: ${Object.keys(PACKS).join(', ')})`)
120
- for (const c of pack.criteria({ locales })) {
144
+ for (const c of pack.criteria({ locales, page: pageTypeOf(node, scenario) })) {
121
145
  if (have.has(c.id)) continue
122
146
  node.criteria.push({ ...c, pack: name })
123
147
  have.add(c.id)
@@ -5,8 +5,31 @@ import { UserError } from './util.mjs'
5
5
  import { expandPacks } from './packs.mjs'
6
6
  import { jsGuard } from './assertions.mjs'
7
7
 
8
+ // a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
9
+ // billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
10
+ const MAX_VALUES = 1_000_000
11
+ const MAX_DEPTH = 64
12
+
13
+ /** The data of a scenario's YAML text; a UserError when it is no YAML or expands beyond MAX_VALUES or MAX_DEPTH. */
14
+ export function parseScenario (text, file = 'scenario') {
15
+ let data
16
+ try { data = yaml.load(text) } catch (e) { throw new UserError(`Scenario ${file} is not valid YAML: ${String(e?.message || e).split('\n')[0]}`) }
17
+ let n = 0
18
+ const stack = [[data, 0]]
19
+ while (stack.length) {
20
+ const [v, depth] = stack.pop()
21
+ if (!v || typeof v !== 'object') continue
22
+ if (depth >= MAX_DEPTH) throw new UserError(`Scenario ${file} is nested deeper than ${MAX_DEPTH} levels.`)
23
+ const kids = Object.values(v)
24
+ // counted before they are pushed, so a shared alias cannot grow the stack past the ceiling either
25
+ if ((n += kids.length) > MAX_VALUES) throw new UserError(`Scenario ${file} expands to more than ${MAX_VALUES} values (YAML aliases?).`)
26
+ for (const k of kids) stack.push([k, depth + 1])
27
+ }
28
+ return data
29
+ }
30
+
8
31
  export async function loadScenario (file) {
9
- const data = yaml.load(await readFile(file, 'utf8'))
32
+ const data = parseScenario(await readFile(file, 'utf8'), file)
10
33
  const problems = validateScenario(data)
11
34
  if (!problems.length) expandPacks(data)
12
35
  if (problems.length) throw new UserError(`Scenario ${file} is invalid:\n - ${problems.join('\n - ')}`)
@@ -54,6 +77,45 @@ export function validateScenario (data) {
54
77
  return problems
55
78
  }
56
79
 
80
+ const TEXT_ASSERTS = new Set(['text_matches', 'text_absent', 'title_matches', 'meta_matches'])
81
+ const ACCENTED = /[áčďéěíňóřšťúůýžäöüßąćęłńśźżàâçèêëîïôûùÿœñ]/i
82
+ const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'for', 'with', 'your', 'you', 'is', 'are', 'on', 'by', 'or', 'not', 'no', 'an', 'this', 'what', 'how'])
83
+ const CS_WORDS = new Set(['a', 'i', 'v', 've', 'na', 'do', 'se', 'je', 'jsou', 'pro', 's', 'z', 'k', 'od', 'po', 'jak', 'co', 'nebo', 'ani', 'už', 'vaše', 'váš'])
84
+
85
+ /** Language of one alternative of a text pattern: 'other' (diacritics or a Czech word), 'en' (an English word) or null. */
86
+ function branchLanguage (branch) {
87
+ const text = branch.replace(/\\./g, ' ').replace(/\[[^\]]*\]/g, ' ')
88
+ if (ACCENTED.test(text)) return 'other'
89
+ const words = (text.match(/\p{L}+/gu) || []).map((w) => w.toLowerCase())
90
+ if (words.some((w) => CS_WORDS.has(w) && w.length > 1)) return 'other'
91
+ if (words.some((w) => EN_WORDS.has(w))) return 'en'
92
+ return null
93
+ }
94
+
95
+ /**
96
+ * Warnings that do not make a scenario invalid. A text criterion in a scenario with several locales whose pattern
97
+ * reads in one language only fails (or passes by accident) in the others. The heuristic is deliberately simple:
98
+ * the pattern is split at `|` and each alternative gets a language: a letter with a diacritic or a common Czech
99
+ * word means not English, a common English word ("the", "and", "your") means English, anything else (a brand,
100
+ * one plain word, "SOC 2") is unknown. It warns only when every alternative has a language and they all agree, so
101
+ * "Plans and payment" or "Vaše data" warn and "Your data|Vaše data", "Slack" or "Design drafts" do not.
102
+ * Pack criteria are left out.
103
+ */
104
+ export function scenarioWarnings (data) {
105
+ const out = []
106
+ if (!data || (data.locales || []).length < 2 || !Array.isArray(data.nodes)) return out
107
+ for (const node of data.nodes) {
108
+ for (const c of node.criteria || []) {
109
+ const k = c.check || {}
110
+ if (c.pack || k.type !== 'deterministic' || !TEXT_ASSERTS.has(k.assert) || typeof k.pattern !== 'string') continue
111
+ const langs = k.pattern.split('|').map(branchLanguage)
112
+ if (langs.some((l) => !l) || new Set(langs).size !== 1) continue
113
+ out.push(`node "${node.id}": ${c.id} pattern /${k.pattern}/ reads in one language only, but the scenario walks ${data.locales.join(', ')}; add the other language as an alternative (English|Czech) or move the check to a per-locale step`)
114
+ }
115
+ }
116
+ return out
117
+ }
118
+
57
119
  /**
58
120
  * Layered layout: column = longest path from a root, lane = explicit `lane`
59
121
  * or inherited from the parent (+1 for each extra `alt` branch).
@@ -292,7 +292,7 @@ export async function pullDrawn ({ root, config, run, args, say, client }) {
292
292
  const scenarioId = typeof args.scenario === 'string' ? args.scenario : run.meta.scenario.id
293
293
  const data = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/ignore-regions`)
294
294
  const regions = data.regions || []
295
- if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Page → Draw a region).`); return { applied: [], skipped: [], regions: [] } }
295
+ if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Mark a changing region).`); return { applied: [], skipped: [], regions: [] } }
296
296
  const byNode = new Map()
297
297
  for (const r of regions) (byNode.get(r.node_id) || byNode.set(r.node_id, []).get(r.node_id)).push(r.scenario_entry)
298
298
  const file = resolve(root, run.meta.scenario_file)
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
  }
@@ -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
@@ -294,6 +294,7 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
294
294
  }
295
295
 
296
296
  .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)}
297
+ .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)}
297
298
 
298
299
  @media (max-width:1100px){
299
300
  .main{grid-template-columns:minmax(0,1fr)}
@@ -1099,6 +1100,11 @@ Object.assign(I18N.cs, { dim_browser:'Prohlížeč', dim_color_scheme:'Barevné
1099
1100
  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
1101
  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
1102
  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' });
1103
+ 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' });
1104
+ Object.assign(I18N.en, { sessionLost:'Your session has expired. Sign in again; what you were writing waits here.', signInAgain:'Sign in again' });
1105
+ // back after signing in again: the session expired on the canvas (sessionLost), the server kept the path, but the
1106
+ // sign-in mail cannot carry the fragment (the open step, the comments), so the canvas stored it for this moment
1107
+ (() => { 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
1108
  const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
1103
1109
  // #lang=cs|en in a link forces the language of the canvas for that page (a shared link, a walk in another locale):
1104
1110
  // it wins over the account, the cookie and the browser, and stays in the address while the canvas rewrites it
@@ -1357,7 +1363,7 @@ const sessColor = (key) => SESSION_COLORS[Math.max(0, sessions().indexOf(key)) %
1357
1363
  const sessTitle = (key) => key ? loc(RUN.sessions?.[key]?.title) || key : '';
1358
1364
  const sessChip = (key) => key && RUN.sessions?.[key] ? `<span class="sess" style="--sc:${sessColor(key)}" title="${esc(key)}"><i></i>${esc(sessTitle(key))}</span>` : '';
1359
1365
  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)); };
1366
+ 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
1367
  const critSt = (c) => VSTAT[critVerdict(c)];
1362
1368
  const prevRun = () => (RUN.history || []).find((h) => h.started_at < RUN.started_at);
1363
1369
  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')}`; };
@@ -1426,6 +1432,7 @@ function commentsBase (scope) {
1426
1432
  }
1427
1433
  async function cApi (scope, path, method, body) {
1428
1434
  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 });
1435
+ if (res.status === 401) throw sessionLost();
1429
1436
  const data = await res.json().catch(() => ({}));
1430
1437
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
1431
1438
  return data;
@@ -1452,6 +1459,8 @@ function rerenderComments (all = false) {
1452
1459
  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
1460
  const loadDraft = (k) => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
1454
1461
  const saveDraft = (k, v) => { try { if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); } catch {} };
1462
+ // notes outside the comment composer (verdict, return, issue) carry their draft key: typed text is kept the same way
1463
+ document.addEventListener('input', (e) => { const k = e.target?.dataset?.draft; if (k) saveDraft(k, e.target.value); });
1455
1464
  const noteHtml = (s) => esc(s).replace(/(^|\s)@([\w.+-]+(?:@[\w.-]+)?)/g, '$1<b class="c-at">@$2</b>').replace(/\n/g, '<br>');
1456
1465
  /** Numbers of the threads pinned on this step's screenshot (the same numbers as the marks on the image). */
1457
1466
  function pinNumbers (nodeId) {
@@ -2197,9 +2206,13 @@ function closeStack (keepHash) {
2197
2206
  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
2207
  $('si').hidden = true; if (keepHash !== true) setHash(null);
2199
2208
  }
2209
+ // a CSV cell: text from crawled pages that starts like a formula (= + - @ tab CR) gets a leading ' so a spreadsheet
2210
+ // shows it instead of running it, also after a ; or a line break (Excel with a semicolon separator splits there; the
2211
+ // same rule as the audit CSV, worker/src/audit-log.js); numbers stay numbers
2212
+ 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
2213
  function stackCsv (n, data) {
2201
2214
  const cols = data.fields || [];
2202
- const q = (v) => { const x = v == null ? '' : String(v); return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; };
2215
+ const q = csvCell;
2203
2216
  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
2217
  const blob = new Blob(['' + rows.map((r) => r.map(q).join(',')).join('\n')], { type: 'text/csv;charset=utf-8' });
2205
2218
  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 +2372,7 @@ function riKey (e) {
2359
2372
  return false;
2360
2373
  }
2361
2374
  function riCsv () {
2362
- const q = (v) => { const x = v == null ? '' : String(v); return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; };
2375
+ const q = csvCell;
2363
2376
  const rows = [['step', 'title', 'kind', 'path', 'status', 'criteria_passed', 'criteria', 'failing', 'http', 'console_errors', 'pages']];
2364
2377
  for (const e of S.ri.entries || []) {
2365
2378
  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 +2653,7 @@ function renderAll () { renderChrome(); renderNodes(); if (S.right) renderReport
2640
2653
  /* Evidence of the built-in checks is a fixed English template (kept as is in the data, for agents and the API);
2641
2654
  the canvas says it as a sentence in the reader's language. Unknown text and HTML snippets stay as they are. */
2642
2655
  const EVIDENCE = [
2656
+ [/^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
2657
  [/^no visible link to the home page$/, () => ['No visible link to the home page', 'Chybí viditelný odkaz na úvodní stránku']],
2644
2658
  [/^no visible (.+)$/, (m) => [`Nothing visible matches ${m[1]}`, `Na stránce není vidět nic, co odpovídá ${m[1]}`]],
2645
2659
  [/^(\d+)× (.+)$/, (m) => [`Found ${m[1]}× ${m[2]}`, `Nalezeno ${m[1]}× ${m[2]}`]],
@@ -3545,9 +3559,9 @@ function renderReport () {
3545
3559
  ${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
3560
  ${versionNotesHtml()}
3547
3561
  ${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>
3562
+ ${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
3563
  <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>` : ''}
3564
+ <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
3565
  ${commentsPanel()}
3552
3566
  <div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
3553
3567
  <div class="stack">${['pass', 'partial', 'fail', 'skip'].map((k) => `<i style="width:${c[k] / tot * 100}%;background:var(--${k})"></i>`).join('')}</div>
@@ -3578,7 +3592,7 @@ function renderReport () {
3578
3592
  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
3593
  const showNote = () => { const t = $('rvVerdictNote'); if (!t) return; t.hidden = false; $('rvNoteShow')?.remove(); t.focus(); };
3580
3594
  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; } }; });
3595
+ $('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
3596
  $('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
3597
  $('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
3598
  $('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,8 +3770,18 @@ const sameAsCmp = (n) => !!(S.cmp && isDesign() && n.kind !== 'decision' && revO
3756
3770
  const headOf = (n, loc = S.loc) => RUN.revisions?.[`${n.id}|${loc}`] || null;
3757
3771
  const who = (a) => a ? (a.agent ? `${a.agent} (${T('viaAgent')} ${a.email})` : a.email) : '';
3758
3772
 
3773
+ /**
3774
+ * The session ended while the canvas was open (an API call answered 401): one bar says so and links to sign in with
3775
+ * the way back; every unsent text is already in localStorage (drafts) and stays. Returns the error to throw.
3776
+ */
3777
+ function sessionLost () {
3778
+ try { localStorage.setItem('qawalk-return', JSON.stringify({ path: location.pathname, hash: location.hash, at: Date.now() })); } catch {}
3779
+ 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>`);
3780
+ return new Error(T('sessionLost'));
3781
+ }
3759
3782
  async function apiJson (method, url, body) {
3760
3783
  const res = await fetch(url, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
3784
+ if (res.status === 401) throw sessionLost();
3761
3785
  const data = await res.json().catch(() => ({}));
3762
3786
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
3763
3787
  return data;
@@ -4710,7 +4734,7 @@ function issuesBlock (n, v) {
4710
4734
  ${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
4735
  <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
4736
  <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>
4737
+ <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
4738
  <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
4739
  } 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
4740
  return `<div class="sec iss"><h4>${esc(T('issTitle'))}</h4>${items}${tail}</div>`;
@@ -4722,9 +4746,10 @@ async function issSend (step, vp) {
4722
4746
  ISS.busy = true; toast(T('issSending'));
4723
4747
  try {
4724
4748
  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 }) });
4749
+ if (res.status === 401) throw sessionLost();
4725
4750
  const out = await res.json().catch(() => ({}));
4726
4751
  if (!res.ok) throw new Error(out.error || `HTTP ${res.status}`);
4727
- ISS.form = null;
4752
+ ISS.form = null; saveDraft(draftKey('step', step, 'issue'), '');
4728
4753
  toast(T('issSent')(out.key));
4729
4754
  await issLoad();
4730
4755
  await refreshComments();
@@ -4794,13 +4819,13 @@ const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
4794
4819
  const rvCur = () => rvItems()[RV.idx] || null;
4795
4820
  const RV_GROUP = { fail: 0, returned: 0, needs_review: 1, not_evaluated: 1, revoked: 1, changed: 2, new: 2, comments: 3 };
4796
4821
  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)));
4822
+ 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
4823
 
4799
4824
  /** A read-only queue from the run itself (local preview, export, public run): no carry-over, no comparison. */
4800
4825
  function rvLocalQueue () {
4801
4826
  const items = []; let clear = 0;
4802
4827
  flowOrder().forEach((n, position) => {
4803
- const vs = (n.criteria || []).map((c) => c.verdict).filter(Boolean);
4828
+ const vs = (n.criteria || []).filter((c) => !c.not_applicable).map((c) => c.verdict).filter(Boolean);
4804
4829
  const reasons = [];
4805
4830
  if (n.status === 'fail' || vs.includes('fail')) reasons.push('fail');
4806
4831
  if (vs.includes('needs_review')) reasons.push('needs_review');
@@ -4960,7 +4985,7 @@ function rvEndHtml () {
4960
4985
  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
4986
  const v = RUN.verdict || d.verdict || {};
4962
4987
  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>
4988
+ <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
4989
  <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
4990
  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
4991
  }
@@ -5088,7 +5113,7 @@ async function rvVerdict (verdict, b) {
5088
5113
  const note = $('rvVNote')?.value.trim() || '';
5089
5114
  if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
5090
5115
  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; }
5116
+ 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
5117
  }
5093
5118
  /** Starts review mode support once the run is on screen: the button, its count and the hash. */
5094
5119
  function rvInit () {
@@ -5150,9 +5175,11 @@ function applyReviewerVerdicts () {
5150
5175
  const summary = { pass: 0, partial: 0, fail: 0, skip: 0 };
5151
5176
  for (const n of RUN.nodes) {
5152
5177
  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);
5178
+ // a criterion limited to another environment (not_applicable) stays out of the status
5179
+ const live = n.criteria.filter((c) => !c.not_applicable);
5180
+ 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'; }
5181
+ 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])]));
5182
+ n.status = statusOf(live.filter((c) => c.verdict !== 'not_reached').map((c) => c.verdict), Object.keys(n.captures || {}).length > 0);
5156
5183
  summary[n.status]++;
5157
5184
  }
5158
5185
  RUN.summary = { ...RUN.summary, ...summary };
@@ -5160,6 +5187,7 @@ function applyReviewerVerdicts () {
5160
5187
  async function postJson (path, body) {
5161
5188
  const a = APP();
5162
5189
  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) });
5190
+ if (res.status === 401) throw sessionLost();
5163
5191
  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
5164
5192
  return res.json();
5165
5193
  }