kaloko 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Kaloko
2
2
 
3
- Acceptance QA walkthroughs rendered as a shareable flow canvas.
3
+ See what your agent built, on one canvas, and sign it off.
4
4
 
5
- Your coding agent (Claude Code, Codex or any LLM with a shell) runs QA from
6
- your acceptance plan with the `kaloko` skill. Kaloko captures every screen and
7
- e-mail of the flow, checks the criteria and builds a canvas that shows the
8
- whole flow (screens, branches, locales, evidence) for review, sign-off and
9
- living documentation.
5
+ Your coding agent (Claude Code, Codex or any LLM with a shell) walks your
6
+ acceptance plan with the `kaloko` skill. Kaloko captures every screen and
7
+ e-mail of the flow, checks the criteria and lays the whole flow out on a
8
+ canvas: screens, branches, locales and evidence. People review and approve it
9
+ there, and the newest captures double as documentation.
10
10
 
11
11
  - Product and guides: <https://kaloko.app>
12
12
  - Service (organizations, shared canvases, reviews): <https://kaloko.app>
@@ -32,9 +32,9 @@ after `npm update kaloko` to refresh the skill.
32
32
 
33
33
  ## Then ask your agent
34
34
 
35
- With the skill installed you do not type the commands below; the agent does.
36
- It writes the acceptance plan and the scenario from the task, walks the
37
- screens, evaluates, shares the canvas and later processes reviewers' feedback.
35
+ With the skill installed, the agent types the commands below, not you. It
36
+ writes the acceptance plan and the scenario from the task, walks the screens,
37
+ evaluates, shares the canvas and later works through what reviewers said.
38
38
  For example:
39
39
 
40
40
  > Read the task in docs/tasks/TASK-123, write its acceptance plan and a Kaloko
@@ -66,8 +66,8 @@ token, environment URL, evaluator key), `npx kaloko doctor` says what to fix.
66
66
 
67
67
  ## Design (Kaloko Design)
68
68
 
69
- Design the screens of a flow as live HTML with your own agent and skills, in the same scenario development and
70
- QA use. A design environment (`kind: design`, a folder of steps) is walked and synced into a shared draft the
69
+ Design the screens of a flow as live HTML with your own agent and skills, in the same scenario that development
70
+ and QA use. A design environment (`kind: design`, a folder of steps) is walked and synced into a shared draft the
71
71
  team sees live; `kaloko share` there publishes a version of the flow (unchanged steps are stored once). An
72
72
  accepted version becomes the design reference, and `packs: [design]` checks every implementation run against
73
73
  it (pixels, structure, tokens, contrast). See `skills/kaloko/reference/design.md`, `docs/GLOSSARY.md` and
@@ -87,7 +87,7 @@ for pages that work everywhere are in `skills/kaloko/reference/portable-html.md`
87
87
 
88
88
  ## Your data is yours: formats
89
89
 
90
- Nothing in Kaloko is stored in a format only Kaloko reads. Every export opens from disk without Kaloko:
90
+ Nothing in Kaloko is stored in a format only Kaloko can read. Every export opens from disk without it:
91
91
 
92
92
  | Export | Command | Formats |
93
93
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
@@ -487,6 +487,8 @@
487
487
  "link_matches",
488
488
  "http_status",
489
489
  "link_home",
490
+ "links_ok",
491
+ "hreflang_pairs",
490
492
  "app_touch_targets",
491
493
  "app_no_truncation",
492
494
  "app_no_crash",
@@ -508,10 +510,12 @@
508
510
  ]
509
511
  },
510
512
  "selector": {
511
- "type": "string"
513
+ "type": "string",
514
+ "description": "CSS selector; links_ok: the links to check (default a[href])."
512
515
  },
513
516
  "pattern": {
514
- "type": "string"
517
+ "type": "string",
518
+ "description": "Regex; links_ok: link paths to skip."
515
519
  },
516
520
  "attr": {
517
521
  "type": "string"
@@ -526,17 +530,23 @@
526
530
  },
527
531
  "min": {
528
532
  "type": "integer",
529
- "minimum": 0
533
+ "minimum": 0,
534
+ "description": "count: at least; http_status: lowest status; hreflang_pairs: languages required (default 2)."
530
535
  },
531
536
  "max": {
532
537
  "type": "integer",
533
- "minimum": 0
538
+ "minimum": 0,
539
+ "description": "count: at most; http_status: highest status; perf_*: the limit; links_ok: links checked per page (default 100)."
534
540
  },
535
541
  "ratio": {
536
542
  "type": "number",
537
543
  "minimum": 0,
538
544
  "description": "design_pixels: maximum share of differing pixels (default 0.03)."
539
545
  },
546
+ "follow": {
547
+ "type": "boolean",
548
+ "description": "links_ok: follow links even when neither the environment nor the scenario is readonly (GET from an isolated copy of the session; links that look like actions are never followed)."
549
+ },
540
550
  "flags": {
541
551
  "type": "string",
542
552
  "pattern": "^[imsu]{0,4}$",
@@ -187,6 +187,27 @@ button styled uppercase reads `VYTVOŘIT`. Add `flags: i` to ignore case
187
187
  white-label check for `vyvolej` also matches the Czech verb "Vyvolej si";
188
188
  anchor it (`vyvolej\.to`).
189
189
 
190
+ Two checks need the network and run next to the capture, once per page:
191
+
192
+ - `links_ok`: every same-origin link (`selector`, default `a[href]`) answers
193
+ below 400 after redirects. `max` caps the links per page (default 100),
194
+ `pattern` skips matching paths. Redirects are listed in the evidence.
195
+ Links are followed only on a readonly environment or scenario; elsewhere
196
+ the criterion is not evaluated unless the check sets `follow: true`. Links
197
+ that look like actions (log out, unsubscribe, delete, remove, tokens,
198
+ switching organization) are never requested.
199
+ - `hreflang_pairs`: at least `min` languages (default 2); the page's own
200
+ language points to the page itself; every other version answers 200
201
+ without a redirect and links back. This is what search engines require
202
+ of hreflang; a count of `link[hreflang]` does not prove it.
203
+
204
+ Both are GETs from a separate request context that starts with a copy of
205
+ the walk's cookies and the environment's gate and never writes back (a
206
+ `?lang=` link cannot switch the language of the next step). They are cached
207
+ per environment, locale and sign-in state, so a stack of hundreds of pages
208
+ fetches its shared navigation once. A recheck
209
+ from stored HTML keeps their last result; capture again to refresh them.
210
+
190
211
  Describe every relevant variant as nodes and `alt` edges, even when a given
191
212
  run covers only part of it. Decisions are `kind: decision` nodes.
192
213
 
@@ -1,7 +1,7 @@
1
1
  # Collaboration: share, feedback, inbox
2
2
 
3
- A run becomes useful when the people who decide can see it. The loop that
4
- works for teams using Kaloko:
3
+ A run is only worth something once the people who decide have seen it. The
4
+ loop teams use:
5
5
 
6
6
  1. **Share the run** (`kaloko share`, tester token, after asking). The
7
7
  command prints the canvas URL.
@@ -22,9 +22,8 @@ evaluating the same capture again costs nothing. `evaluators.jev.hosted: false`
22
22
  When neither is available, or the allowance is used up, semantic criteria stay `not_evaluated`; say so in the
23
23
  report with the reason `kaloko evaluate` gives.
24
24
 
25
- Results are cached by content hash, so re-running is cheap. The default
26
- evaluator is text-only and most accurate in English; it is weak at numbers,
27
- which is why those are deterministic.
25
+ The default evaluator is text-only and most accurate in English. It is weak
26
+ at numbers, which is why those are checked deterministically.
28
27
 
29
28
  When a criterion comes back `needs_review`, read the reduced state the
30
29
  evaluator saw (`steps/<node>/<locale>/<viewport>.capture.json`, key `state`)
package/src/cli.mjs CHANGED
@@ -12,7 +12,8 @@ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral
12
12
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
13
13
  import { observeConsole } from './lib/console.mjs'
14
14
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
15
- import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher } from './lib/access.mjs'
15
+ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateForRequests } from './lib/access.mjs'
16
+ import { configureGate } from './lib/linkcheck.mjs'
16
17
  import { rememberEnvironment } from './lib/secrets.mjs'
17
18
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
18
19
  import { buildManifest, writePreview } from './lib/build.mjs'
@@ -664,6 +665,7 @@ const commands = {
664
665
  } finally { await enc?.close().catch(() => {}) }
665
666
  return
666
667
  }
668
+ configureGate(await gateForRequests(env, { root }))
667
669
  let conn = await connectBrowser({ root, config, env })
668
670
  let ephemeral = null
669
671
  let page
@@ -727,6 +729,7 @@ const commands = {
727
729
  const pick = list(args.steps)
728
730
  const locales = list(args.locales) || run.meta.scope.locales
729
731
  // --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
732
+ configureGate(await gateForRequests(env, { root }))
730
733
  const conn = args.ephemeral ? null : await connectBrowser({ root, config, env })
731
734
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
732
735
  const ephemeral = conn ? null : await launchEphemeral(config)
@@ -119,6 +119,13 @@ export async function contextOptions (env, { root, config, account } = {}) {
119
119
  return options
120
120
  }
121
121
 
122
+ /** The gate for requests made outside the browser (link checks): headers for the gate's origins only, Basic, mTLS. */
123
+ export async function gateForRequests (env, { root } = {}) {
124
+ const headers = gateHeaders(env)
125
+ const options = await contextOptions(env, { root })
126
+ return { headers, toGate: Object.keys(headers).length ? originMatcher(gateOrigins(env)) : () => false, httpCredentials: options.httpCredentials || null, clientCertificates: options.clientCertificates || [] }
127
+ }
128
+
122
129
  /** A new browser context with the environment's gate, policy and (optionally) an account's session. */
123
130
  export async function newContext (browser, env, opts = {}) {
124
131
  const context = await browser.newContext(await contextOptions(env, opts))
@@ -55,6 +55,7 @@ import { criteriaHash } from './evaluate.mjs'
55
55
  import { consoleEntriesFor } from './console.mjs'
56
56
  import { redact, redactDeep } from './secrets.mjs'
57
57
  import { readFields } from './stack.mjs'
58
+ import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
58
59
 
59
60
  /** Probe arguments derived from the node's criteria. */
60
61
  export function probeArgs (node, maxChars, design = false) {
@@ -101,6 +102,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
101
102
  const paths = runPaths(run.dir)
102
103
  const original = page.viewportSize()
103
104
  const results = []
105
+ let netResults = null
104
106
  for (const [viewport, [width, height]] of Object.entries(viewports)) {
105
107
  const now = page.viewportSize()
106
108
  if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
@@ -153,6 +155,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
153
155
  const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
154
156
  const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
155
157
  const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
158
+ // network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
159
+ const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
160
+ if (net.length) {
161
+ netResults ||= await networkChecks(page, net, url, { run, locale })
162
+ Object.assign(result.checks, netResults)
163
+ }
156
164
  for (const c of node.criteria || []) {
157
165
  if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
158
166
  result.checks[c.id] = collected === null
@@ -214,6 +222,50 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
214
222
  return entry
215
223
  }
216
224
 
225
+ /**
226
+ * links_ok and hreflang_pairs for the current page: { [criterion id]: { ok, evidence } }. The requests go through an
227
+ * isolated request context (linkcheck.mjs), so the walker's cookies, language and session stay as they were.
228
+ * links_ok follows links only on a readonly environment or scenario, or when the check says `follow: true`.
229
+ */
230
+ export async function networkChecks (page, criteria, url, { run = null, locale = '', factory = null } = {}) {
231
+ const out = {}
232
+ const readonly = !!(run?.meta?.environment?.readonly || run?.meta?.scenario?.readonly)
233
+ const follows = (c) => c.check.assert !== 'links_ok' || readonly || c.check.follow === true
234
+ const todo = criteria.filter((c) => {
235
+ if (follows(c)) return true
236
+ out[c.id] = { ok: null, evidence: 'links not followed: the environment and scenario are not readonly (set follow: true on the check to opt in)' }
237
+ return false
238
+ })
239
+ if (!todo.length) return out
240
+ let request
241
+ let get
242
+ try {
243
+ request = await isolatedRequest(page.context(), { factory })
244
+ const { cookies = [] } = await page.context().storageState().catch(() => ({}))
245
+ get = requester(request, walkCache, cacheScope({ environment: run?.meta?.environment?.name || '', locale, cookies }))
246
+ } catch (error) {
247
+ for (const c of todo) out[c.id] = { ok: null, evidence: `network checks unavailable: ${error.message}` }
248
+ await request?.dispose().catch(() => {})
249
+ return out
250
+ }
251
+ try {
252
+ const doc = await page.evaluate((sels) => ({
253
+ lang: document.documentElement.getAttribute('lang') || '',
254
+ alternates: [...document.querySelectorAll("link[rel~='alternate'][hreflang][href]")].map((l) => ({ hreflang: l.getAttribute('hreflang').toLowerCase(), href: l.href })),
255
+ links: Object.fromEntries(sels.map((sel) => [sel, (() => { try { return [...document.querySelectorAll(sel)].map((a) => a.href).filter(Boolean) } catch { return [] } })()]))
256
+ }), [...new Set(todo.filter((c) => c.check.assert === 'links_ok').map((c) => c.check.selector || 'a[href]'))]).catch(() => null)
257
+ for (const c of todo) {
258
+ if (!doc) { out[c.id] = { ok: null, evidence: 'page could not be read for network checks' }; continue }
259
+ try {
260
+ out[c.id] = c.check.assert === 'links_ok'
261
+ ? await checkLinks({ links: doc.links[c.check.selector || 'a[href]'] || [], pageUrl: url, get, max: c.check.max ?? undefined, exclude: c.check.pattern || null })
262
+ : await checkHreflang({ alternates: doc.alternates, pageUrl: url, lang: doc.lang, get, min: c.check.min ?? 2 })
263
+ } catch (error) { out[c.id] = { ok: false, evidence: `check error: ${error.message}` } }
264
+ }
265
+ } finally { await request.dispose().catch(() => {}) }
266
+ return out
267
+ }
268
+
217
269
  function sameDocument (a, b) {
218
270
  try {
219
271
  const x = new URL(a); const y = new URL(b)
@@ -6,7 +6,7 @@ import { createWriteStream } from 'node:fs'
6
6
  import { Readable } from 'node:stream'
7
7
  import { pipeline } from 'node:stream/promises'
8
8
  import { gunzipSync } from 'node:zlib'
9
- import { join, dirname, relative, basename } from 'node:path'
9
+ import { join, dirname, relative, basename, isAbsolute } from 'node:path'
10
10
  import yaml from 'js-yaml'
11
11
  import { zipStream } from './zip.mjs'
12
12
  import { UserError } from './util.mjs'
@@ -57,13 +57,24 @@ ${rows}
57
57
  `
58
58
  }
59
59
 
60
+ /**
61
+ * A relative path inside the export, or a UserError: names come from the service's responses (file lists, step ids,
62
+ * locales), and none of them may leave the export folder (no "..", no absolute or drive paths, no backslashes).
63
+ */
64
+ export function safeExportPath (p) {
65
+ const s = String(p ?? '')
66
+ const parts = s.split('/')
67
+ if (!s || s.length > 1024 || /[\\\u0000]/.test(s) || /^[a-zA-Z]:/.test(s) || isAbsolute(s) || parts.some((x) => !x || x === '.' || x === '..')) throw new UserError(`Refusing to write ${JSON.stringify(s.slice(0, 120))}: not a plain relative path inside the export.`)
68
+ return s
69
+ }
70
+
60
71
  /** A folder or, when `out` ends with .zip, a zip with one top folder; put(path, bytes) then close(). */
61
72
  export function exportSink (out) {
62
- if (!out.endsWith('.zip')) return { put: async (p, bytes) => { const t = join(out, p); await mkdir(dirname(t), { recursive: true }); await writeFile(t, bytes) }, close: async () => {} }
73
+ if (!out.endsWith('.zip')) return { put: async (p, bytes) => { const t = join(out, safeExportPath(p)); await mkdir(dirname(t), { recursive: true }); await writeFile(t, bytes) }, close: async () => {} }
63
74
  const top = basename(out, '.zip')
64
75
  const entries = []
65
76
  return {
66
- put: async (p, bytes) => { entries.push({ name: `${top}/${p}`, body: bytes instanceof Uint8Array ? bytes : new TextEncoder().encode(bytes) }) },
77
+ put: async (p, bytes) => { entries.push({ name: `${top}/${safeExportPath(p)}`, body: bytes instanceof Uint8Array ? bytes : new TextEncoder().encode(bytes) }) },
67
78
  close: async () => { await mkdir(dirname(out), { recursive: true }); await pipeline(Readable.fromWeb(zipStream(entries)), createWriteStream(out)) }
68
79
  }
69
80
  }
@@ -109,6 +120,7 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
109
120
  for (const s of revs.steps.filter((x) => x.state === 'default')) {
110
121
  ;(pages[s.node] ||= {})[s.locale] = s.entry
111
122
  for (const [p, hash] of Object.entries(s.files)) {
123
+ safeExportPath(p) // checked before anything is written
112
124
  if (paths.has(p)) { if (paths.get(p).hash !== hash) clashes.push(p); continue }
113
125
  paths.set(p, { hash, revision: s.revision })
114
126
  }
@@ -23,7 +23,7 @@ export function combine ({ node, capture, jev, thresholds }) {
23
23
  if (c.check.type === 'deterministic') {
24
24
  const r = capture.checks?.[c.id]
25
25
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
26
- const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || /^perf_/.test(c.check.assert || '')
26
+ const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || c.check.assert === 'links_ok' || c.check.assert === 'hreflang_pairs' || /^perf_/.test(c.check.assert || '')
27
27
  return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
28
28
  }
29
29
  if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
@@ -0,0 +1,149 @@
1
+ // Checks that need the network, evaluated next to the capture (not inside the page):
2
+ // links_ok every same-origin link on the page answers below 400 (after redirects)
3
+ // hreflang_pairs the page's hreflang alternates name the page itself for its own language, and every other
4
+ // language version answers and links back (reciprocal pairs, as search engines require)
5
+ // Requests are GETs (pages often do not implement HEAD) from a separate request context that starts with a copy of
6
+ // the page's cookies and the environment's gate, and never writes back: following a link (or fetching /?lang=en for
7
+ // hreflang) must not change the language, session or anything else the walk sees next. Links that look like they act
8
+ // (log out, unsubscribe, delete, tokens, switching organization) are never followed, and links_ok follows links only
9
+ // on readonly environments or scenarios unless the check opts in (follow: true). Results are cached per environment,
10
+ // locale and sign-in state for the walk, so shared navigation is fetched once.
11
+
12
+ const DEFAULT_MAX_LINKS = 100
13
+ /** Links that may act when followed with a session: never fetched by the checks. */
14
+ export const SKIP_LINK = /(?:^|[/?&=_.-])(?:token|unsubscribe|delete|remove|log-?out|sign-?out|switch)/i
15
+ const actionLink = (u) => SKIP_LINK.test(u.pathname + u.search)
16
+
17
+ /** Comparable form of a URL: origin, path without a trailing slash (except the root), sorted query, no hash. */
18
+ export function normUrl (href, base) {
19
+ try {
20
+ const u = new URL(href, base)
21
+ u.hash = ''
22
+ const params = [...u.searchParams.entries()].sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
23
+ const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, '') : '/'
24
+ return `${u.origin}${path}${params.length ? '?' + new URLSearchParams(params).toString() : ''}`
25
+ } catch { return null }
26
+ }
27
+
28
+ /** `<link rel="alternate" hreflang href>` entries of an HTML document (attributes in any order). */
29
+ export function parseAlternates (html, base) {
30
+ const out = []
31
+ for (const tag of String(html || '').match(/<link\b[^>]*>/gi) || []) {
32
+ const attr = (name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))?.slice(1).find((v) => v != null)
33
+ if (!/(^|\s)alternate(\s|$)/i.test(attr('rel') || '')) continue
34
+ const hreflang = attr('hreflang'); const href = attr('href')
35
+ if (hreflang && href) out.push({ hreflang: hreflang.toLowerCase(), href: new URL(href.replace(/&amp;/g, '&'), base).href })
36
+ }
37
+ return out
38
+ }
39
+
40
+ const short = (u, origin) => { try { const x = new URL(u); return x.origin === origin ? x.pathname + x.search : x.href } catch { return u } }
41
+
42
+ /**
43
+ * links_ok: { links: [href], pageUrl, get(url) → { status, url, error? }, max?, exclude? } → { ok, evidence }.
44
+ * Only same-origin http(s) links count; fragments and the page itself are skipped.
45
+ */
46
+ export async function checkLinks ({ links, pageUrl, get, max = DEFAULT_MAX_LINKS, exclude = null }) {
47
+ const origin = new URL(pageUrl).origin
48
+ const self = normUrl(pageUrl)
49
+ const skip = exclude ? new RegExp(exclude) : null
50
+ const targets = [...new Set(links.map((h) => { try { const u = new URL(h, pageUrl); u.hash = ''; return u.href } catch { return null } }).filter((h) => h && /^https?:/.test(h)))]
51
+ .filter((h) => new URL(h).origin === origin && normUrl(h) !== self && !actionLink(new URL(h)) && !(skip && skip.test(short(h, origin))))
52
+ const checked = targets.slice(0, max)
53
+ const broken = []; const redirected = []
54
+ for (const url of checked) {
55
+ const r = await get(url)
56
+ if (r.error || !(r.status < 400)) broken.push(`${short(url, origin)} → ${r.error || `HTTP ${r.status}`}`)
57
+ else if (r.url && normUrl(r.url) !== normUrl(url)) redirected.push(short(url, origin))
58
+ }
59
+ if (!checked.length) return { ok: true, evidence: 'no internal links on the page' }
60
+ const more = targets.length > checked.length ? ` (first ${checked.length} of ${targets.length})` : ''
61
+ const note = redirected.length ? `; ${redirected.length} redirect: ${redirected.slice(0, 3).join(', ')}` : ''
62
+ return broken.length
63
+ ? { ok: false, evidence: `${broken.length} of ${checked.length} internal links broken${more}: ${broken.slice(0, 5).join('; ')}${note}` }
64
+ : { ok: true, evidence: `${checked.length} internal links answer below 400${more}${note}` }
65
+ }
66
+
67
+ /**
68
+ * hreflang_pairs: { alternates: [{ hreflang, href }], pageUrl, lang, get(url) → { status, url, html?, error? }, min? }.
69
+ * Fails when there are fewer than `min` languages, when the page's own language does not point to the page, or
70
+ * when another language version does not answer 200 or does not link back to the page.
71
+ */
72
+ export async function checkHreflang ({ alternates, pageUrl, lang, get, min = 2 }) {
73
+ const problems = []
74
+ const langs = alternates.filter((a) => a.hreflang !== 'x-default')
75
+ if (new Set(langs.map((a) => a.hreflang)).size < min) problems.push(`${new Set(langs.map((a) => a.hreflang)).size} hreflang language(s), expected ${min}`)
76
+ const self = normUrl(pageUrl)
77
+ const primary = String(lang || '').toLowerCase().split('-')[0]
78
+ const own = langs.find((a) => a.hreflang === String(lang || '').toLowerCase()) || langs.find((a) => a.hreflang.split('-')[0] === primary)
79
+ if (!primary) problems.push('html lang is not set, so the page\'s own hreflang cannot be identified')
80
+ else if (!own) problems.push(`no hreflang for the page's own language "${lang}"`)
81
+ else if (normUrl(own.href) !== self) problems.push(`hreflang ${own.hreflang} points to ${own.href}, not to this page (${pageUrl})`)
82
+ for (const alt of langs) {
83
+ if (alt === own) continue
84
+ if (actionLink(new URL(alt.href))) { problems.push(`${alt.hreflang} version ${alt.href} looks like an action link; not fetched`); continue }
85
+ const r = await get(alt.href, { html: true })
86
+ if (r.error || r.status !== 200) { problems.push(`${alt.hreflang} version ${alt.href} → ${r.error || `HTTP ${r.status}`}`); continue }
87
+ if (r.url && normUrl(r.url) !== normUrl(alt.href)) problems.push(`${alt.hreflang} version ${alt.href} redirects to ${r.url}`)
88
+ const back = parseAlternates(r.html, r.url || alt.href)
89
+ const mine = own ? own.hreflang : primary
90
+ const ret = back.find((b) => b.hreflang === mine) || back.find((b) => b.hreflang.split('-')[0] === primary)
91
+ if (!ret) problems.push(`${alt.hreflang} version does not link back with hreflang ${mine}`)
92
+ else if (normUrl(ret.href) !== self) problems.push(`${alt.hreflang} version links back to ${ret.href}, not to this page`)
93
+ }
94
+ return problems.length
95
+ ? { ok: false, evidence: problems.slice(0, 5).join('; ') }
96
+ : { ok: true, evidence: `${langs.map((a) => a.hreflang).join(', ')}: reciprocal, the own language points to this page` }
97
+ }
98
+
99
+ /**
100
+ * The environment's gate for the checks' own requests (set once per walk by the CLI): headers for the gate's origins
101
+ * only (third parties never see them), HTTP Basic credentials and client certificates.
102
+ */
103
+ const GATE = { headers: {}, toGate: () => false, httpCredentials: null, clientCertificates: [] }
104
+ export function configureGate ({ headers = {}, toGate = () => false, httpCredentials = null, clientCertificates = [] } = {}) {
105
+ Object.assign(GATE, { headers, toGate, httpCredentials, clientCertificates })
106
+ }
107
+
108
+ /**
109
+ * A request context of its own for the checks: a copy of the browser context's cookies and storage (so pages behind
110
+ * a sign-in answer as they do for the walker) that never writes back to the browser context.
111
+ * `factory` is Playwright's `request` (injectable for tests).
112
+ */
113
+ export async function isolatedRequest (context, { factory = null } = {}) {
114
+ const storageState = await context.storageState()
115
+ const pw = factory || (await import('playwright')).request
116
+ return pw.newContext({
117
+ storageState,
118
+ ...(GATE.httpCredentials ? { httpCredentials: GATE.httpCredentials } : {}),
119
+ ...(GATE.clientCertificates?.length ? { clientCertificates: GATE.clientCertificates } : {})
120
+ })
121
+ }
122
+
123
+ /** Cache scope of a page: environment, locale and sign-in state (a digest of the cookies, never the cookies). */
124
+ export function cacheScope ({ environment = '', locale = '', cookies = [] } = {}) {
125
+ const jar = cookies.map((c) => `${c.domain}|${c.path}|${c.name}=${c.value}`).sort().join(';')
126
+ let h = 0
127
+ for (let i = 0; i < jar.length; i++) h = (Math.imul(h, 31) + jar.charCodeAt(i)) | 0
128
+ return `${environment}|${locale}|${jar ? (h >>> 0).toString(36) : 'anonymous'}`
129
+ }
130
+
131
+ /** Cached GETs through a Playwright APIRequestContext (an isolated one, see isolatedRequest). */
132
+ export function requester (request, cache = new Map(), scope = '') {
133
+ return async (url, { html = false } = {}) => {
134
+ const key = `${scope} ${html ? 'html' : 'status'} ${url}`
135
+ if (cache.has(key)) return cache.get(key)
136
+ const job = (async () => {
137
+ try {
138
+ const res = await request.get(url, { maxRedirects: 5, timeout: 15000, failOnStatusCode: false, ...(GATE.toGate(url) ? { headers: GATE.headers } : {}) })
139
+ return { status: res.status(), url: res.url(), ...(html ? { html: (await res.text()).slice(0, 500000) } : {}) }
140
+ } catch (error) { return { status: 0, error: String(error.message || error).split('\n')[0].slice(0, 120) } }
141
+ })()
142
+ cache.set(key, job)
143
+ return job
144
+ }
145
+ }
146
+
147
+ /** One cache per walk process: navigation links shared by hundreds of stack pages are fetched once. */
148
+ export const walkCache = new Map()
149
+ export const NETWORK_ASSERTS = new Set(['links_ok', 'hreflang_pairs'])
@@ -13,7 +13,7 @@ import { contextOptions, prepareContext } from './access.mjs'
13
13
  import { launchEphemeral } from './browser.mjs'
14
14
  import { treeChecks } from './apps/tree.mjs'
15
15
 
16
- export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || /^perf_/.test(assert)
16
+ export const LIVE_ONLY = (assert) => assert === 'no_console_errors' || assert === 'no_overflow' || assert === 'http_status' || assert === 'links_ok' || assert === 'hreflang_pairs' || /^perf_/.test(assert)
17
17
 
18
18
  const withBase = (html, url) => {
19
19
  if (!url || /<base\s/i.test(html)) return html
package/src/lib/run.mjs CHANGED
@@ -36,7 +36,7 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
36
36
  id,
37
37
  short,
38
38
  scenario_file: relative(root, scenarioFile),
39
- scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
39
+ scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.readonly ? { readonly: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
40
40
  environment: { name: env.name, base_url: env.base_url, readonly: env.readonly, ...(env.kind === 'design' ? { kind: 'design' } : {}) },
41
41
  scope,
42
42
  git,
package/viewer/index.html CHANGED
@@ -668,16 +668,16 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
668
668
  const EMBEDDED = /*__QAWALK_RUN__*/null;
669
669
  if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
670
670
  const I18N = {
671
- cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikovat verzi uloží celé flow jako %V; nezměněné kroky se znovu použijí.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů zákazníka, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit v plátně', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava je na nejnovější revizi', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Tento koncept je živý a sdílený: všichni, kdo ho mají otevřený, vidí změny hned, i změny agentů. Kroky ukládá kaloko sync nebo úprava v plátně; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
671
+ cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
672
672
  pass:'Splněno', partial:'Částečně', fail:'Nesplněno', skip:'Neprověřeno', crop:'výřez', html:'HTML snapshot', email:'e-mail', external:'třetí strana',
673
673
  branch:'Větev', commit:'Commit', author:'Autor', pr:'PR', issue:'Issue', env:'Prostředí', duration:'Délka', noPr:'bez PR', open:'otevřený', merged:'sloučený', closed:'zavřený',
674
674
  older:'Starší běh', newer:'Novější běh', purpose:'Účel obrazovky ze scénáře', criteria:'Akceptační kritéria', jevTitle:'Sémantické hodnocení',
675
- jevNote:'Evaluátor dostal zredukovaný stav stránky a vrátil pravděpodobnost pro každou otázku. Verdikt podle prahů a důkazy doplňuje runner.', evalLabel:'Hodnocení', metaTitle:'Metadata stránky', mdBasic:'Základ', mdHeadings:'Osnova nadpisů', mdHreflang:'Jazykové verze (hreflang)', mdOg:'Open Graph', mdTwitter:'Twitter / X', mdLd:'Strukturovaná data · JSON-LD', mdMicro:'Strukturovaná data · microdata', mdLinks:'Další odkazy v hlavičce', mdOther:'Ostatní meta', missing:'Chybí', duplicate:'duplicitní', size:'Velikost', runVerdict:'Verdikt běhu', accept:'Přijmout běh', ret:'Vrátit běh', pending:'čeká', accepted:'přijato', returned:'vráceno', baseline:'Referenční běh', setBaseline:'Nastavit jako referenční', unsetBaseline:'Zrušit referenci', verdictNotePh:'Poznámka k verdiktu (u vrácení povinná)', manualPass:'Splněno', manualFail:'Nesplněno', manualNotePh:'Důvod (u nesplněno povinný)', reviewerVerdict:'verdikt kontrolujícího', decideManual:'Rozhodnout ručně', consoleErrors:'Chyby konzole', consoleLoading:'Načítám zprávy…', noConsole:'U tohoto snímku se zprávy konzole neuložily.', consoleMore:(n) => `Dalších ${n} se neuložilo (ukládá se prvních 20).`, blockedTitle:'Zablokované požadavky', copyAll:'Kopírovat vše', copyOne:'Kopírovat tuto zprávu', copied:'Zkopírováno', tabOverview:'Kritéria', tabPage:'Stránka', tabTech:'Technické', toSolve:(n) => `k řešení ${n}`, passedN:(n) => `splněno ${n}`, whyMatters:'Proč na tom záleží:', howFix:'Jak opravit:', whyAndFix:'Proč na tom záleží a jak to opravit', aiShort:(p) => `AI ${p} % ano`, noneFailed:'Nic neselhalo.', toDecide:'Rozhodne člověk', toDecideLead:'AI si nebyla jistá nebo nemohla vyhodnotit; otevřete krok, podívejte se na snímek a rozhodněte.', worthLook:'Stojí za pohled', jevScoreHint:'Průměrná jistota AI, že sémantická kritéria této obrazovky jsou splněná', sigTitle:'Rychlé signály', sigLead:'AI se na ně ptá vedle kritérií u každé obrazovky. Výsledek nemění, jen upozorní na obrazovky, na které je dobré se podívat.', sigPurpose:'Plní účel kroku', sigLanguage:'Text v očekávaném jazyce', sigBroken:'Vypadá rozbitě (chyba, zástupný text, prázdno)', sigWarn:(n) => `Signály k prověření: ${n}`, sigYes:(p) => `${p} % ano`,
675
+ jevNote:'Evaluátor dostal zjednodušený stav stránky a ke každé otázce vrátil pravděpodobnost. Verdikt podle prahů a důkazy doplnil runner.', evalLabel:'Hodnocení', metaTitle:'Metadata stránky', mdBasic:'Základ', mdHeadings:'Osnova nadpisů', mdHreflang:'Jazykové verze (hreflang)', mdOg:'Open Graph', mdTwitter:'Twitter / X', mdLd:'Strukturovaná data · JSON-LD', mdMicro:'Strukturovaná data · microdata', mdLinks:'Další odkazy v hlavičce', mdOther:'Ostatní meta', missing:'Chybí', duplicate:'duplicitní', size:'Velikost', runVerdict:'Verdikt běhu', accept:'Přijmout běh', ret:'Vrátit běh', pending:'čeká', accepted:'přijato', returned:'vráceno', baseline:'Referenční běh', setBaseline:'Nastavit jako referenční', unsetBaseline:'Zrušit referenci', verdictNotePh:'Poznámka k verdiktu (u vrácení povinná)', manualPass:'Splněno', manualFail:'Nesplněno', manualNotePh:'Důvod (u nesplněno povinný)', reviewerVerdict:'verdikt kontrolujícího', decideManual:'Rozhodnout ručně', consoleErrors:'Chyby konzole', consoleLoading:'Načítám zprávy…', noConsole:'U tohoto snímku se zprávy konzole neuložily.', consoleMore:(n) => `Dalších ${n} se neuložilo (ukládá se prvních 20).`, blockedTitle:'Zablokované požadavky', copyAll:'Kopírovat vše', copyOne:'Kopírovat tuto zprávu', copied:'Zkopírováno', tabOverview:'Kritéria', tabPage:'Stránka', tabTech:'Technické', toSolve:(n) => `k řešení ${n}`, passedN:(n) => `splněno ${n}`, whyMatters:'Proč na tom záleží:', howFix:'Jak opravit:', whyAndFix:'Proč na tom záleží a jak to opravit', aiShort:(p) => `AI ${p} % ano`, noneFailed:'Nic neselhalo.', toDecide:'Rozhodne člověk', toDecideLead:'AI si nebyla jistá nebo nemohla vyhodnotit; otevřete krok, podívejte se na snímek a rozhodněte.', worthLook:'Stojí za kontrolu', jevScoreHint:'Průměrná jistota AI, že sémantická kritéria této obrazovky jsou splněná', sigTitle:'Rychlé signály', sigLead:'AI se na ně ptá u každé obrazovky spolu s kritérii. Výsledek nemění, jen ukáže obrazovky, na které se vyplatí podívat.', sigPurpose:'Plní účel kroku', sigLanguage:'Text v očekávaném jazyce', sigBroken:'Vypadá rozbitě (chyba, zástupný text, prázdno)', sigWarn:(n) => `Signály k prověření: ${n}`, sigYes:(p) => `${p} % ano`,
676
676
  howAuto:'Automatická kontrola', howAi:'Vyhodnocení AI', howManual:'Rozhoduje člověk', howTech:'Technický detail',
677
- aiPass:(p, t) => `Hodnotitel si je na ${p} % jistý, že odpověď je ano; projde od ${t} %.`, aiFail:(p, t) => `Jen ${p} % ano; neprojde při ${t} % a méně.`, aiUnsure:(p, lo, hi) => `Nejisté: ${p} % ano, mezi ${lo} % a ${hi} %. Podívejte se na snímek a rozhodněte níže.`,
677
+ aiPass:(p, t) => `Evaluátor si je na ${p} % jistý, že odpověď je ano; projde od ${t} %.`, aiFail:(p, t) => `Jen ${p} % ano; neprojde při ${t} % a méně.`, aiUnsure:(p, lo, hi) => `Nejisté: ${p} % ano, mezi ${lo} % a ${hi} %. Podívejte se na snímek a rozhodněte níže.`,
678
678
  aiNotEval:'Zatím nevyhodnoceno.', aiVia:{ qawalk:'vyhodnotilo Kaloko', 'own-key':'vyhodnoceno vaším klíčem' }, aiAsked:'Otázka',
679
679
  a_exists:(s) => `${s} je na stránce`, a_absent:(s) => `${s} na stránce není`, a_count:(s, mn, mx) => `${s} se vyskytuje ${mn != null && mn === mx ? `přesně ${mn}×` : mn != null && mx != null ? `${mn}–${mx}×` : mn != null ? `aspoň ${mn}×` : mx != null ? `nejvýš ${mx}×` : ''}`, a_text_matches:(s, p) => `${s || 'Stránka'} obsahuje ${p}`, a_text_absent:(s, p) => `${s || 'Stránka'} neobsahuje ${p}`, a_attr_equals:(s, a, v) => `${s} má ${a} = ${v}`,
680
- a_no_overflow:'Nic nepřetéká šířku obrazovky', a_no_console_errors:'V konzoli prohlížeče nejsou chyby', a_title_matches:(p) => `Titulek stránky odpovídá ${p}`, a_meta_matches:(s, p) => `Metadata ${s} odpovídají ${p}`, a_link_matches:(s) => `Odkazy ${s} vedou, kam mají`, a_http_status:(v) => `Server odpovídá ${v || '2xx'}`, a_link_home:'Stránka odkazuje zpět na úvod',
680
+ a_no_overflow:'Nic nepřetéká šířku obrazovky', a_no_console_errors:'V konzoli prohlížeče nejsou chyby', a_title_matches:(p) => `Titulek stránky odpovídá ${p}`, a_meta_matches:(s, p) => `Metadata ${s} odpovídají ${p}`, a_link_matches:(s) => `Odkazy ${s} vedou, kam mají`, a_http_status:(v) => `Server odpovídá ${v || '2xx'}`, a_link_home:'Stránka odkazuje zpět na úvod', a_links_ok:'Interní odkazy fungují (žádné 404)', a_hreflang_pairs:'Jazykové verze (hreflang) na sebe vzájemně odkazují',
681
681
  a_perf_lcp:(mx) => `Největší obsah se zobrazí do ${mx} ms`, a_perf_cls:'Rozvržení se při načítání nehýbe', a_perf_ttfb:(mx) => `Server začne odpovídat do ${mx} ms`, a_perf_weight:'Stránka je dost lehká na rychlé načtení',
682
682
  a_a11y:'Pravidlo přístupnosti', a_design:'Odpovídá referenci designu', a_uses_tokens:'Barvy, písma a velikosti pocházejí z tokenů', a_app:'Pravidlo aplikace', files:'souborů', storage:'Úložiště', copyLink:'Kopírovat odkaz', linkCopied:'Odkaz zkopírován', noPlaceToast:'Toto kritérium nemá místo na stránce ani v metadatech; důkaz je v textu kritéria.', noMeta:'Tento běh je starší a metadata stránky nesbíral. Spusťte nový běh.', noBoxToast:'Prvek se na tomto snímku nenašel.', onPage:'na stránce', inMeta:'v metadatech', inConsole:'v konzoli', noPlace:'bez místa na stránce', clickHl:'Klikněte na kritérium: zvýrazní ho na stránce nebo v metadatech', invalidJson:'neplatný JSON', attachments:'Přílohy kroku', tech:'Technické údaje',
683
683
  execution:'Provedení kroku', step:'Krok', of:'z', hover:'Klikněte na kritérium: zvýrazní ho na stránce nebo v metadatech', fullPage:'celá stránka',
@@ -687,21 +687,21 @@ const I18N = {
687
687
  allAbove:'Všechny otázky nad prahem', review:'k revizi', notEvaluated:'nehodnoceno', search:'Hledat scénář, task, cestu…', all:'Vše', tasks:'Úkoly', flows:'Flow',
688
688
  runs:'běhů', last:'poslední', time:'Čas', result:'Výsledek', noRuns:'Žádný běh neodpovídá filtrům.', noScenario:'Žádný scénář neodpovídá hledání.',
689
689
  onlyLocal:'Tento běh je jen lokálně; otevřete jeho složku.', linksExpired:'Odkazy na snímky vypršely. Obnovte stránku.', captured:'Zachyceno', console:'Konzole', errors:'chyb', blocked:'blokovaných požadavků', files:'Soubory',
690
- pagesWord:'stránek', firstScreen:'první obrazovka', layout:'Rozložení', uploadedBy:'Nahrál', addNote:'Přidat poznámku', viewOptions:'Zobrazení', keyComment:'komentář ke kroku (v detailu)', keyHelp:'tato nápověda', helpTitle:'Zkratky a nápověda', flowComments:'Komentáře k flow', runComments:'Komentáře k běhu', commentsTitle:'Komentáře', allOpen:'Otevřené', openWord:'otevřených', flowCommentsLead:'Platí pro celé flow napříč běhy: chybějící krok, obecná připomínka, nápad.', noStepComments:'Ke kroku zatím nikdo nic nenapsal.', noComments:'Zatím žádné komentáře.', showResolved:'Vyřešené', hideResolved:'Skrýt vyřešené', replyAct:'Odpovědět', reopenAct:'Znovu otevřít', editAct:'Upravit', deleteAct:'Smazat', saveAct:'Uložit', cancel:'Zrušit', edited:'upraveno', commentDeleted:'Komentář byl smazán.', viaAgent:'Napsal agent', showPin:'Ukázat na snímku', pinOnShot:'Připnout na snímek', pinClickShot:'Klikněte do snímku', pinnedOnShot:'Připnuto na snímek', replyPh:'Odpověď… (@jméno upozorní)', flowPh:'Komentář k celému flow… (@jméno upozorní)', runPh:'Komentář k celému běhu… (@jméno upozorní)', commentLabel:'Komentář', ctrlEnter:'Ctrl+Enter odešle', confirmDelete:'Smazat komentář?', deletedToast:'Smazáno', openCommentsN:'Nevyřízené komentáře', showAllN:'Zobrazit všech %N%', screenWord:'obrazovka aplikace', uiTree:'UI strom', platformWord:'Platforma', treeHint:'Klikněte na prvek: ukáže ho na snímku.', treeSearch:'Hledat v UI stromu…', treeOcr:'Text rozpoznaný ze snímku (OCR): bez rolí a ovládacích prvků.', treeLoadFail:'UI strom se nepodařilo načíst.', treeEmpty:'Strom je prázdný.', mapView:'Mapa', indexView:'Index', searchSteps:'Hledat v krocích, kritériích a stránkách…', criteriaCol:'Kritéria', notesCol:'Poznámky', noStepsMatch:'Žádný krok neodpovídá filtru.', riKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · I mapa', consoleWord:'chyb v konzoli', loadingPages:'hledám i ve stránkách stacků…', sortTitle:'Název', runWord:'Běh', openIndex:'Otevřít index', pagesNeedAttention:'stránek chce pozornost', allPagesPass:'všechny stránky prošly', stackLoadFail:'Index stránek se nepodařilo načíst', semanticSample:'sémantický vzorek', downloadCsv:'Stáhnout CSV', allWord:'Vše', criterionFilter:'Neprošlé kritérium', anyCriterion:'Jakékoli kritérium', searchPages:'Hledat v titulku, URL a polích…', sortBy:'Řadit podle', sortOrder:'Pořadí', sortStatus:'Stav (nejhorší první)', sortDir:'Směr řazení', pageWord:'Stránka', statusWord:'Stav', failingCriteria:'Neprošlo', stackKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · Esc zavřít', listView:'Seznam', gridView:'Mřížka', noPagesMatch:'Filtru neodpovídá žádná stránka.', backToIndex:'Zpět na index',
690
+ pagesWord:'stránek', firstScreen:'první obrazovka', layout:'Rozložení', uploadedBy:'Nahrál', addNote:'Přidat poznámku', viewOptions:'Zobrazení', keyComment:'komentář ke kroku (v detailu)', keyHelp:'tato nápověda', helpTitle:'Zkratky a nápověda', flowComments:'Komentáře k flow', runComments:'Komentáře k běhu', commentsTitle:'Komentáře', allOpen:'Otevřené', openWord:'otevřených', flowCommentsLead:'Platí pro celé flow napříč běhy: chybějící krok, obecná připomínka, nápad.', noStepComments:'Ke kroku zatím nikdo nic nenapsal.', noComments:'Zatím žádné komentáře.', showResolved:'Vyřešené', hideResolved:'Skrýt vyřešené', replyAct:'Odpovědět', reopenAct:'Znovu otevřít', editAct:'Upravit', deleteAct:'Smazat', saveAct:'Uložit', cancel:'Zrušit', edited:'upraveno', commentDeleted:'Komentář byl smazán.', viaAgent:'Napsal agent', showPin:'Ukázat na snímku', pinOnShot:'Připnout na snímek', pinClickShot:'Klikněte do snímku', pinnedOnShot:'Připnuto na snímek', replyPh:'Odpověď… (@jméno upozorní)', flowPh:'Komentář k celému flow… (@jméno upozorní)', runPh:'Komentář k celému běhu… (@jméno upozorní)', commentLabel:'Komentář', ctrlEnter:'Ctrl+Enter odešle', confirmDelete:'Smazat komentář?', deletedToast:'Smazáno', openCommentsN:'Nevyřízené komentáře', showAllN:'Zobrazit všech %N%', screenWord:'obrazovka aplikace', uiTree:'UI strom', platformWord:'Platforma', treeHint:'Klikněte na prvek: ukáže ho na snímku.', treeSearch:'Hledat v UI stromu…', treeOcr:'Text rozpoznaný ze snímku (OCR): bez rolí a ovládacích prvků.', treeLoadFail:'UI strom se nepodařilo načíst.', treeEmpty:'Strom je prázdný.', mapView:'Mapa', indexView:'Index', searchSteps:'Hledat v krocích, kritériích a stránkách…', criteriaCol:'Kritéria', notesCol:'Poznámky', noStepsMatch:'Žádný krok neodpovídá filtru.', riKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · I mapa', consoleWord:'chyb v konzoli', loadingPages:'hledám i ve stránkách stacků…', sortTitle:'Název', runWord:'Běh', openIndex:'Otevřít index', pagesNeedAttention:'stránek chce pozornost', allPagesPass:'všechny stránky prošly', stackLoadFail:'Index stránek se nepodařilo načíst', semanticSample:'sémantický vzorek', downloadCsv:'Stáhnout CSV', allWord:'Vše', criterionFilter:'Nesplněné kritérium', anyCriterion:'Jakékoli kritérium', searchPages:'Hledat v titulku, URL a polích…', sortBy:'Řadit podle', sortOrder:'Pořadí', sortStatus:'Stav (nejhorší první)', sortDir:'Směr řazení', pageWord:'Stránka', statusWord:'Stav', failingCriteria:'Neprošlo', stackKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · Esc zavřít', listView:'Seznam', gridView:'Mřížka', noPagesMatch:'Filtru neodpovídá žádná stránka.', backToIndex:'Zpět na index',
691
691
  subject:'Předmět', from:'Od', to:'Komu', received:'Přijato', mailBeforeRun:'přišel před začátkem běhu', noCapture:'Bez snímku', viewEval:'eval.json', decision:'rozhodnutí', mainLane:'Hlavní průchod',
692
- downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plátno, které se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Klik na osobu zvýrazní její kroky; přerušované šipky jsou předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
692
+ downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
693
693
  compare:'Porovnat', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
694
694
  reviewTitle:'Akceptace a komentáře', approve:'Schválit', reject:'Zamítnout', comment:'Komentovat', notePh:'Co je potřeba změnit, nebo poznámka · @jméno pošle kolegovi upozornění', approved:'schváleno', rejected:'zamítnuto', commented:'komentář', resolved:'vyřešeno', resolve:'Vyřešit', openFeedback:'Otevřená zpětná vazba', noFeedback:'Žádná otevřená zpětná vazba.', saved:'Uloženo', noteRequired:'Napište, co je potřeba změnit.', expires:'Platí do' },
695
- en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: no scripts of the site, styles and images rehosted, secrets removed.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, agents’ changes too. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
695
+ en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
696
696
  pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
697
697
  branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
698
698
  older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
699
- jevNote:'The evaluator received the reduced page state and returned a probability per question. The runner adds verdicts by threshold and evidence.', evalLabel:'Evaluation', metaTitle:'Page metadata', mdBasic:'Basics', mdHeadings:'Heading outline', mdHreflang:'Language versions (hreflang)', mdOg:'Open Graph', mdTwitter:'Twitter / X', mdLd:'Structured data · JSON-LD', mdMicro:'Structured data · microdata', mdLinks:'Other head links', mdOther:'Other meta', missing:'Missing', duplicate:'duplicate', size:'Size', runVerdict:'Run verdict', accept:'Accept run', ret:'Return run', pending:'pending', accepted:'accepted', returned:'returned', baseline:'Baseline run', setBaseline:'Set as baseline', unsetBaseline:'Unset baseline', verdictNotePh:'Verdict note (required when returning)', manualPass:'Pass', manualFail:'Fail', manualNotePh:'Reason (required for fail)', reviewerVerdict:'reviewer verdict', decideManual:'Decide manually', consoleErrors:'Console errors', consoleLoading:'Loading the messages…', noConsole:'No console messages were kept for this capture.', consoleMore:(n) => `${n} more not stored (the first 20 are kept).`, blockedTitle:'Blocked requests', copyAll:'Copy all', copyOne:'Copy this message', copied:'Copied', tabOverview:'Criteria', tabPage:'Page', tabTech:'Technical', toSolve:(n) => `${n} to solve`, passedN:(n) => `${n} passed`, whyMatters:'Why it matters:', howFix:'How to fix:', whyAndFix:'Why it matters and how to fix it', aiShort:(p) => `AI ${p} % yes`, noneFailed:'Nothing failed.', toDecide:'For a person to decide', toDecideLead:'The AI was unsure or could not evaluate; open the step, look at the screenshot and decide.', worthLook:'Worth a look', jevScoreHint:'Average confidence of the AI that the semantic criteria of this screen are met', sigTitle:'Quick signals', sigLead:'Asked by the AI beside the criteria on every screen. They do not change the result; they point at screens worth a look.', sigPurpose:'Does what the step is for', sigLanguage:'Text in the expected language', sigBroken:'Looks broken (error, placeholder, blank)', sigWarn:(n) => `${n} signal(s) worth a look`, sigYes:(p) => `${p} % yes`,
699
+ jevNote:'The evaluator got a reduced page state and returned a probability for each question. The runner turned them into verdicts by threshold and added the evidence.', evalLabel:'Evaluation', metaTitle:'Page metadata', mdBasic:'Basics', mdHeadings:'Heading outline', mdHreflang:'Language versions (hreflang)', mdOg:'Open Graph', mdTwitter:'Twitter / X', mdLd:'Structured data · JSON-LD', mdMicro:'Structured data · microdata', mdLinks:'Other head links', mdOther:'Other meta', missing:'Missing', duplicate:'duplicate', size:'Size', runVerdict:'Run verdict', accept:'Accept run', ret:'Return run', pending:'pending', accepted:'accepted', returned:'returned', baseline:'Baseline run', setBaseline:'Set as baseline', unsetBaseline:'Unset baseline', verdictNotePh:'Verdict note (required when returning)', manualPass:'Pass', manualFail:'Fail', manualNotePh:'Reason (required for fail)', reviewerVerdict:'reviewer verdict', decideManual:'Decide manually', consoleErrors:'Console errors', consoleLoading:'Loading the messages…', noConsole:'No console messages were kept for this capture.', consoleMore:(n) => `${n} more not stored (the first 20 are kept).`, blockedTitle:'Blocked requests', copyAll:'Copy all', copyOne:'Copy this message', copied:'Copied', tabOverview:'Criteria', tabPage:'Page', tabTech:'Technical', toSolve:(n) => `${n} to solve`, passedN:(n) => `${n} passed`, whyMatters:'Why it matters:', howFix:'How to fix:', whyAndFix:'Why it matters and how to fix it', aiShort:(p) => `AI ${p} % yes`, noneFailed:'Nothing failed.', toDecide:'For a person to decide', toDecideLead:'The AI was unsure or could not evaluate; open the step, look at the screenshot and decide.', worthLook:'Worth a look', jevScoreHint:'Average confidence of the AI that the semantic criteria of this screen are met', sigTitle:'Quick signals', sigLead:'The AI asks these on every screen next to the criteria. They never change the result, only point at screens worth a look.', sigPurpose:'Does what the step is for', sigLanguage:'Text in the expected language', sigBroken:'Looks broken (error, placeholder, blank)', sigWarn:(n) => `${n} signal(s) worth a look`, sigYes:(p) => `${p} % yes`,
700
700
  howAuto:'Automatic check', howAi:'AI evaluation', howManual:'Decided by a person', howTech:'Technical detail',
701
701
  aiPass:(p, t) => `The evaluator is ${p} % sure the answer is yes; passes from ${t} %.`, aiFail:(p, t) => `Only ${p} % yes; fails at ${t} % or less.`, aiUnsure:(p, lo, hi) => `Unsure: ${p} % yes, between ${lo} % and ${hi} %. Look at the screenshot and decide below.`,
702
702
  aiNotEval:'Not evaluated yet.', aiVia:{ qawalk:'evaluated by Kaloko', 'own-key':'evaluated with your key' }, aiAsked:'Asked',
703
703
  a_exists:(s) => `${s} is on the page`, a_absent:(s) => `${s} is not on the page`, a_count:(s, mn, mx) => `${s} appears ${mn != null && mn === mx ? `exactly ${mn}×` : mn != null && mx != null ? `${mn}–${mx}×` : mn != null ? `at least ${mn}×` : mx != null ? `at most ${mx}×` : ''}`, a_text_matches:(s, p) => `${s || 'The page'} contains ${p}`, a_text_absent:(s, p) => `${s || 'The page'} does not contain ${p}`, a_attr_equals:(s, a, v) => `${s} has ${a} = ${v}`,
704
- a_no_overflow:'Nothing overflows the screen width', a_no_console_errors:'No errors in the browser console', a_title_matches:(p) => `The page title matches ${p}`, a_meta_matches:(s, p) => `Page metadata ${s} matches ${p}`, a_link_matches:(s) => `Links ${s} point where they should`, a_http_status:(v) => `The server answers ${v || '2xx'}`, a_link_home:'The page links back home',
704
+ a_no_overflow:'Nothing overflows the screen width', a_no_console_errors:'No errors in the browser console', a_title_matches:(p) => `The page title matches ${p}`, a_meta_matches:(s, p) => `Page metadata ${s} matches ${p}`, a_link_matches:(s) => `Links ${s} point where they should`, a_http_status:(v) => `The server answers ${v || '2xx'}`, a_link_home:'The page links back home', a_links_ok:'Internal links work (no 404)', a_hreflang_pairs:'Language versions (hreflang) link to each other',
705
705
  a_perf_lcp:(mx) => `Largest content appears within ${mx} ms`, a_perf_cls:'Layout stays stable while loading', a_perf_ttfb:(mx) => `The server starts answering within ${mx} ms`, a_perf_weight:'The page is light enough to load quickly',
706
706
  a_a11y:'Accessibility rule', a_design:'Matches the design reference', a_uses_tokens:'Colors, fonts and sizes come from the token set', a_app:'App rule', files:'files', storage:'Storage', copyLink:'Copy link', linkCopied:'Link copied', noPlaceToast:'This criterion has no location on the page or in the metadata; the evidence is in the criterion text.', noMeta:'This run is older and did not collect page metadata. Start a new run.', noBoxToast:'The element was not found in this capture.', onPage:'on the page', inMeta:'in metadata', inConsole:'in the console', noPlace:'no location on the page', clickHl:'Click a criterion to highlight it on the page or in the metadata', invalidJson:'invalid JSON', attachments:'Step attachments', tech:'Technical details',
707
707
  execution:'Step execution', step:'Step', of:'of', hover:'Click a criterion to highlight it on the page or in the metadata', fullPage:'full page',
@@ -1843,7 +1843,7 @@ function renderChrome () {
1843
1843
  const app = window.QAWALK_APP;
1844
1844
  $('brand').href = app ? (app.user ? `/${app.org}/` : '/') : '#';
1845
1845
  $('appNav').innerHTML = (app && app.user ? `<a href="/${esc(app.org)}/projects">${UI === 'cs' ? 'Projekty' : 'Projects'}</a><a href="/${esc(app.org)}/">${UI === 'cs' ? 'Scénáře' : 'Scenarios'}</a><a href="/${esc(app.org)}/runs" aria-current="page">${UI === 'cs' ? 'Běhy' : 'Runs'}</a>` : app ? (app.embed ? `<a href="${esc(location.pathname)}" target="_blank" rel="noopener">${T('openInApp')} ↗</a>` : `<a href="/login">${T('signInLink')}</a>`) : '')
1846
- + `<span class="langsw">${['cs', 'en'].map((l) => `<button type="button" data-lang="${l}" aria-pressed="${l === UI}">${l.toUpperCase()}</button>`).join('')}</span>`;
1846
+ + `<span class="langsw">${['en', 'cs'].map((l) => `<button type="button" data-lang="${l}" aria-pressed="${l === UI}">${l.toUpperCase()}</button>`).join('')}</span>`;
1847
1847
  $('appNav').querySelectorAll('[data-lang]').forEach((b) => { b.onclick = () => { if (b.dataset.lang !== UI) switchLang(b.dataset.lang); }; });
1848
1848
  document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
1849
1849
  $('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
@@ -2472,6 +2472,25 @@ function liveSrc (cap, bases = RUN.live_bases) {
2472
2472
  const b = bases?.[l.revision];
2473
2473
  return b ? b + l.entry : null;
2474
2474
  }
2475
+ /**
2476
+ * A page inside a step's live folder that a prototype link named (frame.js sends `path`, e.g. "step-b/details.html"):
2477
+ * only inside that step's own folder, no "..", no absolute or scheme paths; null otherwise (the step's entry opens).
2478
+ */
2479
+ function livePath (path, node) {
2480
+ if (typeof path !== 'string' || typeof node !== 'string' || path.length > 240 || !path.startsWith(node + '/')) return null;
2481
+ const parts = path.split('/');
2482
+ if (parts.some((x) => !x || x === '.' || x === '..' || /[\\:\u0000-\u001f]/.test(x))) return null;
2483
+ return parts.map(encodeURIComponent).join('/');
2484
+ }
2485
+ /** liveSrc with the step's entry replaced by `path` (from livePath), within the same revision. */
2486
+ function liveSubSrc (cap, path, bases) {
2487
+ const src = liveSrc(cap, bases); const entry = cap?.live?.entry;
2488
+ if (!src || !path || !entry) return src;
2489
+ const enc = entry.split('/').map(encodeURIComponent).join('/');
2490
+ if (src.endsWith(entry)) return src.slice(0, src.length - entry.length) + path;
2491
+ if (src.endsWith(enc)) return src.slice(0, src.length - enc.length) + path;
2492
+ return src;
2493
+ }
2475
2494
  function viewSrc (cap, bases = RUN.live_bases) {
2476
2495
  const l = cap?.view; if (!l?.revision) return null;
2477
2496
  if (!APP()) return `live/${l.revision}/${l.entry}`;
@@ -2596,12 +2615,17 @@ addEventListener('message', (e) => {
2596
2615
  // presenting: the frame moves between steps (links, data-kaloko-go) and hands over the keys it does not use
2597
2616
  if (!$('pt').hidden) {
2598
2617
  const str = (x) => typeof x === 'string' ? x.slice(0, 300).replace(/[#\s]/g, '') : '';
2599
- if (m.type === 'qawalk:go' && typeof m.node === 'string') { if (byId(m.node) && byId(m.node).kind !== 'decision') ptOpen(m.node, PT.vp, { query: str(m.query), hash: str(m.hash) }); else toast(T('protoNoStep')); }
2618
+ if (m.type === 'qawalk:go' && typeof m.node === 'string') { if (byId(m.node) && byId(m.node).kind !== 'decision') ptOpen(m.node, PT.vp, { query: str(m.query), hash: str(m.hash), path: livePath(m.path, m.node) }); else toast(T('protoNoStep')); }
2600
2619
  if (m.type === 'qawalk:key') ptKey({ key: String(m.key), preventDefault () {} });
2601
2620
  return;
2602
2621
  }
2603
2622
  if (m.type === 'qawalk:ready' && n) { toFrame({ type: 'qawalk:pins', pins: pinsFor(n, S.lbVp) }); if (LIVE.mode) toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); }
2604
- if (m.type === 'qawalk:go' && typeof m.node === 'string' && byId(m.node)) { toast(`${T('prototypeTo')} ${loc(byId(m.node).title)}`); openLb(m.node, S.lbVp); }
2623
+ if (m.type === 'qawalk:go' && typeof m.node === 'string' && byId(m.node)) {
2624
+ toast(`${T('prototypeTo')} ${loc(byId(m.node).title)}`); openLb(m.node, S.lbVp);
2625
+ // a link to a page inside the step's folder opens that page in the live frame, not the step's entry
2626
+ const sub = livePath(m.path, m.node), fr = $('lbFrame'), c = byId(m.node).captures?.[S.loc]?.[S.lbVp];
2627
+ if (sub && fr && S.lbMode === 'live') { const u = liveSubSrc(c, sub); if (u) fr.src = u; }
2628
+ }
2605
2629
  if (m.type === 'qawalk:hover' && LIVE.mode === 'inspect' && m.el) { LIVE.insp = sanitizeEl(m.el); const sec = $('inspSec'); if (sec) sec.outerHTML = inspectorHtml(); else $('lbSideTop')?.insertAdjacentHTML('afterend', inspectorHtml()); wireCopyCss(); }
2606
2630
  if (m.type === 'qawalk:picked' && m.el) {
2607
2631
  const el = sanitizeEl(m.el);
@@ -2662,14 +2686,14 @@ function wireStepStatus (n) {
2662
2686
 
2663
2687
 
2664
2688
  // ---- prototype presentation (#42): full screen, live pages where the run has them, screenshots otherwise ----
2665
- const PT = { id: null, vp: null, device: true, query: '', hash: '', from: null };
2689
+ const PT = { id: null, vp: null, device: true, query: '', hash: '', path: '', from: null };
2666
2690
  const ptList = () => flowOrder().filter((n) => n.kind !== 'decision' && n.kind !== 'stack' && !n.item);
2667
2691
  function ptOpen (id, vpName, extra = {}) {
2668
2692
  const list = ptList(); if (!list.length) return;
2669
2693
  const n = list.includes(byId(id)) ? byId(id) : list[0];
2670
2694
  if ($('pt').hidden) { PT.from = $('lb').hidden ? null : S.open; PT.device = innerWidth > 700; }
2671
2695
  $('lb').hidden = true; LIVE.mode = null;
2672
- PT.id = n.id; PT.query = extra.query || ''; PT.hash = extra.hash || '';
2696
+ PT.id = n.id; PT.query = extra.query || ''; PT.hash = extra.hash || ''; PT.path = extra.path || '';
2673
2697
  PT.vp = viewports().some((v) => v.name === vpName) ? vpName : PT.vp || S.lbVp || (S.vp === 'mobile' ? mVp() : dVp())?.name || viewports()[0].name;
2674
2698
  renderPt();
2675
2699
  setHash({ present: n.id, loc: S.loc, vp: PT.vp });
@@ -2693,7 +2717,7 @@ function renderPt () {
2693
2717
  const el = $('pt'), list = ptList(), n = byId(PT.id), i = list.indexOf(n);
2694
2718
  const v = viewports().find((x) => x.name === PT.vp) || viewports()[0];
2695
2719
  const cap = n.captures?.[S.loc]?.[v.name];
2696
- const src = liveSrc(cap);
2720
+ const src = PT.path ? liveSubSrc(cap, PT.path) : liveSrc(cap);
2697
2721
  const url = src ? src + (PT.query ? `?${PT.query}` : '') + (PT.hash ? `#${PT.hash}` : '') : null;
2698
2722
  const kind = v.mobile ? 'phone' : 'desk';
2699
2723
  const screen = url ? `<iframe id="ptIframe" title="${esc(loc(n.title))}" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" src="${esc(url)}" style="width:${v.w}px;height:${v.h}px"></iframe>`