kaloko 0.4.4 → 0.4.7
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 +2 -1
- package/package.json +2 -2
- package/src/cli.mjs +25 -2
- package/src/lib/doctor.mjs +5 -3
- package/src/lib/login.mjs +70 -0
- package/viewer/index.html +163 -14
package/README.md
CHANGED
|
@@ -122,7 +122,8 @@ captured HTML, page text and console output.
|
|
|
122
122
|
adapter module (`provider: script`, example in `templates/mail/`), or hand a
|
|
123
123
|
message over with `npx kaloko mail --step <id> --file message.eml`.
|
|
124
124
|
- **Service and evaluator**: `KALOKO_TOKEN` for `share`, `feedback`, `inbox` and clean-up (`runs`, `trash`, `prune`, `keep`, `archive`)
|
|
125
|
-
(
|
|
125
|
+
(`npx kaloko login` approves one in the browser and saves it to `~/.config/kaloko/.env`, or create it in your
|
|
126
|
+
organization's settings); `TYPESAFE_API_KEY` for the
|
|
126
127
|
semantic evaluator (optional). Both names can be changed in the config.
|
|
127
128
|
|
|
128
129
|
`npx kaloko doctor` checks that every variable the config names is set and that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaloko",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
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=
|
|
35
|
+
"test": "node --test --test-timeout=900000 test/*.test.mjs worker/test/*.test.mjs"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"ajv": "^8.20.0",
|
package/src/cli.mjs
CHANGED
|
@@ -30,7 +30,8 @@ import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
|
30
30
|
import { expandPacks } from './lib/packs.mjs'
|
|
31
31
|
import { upsertPrComment } from './lib/pr.mjs'
|
|
32
32
|
import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
33
|
-
import { service } from './lib/service.mjs'
|
|
33
|
+
import { service, serviceUrl } from './lib/service.mjs'
|
|
34
|
+
import { login } from './lib/login.mjs'
|
|
34
35
|
import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
|
|
35
36
|
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
36
37
|
import { lintDesign, formatLint } from './lib/design-lint.mjs'
|
|
@@ -51,6 +52,10 @@ Project
|
|
|
51
52
|
pages (title, description, H1) with the seo, a11y, perf and console packs
|
|
52
53
|
init [--agent claude|codex|all] [--org <slug>]
|
|
53
54
|
Create ${CONFIG_FILE} and install the kaloko skill for your agent
|
|
55
|
+
login [--org <slug>] [--url <service>] [--print] [--no-open]
|
|
56
|
+
Sign this machine in through the browser: approve the code on kaloko.app, choose
|
|
57
|
+
the organization and what the token may do; the token (at most 30 days) goes to
|
|
58
|
+
~/.config/kaloko/.env, or is printed with --print
|
|
54
59
|
doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
|
|
55
60
|
and app tools; --fix asks macOS for the desktop capture permissions
|
|
56
61
|
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
@@ -558,7 +563,7 @@ const commands = {
|
|
|
558
563
|
await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
|
|
559
564
|
say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
|
|
560
565
|
}
|
|
561
|
-
say(`Next:
|
|
566
|
+
say(`Next: run kaloko login to approve a token in the browser (or create one in the app under Settings → API tokens and put KALOKO_TOKEN into .env).\nScenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
|
|
562
567
|
},
|
|
563
568
|
|
|
564
569
|
async validate (args) {
|
|
@@ -1218,6 +1223,24 @@ const commands = {
|
|
|
1218
1223
|
say(args.undo ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --undo)`)
|
|
1219
1224
|
},
|
|
1220
1225
|
|
|
1226
|
+
async login (args) {
|
|
1227
|
+
// a project is optional: it only names the service URL, the organization and the token variable
|
|
1228
|
+
let config = {}
|
|
1229
|
+
const shell = { ...process.env } // before the project and user .env files are read
|
|
1230
|
+
try { ({ config } = await context(args)) } catch (e) { if (!(e instanceof UserError)) throw e }
|
|
1231
|
+
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
1232
|
+
const inShell = shell[tokenEnv]
|
|
1233
|
+
const base = typeof args.url === 'string' ? args.url : serviceUrl(config)
|
|
1234
|
+
const version = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
|
|
1235
|
+
const r = await login(base, { org: typeof args.org === 'string' ? args.org : config.org || null, print: !!args.print, open: !args['no-open'], tokenEnv, version, say })
|
|
1236
|
+
if (args.print) return
|
|
1237
|
+
say(`✓ Signed in to ${r.org_name || r.org} as ${r.email} (${r.scope}; expires ${String(r.expires_at || '').slice(0, 10)}). ${tokenEnv} saved to ${r.file}.`)
|
|
1238
|
+
if (config.org && r.org && config.org !== r.org) say(`! ${CONFIG_FILE} names the organization ${config.org}; the token is for ${r.org}.`)
|
|
1239
|
+
let projectEnv = ''
|
|
1240
|
+
try { projectEnv = await readFile(join(process.cwd(), '.env'), 'utf8') } catch {}
|
|
1241
|
+
if (inShell || new RegExp(`^\\s*(export\\s+)?${tokenEnv}\\s*=`, 'm').test(projectEnv)) say(`! ${tokenEnv} is also set ${inShell ? 'in your shell' : 'in this project\'s .env'}, which wins over ~/.config/kaloko/.env. Remove it there to use the new token.`)
|
|
1242
|
+
},
|
|
1243
|
+
|
|
1221
1244
|
async inbox (args) {
|
|
1222
1245
|
const { config } = await context(args)
|
|
1223
1246
|
const client = service(config)
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -107,7 +107,7 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
107
107
|
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
108
108
|
const token = env[tokenEnv]
|
|
109
109
|
const service = serviceUrl(config, env)
|
|
110
|
-
if (!token) add('token', false, `${tokenEnv} not set`, `
|
|
110
|
+
if (!token) add('token', false, `${tokenEnv} not set`, `Run kaloko login, or create a tester token in ${service}/${config.org || '<org>'}/settings and put ${tokenEnv}=… into .env (or ~/.config/kaloko/.env).`)
|
|
111
111
|
else {
|
|
112
112
|
try {
|
|
113
113
|
const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
|
|
@@ -115,8 +115,10 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
115
115
|
else {
|
|
116
116
|
const me = await res.json()
|
|
117
117
|
const sameOrg = !config.org || me.org === config.org
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
|
|
119
|
+
const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
|
|
120
|
+
add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
|
|
121
|
+
if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
|
|
120
122
|
}
|
|
121
123
|
} catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL overrides the service URL.') }
|
|
122
124
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// kaloko login: signs this machine in through the browser instead of pasting a token. The service hands out a code
|
|
2
|
+
// (POST /cli/device), a signed-in person approves it at /cli/authorize for one organization with the scopes their
|
|
3
|
+
// role allows, and the next poll (POST /cli/token) returns a personal token that expires within 30 days. The token
|
|
4
|
+
// goes to ~/.config/kaloko/.env (or is printed with --print).
|
|
5
|
+
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { homedir, hostname } from 'node:os'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { UserError } from './util.mjs'
|
|
10
|
+
|
|
11
|
+
export const userEnvFile = (home = homedir()) => join(home, '.config', 'kaloko', '.env')
|
|
12
|
+
|
|
13
|
+
/** Sets NAME=value in a .env text, replacing an earlier line of the same name; keeps everything else. */
|
|
14
|
+
export function setEnvLine (text, name, value) {
|
|
15
|
+
const lines = String(text || '').split('\n').filter((l, i, all) => !(i === all.length - 1 && l === ''))
|
|
16
|
+
const re = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`)
|
|
17
|
+
const at = lines.findIndex((l) => re.test(l))
|
|
18
|
+
if (at >= 0) lines[at] = `${name}=${value}`
|
|
19
|
+
else lines.push(`${name}=${value}`)
|
|
20
|
+
return lines.join('\n') + '\n'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function openBrowser (url) {
|
|
24
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
|
|
25
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref() } catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs the device flow against `base` (the service URL). Returns { token, org, email, scope, expires_at, file? }.
|
|
30
|
+
* Options: org (preselected in the browser), print (do not write the file), open (launch the browser), tokenEnv
|
|
31
|
+
* (the variable name, share.token_env), home, version, say, fetchImpl, sleep.
|
|
32
|
+
*/
|
|
33
|
+
export async function login (base, { org = null, print = false, open = true, tokenEnv = 'KALOKO_TOKEN', home = homedir(), version = '', say = console.log, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
34
|
+
base = base.replace(/\/$/, '')
|
|
35
|
+
const post = async (path, body) => {
|
|
36
|
+
let res
|
|
37
|
+
try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base}: ${e.message}`) }
|
|
38
|
+
return { status: res.status, body: await res.json().catch(() => ({})) }
|
|
39
|
+
}
|
|
40
|
+
const start = await post('/cli/device', { client: `kaloko${version ? ` ${version}` : ''} on ${hostname()}`.slice(0, 60) })
|
|
41
|
+
if (start.status !== 200 || !start.body.device_code) throw new UserError(`${base} did not start the sign-in (HTTP ${start.status}${start.body.error ? `: ${start.body.error}` : ''}). Is this a Kaloko service?`)
|
|
42
|
+
const { device_code: device, user_code: code, verification_uri_complete: complete, expires_in: ttl = 600 } = start.body
|
|
43
|
+
let interval = Math.max(1, Number(start.body.interval) || 5)
|
|
44
|
+
const link = org ? `${complete}&org=${encodeURIComponent(org)}` : complete
|
|
45
|
+
say(`Open this page and approve the sign-in:\n ${link}\nCheck that it shows the code ${code}.`)
|
|
46
|
+
if (open) openBrowser(link)
|
|
47
|
+
const until = Date.now() + ttl * 1000
|
|
48
|
+
while (Date.now() < until) {
|
|
49
|
+
await sleep(interval * 1000)
|
|
50
|
+
const r = await post('/cli/token', { device_code: device })
|
|
51
|
+
if (r.status === 200 && r.body.access_token) {
|
|
52
|
+
const out = { token: r.body.access_token, org: r.body.org, org_name: r.body.org_name, email: r.body.email, scope: r.body.scope, expires_at: r.body.expires_at }
|
|
53
|
+
if (print) { say(`${tokenEnv}=${out.token}`); return out }
|
|
54
|
+
const file = userEnvFile(home)
|
|
55
|
+
await mkdir(dirname(file), { recursive: true })
|
|
56
|
+
let text = ''
|
|
57
|
+
try { text = await readFile(file, 'utf8') } catch {}
|
|
58
|
+
await writeFile(file, setEnvLine(text, tokenEnv, out.token), { mode: 0o600 })
|
|
59
|
+
await chmod(file, 0o600).catch(() => {})
|
|
60
|
+
return { ...out, file }
|
|
61
|
+
}
|
|
62
|
+
const error = r.body.error
|
|
63
|
+
if (error === 'authorization_pending') continue
|
|
64
|
+
if (error === 'slow_down') { interval += 5; continue }
|
|
65
|
+
if (error === 'access_denied') throw new UserError('The sign-in was declined in the browser.')
|
|
66
|
+
if (error === 'expired_token') throw new UserError('The code expired. Run kaloko login again.')
|
|
67
|
+
throw new UserError(`Sign-in failed (HTTP ${r.status}${error ? `: ${error}` : ''}).`)
|
|
68
|
+
}
|
|
69
|
+
throw new UserError('The code expired before it was approved. Run kaloko login again.')
|
|
70
|
+
}
|
package/viewer/index.html
CHANGED
|
@@ -591,6 +591,52 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
591
591
|
/* phones: the lightbox stacks stage over side panel. Placed after the base .lb__box rule, which used to win and
|
|
592
592
|
squeeze the stage to nothing on narrow screens */
|
|
593
593
|
@media (max-width:900px){.lb__box{grid-template-columns:1fr;grid-template-rows:minmax(260px,48%) 1fr}.lb__stage{border-right:0;border-bottom:1px solid var(--line)}}
|
|
594
|
+
/* the secondary tools of the detail: inline on wide screens, behind "⋯" on phones */
|
|
595
|
+
.lb__more{display:contents}.lb__moreBtn,.lb__grab{display:none}
|
|
596
|
+
/* phones: the preview gets the height; the step header and tabs are a bottom sheet that starts collapsed */
|
|
597
|
+
@media (max-width:700px){
|
|
598
|
+
.lb{padding:0}
|
|
599
|
+
.lb__box{--peek:calc(98px + env(safe-area-inset-bottom));display:block;position:relative;border-radius:0;height:100%;overflow:hidden}
|
|
600
|
+
.lb__stage{position:absolute;left:0;right:0;top:0;bottom:var(--peek);border:0}
|
|
601
|
+
.lb__tools{flex-wrap:nowrap;gap:8px;padding:8px 10px;position:relative}
|
|
602
|
+
.lb__tools #lbFocus{padding:5px 10px;flex:none}
|
|
603
|
+
.lb__moreBtn{display:inline-grid;margin-left:auto;flex:none;width:32px;height:32px}
|
|
604
|
+
.lb__more{display:none}
|
|
605
|
+
.lb__more.is-open{display:flex;position:absolute;top:calc(100% + 4px);right:8px;z-index:6;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);max-width:calc(100vw - 16px)}
|
|
606
|
+
.lb__more .spacer,.lb__more .lb__hint{display:none}
|
|
607
|
+
.lb__scroll{padding:10px}
|
|
608
|
+
.lb__side{position:absolute;left:0;right:0;bottom:0;height:var(--peek);z-index:3;background:var(--panel);border-radius:14px 14px 0 0;box-shadow:0 -6px 24px rgba(0,0,0,.18);overflow:hidden;padding-bottom:env(safe-area-inset-bottom);overscroll-behavior:contain}
|
|
609
|
+
.lb[data-sheet="half"] .lb__side{height:58%;overflow:auto}
|
|
610
|
+
.lb[data-sheet="full"] .lb__side{height:calc(100% - 48px);overflow:auto}
|
|
611
|
+
.lb__grab{display:block;position:sticky;top:0;z-index:3;height:16px;background:var(--panel);cursor:grab;touch-action:none}
|
|
612
|
+
.lb__grab::before{content:"";display:block;width:36px;height:4px;border-radius:2px;background:var(--line);margin:6px auto 0}
|
|
613
|
+
.lb__head{padding:0 14px 8px;gap:4px;top:16px;touch-action:none;cursor:pointer}
|
|
614
|
+
.lb__head .kind{display:none}.lb__head .lb__linkTxt{display:none}.lb__head #lbLink{padding:5px 8px}
|
|
615
|
+
.lb__head h3{font-size:16px}
|
|
616
|
+
.lb[data-sheet="peek"] .lb__head h3{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
617
|
+
.lb[data-sheet="peek"] .lb__side:not(.is-drag) .lbtabs,.lb[data-sheet="peek"] .lb__side:not(.is-drag) .lbtab{visibility:hidden}
|
|
618
|
+
.lbtabs{top:16px}
|
|
619
|
+
}
|
|
620
|
+
@media (max-width:359px){.lb__tools #lbFocus span{display:none}}
|
|
621
|
+
@media (max-width:700px) and (prefers-reduced-motion:no-preference){.lb__side{transition:height .2s ease}.lb__side.is-drag{transition:none}}
|
|
622
|
+
/* focus: only this step's capture or live page, full viewport, with a small floating bar that hides when idle */
|
|
623
|
+
.lb.is-focus{padding:0;background:#111110}
|
|
624
|
+
.lb.is-focus .lb__box{display:block;position:relative;height:100%;border-radius:0;background:#111110;box-shadow:none}
|
|
625
|
+
.lb.is-focus .lb__side,.lb.is-focus .lb__tools{display:none}
|
|
626
|
+
.lb.is-focus .lb__stage{position:absolute;inset:0;border:0;background:#111110}
|
|
627
|
+
.lb.is-focus .lb__scroll{padding:0;overscroll-behavior:contain}
|
|
628
|
+
.lb.is-focus .lb__scroll > p.note{display:none}
|
|
629
|
+
.lb.is-focus .lb__img{box-shadow:none}
|
|
630
|
+
.lb.is-focus .lb__img--m,.lb.is-focus .lb__img--dev{border:0;border-radius:0;box-shadow:none}
|
|
631
|
+
.lb.is-focus .lb__live{border:0;border-radius:0;box-shadow:none}
|
|
632
|
+
.lb.is-focus .lb__win{border-radius:0}
|
|
633
|
+
.lb__fbar{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);z-index:8;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:6px 8px;width:max-content;max-width:calc(100% - 16px);padding:6px 8px;border-radius:22px;color-scheme:dark;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#a3a39c;--line:#3d3d37;background:rgba(22,22,21,.9);color:var(--ink);box-shadow:0 8px 30px rgba(0,0,0,.4);backdrop-filter:blur(8px)}
|
|
634
|
+
.lb__fbar .icon-btn{width:34px;height:34px}
|
|
635
|
+
.lb__fpos{font-size:12.5px;color:var(--muted);min-width:44px;text-align:center;font-variant-numeric:tabular-nums}
|
|
636
|
+
.lb__fshow{display:none;position:absolute;top:max(10px,env(safe-area-inset-top));right:10px;z-index:8;width:38px;height:38px;place-items:center;border-radius:50%;background:rgba(22,22,21,.6);color:#f0f0ec;opacity:.75}
|
|
637
|
+
.lb.is-idle .lb__fbar{opacity:0;pointer-events:none}
|
|
638
|
+
.lb.is-focus.is-idle .lb__fshow{display:grid}
|
|
639
|
+
@media (prefers-reduced-motion:no-preference){.lb__fbar{transition:opacity .2s ease}}
|
|
594
640
|
</style>
|
|
595
641
|
</head>
|
|
596
642
|
<body>
|
|
@@ -684,7 +730,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
684
730
|
const EMBEDDED = /*__QAWALK_RUN__*/null;
|
|
685
731
|
if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
|
|
686
732
|
const I18N = {
|
|
687
|
-
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ý',
|
|
733
|
+
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', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <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ý',
|
|
688
734
|
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',
|
|
689
735
|
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ý',
|
|
690
736
|
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í',
|
|
@@ -708,7 +754,7 @@ const I18N = {
|
|
|
708
754
|
downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
|
|
709
755
|
compare:'Porovnat', cmpWith:'Porovnáno s', cmpStop:'Ukončit porovnání', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
|
|
710
756
|
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' },
|
|
711
|
-
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',
|
|
757
|
+
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
712
758
|
pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
|
|
713
759
|
branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
|
|
714
760
|
older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
|
|
@@ -757,7 +803,9 @@ const ICON = {
|
|
|
757
803
|
share:'<svg class="i" viewBox="0 0 24 24"><path d="M12 16V4M7 9l5-5 5 5M5 20h14"/></svg>',
|
|
758
804
|
copy:'<svg class="i" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>',
|
|
759
805
|
download:'<svg class="i" viewBox="0 0 24 24"><path d="M12 4v12M7 11l5 5 5-5M5 20h14"/></svg>',
|
|
760
|
-
next:'<svg class="i" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></svg>'
|
|
806
|
+
next:'<svg class="i" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></svg>',
|
|
807
|
+
focus:'<svg class="i" viewBox="0 0 24 24"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/></svg>',
|
|
808
|
+
more:'<svg class="i" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/></svg>'
|
|
761
809
|
};
|
|
762
810
|
const MARK = { pass:'✓', partial:'!', fail:'✕', skip:'–' };
|
|
763
811
|
const VSTAT = { pass:'pass', fail:'fail', needs_review:'partial', not_evaluated:'partial', not_reached:'skip' };
|
|
@@ -1030,7 +1078,7 @@ function openThread (nodeId, threadId) {
|
|
|
1030
1078
|
requestAnimationFrame(() => requestAnimationFrame(() => { const el = threadId && document.querySelector(`#lb .c-thread[data-thread="${CSS.escape(threadId)}"]`); (el || $('lb').querySelector('#c-step'))?.scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => { C.focus = null; }, 1800); }));
|
|
1031
1079
|
}
|
|
1032
1080
|
/** Key C in the lightbox (or its comment button): the composer of the step, in view and focused. */
|
|
1033
|
-
function focusComposer () { const t
|
|
1081
|
+
function focusComposer () { if (S.focus) setFocus(false); if (lbPhone() && S.lbSheet === 'peek') setSheet('half'); const t =$('lb').querySelector('.c-main .c-text'); if (!t) return; t.scrollIntoView({ behavior: 'smooth', block: 'center' }); t.focus({ preventScroll: true }); }
|
|
1034
1082
|
/** #comments=all|run|flow: the right panel on the comments. */
|
|
1035
1083
|
function showCommentsFromHash (which) {
|
|
1036
1084
|
S.cTab = ['all', 'run', 'flow'].includes(which) ? which : 'all';
|
|
@@ -2133,7 +2181,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2133
2181
|
const lb = $('lb');
|
|
2134
2182
|
const html = `<div class="lb__box">
|
|
2135
2183
|
<div class="lb__stage">
|
|
2136
|
-
<div class="lb__tools"><div class="seg" id="lbVp"></div>${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div>
|
|
2184
|
+
<div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore">${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
|
|
2137
2185
|
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2138
2186
|
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2139
2187
|
if (S.lbMode === 'view' && viewSrc(cap) && !S.cmp) return `<p class="note" style="margin:0 0 8px">${T('htmlViewNote')}</p>` + liveStage(cap, v, 'lbFrame', RUN.live_bases, true);
|
|
@@ -2147,10 +2195,11 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2147
2195
|
return `<div class="lb__cmp"><figure><figcaption><b>${T('thisRun')}</b> ${fmtDate(RUN.started_at)}${RUN.git?.commit ? ' · ' + esc(RUN.git.commit) : ''}</figcaption>${img}</figure><figure><figcaption><b>${esc(cmpLabel())}</b> <span id="lbPx"></span></figcaption>${bimg}</figure></div>`;
|
|
2148
2196
|
})()}</div>
|
|
2149
2197
|
</div>
|
|
2150
|
-
<div class="lb__side">
|
|
2198
|
+
<div class="lb__side" id="lbSide">
|
|
2199
|
+
<div class="lb__grab" id="lbGrab" role="button" tabindex="0" aria-controls="lbSide" aria-expanded="${(S.lbSheet || 'peek') !== 'peek'}" aria-label="${esc(T('sheetHandle'))}"></div>
|
|
2151
2200
|
<div class="lb__head">
|
|
2152
2201
|
<div class="lb__row"><span class="kind">${ICON[n.kind]}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${n.item ? T('pageWord') : T('step')} ${i + 1} ${T('of')} ${list.length}</span>${n.item ? `<button class="btn" id="lbBack">${ICON.stack} ${T('backToIndex')}</button>` : ''}
|
|
2153
|
-
<span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external}
|
|
2202
|
+
<span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external} <span class="lb__linkTxt">${T('copyLink')}</span></button><button class="icon-btn" id="lbPrev" aria-label="Previous" ${i === 0 ? 'disabled' : ''}>${ICON.prev}</button><button class="icon-btn" id="lbNext" aria-label="Next" ${i === list.length - 1 ? 'disabled' : ''}>${ICON.next}</button><button class="icon-btn" id="lbClose" aria-label="Close">${ICON.close}</button></span></div>
|
|
2154
2203
|
<h3 id="lbTitle">${esc(loc(n.title))}</h3>
|
|
2155
2204
|
<div class="node__path">${n.session ? sessChip(n.session) + ' · ' : ''}${cap?.http_status >= 300 ? `<span class="http ${cap.http_status >= 500 ? 'http--5' : ''}" title="HTTP ${esc(cap.http_status)}">${esc(cap.http_status)}</span>` : ''}${esc(cap?.live ? cap.live.entry : cap?.url || loc(n.path))}</div>
|
|
2156
2205
|
${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}
|
|
@@ -2184,11 +2233,18 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2184
2233
|
const keep = opts.keepFrame && ['live', 'view'].includes(S.lbMode) && lb.querySelector('#lbFrame')?.getAttribute('src') === (S.lbMode === 'view' ? viewSrc(cap) : liveSrc(cap)) && lb.dataset.step === id && lb.dataset.vp === v.name;
|
|
2185
2234
|
if (keep) { const tmp = document.createElement('div'); tmp.innerHTML = html; lb.querySelector('.lb__side').replaceWith(tmp.querySelector('.lb__side')); toFrame({ type: 'qawalk:pins', pins: pinsFor(n, v.name) }); }
|
|
2186
2235
|
else lb.innerHTML = html;
|
|
2187
|
-
lb.
|
|
2236
|
+
if (lb.hidden) S.lbSheet = 'peek'; // the phone sheet starts collapsed each time the detail opens
|
|
2237
|
+
lb.dataset.step = id; lb.dataset.vp = v.name; lb.dataset.sheet = S.lbSheet || 'peek';
|
|
2238
|
+
wireLbOnce(lb);
|
|
2239
|
+
renderFocusBar(lb, n, v, cap, list, i);
|
|
2188
2240
|
lb.hidden = false;
|
|
2189
2241
|
fitLive(); requestAnimationFrame(fitLive);
|
|
2190
2242
|
setTimeout(() => presenceView(true), 0);
|
|
2191
|
-
setHash({ step: id, loc: S.loc, vp: v.name });
|
|
2243
|
+
setHash({ step: id, loc: S.loc, vp: v.name, focus: S.focus ? '1' : null });
|
|
2244
|
+
$('lbFocus').onclick = () => setFocus(true);
|
|
2245
|
+
$('lbMoreBtn').onclick = (e) => { e.stopPropagation(); const m = $('lbMore'), open = !m.classList.contains('is-open'); m.classList.toggle('is-open', open); e.currentTarget.setAttribute('aria-expanded', String(open)); };
|
|
2246
|
+
// a tool chosen in the "⋯" menu closes it, so the preview is free for the click that follows (Inspect, Comment on element)
|
|
2247
|
+
$('lbMore').onclick = (e) => { if (e.target.closest('button')) setTimeout(closeLbMore, 0); };
|
|
2192
2248
|
if ($('lbBack')) $('lbBack').onclick = () => { closeLb(); openStack(n.stack, { focus: n.itemId }); };
|
|
2193
2249
|
if ($('lbComments')) $('lbComments').onclick = () => focusComposer();
|
|
2194
2250
|
placeCommentPins($('lbImg'), n, cap, v);
|
|
@@ -2243,6 +2299,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2243
2299
|
if (!li.dataset.where) toast(n.criteria.some((x) => x.target) || n.metadata?.[S.loc] ? T('noPlaceToast') : T('noMeta'));
|
|
2244
2300
|
if (li.dataset.where === 'meta' && !n.metadata?.[S.loc]) { toast(T('noMeta')); return; }
|
|
2245
2301
|
if (li.dataset.where === 'page' && img) {
|
|
2302
|
+
if (lbPhone()) setSheet('peek'); // on a phone the sheet steps aside so the highlight is in view
|
|
2246
2303
|
const els = [...img.querySelectorAll(`.hlbox[data-ac="${CSS.escape(li.dataset.hl)}"]`)].filter((e) => parseFloat(e.style.width) > 0);
|
|
2247
2304
|
els.forEach((e, k) => { e.hidden = false; e.classList.add('on'); if (!k) e.classList.add('first'); });
|
|
2248
2305
|
if (els[0]) $('lbScroll').scrollTo({ top: Math.max(0, els[0].offsetTop - 80), behavior: 'smooth' }); else toast(T('noBoxToast'));
|
|
@@ -2264,13 +2321,96 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2264
2321
|
li.addEventListener('mouseenter', () => { if (li.dataset.where !== 'page' || !img || lb.querySelector('.ac li.is-on')) return; img.querySelectorAll(`.hlbox[data-ac="${CSS.escape(li.dataset.hl)}"]`).forEach((e) => { e.hidden = false; }); });
|
|
2265
2322
|
li.addEventListener('mouseleave', () => { if (!img || lb.querySelector('.ac li.is-on')) return; img.querySelectorAll('.hlbox').forEach((e) => { e.hidden = true; }); });
|
|
2266
2323
|
});
|
|
2267
|
-
$('lbClose').focus();
|
|
2324
|
+
(S.focus ? $('lbFexit') : $('lbClose')).focus({ preventScroll: true });
|
|
2268
2325
|
if (S.pendingAc) {
|
|
2269
2326
|
const li = lb.querySelector(`[data-hl="${CSS.escape(S.pendingAc)}"]`); S.pendingAc = null;
|
|
2270
2327
|
if (li) { const im = img?.querySelector('img'); const go = () => setTimeout(() => li.click(), 60); im && !im.complete ? im.addEventListener('load', go, { once: true }) : go(); }
|
|
2271
2328
|
}
|
|
2272
2329
|
}
|
|
2273
|
-
function closeLb () { $('lb').hidden = true; S.open = null; LIVE.mode = null; LIVE.insp = null; setHash(null); presenceView(); }
|
|
2330
|
+
function closeLb () { const lb = $('lb'); lb.hidden = true; lb.classList.remove('is-focus', 'is-idle'); clearTimeout(FX.timer); S.open = null; S.focus = false; LIVE.mode = null; LIVE.insp = null; setHash(null); presenceView(); }
|
|
2331
|
+
const lbPhone = () => matchMedia('(max-width:700px)').matches;
|
|
2332
|
+
function closeLbMore () { const m = $('lbMore'); if (!m) return; m.classList.remove('is-open'); $('lbMoreBtn')?.setAttribute('aria-expanded', 'false'); }
|
|
2333
|
+
/** The phone bottom sheet of the detail: peek (title and status), half, full. */
|
|
2334
|
+
function setSheet (st) {
|
|
2335
|
+
S.lbSheet = st; $('lb').dataset.sheet = st;
|
|
2336
|
+
$('lbGrab')?.setAttribute('aria-expanded', String(st !== 'peek'));
|
|
2337
|
+
if (st === 'peek' && $('lbSide')) $('lbSide').scrollTop = 0;
|
|
2338
|
+
}
|
|
2339
|
+
/** Focus: only this step's capture or live page, full viewport. Previous / next and the switches keep it on;
|
|
2340
|
+
the prototype (P) is a separate presentation and returns here when it ends. */
|
|
2341
|
+
function setFocus (on) {
|
|
2342
|
+
if (!S.open) return;
|
|
2343
|
+
S.focus = !!on; closeLbMore();
|
|
2344
|
+
if (on) { C.pinMode = false; LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null }); }
|
|
2345
|
+
openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2346
|
+
}
|
|
2347
|
+
const FX = { timer: null, over: false };
|
|
2348
|
+
// the floating bar shows on activity and hides after a while; a tap on the preview brings it back
|
|
2349
|
+
function fxWake () {
|
|
2350
|
+
const lb = $('lb'); lb.classList.remove('is-idle'); clearTimeout(FX.timer);
|
|
2351
|
+
if (!S.focus) return;
|
|
2352
|
+
FX.timer = setTimeout(function idle () { if (!S.focus || lb.hidden) return; if (FX.over) { FX.timer = setTimeout(idle, 1500); return; } lb.classList.add('is-idle'); }, 2800);
|
|
2353
|
+
}
|
|
2354
|
+
function fxHide () { clearTimeout(FX.timer); $('lb').classList.add('is-idle'); }
|
|
2355
|
+
function renderFocusBar (lb, n, v, cap, list, i) {
|
|
2356
|
+
lb.querySelector('#lbFbar')?.remove(); lb.querySelector('#lbFshow')?.remove();
|
|
2357
|
+
lb.classList.toggle('is-focus', !!S.focus);
|
|
2358
|
+
if (!S.focus) { lb.classList.remove('is-idle'); clearTimeout(FX.timer); FX.over = false; return; }
|
|
2359
|
+
const modes = [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : [])];
|
|
2360
|
+
if (modes.length && !S.cmp) modes.push(['shot', T('shotMode')]); else modes.length = 0;
|
|
2361
|
+
const vps = n.item ? viewports().filter((x) => n.captures?.[S.loc]?.[x.name]) : viewports();
|
|
2362
|
+
lb.insertAdjacentHTML('beforeend', `<div class="lb__fbar" id="lbFbar" role="toolbar" aria-label="${esc(T('focusBar'))}"><button class="icon-btn" id="lbFexit" aria-label="${esc(T('focusExit'))}" title="${esc(T('focusExit'))} (Esc)">${ICON.close}</button><button class="icon-btn" id="lbFprev" aria-label="Previous" ${i <= 0 ? 'disabled' : ''}>${ICON.prev}</button><span class="lb__fpos" title="${esc(loc(n.title))}">${i + 1} / ${list.length}</span><button class="icon-btn" id="lbFnext" aria-label="Next" ${i >= list.length - 1 ? 'disabled' : ''}>${ICON.next}</button>${vps.length > 1 ? '<div class="seg" id="lbFvp"></div>' : ''}${modes.length ? '<div class="seg" id="lbFmode"></div>' : ''}</div><button class="lb__fshow" id="lbFshow" aria-label="${esc(T('focusShow'))}" title="${esc(T('focusShow'))}">${ICON.more}</button>`);
|
|
2363
|
+
$('lbFexit').onclick = () => setFocus(false);
|
|
2364
|
+
$('lbFprev').onclick = () => $('lbPrev')?.click();
|
|
2365
|
+
$('lbFnext').onclick = () => $('lbNext')?.click();
|
|
2366
|
+
if ($('lbFvp')) seg($('lbFvp'), vps.map((x) => [x.name, vpLabel(x)]), v.name, (nv) => openLb(n.id, nv));
|
|
2367
|
+
if ($('lbFmode')) seg($('lbFmode'), modes, S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(n.id, v.name); });
|
|
2368
|
+
$('lbFshow').onclick = (e) => { e.stopPropagation(); fxWake(); };
|
|
2369
|
+
FX.over = false; fxWake();
|
|
2370
|
+
}
|
|
2371
|
+
/** Listeners that live as long as the detail element: it re-renders its content, these stay. */
|
|
2372
|
+
function wireLbOnce (lb) {
|
|
2373
|
+
if (lb.dataset.wired) return; lb.dataset.wired = '1';
|
|
2374
|
+
let lastPointer = 'mouse', swipe = null;
|
|
2375
|
+
lb.addEventListener('pointerdown', (e) => { lastPointer = e.pointerType; }, true);
|
|
2376
|
+
lb.addEventListener('pointermove', (e) => { if (S.focus && e.pointerType === 'mouse') { FX.over = !!e.target.closest('#lbFbar'); if (lb.classList.contains('is-idle') || !FX.over) fxWake(); } });
|
|
2377
|
+
lb.addEventListener('click', (e) => {
|
|
2378
|
+
if (!e.target.closest('#lbMore, #lbMoreBtn')) closeLbMore();
|
|
2379
|
+
// focus: a tap on the preview shows or hides the bar (a mouse shows it just by moving)
|
|
2380
|
+
if (!S.focus || !e.target.closest('#lbScroll') || e.target.closest('button,a,input,select,textarea')) return;
|
|
2381
|
+
if (lb.classList.contains('is-idle')) fxWake(); else if (lastPointer !== 'mouse') fxHide();
|
|
2382
|
+
});
|
|
2383
|
+
// focus on a touch screen: a swipe sideways moves to the previous or next step, unless the page is pinch-zoomed
|
|
2384
|
+
lb.addEventListener('touchstart', (e) => { swipe = S.focus && e.touches.length === 1 && e.target.closest('#lbScroll') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
|
|
2385
|
+
lb.addEventListener('touchmove', (e) => { if (e.touches.length > 1) swipe = null; }, { passive: true });
|
|
2386
|
+
lb.addEventListener('touchend', (e) => {
|
|
2387
|
+
const sw = swipe; swipe = null; if (!sw || !S.focus) return;
|
|
2388
|
+
const t = e.changedTouches[0], dx = t.clientX - sw.x, dy = t.clientY - sw.y, sc = $('lbScroll');
|
|
2389
|
+
if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - sw.t > 900) return;
|
|
2390
|
+
if (sc && sc.scrollWidth > sc.clientWidth + 2) return; // a capture wider than the screen pans sideways instead
|
|
2391
|
+
$(dx < 0 ? 'lbNext' : 'lbPrev')?.click();
|
|
2392
|
+
}, { passive: true });
|
|
2393
|
+
// the phone sheet: drag the handle or the header to resize it, tap to open or collapse it
|
|
2394
|
+
lb.addEventListener('pointerdown', (e) => {
|
|
2395
|
+
if (!lbPhone() || S.focus || e.button > 0) return;
|
|
2396
|
+
const h = e.target.closest('#lbGrab, .lb__head'); if (!h || e.target.closest('button,a,input,select,textarea,label')) return;
|
|
2397
|
+
const side = $('lbSide'), box = side.parentElement.getBoundingClientRect(), stage = lb.querySelector('.lb__stage').getBoundingClientRect();
|
|
2398
|
+
const peekH = box.bottom - stage.bottom, fullH = box.height - 48, halfH = box.height * 0.58;
|
|
2399
|
+
const y0 = e.clientY, h0 = side.getBoundingClientRect().height; let moved = false;
|
|
2400
|
+
const move = (ev) => { const dy = ev.clientY - y0; if (!moved && Math.abs(dy) < 6) return; moved = true; side.classList.add('is-drag'); side.style.overflow = 'hidden'; side.style.height = `${Math.max(peekH, Math.min(fullH, h0 - dy))}px`; };
|
|
2401
|
+
const up = (ev) => {
|
|
2402
|
+
removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('pointercancel', up);
|
|
2403
|
+
side.classList.remove('is-drag');
|
|
2404
|
+
if (!moved) { if (ev.type === 'pointerup') setSheet(S.lbSheet === 'peek' ? 'half' : 'peek'); return; }
|
|
2405
|
+
const hh = parseFloat(side.style.height); side.style.height = ''; side.style.overflow = '';
|
|
2406
|
+
setSheet([['peek', peekH], ['half', halfH], ['full', fullH]].sort((a, b) => Math.abs(a[1] - hh) - Math.abs(b[1] - hh))[0][0]);
|
|
2407
|
+
};
|
|
2408
|
+
addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
|
|
2409
|
+
});
|
|
2410
|
+
lb.addEventListener('keydown', (e) => { if (e.target.id === 'lbGrab' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setSheet(S.lbSheet === 'peek' ? 'half' : 'peek'); } });
|
|
2411
|
+
// tabs and the comment composer need the sheet open
|
|
2412
|
+
lb.addEventListener('focusin', (e) => { if (lbPhone() && !S.focus && S.lbSheet === 'peek' && e.target.closest('.lbtabs, .lbtab')) setSheet('half'); });
|
|
2413
|
+
}
|
|
2274
2414
|
function setHash (p) {
|
|
2275
2415
|
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null }).filter(([, x]) => x));
|
|
2276
2416
|
const h = Object.keys(all).length ? '#' + new URLSearchParams(all).toString() : ' ';
|
|
@@ -2293,6 +2433,7 @@ function applyHash () {
|
|
|
2293
2433
|
if (!step || !byId(step)) return;
|
|
2294
2434
|
if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); }
|
|
2295
2435
|
S.pendingAc = p.get('ac');
|
|
2436
|
+
S.focus = p.get('focus') === '1'; // #step=<id>&focus=1 opens the step's preview alone
|
|
2296
2437
|
openLb(step, p.get('vp'));
|
|
2297
2438
|
}
|
|
2298
2439
|
|
|
@@ -2614,9 +2755,14 @@ const frames = () => [...document.querySelectorAll('#lbFrame, #lbFrameB, #ptIfra
|
|
|
2614
2755
|
function fitLive () {
|
|
2615
2756
|
for (const f of frames()) {
|
|
2616
2757
|
const box = f.parentElement, host = box.parentElement;
|
|
2617
|
-
|
|
2758
|
+
f.dataset.h ||= f.style.height; // the frame's own height; focus stretches it to the screen and gives it back
|
|
2759
|
+
const w = parseInt(f.style.width, 10);
|
|
2618
2760
|
const cs = getComputedStyle(host);
|
|
2619
|
-
const
|
|
2761
|
+
const focus = S.focus && host.id === 'lbScroll' && !$('lb').hidden;
|
|
2762
|
+
const k = Math.min(1, (host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - (focus ? 0 : 2)) / w);
|
|
2763
|
+
// in focus the live page fills the screen: its real width, scaled down to fit, and as tall as the screen allows
|
|
2764
|
+
const h = focus ? Math.max(1, Math.floor(host.clientHeight / k)) : parseInt(f.dataset.h, 10);
|
|
2765
|
+
f.style.height = `${h}px`;
|
|
2620
2766
|
f.style.transformOrigin = '0 0'; f.style.transform = `scale(${k})`;
|
|
2621
2767
|
box.style.width = `${Math.floor(w * k)}px`; box.style.height = `${Math.floor(h * k)}px`;
|
|
2622
2768
|
}
|
|
@@ -2665,6 +2811,7 @@ addEventListener('message', (e) => {
|
|
|
2665
2811
|
} else {
|
|
2666
2812
|
// a click in the inspector pins the selection: hover stops, and the element can be edited
|
|
2667
2813
|
LIVE.insp = el; LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null }); toFrame({ type: 'qawalk:highlight', selector: el.selector, box: el.box });
|
|
2814
|
+
if (lbPhone() && S.lbSheet === 'peek') setSheet('half'); // the inspector's table lives in the sheet
|
|
2668
2815
|
const sec = $('inspSec'); if (sec) sec.outerHTML = inspectorHtml(); else $('lbSideTop')?.insertAdjacentHTML('afterend', inspectorHtml());
|
|
2669
2816
|
renderLiveTools(); wireCopyCss();
|
|
2670
2817
|
}
|
|
@@ -2900,7 +3047,9 @@ load().then((data) => {
|
|
|
2900
3047
|
if (!$('br').hidden) { if (e.key === 'Escape') closeBrowser(); return; }
|
|
2901
3048
|
if (!$('lb').hidden) {
|
|
2902
3049
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2903
|
-
if (
|
|
3050
|
+
if (S.focus) fxWake();
|
|
3051
|
+
if (e.key === 'Escape') { if ($('lbMore')?.classList.contains('is-open')) closeLbMore(); else if (S.focus) setFocus(false); else closeLb(); }
|
|
3052
|
+
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); setFocus(!S.focus); }
|
|
2904
3053
|
if (e.key === 'ArrowRight') $('lbNext')?.click();
|
|
2905
3054
|
if (e.key === 'ArrowLeft') $('lbPrev')?.click();
|
|
2906
3055
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); focusComposer(); }
|