kaloko 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
|
@@ -0,0 +1,2772 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="robots" content="noindex">
|
|
7
|
+
<title>Kaloko</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
10
|
+
<style>
|
|
11
|
+
:root{
|
|
12
|
+
--ground:#ecece8; --dots:#c9c9c3; --panel:#ffffff; --panel-2:#f5f5f2;
|
|
13
|
+
--ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514;
|
|
14
|
+
--accent:#ffe452; --accent-ink:#151514;
|
|
15
|
+
--pass:#1d8249; --fail:#c8102e; --partial:#b86e00; --skip:#8d8d86;
|
|
16
|
+
--pass-bg:#e3f3e9; --fail-bg:#fbe5e8; --partial-bg:#fbf0dc; --skip-bg:#ececea;
|
|
17
|
+
--edge:#55554f; --shadow:0 1px 2px rgba(20,20,18,.08),0 8px 24px rgba(20,20,18,.08);
|
|
18
|
+
--sans:"Instrument Sans","Helvetica Neue",-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
|
|
19
|
+
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
|
|
22
|
+
color-scheme:dark;
|
|
23
|
+
--ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
|
|
24
|
+
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
|
|
25
|
+
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
26
|
+
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
27
|
+
--edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35);
|
|
28
|
+
}}
|
|
29
|
+
:root[data-theme="dark"]{
|
|
30
|
+
color-scheme:dark;
|
|
31
|
+
--ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
|
|
32
|
+
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
|
|
33
|
+
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
34
|
+
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
35
|
+
--edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35);
|
|
36
|
+
}
|
|
37
|
+
html,body{height:100%}
|
|
38
|
+
[hidden]{display:none!important}
|
|
39
|
+
body{margin:0}
|
|
40
|
+
body{background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.43;overflow:hidden}
|
|
41
|
+
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
|
|
42
|
+
button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
43
|
+
.app{display:grid;grid-template-rows:auto 1fr;grid-template-columns:minmax(0,1fr);height:100%}
|
|
44
|
+
.bar{min-width:0}
|
|
45
|
+
|
|
46
|
+
/* top bar */
|
|
47
|
+
.popwrap{position:relative;display:flex;align-items:center;gap:2px}
|
|
48
|
+
.slot{display:contents}.slot .appnav{display:flex;align-items:center;gap:4px;white-space:nowrap}.slot .appnav a{display:inline-flex;align-items:center;gap:4px}.slot .viewctls{display:flex;align-items:center;gap:12px}.slot #cmpSel{max-width:220px}.slot .ctl{display:flex;align-items:center;gap:6px}
|
|
49
|
+
#barMenu .appnav{display:grid;gap:2px;padding-bottom:8px;border-bottom:1px solid var(--line)}#barMenu .appnav a{padding:6px 8px;border-radius:6px}#barMenu .appnav a:hover{background:var(--panel-2)}#barMenu .langsw{margin:6px 8px 2px}#barMenu .viewctls{display:grid;gap:12px}
|
|
50
|
+
#menuBtn{position:relative}body.wide #menuWrap{display:none}.menu-btn{width:26px;height:26px}
|
|
51
|
+
.menu{position:absolute;top:calc(100% + 8px);left:0;min-width:210px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.18);padding:8px;z-index:40;display:grid;gap:4px}.menu[hidden]{display:none}
|
|
52
|
+
#brandMenu .appnav{display:grid;gap:2px}#brandMenu .appnav a{padding:6px 8px;border-radius:6px}#brandMenu .appnav a:hover{background:var(--panel-2)}#brandMenu .langsw{margin:6px 8px 2px}
|
|
53
|
+
.pop{left:auto;right:0;min-width:280px;gap:12px;padding:12px}.pop .ctl{display:grid;gap:4px}.pop .seg-label{position:static!important;width:auto!important;height:auto!important;clip:auto!important;overflow:visible!important}.pop select{width:100%}
|
|
54
|
+
.inbox-btn{display:inline-flex;align-items:center;gap:4px;font-size:13px;font-weight:600;color:inherit;text-decoration:none;padding:4px 8px;border-radius:999px}.inbox-btn:hover{background:var(--panel-2)}.inbox-btn b{background:var(--partial);color:#151514;border-radius:999px;padding:0 6px;font-size:11px}
|
|
55
|
+
#viewBtn{position:relative}.dot-on{position:absolute;top:3px;right:3px;width:7px;height:7px;border-radius:50%;background:var(--accent,#ffe452);box-shadow:0 0 0 2px var(--panel)}
|
|
56
|
+
.pmenu{left:auto;right:0}.pmenu button{all:unset;cursor:pointer;padding:6px 8px;border-radius:6px;font-size:13px}.pmenu button:hover{background:var(--panel-2)}.pmenu-btn{all:unset;cursor:pointer;font-size:12px;color:var(--muted);padding:2px 6px;border-radius:999px;border:1px solid var(--line)}
|
|
57
|
+
.helppop{min-width:320px;font-size:13px}.helppop .kbd{margin-right:4px}.helppop ul{margin:0;padding:0 0 0 2px;list-style:none;display:grid;gap:4px}
|
|
58
|
+
.bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line-strong);position:relative;z-index:5}
|
|
59
|
+
.brand{display:flex;align-items:baseline;gap:10px;font-weight:700;letter-spacing:-.01em;font-size:15px}
|
|
60
|
+
.brand small{font-weight:500;color:var(--muted);font-size:13px;border-left:1px solid var(--line);padding-left:10px}
|
|
61
|
+
.task{display:flex;flex-direction:column;min-width:0}
|
|
62
|
+
.task b{font-size:14px;font-weight:600}
|
|
63
|
+
.task span{font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
64
|
+
.spacer{flex:1}
|
|
65
|
+
.seg{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:2px;background:var(--panel-2)}
|
|
66
|
+
.seg button{padding:3px 11px;border-radius:999px;font-size:12.5px;font-weight:500;color:var(--muted)}
|
|
67
|
+
.seg button[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
68
|
+
.seg-label{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-right:4px}
|
|
69
|
+
.ctl{display:flex;align-items:center;gap:6px}
|
|
70
|
+
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:5px 12px;font-size:12.5px;font-weight:500;background:var(--panel)}
|
|
71
|
+
.btn:hover{border-color:var(--ink)}
|
|
72
|
+
.btn--primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
|
73
|
+
.btn--primary:hover{border-color:var(--accent-ink)}
|
|
74
|
+
.icon-btn{width:28px;height:28px;display:inline-grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:var(--panel)}
|
|
75
|
+
.icon-btn:hover{border-color:var(--ink)}
|
|
76
|
+
svg.i{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
77
|
+
|
|
78
|
+
@media (min-width:821px){.bar{flex-wrap:nowrap}.picker{flex:0 1 auto;min-width:0}.task{min-width:0}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bar > *{flex-shrink:0}.bar > .picker{flex:0 1 auto;min-width:0;max-width:clamp(200px,30vw,640px);overflow:hidden}.bar > .spacer{flex:0 0 0}.task span{display:block;overflow:hidden;text-overflow:ellipsis}}
|
|
79
|
+
@media (max-width:1680px){.bar .ctl .seg-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.bar{gap:8px 12px}.picker kbd{display:none}}
|
|
80
|
+
@media (max-width:1400px) and (min-width:1101px){.bar{gap:6px 8px}#viewBtn span[data-t]{display:none}#viewBtn{padding:5px 8px}.bar > .picker{max-width:clamp(140px,18vw,400px)}#rpBtn span{display:none}#rpBtn{padding:5px 8px}#cmpSel{max-width:130px}.appnav a{padding:4px 6px}.brand small{display:none}.seg button{padding:3px 8px}.bar .btn,.seg button,.appnav{font-size:12px}}
|
|
81
|
+
.appnav{display:flex;align-items:center;gap:4px;font-size:13px}
|
|
82
|
+
.appnav a{padding:4px 9px;border-radius:6px;text-decoration:none;color:var(--muted)}
|
|
83
|
+
.appnav a:hover{background:var(--panel-2);color:var(--ink)}
|
|
84
|
+
.appnav .unread{font-size:10.5px;padding:0 5px;border-radius:999px;background:var(--accent);color:var(--accent-ink)}
|
|
85
|
+
.langsw{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:2px}
|
|
86
|
+
.langsw button{padding:2px 9px;border-radius:999px;font-size:12px;color:var(--muted)}
|
|
87
|
+
.langsw button[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
88
|
+
.picker{display:flex;align-items:center;gap:10px;text-align:left;min-width:0;padding:4px 10px 4px 6px;border-radius:8px;border:1px solid transparent;max-width:min(640px,100%)}
|
|
89
|
+
.picker:hover{border-color:var(--line);background:var(--panel-2)}
|
|
90
|
+
.picker .kchip{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:var(--accent);color:var(--accent-ink);flex:none}
|
|
91
|
+
.picker .kchip--flow{background:var(--ink);color:var(--panel)}
|
|
92
|
+
.picker kbd,.kbd{font-family:var(--mono);font-size:10.5px;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--muted);flex:none}
|
|
93
|
+
.tog{width:30px;height:30px;display:inline-grid;place-items:center;border-radius:6px;border:1px solid var(--line);background:var(--panel)}
|
|
94
|
+
.tog[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
95
|
+
.tog svg.i{width:16px;height:16px}
|
|
96
|
+
|
|
97
|
+
/* main: left outline · canvas · right summary */
|
|
98
|
+
.main{display:grid;grid-template-columns:var(--lw,300px) minmax(0,1fr) var(--rw,400px);min-height:0}
|
|
99
|
+
.main.no-left{--lw:0px}.main.no-right{--rw:0px}
|
|
100
|
+
.main.no-left .side,.main.no-right .rside{display:none}
|
|
101
|
+
.side{grid-column:1}.viewport{grid-column:2}.rside{grid-column:3}
|
|
102
|
+
.side{background:var(--panel);border-right:1px solid var(--line);overflow:auto;display:flex;flex-direction:column;min-width:0}
|
|
103
|
+
.side h2,.rside h2{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600;margin:0;padding:16px 16px 8px;display:flex;align-items:center;gap:8px}
|
|
104
|
+
.side h2 .btn{margin-left:auto;padding:2px 9px;font-size:11.5px;text-transform:none;letter-spacing:0}
|
|
105
|
+
.runcard{margin:0 12px;padding:12px;border:1px solid var(--line);border-radius:8px;display:flex;flex-direction:column;gap:8px}
|
|
106
|
+
.runcard__top{display:flex;align-items:center;gap:8px}
|
|
107
|
+
.runcard__when{font-weight:600;font-variant-numeric:tabular-nums}
|
|
108
|
+
.runcard__nav{margin-left:auto;display:flex;gap:4px}
|
|
109
|
+
.runcard__nav .icon-btn{width:24px;height:24px}
|
|
110
|
+
.runcard__nav .icon-btn:disabled{opacity:.35;cursor:default}
|
|
111
|
+
.runcard dl{display:grid;grid-template-columns:auto 1fr;gap:4px 10px;margin:0;font-size:12.5px}
|
|
112
|
+
.runcard dt{color:var(--muted)}
|
|
113
|
+
.runcard dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
|
114
|
+
.runcard .mono{font-family:var(--mono);font-size:11.5px}
|
|
115
|
+
.who{display:inline-flex;align-items:center;gap:6px}
|
|
116
|
+
.av{width:20px;height:20px;border-radius:50%;display:inline-grid;place-items:center;font-size:9.5px;font-weight:700;background:var(--panel-2);border:1px solid var(--line);flex:none}
|
|
117
|
+
a.pr{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:2px}
|
|
118
|
+
a.pr:hover{text-decoration-color:var(--ink)}
|
|
119
|
+
.prstate{font-size:10.5px;font-weight:600;padding:0 5px;border-radius:4px;background:var(--pass-bg);color:var(--pass);margin-left:4px}
|
|
120
|
+
.prstate--merged{background:var(--skip-bg);color:var(--muted)}
|
|
121
|
+
.run__env{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:1px 6px;border-radius:4px;background:var(--skip-bg);color:var(--muted);white-space:nowrap}
|
|
122
|
+
.run__env--staging{background:var(--partial-bg);color:var(--partial)}
|
|
123
|
+
.run__env--production{background:var(--fail-bg);color:var(--fail)}
|
|
124
|
+
.run__env--review{background:var(--pass-bg);color:var(--pass)}
|
|
125
|
+
.bar5{display:flex;height:5px;border-radius:3px;overflow:hidden;background:var(--skip-bg);min-width:60px}
|
|
126
|
+
.bar5 i{display:block}
|
|
127
|
+
.steps{list-style:none;margin:0;padding:0 8px 16px}
|
|
128
|
+
.steps button{display:flex;align-items:center;gap:9px;width:100%;text-align:left;padding:6px 8px;border-radius:6px;font-size:13px}
|
|
129
|
+
.steps button:hover{background:var(--panel-2)}
|
|
130
|
+
.steps .path{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;max-width:92px;overflow:hidden;text-overflow:ellipsis}
|
|
131
|
+
.dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--skip);display:inline-block}
|
|
132
|
+
.st-pass .dot,.dot.st-pass{background:var(--pass)}
|
|
133
|
+
.st-fail .dot,.dot.st-fail{background:var(--fail)}
|
|
134
|
+
.st-partial .dot,.dot.st-partial{background:var(--partial)}
|
|
135
|
+
.side__foot{margin-top:auto;padding:12px 16px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
|
|
136
|
+
.side__foot code{font-family:var(--mono);font-size:11px;color:var(--ink);overflow-wrap:anywhere}
|
|
137
|
+
|
|
138
|
+
/* right: summary */
|
|
139
|
+
.rside{background:var(--panel);border-left:1px solid var(--line);overflow:auto;display:flex;flex-direction:column;min-width:0}
|
|
140
|
+
.rside__head{padding:14px 16px 12px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:flex-start;position:sticky;top:0;background:var(--panel);z-index:1}
|
|
141
|
+
.rside__head h3{margin:0;font-size:16px;letter-spacing:-.01em}
|
|
142
|
+
.rside .sec{padding:14px 16px}
|
|
143
|
+
.verdict{font-size:24px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin:0;text-wrap:balance}
|
|
144
|
+
.verdict em{font-style:normal;color:var(--muted);font-weight:500;font-size:16px}
|
|
145
|
+
.stack{display:flex;height:10px;border-radius:5px;overflow:hidden;margin:10px 0 6px;background:var(--skip-bg)}
|
|
146
|
+
.stack i{display:block}
|
|
147
|
+
.stack-legend{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;color:var(--muted)}
|
|
148
|
+
.stack-legend span{display:inline-flex;align-items:center;gap:5px;font-variant-numeric:tabular-nums}
|
|
149
|
+
.matrix-wrap{overflow-x:auto}
|
|
150
|
+
table.matrix{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
151
|
+
.matrix th,.matrix td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left}
|
|
152
|
+
.matrix th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600}
|
|
153
|
+
.matrix td.c,.matrix th.c{text-align:center}
|
|
154
|
+
.matrix tr[data-id]{cursor:pointer}
|
|
155
|
+
.matrix tr[data-id]:hover td{background:var(--panel-2)}
|
|
156
|
+
.issues{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
|
|
157
|
+
.issues button{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;text-align:left;width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px}
|
|
158
|
+
.issues button:hover{border-color:var(--ink)}
|
|
159
|
+
.issues small{grid-column:2;color:var(--muted);font-size:12px;overflow-wrap:anywhere}
|
|
160
|
+
.share{display:flex;flex-direction:column;gap:8px}
|
|
161
|
+
.url{font-family:var(--mono);font-size:12px;padding:10px 12px;border:1px dashed var(--line-strong);border-radius:8px;overflow-wrap:anywhere;background:var(--panel-2);margin-bottom:10px}
|
|
162
|
+
/* a criterion to decide by hand: its text on its own line, reason and verdict below */
|
|
163
|
+
.mdec{margin:0 0 12px}.mdec__t{margin:0 0 6px;line-height:1.4}.mdec__t .id{margin-right:4px}
|
|
164
|
+
.mdec__row{display:flex;gap:6px;align-items:center}.mdec__row input{flex:1;min-width:0;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink)}.mdec__row .btn{flex:none}
|
|
165
|
+
.share__row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
166
|
+
select,input[type="search"]{font:inherit;font-size:12.5px;padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink)}
|
|
167
|
+
input[type="search"]:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--ink)}
|
|
168
|
+
.note{font-size:12px;color:var(--muted);margin:0}
|
|
169
|
+
.note code{font-family:var(--mono);font-size:11px}
|
|
170
|
+
|
|
171
|
+
/* semantic evaluation on node */
|
|
172
|
+
.jev{margin:8px 10px 0;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);font-size:11.5px;overflow:hidden}
|
|
173
|
+
.jev__head{display:flex;align-items:center;gap:8px;padding:5px 8px}
|
|
174
|
+
.jev__tag{font-family:var(--mono);font-weight:500;font-size:10.5px;letter-spacing:.04em;padding:0 5px;border-radius:3px;background:var(--ink);color:var(--panel)}
|
|
175
|
+
.jev__score{font-family:var(--mono);font-weight:500;font-variant-numeric:tabular-nums}
|
|
176
|
+
.jev__meter{flex:1;height:4px;border-radius:2px;background:var(--skip-bg);overflow:hidden}
|
|
177
|
+
.jev__meter i{display:block;height:100%}
|
|
178
|
+
.jev__c{padding:0 8px 6px;color:var(--ink);display:flex;flex-direction:column;gap:2px}
|
|
179
|
+
.jev__c span{display:grid;grid-template-columns:auto 1fr;gap:6px;line-height:1.35}
|
|
180
|
+
.jev__c b{font-family:var(--mono);font-weight:500;font-size:10.5px;color:var(--muted)}
|
|
181
|
+
.jev__c .ok{color:var(--muted)}
|
|
182
|
+
.jev pre{margin:0;border-top:1px solid var(--line);padding:7px 8px;font-family:var(--mono);font-size:10.5px;line-height:1.45;max-height:190px;overflow:auto;white-space:pre;background:var(--panel);cursor:text;width:338px}
|
|
183
|
+
.json .k{color:var(--muted)}.json .s{color:var(--pass)}.json .n{color:var(--partial)}
|
|
184
|
+
|
|
185
|
+
/* browser: scenarios and runs */
|
|
186
|
+
.br{position:fixed;inset:0;z-index:25;background:rgba(12,12,11,.5);display:flex;justify-content:center;align-items:flex-start;padding:clamp(0px,6vh,64px) clamp(0px,3vw,32px)}
|
|
187
|
+
.br__box{background:var(--panel);width:min(1180px,100%);height:min(760px,100%);border-radius:10px;overflow:hidden;display:grid;grid-template-columns:minmax(300px,420px) 1fr;grid-template-rows:auto 1fr;box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
|
188
|
+
.br__top{grid-column:1/-1;display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--line)}
|
|
189
|
+
.br__top input[type="search"]{flex:1;font-size:15px;padding:8px 12px;border-radius:8px;min-width:0}
|
|
190
|
+
.br__col{display:flex;flex-direction:column;min-height:0;min-width:0}
|
|
191
|
+
.br__col + .br__col{border-left:1px solid var(--line)}
|
|
192
|
+
.br__filters{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line)}
|
|
193
|
+
.br__filters .note{margin-left:auto}
|
|
194
|
+
.br__list{overflow:auto;flex:1}
|
|
195
|
+
.sc{display:grid;grid-template-columns:1fr auto;gap:3px 10px;padding:9px 14px;border-bottom:1px solid var(--line);text-align:left;width:100%}
|
|
196
|
+
.sc:hover{background:var(--panel-2)}
|
|
197
|
+
.sc[aria-selected="true"]{background:var(--panel-2);box-shadow:inset 3px 0 0 var(--accent)}
|
|
198
|
+
.sc__title{font-weight:600;font-size:13px;display:flex;gap:6px;align-items:baseline;min-width:0}
|
|
199
|
+
.sc__title span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
200
|
+
.sc__id{font-family:var(--mono);font-size:11px;color:var(--muted);font-weight:500;flex:none}
|
|
201
|
+
.sc__meta{grid-column:1;font-size:11.5px;color:var(--muted);display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
202
|
+
.sc .bar5{grid-row:1/3;grid-column:2;align-self:center;width:64px}
|
|
203
|
+
.kbadge{font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:0 5px;border-radius:3px;background:var(--accent);color:var(--accent-ink)}
|
|
204
|
+
.kbadge--flow{background:var(--ink);color:var(--panel)}
|
|
205
|
+
table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
206
|
+
.runs th{position:sticky;top:0;background:var(--panel);font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);z-index:1}
|
|
207
|
+
.runs td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap}
|
|
208
|
+
.runs tr.day td{background:var(--panel-2);font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);padding:5px 10px}
|
|
209
|
+
.runs tr[data-run]{cursor:pointer}
|
|
210
|
+
.runs tr[data-run]:hover td{background:var(--panel-2)}
|
|
211
|
+
.runs tr[aria-current="true"] td{background:var(--panel-2)}
|
|
212
|
+
.runs tr[aria-current="true"] td:first-child{box-shadow:inset 3px 0 0 var(--accent)}
|
|
213
|
+
.runs .mono{font-family:var(--mono);font-size:11.5px}
|
|
214
|
+
.runs .msg{color:var(--muted);max-width:220px;overflow:hidden;text-overflow:ellipsis}
|
|
215
|
+
.runs-wrap{overflow:auto;flex:1}
|
|
216
|
+
.more{display:block;margin:12px auto}
|
|
217
|
+
.empty{padding:24px 14px;color:var(--muted);font-size:13px}
|
|
218
|
+
|
|
219
|
+
.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:999px;font-size:13px;z-index:30;box-shadow:var(--shadow)}
|
|
220
|
+
|
|
221
|
+
@media (max-width:1100px){
|
|
222
|
+
.main{grid-template-columns:minmax(0,1fr)}
|
|
223
|
+
.viewport{grid-column:1}
|
|
224
|
+
.side,.rside{position:fixed;top:0;bottom:0;z-index:12;width:min(340px,88vw);box-shadow:0 0 40px rgba(0,0,0,.18)}
|
|
225
|
+
.side{left:0}.rside{right:0}
|
|
226
|
+
.task span{max-width:50vw}
|
|
227
|
+
}
|
|
228
|
+
@media (max-width:900px){
|
|
229
|
+
.lb__box{grid-template-columns:1fr;grid-template-rows:minmax(260px,48%) 1fr}
|
|
230
|
+
.lb__stage{border-right:0;border-bottom:1px solid var(--line)}
|
|
231
|
+
.legend,.hint{display:none}
|
|
232
|
+
.br__box{grid-template-columns:1fr;grid-template-rows:auto minmax(0,40%) 1fr}
|
|
233
|
+
.br__col + .br__col{border-left:0;border-top:1px solid var(--line)}
|
|
234
|
+
.runs .msg,.runs .hide-s{display:none}
|
|
235
|
+
}
|
|
236
|
+
@media (prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
|
|
237
|
+
body.embed .helpwrap{display:none}
|
|
238
|
+
.bar-break{display:none}
|
|
239
|
+
@media (max-width:820px){.bar{flex-wrap:wrap;gap:6px 8px;padding:8px 10px}.bar > *{order:1}.bar-break{display:block;order:2;flex-basis:100%;height:0}.bar > .picker,#rpBtn,#modeSeg,#locSeg,#dbar{order:3}.bar > .picker{flex:1 1 0;min-width:0;max-width:none}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}.bar .spacer{flex:1 1 auto;order:1}.picker .kchip{display:none}}
|
|
240
|
+
.legend-in{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}.legend-in span{display:inline-flex;gap:5px;align-items:center}
|
|
241
|
+
@media (max-width:1100px){.bar .ctl .seg-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}#viewBtn span[data-t],#rpBtn span{display:none}.picker kbd{display:none}.bar > .picker{max-width:clamp(140px,40vw,420px)}}
|
|
242
|
+
/* canvas */
|
|
243
|
+
.viewport{position:relative;overflow:hidden;cursor:grab;touch-action:none;
|
|
244
|
+
background-color:var(--ground);background-image:radial-gradient(var(--dots) 1px,transparent 1.2px);background-size:22px 22px}
|
|
245
|
+
.viewport.dragging{cursor:grabbing}
|
|
246
|
+
.world{position:absolute;left:0;top:0;transform-origin:0 0}
|
|
247
|
+
.edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
|
|
248
|
+
.edges path{fill:none;stroke:var(--edge);stroke-width:1.6}
|
|
249
|
+
.edges path.alt{stroke-dasharray:6 5;opacity:.7}
|
|
250
|
+
.edges path:not(.alt){stroke-width:2}
|
|
251
|
+
.edges.hl path{opacity:.12}.edges.hl path.on{opacity:1;stroke:var(--ink);stroke-width:2.6}
|
|
252
|
+
.lane-band{position:absolute;border-radius:14px;background:color-mix(in srgb,var(--panel) 38%,transparent);border:1px solid color-mix(in srgb,var(--line) 70%,transparent);pointer-events:none}
|
|
253
|
+
.lane-band:nth-child(even){background:color-mix(in srgb,var(--panel-2,var(--panel)) 55%,transparent)}
|
|
254
|
+
.lane-head{position:absolute;left:16px;top:-30px;font-size:14px;font-weight:700;color:var(--ink);white-space:nowrap;display:flex;gap:10px;align-items:baseline}
|
|
255
|
+
.lane-head small{font-size:12px;font-weight:500;color:var(--muted)}
|
|
256
|
+
.world.far .jev,.world.far .node__foot{visibility:hidden}
|
|
257
|
+
/* thumbnails stay while they are still recognisable; below that they would only be a few pixels to decode and paint */
|
|
258
|
+
.world.tiny .shots img{visibility:hidden}
|
|
259
|
+
.world.far .node{outline:6px solid transparent}.world.far .node.st-pass{outline-color:color-mix(in srgb,var(--pass) 55%,transparent)}.world.far .node.st-fail{outline-color:color-mix(in srgb,var(--fail) 55%,transparent)}.world.far .node.st-partial{outline-color:color-mix(in srgb,var(--partial) 55%,transparent)}
|
|
260
|
+
.world.far .lane-head{font-size:calc(14px * var(--inv,1));top:calc(-30px * var(--inv,1))}.world.far .lane-head small{font-size:calc(11px * var(--inv,1))}
|
|
261
|
+
.world.far .node.st-pass{background:color-mix(in srgb,var(--pass) 22%,var(--panel))}.world.far .node.st-fail{background:color-mix(in srgb,var(--fail) 28%,var(--panel))}.world.far .node.st-partial{background:color-mix(in srgb,var(--partial) 28%,var(--panel))}
|
|
262
|
+
.world.far .node__title{font-size:calc(12px * min(var(--inv,1), 2.4));line-height:1.1}
|
|
263
|
+
.steps h3[data-lane]{cursor:pointer}
|
|
264
|
+
.mail-old{color:var(--fail);font-weight:600;font-size:12px}
|
|
265
|
+
.presence{display:flex;align-items:center;gap:4px;margin-left:6px}
|
|
266
|
+
.presence .pav{width:26px;height:26px;border-radius:50%;border:2px solid var(--panel);background:var(--c);color:#fff;font-size:11px;font-weight:700;display:grid;place-items:center;cursor:pointer;padding:0;margin-left:-6px;box-shadow:0 0 0 1px var(--line)}
|
|
267
|
+
.presence .pav[aria-pressed="true"]{box-shadow:0 0 0 2px var(--ink)}
|
|
268
|
+
.presence .pvis{background:none;border:1px solid var(--line);border-radius:999px;font-size:11px;padding:2px 8px;margin-left:6px;cursor:pointer;color:var(--muted)}
|
|
269
|
+
.cur{position:absolute;transform:scale(var(--inv,1));transform-origin:0 0;transition:left .08s linear,top .08s linear}
|
|
270
|
+
.cur svg{width:18px;height:18px;display:block}.cur path{fill:var(--c);stroke:#fff;stroke-width:1.5}
|
|
271
|
+
.cur span{position:absolute;left:14px;top:14px;background:var(--c);color:#fff;font-size:11px;font-weight:600;padding:1px 6px;border-radius:6px;white-space:nowrap}
|
|
272
|
+
.hud.follow{top:52px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--panel);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:600;z-index:6}
|
|
273
|
+
.edges .arrow{fill:var(--edge);stroke:none}
|
|
274
|
+
.elabel{position:absolute;transform:translate(-50%,-50%);background:var(--ground);border:1px solid var(--line);color:var(--ink);font-size:11.5px;font-weight:500;padding:2px 8px;border-radius:999px;white-space:nowrap;pointer-events:none}
|
|
275
|
+
.lane-label{position:absolute;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600;white-space:nowrap}
|
|
276
|
+
|
|
277
|
+
.node{position:absolute;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow);cursor:pointer}.node--decision{cursor:default}
|
|
278
|
+
.node.is-focus{outline:3px solid var(--accent);outline-offset:3px}
|
|
279
|
+
.node--stack{width:300px;box-shadow:7px 7px 0 -1px var(--panel),7px 7px 0 0 var(--line),14px 14px 0 -1px var(--panel),14px 14px 0 0 var(--line),var(--shadow)}
|
|
280
|
+
.node--stack .kind{background:var(--ink);color:var(--panel)}
|
|
281
|
+
.stack-fan{position:relative;display:block;width:100%;height:162px;padding:0;border:0;background:none;cursor:pointer}
|
|
282
|
+
.stack-fan img,.stack-fan .ph{position:absolute;width:58%;height:128px;object-fit:cover;object-position:top;border:1px solid var(--line);border-radius:4px;background:var(--panel-2);box-shadow:0 2px 6px rgba(0,0,0,.12)}
|
|
283
|
+
.stack-fan :nth-child(1){left:5%;top:22px;transform:rotate(-5deg)}.stack-fan :nth-child(2){left:21%;top:12px;transform:rotate(-1deg)}.stack-fan :nth-child(3){left:37%;top:20px;transform:rotate(4deg)}
|
|
284
|
+
.stack-fan .count{position:absolute;right:10px;bottom:6px;background:var(--ink);color:var(--panel);font-weight:700;font-size:12px;border-radius:999px;padding:2px 9px}
|
|
285
|
+
.stack-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;margin:6px 10px 2px;background:var(--panel-2)}.stack-bar i{display:block}
|
|
286
|
+
.ri{grid-column:2;min-width:0;min-height:0;display:grid;background:var(--panel)}.ri[hidden]{display:none}
|
|
287
|
+
.ri .si__box{border-radius:0;box-shadow:none}
|
|
288
|
+
.ri-crumbs{display:flex;gap:6px;align-items:center;min-width:0;font-size:13px;color:var(--muted)}.ri-crumbs button{all:unset;cursor:pointer;color:var(--ink);text-decoration:underline;text-underline-offset:3px}.ri-crumbs b{color:var(--ink);font-size:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
289
|
+
.ri-group{position:absolute;left:0;right:0;height:36px;display:flex;align-items:center;gap:10px;padding:0 18px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);background:var(--panel-2);border-bottom:1px solid var(--line)}.ri-group small{text-transform:none;letter-spacing:0}
|
|
290
|
+
.ri-row,.ri-head{grid-template-columns:104px minmax(200px,2fr) 104px 64px minmax(120px,1.3fr) minmax(90px,.8fr)}
|
|
291
|
+
.ri-row.is-page{padding-left:42px}.ri-row.is-page .t b{font-weight:500}
|
|
292
|
+
.ri-row .t b .kind{display:inline-grid;vertical-align:-3px;margin-right:6px}
|
|
293
|
+
.ri-row .num{font-family:var(--mono,monospace);font-size:12.5px;text-align:right}
|
|
294
|
+
.ri-fan{position:relative;width:96px;height:58px}.ri-fan img,.ri-fan .ph{position:absolute;width:84px!important;height:50px!important}.ri-fan>:nth-child(1){left:0;top:8px;z-index:3}.ri-fan>:nth-child(2){left:6px;top:4px;z-index:2}.ri-fan>:nth-child(3){left:12px;top:0;z-index:1}
|
|
295
|
+
.ri-fan .cnt{position:absolute;right:0;bottom:0;z-index:4;font-size:10.5px;font-weight:700;background:var(--ink);color:var(--panel);border-radius:4px;padding:0 5px}
|
|
296
|
+
.ri-x{display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:12px;color:var(--muted)}
|
|
297
|
+
@media (max-width:760px){.ri-row,.ri-head,.si-row,.si-headrow{grid-template-columns:72px minmax(0,1fr) 92px!important}.ri-row>:nth-child(n+4),.ri-head>:nth-child(n+4),.si-row>:nth-child(n+4),.si-headrow>:nth-child(n+4){display:none}.ri-row,.si-row{grid-template-rows:auto auto;row-gap:2px}.ri-row>:first-child,.si-row>:first-child{grid-row:1/3}.ri-row>.t,.si-row>.t{grid-column:2;grid-row:1}.ri-row>.badge,.si-row>.badge{grid-column:3;grid-row:1/3}.ri-row>.si-fails,.si-row>.si-fails{display:flex!important;grid-column:2;grid-row:2;overflow:hidden;flex-wrap:nowrap}.ri-row img,.ri-row .ph,.si-row img,.si-row .ph{width:64px;height:40px}.ri-fan{width:64px;height:40px}}
|
|
298
|
+
.si{position:fixed;inset:0;z-index:19;background:rgba(12,12,11,.62);display:grid;padding:clamp(0px,2vw,24px)}
|
|
299
|
+
.si__box{background:var(--panel);border-radius:10px;display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
|
300
|
+
.si__head{display:flex;gap:12px;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line)}.si__head h3{margin:0;font-size:18px}
|
|
301
|
+
.si__tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 18px;border-bottom:1px solid var(--line)}
|
|
302
|
+
.si__tools input[type=search]{flex:1 1 240px;min-width:180px;padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font:inherit}
|
|
303
|
+
.si__tools select{padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font:inherit;max-width:280px}
|
|
304
|
+
.si-chip{border:1px solid var(--line);background:var(--panel);border-radius:999px;padding:4px 10px;font:inherit;font-size:13px;cursor:pointer;color:var(--ink)}.si-chip[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
305
|
+
.si__list{position:relative;overflow:auto;min-height:0}.si__spacer{position:relative}
|
|
306
|
+
.si-row{position:absolute;left:0;right:0;height:72px;display:grid;grid-template-columns:104px minmax(140px,2fr) 96px var(--si-cols,repeat(3,minmax(48px,1fr))) minmax(80px,1fr);gap:10px;align-items:center;padding:6px 18px;border:0;border-bottom:1px solid var(--line);background:var(--panel);text-align:left;font:inherit;color:var(--ink);cursor:pointer}
|
|
307
|
+
.si-row:hover,.si-row.is-sel{background:var(--panel-2)}.si-row img,.si-card img,.si-row .ph,.si-card .ph{width:96px;height:58px;object-fit:cover;object-position:top;border:1px solid var(--line);border-radius:3px;background:var(--panel-2)}
|
|
308
|
+
.si-row .t{min-width:0}.si-row .t b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.si-row .t small{display:block;color:var(--muted);font-family:var(--mono,monospace);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
309
|
+
.si-row .f{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)}.si-row .f.num{font-family:var(--mono,monospace);text-align:right}
|
|
310
|
+
.si-fails{display:flex;gap:4px;flex-wrap:wrap}.si-fails span{font-size:11px;font-weight:700;border-radius:4px;padding:1px 5px;background:color-mix(in srgb,var(--fail) 16%,transparent);color:var(--fail)}.si-fails i{font-style:normal;font-weight:500;opacity:.75;margin-left:4px}.si-fails span.rv{background:color-mix(in srgb,var(--partial) 16%,transparent);color:var(--partial)}
|
|
311
|
+
.si-headrow{display:grid;grid-template-columns:104px minmax(140px,2fr) 96px var(--si-cols,repeat(3,minmax(48px,1fr))) minmax(80px,1fr);gap:10px;padding:6px 18px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);border-bottom:1px solid var(--line);background:var(--panel-2)}.si-headrow button{all:unset;cursor:pointer}.si-headrow button.num{text-align:right}.si-headrow button[aria-sort]{color:var(--ink);font-weight:700}
|
|
312
|
+
.si-card{position:absolute;width:220px;height:198px;border:1px solid var(--line);border-radius:6px;background:var(--panel);padding:8px;text-align:left;font:inherit;color:var(--ink);cursor:pointer;display:grid;grid-template-rows:auto auto auto;gap:4px}
|
|
313
|
+
.si-card img,.si-card .ph{width:100%;height:120px}.si-card b{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.si-card small{color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
314
|
+
.si__foot{display:flex;gap:10px;align-items:center;padding:8px 18px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}
|
|
315
|
+
.si__empty{padding:40px;text-align:center;color:var(--muted)}
|
|
316
|
+
.node__head{display:flex;align-items:flex-start;gap:8px;padding:9px 10px 8px}
|
|
317
|
+
.kind{width:22px;height:22px;display:grid;place-items:center;border-radius:5px;background:var(--panel-2);color:var(--muted);flex:none}
|
|
318
|
+
.node--email .kind{background:var(--accent);color:var(--accent-ink)}
|
|
319
|
+
.node--external .kind{background:var(--ink);color:var(--panel)}
|
|
320
|
+
.node__t{min-width:0;flex:1;contain:inline-size}
|
|
321
|
+
.node__title{font-weight:600;font-size:13px;line-height:1.3;text-wrap:balance}
|
|
322
|
+
.http{font-family:var(--mono);font-size:10.5px;font-weight:600;padding:1px 5px;border-radius:4px;background:var(--ink);color:var(--panel);margin-right:5px;vertical-align:1px}.http--5{background:var(--fail)}
|
|
323
|
+
.node__path{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
324
|
+
.badge{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;font-weight:600;padding:2px 7px;border-radius:999px;white-space:nowrap;font-variant-numeric:tabular-nums;flex:none}
|
|
325
|
+
.badge.st-pass{background:var(--pass-bg);color:var(--pass)}
|
|
326
|
+
.badge.st-fail{background:var(--fail-bg);color:var(--fail)}
|
|
327
|
+
.badge.st-partial{background:var(--partial-bg);color:var(--partial)}
|
|
328
|
+
.badge.st-skip{background:var(--skip-bg);color:var(--skip)}
|
|
329
|
+
/* the status stripe is the card's own top border, so it follows the rounded corners */
|
|
330
|
+
.node::before{content:"";position:absolute;inset:-1px;border-radius:inherit;border-top:3px solid var(--skip);pointer-events:none}
|
|
331
|
+
.node.st-pass::before{border-top-color:var(--pass)}.node.st-fail::before{border-top-color:var(--fail)}.node.st-partial::before{border-top-color:var(--partial)}
|
|
332
|
+
.shots{display:flex;gap:10px;padding:0 10px;align-items:flex-start;cursor:zoom-in;text-align:left}
|
|
333
|
+
.shot{position:relative;overflow:hidden;background:#fff;border:1px solid var(--line);border-radius:4px;flex:none}
|
|
334
|
+
.shot--d{width:256px;height:160px}
|
|
335
|
+
.shot--m{width:74px;height:160px;border-radius:9px}
|
|
336
|
+
/* phones only: the phone screenshot is the card, large enough to read and to keep titles on few lines */
|
|
337
|
+
.shots--mbig .shot--m{width:180px;height:390px;border-radius:14px}.node:has(.shots--mbig){min-width:220px}
|
|
338
|
+
.shot__in{position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none}
|
|
339
|
+
.shot__tag{position:absolute;right:4px;bottom:4px;font-size:9.5px;font-family:var(--mono);background:rgba(21,21,20,.72);color:#fff;padding:0 4px;border-radius:3px}
|
|
340
|
+
.shots:hover .shot{border-color:var(--ink)}
|
|
341
|
+
.node__foot{display:flex;align-items:center;gap:8px;padding:8px 10px 9px;color:var(--muted);font-size:11.5px}
|
|
342
|
+
.att{display:inline-flex;align-items:center;gap:3px}
|
|
343
|
+
.delta{margin-left:auto;font-size:11px;font-weight:600;padding:1px 6px;border-radius:4px;background:var(--accent);color:var(--accent-ink)}
|
|
344
|
+
.loc-dots{display:inline-flex;gap:3px;align-items:center}
|
|
345
|
+
.loc-dots span{font-family:var(--mono);font-size:10px;padding:0 3px;border-radius:3px;border:1px solid var(--line)}
|
|
346
|
+
.loc-dots span.st-fail{border-color:var(--fail);color:var(--fail)}
|
|
347
|
+
.loc-dots span.st-partial{border-color:var(--partial);color:var(--partial)}
|
|
348
|
+
|
|
349
|
+
.node--decision{display:flex;align-items:center;gap:10px;padding:12px 14px;width:210px;border-style:dashed;border-color:var(--edge);background:var(--panel-2);box-shadow:none}
|
|
350
|
+
.node--decision::before{display:none}
|
|
351
|
+
.diamond{width:18px;height:18px;transform:rotate(45deg);border:2px solid var(--ink);border-radius:2px;flex:none;background:var(--accent);margin:0 3px}
|
|
352
|
+
.node--decision .node__title{font-weight:600}
|
|
353
|
+
|
|
354
|
+
.hud{position:absolute;display:flex;gap:6px;align-items:center}
|
|
355
|
+
.hud--zoom{right:14px;bottom:14px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:4px 6px;box-shadow:var(--shadow)}
|
|
356
|
+
.hud--zoom output{font-size:12px;font-variant-numeric:tabular-nums;min-width:40px;text-align:center}
|
|
357
|
+
.hud--zoom .icon-btn{border:0}
|
|
358
|
+
.legend{left:14px;bottom:14px;flex-wrap:wrap;gap:6px 12px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:11.5px;color:var(--muted);box-shadow:var(--shadow);max-width:calc(100% - 200px)}
|
|
359
|
+
.legend span{display:inline-flex;align-items:center;gap:5px}
|
|
360
|
+
.hint{left:50%;top:12px;transform:translateX(-50%);font-size:12px;color:var(--muted);background:var(--panel);border:1px solid var(--line);padding:3px 10px;border-radius:999px;white-space:nowrap}
|
|
361
|
+
|
|
362
|
+
/* lightbox */
|
|
363
|
+
.lb{position:fixed;inset:0;z-index:20;background:rgba(12,12,11,.62);display:grid;place-items:stretch;padding:clamp(0px,2vw,24px)}
|
|
364
|
+
.lb__box{background:var(--panel);display:grid;grid-template-columns:1fr minmax(340px,440px);min-height:0;border-radius:10px;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
|
365
|
+
.lb__stage{display:flex;flex-direction:column;min-height:0;background:var(--panel-2);border-right:1px solid var(--line)}
|
|
366
|
+
.lb__tools{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
367
|
+
.lb__scroll{overflow:auto;flex:1;padding:20px}
|
|
368
|
+
.lb__frame{margin:0 auto;background:#fff;box-shadow:var(--shadow);position:relative;overflow:hidden}
|
|
369
|
+
.lb__frame--m{border-radius:18px;border:8px solid #151514}
|
|
370
|
+
.lb__frame .shot__in{position:relative}
|
|
371
|
+
.lb__side{overflow:auto;display:flex;flex-direction:column}
|
|
372
|
+
.lb__head{padding:16px 18px 12px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px;position:sticky;top:0;background:var(--panel);z-index:1}
|
|
373
|
+
.lb__head h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;text-wrap:balance}
|
|
374
|
+
.lb__row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
375
|
+
.lb__nav{display:flex;gap:6px;margin-left:auto}
|
|
376
|
+
.sec{padding:14px 18px;border-bottom:1px solid var(--line)}
|
|
377
|
+
.sec h4{margin:0 0 8px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:6px}
|
|
378
|
+
.purpose{margin:0;max-width:62ch}
|
|
379
|
+
.sigb{font-size:11px;font-weight:700;color:var(--partial);background:var(--partial-bg);border-radius:4px;padding:0 5px}
|
|
380
|
+
.sigs{list-style:none;margin:0;padding:0;display:grid;gap:6px;font-size:12.5px}.sigs li{display:grid;grid-template-columns:1fr 90px 64px;gap:8px;align-items:center}.sigs li.bad{color:var(--partial);font-weight:600}.gauge--sig{background:var(--skip-bg)}
|
|
381
|
+
.fixhelp{grid-column:2;font-size:12.5px;margin-top:4px;padding:6px 8px;border-radius:6px;background:var(--panel-2)}.fixhelp p{margin:0 0 3px}.fixhelp p:last-child{margin:0}details.fixhelp{background:none;padding:0}details.fixhelp summary{cursor:pointer;color:var(--muted);font-size:11.5px}
|
|
382
|
+
.ev--text{display:block;grid-column:2;font-size:12.5px;padding:4px 8px;border-radius:6px;background:var(--panel-2)}
|
|
383
|
+
.lbtabs{display:flex;gap:2px;padding:6px 16px 0;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--panel);z-index:2}.lbtabs button{padding:8px 10px;font-size:12.5px;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px}.lbtabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--ink)}
|
|
384
|
+
.account{font-size:11px;font-weight:600;letter-spacing:0;text-transform:none;color:var(--muted);margin-left:6px}.st-fail-t{color:var(--fail)}
|
|
385
|
+
.ac li .acbody{grid-column:2;display:grid;gap:4px}.where--go{justify-self:start;text-decoration:underline;color:var(--ink)}.ac li.st-pass:not(.is-on) .acbody{display:none}.ac li.st-pass{padding:5px 8px}.ac li.st-pass:not(.is-on){cursor:pointer}
|
|
386
|
+
.clog{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:6px;max-height:420px;overflow:auto}.clog li{position:relative;background:#151514;color:#e8e8e2;border-radius:6px}.clog pre{margin:0;padding:8px 34px 8px 10px;font:12px/1.5 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto}
|
|
387
|
+
.clog__copy{position:absolute;top:4px;right:4px;width:24px;height:24px;display:grid;place-items:center;border-radius:4px;color:#bdbdb6;opacity:.7}.clog__copy:hover{opacity:1;background:rgba(255,255,255,.1)}.clog__all{float:right;font-size:11.5px;padding:2px 8px;text-transform:none;letter-spacing:0}
|
|
388
|
+
.k-err{color:#ff7a85;font-weight:600}.k-url{color:#8ab4ff}.k-pos{color:#ffd166}.k-str{color:#9ee493}.k-at{color:#8d8d86}.k-num{color:#ffd166}
|
|
389
|
+
.vhead{display:flex;align-items:center;gap:8px}.vhead .badge{text-transform:none;letter-spacing:0}.vmore{display:flex;flex-wrap:wrap;gap:4px 16px;margin-top:10px;font-size:12.5px}.vmore .linkish{color:var(--muted)}.vmore .linkish:hover{color:var(--ink)}
|
|
390
|
+
/* how a criterion was checked: kind, meaning, AI score between the thresholds */
|
|
391
|
+
.how{grid-column:2;display:grid;gap:4px;margin-top:4px;font-size:12.5px}.how__kind{justify-self:start;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:1px 7px}.how--ai{color:var(--ink);border-color:var(--ink)}.how__q{color:var(--ink)}.how__line{color:var(--muted)}
|
|
392
|
+
.gauge{position:relative;display:block;height:8px;border-radius:4px;background:var(--skip-bg);overflow:visible;margin:2px 0}.gauge__lo{position:absolute;left:0;top:0;bottom:0;background:var(--fail-bg);border-radius:4px 0 0 4px}.gauge__hi{position:absolute;right:0;top:0;bottom:0;background:var(--pass-bg);border-radius:0 4px 4px 0}.gauge b{position:absolute;top:-3px;width:3px;height:14px;margin-left:-1.5px;background:var(--ink);border-radius:2px}
|
|
393
|
+
.ac li .tech{grid-column:2;font-size:11.5px}.ac li .tech summary{cursor:pointer;color:var(--muted)}
|
|
394
|
+
.ac{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
|
|
395
|
+
.ac li{display:grid;grid-template-columns:22px 1fr;gap:2px 8px;padding:8px;border-radius:6px;border:1px solid var(--line)}
|
|
396
|
+
.ac li:hover{border-color:var(--ink)}
|
|
397
|
+
.ac .mark{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:12px;font-weight:700}
|
|
398
|
+
.ac .st-pass .mark,.ac li.st-pass .mark{background:var(--pass-bg);color:var(--pass)}
|
|
399
|
+
.ac li.st-fail .mark{background:var(--fail-bg);color:var(--fail)}
|
|
400
|
+
.ac li.st-partial .mark{background:var(--partial-bg);color:var(--partial)}
|
|
401
|
+
.ac li.st-skip .mark{background:var(--skip-bg);color:var(--skip)}
|
|
402
|
+
.ac .id{font-family:var(--mono);font-size:11px;color:var(--muted);margin-right:4px}
|
|
403
|
+
.ac .ev{grid-column:2;font-family:var(--mono);font-size:11px;color:var(--muted);background:var(--panel-2);padding:4px 6px;border-radius:4px;overflow-wrap:anywhere}
|
|
404
|
+
.files{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
|
|
405
|
+
.file{border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;flex-direction:column;text-align:left;background:var(--panel)}
|
|
406
|
+
.file:hover{border-color:var(--ink)}
|
|
407
|
+
.file__prev{height:74px;background:var(--panel-2);position:relative;overflow:hidden;display:grid;place-items:center;color:var(--muted)}
|
|
408
|
+
.file__name{font-size:11.5px;padding:5px 7px;display:flex;gap:5px;align-items:center;line-height:1.25}
|
|
409
|
+
pre.json{margin:0;font-family:var(--mono);font-size:11.5px;line-height:1.5;background:var(--panel-2);border-radius:6px;padding:10px;overflow:auto;max-height:280px;white-space:pre}
|
|
410
|
+
.json .k{color:var(--muted)}.json .s{color:var(--pass)}.json .n{color:var(--partial)}
|
|
411
|
+
details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
412
|
+
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:12.5px;margin:0}
|
|
413
|
+
.kv dt{color:var(--muted)}.kv dd{margin:0;font-family:var(--mono);font-size:11.5px;overflow-wrap:anywhere}
|
|
414
|
+
[data-ac].hl{outline:3px solid #ffd200;outline-offset:2px;background-color:rgba(255,228,82,.25)!important}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
/* viewer additions: real screenshots */
|
|
418
|
+
.shot img{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover;object-position:top center;display:block}
|
|
419
|
+
.shot--empty{display:grid;place-items:center;color:var(--muted);font-size:11px;background:var(--panel-2)}
|
|
420
|
+
.lb__img{position:relative;margin:0 auto;background:#fff;box-shadow:var(--shadow)}
|
|
421
|
+
.lb__img img{display:block;width:100%;height:auto}
|
|
422
|
+
.lb__img--m{border-radius:18px;border:8px solid #151514;overflow:hidden}
|
|
423
|
+
.lb__img--dev{border:10px solid #151514;border-radius:38px;overflow:hidden;box-shadow:0 0 0 2px #3a3a37,var(--shadow)}.lb__img--android{border-radius:26px}
|
|
424
|
+
.lb__win{margin:0 auto;border-radius:10px;overflow:hidden;box-shadow:0 0 0 1px var(--line),var(--shadow);background:#fff}.lb__win .lb__img{box-shadow:none}
|
|
425
|
+
.winbar{display:flex;align-items:center;gap:7px;padding:8px 12px;background:var(--panel-2);border-bottom:1px solid var(--line);font-size:12px;color:var(--muted)}.winbar i{width:11px;height:11px;border-radius:50%;background:#ff5f57}.winbar i:nth-child(2){background:#febc2e}.winbar i:nth-child(3){background:#28c840}.winbar span{margin-left:8px}
|
|
426
|
+
.tree{font-family:var(--mono,monospace);font-size:12px;max-height:340px;overflow:auto;border:1px solid var(--line);border-radius:6px;padding:6px 0;margin-top:8px}
|
|
427
|
+
.tree button{all:unset;display:block;width:100%;box-sizing:border-box;padding:1px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.tree button:hover,.tree button.on{background:var(--panel-2)}.tree .r{color:var(--muted)}.tree .id{color:var(--muted);opacity:.8}
|
|
428
|
+
.tree-q{width:100%;box-sizing:border-box;padding:6px 9px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font:inherit;margin-top:6px}
|
|
429
|
+
#treeSec summary{cursor:pointer;list-style:none}#treeSec summary h4{display:inline}
|
|
430
|
+
.hlbox{position:absolute;outline:3px solid #ffd200;background:rgba(255,228,82,.22);pointer-events:none;border-radius:2px}
|
|
431
|
+
.hlbox[hidden]{display:none}
|
|
432
|
+
.hlbox.on{outline:3px solid #ffd200;background:transparent}
|
|
433
|
+
.hlbox.on.first{box-shadow:0 0 0 9999px rgba(15,15,14,.42)}
|
|
434
|
+
.elabel{z-index:4;white-space:normal;max-width:180px;text-align:center;line-height:1.25;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
435
|
+
.ac li{cursor:pointer}
|
|
436
|
+
.ac li.is-on{border-color:var(--ink);box-shadow:inset 3px 0 0 #ffd200}
|
|
437
|
+
.ac .where{grid-column:2;display:flex;gap:6px;align-items:center;font-size:11px;color:var(--muted)}
|
|
438
|
+
.ac .where svg.i{width:12px;height:12px}
|
|
439
|
+
.md details{border:1px solid var(--line);border-radius:6px;margin-bottom:6px;background:var(--panel)}
|
|
440
|
+
.md summary{padding:7px 10px;display:flex;gap:8px;align-items:center;font-size:12.5px;font-weight:600;list-style:none}
|
|
441
|
+
.md summary::-webkit-details-marker{display:none}
|
|
442
|
+
.md summary::before{content:"▸";color:var(--muted);font-size:10px}
|
|
443
|
+
.md details[open] > summary::before{content:"▾"}
|
|
444
|
+
.md summary .cnt{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted);font-weight:400}
|
|
445
|
+
.md table{width:100%;border-collapse:collapse;font-size:11.5px}
|
|
446
|
+
.md td{padding:5px 10px;border-top:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
|
|
447
|
+
.md td:first-child{font-family:var(--mono);color:var(--muted);width:34%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0}
|
|
448
|
+
.md tr.flash td{animation:mdflash 1.6s ease-out}
|
|
449
|
+
@keyframes mdflash{0%{background:rgba(255,228,82,.7)}100%{background:transparent}}
|
|
450
|
+
.md tr.st-fail td{background:var(--fail-bg)}
|
|
451
|
+
.md tr.st-pass td:first-child{color:var(--pass)}
|
|
452
|
+
.md .acb{display:inline-block;font-family:var(--mono);font-size:10px;padding:0 4px;border-radius:3px;margin-left:6px;vertical-align:1px}
|
|
453
|
+
.md .acb.st-pass{background:var(--pass-bg);color:var(--pass)}.md .acb.st-fail{background:var(--fail-bg);color:var(--fail)}.md .acb.st-partial{background:var(--partial-bg);color:var(--partial)}
|
|
454
|
+
.md pre{margin:0;padding:8px 10px;border-top:1px solid var(--line);font-family:var(--mono);font-size:11px;max-height:260px;overflow:auto;background:var(--panel-2)}
|
|
455
|
+
.md .h{display:block;font-size:12px;padding:3px 10px;border-top:1px solid var(--line)}
|
|
456
|
+
.md .h i{font-style:normal;font-family:var(--mono);color:var(--muted);font-size:10.5px;margin-right:6px}
|
|
457
|
+
.lb__empty{padding:40px;text-align:center;color:var(--muted)}
|
|
458
|
+
.mailmeta{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:12.5px;margin:0}
|
|
459
|
+
.mailmeta dt{color:var(--muted)}.mailmeta dd{margin:0;overflow-wrap:anywhere}
|
|
460
|
+
.instr{white-space:pre-wrap;font-size:12.5px;margin:0;color:var(--ink)}
|
|
461
|
+
.strategy{font-family:var(--mono);font-size:10.5px;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted)}
|
|
462
|
+
.empty-canvas{position:absolute;inset:0;display:grid;place-items:center;color:var(--muted)}
|
|
463
|
+
/* compare mode */
|
|
464
|
+
#cmpSel{max-width:220px}
|
|
465
|
+
.pxd{font-family:var(--mono);font-size:10.5px;font-weight:500;padding:1px 5px;border-radius:4px;background:var(--skip-bg);color:var(--muted);white-space:nowrap}
|
|
466
|
+
.pxd.lv1{background:var(--partial-bg);color:var(--partial)}.pxd.lv2{background:var(--fail-bg);color:var(--fail)}.pxd.lv0{background:var(--pass-bg);color:var(--pass)}
|
|
467
|
+
.cmpd{font-size:11px;font-weight:600;padding:1px 6px;border-radius:4px;background:var(--fail-bg);color:var(--fail)}
|
|
468
|
+
.lb__cmp{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
|
|
469
|
+
.lb__cmp figure{margin:0;min-width:0}
|
|
470
|
+
.lb__cmp figcaption{font-size:11.5px;color:var(--muted);margin:0 0 6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
471
|
+
.lb__cmp figcaption b{color:var(--ink);font-weight:600}
|
|
472
|
+
.lb__cmp .lb__img{max-width:100%}
|
|
473
|
+
.lb__diff{margin:0 auto;background:#fff;box-shadow:var(--shadow);position:relative}
|
|
474
|
+
.lb__diff canvas{display:block;max-width:100%;height:auto}
|
|
475
|
+
.ac .ev--cmp{border-left:3px solid var(--partial)}
|
|
476
|
+
.ac li.cmp-changed{border-color:var(--partial)}
|
|
477
|
+
.ac .dl{font-family:var(--mono);font-size:10px;padding:0 4px;border-radius:3px;background:var(--partial-bg);color:var(--partial);margin-left:4px}
|
|
478
|
+
@media (max-width:900px){.lb__cmp{grid-template-columns:1fr}}
|
|
479
|
+
/* sessions: people in the flow */
|
|
480
|
+
.sess{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:600;padding:1px 7px;border-radius:999px;border:1px solid var(--line);color:var(--ink);white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
|
|
481
|
+
.sess i{width:8px;height:8px;border-radius:50%;flex:none;background:var(--sc,var(--skip))}
|
|
482
|
+
.node .sess{display:flex;width:fit-content;margin:11px 10px 0}.node .sess+.node__head{padding-top:6px}
|
|
483
|
+
.node.dim{opacity:.28}
|
|
484
|
+
.edges path.xs{stroke-dasharray:3 4;stroke:var(--accent-ink);stroke-width:1.4}
|
|
485
|
+
.steps h3{margin:10px 8px 4px;font-size:11.5px;font-weight:600;display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 8px;border-radius:6px}
|
|
486
|
+
.steps h3:hover{background:var(--panel-2)}.steps h3[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
487
|
+
.steps h3 .bar5{margin-left:auto;min-width:48px}
|
|
488
|
+
.sesslegend{display:flex;flex-direction:column;gap:6px}.sesslegend button{display:flex;align-items:center;gap:8px;text-align:left;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font-size:12.5px}.sesslegend button[aria-pressed="true"]{border-color:var(--ink);background:var(--panel-2)}.sesslegend .bar5{margin-left:auto;width:64px}
|
|
489
|
+
/* minimap */
|
|
490
|
+
.mini{position:absolute;right:14px;bottom:62px;width:180px;height:110px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow);overflow:hidden;cursor:pointer;opacity:.92}
|
|
491
|
+
.mini:hover{opacity:1}
|
|
492
|
+
.mini svg{display:block;width:100%;height:100%}
|
|
493
|
+
.mini rect.n{fill:var(--skip);rx:1}.mini rect.n.st-pass{fill:var(--pass)}.mini rect.n.st-fail{fill:var(--fail)}.mini rect.n.st-partial{fill:var(--partial)}.mini rect.n.dec{fill:var(--accent)}
|
|
494
|
+
.mini rect.view{fill:rgba(21,21,20,.06);stroke:var(--ink);stroke-width:1.2;rx:2}
|
|
495
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .mini rect.view{fill:rgba(240,240,236,.08)}}
|
|
496
|
+
@media (max-width:1100px){.mini{width:120px;height:72px;right:10px;bottom:58px}}
|
|
497
|
+
/* replay: guided walkthrough */
|
|
498
|
+
.rp{position:fixed;inset:0;z-index:22;background:var(--ground);display:grid;grid-template-rows:auto 1fr auto}
|
|
499
|
+
.rp__top{display:flex;align-items:center;gap:10px 14px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line-strong)}
|
|
500
|
+
.rp__top .crumbs{display:flex;align-items:center;gap:4px;flex-wrap:wrap;min-width:0;font-size:12.5px}
|
|
501
|
+
.rp__top .crumbs button{padding:2px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted);background:var(--panel)}
|
|
502
|
+
.rp__top .crumbs button[aria-current="step"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
503
|
+
.rp__top .crumbs i{color:var(--muted);font-style:normal}
|
|
504
|
+
.rp__body{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,420px);min-height:0}
|
|
505
|
+
.rp__stage{overflow:auto;padding:24px;display:flex;justify-content:center;align-items:flex-start;background:var(--panel-2)}
|
|
506
|
+
.rp__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
|
|
507
|
+
.rp__foot{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-top:1px solid var(--line)}
|
|
508
|
+
.rp__foot .spacer{flex:1}
|
|
509
|
+
.rp__alts{display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:12.5px;color:var(--muted)}
|
|
510
|
+
.rp__decision{width:min(640px,100%);margin:6vh auto 0;text-align:center}
|
|
511
|
+
.rp__decision .diamond{width:34px;height:34px;margin:0 auto 18px}
|
|
512
|
+
.rp__decision h3{font-size:24px;letter-spacing:-.02em;margin:0 0 20px;text-wrap:balance}
|
|
513
|
+
.rp__opts{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
|
514
|
+
.rp__opts button{display:grid;gap:4px;text-align:left;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}
|
|
515
|
+
.rp__opts button:hover{border-color:var(--ink)}
|
|
516
|
+
.rp__opts button b{font-size:15px}.rp__opts button small{color:var(--muted)}
|
|
517
|
+
.rp__opts .kbd{justify-self:start}
|
|
518
|
+
.rp__end{text-align:center;color:var(--muted);padding:40px}
|
|
519
|
+
@media (max-width:900px){.rp__body{grid-template-columns:1fr;grid-template-rows:minmax(240px,50%) 1fr}.rp__side{border-left:0;border-top:1px solid var(--line)}}
|
|
520
|
+
|
|
521
|
+
/* ---------- design: drafts, versions, live steps, multiplayer (#42) ---------- */
|
|
522
|
+
.dbar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-width:0}
|
|
523
|
+
.dbar .vchip{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border:1px solid var(--line);border-radius:999px;font:500 12px/1 var(--mono);color:var(--ink);text-decoration:none;white-space:nowrap;background:var(--panel)}
|
|
524
|
+
.dbar .vchip[aria-current="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
525
|
+
.dbar .vchip .ref{color:#e4a400}
|
|
526
|
+
.dbar .vchip--draft{border-style:dashed}
|
|
527
|
+
#cursors{position:absolute;left:0;top:0;pointer-events:none}
|
|
528
|
+
.node.is-same{opacity:.38}
|
|
529
|
+
.node .stchip{position:absolute;right:8px;top:8px;font:600 10px/1 system-ui,sans-serif;padding:3px 6px;border-radius:999px;background:var(--panel-2);border:1px solid var(--line)}
|
|
530
|
+
.node .stchip--ready_for_dev{background:#e6f4ea;border-color:#76b041;color:#2d6a1f}
|
|
531
|
+
.node .stchip--ready_for_review{background:#fff6e0;border-color:#e4a400;color:#7a5800}
|
|
532
|
+
.presence .pav--agent{border-radius:7px;cursor:default}
|
|
533
|
+
.node .editing{position:absolute;left:8px;bottom:8px;font:600 10px/1 system-ui,sans-serif;color:#fff;padding:3px 6px;border-radius:999px}
|
|
534
|
+
.lb__stage{min-width:0}.lb__live{margin:0 auto;background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
535
|
+
.lb__live iframe{display:block;border:0;background:#fff}
|
|
536
|
+
.lb__cmp .lb__live{margin:0}
|
|
537
|
+
.insp{font:12px/1.4 var(--mono)}
|
|
538
|
+
.insp table{width:100%;border-collapse:collapse}.insp td{padding:3px 4px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
539
|
+
.insp td:first-child{color:var(--muted);white-space:nowrap}
|
|
540
|
+
.insp .tok{color:#2e6bff}.insp .raw{color:#c73e1d}
|
|
541
|
+
.c-sec h4{display:flex;align-items:center;gap:6px}.c-count{display:inline-block;min-width:18px;padding:0 6px;border-radius:999px;background:var(--partial);color:#151514;font:700 11px/18px system-ui,sans-serif;text-align:center}
|
|
542
|
+
.c-toggle{all:unset;margin-left:auto;font-size:12px;color:var(--muted);cursor:pointer;text-decoration:underline;text-underline-offset:3px;text-transform:none;letter-spacing:0;font-weight:500}
|
|
543
|
+
.c-list,.c-replies{list-style:none;margin:0 0 10px;padding:0}.c-thread{border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin:0 0 8px;background:var(--panel)}.c-thread.is-done{opacity:.7}.c-thread.is-focus{outline:2px solid var(--accent,#ffe452);outline-offset:1px}
|
|
544
|
+
.c-replies{margin:8px 0 0 14px;border-left:2px solid var(--line);padding-left:10px}.c-replies li{margin:0 0 6px}
|
|
545
|
+
.c-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px}.c-meta .av{width:20px;height:20px;font-size:9px}.c-who{font-weight:600}.c-agent{font:600 10px/16px system-ui,sans-serif;padding:0 5px;border-radius:4px;background:var(--panel-2);color:var(--muted)}
|
|
546
|
+
.c-note{margin:4px 0 2px;font-size:13.5px;overflow-wrap:anywhere}.c-at{color:var(--ink);background:color-mix(in srgb,var(--accent,#ffe452) 45%,transparent);border-radius:3px;padding:0 2px;font-weight:600}
|
|
547
|
+
.c-acts{display:flex;gap:10px;font-size:12px}.c-acts button,.linkish{all:unset;cursor:pointer;color:var(--muted);text-decoration:underline;text-underline-offset:3px}.c-acts button:hover,.linkish:hover{color:var(--ink)}
|
|
548
|
+
.c-pin{all:unset;cursor:pointer;min-width:18px;height:18px;padding:0 4px;border-radius:9px 9px 9px 2px;background:#e4572e;color:#fff;font:600 10.5px/18px system-ui,sans-serif;text-align:center}
|
|
549
|
+
.c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
|
|
550
|
+
.c-hint{margin-left:auto;font-size:11.5px}.c-pinrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}.c-pinned{display:inline-flex;gap:5px;align-items:center}.c-pinbtn[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
551
|
+
.c-tabs{margin:0 0 10px}.cmt{display:inline-flex;align-items:center;gap:3px;font:600 11px/1 system-ui,sans-serif;color:var(--ink);padding:2px 6px;border-radius:999px;background:color-mix(in srgb,var(--partial) 22%,transparent)}.cmt .i{width:12px;height:12px}
|
|
552
|
+
.lb__img.is-pinning,.lb__img.is-pinning img{cursor:crosshair}.pinmark.pin-xy{border:0;cursor:pointer;transform:translate(-2px,-100%)}.pinmark.pin-xy.is-done{background:#8a8a84}.pinmark.pin-xy.is-new{background:var(--ink)}.pinmark.flash{animation:pinflash 1s 2}
|
|
553
|
+
@keyframes pinflash{50%{transform:translate(-2px,-100%) scale(1.5)}}
|
|
554
|
+
.pinmark{position:absolute;min-width:20px;height:20px;padding:0 5px;border-radius:10px 10px 10px 2px;background:#e4572e;color:#fff;font:600 11px/20px system-ui,sans-serif;text-align:center;transform:translate(-4px,-16px);z-index:5}
|
|
555
|
+
.anchor-note{font:12px/1.4 var(--mono);color:var(--muted);margin:2px 0 6px}
|
|
556
|
+
/* phones: the lightbox stacks stage over side panel. Placed after the base .lb__box rule, which used to win and
|
|
557
|
+
squeeze the stage to nothing on narrow screens */
|
|
558
|
+
@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)}}
|
|
559
|
+
</style>
|
|
560
|
+
</head>
|
|
561
|
+
<body>
|
|
562
|
+
<div class="app">
|
|
563
|
+
<header class="bar">
|
|
564
|
+
<button class="tog" id="togLeft" aria-pressed="true" aria-label="Left panel: run and steps" title="Left panel (F hides both)"><svg class="i" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1.5"/><path d="M9 4v16"/></svg></button>
|
|
565
|
+
<a class="brand" id="brand" href="#" style="text-decoration:none;color:inherit">Kaloko</a>
|
|
566
|
+
<span class="slot" id="navSlot"></span>
|
|
567
|
+
<span class="bar-break" aria-hidden="true"></span>
|
|
568
|
+
<button class="picker" id="picker" aria-label="Browse scenarios and runs">
|
|
569
|
+
<span class="kchip" id="pkKind">task</span>
|
|
570
|
+
<span class="task"><b id="taskTitle"></b><span id="runMeta"></span></span>
|
|
571
|
+
<kbd>K</kbd>
|
|
572
|
+
</button>
|
|
573
|
+
<button class="btn" id="rpBtn" title="R"><svg class="i" viewBox="0 0 24 24"><path d="M7 5v14l11-7z"/></svg><span data-t="replay"></span></button>
|
|
574
|
+
<div class="dbar" id="dbar" hidden></div>
|
|
575
|
+
<div class="spacer"></div>
|
|
576
|
+
<div class="seg" id="modeSeg" aria-label="Layout"></div>
|
|
577
|
+
<div class="seg" id="locSeg" aria-label="Locale"></div>
|
|
578
|
+
<span class="slot" id="viewSlot"></span>
|
|
579
|
+
<div class="presence" id="presence" hidden aria-label="Who else is here"></div>
|
|
580
|
+
<div class="popwrap" id="menuWrap"><button class="icon-btn" id="menuBtn" aria-haspopup="true" aria-expanded="false" aria-label="Menu"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg><span class="dot-on" id="viewDot" hidden></span></button>
|
|
581
|
+
<div class="menu pop" id="barMenu" hidden>
|
|
582
|
+
<nav class="appnav" id="appNav" aria-label="Project"></nav>
|
|
583
|
+
<div class="viewctls" id="viewCtls">
|
|
584
|
+
<div class="ctl"><div class="seg" id="vpSeg" aria-label="View"></div></div>
|
|
585
|
+
<div class="ctl" id="cmpCtl" hidden><span class="seg-label" data-t="compare"></span><select id="cmpSel" aria-label="Compare with"></select></div>
|
|
586
|
+
</div>
|
|
587
|
+
</div></div>
|
|
588
|
+
<div class="popwrap helpwrap"><button class="icon-btn" id="helpBtn" aria-haspopup="true" aria-expanded="false" title="Shortcuts and help (?)" aria-label="Shortcuts and help"><svg class="i" viewBox="0 0 24 24"><rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9 10h.01M12 10h.01M15 10h.01M18 10h.01M7 14h10"/></svg></button><div class="menu pop helppop" id="helpPop" hidden><div id="hint"></div><div class="legend-in" id="legend"></div></div></div>
|
|
589
|
+
<a class="inbox-btn" id="inboxBtn" hidden></a>
|
|
590
|
+
<button class="tog" id="togRight" aria-pressed="true" aria-label="Right panel: summary" title="Summary (F hides both)"><svg class="i" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1.5"/><path d="M15 4v16"/></svg></button>
|
|
591
|
+
</header>
|
|
592
|
+
<div class="main" id="main">
|
|
593
|
+
<aside class="side" id="side">
|
|
594
|
+
<h2><span data-t="shownRun"></span> <button class="btn" id="allRuns" data-t="allRuns"></button></h2>
|
|
595
|
+
<div class="runcard" id="runcard"></div>
|
|
596
|
+
<h2 data-t="steps"></h2>
|
|
597
|
+
<ul class="steps" id="steps"></ul>
|
|
598
|
+
<div class="side__foot" id="scFoot"></div>
|
|
599
|
+
</aside>
|
|
600
|
+
<section class="viewport" id="vp">
|
|
601
|
+
<div class="world" id="world">
|
|
602
|
+
<div id="bands"></div>
|
|
603
|
+
<svg class="edges" id="edges" width="1" height="1"><defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="arrow" d="M0 0L10 5L0 10z"/></marker></defs><g id="edgeG"></g></svg>
|
|
604
|
+
<div id="labels"></div>
|
|
605
|
+
<div id="nodes"></div>
|
|
606
|
+
<div id="cursors" aria-hidden="true"></div>
|
|
607
|
+
</div>
|
|
608
|
+
<div class="hud follow" id="followBar" hidden></div>
|
|
609
|
+
<div class="mini" id="mini" title="Minimap" role="img" aria-label="Minimap"></div>
|
|
610
|
+
<div class="hud hud--zoom">
|
|
611
|
+
<button class="icon-btn" id="zOut" aria-label="Zoom out"><svg class="i" viewBox="0 0 24 24"><path d="M5 12h14"/></svg></button>
|
|
612
|
+
<output id="zVal">100 %</output>
|
|
613
|
+
<button class="icon-btn" id="zIn" aria-label="Zoom in"><svg class="i" viewBox="0 0 24 24"><path d="M5 12h14M12 5v14"/></svg></button>
|
|
614
|
+
<button class="btn" id="zFit" data-t="fit"></button>
|
|
615
|
+
</div>
|
|
616
|
+
</section>
|
|
617
|
+
<section class="ri" id="ri" hidden aria-label="Index"></section>
|
|
618
|
+
<aside class="rside" id="rside"></aside>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="si" id="si" hidden role="dialog" aria-modal="true" aria-labelledby="siTitle"></div>
|
|
622
|
+
<div class="lb" id="lb" hidden role="dialog" aria-modal="true" aria-labelledby="lbTitle"></div>
|
|
623
|
+
<div class="br" id="br" hidden role="dialog" aria-modal="true">
|
|
624
|
+
<div class="br__box">
|
|
625
|
+
<div class="br__top">
|
|
626
|
+
<input type="search" id="brSearch" autocomplete="off">
|
|
627
|
+
<button class="icon-btn" id="brClose" aria-label="Close"><svg class="i" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
628
|
+
</div>
|
|
629
|
+
<div class="br__col">
|
|
630
|
+
<div class="br__filters"><div class="seg" id="brKind"></div><select id="brProject" aria-label="Project"></select><span class="note" id="brCount"></span></div>
|
|
631
|
+
<div class="br__list" id="brList"></div>
|
|
632
|
+
</div>
|
|
633
|
+
<div class="br__col">
|
|
634
|
+
<div class="br__filters" id="brRunFilters"></div>
|
|
635
|
+
<div class="runs-wrap" id="brRuns"></div>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
<div class="rp" id="rp" hidden role="dialog" aria-modal="true" aria-label="Replay"></div>
|
|
640
|
+
<div class="toast" id="toast" hidden></div>
|
|
641
|
+
<script>
|
|
642
|
+
/* Kaloko viewer. Data source:
|
|
643
|
+
* - preview: window.KALOKO_RUN embedded by `kaloko preview` (files relative to this page)
|
|
644
|
+
* - app: window.QAWALK_APP = {org, run, api} set by the Worker; data fetched from the API
|
|
645
|
+
*/
|
|
646
|
+
const EMBEDDED = /*__QAWALK_RUN__*/null;
|
|
647
|
+
if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
|
|
648
|
+
const I18N = {
|
|
649
|
+
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é', 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">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ý',
|
|
650
|
+
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',
|
|
651
|
+
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ý',
|
|
652
|
+
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í',
|
|
653
|
+
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`,
|
|
654
|
+
howAuto:'Automatická kontrola', howAi:'Vyhodnocení AI', howManual:'Rozhoduje člověk', howTech:'Technický detail',
|
|
655
|
+
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.`,
|
|
656
|
+
aiNotEval:'Zatím nevyhodnoceno.', aiVia:{ qawalk:'vyhodnotilo Kaloko', 'own-key':'vyhodnoceno vaším klíčem' }, aiAsked:'Otázka',
|
|
657
|
+
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}`,
|
|
658
|
+
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',
|
|
659
|
+
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í',
|
|
660
|
+
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',
|
|
661
|
+
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',
|
|
662
|
+
summary:'Souhrnný report', stepsDone:'kroků splněno', criteriaWord:'kritérií', failed:'Co neprošlo', allPassed:'Všechna prověřená kritéria prošla.',
|
|
663
|
+
locales:'Jazykové verze', changes:'Změny oproti předchozímu běhu', noChanges:'Beze změn.', noPrev:'Žádný starší běh tohoto scénáře.',
|
|
664
|
+
share:'Sdílení', shareLocal:'Lokální náhled. Do služby ho nahraje příkaz výše; tým pak plachtu otevře z odkazu.', hide:'Skrýt', notReached:'Krok nebyl v tomto běhu dosažen',
|
|
665
|
+
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',
|
|
666
|
+
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í.',
|
|
667
|
+
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',
|
|
668
|
+
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',
|
|
669
|
+
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',
|
|
670
|
+
downloadZip:'Stáhnout ZIP', 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',
|
|
671
|
+
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í',
|
|
672
|
+
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' },
|
|
673
|
+
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', 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">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',
|
|
674
|
+
pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
|
|
675
|
+
branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
|
|
676
|
+
older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
|
|
677
|
+
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`,
|
|
678
|
+
howAuto:'Automatic check', howAi:'AI evaluation', howManual:'Decided by a person', howTech:'Technical detail',
|
|
679
|
+
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.`,
|
|
680
|
+
aiNotEval:'Not evaluated yet.', aiVia:{ qawalk:'evaluated by Kaloko', 'own-key':'evaluated with your key' }, aiAsked:'Asked',
|
|
681
|
+
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}`,
|
|
682
|
+
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',
|
|
683
|
+
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',
|
|
684
|
+
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',
|
|
685
|
+
execution:'Step execution', step:'Step', of:'of', hover:'Click a criterion to highlight it on the page or in the metadata', fullPage:'full page',
|
|
686
|
+
summary:'Summary report', stepsDone:'steps passed', criteriaWord:'criteria', failed:'What failed', allPassed:'All checked criteria passed.',
|
|
687
|
+
locales:'Locales', changes:'Changes since the previous run', noChanges:'No changes.', noPrev:'No older run of this scenario.',
|
|
688
|
+
share:'Sharing', shareLocal:'Local preview. The command above uploads it to the service; the team then opens the canvas from a link.', hide:'Hide', notReached:'Step not reached in this run',
|
|
689
|
+
allAbove:'All questions above threshold', review:'needs review', notEvaluated:'not evaluated', search:'Search scenario, task, path…', all:'All', tasks:'Tasks', flows:'Flows',
|
|
690
|
+
runs:'runs', last:'last', time:'Time', result:'Result', noRuns:'No run matches the filters.', noScenario:'No scenario matches.',
|
|
691
|
+
onlyLocal:'This run exists locally only; open its folder.', linksExpired:'Links to the captures expired. Reload the page.', captured:'Captured', console:'Console', errors:'errors', blocked:'blocked requests', files:'Files',
|
|
692
|
+
pagesWord:'pages', firstScreen:'first screen', layout:'Layout', uploadedBy:'Uploaded by', addNote:'Add a note', viewOptions:'View', keyComment:'comment on the step (in the detail)', keyHelp:'this help', helpTitle:'Shortcuts and help', flowComments:'Flow comments', runComments:'Run comments', commentsTitle:'Comments', allOpen:'Open', openWord:'open', flowCommentsLead:'For the whole flow across runs: a missing step, a general remark, an idea.', noStepComments:'No comments on this step yet.', noComments:'No comments yet.', showResolved:'Resolved', hideResolved:'Hide resolved', replyAct:'Reply', reopenAct:'Reopen', editAct:'Edit', deleteAct:'Delete', saveAct:'Save', cancel:'Cancel', edited:'edited', commentDeleted:'This comment was deleted.', viaAgent:'Written by an agent', showPin:'Show on the screenshot', pinOnShot:'Pin on the screenshot', pinClickShot:'Click on the screenshot', pinnedOnShot:'Pinned on the screenshot', replyPh:'Reply… (@name notifies)', flowPh:'Comment on the whole flow… (@name notifies)', runPh:'Comment on the whole run… (@name notifies)', commentLabel:'Comment', ctrlEnter:'Ctrl+Enter sends', confirmDelete:'Delete this comment?', deletedToast:'Deleted', openCommentsN:'Open comments', showAllN:'Show all %N%', screenWord:'app screen', uiTree:'UI tree', platformWord:'Platform', treeHint:'Click an element to show it on the screen.', treeSearch:'Search the UI tree…', treeOcr:'Text recognized from the screenshot (OCR): no roles or controls.', treeLoadFail:'The UI tree could not be loaded.', treeEmpty:'The tree is empty.', mapView:'Map', indexView:'Index', searchSteps:'Search steps, criteria and pages…', criteriaCol:'Criteria', notesCol:'Notes', noStepsMatch:'No step matches the filter.', riKeys:'/ search · ↑↓ select · Enter open · I map', consoleWord:'console errors', loadingPages:'also searching the pages of stacks…', sortTitle:'Title', runWord:'Run', openIndex:'Open index', pagesNeedAttention:'pages need attention', allPagesPass:'all pages pass', stackLoadFail:'The page index could not be loaded', semanticSample:'semantic sample', downloadCsv:'Download CSV', allWord:'All', criterionFilter:'Failing criterion', anyCriterion:'Any criterion', searchPages:'Search title, URL and fields…', sortBy:'Sort by', sortOrder:'Order', sortStatus:'Status (worst first)', sortDir:'Sort direction', pageWord:'Page', statusWord:'Status', failingCriteria:'Failing', stackKeys:'/ search · ↑↓ select · Enter open · Esc close', listView:'List', gridView:'Grid', noPagesMatch:'No page matches the filter.', backToIndex:'Back to index',
|
|
693
|
+
subject:'Subject', from:'From', to:'To', received:'Received', mailBeforeRun:'arrived before the run started', noCapture:'No capture', viewEval:'eval.json', decision:'decision', mainLane:'Main path',
|
|
694
|
+
downloadZip:'Download ZIP', filesWord:'files', publicAccess:'public link', orgAccess:'access after signing in to the organization', projectAll:'All projects', packNote:'Criterion from a check pack (packs in the scenario)', coverage:'Plan coverage', coverageLine:'criteria from ACCEPTANCE.md have a check', coverageExtra:'checks outside the plan', coverageFull:'Every planned criterion has a check.', coverageSteps:'Steps in the plan without a node', flakyTitle:'Flaky criteria', flaky:'flaky', flakyNote:'The verdict differs between runs on the same commit and environment.', people:'People in the flow', peopleHint:'Click a person to highlight their steps; dashed arrows are hand-offs between people.', awaiting:'Awaiting approval from', approvedBy:'approved by', approvedByAll:'Every required approver has approved.', publicRun:'Public run', publicNote:'The canvas is readable without sign-in; reviewer names are hidden.', makePublic:'Make public', makePrivate:'Make private', publicHint:'Only for scenarios without personal data', publicConfirm:'Make this run public? Anyone with the link can read it without signing in. Use it only for scenarios without personal data.', openInApp:'Open in Kaloko', signInLink:'Sign in', replay:'Replay', rpStep:'Step', rpNext:'Next', rpPrev:'Back', rpEnd:'End of the walkthrough.', rpRestart:'Start over', rpChoose:'Which way?', rpOr:'or', rpKeys:'← → step · digits pick a branch · Esc closes', rpDecision:'Decision', rpBranch:'branch', rpAltOnly:'other branch',
|
|
695
|
+
compare:'Compare', cmpOff:'nothing', cmpBaseline:'baseline', cmpPrev:'previous', cmpTitle:'Differences from', cmpNone:'Statuses, verdicts and evidence are the same.', pxChanged:'pixels changed', cmpSide:'Side by side', cmpDiff:'Diff', cmpLoadErr:'Could not load the compared run.', pxUnavail:'Pixel diff works in the service only (same-origin images).', was:'was', thisRun:'this run', cmpNoCap:'no capture in the compared run', cmpAdded:'new steps', cmpRemoved:'steps that disappeared', cmpHint:'Ctrl/⌘ + click a step to open the comparison',
|
|
696
|
+
reviewTitle:'Sign-off and comments', approve:'Approve', reject:'Reject', comment:'Comment', notePh:'What should change, or a note · @name notifies a colleague', approved:'approved', rejected:'rejected', commented:'comment', resolved:'resolved', resolve:'Resolve', openFeedback:'Open feedback', noFeedback:'No open feedback.', saved:'Saved', noteRequired:'Write what should change.', expires:'Expires' }
|
|
697
|
+
};
|
|
698
|
+
const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
|
|
699
|
+
const UI = (() => { const l = window.QAWALK_APP?.lang || new URLSearchParams(location.search).get('lang') || storedLang || (navigator.language || 'en').slice(0, 2); return l === 'cs' || l === 'sk' ? 'cs' : 'en'; })();
|
|
700
|
+
function switchLang (l) {
|
|
701
|
+
if (window.QAWALK_APP) { location.href = `${location.pathname}?lang=${l}${location.hash}`; return; }
|
|
702
|
+
try { localStorage.setItem('qawalk.lang', l); } catch {}
|
|
703
|
+
location.reload();
|
|
704
|
+
}
|
|
705
|
+
const T = (k) => I18N[UI][k] ?? I18N.en[k] ?? k;
|
|
706
|
+
const ICON = {
|
|
707
|
+
comment:'<svg class="i" viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></svg>',
|
|
708
|
+
pin:'<svg class="i" viewBox="0 0 24 24"><path d="M12 21s-6-5.6-6-11a6 6 0 0 1 12 0c0 5.4-6 11-6 11z"/><circle cx="12" cy="10" r="2.2"/></svg>',
|
|
709
|
+
screen:'<svg class="i" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="13" rx="1.5"/><path d="M8 20h8"/></svg>',
|
|
710
|
+
stack:'<svg class="i" viewBox="0 0 24 24"><rect x="7" y="3" width="14" height="11" rx="1.5"/><path d="M4 7v10a2 2 0 0 0 2 2h11"/><path d="M1.5 10v9.5A2.5 2.5 0 0 0 4 22h9"/></svg>',
|
|
711
|
+
email:'<svg class="i" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="m3.5 6 8.5 7 8.5-7"/></svg>',
|
|
712
|
+
external:'<svg class="i" viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9M18 14v5H5V6h5"/></svg>',
|
|
713
|
+
crop:'<svg class="i" viewBox="0 0 24 24"><path d="M6 2v16h16M2 6h16v16"/></svg>',
|
|
714
|
+
html:'<svg class="i" viewBox="0 0 24 24"><path d="m8 7-5 5 5 5M16 7l5 5-5 5"/></svg>',
|
|
715
|
+
log:'<svg class="i" viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 9h8M8 13h8M8 17h5"/></svg>',
|
|
716
|
+
ai:'<svg class="i" viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/></svg>',
|
|
717
|
+
close:'<svg class="i" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>',
|
|
718
|
+
prev:'<svg class="i" viewBox="0 0 24 24"><path d="m15 5-7 7 7 7"/></svg>',
|
|
719
|
+
share:'<svg class="i" viewBox="0 0 24 24"><path d="M12 16V4M7 9l5-5 5 5M5 20h14"/></svg>',
|
|
720
|
+
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>',
|
|
721
|
+
download:'<svg class="i" viewBox="0 0 24 24"><path d="M12 4v12M7 11l5 5 5-5M5 20h14"/></svg>',
|
|
722
|
+
next:'<svg class="i" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></svg>'
|
|
723
|
+
};
|
|
724
|
+
const MARK = { pass:'✓', partial:'!', fail:'✕', skip:'–' };
|
|
725
|
+
const VSTAT = { pass:'pass', fail:'fail', needs_review:'partial', not_evaluated:'partial', not_reached:'skip' };
|
|
726
|
+
const RANK = { fail:4, needs_review:3, not_evaluated:2, pass:1, not_reached:0 };
|
|
727
|
+
const worst = (l) => l.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'not_reached');
|
|
728
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]));
|
|
729
|
+
const $ = (id) => document.getElementById(id);
|
|
730
|
+
|
|
731
|
+
let RUN = null;
|
|
732
|
+
let BASE = '';
|
|
733
|
+
const S = { loc:null, vp:'both', jev:'sum', view:{ x:0, y:0, k:1 }, open:null, lbVp:null, left:true, right:true, brKind:'all', brQ:'', brSc:null, brProject:'', cmp:null, lbCmp:'side', rp:null, sess:null };
|
|
734
|
+
|
|
735
|
+
const loc = (v) => v == null ? '' : typeof v === 'string' ? v : (v[S.loc] ?? v.default ?? Object.values(v)[0] ?? '');
|
|
736
|
+
const asset = (p) => typeof p === 'string' && /^(steps|attachments)\/[A-Za-z0-9._\/-]+\.(png|jpe?g|webp|html|json)$/.test(p) && !p.includes('..') ? BASE + p : '#';
|
|
737
|
+
const webHref = (p) => { try { const u = new URL(p); return ['http:', 'https:'].includes(u.protocol) ? u.href : '#'; } catch { return '#'; } };
|
|
738
|
+
/* A viewport by its name and width ("Tablet 640", "Mobil 360"): several phones or desktops stay distinguishable. */
|
|
739
|
+
const VP_NAMES = { desktop: ['Desktop', 'Desktop'], mobile: ['Mobile', 'Mobil'], phone: ['Phone', 'Telefon'], tablet: ['Tablet', 'Tablet'], wide: ['Wide', 'Široký'], laptop: ['Laptop', 'Notebook'] };
|
|
740
|
+
function vpLabel (x) { const k = String(x.name).toLowerCase(); const base = VP_NAMES[k] ? VP_NAMES[k][UI === 'cs' ? 1 : 0] : x.name.charAt(0).toUpperCase() + x.name.slice(1); return `${base} ${x.w}`; }
|
|
741
|
+
const viewports = () => Object.entries(RUN.scope.viewports).map(([name, [w, h]]) => ({ name, w: Number(w), h: Number(h), mobile: Number(w) < 700 }));
|
|
742
|
+
const dVp = () => viewports().find((v) => !v.mobile);
|
|
743
|
+
const mVp = () => viewports().find((v) => v.mobile);
|
|
744
|
+
const screens = () => RUN.nodes.filter((n) => n.kind !== 'decision');
|
|
745
|
+
// steps by id: a map rebuilt whenever RUN.nodes is a new array (large runs look nodes up per card and per edge)
|
|
746
|
+
let NODE_IX = { nodes: null, map: null };
|
|
747
|
+
const byId = (id) => { if (ITEM_NODES.has(id)) return ITEM_NODES.get(id); if (NODE_IX.nodes !== RUN.nodes) NODE_IX = { nodes: RUN.nodes, map: new Map(RUN.nodes.map((n) => [n.id, n])) }; return NODE_IX.map.get(id); };
|
|
748
|
+
const ITEM_NODES = new Map(); // pages of stack steps opened in the lightbox, as node-like objects
|
|
749
|
+
const SESSION_COLORS = ['#1d8249', '#2f6fed', '#b86e00', '#8a3ffc', '#c8102e', '#0f8b8d', '#8d8d86'];
|
|
750
|
+
const sessions = () => RUN.sessions ? Object.keys(RUN.sessions) : [];
|
|
751
|
+
const sessColor = (key) => SESSION_COLORS[Math.max(0, sessions().indexOf(key)) % SESSION_COLORS.length];
|
|
752
|
+
const sessTitle = (key) => key ? loc(RUN.sessions?.[key]?.title) || key : '';
|
|
753
|
+
const sessChip = (key) => key && RUN.sessions?.[key] ? `<span class="sess" style="--sc:${sessColor(key)}" title="${esc(key)}"><i></i>${esc(sessTitle(key))}</span>` : '';
|
|
754
|
+
const nodeSt = (n) => { const status = n.by_locale?.[S.loc] || n.status; return ['pass', 'partial', 'fail', 'skip'].includes(status) ? status : 'skip'; };
|
|
755
|
+
const critVerdict = (c, l = S.loc) => { if (c.counts) return c.verdict || 'not_reached'; const vs = viewports().map((v) => c.by_locale?.[l]?.[v.name]?.verdict || 'not_reached'); return vs.every((v) => v === 'not_reached') ? 'not_reached' : worst(vs.map((v) => v === 'not_reached' ? 'not_evaluated' : v)); };
|
|
756
|
+
const critSt = (c) => VSTAT[critVerdict(c)];
|
|
757
|
+
const prevRun = () => (RUN.history || []).find((h) => h.started_at < RUN.started_at);
|
|
758
|
+
const fmtDate = (iso) => { if (!iso) return ''; const d = new Date(iso); return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
759
|
+
const fmtBytes = (n) => { n = Number(n) || 0; const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; };
|
|
760
|
+
const initials = (n) => String(n || '?').split(/\s+/).map((p) => p[0]).join('').slice(0, 2).toUpperCase();
|
|
761
|
+
const dur = () => { const s = (new Date(RUN.finished_at) - new Date(RUN.started_at)) / 1000; return isFinite(s) ? `${Math.floor(s / 60)} min ${String(Math.round(s % 60)).padStart(2, '0')} s` : '—'; };
|
|
762
|
+
|
|
763
|
+
/* ---------- semantic evaluation ---------- */
|
|
764
|
+
function jevFor (n, vp) { return n.semantic?.[`${S.loc}|${vp}`] || n.jev?.[`${S.loc}|${vp}`] || null; }
|
|
765
|
+
function jevBlock (n) {
|
|
766
|
+
const vp = (S.vp === 'mobile' ? mVp() : dVp())?.name;
|
|
767
|
+
const j = jevFor(n, vp);
|
|
768
|
+
const sem = n.criteria.filter((c) => c.type === 'semantic');
|
|
769
|
+
if (!sem.length) return '';
|
|
770
|
+
const s = nodeSt(n);
|
|
771
|
+
const probs = sem.map((c) => c.by_locale?.[S.loc]?.[vp]?.score).filter((x) => x != null);
|
|
772
|
+
const score = probs.length ? probs.reduce((a, b) => a + b, 0) / probs.length : null;
|
|
773
|
+
const col = `var(--${s})`;
|
|
774
|
+
const bad = sem.filter((c) => { const v = c.by_locale?.[S.loc]?.[vp]?.verdict; return v && v !== 'pass'; });
|
|
775
|
+
const lab = { fail:'fail', needs_review:T('review'), not_evaluated:T('notEvaluated'), not_reached:T('skip') };
|
|
776
|
+
let lines;
|
|
777
|
+
if (!n.captures?.[S.loc]) lines = `<span class="ok"><b>—</b>${T('notReached')}</span>`;
|
|
778
|
+
else if (j?.skipped || j?.error) lines = `<span class="ok"><b>—</b>${esc(j.skipped || j.error)}</span>`;
|
|
779
|
+
else if (bad.length) lines = bad.map((c) => { const r = c.by_locale[S.loc][vp]; return `<span><b>${esc(c.id)} ${r.score != null ? r.score.toFixed(2) : '—'}</b>${esc(lab[r.verdict] || r.verdict)}: ${esc(c.text)}</span>`; }).join('');
|
|
780
|
+
else lines = `<span class="ok"><b>✓</b>${T('allAbove')}</span>`;
|
|
781
|
+
const view = j && !j.skipped ? { provider: j.provider, model: j.model, answers: j.answers, usage: j.usage, cached: j.cached } : j;
|
|
782
|
+
return `<div class="jev"><div class="jev__head"><span class="jev__tag" title="${esc(j?.provider || '')} ${esc(j?.model || '')}">AI</span><span class="jev__score" title="${esc(T('jevScoreHint'))}">${score == null ? '—' : `${Math.round(score * 100)} %`}</span><span class="jev__meter"><i style="width:${(score || 0) * 100}%;background:${col}"></i></span><span class="badge st-${s}">${T(s).toLowerCase()}</span></div><div class="jev__c">${lines}</div>${S.jev === 'json' && view ? `<pre class="json">${jsonHTML(view)}</pre>` : ''}</div>`;
|
|
783
|
+
}
|
|
784
|
+
function jsonHTML (o) { return JSON.stringify(o, null, 2).replace(/&/g, '&').replace(/</g, '<').replace(/"([^"]+)":/g, '<span class="k">"$1"</span>:').replace(/: "([^"]*)"/g, ': <span class="s">"$1"</span>').replace(/: (-?[\d.]+|true|false|null)/g, ': <span class="n">$1</span>'); }
|
|
785
|
+
|
|
786
|
+
/* ---------- comments (service only): a step, the whole run, the whole flow, a design draft ---------- */
|
|
787
|
+
const APP = () => window.QAWALK_APP || null;
|
|
788
|
+
const canWrite = () => !!APP()?.user;
|
|
789
|
+
const reviewsOf = (id) => (RUN.reviews || []).filter((r) => r.node_id === id && !r.parent_id);
|
|
790
|
+
function reviewState (id) {
|
|
791
|
+
const rs = reviewsOf(id).filter((r) => !r.resolved_at && !r.deleted && r.status !== 'comment');
|
|
792
|
+
const last = rs[rs.length - 1];
|
|
793
|
+
return last ? last.status : null;
|
|
794
|
+
}
|
|
795
|
+
// UI state of the comment threads: which one is being answered or edited, resolved shown, a pin being placed
|
|
796
|
+
const C = { replyTo: null, editing: null, showResolved: false, pin: null, pinMode: false, focus: null };
|
|
797
|
+
const scopeList = (scope) => scope === 'flow' ? (RUN.flow_comments || []) : (RUN.reviews || []);
|
|
798
|
+
function threads (scope, nodeId = '') {
|
|
799
|
+
const all = scopeList(scope);
|
|
800
|
+
return all.filter((r) => !r.parent_id && (scope === 'flow' || (r.node_id || '') === nodeId)).map((r) => ({ ...r, replies: all.filter((x) => x.parent_id === r.id) }));
|
|
801
|
+
}
|
|
802
|
+
const isOpenT = (t) => !t.resolved_at && !t.deleted && t.status !== 'approved';
|
|
803
|
+
const openCount = (scope, nodeId = '') => threads(scope, nodeId).filter(isOpenT).length;
|
|
804
|
+
/** Open threads of a step (a stack counts the threads of its pages). */
|
|
805
|
+
// open threads per step: counted once per synchronous pass (a whole render), then forgotten, so never stale
|
|
806
|
+
let OPEN_IX = null;
|
|
807
|
+
const stepOpenCount = (id) => {
|
|
808
|
+
if (!OPEN_IX) {
|
|
809
|
+
OPEN_IX = new Map();
|
|
810
|
+
for (const r of RUN.reviews || []) { if (r.parent_id || !isOpenT(r) || !r.node_id) continue; const k = r.node_id.split('~')[0]; OPEN_IX.set(k, (OPEN_IX.get(k) || 0) + 1); }
|
|
811
|
+
queueMicrotask(() => { OPEN_IX = null; });
|
|
812
|
+
}
|
|
813
|
+
return OPEN_IX.get(id) || 0;
|
|
814
|
+
};
|
|
815
|
+
const cmtBadge = (id) => { const k = APP() ? stepOpenCount(id) : 0; return k ? `<span class="cmt" title="${esc(T('openCommentsN'))}: ${k}">${ICON.comment}${k}</span>` : ''; };
|
|
816
|
+
const runOpenThreads = () => (RUN.reviews || []).filter((r) => !r.parent_id && isOpenT(r));
|
|
817
|
+
function commentsBase (scope) {
|
|
818
|
+
const a = APP();
|
|
819
|
+
if (isDraft()) return scenarioApi('/draft/reviews');
|
|
820
|
+
return scope === 'flow' ? `${a.api}/orgs/${a.org}/scenarios/${encodeURIComponent(RUN.scenario.id)}/comments` : `${a.api}/orgs/${a.org}/runs/${a.run}/comments`;
|
|
821
|
+
}
|
|
822
|
+
async function cApi (scope, path, method, body) {
|
|
823
|
+
const res = await fetch(commentsBase(scope) + path, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined });
|
|
824
|
+
const data = await res.json().catch(() => ({}));
|
|
825
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
826
|
+
return data;
|
|
827
|
+
}
|
|
828
|
+
async function refreshComments () {
|
|
829
|
+
const a = APP(); if (!a) return;
|
|
830
|
+
const get = (u) => fetch(u, { credentials: 'same-origin' }).then((x) => x.ok ? x.json() : null).catch(() => null);
|
|
831
|
+
const [r, f] = await Promise.all([get(isDraft() ? scenarioApi(`/draft/reviews${branchQ()}`) : commentsBase('run')), isDraft() || !a.user ? null : get(commentsBase('flow'))]);
|
|
832
|
+
if (r) RUN.reviews = r;
|
|
833
|
+
if (f) RUN.flow_comments = f;
|
|
834
|
+
rerenderComments(true);
|
|
835
|
+
}
|
|
836
|
+
const refreshReviews = refreshComments;
|
|
837
|
+
/** Everything that shows comments, redrawn in place (the side panel keeps its scroll). */
|
|
838
|
+
function rerenderComments (all = false) {
|
|
839
|
+
if (all) renderNodes();
|
|
840
|
+
if (S.right) { const top = $('rside').scrollTop; renderReport(); $('rside').scrollTop = top; }
|
|
841
|
+
if (S.open && !$('lb').hidden) { const side = $('lb').querySelector('.lb__side'); const top = side?.scrollTop || 0; openLb(S.open, S.lbVp, { keepFrame: true }); const s2 = $('lb').querySelector('.lb__side'); if (s2) s2.scrollTop = top; }
|
|
842
|
+
if (S.rp && !$('rp').hidden) renderReplay();
|
|
843
|
+
if (all && S.mode === 'index' && !S.st?.inline && $('riList')) riPaint();
|
|
844
|
+
}
|
|
845
|
+
// unsent text survives re-renders, navigation and reloads (per run or flow, step, and reply or edit)
|
|
846
|
+
const draftKey = (scope, nodeId, sub) => `qawalk-draft|${APP()?.org || 'local'}|${scope === 'flow' ? `flow:${RUN.scenario.id}` : isDraft() ? `draft:${RUN.scenario.id}` : RUN.id}|${nodeId || ''}|${sub || 'new'}`;
|
|
847
|
+
const loadDraft = (k) => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
|
|
848
|
+
const saveDraft = (k, v) => { try { if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); } catch {} };
|
|
849
|
+
const noteHtml = (s) => esc(s).replace(/(^|\s)@([\w.+-]+(?:@[\w.-]+)?)/g, '$1<b class="c-at">@$2</b>').replace(/\n/g, '<br>');
|
|
850
|
+
/** Numbers of the threads pinned on this step's screenshot (the same numbers as the marks on the image). */
|
|
851
|
+
function pinNumbers (nodeId) {
|
|
852
|
+
const m = new Map(); let k = 0;
|
|
853
|
+
for (const t of threads('step', nodeId)) if (t.pin && !t.deleted && (C.showResolved || isOpenT(t))) m.set(t.id, ++k);
|
|
854
|
+
return m;
|
|
855
|
+
}
|
|
856
|
+
function commentsBlock ({ scope, nodeId = '', pinnable = false, compact = false }) {
|
|
857
|
+
if (!APP()) return '';
|
|
858
|
+
const all = threads(scope, nodeId);
|
|
859
|
+
const open = all.filter(isOpenT); const closed = all.filter((t) => !isOpenT(t));
|
|
860
|
+
const shown = C.showResolved ? all : open;
|
|
861
|
+
const me = APP().user?.email; const admin = APP().user?.role === 'admin';
|
|
862
|
+
const pins = scope === 'step' ? pinNumbers(nodeId) : new Map();
|
|
863
|
+
const lab = { approved: T('approved'), rejected: T('rejected') };
|
|
864
|
+
const item = (r, root) => C.editing === r.id ? composer({ scope, nodeId, edit: r }) : `<div class="c-item" data-cid="${esc(r.id)}">
|
|
865
|
+
<div class="c-meta"><span class="av">${esc(initials(r.email))}</span><span class="c-who">${esc(r.email)}${r.agent ? ` <span class="c-agent" title="${esc(T('viaAgent'))}">${esc(r.agent)}</span>` : ''}</span><span class="id">${fmtDate(r.created_at)}${r.updated_at ? ` · ${T('edited')}` : ''}${root && r.locale && scope === 'step' ? ` · ${esc(r.locale)}` : ''}</span>
|
|
866
|
+
${root && pins.has(r.id) ? `<button class="c-pin" data-c-act="showpin" data-id="${esc(r.id)}" title="${esc(T('showPin'))}">${pins.get(r.id)}</button>` : ''}${root && r.anchor ? `<span class="c-anchor" title="${esc(r.anchor.selector || '')}">⌖</span>` : ''}
|
|
867
|
+
${root && lab[r.status] ? `<span class="badge st-${r.status === 'approved' ? 'pass' : 'fail'}">${esc(lab[r.status])}</span>` : ''}${root && r.resolved_at ? `<span class="badge st-skip" title="${esc(r.resolved_by || '')}">${T('resolved')}</span>` : ''}</div>
|
|
868
|
+
<div class="c-note">${r.deleted ? `<i class="muted">${T('commentDeleted')}</i>` : noteHtml(r.note)}</div>
|
|
869
|
+
${canWrite() && !r.deleted ? `<div class="c-acts">${root && isOpenT(r) ? `<button data-c-act="reply" data-id="${esc(r.id)}">${T('replyAct')}</button>` : ''}${root ? (r.resolved_at ? `<button data-c-act="reopen" data-id="${esc(r.id)}">${T('reopenAct')}</button>` : r.status !== 'approved' ? `<button data-c-act="resolve" data-id="${esc(r.id)}">${T('resolve')}</button>` : '') : ''}${r.email === me ? `<button data-c-act="edit" data-id="${esc(r.id)}">${T('editAct')}</button>` : ''}${r.email === me || admin ? `<button data-c-act="delete" data-id="${esc(r.id)}">${T('deleteAct')}</button>` : ''}</div>` : ''}
|
|
870
|
+
</div>`;
|
|
871
|
+
const card = (t) => `<li class="c-thread ${isOpenT(t) ? '' : 'is-done'} ${C.focus === t.id ? 'is-focus' : ''}" data-thread="${esc(t.id)}">${item(t, true)}${t.replies.length ? `<ul class="c-replies">${t.replies.map((r) => `<li>${item(r, false)}</li>`).join('')}</ul>` : ''}${C.replyTo === t.id ? composer({ scope, nodeId, parent: t.id }) : ''}</li>`;
|
|
872
|
+
const title = T(scope === 'flow' ? 'flowComments' : scope === 'run' ? 'runComments' : 'reviewTitle');
|
|
873
|
+
return `<div class="sec c-sec" id="c-${scope}" data-scope="${scope}" data-node="${esc(nodeId)}">
|
|
874
|
+
<h4>${ICON.comment} ${title}${open.length ? ` <span class="c-count">${open.length}</span>` : ''}${closed.length ? `<button class="c-toggle" data-c-act="toggleResolved">${C.showResolved ? T('hideResolved') : `${T('showResolved')} (${closed.length})`}</button>` : ''}</h4>
|
|
875
|
+
${scope === 'flow' && !compact ? `<p class="note">${T('flowCommentsLead')}</p>` : ''}
|
|
876
|
+
${shown.length ? `<ul class="c-list">${shown.map(card).join('')}</ul>` : compact ? '' : `<p class="note">${T(scope === 'step' ? 'noStepComments' : 'noComments')}</p>`}
|
|
877
|
+
${canWrite() ? composer({ scope, nodeId, pinnable }) : ''}
|
|
878
|
+
</div>`;
|
|
879
|
+
}
|
|
880
|
+
function composer ({ scope, nodeId, parent = null, edit = null, pinnable = false }) {
|
|
881
|
+
const key = draftKey(scope, nodeId, edit ? `edit:${edit.id}` : parent ? `reply:${parent}` : 'new');
|
|
882
|
+
const val = edit ? (loadDraft(key) || edit.note) : loadDraft(key);
|
|
883
|
+
const main = !parent && !edit;
|
|
884
|
+
const step = scope === 'step' && main;
|
|
885
|
+
const placeholder = T(parent ? 'replyPh' : scope === 'flow' ? 'flowPh' : scope === 'run' ? 'runPh' : 'notePh');
|
|
886
|
+
return `<div class="c-compose ${main ? 'c-main' : ''}" data-key="${esc(key)}" data-scope="${scope}" data-node="${esc(nodeId)}"${parent ? ` data-parent="${esc(parent)}"` : ''}${edit ? ` data-edit="${esc(edit.id)}"` : ''}>
|
|
887
|
+
${step && (pinnable || LIVE.anchor) ? `<div class="c-pinrow">${C.pin ? `<span class="c-pinned">${ICON.pin} ${T('pinnedOnShot')} <button class="linkish" data-c-act="unpin">${T('cancel')}</button></span>` : pinnable ? `<button class="btn c-pinbtn" data-c-act="pinmode" aria-pressed="${C.pinMode}">${ICON.pin} ${T(C.pinMode ? 'pinClickShot' : 'pinOnShot')}</button>` : ''}${LIVE.anchor ? `<span class="c-pinned">⌖ ${esc(LIVE.anchor.text || LIVE.anchor.selector || '')}</span>` : ''}</div>` : ''}
|
|
888
|
+
<textarea rows="${main ? 3 : 2}" class="c-text" placeholder="${esc(placeholder)}" aria-label="${esc(T('commentLabel'))}">${esc(val)}</textarea>
|
|
889
|
+
<div class="share__row c-btns">${edit ? `<button class="btn btn--primary" data-c-act="save">${T('saveAct')}</button><button class="btn" data-c-act="cancel">${T('cancel')}</button>`
|
|
890
|
+
: parent ? `<button class="btn btn--primary" data-c-act="send">${T('replyAct')}</button><button class="btn" data-c-act="cancel">${T('cancel')}</button>`
|
|
891
|
+
: `<button class="btn btn--primary" data-c-act="send" data-status="comment">${T('comment')}</button>${step ? `<button class="btn" data-c-act="send" data-status="rejected">${T('reject')}</button><button class="btn" data-c-act="send" data-status="approved">${T('approve')}</button>` : ''}`}
|
|
892
|
+
<span class="note c-hint">${T('ctrlEnter')}</span></div></div>`;
|
|
893
|
+
}
|
|
894
|
+
/** One listener per container (lightbox, report, replay): threads re-render inside it. */
|
|
895
|
+
function wireComments (root) {
|
|
896
|
+
if (!root || root.dataset.cWired) return;
|
|
897
|
+
root.dataset.cWired = '1';
|
|
898
|
+
root.addEventListener('input', (e) => { const t = e.target.closest?.('.c-text'); if (t) saveDraft(t.closest('.c-compose').dataset.key, t.value); });
|
|
899
|
+
root.addEventListener('keydown', (e) => {
|
|
900
|
+
const t = e.target.closest?.('.c-text'); if (!t) return;
|
|
901
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); t.closest('.c-compose').querySelector('[data-c-act="send"],[data-c-act="save"]')?.click(); }
|
|
902
|
+
});
|
|
903
|
+
root.addEventListener('click', async (e) => {
|
|
904
|
+
const b = e.target.closest('[data-c-act]'); if (!b || !root.contains(b)) return;
|
|
905
|
+
const act = b.dataset.cAct; const sec = b.closest('.c-sec'); const box = b.closest('.c-compose');
|
|
906
|
+
const scope = sec?.dataset.scope || box?.dataset.scope || 'step'; const nodeId = sec?.dataset.node ?? box?.dataset.node ?? '';
|
|
907
|
+
const id = b.dataset.id;
|
|
908
|
+
const focusIn = (sel) => requestAnimationFrame(() => { const t = document.querySelector(sel); if (t) { t.focus(); t.setSelectionRange(t.value.length, t.value.length); } });
|
|
909
|
+
try {
|
|
910
|
+
if (act === 'toggleResolved') { C.showResolved = !C.showResolved; rerenderComments(); return; }
|
|
911
|
+
if (act === 'reply') { C.replyTo = id; C.editing = null; rerenderComments(); focusIn(`.c-compose[data-parent="${CSS.escape(id)}"] .c-text`); return; }
|
|
912
|
+
if (act === 'edit') { C.editing = id; C.replyTo = null; rerenderComments(); focusIn(`.c-compose[data-edit="${CSS.escape(id)}"] .c-text`); return; }
|
|
913
|
+
if (act === 'cancel') { saveDraft(box.dataset.key, ''); C.replyTo = null; C.editing = null; rerenderComments(); return; }
|
|
914
|
+
if (act === 'pinmode') { C.pinMode = !C.pinMode; $('lbImg')?.classList.toggle('is-pinning', C.pinMode); b.setAttribute('aria-pressed', C.pinMode); b.lastChild.textContent = ` ${T(C.pinMode ? 'pinClickShot' : 'pinOnShot')}`; if (C.pinMode) toast(T('pinClickShot')); return; }
|
|
915
|
+
if (act === 'unpin') { C.pin = null; rerenderComments(); return; }
|
|
916
|
+
if (act === 'showpin') { const mark = document.querySelector(`.pinmark[data-thread="${CSS.escape(id)}"]`); if (mark) { mark.classList.remove('flash'); void mark.offsetWidth; mark.classList.add('flash'); $('lbScroll')?.scrollTo({ top: Math.max(0, mark.offsetTop - 120), behavior: 'smooth' }); } return; }
|
|
917
|
+
if (act === 'delete') { if (!confirm(T('confirmDelete'))) return; b.disabled = true; await cApi(scope, `/${encodeURIComponent(id)}`, 'DELETE'); toast(T('deletedToast')); await refreshComments(); return; }
|
|
918
|
+
if (act === 'resolve' || act === 'reopen') { b.disabled = true; await cApi(scope, `/${encodeURIComponent(id)}/${act}`, 'POST', act === 'resolve' ? { note: '' } : {}); await refreshComments(); return; }
|
|
919
|
+
if (act === 'save') {
|
|
920
|
+
const note = box.querySelector('.c-text').value.trim(); b.disabled = true;
|
|
921
|
+
await cApi(scope, `/${encodeURIComponent(box.dataset.edit)}`, 'PATCH', { note });
|
|
922
|
+
saveDraft(box.dataset.key, ''); C.editing = null; toast(T('saved')); await refreshComments(); return;
|
|
923
|
+
}
|
|
924
|
+
if (act === 'send') {
|
|
925
|
+
const note = box.querySelector('.c-text').value.trim(); const status = b.dataset.status || 'comment';
|
|
926
|
+
if (status !== 'approved' && !note) { toast(T('noteRequired')); box.querySelector('.c-text').focus(); return; }
|
|
927
|
+
const main = !box.dataset.parent;
|
|
928
|
+
const body = { note, status, node_id: nodeId, locale: scope === 'step' ? S.loc : null, ...(scope === 'step' && main ? { viewport: S.lbVp || null, ...(C.pin ? { pin: C.pin } : {}), ...(LIVE.anchor ? { anchor: LIVE.anchor, revision_id: revOf(byId(nodeId)) } : {}) } : {}), ...(box.dataset.parent ? { parent_id: box.dataset.parent } : {}) };
|
|
929
|
+
b.disabled = true;
|
|
930
|
+
await cApi(scope, '', 'POST', body);
|
|
931
|
+
saveDraft(box.dataset.key, ''); if (main) { C.pin = null; C.pinMode = false; LIVE.anchor = null; } C.replyTo = null;
|
|
932
|
+
toast(T('saved')); await refreshComments();
|
|
933
|
+
}
|
|
934
|
+
} catch (err) { window.Sentry?.captureException(err); toast(err.message); b.disabled = false; }
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
/** Marks of the threads pinned on the step's screenshot, the pin being placed, and click-to-pin. */
|
|
938
|
+
function placeCommentPins (img, n, cap, v) {
|
|
939
|
+
if (!img || !APP()) return;
|
|
940
|
+
const draw = () => {
|
|
941
|
+
img.querySelectorAll('.pinmark.pin-xy').forEach((x) => x.remove());
|
|
942
|
+
const w = img.querySelector('img')?.clientWidth || 0; const h = img.querySelector('img')?.clientHeight || 0; if (!w) return;
|
|
943
|
+
const nums = pinNumbers(n.id);
|
|
944
|
+
for (const t of threads('step', n.id)) {
|
|
945
|
+
if (!t.pin || !nums.has(t.id) || (t.pin.viewport && t.pin.viewport !== v.name) || (t.locale && t.locale !== S.loc)) continue;
|
|
946
|
+
const d = document.createElement('button'); d.type = 'button'; d.className = `pinmark pin-xy${isOpenT(t) ? '' : ' is-done'}`; d.dataset.thread = t.id; d.textContent = nums.get(t.id); d.title = t.note.slice(0, 120);
|
|
947
|
+
Object.assign(d.style, { left: `${t.pin.x * w}px`, top: `${t.pin.y * h}px` });
|
|
948
|
+
d.onclick = (e) => { e.stopPropagation(); C.focus = t.id; const el = document.querySelector(`.c-thread[data-thread="${CSS.escape(t.id)}"]`); el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); el?.classList.add('is-focus'); setTimeout(() => el?.classList.remove('is-focus'), 1600); };
|
|
949
|
+
img.appendChild(d);
|
|
950
|
+
}
|
|
951
|
+
if (C.pin && (!C.pin.viewport || C.pin.viewport === v.name)) { const d = document.createElement('span'); d.className = 'pinmark pin-xy is-new'; d.textContent = '+'; Object.assign(d.style, { left: `${C.pin.x * w}px`, top: `${C.pin.y * h}px` }); img.appendChild(d); }
|
|
952
|
+
};
|
|
953
|
+
const im = img.querySelector('img'); if (im) im.complete ? draw() : im.addEventListener('load', draw, { once: true });
|
|
954
|
+
img.classList.toggle('is-pinning', C.pinMode);
|
|
955
|
+
img.onclick = (e) => {
|
|
956
|
+
if (!C.pinMode || e.target.closest('.pinmark')) return;
|
|
957
|
+
const r = img.querySelector('img').getBoundingClientRect();
|
|
958
|
+
C.pin = { x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)), y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)), viewport: v.name };
|
|
959
|
+
C.pinMode = false; rerenderComments();
|
|
960
|
+
requestAnimationFrame(() => { const t = $('lb').querySelector('.c-main .c-text'); t?.scrollIntoView({ block: 'center' }); t?.focus(); });
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** Right panel: every open thread of the run and the flow, then the run and flow threads themselves. */
|
|
964
|
+
function commentsPanel () {
|
|
965
|
+
if (!APP()) return '';
|
|
966
|
+
const tab = S.cTab || 'all';
|
|
967
|
+
const stepOpen = runOpenThreads().filter((r) => r.node_id);
|
|
968
|
+
const runOpen = openCount('run', ''); const flowOpen = isDraft() ? 0 : openCount('flow');
|
|
969
|
+
const tabs = [['all', T('allOpen'), stepOpen.length + runOpen + flowOpen], ['run', T(isDraft() ? 'draft' : 'runWord'), runOpen], ...(isDraft() ? [] : [['flow', 'Flow', flowOpen]])];
|
|
970
|
+
const title = (r) => { const nn = byId(r.node_id) || (r.node_id.includes('~') ? { title: r.node_id.split('~').pop() } : null); return nn ? loc(nn.title) : r.node_id; };
|
|
971
|
+
// newest first, grouped by step; long lists fold after eight threads
|
|
972
|
+
const sorted = stepOpen.slice().sort((x, y) => String(y.created_at).localeCompare(String(x.created_at)));
|
|
973
|
+
const byStep = []; for (const r of sorted) { const g = byStep.find((x) => x.id === r.node_id); if (g) g.list.push(r); else byStep.push({ id: r.node_id, list: [r] }); }
|
|
974
|
+
const flat = byStep.flatMap((g) => g.list); const limited = S.cAll ? flat : flat.slice(0, 8);
|
|
975
|
+
const more = flat.length - limited.length;
|
|
976
|
+
const list = tab === 'all' ? `${stepOpen.length || runOpen || flowOpen ? `<ul class="issues">${[...limited.map((r) => `<li><button data-cgo="${esc(r.node_id)}" data-thread="${esc(r.id)}"><span class="badge st-${r.status === 'rejected' ? 'fail' : 'skip'}">${r.status === 'rejected' ? '✕' : ICON.comment}</span><span><b>${esc(title(r))}</b> · ${esc(r.email)}${r.pin ? ` · ${ICON.pin}` : ''}</span><small>${esc(r.note)}</small></button></li>`),
|
|
977
|
+
...(runOpen ? [`<li><button data-ctab="run"><span class="badge st-skip">${ICON.comment}</span><span><b>${T('runComments')}</b></span><small>${runOpen} ${T('openWord')}</small></button></li>`] : []),
|
|
978
|
+
...(flowOpen ? [`<li><button data-ctab="flow"><span class="badge st-skip">${ICON.comment}</span><span><b>${T('flowComments')}</b></span><small>${flowOpen} ${T('openWord')}</small></button></li>`] : [])].join('')}</ul>${more > 0 ? `<button class="linkish" data-cmore>${T('showAllN').replace('%N%', flat.length)}</button>` : ''}` : `<p class="note">${T('noFeedback')}</p>`}`
|
|
979
|
+
: commentsBlock({ scope: tab, nodeId: '' });
|
|
980
|
+
return `<div class="sec c-panel" id="commentsPanel"><h4>${ICON.comment} ${T('commentsTitle')}</h4><div class="seg c-tabs" role="tablist">${tabs.map(([k, l, n]) => `<button role="tab" data-ctab="${k}" aria-pressed="${tab === k}">${esc(l)}${n ? ` <span class="c-count">${n}</span>` : ''}</button>`).join('')}</div>${list}</div>`;
|
|
981
|
+
}
|
|
982
|
+
function wireCommentsPanel () {
|
|
983
|
+
const p = $('commentsPanel'); if (!p) return;
|
|
984
|
+
p.querySelectorAll('[data-ctab]').forEach((b) => { b.onclick = () => { S.cTab = b.dataset.ctab; renderReport(); $('commentsPanel')?.scrollIntoView({ block: 'nearest' }); }; });
|
|
985
|
+
p.querySelectorAll('[data-cgo]').forEach((b) => { b.onclick = () => openThread(b.dataset.cgo, b.dataset.thread); });
|
|
986
|
+
p.querySelector('[data-cmore]')?.addEventListener('click', () => { S.cAll = true; renderReport(); });
|
|
987
|
+
}
|
|
988
|
+
/** Opens the step of a thread and brings the thread into view. */
|
|
989
|
+
function openThread (nodeId, threadId) {
|
|
990
|
+
C.focus = threadId || null; C.showResolved = C.showResolved || !!(threadId && !runOpenThreads().some((r) => r.id === threadId));
|
|
991
|
+
if (nodeId.includes('~') && !byId(nodeId)) { setHash({ step: nodeId }); applyHash(); } else openLb(nodeId, S.lbVp);
|
|
992
|
+
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); }));
|
|
993
|
+
}
|
|
994
|
+
/** Key C in the lightbox (or its comment button): the composer of the step, in view and focused. */
|
|
995
|
+
function focusComposer () { const t = $('lb').querySelector('.c-main .c-text'); if (!t) return; t.scrollIntoView({ behavior: 'smooth', block: 'center' }); t.focus({ preventScroll: true }); }
|
|
996
|
+
/** #comments=all|run|flow: the right panel on the comments. */
|
|
997
|
+
function showCommentsFromHash (which) {
|
|
998
|
+
S.cTab = ['all', 'run', 'flow'].includes(which) ? which : 'all';
|
|
999
|
+
S.right = true; setPanels();
|
|
1000
|
+
requestAnimationFrame(() => $('commentsPanel')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/* ---------- compare (run vs baseline or another run of the scenario) ---------- */
|
|
1004
|
+
const RANKW = { fail:4, needs_review:3, not_evaluated:2, pass:1, not_reached:0 };
|
|
1005
|
+
const worstV = (l) => l.reduce((a, b) => (RANKW[b] > RANKW[a] ? b : a), 'not_reached');
|
|
1006
|
+
function cmpCandidates () {
|
|
1007
|
+
if (isDesign()) {
|
|
1008
|
+
// versions of the flow, the design reference and (from a version) the draft (#42)
|
|
1009
|
+
const refId = RUN.references?.design || RUN.design_reference_id;
|
|
1010
|
+
const out = [];
|
|
1011
|
+
if (!isDraft()) out.push({ id: 'draft', kind: 'draft', started_at: null });
|
|
1012
|
+
if (refId && refId !== RUN.id) out.push({ id: refId, kind: 'designref', started_at: (RUN.versions || []).find((v) => v.id === refId)?.at });
|
|
1013
|
+
for (const v of RUN.versions || []) if (v.id !== RUN.id && !out.some((o) => o.id === v.id)) out.push({ id: v.id, kind: 'version', version: v.version, started_at: v.at });
|
|
1014
|
+
return out.slice(0, 20);
|
|
1015
|
+
}
|
|
1016
|
+
const hist = (RUN.history || []).filter((h) => h.id !== RUN.id).sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
1017
|
+
const out = [];
|
|
1018
|
+
if (RUN.baseline_id && RUN.baseline_id !== RUN.id) out.push({ id: RUN.baseline_id, kind: 'baseline', started_at: hist.find((h) => h.id === RUN.baseline_id)?.started_at });
|
|
1019
|
+
const prev = hist.find((h) => h.started_at < RUN.started_at);
|
|
1020
|
+
if (prev && !out.some((o) => o.id === prev.id)) out.push({ id: prev.id, kind: 'prev', started_at: prev.started_at });
|
|
1021
|
+
for (const h of hist) if (!out.some((o) => o.id === h.id)) out.push({ id: h.id, kind: 'other', started_at: h.started_at, git: h.git });
|
|
1022
|
+
return out.slice(0, 20);
|
|
1023
|
+
}
|
|
1024
|
+
/* Signed file links on the files origin open one run and expire (#29): each run gets its own base,
|
|
1025
|
+
refreshed shortly before expiry or after an image fails, and the new base is swapped into the page. */
|
|
1026
|
+
const linkExp = (base) => Number((base || '').match(/\/f\/(\d+)\./)?.[1] || 0) * 1000;
|
|
1027
|
+
async function signedBase (id) {
|
|
1028
|
+
const app = window.QAWALK_APP;
|
|
1029
|
+
const res = await fetch(`${app.api}/orgs/${app.org}/runs/${id}/files-base`, { credentials: 'same-origin' });
|
|
1030
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1031
|
+
return (await res.json()).base;
|
|
1032
|
+
}
|
|
1033
|
+
async function cmpBase (id) { const app = window.QAWALK_APP; return app ? (app.files ? signedBase(id) : `${app.api}/orgs/${app.org}/runs/${id}/files/`) : `../${id}/`; }
|
|
1034
|
+
let refreshing = null, refreshedAt = 0, refreshTimer;
|
|
1035
|
+
function swapBase (from, to) {
|
|
1036
|
+
if (!from || !to || from === to) return;
|
|
1037
|
+
for (const el of document.querySelectorAll('img[src], a[href]')) { const at = el.tagName === 'IMG' ? 'src' : 'href'; const v = el.getAttribute(at); if (v.startsWith(from)) el.setAttribute(at, to + v.slice(from.length)); }
|
|
1038
|
+
}
|
|
1039
|
+
function refreshLinks () {
|
|
1040
|
+
const app = window.QAWALK_APP;
|
|
1041
|
+
if (!app?.files || refreshing || Date.now() - refreshedAt < 30000) return refreshing;
|
|
1042
|
+
refreshing = (async () => {
|
|
1043
|
+
try {
|
|
1044
|
+
if (isDraft()) { await reloadDraft(); return; }
|
|
1045
|
+
const fb = await apiJson('GET', `${app.api}/orgs/${app.org}/runs/${app.run}/files-base`);
|
|
1046
|
+
const next = fb.base; swapBase(BASE, next); BASE = next;
|
|
1047
|
+
if (fb.live) RUN.live_bases = { ...(RUN.live_bases || {}), ...fb.live };
|
|
1048
|
+
if (S.cmp && linkExp(S.cmp.base)) { const cb = await signedBase(S.cmp.id); swapBase(S.cmp.base, cb); S.cmp.base = cb; }
|
|
1049
|
+
} catch (e) { toast(T('linksExpired')); } finally { refreshedAt = Date.now(); refreshing = null; scheduleRefresh(); }
|
|
1050
|
+
})();
|
|
1051
|
+
return refreshing;
|
|
1052
|
+
}
|
|
1053
|
+
function scheduleRefresh () {
|
|
1054
|
+
clearTimeout(refreshTimer);
|
|
1055
|
+
const exp = linkExp(BASE); if (!exp) return;
|
|
1056
|
+
refreshTimer = setTimeout(refreshLinks, Math.max(0, exp - Date.now() - 5 * 60000));
|
|
1057
|
+
}
|
|
1058
|
+
// a tab that slept past the refresh, or links revoked by an admin: fetch new ones when an image fails
|
|
1059
|
+
document.addEventListener('error', (e) => { const t = e.target; if (t?.tagName === 'IMG' && linkExp(t.getAttribute('src'))) refreshLinks(); }, true);
|
|
1060
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && linkExp(BASE) && linkExp(BASE) - Date.now() < 5 * 60000) refreshLinks(); });
|
|
1061
|
+
async function loadCmp (id) {
|
|
1062
|
+
if (!id) { S.cmp = null; CMP.pix.clear(); renderAll(); if (S.open) openLb(S.open, S.lbVp); else setHash(null); return; }
|
|
1063
|
+
if (id === 'draft' && APP()) {
|
|
1064
|
+
try {
|
|
1065
|
+
const m = await apiJson('GET', scenarioApi('/draft/manifest'));
|
|
1066
|
+
S.cmp = { id, run: m, base: m.files_base, live: m.live_bases }; CMP.pix.clear();
|
|
1067
|
+
renderAll(); if (S.open) openLb(S.open, S.lbVp); else setHash(null);
|
|
1068
|
+
} catch (e) { toast(T('cmpLoadErr')); S.cmp = null; renderAll(); }
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(id)) return;
|
|
1072
|
+
try {
|
|
1073
|
+
const app = window.QAWALK_APP;
|
|
1074
|
+
const res = await fetch(app ? `${app.api}/orgs/${app.org}/runs/${id}` : `../${id}/run.json`, { credentials: 'same-origin' });
|
|
1075
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1076
|
+
const run = await res.json();
|
|
1077
|
+
S.cmp = { id, run, base: await cmpBase(id), live: run.live_bases || null }; CMP.pix.clear();
|
|
1078
|
+
renderAll(); if (S.open) openLb(S.open, S.lbVp); else setHash(null);
|
|
1079
|
+
} catch (e) { toast(T('cmpLoadErr')); S.cmp = null; renderAll(); }
|
|
1080
|
+
}
|
|
1081
|
+
const CMP = { pix: new Map() };
|
|
1082
|
+
let CMP_IX = { nodes: null, map: null };
|
|
1083
|
+
const cmpNode = (id) => { const ns = S.cmp?.run.nodes; if (!ns) return null; if (CMP_IX.nodes !== ns) CMP_IX = { nodes: ns, map: new Map(ns.map((n) => [n.id, n])) }; return CMP_IX.map.get(id) || null; };
|
|
1084
|
+
const cmpLabel = () => { const r = S.cmp?.run; if (!r) return ''; if (isDesign()) return r.draft ? `${T('draft')} #${r.draft.seq}` : `v${r.version}${r.id === (RUN.references?.design || RUN.design_reference_id) ? ` · ${T('designRef')}` : ''}`; const kind = r.id === RUN.baseline_id ? T('cmpBaseline') : prevRun()?.id === r.id ? T('cmpPrev') : ''; return `${kind ? kind + ' · ' : ''}${fmtDate(r.started_at)}${r.git?.commit ? ' · ' + r.git.commit : ''}`; };
|
|
1085
|
+
const cellV = (c, l, vp) => { const r = c?.by_locale?.[l]?.[vp]; return r && r.verdict && r.verdict !== 'not_reached' ? r : null; };
|
|
1086
|
+
/* criteria whose verdict or evidence differ in the current locale (worst over viewports) */
|
|
1087
|
+
function cmpCritChanges (n) {
|
|
1088
|
+
const b = cmpNode(n.id); if (!b) return [];
|
|
1089
|
+
const vps = viewports().map((v) => v.name);
|
|
1090
|
+
const out = [];
|
|
1091
|
+
for (const c of n.criteria) {
|
|
1092
|
+
const bc = (b.criteria || []).find((x) => x.id === c.id);
|
|
1093
|
+
const a = vps.map((vp) => cellV(c, S.loc, vp)), z = vps.map((vp) => cellV(bc, S.loc, vp));
|
|
1094
|
+
const va = a.some(Boolean) ? worstV(a.filter(Boolean).map((r) => r.verdict)) : null, vz = z.some(Boolean) ? worstV(z.filter(Boolean).map((r) => r.verdict)) : null;
|
|
1095
|
+
const ea = a.filter(Boolean).map((r) => r.evidence || r.note || '').join(' | '), ez = z.filter(Boolean).map((r) => r.evidence || r.note || '').join(' | ');
|
|
1096
|
+
if (va !== vz || ea !== ez) out.push({ id: c.id, text: c.text, run: va, base: vz, evRun: ea, evBase: ez });
|
|
1097
|
+
}
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
1100
|
+
function loadImg (src) { return new Promise((res, rej) => { const i = new Image(); if (/^https?:/.test(src) && new URL(src, location.href).origin !== location.origin) i.crossOrigin = 'anonymous'; i.onload = () => res(i); i.onerror = () => rej(new Error('image')); i.src = src; }); }
|
|
1101
|
+
/* pixel diff of two captures scaled to width W: changed pixels in red on a faded copy of the current one */
|
|
1102
|
+
async function pixelDiff (key, srcA, srcB, W) {
|
|
1103
|
+
const k = `${key}|${W}`;
|
|
1104
|
+
if (CMP.pix.has(k)) return CMP.pix.get(k);
|
|
1105
|
+
const job = (async () => {
|
|
1106
|
+
const [a, b] = await Promise.all([loadImg(srcA), loadImg(srcB)]);
|
|
1107
|
+
const ha = Math.round(a.naturalHeight * W / a.naturalWidth), hb = Math.round(b.naturalHeight * W / b.naturalWidth), H = Math.max(ha, hb);
|
|
1108
|
+
const draw = (img, h) => { const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d', { willReadFrequently: true }); x.fillStyle = '#fff'; x.fillRect(0, 0, W, H); x.drawImage(img, 0, 0, W, h); return x.getImageData(0, 0, W, H); };
|
|
1109
|
+
const A = draw(a, ha), B = draw(b, hb);
|
|
1110
|
+
const out = document.createElement('canvas'); out.width = W; out.height = H; const ox = out.getContext('2d'); const O = ox.createImageData(W, H);
|
|
1111
|
+
let n = 0; const da = A.data, db = B.data, dout = O.data;
|
|
1112
|
+
for (let i = 0; i < da.length; i += 4) {
|
|
1113
|
+
const d = Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]);
|
|
1114
|
+
if (d > 48) { n++; dout[i] = 220; dout[i + 1] = 20; dout[i + 2] = 60; dout[i + 3] = 255; }
|
|
1115
|
+
else { dout[i] = 255 - (255 - da[i]) * 0.35; dout[i + 1] = 255 - (255 - da[i + 1]) * 0.35; dout[i + 2] = 255 - (255 - da[i + 2]) * 0.35; dout[i + 3] = 255; }
|
|
1116
|
+
}
|
|
1117
|
+
ox.putImageData(O, 0, 0);
|
|
1118
|
+
return { ratio: n / (W * H), canvas: out, ha, hb, H };
|
|
1119
|
+
})();
|
|
1120
|
+
CMP.pix.set(k, job);
|
|
1121
|
+
job.catch(() => CMP.pix.delete(k));
|
|
1122
|
+
return job;
|
|
1123
|
+
}
|
|
1124
|
+
const pxClass = (r) => r > 0.1 ? 'lv2' : r > 0.01 ? 'lv1' : 'lv0';
|
|
1125
|
+
const pxText = (r) => `${(r * 100).toFixed(r < 0.1 ? 1 : 0)} % ${T('pxChanged')}`;
|
|
1126
|
+
/* fills the per-node pixel badges after the nodes are rendered */
|
|
1127
|
+
function fillPixelBadges () {
|
|
1128
|
+
if (!S.cmp) return;
|
|
1129
|
+
const v = (S.vp === 'mobile' ? mVp() : dVp()) || viewports()[0];
|
|
1130
|
+
// card badges compare the thumbnails (a 256 px diff needs no full screenshots), visible cards first, three at a time;
|
|
1131
|
+
// the step detail still compares the full captures
|
|
1132
|
+
const vpRect = $('vp').getBoundingClientRect();
|
|
1133
|
+
const jobs = [...document.querySelectorAll('[data-pxd]')].map((el) => {
|
|
1134
|
+
const r = el.getBoundingClientRect();
|
|
1135
|
+
const visible = r.right > vpRect.left && r.left < vpRect.right && r.bottom > vpRect.top && r.top < vpRect.bottom;
|
|
1136
|
+
return { el, visible };
|
|
1137
|
+
}).sort((a, b) => Number(b.visible) - Number(a.visible));
|
|
1138
|
+
const run = async ({ el }) => {
|
|
1139
|
+
const n = byId(el.dataset.pxd), b = cmpNode(el.dataset.pxd);
|
|
1140
|
+
const ca = n?.captures?.[S.loc]?.[v.name], cb = b?.captures?.[S.loc]?.[v.name];
|
|
1141
|
+
if (!ca || !cb) { el.textContent = '—'; el.title = T('cmpNoCap'); return; }
|
|
1142
|
+
const small = ca.thumb && cb.thumb;
|
|
1143
|
+
try {
|
|
1144
|
+
const r = await pixelDiff(`${n.id}|${S.loc}|${v.name}${small ? '|thumb' : ''}`, asset(small ? ca.thumb : ca.image || ca.png), S.cmp.base + (small ? cb.thumb : cb.image || cb.png), 256);
|
|
1145
|
+
el.textContent = `◐ ${pxText(r.ratio)}`; el.className = `pxd ${pxClass(r.ratio)}`;
|
|
1146
|
+
} catch { el.textContent = '◐ ?'; el.title = T('pxUnavail'); }
|
|
1147
|
+
};
|
|
1148
|
+
const worker = async () => { for (let j = jobs.shift(); j; j = jobs.shift()) { if (!j.el.isConnected) continue; await run(j); } };
|
|
1149
|
+
Promise.all([worker(), worker(), worker()]);
|
|
1150
|
+
}
|
|
1151
|
+
function renderCmpControl () {
|
|
1152
|
+
const list = cmpCandidates();
|
|
1153
|
+
const ctl = $('cmpCtl');
|
|
1154
|
+
ctl.hidden = !list.length;
|
|
1155
|
+
if (!list.length) return;
|
|
1156
|
+
const kindLabel = { baseline: T('cmpBaseline'), prev: T('cmpPrev'), other: '', draft: T('draft'), designref: T('designRef'), version: '' };
|
|
1157
|
+
$('cmpSel').innerHTML = `<option value="">${T('cmpOff')}</option>` + list.map((c) => `<option value="${esc(c.id)}" ${S.cmp?.id === c.id ? 'selected' : ''}>${kindLabel[c.kind] ? kindLabel[c.kind] + (c.started_at ? ' · ' : '') : ''}${c.version ? `v${esc(c.version)} · ` : ''}${c.started_at ? fmtDate(c.started_at) : c.kind === 'draft' ? '' : esc(c.id)}${c.git?.commit ? ' · ' + esc(c.git.commit) : ''}</option>`).join('');
|
|
1158
|
+
$('cmpSel').onchange = () => loadCmp($('cmpSel').value);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/* ---------- canvas ---------- */
|
|
1162
|
+
const X0 = 80, Y0 = 90;
|
|
1163
|
+
// phones only: the larger phone cards are narrower and taller than the desktop pair
|
|
1164
|
+
const COLW = () => S.vp === 'mobile' ? 420 : 580;
|
|
1165
|
+
const LANEH = () => (S.jev === 'json' ? 640 : 460) + (S.vp === 'mobile' ? 240 : 0);
|
|
1166
|
+
// long flows wrap: a lane with more than WRAP + 2 columns continues in rows of WRAP steps under itself
|
|
1167
|
+
const WRAP = 8;
|
|
1168
|
+
let LANE_ROWS = null;
|
|
1169
|
+
function laneRows () {
|
|
1170
|
+
if (LANE_ROWS) return LANE_ROWS;
|
|
1171
|
+
const maxCol = {}; for (const n of RUN.nodes) maxCol[n.lane || 0] = Math.max(maxCol[n.lane || 0] ?? 0, n.col || 0);
|
|
1172
|
+
const wrap = Math.max(0, ...Object.values(maxCol)) + 1 > WRAP + 2;
|
|
1173
|
+
const count = Math.max(0, ...Object.keys(maxCol).map(Number)) + 1; let top = 0; const rows = [];
|
|
1174
|
+
for (let l = 0; l < count; l++) { const w = wrap ? WRAP : Infinity; rows[l] = { top, wrap: w }; top += wrap ? Math.ceil(((maxCol[l] ?? 0) + 1) / WRAP) : 1; }
|
|
1175
|
+
return (LANE_ROWS = rows);
|
|
1176
|
+
}
|
|
1177
|
+
function nodePos (n) { const L = laneRows()[n.lane || 0] || { top: n.lane || 0, wrap: Infinity }; const col = n.col || 0; const r = Number.isFinite(L.wrap) ? Math.floor(col / L.wrap) : 0; const c = Number.isFinite(L.wrap) ? col % L.wrap : col; return { x: X0 + c * COLW() + (n.kind === 'decision' ? 70 : 0), y: Y0 + (L.top + r) * LANEH() + (n.kind === 'decision' ? 86 : 0) }; }
|
|
1178
|
+
function shot (n, v, w, h) {
|
|
1179
|
+
const c = n.captures?.[S.loc]?.[v.name];
|
|
1180
|
+
if (!c) return `<span class="shot shot--${v.mobile ? 'm' : 'd'} shot--empty" style="width:${w}px;height:${h}px">${T('noCapture')}</span>`;
|
|
1181
|
+
return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}" alt=""><span class="shot__tag">${v.w}</span></span>`;
|
|
1182
|
+
}
|
|
1183
|
+
/* Quick signals of a capture (probabilities of "yes"): which ones deserve a look. Purpose and language are good
|
|
1184
|
+
when high, broken is good when low; the evaluator is calibrated, so 30 % / 70 % are honest cut-offs. */
|
|
1185
|
+
const SIGNALS = [['purpose', 'sigPurpose', false], ['language', 'sigLanguage', false], ['broken', 'sigBroken', true]];
|
|
1186
|
+
const sigBad = (key, p, inverted) => p != null && (inverted ? p >= 0.7 : p <= 0.3);
|
|
1187
|
+
function sigWarnings (cap) { const sg = cap?.signals; if (!sg) return []; return SIGNALS.filter(([k, , inv]) => sigBad(k, sg[k], inv)).map(([k, label]) => `${T(label)}: ${T('sigYes')(Math.round(sg[k] * 100))}`); }
|
|
1188
|
+
function sigBadge (n) {
|
|
1189
|
+
const caps = Object.values(n.captures?.[S.loc] || {});
|
|
1190
|
+
const w = [...new Set(caps.flatMap(sigWarnings))];
|
|
1191
|
+
return w.length ? `<span class="sigb" title="${esc(w.join('\n'))}" aria-label="${esc(T('sigWarn')(w.length))}">⚠ ${w.length}</span>` : '';
|
|
1192
|
+
}
|
|
1193
|
+
function sigSection (cap) {
|
|
1194
|
+
const sg = cap?.signals; if (!sg) return '';
|
|
1195
|
+
const rows = SIGNALS.filter(([k]) => sg[k] != null).map(([k, label, inv]) => { const p = sg[k]; const bad = sigBad(k, p, inv); return `<li class="${bad ? 'bad' : ''}"><span>${bad ? '⚠' : '·'} ${T(label)}</span><span class="gauge gauge--sig"><b style="left:${Math.round(p * 100)}%"></b></span><span class="muted">${T('sigYes')(Math.round(p * 100))}</span></li>`; }).join('');
|
|
1196
|
+
return `<div class="sec"><h4>${T('sigTitle')}</h4><p class="note" style="margin:0 0 6px">${T('sigLead')}</p><ul class="sigs">${rows}</ul></div>`;
|
|
1197
|
+
}
|
|
1198
|
+
function attIcons (n) {
|
|
1199
|
+
const crops = (n.attachments || []).filter((a) => a.type === 'crop' && (!a.locale || a.locale === S.loc)).length;
|
|
1200
|
+
const html = Object.values(n.captures?.[S.loc] || {}).length;
|
|
1201
|
+
return `${crops ? `<span class="att" title="${T('crop')}">${ICON.crop}${crops}</span>` : ''}${html ? `<span class="att" title="${T('html')}">${ICON.html}${html}</span>` : ''}`;
|
|
1202
|
+
}
|
|
1203
|
+
function renderNodes () {
|
|
1204
|
+
LANE_ROWS = null;
|
|
1205
|
+
const pr = prevRun();
|
|
1206
|
+
$('nodes').innerHTML = RUN.nodes.map((n) => {
|
|
1207
|
+
const p = nodePos(n);
|
|
1208
|
+
if (n.kind === 'stack') return stackNodeHtml(n, p);
|
|
1209
|
+
if (n.kind === 'decision') return `<div class="node node--decision" data-id="${esc(n.id)}" style="left:${p.x}px;top:${p.y}px"><span class="diamond"></span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${T('decision')}</div></div></div>`;
|
|
1210
|
+
const s = nodeSt(n), ok = n.criteria.filter((c) => critSt(c) === 'pass').length;
|
|
1211
|
+
const before = pr?.statuses?.[n.id], changed = before && before !== n.status;
|
|
1212
|
+
const d = dVp(), m = mVp();
|
|
1213
|
+
const hd = isDraft() ? headOf(n) : null;
|
|
1214
|
+
const ed = [...PR.editing].find(([, node]) => node === n.id);
|
|
1215
|
+
const edp = ed && PR.peers.get(ed[0]);
|
|
1216
|
+
return `<div class="node node--${esc(n.kind)} st-${s} ${S.sess && n.session !== S.sess ? 'dim' : ''} ${sameAsCmp(n) ? 'is-same' : ''}" data-id="${esc(n.id)}" style="left:${p.x}px;top:${p.y}px" ${sameAsCmp(n) ? `title="${T('unchangedSince')} ${esc(cmpLabel())}"` : ''}>
|
|
1217
|
+
${hd && hd.status !== 'in_progress' ? `<span class="stchip stchip--${esc(hd.status)}">${T('st_' + hd.status)}</span>` : ''}${edp ? `<span class="editing" style="background:${peerColor(edp.email)}">${esc(edp.name || edp.email)}</span>` : ''}
|
|
1218
|
+
${n.session ? sessChip(n.session) : ''}<div class="node__head"><span class="kind">${ICON[n.kind]}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${httpChip(n)}${esc(loc(n.path) || n.captures?.[S.loc]?.[d?.name]?.mail?.subject || '')}</div></div><span class="badge st-${s}">${MARK[s]} ${s === 'skip' ? '—' : `${ok}/${n.criteria.length}`}</span></div>
|
|
1219
|
+
<button class="shots ${S.vp === 'mobile' ? 'shots--mbig' : ''}" data-open="${esc(n.id)}" aria-label="${esc(loc(n.title))}">${S.vp !== 'mobile' && d ? shot(n, d, 256, 160) : ''}${S.vp !== 'desktop' && m ? shot(n, m, 74, 160) : ''}</button>
|
|
1220
|
+
${jevBlock(n)}
|
|
1221
|
+
<div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` : ''}</div>
|
|
1222
|
+
</div>`;
|
|
1223
|
+
}).join('');
|
|
1224
|
+
requestAnimationFrame(() => { drawEdges(); renderMini(); });
|
|
1225
|
+
fillPixelBadges();
|
|
1226
|
+
}
|
|
1227
|
+
// card boxes: measured once per edge drawing (one pass over the cards) instead of a query and four reads per lookup
|
|
1228
|
+
let BOXES = null;
|
|
1229
|
+
function box (id) { if (BOXES) return BOXES.get(id) || null; const el = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`); return el ? { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight } : null; }
|
|
1230
|
+
function drawEdges () {
|
|
1231
|
+
BOXES = new Map(); for (const el of $('nodes').querySelectorAll('.node[data-id]')) BOXES.set(el.dataset.id, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
|
|
1232
|
+
try { return drawEdgesNow(); } finally { BOXES = null; }
|
|
1233
|
+
}
|
|
1234
|
+
const laneNamesOf = () => (RUN.lanes || []).length ? RUN.lanes : sessions().map(sessTitle);
|
|
1235
|
+
/** Box of every lane from its rendered cards; bands and edge corridors use it. */
|
|
1236
|
+
function laneBoxes () {
|
|
1237
|
+
const out = new Map();
|
|
1238
|
+
for (const n of RUN.nodes) { const b = box(n.id); if (!b) continue; const L = out.get(n.lane) || { x1: 1e9, y1: 1e9, x2: 0, y2: 0, n: 0, pass: 0 }; L.x1 = Math.min(L.x1, b.x); L.y1 = Math.min(L.y1, b.y); L.x2 = Math.max(L.x2, b.x + b.w); L.y2 = Math.max(L.y2, b.y + b.h); if (n.kind !== 'decision') { L.n++; if (nodeSt(n) === 'pass') L.pass++; } out.set(n.lane, L); }
|
|
1239
|
+
return out;
|
|
1240
|
+
}
|
|
1241
|
+
// path through points with rounded corners (orthogonal routing)
|
|
1242
|
+
function ortho (pts, r = 12) {
|
|
1243
|
+
let d = `M${pts[0][0]} ${pts[0][1]}`;
|
|
1244
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
1245
|
+
const [x0, y0] = pts[i - 1], [x, y] = pts[i], [x1, y1] = pts[i + 1];
|
|
1246
|
+
const k1 = Math.min(r, Math.hypot(x - x0, y - y0) / 2), k2 = Math.min(r, Math.hypot(x1 - x, y1 - y) / 2);
|
|
1247
|
+
const a = [x - Math.sign(x - x0) * k1, y - Math.sign(y - y0) * k1], b = [x + Math.sign(x1 - x) * k2, y + Math.sign(y1 - y) * k2];
|
|
1248
|
+
d += ` L${a[0]} ${a[1]} Q${x} ${y} ${b[0]} ${b[1]}`;
|
|
1249
|
+
}
|
|
1250
|
+
const [xe, ye] = pts[pts.length - 1]; return d + ` L${xe} ${ye}`;
|
|
1251
|
+
}
|
|
1252
|
+
function drawEdgesNow () {
|
|
1253
|
+
let maxX = 0, maxY = 0; const labels = [];
|
|
1254
|
+
const lanesBox = laneBoxes();
|
|
1255
|
+
const paths = RUN.edges.map((e) => {
|
|
1256
|
+
const A = box(e.from), B = box(e.to), na = byId(e.from), nb = byId(e.to);
|
|
1257
|
+
if (!A || !B) return '';
|
|
1258
|
+
const down = na.kind === 'decision' && nb.lane > na.lane;
|
|
1259
|
+
const below = B.y > A.y + A.h / 2; // next row of a wrapped lane or a lower lane
|
|
1260
|
+
const back = B.x <= A.x && !below;
|
|
1261
|
+
const ay = na.kind === 'decision' ? A.y + A.h / 2 : A.y + 118, by = nb.kind === 'decision' ? B.y + B.h / 2 : B.y + 118;
|
|
1262
|
+
const x1 = down ? A.x + A.w / 2 : A.x + A.w, y1 = down ? A.y + A.h : ay, x2 = B.x, y2 = by;
|
|
1263
|
+
maxX = Math.max(maxX, B.x + B.w, A.x + A.w); maxY = Math.max(maxY, B.y + B.h, A.y + A.h);
|
|
1264
|
+
const LA = lanesBox.get(na.lane), LB = lanesBox.get(nb.lane);
|
|
1265
|
+
const span = (nb.col ?? 0) - (na.col ?? 0);
|
|
1266
|
+
let d, lx, ly;
|
|
1267
|
+
if (back) {
|
|
1268
|
+
// back to an earlier step: along the bottom margin of the lower lane, not across the canvas
|
|
1269
|
+
const low = Math.max(LA?.y2 ?? A.y + A.h, LB?.y2 ?? B.y + B.h) + 22;
|
|
1270
|
+
d = ortho([[A.x + A.w / 2, A.y + A.h], [A.x + A.w / 2, low], [B.x + B.w / 2, low], [B.x + B.w / 2, B.y + B.h + 2]]);
|
|
1271
|
+
lx = (A.x + A.w / 2 + B.x + B.w / 2) / 2; ly = low;
|
|
1272
|
+
} else if (down) { d = `M${x1} ${y1} C${x1} ${y2}, ${x1} ${y2}, ${x2 - 2} ${y2}`; lx = x1; ly = (y1 + y2) / 2; }
|
|
1273
|
+
else if (span > 1 || na.lane !== nb.lane || below) {
|
|
1274
|
+
// long, cross-lane or wrapped edge: out to the gutter, through the gap above the target row, into the target
|
|
1275
|
+
const corridor = (below ? B.y : Math.min(LB ? LB.y1 : B.y, B.y)) - 26, gx1 = x1 + 26, gx2 = x2 - 26;
|
|
1276
|
+
d = ortho([[x1, y1], [gx1, y1], [gx1, corridor], [gx2, corridor], [gx2, y2], [x2 - 2, y2]]);
|
|
1277
|
+
lx = (gx1 + gx2) / 2; ly = corridor;
|
|
1278
|
+
} else { const dx = Math.max(40, (x2 - x1) / 2); d = `M${x1} ${y1} C${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2 - 2} ${y2}`; lx = (x1 + x2) / 2; ly = (y1 + y2) / 2; }
|
|
1279
|
+
if (e.label) labels.push(`<span class="elabel" style="left:${lx}px;top:${ly}px">${esc(e.label)}</span>`);
|
|
1280
|
+
const xs = na.session && nb.session && na.session !== nb.session;
|
|
1281
|
+
return `<path class="${e.kind === 'alt' ? 'alt' : ''} ${xs ? 'xs' : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" d="${d}" marker-end="url(#arr)"/>`;
|
|
1282
|
+
});
|
|
1283
|
+
$('edgeG').innerHTML = paths.join('');
|
|
1284
|
+
$('edges').setAttribute('width', maxX + 100); $('edges').setAttribute('height', maxY + 200);
|
|
1285
|
+
// lanes: a band with a header (title, steps, passed) behind the cards of each lane
|
|
1286
|
+
const names = laneNamesOf();
|
|
1287
|
+
const bands = names.length > 1 ? [...lanesBox.entries()].sort(([a], [b]) => a - b).map(([i, L]) => `<div class="lane-band" data-lane="${i}" style="left:${L.x1 - 24}px;top:${L.y1 - 20}px;width:${L.x2 - L.x1 + 48}px;height:${L.y2 - L.y1 + 40}px"><span class="lane-head">${esc(names[i] || '')}<small>${L.n} ${T('stepsWord')} · ${L.pass}/${L.n} ${T('passedWord')}</small></span></div>`) : [];
|
|
1288
|
+
$('bands').innerHTML = bands.join('');
|
|
1289
|
+
const lanes = names.length > 1 ? [] : names.map((l, i) => { const first = RUN.nodes.filter((n) => n.lane === i).sort((a, b) => a.col - b.col)[0]; return l && first ? `<span class="lane-label" style="left:${nodePos(first).x}px;top:${Y0 + i * LANEH() - 34}px">${esc(l)}</span>` : ''; });
|
|
1290
|
+
$('labels').innerHTML = labels.join('') + lanes.join('');
|
|
1291
|
+
}
|
|
1292
|
+
/** Highlight the edges of one step and dim the rest (hover or the open step). */
|
|
1293
|
+
function hlEdges (id) {
|
|
1294
|
+
const svg = $('edges'); svg.classList.toggle('hl', !!id);
|
|
1295
|
+
svg.querySelectorAll('path[data-from]').forEach((p) => p.classList.toggle('on', !!id && (p.dataset.from === id || p.dataset.to === id)));
|
|
1296
|
+
}
|
|
1297
|
+
function fitLane (i) {
|
|
1298
|
+
const L = laneBoxes().get(i), r = $('vp').getBoundingClientRect(); if (!L || !r.width) return;
|
|
1299
|
+
// the whole lane when it fits at a readable zoom, else its start at the readable zoom
|
|
1300
|
+
const k = Math.max(0.45, Math.min(1, (r.width - 80) / (L.x2 - L.x1 + 48), (r.height - 140) / (L.y2 - L.y1 + 80)));
|
|
1301
|
+
S.view = { k, x: 40 - (L.x1 - 24) * k, y: 70 - (L.y1 - 40) * k }; applyView();
|
|
1302
|
+
}
|
|
1303
|
+
const platformLabel = (p) => ({ android: 'Android', ios: 'iOS', electron: 'Electron', desktop: 'Desktop' })[p] || p;
|
|
1304
|
+
/** Screenshot in the frame of its platform: a phone bezel for Android and iOS, a window for desktop apps. */
|
|
1305
|
+
function frameImg (cap, v, inner, id) {
|
|
1306
|
+
const idAttr = id ? ` id="${id}"` : '';
|
|
1307
|
+
if (cap.platform === 'ios' || cap.platform === 'android') { const w = (cap.size?.[0] || v.w) + 20; return `<div class="lb__img lb__img--dev lb__img--${cap.platform}"${idAttr} style="max-width:${Math.min(w, 460)}px">${inner}</div>`; }
|
|
1308
|
+
if (cap.platform === 'desktop' || cap.platform === 'electron') { const w = cap.size?.[0] || v.w; return `<div class="lb__win" style="max-width:${w}px"><div class="winbar"><i></i><i></i><i></i><span>${esc(cap.device || platformLabel(cap.platform))}</span></div><div class="lb__img"${idAttr}>${inner}</div></div>`; }
|
|
1309
|
+
return `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}"${idAttr} style="max-width:${v.mobile ? v.w + 16 : v.w}px">${inner}</div>`;
|
|
1310
|
+
}
|
|
1311
|
+
/** UI tree of an app capture: an indented, searchable list; clicking an element outlines it on the screenshot. */
|
|
1312
|
+
async function loadTreePanel (cap, img) {
|
|
1313
|
+
const body = $('treeBody'); body.dataset.done = '1'; body.textContent = '…';
|
|
1314
|
+
let t = cap.tree_inline; if (!t) { try { t = await (await fetch(asset(cap.tree))).json(); } catch { body.textContent = T('treeLoadFail'); return; } }
|
|
1315
|
+
if (t.format === 'ocr') $('treeNote').textContent = T('treeOcr');
|
|
1316
|
+
const rows = [];
|
|
1317
|
+
const walk = (nd, depth) => { if (depth > 0 && (nd.name || nd.text || nd.clickable || nd.id)) rows.push({ nd, depth }); for (const c of nd.children || []) walk(c, depth + 1); };
|
|
1318
|
+
walk(t.root, 0);
|
|
1319
|
+
body.innerHTML = rows.map(({ nd, depth }, i) => `<button data-i="${i}" style="padding-left:${10 + Math.min(depth - 1, 14) * 12}px"><span class="r">${esc(nd.role)}</span>${nd.name || nd.text ? ` "${esc((nd.name || nd.text).slice(0, 80))}"` : ''}${nd.id ? ` <span class="id">#${esc(String(nd.id).split('/').pop())}</span>` : ''}${nd.bounds ? ` <span class="id">${nd.bounds.w}×${nd.bounds.h}</span>` : ''}</button>`).join('') || `<p class="note">${T('treeEmpty')}</p>`;
|
|
1320
|
+
const show = (b) => {
|
|
1321
|
+
img?.querySelectorAll('.hlbox.tree-hl').forEach((e) => e.remove());
|
|
1322
|
+
body.querySelectorAll('button.on').forEach((e) => e.classList.remove('on'));
|
|
1323
|
+
const r = rows[Number(b.dataset.i)]; b.classList.add('on'); if (!r?.nd.bounds || !img) return;
|
|
1324
|
+
const k = img.querySelector('img').clientWidth / (cap.size?.[0] || 1); const bb = r.nd.bounds;
|
|
1325
|
+
const el = document.createElement('span'); el.className = 'hlbox on first tree-hl';
|
|
1326
|
+
Object.assign(el.style, { left: `${bb.x * k}px`, top: `${bb.y * k}px`, width: `${bb.w * k}px`, height: `${bb.h * k}px` }); img.appendChild(el);
|
|
1327
|
+
$('lbScroll').scrollTo({ top: Math.max(0, el.offsetTop + img.offsetTop - 80), behavior: 'smooth' });
|
|
1328
|
+
};
|
|
1329
|
+
body.onclick = (e) => { const b = e.target.closest('button[data-i]'); if (b) show(b); };
|
|
1330
|
+
$('treeQ').oninput = () => { const q = $('treeQ').value.trim().toLowerCase(); body.querySelectorAll('button[data-i]').forEach((b) => { b.hidden = !!q && !b.textContent.toLowerCase().includes(q); }); };
|
|
1331
|
+
}
|
|
1332
|
+
/** HTTP status chip on a node when its captured document answered 3xx–5xx (error pages, redirects kept). */
|
|
1333
|
+
function httpChip (n) {
|
|
1334
|
+
const c = n.captures?.[S.loc] || Object.values(n.captures || {})[0] || {};
|
|
1335
|
+
const st = Object.values(c).map((x) => x?.http_status).find((x) => x >= 300);
|
|
1336
|
+
return st ? `<span class="http ${st >= 500 ? 'http--5' : ''}" title="HTTP ${esc(st)}">${esc(st)}</span>` : '';
|
|
1337
|
+
}
|
|
1338
|
+
/* ---------- stacks: one step, many pages of one template (index with search, filters, thumbnails) ---------- */
|
|
1339
|
+
const STACKS = new Map(); // node id → { items, fields, criteria, semantic_sample } once loaded
|
|
1340
|
+
const stackThumb = (it) => { const c = it.captures?.[S.loc] || Object.values(it.captures || {})[0]; const v = c && (c[S.lbVp] || Object.values(c)[0]); return v ? v.thumb || v.png : null; };
|
|
1341
|
+
function stackNodeHtml (n, p) {
|
|
1342
|
+
const st = n.stack || { count: 0, summary: {}, sample: [] }; const s = nodeSt(n); const tot = st.count || 1;
|
|
1343
|
+
const thumbs = (st.sample || []).slice(0, 3).map((x) => x.thumb ? `<img loading="lazy" src="${esc(asset(x.thumb))}" alt="">` : '<span class="ph"></span>').join('') || '<span class="ph"></span><span class="ph"></span><span class="ph"></span>';
|
|
1344
|
+
const bad = (st.summary.fail || 0) + (st.summary.partial || 0);
|
|
1345
|
+
return `<div class="node node--stack st-${s} ${S.sess && n.session !== S.sess ? 'dim' : ''}" data-id="${esc(n.id)}" style="left:${p.x}px;top:${p.y}px">
|
|
1346
|
+
<div class="node__head"><span class="kind">${ICON.stack}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${esc(st.count)} ${T('pagesWord')}${st.source ? ` · ${esc(st.source)}` : ''}</div></div><span class="ok st-${s}">${esc(st.summary.pass || 0)}/${esc(st.count)}</span></div>
|
|
1347
|
+
<button class="stack-fan" data-open="${esc(n.id)}" aria-label="${esc(T('openIndex'))}: ${esc(loc(n.title))}">${thumbs}<span class="count">${esc(st.count)}</span></button>
|
|
1348
|
+
<div class="stack-bar">${['pass', 'partial', 'fail', 'skip'].map((k) => st.summary[k] ? `<i style="width:${st.summary[k] / tot * 100}%;background:var(--${k})"></i>` : '').join('')}</div>
|
|
1349
|
+
<div class="node__foot"><span class="note">${bad ? `${bad} ${T('pagesNeedAttention')}` : T('allPagesPass')}</span>${cmtBadge(n.id)}<span class="note">${T('openIndex')} →</span></div>
|
|
1350
|
+
</div>`;
|
|
1351
|
+
}
|
|
1352
|
+
async function loadStack (n) {
|
|
1353
|
+
if (!n?.stack) return null;
|
|
1354
|
+
if (STACKS.has(n.id)) return STACKS.get(n.id);
|
|
1355
|
+
let data;
|
|
1356
|
+
if (n.stack.items_inline) data = { items: n.stack.items_inline, fields: n.stack.fields, criteria: n.criteria };
|
|
1357
|
+
else { try { const r = await fetch(asset(n.stack.items)); if (!r.ok) throw new Error(r.status); data = await r.json(); } catch (e) { toast(`${T('stackLoadFail')} (${e.message})`); return null; } }
|
|
1358
|
+
data.fields ||= n.stack.fields || []; data.criteria ||= n.criteria;
|
|
1359
|
+
for (const [i, it] of data.items.entries()) { it._i = i; it._text = [it.title, it.path, ...Object.values(it.fields || {})].filter((x) => x != null).join(' ').toLowerCase(); }
|
|
1360
|
+
STACKS.set(n.id, data);
|
|
1361
|
+
return data;
|
|
1362
|
+
}
|
|
1363
|
+
/** A page of a stack as a node-like object for the lightbox (captures, criteria with this page's verdicts). */
|
|
1364
|
+
function ensureItemNode (n, data, it) {
|
|
1365
|
+
const id = `${n.id}~${it.id}`;
|
|
1366
|
+
if (ITEM_NODES.has(id)) return ITEM_NODES.get(id);
|
|
1367
|
+
const crit = (data.criteria || n.criteria).map((c) => {
|
|
1368
|
+
const by_locale = {};
|
|
1369
|
+
for (const [key, res] of Object.entries(it.results || {})) { const [l, vp] = key.split('|'); const r = res[c.id]; if (r) ((by_locale[l] ||= {})[vp] = { verdict: r[0], evidence: r[1], score: r[2] }); }
|
|
1370
|
+
return { id: c.id, text: c.text, type: c.type, pack: c.pack || null, by_locale, evidence: null };
|
|
1371
|
+
}).filter((c) => Object.keys(c.by_locale).length);
|
|
1372
|
+
const node = { id, item: true, itemId: it.id, stack: n.id, kind: 'screen', title: it.title || it.path, path: it.path, purpose: n.purpose, captures: it.captures || {}, criteria: crit, by_locale: it.by_locale || {}, status: it.status, attachments: [], semantic: {}, metadata: {}, execution: n.execution, session: n.session };
|
|
1373
|
+
ITEM_NODES.set(id, node);
|
|
1374
|
+
return node;
|
|
1375
|
+
}
|
|
1376
|
+
const numeric = (v) => typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v));
|
|
1377
|
+
const RANK_ST = { fail: 0, partial: 1, skip: 2, pass: 3 };
|
|
1378
|
+
function stackFilter () {
|
|
1379
|
+
const st = S.st, data = STACKS.get(st.id); const q = st.q.trim().toLowerCase();
|
|
1380
|
+
let list = data.items.filter((it) => (st.status === 'all' || it.status === st.status) && (!st.crit || (it.fails || []).includes(st.crit) || (it.review || []).includes(st.crit)) && (!q || q.split(/\s+/).every((w) => it._text.includes(w))));
|
|
1381
|
+
const dir = st.dir === 'desc' ? -1 : 1;
|
|
1382
|
+
const key = st.sort;
|
|
1383
|
+
if (key === 'status') list = list.slice().sort((a, b) => dir * ((RANK_ST[a.status] ?? 9) - (RANK_ST[b.status] ?? 9)) || a._i - b._i);
|
|
1384
|
+
else if (key === 'path') list = list.slice().sort((a, b) => dir * String(a.path).localeCompare(String(b.path)));
|
|
1385
|
+
else if (key !== 'order') list = list.slice().sort((a, b) => { const x = a.fields?.[key], y = b.fields?.[key]; if (x == null) return 1; if (y == null) return -1; return dir * (numeric(x) && numeric(y) ? Number(x) - Number(y) : String(x).localeCompare(String(y), undefined, { numeric: true })); });
|
|
1386
|
+
st.filtered = list;
|
|
1387
|
+
}
|
|
1388
|
+
async function openStack (id, opts = {}) {
|
|
1389
|
+
const n = byId(id); const data = await loadStack(n); if (!data) return;
|
|
1390
|
+
if (!S.st || S.st.id !== id) S.st = { id, q: '', status: 'all', crit: opts.crit || '', sort: 'order', dir: 'asc', view: 'list', filtered: [], sel: 0 };
|
|
1391
|
+
if (opts.status) S.st.status = opts.status;
|
|
1392
|
+
if (opts.crit) S.st.crit = opts.crit;
|
|
1393
|
+
// in the index layout a stack is a folder of the run index; on the map it opens over the canvas
|
|
1394
|
+
S.st.inline = S.mode === 'index';
|
|
1395
|
+
const st = S.st; const sum = n.stack.summary || {}; const cols = (data.fields || []).filter((f) => f.id !== 'title').slice(0, 4);
|
|
1396
|
+
const crits = (data.criteria || []).map((c) => { const k = n.criteria.find((x) => x.id === c.id)?.counts || {}; return { ...c, bad: (k.fail || 0) + (k.needs_review || 0) }; });
|
|
1397
|
+
const el = st.inline ? $('ri') : $('si');
|
|
1398
|
+
el.innerHTML = `<div class="si__box">
|
|
1399
|
+
<div class="si__head"><span class="kind" style="background:var(--ink);color:var(--panel)">${ICON.stack}</span><div style="flex:1;min-width:0">${st.inline ? `<div class="ri-crumbs"><button id="siUp">${esc(RUN.scenario.title)}</button><span>›</span><b id="siTitle">${esc(loc(n.title))}</b></div>` : `<h3 id="siTitle">${esc(loc(n.title))}</h3>`}<div class="note">${esc(n.stack.count)} ${T('pagesWord')}${n.stack.source ? ` · ${esc(n.stack.source)}` : ''}${n.stack.semantic_sample != null && crits.some((c) => c.type === 'semantic') ? ` · ${T('semanticSample')} ${esc(Math.min(n.stack.semantic_sample, n.stack.count))}` : ''}</div></div>
|
|
1400
|
+
<div class="seg" id="siView"></div><button class="btn" id="siCsv">${T('downloadCsv')}</button>${st.inline ? '' : `<button class="icon-btn" id="siClose" aria-label="Close">${ICON.close}</button>`}</div>
|
|
1401
|
+
<div class="si__tools">
|
|
1402
|
+
${['all', 'fail', 'partial', 'pass', 'skip'].filter((k) => k === 'all' || sum[k]).map((k) => `<button class="si-chip" data-st="${k}" aria-pressed="${st.status === k}">${k === 'all' ? T('allWord') : `${MARK[k]} ${T(k)}`} ${k === 'all' ? esc(n.stack.count) : esc(sum[k] || 0)}</button>`).join('')}
|
|
1403
|
+
<select id="siCrit" aria-label="${esc(T('criterionFilter'))}"><option value="">${T('anyCriterion')}</option>${crits.map((c) => `<option value="${esc(c.id)}" ${st.crit === c.id ? 'selected' : ''}>${esc(c.id)} ${esc(c.text)}${c.bad ? ` (${c.bad})` : ''}</option>`).join('')}</select>
|
|
1404
|
+
<input type="search" id="siQ" placeholder="${esc(T('searchPages'))}" value="${esc(st.q)}" aria-label="${esc(T('searchPages'))}">
|
|
1405
|
+
<select id="siSort" aria-label="${esc(T('sortBy'))}"><option value="order">${T('sortOrder')}</option><option value="status">${T('sortStatus')}</option><option value="path">URL</option>${(data.fields || []).map((f) => `<option value="${esc(f.id)}">${esc(f.label || f.id)}</option>`).join('')}</select>
|
|
1406
|
+
<button class="icon-btn" id="siDir" aria-label="${esc(T('sortDir'))}">${st.dir === 'asc' ? '↑' : '↓'}</button>
|
|
1407
|
+
</div>
|
|
1408
|
+
<div class="si__list" id="siList"><div class="si-headrow" id="siHead" style="--si-cols:repeat(${cols.length || 1},minmax(48px,1fr))"><span></span><button data-sort="path">${T('pageWord')}</button><button data-sort="status">${T('statusWord')}</button>${cols.map((f) => { const vals = data.items.slice(0, 50).map((it) => it.fields?.[f.id]).filter((x) => x != null && x !== ''); return `<button data-sort="${esc(f.id)}" class="${vals.length && vals.every(numeric) ? 'num' : ''}">${esc(f.label || f.id)}</button>`; }).join('') || '<span></span>'}<span>${T('failingCriteria')}</span></div><div class="si__spacer" id="siSpacer"><div id="siRows"></div></div></div>
|
|
1409
|
+
<div class="si__foot"><span id="siCount"></span><span class="spacer" style="flex:1"></span><span>${T('stackKeys')}</span></div>
|
|
1410
|
+
</div>`;
|
|
1411
|
+
el.hidden = false;
|
|
1412
|
+
$('siSort').value = st.sort;
|
|
1413
|
+
seg($('siView'), [['list', T('listView')], ['grid', T('gridView')]], st.view, (v) => { st.view = v; $('siHead').hidden = v === 'grid'; $('siList').scrollTop = 0; paintStack(); });
|
|
1414
|
+
$('siHead').hidden = st.view === 'grid';
|
|
1415
|
+
const refresh = () => { stackFilter(); st.sel = 0; $('siList').scrollTop = 0; paintStack(); };
|
|
1416
|
+
el.querySelectorAll('[data-st]').forEach((b) => { b.onclick = () => { st.status = b.dataset.st; el.querySelectorAll('[data-st]').forEach((x) => x.setAttribute('aria-pressed', x === b)); refresh(); }; });
|
|
1417
|
+
$('siCrit').onchange = () => { st.crit = $('siCrit').value; refresh(); };
|
|
1418
|
+
let tq; $('siQ').oninput = () => { clearTimeout(tq); tq = setTimeout(() => { st.q = $('siQ').value; refresh(); }, 120); };
|
|
1419
|
+
$('siSort').onchange = () => { st.sort = $('siSort').value; refresh(); };
|
|
1420
|
+
$('siDir').onclick = () => { st.dir = st.dir === 'asc' ? 'desc' : 'asc'; $('siDir').textContent = st.dir === 'asc' ? '↑' : '↓'; refresh(); };
|
|
1421
|
+
$('siHead').onclick = (e) => { const b = e.target.closest('[data-sort]'); if (!b) return; if (st.sort === b.dataset.sort) st.dir = st.dir === 'asc' ? 'desc' : 'asc'; else { st.sort = b.dataset.sort; st.dir = 'asc'; } $('siSort').value = st.sort; $('siDir').textContent = st.dir === 'asc' ? '↑' : '↓'; refresh(); };
|
|
1422
|
+
if ($('siClose')) $('siClose').onclick = closeStack;
|
|
1423
|
+
if ($('siUp')) $('siUp').onclick = () => closeStack();
|
|
1424
|
+
$('siCsv').onclick = () => stackCsv(n, data);
|
|
1425
|
+
$('siList').onscroll = () => requestAnimationFrame(paintStack);
|
|
1426
|
+
$('siRows').onclick = (e) => { const b = e.target.closest('[data-item]'); if (b) openStackItem(Number(b.dataset.idx)); };
|
|
1427
|
+
stackFilter();
|
|
1428
|
+
if (opts.focus) { const k = st.filtered.findIndex((it) => it.id === opts.focus); if (k >= 0) { st.sel = k; requestAnimationFrame(() => { scrollStackTo(k); }); } }
|
|
1429
|
+
paintStack();
|
|
1430
|
+
setHash({ step: id });
|
|
1431
|
+
requestAnimationFrame(() => $('siQ')?.focus());
|
|
1432
|
+
}
|
|
1433
|
+
const ROW_H = 72, CARD_W = 232, CARD_H = 212;
|
|
1434
|
+
function scrollStackTo (k) { const box = $('siList'); if (!box) return; if (S.st.view === 'grid') { const cols = Math.max(1, Math.floor((box.clientWidth - 24) / CARD_W)); box.scrollTop = Math.floor(k / cols) * CARD_H; } else box.scrollTop = Math.max(0, k * ROW_H - box.clientHeight / 2); paintStack(); }
|
|
1435
|
+
function paintStack () {
|
|
1436
|
+
const st = S.st; const box = $('siList'); if (!st || !box || (st.inline ? $('ri').hidden : $('si').hidden)) return;
|
|
1437
|
+
const data = STACKS.get(st.id); const n = byId(st.id); const cols = (data.fields || []).filter((f) => f.id !== 'title').slice(0, 4);
|
|
1438
|
+
const list = st.filtered; const head = st.view === 'grid' ? 0 : $('siHead').offsetHeight;
|
|
1439
|
+
$('siCount').textContent = `${list.length} / ${data.items.length} ${T('pagesWord')}`;
|
|
1440
|
+
const img = (it) => { const t = stackThumb(it); return t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'; };
|
|
1441
|
+
const fails = (it) => `<span class="si-fails">${(it.fails || []).map((id) => `<span title="${esc((data.criteria || []).find((c) => c.id === id)?.text || '')}">${esc(id)}</span>`).join('')}${(it.review || []).slice(0, 3).map((id) => `<span class="rv" title="${esc(T('needs_review'))}">${esc(id)}</span>`).join('')}</span>`;
|
|
1442
|
+
if (!list.length) { $('siSpacer').style.height = '0px'; $('siRows').innerHTML = `<div class="si__empty">${T('noPagesMatch')}</div>`; return; }
|
|
1443
|
+
let html = '';
|
|
1444
|
+
if (st.view === 'grid') {
|
|
1445
|
+
const per = Math.max(1, Math.floor((box.clientWidth - 24) / CARD_W)); const rows = Math.ceil(list.length / per);
|
|
1446
|
+
$('siSpacer').style.height = `${rows * CARD_H + 16}px`;
|
|
1447
|
+
const r0 = Math.max(0, Math.floor(box.scrollTop / CARD_H) - 2), r1 = Math.min(rows, Math.ceil((box.scrollTop + box.clientHeight) / CARD_H) + 2);
|
|
1448
|
+
for (let k = r0 * per; k < Math.min(list.length, r1 * per); k++) { const it = list[k]; html += `<button class="si-card st-${esc(it.status)} ${k === st.sel ? 'is-sel' : ''}" data-item="${esc(it.id)}" data-idx="${k}" style="top:${Math.floor(k / per) * CARD_H + 12}px;left:${(k % per) * CARD_W + 12}px">${img(it)}<b><span class="badge st-${esc(it.status)}">${MARK[it.status] || ''}</span> ${esc(it.title || it.path)}</b><small>${esc(it.path)}</small></button>`; }
|
|
1449
|
+
} else {
|
|
1450
|
+
$('siSpacer').style.height = `${list.length * ROW_H}px`;
|
|
1451
|
+
const top = Math.max(0, box.scrollTop - head);
|
|
1452
|
+
const k0 = Math.max(0, Math.floor(top / ROW_H) - 8), k1 = Math.min(list.length, Math.ceil((top + box.clientHeight) / ROW_H) + 8);
|
|
1453
|
+
for (let k = k0; k < k1; k++) {
|
|
1454
|
+
const it = list[k];
|
|
1455
|
+
html += `<button class="si-row st-${esc(it.status)} ${k === st.sel ? 'is-sel' : ''}" data-item="${esc(it.id)}" data-idx="${k}" style="top:${k * ROW_H}px;--si-cols:repeat(${cols.length || 1},minmax(48px,1fr))">${img(it)}<span class="t"><b>${esc(it.title || it.fields?.title || it.path)}</b><small>${esc(it.path)}</small></span><span class="badge st-${esc(it.status)}">${MARK[it.status] || ''} ${esc(T(it.status))}</span>${cols.map((f) => { const v = it.fields?.[f.id]; return `<span class="f ${numeric(v) ? 'num' : ''}" title="${esc(v ?? '')}">${v == null || v === '' ? '—' : esc(v)}</span>`; }).join('') || '<span></span>'}${fails(it)}</button>`;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
$('siRows').innerHTML = html;
|
|
1459
|
+
}
|
|
1460
|
+
function openStackItem (k) {
|
|
1461
|
+
const st = S.st; const n = byId(st.id); const data = STACKS.get(st.id); const it = st.filtered[k]; if (!it) return;
|
|
1462
|
+
st.sel = k;
|
|
1463
|
+
S.itemList = st.filtered.map((x) => ensureItemNode(n, data, x).id);
|
|
1464
|
+
closeStack(true);
|
|
1465
|
+
openLb(`${n.id}~${it.id}`);
|
|
1466
|
+
}
|
|
1467
|
+
function closeStack (keepHash) {
|
|
1468
|
+
if (S.st?.inline) { if (keepHash === true) return; const id = S.st.id; S.st = null; riRender({ focus: id }); setHash(null); return; } // folder → back to the run index
|
|
1469
|
+
$('si').hidden = true; if (keepHash !== true) setHash(null);
|
|
1470
|
+
}
|
|
1471
|
+
function stackCsv (n, data) {
|
|
1472
|
+
const cols = data.fields || [];
|
|
1473
|
+
const q = (v) => { const x = v == null ? '' : String(v); return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; };
|
|
1474
|
+
const rows = [['path', 'url', 'status', 'failing', 'review', ...cols.map((f) => f.label || f.id)], ...S.st.filtered.map((it) => [it.path, it.url, it.status, (it.fails || []).join(' '), (it.review || []).join(' '), ...cols.map((f) => it.fields?.[f.id])])];
|
|
1475
|
+
const blob = new Blob(['' + rows.map((r) => r.map(q).join(',')).join('\n')], { type: 'text/csv;charset=utf-8' });
|
|
1476
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${RUN.scenario?.id || 'run'}-${n.id}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
1477
|
+
}
|
|
1478
|
+
function stackKey (e) {
|
|
1479
|
+
const st = S.st; if (!st) return false;
|
|
1480
|
+
if (e.key === 'Escape') { closeStack(); return true; }
|
|
1481
|
+
if (e.key === '/' && document.activeElement?.id !== 'siQ') { e.preventDefault(); $('siQ').focus(); return true; }
|
|
1482
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); st.sel = Math.max(0, Math.min(st.filtered.length - 1, st.sel + (e.key === 'ArrowDown' ? 1 : -1))); scrollStackTo(st.sel); return true; }
|
|
1483
|
+
if (e.key === 'Enter' && document.activeElement?.tagName !== 'BUTTON') { e.preventDefault(); openStackItem(st.sel); return true; }
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/* ---------- run index: the run as a list (alternative to the map); stacks are folders inside it ---------- */
|
|
1488
|
+
const RI_H = { group: 36, row: 72 };
|
|
1489
|
+
function setMode (m) {
|
|
1490
|
+
S.mode = m === 'index' ? 'index' : 'map';
|
|
1491
|
+
try { localStorage.setItem('qawalk-mode', S.mode); } catch {}
|
|
1492
|
+
applyMode();
|
|
1493
|
+
if (!S.open) setHash(S.st?.inline ? { step: S.st.id } : null);
|
|
1494
|
+
}
|
|
1495
|
+
function applyMode () {
|
|
1496
|
+
const idx = S.mode === 'index';
|
|
1497
|
+
seg($('modeSeg'), [['map', T('mapView')], ['index', T('indexView')]], S.mode, setMode);
|
|
1498
|
+
$('vp').hidden = idx; $('ri').hidden = !idx;
|
|
1499
|
+
if (idx) { if (!$('si').hidden) { $('si').hidden = true; $('si').innerHTML = ''; if (S.st) { const id = S.st.id; S.st = null; openStack(id); return; } } if (S.st?.inline) openStack(S.st.id); else riRender(); }
|
|
1500
|
+
else { if (S.st?.inline) S.st = null; $('ri').innerHTML = ''; requestAnimationFrame(startView); }
|
|
1501
|
+
}
|
|
1502
|
+
const riText = (n) => (n._riText ||= [loc(n.title), loc(n.path), loc(n.purpose), n.id, ...(n.criteria || []).map((c) => `${c.id} ${c.text}`)].filter(Boolean).join(' ').toLowerCase());
|
|
1503
|
+
const riThumb = (n) => { const c = n.captures?.[S.loc] || {}; const pref = (S.vp === 'mobile' ? mVp() : dVp())?.name; const v = c[pref] || Object.values(c)[0]; return v ? v.thumb || v.image || v.png : null; };
|
|
1504
|
+
function riEntries () {
|
|
1505
|
+
const r = S.ri; const words = r.q.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1506
|
+
let steps = screens().filter((n) => (r.status === 'all' || nodeSt(n) === r.status) && (!S.sess || n.session === S.sess));
|
|
1507
|
+
const pages = new Map(); let waiting = false;
|
|
1508
|
+
if (words.length) {
|
|
1509
|
+
for (const n of screens().filter((x) => x.kind === 'stack')) {
|
|
1510
|
+
const d = STACKS.get(n.id);
|
|
1511
|
+
if (!d) { waiting = true; loadStack(n).then(() => { if (S.mode === 'index' && !S.st?.inline) riPaint(); }); continue; }
|
|
1512
|
+
const hit = d.items.filter((it) => words.every((w) => it._text.includes(w)) && (r.status === 'all' || it.status === r.status));
|
|
1513
|
+
if (hit.length) pages.set(n.id, hit);
|
|
1514
|
+
}
|
|
1515
|
+
steps = screens().filter((n) => (!S.sess || n.session === S.sess) && ((words.every((w) => riText(n).includes(w)) && (r.status === 'all' || nodeSt(n) === r.status)) || pages.has(n.id)));
|
|
1516
|
+
}
|
|
1517
|
+
const order = flowOrder().filter((n) => steps.includes(n));
|
|
1518
|
+
const dir = r.dir === 'desc' ? -1 : 1;
|
|
1519
|
+
let list = order;
|
|
1520
|
+
if (r.sort === 'status') list = order.slice().sort((a, b) => dir * ((RANK_ST[nodeSt(a)] ?? 9) - (RANK_ST[nodeSt(b)] ?? 9)));
|
|
1521
|
+
else if (r.sort === 'title') list = order.slice().sort((a, b) => dir * String(loc(a.title)).localeCompare(String(loc(b.title))));
|
|
1522
|
+
else if (dir < 0) list = order.slice().reverse();
|
|
1523
|
+
const out = []; const push = (n) => { out.push({ type: 'step', n }); for (const it of (pages.get(n.id) || []).slice(0, 200)) out.push({ type: 'page', n, it }); };
|
|
1524
|
+
const lanes = laneNamesOf();
|
|
1525
|
+
if (r.sort === 'order' && lanes.length > 1 && !words.length) {
|
|
1526
|
+
lanes.forEach((name, i) => { const mine = list.filter((n) => (RUN.lanes?.length ? n.lane === i : n.session === sessions()[i])); if (!mine.length) return; out.push({ type: 'group', label: name, pass: mine.filter((n) => nodeSt(n) === 'pass').length, total: mine.length }); mine.forEach(push); });
|
|
1527
|
+
list.filter((n) => !out.some((e) => e.n === n)).forEach(push);
|
|
1528
|
+
} else list.forEach(push);
|
|
1529
|
+
return { out, waiting };
|
|
1530
|
+
}
|
|
1531
|
+
function riRender (opts = {}) {
|
|
1532
|
+
S.ri ||= { q: '', status: 'all', sort: 'order', dir: 'asc', sel: 0 };
|
|
1533
|
+
const r = S.ri; const sum = RUN.summary || {}; const all = screens();
|
|
1534
|
+
const count = (k) => all.filter((n) => nodeSt(n) === k).length;
|
|
1535
|
+
$('ri').innerHTML = `<div class="si__box">
|
|
1536
|
+
<div class="si__head"><span class="kind" style="background:var(--ink);color:var(--panel)">${ICON.stack}</span><div style="flex:1;min-width:0"><h3>${esc(RUN.scenario.title)}</h3><div class="note">${all.length} ${T('stepsWord')} · ${esc(sum.pass || 0)} ${T('passedWord')}${all.some((n) => n.kind === 'stack') ? ` · ${all.filter((n) => n.kind === 'stack').reduce((k, n) => k + (n.stack?.count || 0), 0)} ${T('pagesWord')}` : ''}</div></div>
|
|
1537
|
+
<button class="btn" id="riCsv">${T('downloadCsv')}</button></div>
|
|
1538
|
+
<div class="si__tools">
|
|
1539
|
+
${['all', 'fail', 'partial', 'pass', 'skip'].filter((k) => k === 'all' || count(k)).map((k) => `<button class="si-chip" data-st="${k}" aria-pressed="${r.status === k}">${k === 'all' ? T('allWord') : `${MARK[k]} ${T(k)}`} ${k === 'all' ? all.length : count(k)}</button>`).join('')}
|
|
1540
|
+
<input type="search" id="riQ" placeholder="${esc(T('searchSteps'))}" value="${esc(r.q)}" aria-label="${esc(T('searchSteps'))}">
|
|
1541
|
+
<select id="riSort" aria-label="${esc(T('sortBy'))}"><option value="order">${T('sortOrder')}</option><option value="status">${T('sortStatus')}</option><option value="title">${T('sortTitle')}</option></select>
|
|
1542
|
+
<button class="icon-btn" id="riDir" aria-label="${esc(T('sortDir'))}">${r.dir === 'asc' ? '↑' : '↓'}</button>
|
|
1543
|
+
</div>
|
|
1544
|
+
<div class="si__list" id="riList"><div class="si-headrow ri-head" id="riHead"><span></span><span>${T('step')}</span><span>${T('statusWord')}</span><span class="num">${T('criteriaCol')}</span><span>${T('failingCriteria')}</span><span>${T('notesCol')}</span></div><div class="si__spacer" id="riSpacer"><div id="riRows"></div></div></div>
|
|
1545
|
+
<div class="si__foot"><span id="riCount"></span><span class="spacer" style="flex:1"></span><span>${T('riKeys')}</span></div>
|
|
1546
|
+
</div>`;
|
|
1547
|
+
$('riSort').value = r.sort;
|
|
1548
|
+
const refresh = () => { r.sel = 0; $('riList').scrollTop = 0; riPaint(); };
|
|
1549
|
+
$('ri').querySelectorAll('[data-st]').forEach((b) => { b.onclick = () => { r.status = b.dataset.st; $('ri').querySelectorAll('[data-st]').forEach((x) => x.setAttribute('aria-pressed', x === b)); refresh(); }; });
|
|
1550
|
+
let tq; $('riQ').oninput = () => { clearTimeout(tq); tq = setTimeout(() => { r.q = $('riQ').value; refresh(); }, 120); };
|
|
1551
|
+
$('riSort').onchange = () => { r.sort = $('riSort').value; refresh(); };
|
|
1552
|
+
$('riDir').onclick = () => { r.dir = r.dir === 'asc' ? 'desc' : 'asc'; $('riDir').textContent = r.dir === 'asc' ? '↑' : '↓'; refresh(); };
|
|
1553
|
+
$('riList').onscroll = () => requestAnimationFrame(riPaint);
|
|
1554
|
+
$('riRows').onclick = (e) => { const b = e.target.closest('[data-k]'); if (b) riOpen(Number(b.dataset.k)); };
|
|
1555
|
+
$('riCsv').onclick = riCsv;
|
|
1556
|
+
riPaint();
|
|
1557
|
+
if (opts.focus) { const k = S.ri.entries.findIndex((x) => x.type === 'step' && x.n.id === opts.focus); if (k >= 0) { r.sel = k; riScrollTo(k); } }
|
|
1558
|
+
}
|
|
1559
|
+
function riPaint () {
|
|
1560
|
+
const box = $('riList'); if (!box || S.mode !== 'index' || S.st?.inline) return;
|
|
1561
|
+
const { out, waiting } = riEntries(); S.ri.entries = out;
|
|
1562
|
+
const tops = []; let y = 0; for (const e of out) { tops.push(y); y += e.type === 'group' ? RI_H.group : RI_H.row; }
|
|
1563
|
+
S.ri.tops = tops;
|
|
1564
|
+
const nSteps = out.filter((e) => e.type === 'step').length, nPages = out.filter((e) => e.type === 'page').length;
|
|
1565
|
+
$('riCount').textContent = `${nSteps} / ${screens().length} ${T('stepsWord')}${nPages ? ` · ${nPages} ${T('pagesWord')}` : ''}${waiting ? ` · ${T('loadingPages')}` : ''}`;
|
|
1566
|
+
if (!out.length) { $('riSpacer').style.height = '0px'; $('riRows').innerHTML = `<div class="si__empty">${T('noStepsMatch')}</div>`; return; }
|
|
1567
|
+
$('riSpacer').style.height = `${y}px`;
|
|
1568
|
+
const head = $('riHead').offsetHeight; const top = Math.max(0, box.scrollTop - head); const bottom = top + box.clientHeight;
|
|
1569
|
+
let html = '';
|
|
1570
|
+
for (let k = 0; k < out.length; k++) {
|
|
1571
|
+
const h = out[k].type === 'group' ? RI_H.group : RI_H.row;
|
|
1572
|
+
if (tops[k] + h < top - 400 || tops[k] > bottom + 400) continue;
|
|
1573
|
+
html += riRow(out[k], k, tops[k]);
|
|
1574
|
+
}
|
|
1575
|
+
$('riRows').innerHTML = html;
|
|
1576
|
+
}
|
|
1577
|
+
function riRow (e, k, top) {
|
|
1578
|
+
const sel = k === S.ri.sel ? 'is-sel' : '';
|
|
1579
|
+
if (e.type === 'group') return `<div class="ri-group" style="top:${top}px">${esc(e.label)} <small>${e.pass}/${e.total}</small></div>`;
|
|
1580
|
+
if (e.type === 'page') {
|
|
1581
|
+
const it = e.it; const t = stackThumb(it);
|
|
1582
|
+
return `<button class="si-row ri-row is-page st-${esc(it.status)} ${sel}" data-k="${k}" style="top:${top}px">${t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'}<span class="t"><b>${esc(it.title || it.fields?.title || it.path)}</b><small>${esc(loc(e.n.title))} › ${esc(it.path)}</small></span><span class="badge st-${esc(it.status)}">${MARK[it.status] || ''} ${esc(T(it.status))}</span><span></span><span class="si-fails">${(it.fails || []).map((id) => `<span>${esc(id)}</span>`).join('')}${(it.review || []).slice(0, 3).map((id) => `<span class="rv">${esc(id)}</span>`).join('')}</span><span class="ri-x">${it.fields?.http >= 300 ? `<span class="http ${it.fields.http >= 500 ? 'http--5' : ''}">${esc(it.fields.http)}</span>` : ''}</span></button>`;
|
|
1583
|
+
}
|
|
1584
|
+
const n = e.n; const s = nodeSt(n); const crit = n.criteria || [];
|
|
1585
|
+
const ok = crit.filter((c) => critSt(c) === 'pass').length;
|
|
1586
|
+
const bad = crit.filter((c) => !['pass', 'not_reached'].includes(critSt(c)));
|
|
1587
|
+
const thumb = n.kind === 'stack'
|
|
1588
|
+
? `<span class="ri-fan">${((n.stack?.sample || []).slice(0, 3).map((x) => x.thumb ? `<img loading="lazy" src="${esc(asset(x.thumb))}" alt="">` : '<span class="ph"></span>').join('') || '<span class="ph"></span><span class="ph"></span><span class="ph"></span>')}<span class="cnt">${esc(n.stack?.count || 0)}</span></span>`
|
|
1589
|
+
: (() => { const t = riThumb(n); return t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'; })();
|
|
1590
|
+
const sub = n.kind === 'stack' ? `${esc(n.stack?.count || 0)} ${T('pagesWord')}${n.stack?.source ? ` · ${esc(n.stack.source)}` : ''}` : esc(loc(n.path) || '');
|
|
1591
|
+
const cons = Object.values(n.captures?.[S.loc] || {}).reduce((k, c) => k + (c?.console_errors || 0), 0);
|
|
1592
|
+
const chips = bad.map((c) => `<span class="${critSt(c) === 'fail' ? '' : 'rv'}" title="${esc(c.text)}">${esc(c.id)}${c.counts ? `<i>${esc((c.counts.fail || 0) + (c.counts.needs_review || 0))}</i>` : ''}</span>`).join('');
|
|
1593
|
+
const crits = n.kind === 'stack' ? `${esc(n.stack?.summary?.pass || 0)}/${esc(n.stack?.count || 0)}` : s === 'skip' ? '—' : `${ok}/${crit.length}`;
|
|
1594
|
+
return `<button class="si-row ri-row st-${s} ${sel}" data-k="${k}" data-step="${esc(n.id)}" style="top:${top}px">${thumb}<span class="t"><b><span class="kind">${ICON[n.kind] || ''}</span>${esc(loc(n.title))}</b><small>${sub}</small></span><span class="badge st-${s}">${MARK[s]} ${esc(T(s))}</span><span class="num">${crits}</span><span class="si-fails">${chips}</span><span class="ri-x">${cmtBadge(n.id)}${httpChip(n)}${cons ? `<span title="${esc(T('consoleWord'))}">⚠ ${cons}</span>` : ''}${n.session ? sessChip(n.session) : ''}</span></button>`;
|
|
1595
|
+
}
|
|
1596
|
+
function riScrollTo (k) { const box = $('riList'); if (!box || !S.ri.tops) return; box.scrollTop = Math.max(0, S.ri.tops[k] - box.clientHeight / 2); riPaint(); }
|
|
1597
|
+
function riOpen (k) {
|
|
1598
|
+
const e = S.ri.entries?.[k]; if (!e || e.type === 'group') return;
|
|
1599
|
+
S.ri.sel = k;
|
|
1600
|
+
if (e.type === 'page') { const d = STACKS.get(e.n.id); S.itemList = S.ri.entries.filter((x) => x.type === 'page' && x.n === e.n).map((x) => ensureItemNode(e.n, d, x.it).id); openLb(`${e.n.id}~${e.it.id}`); return; }
|
|
1601
|
+
if (e.n.kind === 'stack') { openStack(e.n.id); return; }
|
|
1602
|
+
S.riList = S.ri.entries.filter((x) => x.type === 'step' && x.n.kind !== 'stack').map((x) => x.n.id);
|
|
1603
|
+
openLb(e.n.id);
|
|
1604
|
+
}
|
|
1605
|
+
/** From the sidebar or the report: select the step in the index (a stack opens as a folder). */
|
|
1606
|
+
function riFocus (id) {
|
|
1607
|
+
const n = byId(id); if (!n) return;
|
|
1608
|
+
if (n.kind === 'stack') { openStack(id); return; }
|
|
1609
|
+
if (S.st?.inline) S.st = null;
|
|
1610
|
+
S.ri ||= { q: '', status: 'all', sort: 'order', dir: 'asc', sel: 0 };
|
|
1611
|
+
if (S.ri.status !== 'all' && nodeSt(n) !== S.ri.status) S.ri.status = 'all';
|
|
1612
|
+
if (S.ri.q && !riText(n).includes(S.ri.q.toLowerCase())) S.ri.q = '';
|
|
1613
|
+
riRender({ focus: id }); setHash(null);
|
|
1614
|
+
requestAnimationFrame(() => document.querySelector('.ri-row.is-sel')?.focus({ preventScroll: true }));
|
|
1615
|
+
}
|
|
1616
|
+
function riKey (e) {
|
|
1617
|
+
const r = S.ri; if (!r?.entries) return false;
|
|
1618
|
+
const typing = e.target.closest('input,select,textarea');
|
|
1619
|
+
if (e.key === '/' && !typing) { e.preventDefault(); $('riQ').focus(); return true; }
|
|
1620
|
+
if (e.key === 'Escape' && typing) { e.target.blur(); return true; }
|
|
1621
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
1622
|
+
e.preventDefault(); let k = r.sel;
|
|
1623
|
+
do { k += e.key === 'ArrowDown' ? 1 : -1; } while (r.entries[k]?.type === 'group');
|
|
1624
|
+
if (r.entries[k]) { r.sel = k; riScrollTo(k); }
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
if (e.key === 'Enter' && (!typing || e.target.id === 'riQ')) { const b = e.target.closest('button,a'); if (b && !b.closest('.ri-row, #steps')) return false; e.preventDefault(); riOpen(r.sel); return true; }
|
|
1628
|
+
return false;
|
|
1629
|
+
}
|
|
1630
|
+
function riCsv () {
|
|
1631
|
+
const q = (v) => { const x = v == null ? '' : String(v); return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; };
|
|
1632
|
+
const rows = [['step', 'title', 'kind', 'path', 'status', 'criteria_passed', 'criteria', 'failing', 'http', 'console_errors', 'pages']];
|
|
1633
|
+
for (const e of S.ri.entries || []) {
|
|
1634
|
+
if (e.type === 'page') { rows.push([`${e.n.id}~${e.it.id}`, e.it.title || '', 'page', e.it.path, e.it.status, '', '', (e.it.fails || []).join(' '), e.it.fields?.http ?? '', '', '']); continue; }
|
|
1635
|
+
if (e.type !== 'step') continue;
|
|
1636
|
+
const n = e.n; const crit = n.criteria || [];
|
|
1637
|
+
const http = Object.values(n.captures?.[S.loc] || {}).map((c) => c?.http_status).find(Boolean) ?? '';
|
|
1638
|
+
rows.push([n.id, loc(n.title), n.kind, loc(n.path) || '', nodeSt(n), crit.filter((c) => critSt(c) === 'pass').length, crit.length, crit.filter((c) => !['pass', 'not_reached'].includes(critSt(c))).map((c) => c.id).join(' '), http, Object.values(n.captures?.[S.loc] || {}).reduce((k, c) => k + (c?.console_errors || 0), 0), n.stack?.count ?? '']);
|
|
1639
|
+
}
|
|
1640
|
+
const blob = new Blob(['' + rows.map((r) => r.map(q).join(',')).join('\n')], { type: 'text/csv;charset=utf-8' });
|
|
1641
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${RUN.scenario?.id || 'run'}-index.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/* ---------- live presence (#21): cursors and follow mode over a WebSocket, signed-in members only ---------- */
|
|
1645
|
+
const PR = { ws: null, me: null, peers: new Map(), follow: null, applying: false, tView: 0, tCur: 0, pendingView: null, retry: 0, editing: new Map(), agents: new Map(), presenting: false };
|
|
1646
|
+
const presenceOn = () => { try { return localStorage.getItem('qawalk-presence') !== 'off'; } catch { return true; } };
|
|
1647
|
+
const peerColor = (s) => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return `hsl(${h % 360} 62% 42%)`; };
|
|
1648
|
+
function presenceStart () {
|
|
1649
|
+
const app = APP();
|
|
1650
|
+
// presence is for members: a read-only guest from another organization (superadmin) is refused by the service
|
|
1651
|
+
if (!app?.user || app.user.guest || app.public || app.embed || !('WebSocket' in window) || PR.ws) return;
|
|
1652
|
+
renderPresence();
|
|
1653
|
+
if (!presenceOn()) return;
|
|
1654
|
+
// a design draft has its own room per branch (#42); the service also sends draft events into it
|
|
1655
|
+
const room = isDraft() ? `${app.api}/orgs/${app.org}/scenarios/${encodeURIComponent(RUN.scenario.id)}/draft/presence${branchQ()}` : `${app.api}/orgs/${app.org}/runs/${app.run}/presence`;
|
|
1656
|
+
let ws; try { ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${room}`); } catch { return; }
|
|
1657
|
+
PR.ws = ws;
|
|
1658
|
+
ws.onopen = () => { PR.retry = 0; presenceView(true); };
|
|
1659
|
+
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onPresence(m); };
|
|
1660
|
+
ws.onclose = () => { PR.ws = null; PR.peers.clear(); stopFollow(); renderPresence(); renderCursors(); if (presenceOn() && PR.retry < 5) setTimeout(presenceStart, 3000 * ++PR.retry); };
|
|
1661
|
+
}
|
|
1662
|
+
function presenceSend (m) { if (PR.ws && PR.ws.readyState === 1) PR.ws.send(JSON.stringify(m)); }
|
|
1663
|
+
/* who edits which step: only the chips change, the cards (and their screenshots) stay */
|
|
1664
|
+
function refreshEditing () {
|
|
1665
|
+
for (const card of document.querySelectorAll('#nodes .node[data-id]')) {
|
|
1666
|
+
const ed = [...PR.editing].find(([, node]) => node === card.dataset.id);
|
|
1667
|
+
const p = ed && PR.peers.get(ed[0]);
|
|
1668
|
+
let chip = card.querySelector(':scope > .editing');
|
|
1669
|
+
if (!p) { chip?.remove(); continue; }
|
|
1670
|
+
if (!chip) { chip = document.createElement('span'); chip.className = 'editing'; card.prepend(chip); }
|
|
1671
|
+
chip.style.background = peerColor(p.email); chip.textContent = p.name || p.email;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
function onPresence (m) {
|
|
1675
|
+
if (m.type === 'hello') { PR.me = m.you; PR.peers.clear(); for (const p of m.peers) PR.peers.set(p.id, { ...p }); }
|
|
1676
|
+
else if (m.type === 'join') PR.peers.set(m.peer.id, { ...m.peer });
|
|
1677
|
+
else if (m.type === 'leave') { PR.peers.delete(m.id); if (PR.follow === m.id) stopFollow(); }
|
|
1678
|
+
else if (m.type === 'cursor' || m.type === 'leave-cursor') { const p = PR.peers.get(m.id); if (p) { p.cursor = m.type === 'cursor' ? { x: m.x, y: m.y } : null; renderCursors(); } return; }
|
|
1679
|
+
else if (m.type === 'view') {
|
|
1680
|
+
const p = PR.peers.get(m.id); if (!p) return;
|
|
1681
|
+
p.view = m;
|
|
1682
|
+
// someone presents: everyone else follows them until they stop (Esc leaves)
|
|
1683
|
+
if (m.present && !PR.presenting && PR.follow !== m.id) startFollow(m.id);
|
|
1684
|
+
if (PR.follow === m.id) applyFollow(m);
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
else if (m.type === 'editing') { if (m.on) PR.editing.set(m.id, m.step); else PR.editing.delete(m.id); refreshEditing(); return; }
|
|
1688
|
+
else if (m.source === 'service') { onServiceEvent(m); return; }
|
|
1689
|
+
renderPresence(); renderCursors();
|
|
1690
|
+
}
|
|
1691
|
+
/* writes through the API (people in other tabs, agents over the CLI or MCP) arrive as service events (#42) */
|
|
1692
|
+
function onServiceEvent (m) {
|
|
1693
|
+
const a = m.actor;
|
|
1694
|
+
if (a?.agent) { PR.agents.set(`${a.agent}|${a.email}`, { name: a.agent, email: a.email, until: Date.now() + 60000, step: m.steps?.[0]?.node || m.node || null }); setTimeout(renderPresence, 60500); }
|
|
1695
|
+
if (['draft.updated', 'step.status', 'version.published', 'reference.changed'].includes(m.type)) {
|
|
1696
|
+
if (a && (a.agent || a.email !== APP()?.user?.email)) toast(`${who(a)} ${m.type === 'version.published' ? `→ v${m.version}` : m.type === 'reference.changed' ? `→ ${T('designRef')}` : `${T('updatedSteps')} ${[...new Set((m.steps || []).map((x) => x.node).concat(m.node ? [m.node] : []))].join(', ')}`}`);
|
|
1697
|
+
reloadDraftSoon();
|
|
1698
|
+
}
|
|
1699
|
+
if (m.type === 'comment.added' || m.type === 'comment.resolved') refreshReviews();
|
|
1700
|
+
renderPresence();
|
|
1701
|
+
}
|
|
1702
|
+
function renderPresence () {
|
|
1703
|
+
const el = $('presence'); if (!el) return;
|
|
1704
|
+
const app = APP(); if (!app?.user || app.public || app.embed) { el.hidden = true; return; }
|
|
1705
|
+
el.hidden = false;
|
|
1706
|
+
const peers = [...PR.peers.values()];
|
|
1707
|
+
const now = Date.now(); for (const [k, x] of PR.agents) if (x.until < now) PR.agents.delete(k);
|
|
1708
|
+
const presentBtn = PR.ws && peers.length ? `<button class="pvis" data-present aria-pressed="${PR.presenting}">${esc(PR.presenting ? T('stopPresent') : T('present'))}</button>` : '';
|
|
1709
|
+
const visBtn = `<button class="pvis" data-vis title="${esc(presenceOn() ? T('visOn') : T('visOff'))}">${presenceOn() ? '●' : '○'} ${esc(presenceOn() ? T('visOn') : T('visOff'))}</button>`;
|
|
1710
|
+
const menu = document.body.classList.contains('wide') ? presentBtn + visBtn : `<button class="pmenu-btn" data-pmenu aria-expanded="${!!PR.menu}" title="${esc(presenceOn() ? T('visOn') : T('visOff'))}">${presenceOn() ? '●' : '○'}</button>${PR.menu ? `<div class="menu pmenu">${PR.ws && peers.length ? `<button data-present aria-pressed="${PR.presenting}">${esc(PR.presenting ? T('stopPresent') : T('present'))}</button>` : ''}<button data-vis>${presenceOn() ? '●' : '○'} ${esc(presenceOn() ? T('visOn') : T('visOff'))}</button></div>` : ''}`;
|
|
1711
|
+
el.innerHTML = [...PR.agents.values()].map((x) => `<span class="pav pav--agent" style="--c:#151514" title="${esc(x.name)} ${esc(T('viaAgent'))} ${esc(x.email)}${x.step ? ` · ${esc(x.step)}` : ''}">AI</span>`).join('') + peers.slice(0, 6).map((p) => `<button class="pav" style="--c:${peerColor(p.email)}" data-follow="${esc(p.id)}" aria-pressed="${PR.follow === p.id}" title="${esc(p.email)} · ${esc(T('followHint'))}">${esc(initials(p.name || p.email))}</button>`).join('') + (peers.length > 6 ? `<span class="muted">+${peers.length - 6}</span>` : '') + menu;
|
|
1712
|
+
el.onclick = (e) => {
|
|
1713
|
+
if (e.target.closest('[data-pmenu]')) { e.stopPropagation(); S.closeMenus?.('pmenu'); PR.menu = !PR.menu; renderPresence(); return; }
|
|
1714
|
+
const f = e.target.closest('[data-follow]'); if (f) { PR.follow === f.dataset.follow ? stopFollow() : startFollow(f.dataset.follow); return; }
|
|
1715
|
+
if (e.target.closest('[data-present]')) { PR.presenting = !PR.presenting; if (PR.presenting) stopFollow(); presenceView(true); renderPresence(); toast(PR.presenting ? T('presenting') : T('stopPresent')); return; }
|
|
1716
|
+
if (e.target.closest('[data-vis]')) { try { localStorage.setItem('qawalk-presence', presenceOn() ? 'off' : 'on'); } catch {} if (presenceOn()) presenceStart(); else { PR.retry = 99; PR.ws?.close(); } renderPresence(); }
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
function renderCursors () {
|
|
1720
|
+
const el = $('cursors'); if (!el) return;
|
|
1721
|
+
el.innerHTML = [...PR.peers.values()].filter((p) => p.cursor && p.cursor.x != null).map((p) => `<div class="cur" data-peer="${esc(p.id)}" style="left:${p.cursor.x}px;top:${p.cursor.y}px;--c:${peerColor(p.email)}"><svg viewBox="0 0 18 18"><path d="M2 2l5 14 2-6 6-2z"/></svg><span>${esc(p.name || p.email)}</span></div>`).join('');
|
|
1722
|
+
}
|
|
1723
|
+
function presenceView (now = false) {
|
|
1724
|
+
if (!PR.ws) return;
|
|
1725
|
+
const send = () => { PR.tView = Date.now(); PR.pendingView = null; const r = $('vp').getBoundingClientRect(), v = S.view; presenceSend({ type: 'view', cx: (r.width / 2 - v.x) / v.k, cy: (r.height / 2 - v.y) / v.k, k: v.k, step: S.open || null, lb: !$('lb').hidden, lbVp: S.lbVp || null, loc: S.loc, vp: S.vp, live: S.lbMode === 'live', present: PR.presenting }); };
|
|
1726
|
+
if (now || Date.now() - PR.tView > 150) send(); else if (!PR.pendingView) PR.pendingView = setTimeout(send, 160);
|
|
1727
|
+
}
|
|
1728
|
+
function presenceCursor (e) {
|
|
1729
|
+
if (!PR.ws || Date.now() - PR.tCur < 60) return; PR.tCur = Date.now();
|
|
1730
|
+
const r = $('vp').getBoundingClientRect(), v = S.view;
|
|
1731
|
+
presenceSend({ type: 'cursor', x: (e.clientX - r.left - v.x) / v.k, y: (e.clientY - r.top - v.y) / v.k });
|
|
1732
|
+
}
|
|
1733
|
+
function startFollow (id) {
|
|
1734
|
+
const p = PR.peers.get(id); if (!p) return;
|
|
1735
|
+
PR.follow = id; const bar = $('followBar'); bar.hidden = false; bar.textContent = `${T('following')} ${p.name || p.email} · ${T('followEsc')}`;
|
|
1736
|
+
renderPresence(); if (p.view) applyFollow(p.view);
|
|
1737
|
+
}
|
|
1738
|
+
function stopFollow () { if (!PR.follow) return; PR.follow = null; $('followBar').hidden = true; renderPresence(); }
|
|
1739
|
+
function applyFollow (m) {
|
|
1740
|
+
PR.applying = true;
|
|
1741
|
+
try {
|
|
1742
|
+
let again = false;
|
|
1743
|
+
if (m.loc && m.loc !== S.loc && RUN.scope.locales.includes(m.loc)) { S.loc = m.loc; again = true; }
|
|
1744
|
+
if (m.vp && m.vp !== S.vp) { S.vp = m.vp; again = true; }
|
|
1745
|
+
if (again) renderAll();
|
|
1746
|
+
if (m.k) { const r = $('vp').getBoundingClientRect(); S.view = { k: m.k, x: r.width / 2 - m.cx * m.k, y: r.height / 2 - m.cy * m.k }; applyView(); }
|
|
1747
|
+
if (m.live !== undefined && isDesign()) S.lbMode = m.live ? 'live' : 'shot';
|
|
1748
|
+
if (m.lb && m.step && byId(m.step)) { if ($('lb').hidden || S.open !== m.step || (m.lbVp && S.lbVp !== m.lbVp)) openLb(m.step, m.lbVp || undefined); }
|
|
1749
|
+
else if (!$('lb').hidden) closeLb();
|
|
1750
|
+
} finally { PR.applying = false; }
|
|
1751
|
+
}
|
|
1752
|
+
function applyView () { const v = S.view; $('world').style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`; { const far = v.k < 0.6; if ($('world').classList.contains('far') !== far) { $('world').classList.toggle('far', far); requestAnimationFrame(drawEdges); } $('world').classList.toggle('tiny', v.k < 0.25); } $('world').style.setProperty('--inv', String(Math.min(6, 1 / v.k))); $('zVal').textContent = Math.round(v.k * 100) + ' %'; $('vp').style.backgroundSize = `${22 * v.k}px ${22 * v.k}px`; $('vp').style.backgroundPosition = `${v.x}px ${v.y}px`; renderMini(); presenceView(); }
|
|
1753
|
+
/* minimap: every node as a status-coloured block, the visible area as a frame; click or drag to move */
|
|
1754
|
+
let MINI = null;
|
|
1755
|
+
function renderMini () {
|
|
1756
|
+
const el = $('mini'); const b = bounds(); const r = $('vp').getBoundingClientRect();
|
|
1757
|
+
if (!el || !b || !r.width) return;
|
|
1758
|
+
const pad = 60; const W = el.clientWidth || 180, H = el.clientHeight || 110;
|
|
1759
|
+
const x1 = Math.min(b.x1, -S.view.x / S.view.k) - pad, y1 = Math.min(b.y1, -S.view.y / S.view.k) - pad;
|
|
1760
|
+
const x2 = Math.max(b.x2, (r.width - S.view.x) / S.view.k) + pad, y2 = Math.max(b.y2, (r.height - S.view.y) / S.view.k) + pad;
|
|
1761
|
+
const k = Math.min(W / (x2 - x1), H / (y2 - y1)); const ox = (W - (x2 - x1) * k) / 2, oy = (H - (y2 - y1) * k) / 2;
|
|
1762
|
+
MINI = { x1, y1, k, ox, oy, W, H };
|
|
1763
|
+
const nodes = [...document.querySelectorAll('.node')].map((n) => { const d = byId(n.dataset.id); const st = d?.kind === 'decision' ? 'dec' : `st-${nodeSt(d)}`; return `<rect class="n ${st}" x="${((n.offsetLeft - x1) * k + ox).toFixed(1)}" y="${((n.offsetTop - y1) * k + oy).toFixed(1)}" width="${Math.max(2, n.offsetWidth * k).toFixed(1)}" height="${Math.max(2, n.offsetHeight * k).toFixed(1)}"/>`; }).join('');
|
|
1764
|
+
const vx = (-S.view.x / S.view.k - x1) * k + ox, vy = (-S.view.y / S.view.k - y1) * k + oy, vw = r.width / S.view.k * k, vh = r.height / S.view.k * k;
|
|
1765
|
+
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}">${nodes}<rect class="view" x="${vx.toFixed(1)}" y="${vy.toFixed(1)}" width="${vw.toFixed(1)}" height="${vh.toFixed(1)}"/></svg>`;
|
|
1766
|
+
}
|
|
1767
|
+
function miniCenter (e) {
|
|
1768
|
+
if (!MINI) return; const el = $('mini'); const rr = el.getBoundingClientRect(); const r = $('vp').getBoundingClientRect();
|
|
1769
|
+
const wx = (e.clientX - rr.left) / rr.width * MINI.W; const wy = (e.clientY - rr.top) / rr.height * MINI.H;
|
|
1770
|
+
const cx = (wx - MINI.ox) / MINI.k + MINI.x1, cy = (wy - MINI.oy) / MINI.k + MINI.y1;
|
|
1771
|
+
S.view.x = r.width / 2 - cx * S.view.k; S.view.y = r.height / 2 - cy * S.view.k; applyView();
|
|
1772
|
+
}
|
|
1773
|
+
function bounds () { const els = [...document.querySelectorAll('.node')]; if (!els.length) return null; let x1 = 1e9, y1 = 1e9, x2 = 0, y2 = 0; els.forEach((e) => { x1 = Math.min(x1, e.offsetLeft); y1 = Math.min(y1, e.offsetTop - 40); x2 = Math.max(x2, e.offsetLeft + e.offsetWidth); y2 = Math.max(y2, e.offsetTop + e.offsetHeight); }); return { x1, y1, x2, y2 }; }
|
|
1774
|
+
function fit () { const b = bounds(), r = $('vp').getBoundingClientRect(); if (!b || !r.width) return; const k = Math.min(1.1, (r.width - 60) / (b.x2 - b.x1), (r.height - 120) / (b.y2 - b.y1)); S.view = { k, x: (r.width - (b.x2 - b.x1) * k) / 2 - b.x1 * k, y: 40 - b.y1 * k + Math.max(0, (r.height - 100 - (b.y2 - b.y1) * k) / 2) }; applyView(); }
|
|
1775
|
+
function startView () { const b = bounds(), r = $('vp').getBoundingClientRect(); if (!b || !r.width) return; const k = Math.max(0.5, Math.min(0.95, (r.height - 110) / (b.y2 - b.y1))); S.view = { k, x: 30 - b.x1 * k, y: 50 - b.y1 * k }; applyView(); }
|
|
1776
|
+
function zoomAt (f, cx, cy) { const v = S.view, k = Math.min(2, Math.max(0.12, v.k * f)); v.x = cx - (cx - v.x) * (k / v.k); v.y = cy - (cy - v.y) * (k / v.k); v.k = k; applyView(); }
|
|
1777
|
+
function focusNode (id) { const el = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`); if (!el) return; const r = $('vp').getBoundingClientRect(); const k = Math.max(S.view.k, 0.8); S.view = { k, x: r.width / 2 - (el.offsetLeft + el.offsetWidth / 2) * k, y: r.height / 2 - (el.offsetTop + el.offsetHeight / 2) * k }; applyView(); el.classList.add('is-focus'); setTimeout(() => el.classList.remove('is-focus'), 1600); }
|
|
1778
|
+
|
|
1779
|
+
function wireCanvas () {
|
|
1780
|
+
const vp = $('vp'); let drag = null;
|
|
1781
|
+
vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.node,.hud')) return; drag = { x: e.clientX, y: e.clientY, vx: S.view.x, vy: S.view.y }; vp.setPointerCapture(e.pointerId); vp.classList.add('dragging'); });
|
|
1782
|
+
vp.addEventListener('pointermove', (e) => { if (!drag) return; S.view.x = drag.vx + e.clientX - drag.x; S.view.y = drag.vy + e.clientY - drag.y; applyView(); });
|
|
1783
|
+
const end = () => { drag = null; vp.classList.remove('dragging'); };
|
|
1784
|
+
vp.addEventListener('pointerup', end); vp.addEventListener('pointercancel', end);
|
|
1785
|
+
// presence: my cursor to the others; dragging or zooming myself ends following someone
|
|
1786
|
+
vp.addEventListener('pointermove', presenceCursor);
|
|
1787
|
+
vp.addEventListener('pointerleave', () => presenceSend({ type: 'leave-cursor' }));
|
|
1788
|
+
vp.addEventListener('pointerdown', (e) => { if (!PR.applying && !e.target.closest('.hud')) stopFollow(); });
|
|
1789
|
+
vp.addEventListener('wheel', () => { if (!PR.applying) stopFollow(); }, { passive: true });
|
|
1790
|
+
// hovering a step lights up its edges and dims the others
|
|
1791
|
+
$('nodes').addEventListener('pointerover', (e) => { const n = e.target.closest('.node'); hlEdges(n ? n.dataset.id : null); });
|
|
1792
|
+
$('nodes').addEventListener('pointerleave', () => hlEdges(null));
|
|
1793
|
+
$('nodes').addEventListener('wheel', (e) => { const pre = e.target.closest('.jev pre'); if (pre && !e.ctrlKey && !e.metaKey && pre.scrollHeight > pre.clientHeight) e.stopPropagation(); }, { passive: true });
|
|
1794
|
+
vp.addEventListener('wheel', (e) => { e.preventDefault(); const r = vp.getBoundingClientRect(); if (e.ctrlKey || e.metaKey) zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top); else { S.view.x -= e.deltaX; S.view.y -= e.deltaY; applyView(); } }, { passive: false });
|
|
1795
|
+
$('zIn').onclick = () => { const r = vp.getBoundingClientRect(); zoomAt(1.2, r.width / 2, r.height / 2); };
|
|
1796
|
+
$('zOut').onclick = () => { const r = vp.getBoundingClientRect(); zoomAt(1 / 1.2, r.width / 2, r.height / 2); };
|
|
1797
|
+
$('zFit').onclick = fit;
|
|
1798
|
+
const mini = $('mini'); let md = false;
|
|
1799
|
+
mini.addEventListener('pointerdown', (e) => { md = true; mini.setPointerCapture(e.pointerId); miniCenter(e); e.stopPropagation(); });
|
|
1800
|
+
mini.addEventListener('pointermove', (e) => { if (md) miniCenter(e); });
|
|
1801
|
+
mini.addEventListener('pointerup', () => { md = false; }); mini.addEventListener('pointercancel', () => { md = false; });
|
|
1802
|
+
// the whole card opens the step (the screenshot picks its viewport); its own buttons and links keep their job
|
|
1803
|
+
$('nodes').addEventListener('click', (e) => {
|
|
1804
|
+
const b = e.target.closest('[data-open]');
|
|
1805
|
+
if (b) { const sh = e.target.closest('.shot[data-vp]'); openLb(b.dataset.open, sh ? sh.dataset.vp : null); return; }
|
|
1806
|
+
if (e.target.closest('a, button, input, select, textarea, [data-pxd], [data-sess]')) return;
|
|
1807
|
+
const card = e.target.closest('.node[data-id]');
|
|
1808
|
+
if (card && !card.classList.contains('node--decision')) openLb(card.dataset.id);
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/* ---------- panels ---------- */
|
|
1813
|
+
function seg (el, opts, cur, on) { el.innerHTML = opts.map(([v, l]) => `<button aria-pressed="${v === cur}" data-v="${esc(v)}">${esc(l)}</button>`).join(''); el.onclick = (e) => { const b = e.target.closest('button'); if (b) on(b.dataset.v); }; }
|
|
1814
|
+
const bar5 = (c) => { const tot = (c.pass || 0) + (c.partial || 0) + (c.fail || 0) + (c.skip || 0) || 1; return `<span class="bar5">${['pass', 'partial', 'fail'].map((k) => `<i style="width:${(c[k] || 0) / tot * 100}%;background:var(--${k})"></i>`).join('')}</span>`; };
|
|
1815
|
+
// The issue the run belongs to (from the scenario id, the branch or scenario `issue:`), linked when its URL is known.
|
|
1816
|
+
const issueRef = (issue) => { const label = `${issue.repo ? issue.repo + '#' : '#'}${issue.number}`; return `${issue.url ? `<a class="pr" href="${esc(webHref(issue.url))}" target="_blank" rel="noopener">${esc(label)}</a>` : `<span class="mono">${esc(label)}</span>`}${issue.title ? ` <span class="note">${esc(issue.title)}</span>` : ''}` };
|
|
1817
|
+
const prLink = (pr) => pr ? `<a class="pr" href="${esc(webHref(pr.url))}" target="_blank" rel="noopener">PR #${esc(pr.number)}</a><span class="prstate ${pr.state === 'open' ? '' : 'prstate--merged'}">${esc(T(pr.state) || pr.state)}</span>` : `<span class="note">${T('noPr')}</span>`;
|
|
1818
|
+
function setPanels () { $('main').classList.toggle('no-left', !S.left); $('main').classList.toggle('no-right', !S.right); $('togLeft').setAttribute('aria-pressed', S.left); $('togRight').setAttribute('aria-pressed', S.right); if (S.right) renderReport(); }
|
|
1819
|
+
function runHref (id) { if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(id)) return '#'; return window.QAWALK_APP ? `/${window.QAWALK_APP.org}/runs/${id}/` : `../${id}/index.html`; }
|
|
1820
|
+
function renderChrome () {
|
|
1821
|
+
const app = window.QAWALK_APP;
|
|
1822
|
+
$('brand').href = app ? (app.user ? `/${app.org}/` : '/') : '#';
|
|
1823
|
+
$('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>`) : '')
|
|
1824
|
+
+ `<span class="langsw">${['cs', 'en'].map((l) => `<button type="button" data-lang="${l}" aria-pressed="${l === UI}">${l.toUpperCase()}</button>`).join('')}</span>`;
|
|
1825
|
+
$('appNav').querySelectorAll('[data-lang]').forEach((b) => { b.onclick = () => { if (b.dataset.lang !== UI) switchLang(b.dataset.lang); }; });
|
|
1826
|
+
document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
|
|
1827
|
+
$('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
|
|
1828
|
+
$('hint').innerHTML = `<ul>${T('hint').split(' · ').map((x) => `<li>${x}</li>`).join('')}<li><span class="kbd">C</span> ${esc(T('keyComment'))}</li><li><span class="kbd">?</span> ${esc(T('keyHelp'))}</li></ul>`;
|
|
1829
|
+
const ib = $('inboxBtn');
|
|
1830
|
+
if (app?.user) { ib.hidden = false; ib.href = `/${app.org}/inbox`; ib.innerHTML = `<svg class="i" viewBox="0 0 24 24"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>${app.unread ? `<b>${esc(app.unread)}</b>` : ''}`; ib.title = 'Inbox'; ib.setAttribute('aria-label', `Inbox${app.unread ? ` (${app.unread})` : ''}`); }
|
|
1831
|
+
else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
|
|
1832
|
+
else ib.hidden = true;
|
|
1833
|
+
$('locSeg').hidden = RUN.scope.locales.length < 2;
|
|
1834
|
+
$('viewDot').hidden = S.vp === 'both' && S.jev === 'sum' && !S.cmp;
|
|
1835
|
+
$('legend').innerHTML = ['pass', 'partial', 'fail', 'skip'].map((k) => `<span><i class="dot st-${k}"></i>${T(k).toLowerCase()}</span>`).join('') + `<span>${ICON.crop}${T('crop')}</span><span>${ICON.html}${T('html')}</span><span>${ICON.email}${T('email')}</span><span>${ICON.external}${T('external')}</span>`;
|
|
1836
|
+
$('brSearch').placeholder = T('search');
|
|
1837
|
+
$('locSeg').title = T('locale'); $('vpSeg').title = T('view');
|
|
1838
|
+
seg($('locSeg'), RUN.scope.locales.map((l) => [l, l]), S.loc, (v) => { S.loc = v; renderAll(); if (S.open) openLb(S.open, S.lbVp); });
|
|
1839
|
+
const vps = [['both', T('both')], ...(dVp() ? [['desktop', 'Desktop']] : []), ...(mVp() ? [['mobile', 'Mobile']] : [])];
|
|
1840
|
+
seg($('vpSeg'), vps, S.vp, (v) => { S.vp = v; renderAll(); });
|
|
1841
|
+
renderCmpControl();
|
|
1842
|
+
const g = RUN.git || {};
|
|
1843
|
+
$('pkKind').textContent = RUN.scenario.kind || 'task'; $('pkKind').className = `kchip ${RUN.scenario.kind === 'flow' ? 'kchip--flow' : ''}`;
|
|
1844
|
+
$('taskTitle').textContent = `${RUN.issue ? `${RUN.issue.repo ? RUN.issue.repo + '#' : '#'}${RUN.issue.number} · ` : ''}${RUN.scenario.title}`;
|
|
1845
|
+
if (isDesign()) $('runMeta').textContent = [isDraft() ? `${T('draft')} #${RUN.draft.seq}${RUN.draft.branch !== 'main' ? ` @${RUN.draft.branch}` : ''} · ${T('liveShared')}` : `${T('version')} v${RUN.version}${RUN.version_name ? ` · ${RUN.version_name}` : ''}`, fmtDate(RUN.started_at), RUN.verdict && RUN.verdict.verdict !== 'pending' && T(RUN.verdict.verdict), (RUN.references?.design || RUN.design_reference_id) === RUN.id && T('designRef')].filter(Boolean).join(' · ');
|
|
1846
|
+
else $('runMeta').textContent = [fmtDate(RUN.started_at), RUN.environment.name, g.branch && `${g.branch} @ ${g.commit}`, g.author, g.pr && `PR #${g.pr.number}`, RUN.verdict && RUN.verdict.verdict !== 'pending' && `${T(RUN.verdict.verdict)}${RUN.verdict.baseline ? ' · ' + T('baseline') : ''}`].filter(Boolean).join(' · ');
|
|
1847
|
+
document.title = `${RUN.scenario.id} · Kaloko`;
|
|
1848
|
+
const hist = [...(RUN.history || []), { id: RUN.id, started_at: RUN.started_at, current: true }].sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
1849
|
+
const i = hist.findIndex((h) => h.current);
|
|
1850
|
+
const c = { pass: RUN.summary.pass, partial: RUN.summary.partial, fail: RUN.summary.fail, skip: RUN.summary.skip };
|
|
1851
|
+
$('runcard').innerHTML = `<div class="runcard__top"><span class="runcard__when">${fmtDate(RUN.started_at)}</span><span class="run__env run__env--${esc(RUN.environment.name)}">${esc(RUN.environment.name)}</span>
|
|
1852
|
+
<span class="runcard__nav"><a class="icon-btn" aria-label="${T('older')}" ${hist[i + 1] ? `href="${runHref(hist[i + 1].id)}"` : 'aria-disabled="true" style="opacity:.35"'}>${ICON.prev}</a><a class="icon-btn" aria-label="${T('newer')}" ${hist[i - 1] ? `href="${runHref(hist[i - 1].id)}"` : 'aria-disabled="true" style="opacity:.35"'}>${ICON.next}</a></span></div>
|
|
1853
|
+
${bar5(c)}
|
|
1854
|
+
<dl>${g.branch ? `<dt>${T('branch')}</dt><dd class="mono">${esc(g.branch)}</dd><dt>${T('commit')}</dt><dd><span class="mono">${esc(g.commit)}</span> ${esc(g.message)}</dd><dt>${T('author')}</dt><dd><span class="who"><span class="av">${esc(initials(g.author))}</span>${esc(g.author)}</span></dd><dt>${T('pr')}</dt><dd>${prLink(g.pr)}</dd>` : ''}${RUN.uploaded_by ? `<dt>${g.branch ? T('uploadedBy') : T('author')}</dt><dd><span class="who"><span class="av">${esc(initials(RUN.uploaded_by))}</span>${esc(RUN.uploaded_by)}</span></dd>` : ''}${RUN.issue ? `<dt>${T('issue')}</dt><dd>${issueRef(RUN.issue)}</dd>` : ''}
|
|
1855
|
+
${RUN.scenario.platform ? `<dt>${T('platformWord')}</dt><dd>${esc(platformLabel(RUN.scenario.platform))}${(() => { const d = screens().flatMap((n) => Object.values(n.captures?.[S.loc] || {})).map((c) => c?.device).find(Boolean); return d ? ` · ${esc(d)}` : ''; })()}</dd>` : ''}
|
|
1856
|
+
<dt>${T('env')}</dt><dd class="mono">${RUN.environment.kind === 'design' ? `${esc(RUN.environment.name)}${RUN.environment.name !== 'design' ? ` · ${T('designEnv')}` : ''}` : esc(RUN.environment.base_url == null ? '—' : typeof RUN.environment.base_url === 'string' ? RUN.environment.base_url : loc(RUN.environment.base_url))}${RUN.environment.readonly ? ' · readonly' : ''}</dd>
|
|
1857
|
+
<dt>${T('duration')}</dt><dd>${dur()}</dd></dl>${isDesign() ? '<div class="dvers" id="dvers"></div>' : ''}`;
|
|
1858
|
+
renderDesignBar();
|
|
1859
|
+
const order = screens().slice().sort((a, b) => a.col - b.col || a.lane - b.lane);
|
|
1860
|
+
const stepLi = (n) => `<li><button data-step="${esc(n.id)}" class="st-${nodeSt(n)}"><span class="dot"></span><span>${n.kind === 'stack' ? `${ICON.stack} ` : ''}${esc(loc(n.title))}</span><span class="path">${n.kind === 'stack' ? `${esc(n.stack?.count || 0)} ${T('pagesWord')}` : esc(loc(n.path))}</span></button></li>`;
|
|
1861
|
+
$('steps').innerHTML = sessions().length
|
|
1862
|
+
? sessions().map((k) => { const mine = order.filter((n) => n.session === k); if (!mine.length) return ''; const c = RUN.summary.by_session?.[k] || {}; return `<h3 data-sess="${esc(k)}" aria-pressed="${S.sess === k}" title="${esc(k)}"><i class="dot" style="background:${sessColor(k)}"></i>${esc(sessTitle(k))}${bar5(c)}</h3>${mine.map(stepLi).join('')}`; }).join('') + (order.some((n) => !n.session) ? order.filter((n) => !n.session).map(stepLi).join('') : '')
|
|
1863
|
+
: laneNamesOf().length > 1
|
|
1864
|
+
? laneNamesOf().map((name, i) => { const mine = order.filter((n) => n.lane === i); if (!mine.length) return ''; const c = { pass: mine.filter((n) => nodeSt(n) === 'pass').length }; return `<h3 data-lane="${i}" title="${esc(T('fitLane'))}">${esc(name)} <small class="muted">${c.pass}/${mine.length}</small></h3>${mine.map(stepLi).join('')}`; }).join('')
|
|
1865
|
+
: order.map(stepLi).join('');
|
|
1866
|
+
$('steps').onclick = (e) => { const ln = e.target.closest('[data-lane]'); if (ln) { if (S.mode === 'index') { const k = (S.ri?.entries || []).findIndex((x) => x.type === 'group' && x.label === laneNamesOf()[Number(ln.dataset.lane)]); if (k >= 0) $('riList').scrollTop = S.ri.tops[k]; return; } fitLane(Number(ln.dataset.lane)); return; } const h = e.target.closest('[data-sess]'); if (h) { S.sess = S.sess === h.dataset.sess ? null : h.dataset.sess; renderAll(); return; } const b = e.target.closest('[data-step]'); if (!b) return; if (S.mode === 'index') riFocus(b.dataset.step); else if (byId(b.dataset.step)?.kind === 'stack') { focusNode(b.dataset.step); openStack(b.dataset.step); } else focusNode(b.dataset.step); if (innerWidth <= 1100) { S.left = false; setPanels(); } };
|
|
1867
|
+
$('scFoot').innerHTML = `${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
|
|
1868
|
+
}
|
|
1869
|
+
function renderAll () { renderChrome(); renderNodes(); if (S.right) renderReport(); presenceView(); if (S.mode === 'index') { if (S.st?.inline) paintStack(); else if ($('riList')) riPaint(); else riRender(); } }
|
|
1870
|
+
|
|
1871
|
+
/* Evidence of the built-in checks is a fixed English template (kept as is in the data, for agents and the API);
|
|
1872
|
+
the canvas says it as a sentence in the reader's language. Unknown text and HTML snippets stay as they are. */
|
|
1873
|
+
const EVIDENCE = [
|
|
1874
|
+
[/^no visible link to the home page$/, () => ['No visible link to the home page', 'Chybí viditelný odkaz na úvodní stránku']],
|
|
1875
|
+
[/^no visible (.+)$/, (m) => [`Nothing visible matches ${m[1]}`, `Na stránce není vidět nic, co odpovídá ${m[1]}`]],
|
|
1876
|
+
[/^(\d+)× (.+)$/, (m) => [`Found ${m[1]}× ${m[2]}`, `Nalezeno ${m[1]}× ${m[2]}`]],
|
|
1877
|
+
[/^"([\s\S]*)" does not match \/(.+)\/$/, (m) => [`The text “${m[1]}” is not what was expected`, `Text „${m[1]}“ neodpovídá očekávání`]],
|
|
1878
|
+
[/^found: ([\s\S]+)$/, (m) => [`Found: ${m[1]}`, `Nalezeno: ${m[1]}`]],
|
|
1879
|
+
[/^\/(.+)\/ not found$/, () => ['The expected text is not on the page', 'Očekávaný text na stránce není']],
|
|
1880
|
+
[/^no horizontal overflow$/, () => ['Nothing overflows the screen width', 'Nic nepřetéká šířku obrazovky']],
|
|
1881
|
+
[/^no console errors$/, () => ['No errors in the browser console', 'V konzoli prohlížeče nejsou chyby']],
|
|
1882
|
+
[/^Console was not observed\.$/, () => ['The console was not recorded for this capture', 'Konzole se u tohoto snímku nezaznamenala']],
|
|
1883
|
+
[/^(\d+) image\(s\) without alt: (.+)$/, (m) => [`${m[1]} image(s) without alternative text: ${m[2]}`, `Obrázky bez alternativního textu (${m[1]}): ${m[2]}`]],
|
|
1884
|
+
[/^every image has alt$/, () => ['Every image has alternative text', 'Všechny obrázky mají alternativní text']],
|
|
1885
|
+
[/^(\d+) control\(s\) without an accessible name: (.+)$/, (m) => [`${m[1]} link(s) or button(s) without a name: ${m[2]}`, `Odkazy nebo tlačítka bez názvu (${m[1]}): ${m[2]}`]],
|
|
1886
|
+
[/^every link and button has a name$/, () => ['Every link and button has a name', 'Všechny odkazy a tlačítka mají název']],
|
|
1887
|
+
[/^(\d+) field\(s\) without a label: (.+)$/, (m) => [`${m[1]} field(s) without a label: ${m[2]}`, `Pole bez popisku (${m[1]}): ${m[2]}`]],
|
|
1888
|
+
[/^every field has a label$/, () => ['Every field has a label', 'Všechna pole mají popisek']],
|
|
1889
|
+
[/^heading level skipped: (.+)$/, (m) => [`A heading level is skipped: ${m[1]}`, `Přeskočená úroveň nadpisu: ${m[1]}`]],
|
|
1890
|
+
[/^no main landmark$/, () => ['The page has no <main> area', 'Stránka nemá hlavní oblast <main>']],
|
|
1891
|
+
[/^duplicate ids: (.+)$/, (m) => [`Repeated ids: ${m[1]}`, `Opakovaná id: ${m[1]}`]],
|
|
1892
|
+
[/^(\d+) text\(s\) below WCAG AA contrast: (.+)$/, (m) => [`${m[1]} text(s) with too little contrast: ${m[2]}`, `Texty s nízkým kontrastem (${m[1]}): ${m[2]}`]],
|
|
1893
|
+
[/^(\d+) texts meet WCAG AA contrast$/, (m) => [`All ${m[1]} texts have enough contrast`, `Všech ${m[1]} textů má dostatečný kontrast`]],
|
|
1894
|
+
[/^LCP (.+) since page load \(limit (.+)\)$/, (m) => [`The largest content appeared after ${m[1]} (limit ${m[2]})`, `Největší obsah se zobrazil za ${m[1]} (limit ${m[2]})`]],
|
|
1895
|
+
[/^LCP not measured/, () => ['Largest content paint was not measured', 'Vykreslení největšího obsahu se nezměřilo']],
|
|
1896
|
+
[/^CLS ([\d.]+) \(limit (.+)\)$/, (m) => [`Layout shifted by ${m[1]} while loading (limit ${m[2]})`, `Rozvržení se při načítání posunulo o ${m[1]} (limit ${m[2]})`]],
|
|
1897
|
+
[/^CLS not measured$/, () => ['Layout shift was not measured', 'Posun rozvržení se nezměřil']],
|
|
1898
|
+
[/^TTFB (\d+) ms \(limit (\d+) ms\)$/, (m) => [`The server started answering after ${m[1]} ms (limit ${m[2]} ms)`, `Server začal odpovídat za ${m[1]} ms (limit ${m[2]} ms)`]],
|
|
1899
|
+
[/^no navigation timing$/, () => ['Server timing was not measured', 'Odezva serveru se nezměřila']],
|
|
1900
|
+
[/^(\d+) kB transferred in (\d+) requests \(limit (\d+) kB\)/, (m) => [`${m[1]} kB downloaded in ${m[2]} requests (limit ${m[3]} kB)`, `Staženo ${m[1]} kB ve ${m[2]} požadavcích (limit ${m[3]} kB)`]],
|
|
1901
|
+
[/^HTTP (\d{3}) (.*)$/, (m) => [`The server answered ${m[1]} for ${m[2] || '/'}`, `Server odpověděl ${m[1]} pro ${m[2] || '/'}`]],
|
|
1902
|
+
[/^HTTP status not known for this capture$/, () => ['The HTTP status of this capture is not known', 'HTTP stav tohoto snímku není známý']],
|
|
1903
|
+
[/^check error: (.+)$/, (m) => [`The check could not run: ${m[1]}`, `Kontrolu nešlo provést: ${m[1]}`]],
|
|
1904
|
+
[/^live-page check; capture the step again to evaluate it$/, () => ['Needs the live page: capture the step again', 'Potřebuje živou stránku: zachyťte krok znovu']],
|
|
1905
|
+
[/^(\d+) value\(s\) outside the token set: (.+)$/, (m) => [`${m[1]} value(s) not from the token set: ${m[2]}`, `Hodnoty mimo tokeny (${m[1]}): ${m[2]}`]],
|
|
1906
|
+
[/^every color, font, size, spacing and radius comes from the token set$/, () => ['Every color, font, size, spacing and radius comes from the tokens', 'Všechny barvy, písma, velikosti, mezery a zaoblení pocházejí z tokenů']],
|
|
1907
|
+
[/^([\d.]+) % of pixels differ from design v(\S+) \(limit (.+)\)$/, (m) => [`${m[1]} % of pixels differ from design v${m[2]} (limit ${m[3]})`, `${m[1]} % pixelů se liší od designu v${m[2]} (limit ${m[3]})`]],
|
|
1908
|
+
[/^same headings, landmarks, actions and fields as design v(\S+)$/, (m) => [`Same headings, areas, actions and fields as design v${m[1]}`, `Stejné nadpisy, oblasti, akce a pole jako design v${m[1]}`]],
|
|
1909
|
+
[/^vs design v(\S+): (.+)$/, (m) => [`Differs from design v${m[1]}: ${m[2]}`, `Oproti designu v${m[1]}: ${m[2]}`]]
|
|
1910
|
+
];
|
|
1911
|
+
function humanEvidence (text) {
|
|
1912
|
+
const t = String(text || '').trim();
|
|
1913
|
+
for (const [re, f] of EVIDENCE) { const m = t.match(re); if (m) return f(m)[UI === 'cs' ? 1 : 0]; }
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
/* Step detail: criteria that need attention first and open; passing ones as one line that opens when selected. */
|
|
1917
|
+
const cellOf = (c, v) => c.by_locale?.[S.loc]?.[v.name] || {};
|
|
1918
|
+
function critOrder (n, v) { const bad = (c) => (VSTAT[cellOf(c, v).verdict || 'not_reached'] === 'pass' ? 1 : 0); return [...n.criteria].sort((a, b) => bad(a) - bad(b)); }
|
|
1919
|
+
function critCounts (n, v) {
|
|
1920
|
+
const st = n.criteria.map((c) => VSTAT[cellOf(c, v).verdict || 'not_reached']);
|
|
1921
|
+
const pass = st.filter((x) => x === 'pass').length, rest = st.length - pass;
|
|
1922
|
+
return st.length ? `<span class="account">${rest ? `<b class="st-fail-t">${T('toSolve')(rest)}</b> · ` : ''}${T('passedN')(pass)}</span>` : '';
|
|
1923
|
+
}
|
|
1924
|
+
function showLbTab (k) {
|
|
1925
|
+
S.lbTab = k;
|
|
1926
|
+
const lb = $('lb');
|
|
1927
|
+
lb.querySelectorAll('[data-lbtab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lbtab === k)));
|
|
1928
|
+
lb.querySelectorAll('.lbtab').forEach((p) => { p.hidden = p.dataset.pane !== k; });
|
|
1929
|
+
}
|
|
1930
|
+
/* Console errors and blocked requests: highlighted, wrapped, scrollable when long, each one copyable. */
|
|
1931
|
+
function hlLog (text) {
|
|
1932
|
+
let h = esc(text);
|
|
1933
|
+
h = h.replace(/(https?:\/\/(?:[^\s)"&]|&)+?)(:\d+(?::\d+)?)?(?=[\s)"]|&(?!amp;)|$)/g, (m, u, pos) => `<span class="k-url">${u}</span>${pos ? `<span class="k-pos">${pos}</span>` : ''}`);
|
|
1934
|
+
h = h.replace(/\b((?:Uncaught |Unhandled )?[A-Z][A-Za-z]*(?:Error|Exception)|Warning|Failed to load resource)\b/g, '<span class="k-err">$1</span>');
|
|
1935
|
+
h = h.replace(/("[^&\n]{0,200}?"|'[^'\n]{0,200}?')/g, '<span class="k-str">$1</span>');
|
|
1936
|
+
h = h.replace(/^(\s*at )/gm, '<span class="k-at">$1</span>');
|
|
1937
|
+
h = h.replace(/\b(\d{3}) \((?=[A-Z])/g, '<span class="k-num">$1</span> (');
|
|
1938
|
+
return h;
|
|
1939
|
+
}
|
|
1940
|
+
function logList (items, kind) {
|
|
1941
|
+
return `<ol class="clog">${items.map((x, i) => `<li><pre>${hlLog(x)}</pre><button type="button" class="clog__copy" data-clog="${kind}" data-i="${i}" title="${esc(T('copyOne'))}" aria-label="${esc(T('copyOne'))}">${ICON.copy}</button></li>`).join('')}</ol>`;
|
|
1942
|
+
}
|
|
1943
|
+
function consoleSection (cap) {
|
|
1944
|
+
const errs = cap?.console || [], blocked = cap?.blocked_list || [];
|
|
1945
|
+
// "loading" only while the deferred details are on their way; after that the messages were simply not kept
|
|
1946
|
+
if (!errs.length && !blocked.length) return cap?.console_errors ? `<div class="sec" id="consoleSec"><h4>${T('consoleErrors')} (${esc(cap.console_errors)})</h4><p class="note">${RUN.details_deferred ? T('consoleLoading') : T('noConsole')}</p></div>` : '';
|
|
1947
|
+
const all = [...errs, ...blocked].join('\n\n');
|
|
1948
|
+
return `<div class="sec" id="consoleSec"><h4>${T('consoleErrors')} (${esc(cap.console_errors || errs.length)})<button type="button" class="btn clog__all" data-copy-all="${esc(all)}">${ICON.copy} ${T('copyAll')}</button></h4>${errs.length ? logList(errs, 'console') : `<p class="note">${T('noConsole')}</p>`}${cap.console_errors > errs.length ? `<p class="note">${T('consoleMore')(cap.console_errors - errs.length)}</p>` : ''}${blocked.length ? `<h4 style="margin-top:12px">${T('blockedTitle')} (${blocked.length})</h4>${logList(blocked, 'blocked')}` : ''}</div>`;
|
|
1949
|
+
}
|
|
1950
|
+
/* Pack criteria come with a name in the reader's language, why the check matters and how to fix a failure. */
|
|
1951
|
+
const critText = (c) => (UI !== 'en' && c.help?.[UI]?.name) || c.text;
|
|
1952
|
+
function helpBlock (c, st) {
|
|
1953
|
+
const h = c.help?.[UI] || c.help?.en;
|
|
1954
|
+
if (!h) return '';
|
|
1955
|
+
const body = `<p><b>${T('whyMatters')}</b> ${esc(h.why)}</p><p><b>${T('howFix')}</b> ${esc(h.fix)}</p>`;
|
|
1956
|
+
return st === 'pass' ? `<details class="fixhelp"><summary>${T('whyAndFix')}</summary>${body}</details>` : `<div class="fixhelp">${body}</div>`;
|
|
1957
|
+
}
|
|
1958
|
+
/* How a criterion was checked, in words: the kind of check, what it means and, for the AI, where the answer sits
|
|
1959
|
+
between the thresholds. The technical form (assert, selector, pattern) stays one click away. */
|
|
1960
|
+
function plainCheck (k) {
|
|
1961
|
+
const t = (key, ...a) => { const f = I18N[UI][key] ?? I18N.en[key]; return typeof f === 'function' ? f(...a) : f; };
|
|
1962
|
+
const sel = k.selector ? `„${k.selector}“` : '';
|
|
1963
|
+
const pat = k.pattern ? `„${k.pattern}“` : '';
|
|
1964
|
+
const a = k.assert || '';
|
|
1965
|
+
if (/^a11y_/.test(a)) return t('a_a11y') + ` (${a.slice(5).replace(/_/g, ' ')})`;
|
|
1966
|
+
if (/^design_/.test(a)) return t('a_design');
|
|
1967
|
+
if (/^app_/.test(a)) return t('a_app') + ` (${a.slice(4).replace(/_/g, ' ')})`;
|
|
1968
|
+
if (a === 'count') return t('a_count', sel, k.min, k.max);
|
|
1969
|
+
if (a === 'text_matches' || a === 'text_absent') return t('a_' + a, sel, pat);
|
|
1970
|
+
if (a === 'attr_equals') return t('a_attr_equals', sel, k.attr, k.value);
|
|
1971
|
+
if (a === 'meta_matches') return t('a_meta_matches', sel, pat);
|
|
1972
|
+
if (a === 'title_matches') return t('a_title_matches', pat);
|
|
1973
|
+
if (a === 'link_matches') return t('a_link_matches', sel);
|
|
1974
|
+
if (a === 'http_status') return t('a_http_status', k.value);
|
|
1975
|
+
if (a === 'perf_lcp' || a === 'perf_ttfb') return t('a_' + a, k.max);
|
|
1976
|
+
if (I18N.en["a_" + a] != null) return t('a_' + a, sel);
|
|
1977
|
+
return checkLabel(k);
|
|
1978
|
+
}
|
|
1979
|
+
function aiGauge (r) {
|
|
1980
|
+
if (r.score == null) return '';
|
|
1981
|
+
const [lo, hi] = r.th || [0.15, 0.85];
|
|
1982
|
+
const pct = (x) => Math.round(x * 100);
|
|
1983
|
+
const line = r.verdict === 'pass' ? T('aiPass')(pct(r.score), pct(hi)) : r.verdict === 'fail' ? T('aiFail')(pct(r.score), pct(lo)) : T('aiUnsure')(pct(r.score), pct(lo), pct(hi));
|
|
1984
|
+
return `<span class="gauge" role="img" aria-label="${esc(line)}"><i class="gauge__lo" style="width:${pct(lo)}%"></i><i class="gauge__hi" style="left:${pct(hi)}%"></i><b style="left:${pct(r.score)}%"></b></span><span class="how__line">${esc(line)}${r.via ? ` <span class="muted">· ${esc(T('aiVia')[r.via] || '')}</span>` : ''}</span>`;
|
|
1985
|
+
}
|
|
1986
|
+
function howBlock (c, r) {
|
|
1987
|
+
const k = c.check || {};
|
|
1988
|
+
if (k.type === 'semantic') return `<div class="how"><span class="how__kind how--ai">${T('howAi')}</span><span class="how__q">${T('aiAsked')}: ${esc(k.question || c.text)}</span>${r.score != null ? aiGauge(r) : `<span class="how__line">${esc(r.note || T('aiNotEval'))}</span>`}</div>`;
|
|
1989
|
+
if (k.type === 'manual' || c.type === 'manual') return `<div class="how"><span class="how__kind">${T('howManual')}</span>${k.note ? `<span class="how__line">${esc(k.note)}</span>` : ''}</div>`;
|
|
1990
|
+
// a pack criterion's name already says it in words; its selector and pattern stay under Technical detail
|
|
1991
|
+
return `<div class="how"><span class="how__kind how--auto">${T('howAuto')}</span>${c.help ? '' : `<span class="how__line">${esc(plainCheck(k))}</span>`}</div>`;
|
|
1992
|
+
}
|
|
1993
|
+
function checkLabel (k) {
|
|
1994
|
+
if (k.type === 'semantic') return `semantic: ${k.question}${k.scope ? ` · scope ${k.scope}` : ''}`;
|
|
1995
|
+
if (k.type === 'manual') return `manual${k.note ? ': ' + k.note : ''}`;
|
|
1996
|
+
return [k.assert, k.selector, k.pattern && `/${k.pattern}/`, k.attr && `${k.attr}=${k.value}`, k.min != null && `min ${k.min}`, k.max != null && `max ${k.max}`].filter(Boolean).join(' · ');
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/* ---------- page metadata ---------- */
|
|
2000
|
+
function selTargets (sel) {
|
|
2001
|
+
// "meta[name='description']" → {tag:'meta', key:'description'}; "link[rel='alternate'][hreflang]" → {tag:'link', rel:'alternate', hreflang:true}
|
|
2002
|
+
const t = { tag: (sel.match(/^\s*([a-z][a-z0-9]*)/i) || [])[1]?.toLowerCase() };
|
|
2003
|
+
for (const m of sel.matchAll(/\[\s*([a-z:-]+)\s*(?:[~|^$*]?=\s*['"]?([^'"\]]+)['"]?)?\s*\]/gi)) {
|
|
2004
|
+
const [, a, val] = m;
|
|
2005
|
+
if (a === 'name' || a === 'property' || a === 'itemprop' || a === 'http-equiv') t.key = (val || '').toLowerCase();
|
|
2006
|
+
else if (a === 'rel') t.rel = (val || '').toLowerCase();
|
|
2007
|
+
else if (a === 'hreflang') t.hreflang = val || true;
|
|
2008
|
+
else if (a === 'type') t.type = val;
|
|
2009
|
+
}
|
|
2010
|
+
return t;
|
|
2011
|
+
}
|
|
2012
|
+
function metaSection (n, cap, v) {
|
|
2013
|
+
const md = n.metadata?.[S.loc];
|
|
2014
|
+
if (!md && cap?.platform && cap.platform !== 'electron') return '';
|
|
2015
|
+
if (!md) return cap ? `<div class="sec md" id="mdSec"><h4>${ICON.html} ${T('metaTitle')}</h4><p class="note">${T('noMeta')}</p></div>` : '';
|
|
2016
|
+
const crits = n.criteria.filter((c) => c.target?.kind === 'meta').map((c) => ({ c, t: selTargets(c.target.selector), st: VSTAT[(c.by_locale?.[S.loc]?.[v.name] || {}).verdict || 'not_reached'] }));
|
|
2017
|
+
const badges = (list) => list.map(({ c, st }) => `<span class="acb st-${st}">${esc(c.id)}</span>`).join('');
|
|
2018
|
+
const rowAttr = (list) => { const worstSt = list.some((x) => x.st === 'fail') ? 'fail' : list.length ? list[0].st : ''; return `data-acs="${esc(list.map((x) => x.c.id).join(' '))}" class="${worstSt ? 'st-' + worstSt : ''}"`; };
|
|
2019
|
+
const matchMeta = (m) => crits.filter(({ t }) => t.tag === 'meta' && t.key && t.key === (m.key || '').toLowerCase());
|
|
2020
|
+
const matchLink = (l) => crits.filter(({ t }) => t.tag === 'link' && (!t.rel || l.rel.toLowerCase().split(/\s+/).includes(t.rel)) && (!t.hreflang || l.hreflang));
|
|
2021
|
+
const row = (k, val, list) => `<tr ${rowAttr(list)}><td title="${esc(k)}">${esc(k)}</td><td>${esc(val ?? '')}${badges(list)}</td></tr>`;
|
|
2022
|
+
const used = new Set();
|
|
2023
|
+
const note = (list) => list.forEach((x) => used.add(x.c.id));
|
|
2024
|
+
const titleCrit = crits.filter(({ t }) => t.tag === 'title'); note(titleCrit);
|
|
2025
|
+
const metas = md.meta || [];
|
|
2026
|
+
const isOg = (m) => /^(og|article|product|fb|music|video|book|profile):/i.test(m.key);
|
|
2027
|
+
const isTw = (m) => /^twitter:/i.test(m.key);
|
|
2028
|
+
const basicKeys = ['description', 'robots', 'viewport', 'keywords', 'author', 'googlebot', 'theme-color', 'charset', 'content-type', 'content-language'];
|
|
2029
|
+
const basicMeta = metas.filter((m) => basicKeys.includes(m.key.toLowerCase()));
|
|
2030
|
+
const otherMeta = metas.filter((m) => !isOg(m) && !isTw(m) && !basicKeys.includes(m.key.toLowerCase()));
|
|
2031
|
+
const links = md.links || [];
|
|
2032
|
+
const canon = links.filter((l) => /\bcanonical\b/i.test(l.rel));
|
|
2033
|
+
const hreflang = links.filter((l) => l.hreflang);
|
|
2034
|
+
const otherLinks = links.filter((l) => !/\bcanonical\b/i.test(l.rel) && !l.hreflang);
|
|
2035
|
+
const seen = {}; metas.forEach((m) => { const k = m.key.toLowerCase(); seen[k] = (seen[k] || 0) + 1; });
|
|
2036
|
+
const dup = (k) => seen[k.toLowerCase()] > 1 && !/^(og|article|product):|^twitter:/i.test(k) ? ` <span class="acb st-partial">${seen[k.toLowerCase()]}× ${T('duplicate')}</span>` : '';
|
|
2037
|
+
const mrows = (list) => list.map((m) => { const c = matchMeta(m); note(c); return row(m.key, m.content, c).replace('</td></tr>', `${dup(m.key)}</td></tr>`); }).join('');
|
|
2038
|
+
const lrows = (list, label) => list.map((l) => { const c = matchLink(l); note(c); return row(label(l), l.href, c); }).join('');
|
|
2039
|
+
const basic = row('title', md.title, titleCrit) + row('html lang', md.lang, []) + mrows(basicMeta) + lrows(canon, () => 'link rel=canonical');
|
|
2040
|
+
const hl = lrows(hreflang, (l) => `hreflang=${l.hreflang}`);
|
|
2041
|
+
const og = mrows(metas.filter(isOg));
|
|
2042
|
+
const tw = mrows(metas.filter(isTw));
|
|
2043
|
+
const other = mrows(otherMeta);
|
|
2044
|
+
const ol = lrows(otherLinks, (l) => `rel=${l.rel}${l.sizes ? ' ' + l.sizes : ''}`);
|
|
2045
|
+
const headings = (md.headings || []).map((h) => `<span class="h" style="padding-left:${10 + (h.level - 1) * 14}px"><i>h${h.level}</i>${esc(h.text)}</span>`).join('');
|
|
2046
|
+
const hCount = (md.headings || []).filter((h) => h.level === 1).length;
|
|
2047
|
+
const hCrit = crits.filter(({ t }) => /^h[1-6]$/.test(t.tag || '')); note(hCrit);
|
|
2048
|
+
const hTable = hCrit.length ? `<table>${hCrit.map((x) => row(`${x.t.tag} ×`, (md.headings || []).filter((h) => 'h' + h.level === x.t.tag).length, [x])).join('')}</table>` : '';
|
|
2049
|
+
const ld = (md.jsonld || []).map((x, k) => { const types = x.ok ? [].concat(x.data?.['@graph'] || x.data).map((d) => d?.['@type']).flat().filter(Boolean).join(', ') : T('invalidJson'); return `<details><summary>${esc(types || 'JSON-LD')} <span class="cnt">#${k + 1}</span></summary><pre>${x.ok ? jsonHTML(x.data) : esc(x.error + '\n' + x.raw)}</pre></details>`; }).join('');
|
|
2050
|
+
const micro = (md.microdata || []).map((x, k) => `<details><summary>${esc((x.type || 'itemscope').replace(/^https?:\/\/schema\.org\//, ''))} <span class="cnt">#${k + 1}</span></summary><pre>${jsonHTML(x.props)}</pre></details>`).join('');
|
|
2051
|
+
const missing = crits.filter((x) => !used.has(x.c.id));
|
|
2052
|
+
const missRows = missing.map((x) => row(`${T('missing')}: ${x.c.target.selector}`, '', [x])).join('');
|
|
2053
|
+
const failIn = (html) => /class="st-fail"/.test(html);
|
|
2054
|
+
const group = (title, cnt, body, open) => body ? `<details ${open ? 'open' : ''}><summary>${title}<span class="cnt">${cnt}</span></summary>${body.startsWith('<tr') ? `<table>${body}</table>` : body}</details>` : '';
|
|
2055
|
+
const basicAll = missRows + basic;
|
|
2056
|
+
return `<div class="sec md" id="mdSec"><h4>${ICON.html} ${T('metaTitle')}</h4>
|
|
2057
|
+
${group(T('mdBasic'), basicMeta.length + 2 + canon.length, basicAll, failIn(basicAll) || missing.length)}
|
|
2058
|
+
${group(T('mdHeadings'), `${(md.headings || []).length} · h1 ${hCount}`, hTable + headings, failIn(hTable))}
|
|
2059
|
+
${group(T('mdHreflang'), hreflang.length, hl, failIn(hl))}
|
|
2060
|
+
${group(T('mdOg'), metas.filter(isOg).length, og, failIn(og))}
|
|
2061
|
+
${group(T('mdTwitter'), metas.filter(isTw).length, tw, failIn(tw))}
|
|
2062
|
+
${group(T('mdLd'), (md.jsonld || []).length, ld, false)}
|
|
2063
|
+
${group(T('mdMicro'), (md.microdata || []).length, micro, false)}
|
|
2064
|
+
${group(T('mdOther'), otherMeta.length, other, failIn(other))}
|
|
2065
|
+
${group(T('mdLinks'), otherLinks.length, ol, false)}
|
|
2066
|
+
</div>`;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/* ---------- lightbox ---------- */
|
|
2070
|
+
const flowOrder = () => screens().slice().sort((a, b) => a.col - b.col || a.lane - b.lane);
|
|
2071
|
+
function openLb (id, vpName, opts = {}) {
|
|
2072
|
+
if (RUN.details_deferred) loadDetails();
|
|
2073
|
+
const n0 = byId(id); if (n0?.kind === 'stack') { openStack(id); return; }
|
|
2074
|
+
const n = n0; const list = n.item ? (S.itemList || [id]).map(byId).filter(Boolean) : S.mode === 'index' && S.riList?.includes(id) ? S.riList.map(byId) : flowOrder(), i = list.indexOf(n);
|
|
2075
|
+
if (S.open !== id) { LIVE.insp = null; LIVE.anchor = null; LIVE.mode = null; }
|
|
2076
|
+
S.lbMode ||= isDesign() ? 'live' : 'shot';
|
|
2077
|
+
S.open = id; S.lbVp = vpName || (S.vp === 'mobile' ? mVp() : dVp())?.name || viewports()[0].name;
|
|
2078
|
+
if (n.item && !n.captures?.[S.loc]?.[S.lbVp]) S.lbVp = Object.keys(n.captures?.[S.loc] || {})[0] || S.lbVp; // stack pages: the captured viewport
|
|
2079
|
+
const v = viewports().find((x) => x.name === S.lbVp) || viewports()[0];
|
|
2080
|
+
const cap = n.captures?.[S.loc]?.[v.name];
|
|
2081
|
+
const s = nodeSt(n); const j = jevFor(n, v.name);
|
|
2082
|
+
const g = RUN.git || {};
|
|
2083
|
+
const lb = $('lb');
|
|
2084
|
+
const html = `<div class="lb__box">
|
|
2085
|
+
<div class="lb__stage">
|
|
2086
|
+
<div class="lb__tools"><div class="seg" id="lbVp"></div>${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>
|
|
2087
|
+
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2088
|
+
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2089
|
+
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);
|
|
2090
|
+
if (S.lbMode === 'live' && liveSrc(cap) && !S.cmp) return liveStage(cap, v);
|
|
2091
|
+
if (S.lbMode === 'live' && liveSrc(cap) && S.cmp && S.lbCmp !== 'diff' && bcap0?.live) return `<div class="lb__cmp"><figure><figcaption><b>${T('thisRun')}</b></figcaption>${liveStage(cap, v)}</figure><figure><figcaption><b>${esc(cmpLabel())}</b>${sameAsCmp(n) ? ` · ${T('unchangedSince')} ${esc(cmpLabel())}` : ''}</figcaption>${liveStage(bcap0, v, 'lbFrameB', S.cmp.live || RUN.live_bases)}</figure></div>`;
|
|
2092
|
+
const img = cap ? frameImg(cap, v, `<img src="${esc(asset(cap.image || cap.png))}" alt="${esc(loc(n.title))}">`, 'lbImg') : `<p class="lb__empty">${T('notReached')}</p>`;
|
|
2093
|
+
if (!S.cmp) return img;
|
|
2094
|
+
const bn = cmpNode(n.id), bcap = bn?.captures?.[S.loc]?.[v.name];
|
|
2095
|
+
const bimg = bcap ? `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}" style="max-width:${v.mobile ? v.w + 16 : v.w}px"><img src="${esc(S.cmp.base + (bcap.image || bcap.png))}" alt=""></div>` : `<p class="lb__empty">${T('cmpNoCap')}</p>`;
|
|
2096
|
+
if (S.lbCmp === 'diff' && cap && bcap) return `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption><b>${T('cmpDiff')}</b> <span id="lbPx">…</span></figcaption><div class="lb__diff" id="lbDiff" style="max-width:${v.mobile ? v.w + 16 : v.w}px"></div></figure></div>`;
|
|
2097
|
+
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>`;
|
|
2098
|
+
})()}</div>
|
|
2099
|
+
</div>
|
|
2100
|
+
<div class="lb__side">
|
|
2101
|
+
<div class="lb__head">
|
|
2102
|
+
<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>` : ''}
|
|
2103
|
+
<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} ${T('copyLink')}</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>
|
|
2104
|
+
<h3 id="lbTitle">${esc(loc(n.title))}</h3>
|
|
2105
|
+
<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>
|
|
2106
|
+
${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}
|
|
2107
|
+
</div>
|
|
2108
|
+
<div class="lbtabs" role="tablist">${[['overview', T('tabOverview')], ['page', T('tabPage')], ['tech', T('tabTech')]].map(([k, l]) => `<button role="tab" data-lbtab="${k}" aria-selected="${(S.lbTab || 'overview') === k}">${l}</button>`).join('')}</div>
|
|
2109
|
+
<div class="lbtab" data-pane="overview" ${(S.lbTab || 'overview') === 'overview' ? '' : 'hidden'}>
|
|
2110
|
+
<div id="lbSideTop"></div>${inspectorHtml()}
|
|
2111
|
+
${stepStatusHtml(n)}
|
|
2112
|
+
${cap?.mail ? `<div class="sec"><dl class="mailmeta"><dt>${T('subject')}</dt><dd>${esc(cap.mail.subject)}</dd><dt>${T('from')}</dt><dd>${esc(cap.mail.from)}</dd><dt>${T('to')}</dt><dd>${esc(cap.mail.to)}</dd>${cap.mail.date ? `<dt>${T('received')}</dt><dd>${esc(fmtDate(cap.mail.date))}${RUN.started_at && Date.parse(cap.mail.date) < Date.parse(RUN.started_at) - 5000 ? ` <span class="mail-old" title="${esc(T('mailBeforeRun'))}">${esc(T('mailBeforeRun'))}</span>` : ''}</dd>` : ''}${cap.mail.id ? `<dt>ID</dt><dd class="mono">${esc(cap.mail.id)}</dd>` : ''}</dl></div>` : ''}
|
|
2113
|
+
${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}
|
|
2114
|
+
<div class="sec"><h4>${T('criteria')} ${critCounts(n, v)}</h4><ul class="ac">${critOrder(n, v).map((c) => { const r = c.by_locale?.[S.loc]?.[v.name] || {}; const st = VSTAT[r.verdict || 'not_reached']; const hasBox = (cap?.boxes?.[c.id] || []).some((b) => b.w > 0 && b.h > 0); const where = hasBox ? `${ICON.crop}${T('onPage')}` : c.target?.kind === 'meta' ? `${ICON.html}${T('inMeta')}` : c.target?.kind === 'console' ? `${ICON.log}${T('inConsole')}` : T('noPlace');
|
|
2115
|
+
const br = S.cmp ? cellV((cmpNode(n.id)?.criteria || []).find((x) => x.id === c.id), S.loc, v.name) : null; const cmpChanged = S.cmp && cmpNode(n.id) && ((br?.verdict || null) !== (r.verdict && r.verdict !== 'not_reached' ? r.verdict : null) || (br?.evidence || br?.note || '') !== (r.evidence || r.note || ''));
|
|
2116
|
+
return `<li class="st-${st} ${cmpChanged ? 'cmp-changed' : ''}" data-hl="${esc(c.id)}" data-where="${esc(hasBox ? 'page' : c.target?.kind || '')}" tabindex="0"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(c.id)}</span>${esc(critText(c))} ${cmpChanged ? `<span class="dl">Δ</span>` : ''}${c.flaky ? `<span class="dl" title="${T('flakyNote')}">~ ${T('flaky')}</span>` : ''}${c.pack ? `<span class="acb st-skip" style="margin-left:6px" title="${T('packNote')}">${esc(c.pack)}</span>` : ''}</span><div class="acbody">${howBlock(c, r)}${r.evidence ? (humanEvidence(r.evidence) ? `<span class="ev ev--text">${esc(humanEvidence(r.evidence))}</span>` : `<code class="ev">${esc(r.evidence)}</code>`) : ''}${helpBlock(c, st)}${c.check || r.score != null ? `<details class="tech"><summary>${T('howTech')}</summary><code class="ev">${c.check ? esc(checkLabel(c.check)) : esc(c.type || '')}${r.score != null ? ` · score ${r.score.toFixed(2)}` : ''}${r.note && c.check?.type !== 'semantic' ? ` · ${esc(r.note)}` : ''}${r.evidence && humanEvidence(r.evidence) ? `\n${esc(r.evidence)}` : ''}</code></details>` : ''}${cmpChanged ? `<code class="ev ev--cmp">${T('was')}: ${esc(br ? `${br.verdict}${br.score != null ? ' · ' + br.score.toFixed(2) : ''}${br.evidence || br.note ? ' — ' + (br.evidence || br.note) : ''}` : T('skip').toLowerCase())}</code>` : ''}${hasBox ? `<span class="where">${where}</span>` : ['meta', 'console'].includes(c.target?.kind) ? `<button type="button" class="where where--go" data-jump="${esc(c.target.kind)}">${where} →</button>` : `<span class="where">${where}</span>`}</div></li>`; }).join('') || `<li class="st-pass"><span class="mark">–</span><span>—</span></li>`}</ul></div>
|
|
2117
|
+
${!n.item && canWrite() && n.criteria.some((c) => c.type === 'manual' || (c.by_locale?.[S.loc]?.[v.name]?.verdict === 'needs_review')) ? `<div class="sec"><h4>${T('decideManual')}</h4>${n.criteria.filter((c) => c.type === 'manual' || c.by_locale?.[S.loc]?.[v.name]?.verdict === 'needs_review').map((c) => `<div class="mdec"><p class="mdec__t"><span class="id">${esc(c.id)}</span> ${esc(c.text)}</p><div class="mdec__row"><input type="text" data-mnote="${esc(c.id)}" placeholder="${T('manualNotePh')}" aria-label="${esc(c.id)}: ${T('manualNotePh')}"><button class="btn" data-manual="${esc(c.id)}" data-v="pass">${T('manualPass')}</button><button class="btn" data-manual="${esc(c.id)}" data-v="fail">${T('manualFail')}</button></div></div>`).join('')}</div>` : ''}
|
|
2118
|
+
${commentsBlock({ scope: 'step', nodeId: n.id, pinnable: !!cap && !['live', 'view'].includes(S.lbMode) })}
|
|
2119
|
+
</div>
|
|
2120
|
+
<div class="lbtab" data-pane="page" ${S.lbTab === 'page' ? '' : 'hidden'}>
|
|
2121
|
+
${sigSection(cap)}
|
|
2122
|
+
${metaSection(n, cap, v)}
|
|
2123
|
+
${(n.attachments || []).length ? `<div class="sec"><h4>${T('attachments')}</h4><div class="files">${n.attachments.map((a) => `<a class="file" href="${esc(asset(a.file))}" target="_blank" rel="noopener"><span class="file__prev"><img src="${esc(asset(a.file))}" alt="" style="max-width:100%;max-height:74px"></span><span class="file__name">${ICON.crop}${esc(a.title)}${a.locale ? ` · ${esc(a.locale)}` : ''}</span></a>`).join('')}</div></div>` : ''}
|
|
2124
|
+
${cap?.tree ? `<details class="sec" id="treeSec"><summary><h4>${ICON.html} ${T('uiTree')}</h4></summary><p class="note" id="treeNote">${T('treeHint')}</p><input type="search" class="tree-q" id="treeQ" placeholder="${esc(T('treeSearch'))}"><div class="tree" id="treeBody"></div></details>` : ''}
|
|
2125
|
+
</div>
|
|
2126
|
+
<div class="lbtab" data-pane="tech" ${S.lbTab === 'tech' ? '' : 'hidden'}>
|
|
2127
|
+
${j ? `<div class="sec"><h4>${ICON.ai} ${T('jevTitle')} <span class="strategy">${esc(j.model || j.provider || '')}</span></h4><p class="note" style="margin-bottom:8px">${T('jevNote')}</p><pre class="json">${jsonHTML(j)}</pre></div>` : ''}
|
|
2128
|
+
${n.execution ? `<div class="sec"><h4>${T('execution')} <span class="strategy">${esc(n.execution.strategy)}</span></h4><p class="instr">${esc(n.execution.instructions)}</p></div>` : ''}
|
|
2129
|
+
${consoleSection(cap)}
|
|
2130
|
+
<div class="sec" id="techSec"><h4>${T('tech')}</h4><dl class="kv"><dt>${T('commit')}</dt><dd>${esc(g.commit || '—')} · ${esc(g.branch || '')}</dd><dt>${T('env')}</dt><dd>${esc(RUN.environment.name)}</dd>${cap ? `<dt>${T('captured')}</dt><dd>${fmtDate(cap.captured_at)}</dd><dt>${T('console')}</dt><dd>${esc(cap.console_errors)} ${T('errors')} · ${esc(cap.blocked)} ${T('blocked')}</dd>${RUN.usage?.nodes?.[n.id] ? `<dt>${T('size')}</dt><dd>${fmtBytes(RUN.usage.nodes[n.id].bytes)} · ${RUN.usage.nodes[n.id].files} ${T('files')}${RUN.usage.nodes[n.id].captures?.[S.loc]?.[v.name] ? ` · ${S.loc}/${v.name} ${fmtBytes(RUN.usage.nodes[n.id].captures[S.loc][v.name])}` : ''}</dd>` : ''}${cap.platform ? `<dt>${T('platformWord')}</dt><dd>${esc(platformLabel(cap.platform))}${cap.device ? ` · ${esc(cap.device)}` : ''}</dd>` : ''}<dt>${T('files')}</dt><dd>${cap.html ? `<a class="pr" href="${esc(asset(cap.html))}" target="_blank" rel="noopener">HTML</a> · ` : ''}${cap.tree ? `<a class="pr" href="${esc(asset(cap.tree))}" target="_blank" rel="noopener">${T('uiTree')}</a> · ` : ''}<a class="pr" href="${esc(asset(cap.image || cap.png))}" target="_blank" rel="noopener">${(cap.image || cap.png || '').endsWith('.webp') ? 'WebP' : 'PNG'}</a>${cap.eval ? ` · <a class="pr" href="${esc(asset(cap.eval))}" target="_blank" rel="noopener">${T('viewEval')}</a>` : ''}</dd>` : ''}</dl></div>
|
|
2131
|
+
</div>
|
|
2132
|
+
</div></div>`;
|
|
2133
|
+
// a live frame keeps running when only the side panel changes (new comment, status, draft event)
|
|
2134
|
+
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;
|
|
2135
|
+
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) }); }
|
|
2136
|
+
else lb.innerHTML = html;
|
|
2137
|
+
lb.dataset.step = id; lb.dataset.vp = v.name;
|
|
2138
|
+
lb.hidden = false;
|
|
2139
|
+
fitLive(); requestAnimationFrame(fitLive);
|
|
2140
|
+
setTimeout(() => presenceView(true), 0);
|
|
2141
|
+
setHash({ step: id, loc: S.loc, vp: v.name });
|
|
2142
|
+
if ($('lbBack')) $('lbBack').onclick = () => { closeLb(); openStack(n.stack, { focus: n.itemId }); };
|
|
2143
|
+
if ($('lbComments')) $('lbComments').onclick = () => focusComposer();
|
|
2144
|
+
placeCommentPins($('lbImg'), n, cap, v);
|
|
2145
|
+
$('lbLink').onclick = async () => { try { await navigator.clipboard.writeText(location.href); toast(T('linkCopied')); } catch { toast(location.href); } };
|
|
2146
|
+
seg($('lbVp'), (n.item ? viewports().filter((x) => n.captures?.[S.loc]?.[x.name]) : viewports()).map((x) => [x.name, vpLabel(x)]), v.name, (nv) => openLb(id, nv));
|
|
2147
|
+
if ($('lbMode')) seg($('lbMode'), [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : []), ['shot', T('shotMode')]], S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(id, v.name); });
|
|
2148
|
+
if ($('lbLiveTools')) renderLiveTools();
|
|
2149
|
+
wireStepStatus(n); wireCopyCss();
|
|
2150
|
+
// pins of comments made on elements, drawn over the screenshot too
|
|
2151
|
+
const pimg = $('lbImg');
|
|
2152
|
+
if (pimg) { const place = () => { const k = pimg.querySelector('img').clientWidth / (cap?.size?.[0] || v.w); for (const p of pinsFor(n, v.name)) { if (!p.box) continue; const d = document.createElement('span'); d.className = 'pinmark'; d.textContent = p.n; Object.assign(d.style, { left: `${(p.box[0] + p.box[2]) * k}px`, top: `${p.box[1] * k}px`, opacity: p.resolved ? 0.5 : 1 }); pimg.appendChild(d); } }; const im = pimg.querySelector('img'); if (im) im.complete ? place() : im.addEventListener('load', place); }
|
|
2153
|
+
if (S.cmp) {
|
|
2154
|
+
if ($('lbCmpSeg')) seg($('lbCmpSeg'), [['side', T('cmpSide')], ['diff', T('cmpDiff')]], S.lbCmp, (m) => { S.lbCmp = m; openLb(id, v.name); });
|
|
2155
|
+
const bcap = cmpNode(n.id)?.captures?.[S.loc]?.[v.name];
|
|
2156
|
+
if (cap && bcap) pixelDiff(`${n.id}|${S.loc}|${v.name}`, asset(cap.image || cap.png), S.cmp.base + (bcap.image || bcap.png), Math.min(1000, v.w)).then((r) => {
|
|
2157
|
+
const px = $('lbPx'); if (px) { px.innerHTML = `<span class="pxd ${pxClass(r.ratio)}">◐ ${pxText(r.ratio)}</span>`; }
|
|
2158
|
+
const d = $('lbDiff'); if (d) { d.innerHTML = ''; d.appendChild(r.canvas); }
|
|
2159
|
+
}).catch(() => { const px = $('lbPx'); if (px) px.textContent = T('pxUnavail'); });
|
|
2160
|
+
}
|
|
2161
|
+
$('lbClose').onclick = closeLb;
|
|
2162
|
+
$('lbPrev').onclick = () => i > 0 && openLb(list[i - 1].id, S.lbVp);
|
|
2163
|
+
$('lbNext').onclick = () => i < list.length - 1 && openLb(list[i + 1].id, S.lbVp);
|
|
2164
|
+
lb.querySelectorAll('[data-manual]').forEach((b) => { b.onclick = async () => { const note = lb.querySelector(`[data-mnote="${b.dataset.manual}"]`)?.value.trim() || ''; if (b.dataset.v === 'fail' && !note) { toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/criteria', { node_id: id, criterion_id: b.dataset.manual, locale: S.loc, verdict: b.dataset.v, note }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
2165
|
+
const img = $('lbImg');
|
|
2166
|
+
if (img && cap?.boxes) {
|
|
2167
|
+
const scaleBoxes = () => {
|
|
2168
|
+
img.querySelectorAll('.hlbox').forEach((b) => b.remove());
|
|
2169
|
+
const k = img.querySelector('img').clientWidth / (cap.size?.[0] || v.w);
|
|
2170
|
+
for (const [cid, list2] of Object.entries(cap.boxes)) for (const b of list2) {
|
|
2171
|
+
const el = document.createElement('span'); el.className = 'hlbox'; el.dataset.ac = cid; el.hidden = true;
|
|
2172
|
+
Object.assign(el.style, { left: `${b.x * k}px`, top: `${b.y * k}px`, width: `${b.w * k}px`, height: `${b.h * k}px` });
|
|
2173
|
+
img.appendChild(el);
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
const im = img.querySelector('img'); im.complete ? scaleBoxes() : im.addEventListener('load', scaleBoxes);
|
|
2177
|
+
}
|
|
2178
|
+
if ($('treeSec')) $('treeSec').addEventListener('toggle', () => { if ($('treeSec').open && !$('treeBody').dataset.done) loadTreePanel(cap, img); }, { once: false });
|
|
2179
|
+
const clearHl = () => { lb.querySelectorAll('.hlbox').forEach((e) => { e.hidden = true; e.classList.remove('on', 'first'); }); lb.querySelectorAll('.ac li.is-on').forEach((x) => x.classList.remove('is-on')); };
|
|
2180
|
+
lb.querySelectorAll('[data-lbtab]').forEach((b) => { b.onclick = () => showLbTab(b.dataset.lbtab); });
|
|
2181
|
+
const copyText = async (t) => { try { await navigator.clipboard.writeText(t); toast(T('copied')); } catch { toast(t.slice(0, 200)); } };
|
|
2182
|
+
lb.querySelectorAll('[data-clog]').forEach((b) => { b.onclick = () => copyText((b.dataset.clog === 'console' ? cap?.console : cap?.blocked_list)?.[Number(b.dataset.i)] || ''); });
|
|
2183
|
+
lb.querySelectorAll('[data-copy-all]').forEach((b) => { b.onclick = () => copyText(b.dataset.copyAll); });
|
|
2184
|
+
lb.querySelectorAll('[data-hl]').forEach((li) => {
|
|
2185
|
+
const act = (e) => {
|
|
2186
|
+
if (e?.target?.closest?.('.acbody')) return; // reading the detail (summary, technical detail) keeps the selection
|
|
2187
|
+
const was = li.classList.contains('is-on');
|
|
2188
|
+
clearHl();
|
|
2189
|
+
if (was) { setHash({ step: id, loc: S.loc, vp: v.name }); return; }
|
|
2190
|
+
li.classList.add('is-on');
|
|
2191
|
+
setHash({ step: id, loc: S.loc, vp: v.name, ac: li.dataset.hl });
|
|
2192
|
+
if (!li.dataset.where) toast(n.criteria.some((x) => x.target) || n.metadata?.[S.loc] ? T('noPlaceToast') : T('noMeta'));
|
|
2193
|
+
if (li.dataset.where === 'meta' && !n.metadata?.[S.loc]) { toast(T('noMeta')); return; }
|
|
2194
|
+
if (li.dataset.where === 'page' && img) {
|
|
2195
|
+
const els = [...img.querySelectorAll(`.hlbox[data-ac="${CSS.escape(li.dataset.hl)}"]`)].filter((e) => parseFloat(e.style.width) > 0);
|
|
2196
|
+
els.forEach((e, k) => { e.hidden = false; e.classList.add('on'); if (!k) e.classList.add('first'); });
|
|
2197
|
+
if (els[0]) $('lbScroll').scrollTo({ top: Math.max(0, els[0].offsetTop - 80), behavior: 'smooth' }); else toast(T('noBoxToast'));
|
|
2198
|
+
} else if (li.dataset.where === 'meta' && li.dataset.go) {
|
|
2199
|
+
// only from the "in metadata" link: the criterion stays open in its tab otherwise
|
|
2200
|
+
showLbTab('page');
|
|
2201
|
+
const rows = [...lb.querySelectorAll(`.md [data-acs~="${CSS.escape(li.dataset.hl)}"]`)];
|
|
2202
|
+
rows.forEach((r) => { r.closest('details').open = true; r.classList.remove('flash'); void r.offsetWidth; r.classList.add('flash'); });
|
|
2203
|
+
const md = $('mdSec'); if (md) (rows[0] || md).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2204
|
+
} else if (li.dataset.where === 'console' && li.dataset.go) {
|
|
2205
|
+
showLbTab('tech');
|
|
2206
|
+
($('consoleSec') || $('techSec'))?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
li.addEventListener('click', act);
|
|
2210
|
+
// "in metadata" / "in console": open that tab and point at the matching rows
|
|
2211
|
+
li.querySelector('.where[data-jump]')?.addEventListener('click', (e) => { e.stopPropagation(); li.dataset.go = '1'; li.classList.remove('is-on'); act(); delete li.dataset.go; });
|
|
2212
|
+
li.addEventListener('keydown', (e) => { if (e.target === li && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); act(); } });
|
|
2213
|
+
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; }); });
|
|
2214
|
+
li.addEventListener('mouseleave', () => { if (!img || lb.querySelector('.ac li.is-on')) return; img.querySelectorAll('.hlbox').forEach((e) => { e.hidden = true; }); });
|
|
2215
|
+
});
|
|
2216
|
+
$('lbClose').focus();
|
|
2217
|
+
if (S.pendingAc) {
|
|
2218
|
+
const li = lb.querySelector(`[data-hl="${CSS.escape(S.pendingAc)}"]`); S.pendingAc = null;
|
|
2219
|
+
if (li) { const im = img?.querySelector('img'); const go = () => setTimeout(() => li.click(), 60); im && !im.complete ? im.addEventListener('load', go, { once: true }) : go(); }
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
function closeLb () { $('lb').hidden = true; S.open = null; LIVE.mode = null; LIVE.insp = null; setHash(null); presenceView(); }
|
|
2223
|
+
function setHash (p) {
|
|
2224
|
+
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null }).filter(([, x]) => x));
|
|
2225
|
+
const h = Object.keys(all).length ? '#' + new URLSearchParams(all).toString() : ' ';
|
|
2226
|
+
history.replaceState(null, '', h === ' ' ? location.pathname + location.search : h);
|
|
2227
|
+
}
|
|
2228
|
+
function applyHash () {
|
|
2229
|
+
const p = new URLSearchParams(location.hash.slice(1));
|
|
2230
|
+
if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
|
|
2231
|
+
const cmp = p.get('cmp');
|
|
2232
|
+
if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
|
|
2233
|
+
if (p.get('replay')) { const path = (p.get('path') || p.get('step') || '').split(',').filter((id) => byId(id)); if (!S.rp || S.rp.path.join(',') !== path.join(',')) rpOpen(path); return; }
|
|
2234
|
+
if (S.rp) rpClose();
|
|
2235
|
+
if (p.get('comments')) showCommentsFromHash(p.get('comments'));
|
|
2236
|
+
const step = p.get('step');
|
|
2237
|
+
if (step && step.includes('~') && !byId(step) && byId(step.split('~')[0])?.kind === 'stack') { loadStack(byId(step.split('~')[0])).then((d) => { if (d) { S.itemList = d.items.map((it) => ensureItemNode(byId(step.split('~')[0]), d, it).id); applyHash(); } }); return; }
|
|
2238
|
+
if (!step || !byId(step)) return;
|
|
2239
|
+
if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); }
|
|
2240
|
+
S.pendingAc = p.get('ac');
|
|
2241
|
+
openLb(step, p.get('vp'));
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/* ---------- replay: guided walkthrough with branch choice ---------- */
|
|
2245
|
+
const outEdges = (id) => RUN.edges.filter((e) => e.from === id && byId(e.to)).sort((a, b) => (a.kind === 'alt') - (b.kind === 'alt'));
|
|
2246
|
+
function startNode () { const order = RUN.nodes.slice().sort((a, b) => a.col - b.col || a.lane - b.lane); return order.find((n) => !RUN.edges.some((e) => e.to === n.id)) || order[0]; }
|
|
2247
|
+
function rpOpen (path) {
|
|
2248
|
+
const ids = (path || []).filter((id) => byId(id));
|
|
2249
|
+
S.rp = { path: ids.length ? ids : [startNode().id] };
|
|
2250
|
+
$('lb').hidden = true; S.open = null; closeBrowser();
|
|
2251
|
+
renderReplay();
|
|
2252
|
+
}
|
|
2253
|
+
function rpClose () { S.rp = null; $('rp').hidden = true; setHash(null); }
|
|
2254
|
+
function rpGo (id) { if (!byId(id)) return; S.rp.path.push(id); renderReplay(); }
|
|
2255
|
+
function rpBack () { if (S.rp.path.length > 1) { S.rp.path.pop(); renderReplay(); } }
|
|
2256
|
+
function rpJump (i) { S.rp.path = S.rp.path.slice(0, i + 1); renderReplay(); }
|
|
2257
|
+
function rpNextOptions (n) { return outEdges(n.id).map((e, k) => ({ e, n: byId(e.to), k })); }
|
|
2258
|
+
function renderReplay () {
|
|
2259
|
+
const cur = S.rp.path[S.rp.path.length - 1], n = byId(cur);
|
|
2260
|
+
const v = (S.vp === 'mobile' ? mVp() : dVp()) || viewports()[0];
|
|
2261
|
+
const cap = n.captures?.[S.loc]?.[v.name];
|
|
2262
|
+
const opts = rpNextOptions(n);
|
|
2263
|
+
const s = n.kind === 'decision' ? null : nodeSt(n);
|
|
2264
|
+
const crumbs = S.rp.path.map((id, i) => `<button data-jump="${i}" ${i === S.rp.path.length - 1 ? 'aria-current="step"' : ''}>${esc(loc(byId(id).title))}</button>`).join('<i>›</i>');
|
|
2265
|
+
const optBtn = (o, primary) => `<button class="${primary ? 'btn btn--primary' : 'btn'}" data-go="${esc(o.n.id)}" title="${esc(o.e.label || '')}">${primary ? T('rpNext') + ': ' : ''}${esc(loc(o.n.title))}${o.e.label ? ` <span class="note">· ${esc(o.e.label)}</span>` : ''}</button>`;
|
|
2266
|
+
let stage, foot;
|
|
2267
|
+
if (n.kind === 'decision') {
|
|
2268
|
+
stage = `<div class="rp__decision"><span class="diamond"></span><div class="seg-label">${T('rpDecision')}</div><h3>${esc(loc(n.title))}</h3><p class="note" style="margin-bottom:14px">${T('rpChoose')}</p><div class="rp__opts">${opts.map((o) => `<button data-go="${esc(o.n.id)}"><span class="kbd">${o.k + 1}</span><b>${esc(o.e.label || loc(o.n.title))}</b><small>→ ${esc(loc(o.n.title))}${loc(o.n.path) ? ` · ${esc(loc(o.n.path))}` : ''}</small></button>`).join('') || `<p class="rp__end">${T('rpEnd')}</p>`}</div></div>`;
|
|
2269
|
+
foot = '';
|
|
2270
|
+
} else if (n.kind === 'stack') {
|
|
2271
|
+
const st = n.stack || { sample: [], summary: {} };
|
|
2272
|
+
stage = `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(st.count)} ${T('pagesWord')}</div><h3>${esc(loc(n.title))}</h3><div class="stack-fan" style="max-width:520px;height:220px;margin:12px auto">${(st.sample || []).slice(0, 3).map((x) => x.thumb ? `<img src="${esc(asset(x.thumb))}" alt="" style="height:180px">` : '<span class="ph" style="height:180px"></span>').join('')}</div><p class="note">${['pass', 'partial', 'fail', 'skip'].filter((k) => st.summary[k]).map((k) => `${MARK[k]} ${T(k)} ${st.summary[k]}`).join(' · ')}</p><button class="btn btn--primary" data-stack-open="${esc(n.id)}">${T('openIndex')}</button></div>`;
|
|
2273
|
+
foot = opts.length ? `${optBtn(opts[0], true)}${opts.length > 1 ? `<span class="rp__alts">${T('rpOr')} ${opts.slice(1).map((o) => optBtn(o, false)).join('')}</span>` : ''}` : `<span class="note">${T('rpEnd')}</span>`;
|
|
2274
|
+
} else {
|
|
2275
|
+
stage = cap ? `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}" style="max-width:${v.mobile ? v.w + 16 : v.w}px;width:100%"><img src="${esc(asset(cap.image || cap.png))}" alt="${esc(loc(n.title))}"></div>` : `<p class="lb__empty">${T('notReached')}</p>`;
|
|
2276
|
+
foot = opts.length ? `${optBtn(opts[0], true)}${opts.length > 1 ? `<span class="rp__alts">${T('rpOr')} ${opts.slice(1).map((o) => optBtn(o, false)).join('')}</span>` : ''}` : `<span class="note">${T('rpEnd')}</span> <button class="btn" data-restart>${T('rpRestart')}</button>`;
|
|
2277
|
+
}
|
|
2278
|
+
const crit = n.kind === 'decision' ? '' : `<div class="sec"><h4>${T('criteria')}</h4><ul class="ac">${n.criteria.map((c) => { const r = c.by_locale?.[S.loc]?.[v.name] || {}; const st = VSTAT[r.verdict || 'not_reached']; return `<li class="st-${st}"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(c.id)}</span>${esc(c.text)}${r.score != null ? ` <span class="id">${r.score.toFixed(2)}</span>` : ''}</span>${r.evidence || r.note ? `<code class="ev">${esc(r.evidence || r.note)}</code>` : ''}</li>`; }).join('') || `<li class="st-pass"><span class="mark">–</span><span>—</span></li>`}</ul></div>`;
|
|
2279
|
+
$('rp').innerHTML = `
|
|
2280
|
+
<div class="rp__top"><span class="brand">Kaloko <small>${T('replay')}</small></span><span class="note">${T('rpStep')} ${S.rp.path.length}</span><span class="crumbs" id="rpCrumbs">${crumbs}</span><span class="spacer"></span>
|
|
2281
|
+
<div class="seg" id="rpLoc"></div><div class="seg" id="rpVp"></div><span class="note">${T('rpKeys')}</span><button class="icon-btn" id="rpClose" aria-label="Close">${ICON.close}</button></div>
|
|
2282
|
+
<div class="rp__body"><div class="rp__stage" id="rpStage">${stage}</div>
|
|
2283
|
+
<aside class="rp__side"><div class="lb__head"><div class="lb__row">${n.kind === 'decision' ? `<span class="diamond" style="width:14px;height:14px"></span>` : `<span class="kind">${ICON[n.kind]}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span>`}<span class="note">${esc(RUN.scenario.title)}</span></div><h3>${esc(loc(n.title))}</h3>${n.kind !== 'decision' ? `<div class="node__path">${esc(cap?.url || loc(n.path) || '')}</div>` : ''}</div>
|
|
2284
|
+
${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
2285
|
+
${n.kind !== 'decision' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}
|
|
2286
|
+
${n.kind !== 'decision' && n.execution ? `<div class="sec"><h4>${T('execution')}</h4><p class="instr">${esc(n.execution.instructions || '')}</p></div>` : ''}</aside></div>
|
|
2287
|
+
<div class="rp__foot"><button class="btn" id="rpPrev" ${S.rp.path.length > 1 ? '' : 'disabled'}>${ICON.prev} ${T('rpPrev')}</button><span class="spacer"></span>${foot}</div>`;
|
|
2288
|
+
$('rp').hidden = false;
|
|
2289
|
+
seg($('rpLoc'), RUN.scope.locales.map((l) => [l, l]), S.loc, (l) => { S.loc = l; renderAll(); renderReplay(); });
|
|
2290
|
+
seg($('rpVp'), viewports().map((x) => [x.name, vpLabel(x)]), v.name, (nv) => { S.vp = nv; renderAll(); renderReplay(); });
|
|
2291
|
+
$('rpClose').onclick = rpClose; $('rpPrev').onclick = rpBack;
|
|
2292
|
+
$('rp').querySelectorAll('[data-go]').forEach((b) => { b.onclick = () => rpGo(b.dataset.go); });
|
|
2293
|
+
$('rp').querySelectorAll('[data-jump]').forEach((b) => { b.onclick = () => rpJump(Number(b.dataset.jump)); });
|
|
2294
|
+
$('rp').querySelectorAll('[data-stack-open]').forEach((b) => { b.onclick = () => openStack(b.dataset.stackOpen); });
|
|
2295
|
+
$('rp').querySelector('[data-restart]')?.addEventListener('click', () => rpOpen(null));
|
|
2296
|
+
$('rpStage').scrollTop = 0;
|
|
2297
|
+
setHash({ replay: 1, step: cur, path: S.rp.path.join(',') });
|
|
2298
|
+
focusNode(cur);
|
|
2299
|
+
}
|
|
2300
|
+
function rpKey (e) {
|
|
2301
|
+
if (e.key === 'Escape') { rpClose(); return; }
|
|
2302
|
+
const n = byId(S.rp.path[S.rp.path.length - 1]); const opts = rpNextOptions(n);
|
|
2303
|
+
if (e.key === 'ArrowRight' || e.key === 'Enter') { if (opts[0]) rpGo(opts[0].n.id); }
|
|
2304
|
+
else if (e.key === 'ArrowLeft' || e.key === 'Backspace') rpBack();
|
|
2305
|
+
else if (/^[1-9]$/.test(e.key)) { const o = opts[Number(e.key) - 1]; if (o) rpGo(o.n.id); }
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/* ---------- summary ---------- */
|
|
2309
|
+
/* The run's problems, sorted by what to do about them: what failed (with the reason in words), what a person
|
|
2310
|
+
should decide (AI unsure or not evaluated) and screens whose quick signals look off. */
|
|
2311
|
+
function whyOf (n, x) {
|
|
2312
|
+
if (x.counts) return `${(x.counts.fail || 0) + (x.counts.needs_review || 0)}/${n.stack.count} ${T('pagesWord')}`;
|
|
2313
|
+
const cells = viewports().map((v) => ({ v, r: x.by_locale?.[S.loc]?.[v.name] || {} })).filter(({ r }) => r.verdict && r.verdict !== 'pass' && r.verdict !== 'not_reached');
|
|
2314
|
+
const c = cells.find(({ r }) => r.verdict === 'fail') || cells[0];
|
|
2315
|
+
if (!c) return x.evidence || '';
|
|
2316
|
+
const where = viewports().length > 1 ? `${c.v.name}: ` : '';
|
|
2317
|
+
if (c.r.score != null) return `${where}${T('aiShort')(Math.round(c.r.score * 100))}`;
|
|
2318
|
+
const ev = c.r.evidence || c.r.note || x.evidence || '';
|
|
2319
|
+
return `${where}${humanEvidence(ev) || ev}`;
|
|
2320
|
+
}
|
|
2321
|
+
function issuesSections (issues) {
|
|
2322
|
+
const row = ({ n, x }) => `<li><button data-go="${esc(n.id)}"><span class="badge st-${critSt(x)}">${MARK[critSt(x)]}</span><span><b>${esc(loc(n.title))}</b> · ${esc(x.id)} <span class="muted" style="font-weight:400;font-size:11.5px">${esc(x.type === 'semantic' ? T('howAi') : x.type === 'manual' ? T('howManual') : T('howAuto'))}</span></span><small>${esc(critText(x))}${whyOf(n, x) ? ` — ${esc(whyOf(n, x))}` : ''}</small></button></li>`;
|
|
2323
|
+
const fails = issues.filter(({ x }) => critVerdict(x) === 'fail');
|
|
2324
|
+
const decide = issues.filter(({ x }) => critVerdict(x) !== 'fail');
|
|
2325
|
+
const looks = screens().map((n) => ({ n, w: [...new Set(Object.values(n.captures?.[S.loc] || {}).flatMap(sigWarnings))] })).filter(({ w }) => w.length);
|
|
2326
|
+
return `<div class="sec"><h4>${T('failed')}</h4>${fails.length ? `<ul class="issues">${fails.map(row).join('')}</ul>` : `<p class="note">${issues.length ? T('noneFailed') : T('allPassed')}</p>`}</div>`
|
|
2327
|
+
+ (decide.length ? `<div class="sec"><h4>${T('toDecide')}</h4><p class="note" style="margin:0 0 6px">${T('toDecideLead')}</p><ul class="issues">${decide.map(row).join('')}</ul></div>` : '')
|
|
2328
|
+
+ (looks.length ? `<div class="sec"><h4>${T('worthLook')}</h4><p class="note" style="margin:0 0 6px">${T('sigLead')}</p><ul class="issues">${looks.map(({ n, w }) => `<li><button data-go="${esc(n.id)}"><span class="badge st-partial">⚠</span><span><b>${esc(loc(n.title))}</b></span><small>${esc(w.join(' · '))}</small></button></li>`).join('')}</ul></div>` : '');
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function renderReport () {
|
|
2332
|
+
const c = RUN.summary, tot = screens().length || 1, pr = prevRun();
|
|
2333
|
+
const all = screens().flatMap((n) => n.criteria), ok = all.filter((x) => critSt(x) === 'pass').length;
|
|
2334
|
+
const issues = screens().flatMap((n) => n.criteria.filter((x) => ['fail', 'partial'].includes(critSt(x))).map((x) => ({ n, x })));
|
|
2335
|
+
const changes = pr ? screens().filter((n) => pr.statuses?.[n.id] && pr.statuses[n.id] !== n.status) : [];
|
|
2336
|
+
$('rside').innerHTML = `
|
|
2337
|
+
<div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
|
|
2338
|
+
${APP() && isDraft() ? `<div class="sec"><h4>${T('draft')} #${esc(RUN.draft.seq)}</h4><p class="note">${T('draftInfo')}</p><div id="draftChanges" class="note"></div></div>` : ''}
|
|
2339
|
+
${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}</p>` : ''}
|
|
2340
|
+
${canWrite() ? `<textarea id="rvVerdictNote" hidden rows="2" placeholder="${T('verdictNotePh')}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"></textarea>
|
|
2341
|
+
<div class="share__row"><button class="btn btn--primary" data-verdict="accepted">${isDesign() ? T('acceptVersion') : T('accept')}</button><button class="btn" data-verdict="returned">${isDesign() ? T('returnVersion') : T('ret')}</button></div>
|
|
2342
|
+
<div class="vmore"><button class="linkish" id="rvNoteShow">${T('addNote')}</button>${RUN.verdict?.verdict === 'accepted' && !isDesign() ? `<button class="linkish" data-baseline="${RUN.verdict.baseline ? 'unset' : 'set'}">${RUN.verdict.baseline ? T('unsetBaseline') : T('setBaseline')}</button>` : ''}${APP().user?.role === 'admin' ? `<button class="linkish" data-public="${RUN.public ? 'off' : 'on'}" title="${T('publicHint')}">${RUN.public ? T('makePrivate') : isDesign() ? T('makePublicVersion') : T('makePublic')}</button>` : ''}</div>` : ''}${RUN.public ? `<p class="note" style="margin:6px 0 0"><span class="badge st-partial">${T('publicRun')}</span> ${T('publicNote')}</p>` : ''}</div>` : ''}
|
|
2343
|
+
${commentsPanel()}
|
|
2344
|
+
<div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
|
|
2345
|
+
<div class="stack">${['pass', 'partial', 'fail', 'skip'].map((k) => `<i style="width:${c[k] / tot * 100}%;background:var(--${k})"></i>`).join('')}</div>
|
|
2346
|
+
<div class="stack-legend">${['pass', 'partial', 'fail', 'skip'].map((k) => `<span><i class="dot st-${k}"></i>${T(k)} ${esc(c[k])}</span>`).join('')}</div></div>
|
|
2347
|
+
${issuesSections(issues)}
|
|
2348
|
+
${sessions().length ? `<div class="sec"><h4>${T('people')}</h4><div class="sesslegend">${sessions().map((k) => { const c = RUN.summary.by_session?.[k] || {}; const tot = (c.pass || 0) + (c.partial || 0) + (c.fail || 0) + (c.skip || 0); return `<button data-sess="${esc(k)}" aria-pressed="${S.sess === k}"><i class="dot" style="background:${sessColor(k)}"></i><span><b>${esc(sessTitle(k))}</b>${RUN.sessions[k].mailbox ? `<br><span class="note mono">${esc(RUN.sessions[k].mailbox)}</span>` : ''}</span><span class="note">${c.pass || 0}/${tot}</span>${bar5(c)}</button>`; }).join('')}</div><p class="note" style="margin-top:8px">${T('peopleHint')}</p></div>` : ''}
|
|
2349
|
+
${RUN.coverage ? `<div class="sec"><h4>${T('coverage')}</h4><p style="margin:0 0 6px"><b>${RUN.coverage.checked}/${RUN.coverage.planned}</b> ${T('coverageLine')}${RUN.coverage.extra.length ? ` · ${RUN.coverage.extra.length} ${T('coverageExtra')}` : ''}</p>${RUN.coverage.missing.length ? `<ul class="issues">${RUN.coverage.missing.map((m) => `<li><button data-go="${esc(m.step)}"><span class="badge st-skip">–</span><span><b>${esc(m.step)}</b> · ${esc(m.id)}</span><small>${esc(m.text)}</small></button></li>`).join('')}</ul>` : `<p class="note">${T('coverageFull')}</p>`}${RUN.coverage.steps_missing.length ? `<p class="note">${T('coverageSteps')}: ${esc(RUN.coverage.steps_missing.join(', '))}</p>` : ''}</div>` : ''}
|
|
2350
|
+
${RUN.summary.flaky ? `<div class="sec"><h4>${T('flakyTitle')}</h4><p class="note">${T('flakyNote')}</p><ul class="issues">${screens().flatMap((n) => (n.criteria || []).filter((c) => c.flaky).map((c) => `<li><button data-go="${esc(n.id)}"><span class="badge st-partial">~</span><span><b>${esc(loc(n.title))}</b> · ${esc(c.id)}</span><small>${esc(c.text)}</small></button></li>`)).join('')}</ul></div>` : ''}
|
|
2351
|
+
<div class="sec"><h4>${T('locales')}</h4><div class="matrix-wrap"><table class="matrix"><thead><tr><th>${T('step')}</th>${RUN.scope.locales.map((l) => `<th class="c">${esc(l)}</th>`).join('')}</tr></thead><tbody>${flowOrder().map((n) => `<tr data-id="${esc(n.id)}"><td>${esc(loc(n.title))}</td>${RUN.scope.locales.map((l) => `<td class="c"><i class="dot st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(T(n.by_locale?.[l] || 'skip'))}"></i></td>`).join('')}</tr>`).join('')}</tbody></table></div></div>
|
|
2352
|
+
${S.cmp ? `<div class="sec"><h4>${T('cmpTitle')} ${esc(cmpLabel())}</h4>${(() => { const rows = screens().map((n) => ({ n, b: cmpNode(n.id), crit: cmpCritChanges(n) })).filter((x) => x.b && ((x.n.status || null) !== (x.b.status || null) || x.crit.length)); const added = screens().filter((n) => !cmpNode(n.id)).map((n) => loc(n.title)); const removed = (S.cmp.run.nodes || []).filter((n) => n.kind !== 'decision' && !byId(n.id)).map((n) => loc(n.title)); return `${rows.length ? `<ul class="issues">${rows.map(({ n, b, crit }) => `<li><button data-go="${esc(n.id)}"><span class="badge st-${esc(nodeSt(n))}">Δ</span><span><b>${esc(loc(n.title))}</b>${(n.status || null) !== (b.status || null) ? ` · ${esc(T(b.status || 'skip'))} → ${esc(T(n.status || 'skip'))}` : ''}</span>${crit.map((c) => `<small>${esc(c.id)}: ${esc(c.base || '—')} → ${esc(c.run || '—')}${c.evRun !== c.evBase ? ` · ${esc(c.evBase || '—')} → ${esc(c.evRun || '—')}` : ''}</small>`).join('')}</button></li>`).join('')}</ul>` : `<p class="note">${T('cmpNone')}</p>`}${added.length ? `<p class="note">${T('cmpAdded')}: ${esc(added.join(', '))}</p>` : ''}${removed.length ? `<p class="note">${T('cmpRemoved')}: ${esc(removed.join(', '))}</p>` : ''}`; })()}</div>` : ''}
|
|
2353
|
+
${isDesign() ? '' : `<div class="sec"><h4>${T('changes')}</h4>`}${isDesign() ? '' : pr ? (changes.length ? `<ul class="issues">${changes.map((n) => `<li><button data-go="${esc(n.id)}"><span class="badge st-${esc(n.status)}">Δ</span><span><b>${esc(loc(n.title))}</b></span><small>${esc(T(pr.statuses[n.id]))} (${esc(pr.git?.commit || '')}) → ${esc(T(n.status))} (${esc(RUN.git?.commit || '')})</small></button></li>`).join('')}</ul>` : `<p class="note">${T('noChanges')}</p>`) : `<p class="note">${T('noPrev')}</p>`}${isDesign() ? '' : '</div>'}
|
|
2354
|
+
<div class="sec share"><h4>${ICON.share} ${T('share')}</h4>${APP()
|
|
2355
|
+
? `<div class="url" id="shareUrl">${esc(location.origin + location.pathname)}</div>
|
|
2356
|
+
<div class="share__row"><button class="btn btn--primary" id="shareCopy">${ICON.copy} ${T('copyLink')}</button>${APP().user && APP().run ? `<a class="btn" href="${esc(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/export.zip`)}" download>${ICON.download} ${T('downloadZip')}</a>` : ''}</div>
|
|
2357
|
+
<p class="note">${[RUN.expires_at && `${T('expires')} ${fmtDate(RUN.expires_at)}`, RUN.usage && `${T('storage')}: ${fmtBytes(RUN.usage.bytes)} · ${RUN.usage.files} ${T('filesWord')}`, RUN.public ? T('publicAccess') : T('orgAccess')].filter(Boolean).map(esc).join(' · ')}</p>`
|
|
2358
|
+
: `<div class="url">kaloko share</div><p class="note">${T('shareLocal')}</p>`}</div>`;
|
|
2359
|
+
$('rsClose').onclick = () => { S.right = false; setPanels(); };
|
|
2360
|
+
// what the draft changed since the latest version (#42)
|
|
2361
|
+
if (isDraft() && RUN.versions?.length && $('draftChanges')) {
|
|
2362
|
+
const v = RUN.versions[0];
|
|
2363
|
+
apiJson('GET', scenarioApi(`/changes?from=v${v.version}&to=${RUN.draft.branch === 'main' ? 'draft' : `draft:${enc(RUN.draft.branch)}`}`)).then((d) => {
|
|
2364
|
+
const el = $('draftChanges'); if (!el) return;
|
|
2365
|
+
const bySteps = new Map(); for (const c of d.changed) bySteps.set(c.step, [...(bySteps.get(c.step) || []), c.locale]);
|
|
2366
|
+
el.innerHTML = bySteps.size ? `${T('changedSince')} v${esc(v.version)}: ${[...bySteps].map(([step, locs]) => `<button class="pr" data-go="${esc(step)}" style="border:0;background:none;padding:0;cursor:pointer;text-decoration:underline">${esc(loc(byId(step)?.title) || step)}</button> (${locs.map(esc).join(', ')})`).join(', ')}` : `${T('noChangeSince')} v${esc(v.version)}`;
|
|
2367
|
+
el.querySelectorAll('[data-go]').forEach((b) => { b.onclick = () => openLb(b.dataset.go); });
|
|
2368
|
+
}).catch(() => {});
|
|
2369
|
+
}
|
|
2370
|
+
const sc = $('shareCopy'); if (sc) sc.onclick = async () => { const link = $('shareUrl').textContent; try { await navigator.clipboard.writeText(link); toast(T('linkCopied')); } catch { toast(link); } };
|
|
2371
|
+
const showNote = () => { const t = $('rvVerdictNote'); if (!t) return; t.hidden = false; $('rvNoteShow')?.remove(); t.focus(); };
|
|
2372
|
+
if ($('rvNoteShow')) $('rvNoteShow').onclick = showNote;
|
|
2373
|
+
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
2374
|
+
$('rside').querySelectorAll('[data-public]').forEach((b) => { b.onclick = async () => { if (b.dataset.public === 'on' && !confirm(T('publicConfirm'))) return; b.disabled = true; try { await postJson('/public', { public: b.dataset.public === 'on' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
2375
|
+
$('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
2376
|
+
$('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => openLb(el.dataset.go || el.dataset.id); });
|
|
2377
|
+
wireCommentsPanel();
|
|
2378
|
+
$('rside').querySelectorAll('.sesslegend [data-sess]').forEach((b) => { b.onclick = () => { S.sess = S.sess === b.dataset.sess ? null : b.dataset.sess; renderAll(); }; });
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/* ---------- scenarios and runs ---------- */
|
|
2382
|
+
function catalog () {
|
|
2383
|
+
const list = (RUN.catalog || []).slice();
|
|
2384
|
+
if (!list.find((s) => s.id === RUN.scenario.id)) list.unshift({ id: RUN.scenario.id, title: RUN.scenario.title, kind: RUN.scenario.kind, file: RUN.scenario.file });
|
|
2385
|
+
return list;
|
|
2386
|
+
}
|
|
2387
|
+
function runsOf (scId) {
|
|
2388
|
+
if (scId !== RUN.scenario.id) return (catalog().find((s) => s.id === scId)?.runs) || [];
|
|
2389
|
+
return [...(RUN.history || []), { id: RUN.id, started_at: RUN.started_at, git: RUN.git, environment: RUN.environment, summary: RUN.summary }].sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
2390
|
+
}
|
|
2391
|
+
/* The project picked in the lists is remembered per organization (cookie qw_p_<org>, set by the pages too). */
|
|
2392
|
+
function rememberedProject () { const org = window.QAWALK_APP?.org; if (!org) return ''; const m = document.cookie.match(new RegExp(`(?:^|; )qw_p_${org.replace(/[^a-z0-9-]/g, '')}=([^;]*)`)); try { return m ? decodeURIComponent(m[1]) : ''; } catch { return ''; } }
|
|
2393
|
+
function rememberProject (v) { const org = window.QAWALK_APP?.org; if (!org) return; try { document.cookie = `qw_p_${org}=${encodeURIComponent(v)}; path=/; max-age=${v ? 31536000 : 0}; samesite=lax${location.protocol === 'https:' ? '; secure' : ''}`; } catch {} }
|
|
2394
|
+
function renderBrowser () {
|
|
2395
|
+
const q = S.brQ.trim().toLowerCase();
|
|
2396
|
+
const projects = [...new Set(catalog().map((s) => s.project || '').filter(Boolean))].sort();
|
|
2397
|
+
const list = catalog().filter((s) => (S.brKind === 'all' || (s.kind || 'task') === S.brKind) && (!S.brProject || (s.project || '') === S.brProject) && (!q || `${s.id} ${s.title} ${s.file || ''} ${s.project || ''}`.toLowerCase().includes(q)));
|
|
2398
|
+
seg($('brKind'), [['all', T('all')], ['task', T('tasks')], ['flow', T('flows')]], S.brKind, (v) => { S.brKind = v; renderBrowser(); });
|
|
2399
|
+
const bp = $('brProject'); bp.hidden = !projects.length;
|
|
2400
|
+
bp.innerHTML = `<option value="">${T('projectAll')}</option>${projects.map((p) => `<option value="${esc(p)}" ${p === S.brProject ? 'selected' : ''}>${esc(p)}</option>`).join('')}`;
|
|
2401
|
+
bp.onchange = () => { S.brProject = bp.value; rememberProject(bp.value); renderBrowser(); };
|
|
2402
|
+
$('brCount').textContent = `${list.length} / ${catalog().length}`;
|
|
2403
|
+
$('brList').innerHTML = list.length ? list.map((s) => { const rs = runsOf(s.id); const last = rs[0]; return `<button class="sc" aria-selected="${s.id === S.brSc}" data-sc="${esc(s.id)}"><span class="sc__title"><span class="sc__id">${esc(s.id)}</span><span>${esc(s.title)}</span></span>${last?.summary ? bar5(last.summary) : '<span class="bar5"></span>'}<span class="sc__meta"><span class="kbadge ${s.kind === 'flow' ? 'kbadge--flow' : ''}">${esc(s.kind || 'task')}</span>${s.project ? `<span class="mono">${esc(s.project)}</span>` : ''}<span>${rs.length} ${T('runs')}</span>${last ? `<span>${T('last')} ${fmtDate(last.started_at)}</span>` : ''}${s.open_comments ? `<span class="cmt" title="${esc(T('openCommentsN'))}: ${s.open_comments}">${ICON.comment}${s.open_comments}</span>` : ''}</span></button>`; }).join('') : `<p class="empty">${T('noScenario')}</p>`;
|
|
2404
|
+
renderBrRuns();
|
|
2405
|
+
}
|
|
2406
|
+
function renderBrRuns () {
|
|
2407
|
+
const rs = runsOf(S.brSc);
|
|
2408
|
+
$('brRunFilters').innerHTML = `<b style="font-size:13px">${esc(S.brSc)}</b>`;
|
|
2409
|
+
if (!rs.length) { $('brRuns').innerHTML = `<p class="empty">${T('noRuns')}</p>`; return; }
|
|
2410
|
+
let lastDay = null; const rows = [];
|
|
2411
|
+
for (const r of rs) {
|
|
2412
|
+
const d = new Date(r.started_at); const day = d.toDateString();
|
|
2413
|
+
if (day !== lastDay) { lastDay = day; rows.push(`<tr class="day"><td colspan="6">${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()}</td></tr>`); }
|
|
2414
|
+
const c = r.summary || {}; const tot = (c.pass || 0) + (c.partial || 0) + (c.fail || 0) + (c.skip || 0);
|
|
2415
|
+
rows.push(`<tr data-run="${esc(r.id)}" aria-current="${r.id === RUN.id}"><td class="mono">${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}</td><td><span class="run__env run__env--${esc(r.environment?.name)}">${esc(r.environment?.name)}</span></td><td><span class="mono">${esc(r.git?.branch || '')} @ ${esc(r.git?.commit || '')}</span>${r.git?.message ? `<div class="msg">${esc(r.git.message)}</div>` : ''}</td><td class="hide-s">${r.git?.author ? `<span class="who"><span class="av">${esc(initials(r.git.author))}</span>${esc(r.git.author)}</span>` : ''}</td><td class="hide-s">${r.git?.pr ? `<a class="pr" href="${esc(webHref(r.git.pr.url))}" target="_blank" rel="noopener">#${esc(r.git.pr.number)}</a>` : '—'}</td><td><span style="display:flex;align-items:center;gap:6px">${bar5(c)}<span class="mono">${esc(c.pass || 0)}/${esc(tot)}</span>${r.open_comments ? `<span class="cmt" title="${esc(T('openCommentsN'))}: ${r.open_comments}">${ICON.comment}${r.open_comments}</span>` : ''}</span></td></tr>`);
|
|
2416
|
+
}
|
|
2417
|
+
$('brRuns').innerHTML = `<table class="runs"><thead><tr><th>${T('time')}</th><th>${T('env')}</th><th>${T('branch')} · ${T('commit')}</th><th class="hide-s">${T('author')}</th><th class="hide-s">PR</th><th>${T('result')}</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
2418
|
+
}
|
|
2419
|
+
function openBrowser () {
|
|
2420
|
+
S.brProject = rememberedProject(); // the project picked in the lists applies here too
|
|
2421
|
+
S.brSc = RUN.scenario.id; $('br').hidden = false; renderBrowser(); $('brSearch').value = S.brQ; $('brSearch').focus();
|
|
2422
|
+
// on the service the catalog of scenarios comes when the browser opens (the run itself loads without it)
|
|
2423
|
+
const a = APP();
|
|
2424
|
+
if (a?.user && !RUN.catalog && !S.catLoading && !isDraft()) { S.catLoading = true; fetch(`${a.api}/orgs/${a.org}/scenarios?runs=60`, { credentials: 'same-origin' }).then((r) => r.ok ? r.json() : null).then((c) => { if (c) { RUN.catalog = c; if (!$('br').hidden) renderBrowser(); } }).catch(() => {}).finally(() => { S.catLoading = false; }); }
|
|
2425
|
+
}
|
|
2426
|
+
function closeBrowser () { $('br').hidden = true; }
|
|
2427
|
+
function wireBrowser () {
|
|
2428
|
+
$('picker').onclick = openBrowser; $('allRuns').onclick = openBrowser; $('brClose').onclick = closeBrowser;
|
|
2429
|
+
$('br').addEventListener('click', (e) => { if (e.target === $('br')) closeBrowser(); });
|
|
2430
|
+
$('brSearch').addEventListener('input', (e) => { S.brQ = e.target.value; renderBrowser(); });
|
|
2431
|
+
$('brList').addEventListener('click', (e) => { const b = e.target.closest('[data-sc]'); if (!b) return; S.brSc = b.dataset.sc; renderBrowser(); });
|
|
2432
|
+
$('brRuns').addEventListener('click', (e) => { if (e.target.closest('a')) return; const tr = e.target.closest('tr[data-run]'); if (!tr || tr.dataset.run === RUN.id) return; location.href = runHref(tr.dataset.run); });
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/* ---------- design: drafts, versions, live steps, multiplayer (#42) ---------- */
|
|
2436
|
+
const isDesign = () => RUN.source === 'design' || !!RUN.draft;
|
|
2437
|
+
const isDraft = () => !!RUN.draft;
|
|
2438
|
+
const enc = encodeURIComponent;
|
|
2439
|
+
const scenarioApi = (p) => { const a = APP(); return `${a.api}/orgs/${a.org}/scenarios/${enc(RUN.scenario.id)}${p}`; };
|
|
2440
|
+
const branchQ = () => RUN.draft && RUN.draft.branch !== 'main' ? `?branch=${enc(RUN.draft.branch)}` : '';
|
|
2441
|
+
function liveSrc (cap, bases = RUN.live_bases) {
|
|
2442
|
+
const l = cap?.live; if (!l) return null;
|
|
2443
|
+
if (!APP()) return `live/${l.revision}/${l.entry}`;
|
|
2444
|
+
const b = bases?.[l.revision];
|
|
2445
|
+
return b ? b + l.entry : null;
|
|
2446
|
+
}
|
|
2447
|
+
function viewSrc (cap, bases = RUN.live_bases) {
|
|
2448
|
+
const l = cap?.view; if (!l?.revision) return null;
|
|
2449
|
+
if (!APP()) return `live/${l.revision}/${l.entry}`;
|
|
2450
|
+
const b = bases?.[l.revision];
|
|
2451
|
+
return b ? b + l.entry : null;
|
|
2452
|
+
}
|
|
2453
|
+
const revOf = (n, loc = S.loc) => { const c = n?.captures?.[loc]; if (!c) return null; const any = Object.values(c)[0]; return any?.live?.revision || null; };
|
|
2454
|
+
const sameAsCmp = (n) => !!(S.cmp && isDesign() && n.kind !== 'decision' && revOf(n) && revOf(n) === revOf(cmpNode(n.id)));
|
|
2455
|
+
const headOf = (n, loc = S.loc) => RUN.revisions?.[`${n.id}|${loc}`] || null;
|
|
2456
|
+
const who = (a) => a ? (a.agent ? `${a.agent} (${T('viaAgent')} ${a.email})` : a.email) : '';
|
|
2457
|
+
|
|
2458
|
+
async function apiJson (method, url, body) {
|
|
2459
|
+
const res = await fetch(url, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
|
2460
|
+
const data = await res.json().catch(() => ({}));
|
|
2461
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
2462
|
+
return data;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// the draft is live: re-read it when someone (a person or an agent) changes it
|
|
2466
|
+
let reloadTimer = null;
|
|
2467
|
+
function reloadDraftSoon () { clearTimeout(reloadTimer); reloadTimer = setTimeout(reloadDraft, 350); }
|
|
2468
|
+
async function reloadDraft () {
|
|
2469
|
+
if (!isDraft()) { if (APP()) return reloadRun(); return; }
|
|
2470
|
+
const a = APP();
|
|
2471
|
+
const m = await apiJson('GET', scenarioApi(`/draft/manifest${branchQ()}`)).catch(() => null);
|
|
2472
|
+
if (!m) return;
|
|
2473
|
+
swapBase(BASE, m.files_base); BASE = m.files_base || BASE; a.files = m.files_base;
|
|
2474
|
+
RUN = m; applyReviewerVerdicts(); renderAll(); if (S.open) openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
function renderDesignBar () {
|
|
2478
|
+
$('dbar').hidden = true;
|
|
2479
|
+
const bar = $('dvers');
|
|
2480
|
+
if (!bar || !APP() || !isDesign()) return;
|
|
2481
|
+
const a = APP();
|
|
2482
|
+
const refId = RUN.references?.design || RUN.design_reference_id || null;
|
|
2483
|
+
const vs = (RUN.versions || []).slice(0, 8);
|
|
2484
|
+
const draftHref = `/${enc(a.org)}/scenarios/${enc(RUN.scenario.id)}/draft`;
|
|
2485
|
+
bar.innerHTML = `<div class="seg-label" style="margin:10px 0 6px">${T('versions')}</div><div class="dbar">`+`<a class="vchip vchip--draft" href="${draftHref}" ${isDraft() ? 'aria-current="true"' : ''} title="${T('openDraft')}">${T('draft')}${isDraft() ? ` #${esc(RUN.draft.seq)}` : ''}</a>`
|
|
2486
|
+
+ vs.map((v) => `<a class="vchip" href="${runHref(v.id)}" ${v.id === RUN.id ? 'aria-current="true"' : ''} title="${esc(v.name || '')} · ${esc(v.by)} · ${fmtDate(v.at)} · ${esc(T(v.verdict))}">v${esc(v.version)}${v.id === refId ? ' <span class="ref" title="' + T('designRef') + '">★</span>' : ''}${v.verdict === 'accepted' ? ' ✓' : ''}</a>`).join('')
|
|
2487
|
+
+ '</div><div id="dbranches"></div>'
|
|
2488
|
+
+ (isDraft() ? `<p class="note" style="margin:8px 0 0">${T('publishHint').replace('%V', `v${(RUN.versions?.[0]?.version || 0) + 1}`)}</p>` : '')
|
|
2489
|
+
+ '<div class="share__row" style="margin-top:8px">'
|
|
2490
|
+
+ (isDraft() && canWrite() && APP().user?.role !== 'viewer' ? `<button class="btn btn--primary" id="publishBtn">${T('publish')}</button>` : '')
|
|
2491
|
+
+ (!isDraft() && canWrite() && RUN.verdict?.verdict === 'accepted' ? `<button class="btn" id="refBtn">${RUN.id === refId ? T('unsetDesignRef') : T('setDesignRef')}</button>` : '')
|
|
2492
|
+
+ '</div>';
|
|
2493
|
+
const pb = $('publishBtn');
|
|
2494
|
+
if (pb) pb.onclick = async () => {
|
|
2495
|
+
const name = prompt(T('publishName'), '');
|
|
2496
|
+
if (name === null) return;
|
|
2497
|
+
pb.disabled = true;
|
|
2498
|
+
try { const r = await apiJson('POST', scenarioApi('/versions'), { branch: RUN.draft.branch, ...(name.trim() ? { name: name.trim() } : {}) }); toast(`${T('published')} v${r.version} · ${r.changed} / ${r.changed + r.unchanged}`); await reloadDraft(); } catch (e) { toast(e.message); pb.disabled = false; }
|
|
2499
|
+
};
|
|
2500
|
+
// branches of the draft: links, and merging a branch back into main per step
|
|
2501
|
+
if (isDraft()) apiJson('GET', scenarioApi('/branches')).then((list) => {
|
|
2502
|
+
const el = $('dbranches'); if (!el || list.length < 2 && RUN.draft.branch === 'main') return;
|
|
2503
|
+
el.innerHTML = `<div class="seg-label" style="margin:10px 0 6px">${T('branches')}</div><div class="dbar">${list.filter((b) => !b.merged_at).map((b) => `<a class="vchip ${b.branch === RUN.draft.branch ? '' : 'vchip--draft'}" ${b.branch === RUN.draft.branch ? 'aria-current="true"' : ''} href="${draftHref}${b.branch === 'main' ? '' : `?branch=${enc(b.branch)}`}" title="${b.git_branch ? 'git: ' + esc(b.git_branch) : ''}">${esc(b.branch)}${b.git_branch ? ' ⎇' : ''}</a>`).join('')}</div>${RUN.draft.branch !== 'main' && canWrite() ? `<button class="btn" id="mergeBtn" style="margin-top:8px">${T('mergeBranch')}</button>` : ''}`;
|
|
2504
|
+
const mb = $('mergeBtn'); if (!mb) return;
|
|
2505
|
+
mb.onclick = async () => {
|
|
2506
|
+
mb.disabled = true;
|
|
2507
|
+
const url = scenarioApi(`/branches/${enc(RUN.draft.branch)}/merge`);
|
|
2508
|
+
try {
|
|
2509
|
+
let res = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
2510
|
+
let data = await res.json();
|
|
2511
|
+
if (res.status === 409 && data.conflicts) {
|
|
2512
|
+
const choices = {};
|
|
2513
|
+
for (const c of data.conflicts) choices[c.key] = confirm(`${T('mergeConflict')} ${c.key.split('|').slice(0, 2).join(' ')}\n${T('mergeTakeBranch')}`) ? 'branch' : 'main';
|
|
2514
|
+
res = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ choices }) });
|
|
2515
|
+
data = await res.json();
|
|
2516
|
+
}
|
|
2517
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
2518
|
+
toast(`${T('branchMerged')}: ${data.moved.length}`); location.href = draftHref;
|
|
2519
|
+
} catch (e) { toast(e.message); mb.disabled = false; }
|
|
2520
|
+
};
|
|
2521
|
+
}).catch(() => {});
|
|
2522
|
+
const rb = $('refBtn');
|
|
2523
|
+
if (rb) rb.onclick = async () => {
|
|
2524
|
+
rb.disabled = true;
|
|
2525
|
+
try { const refs = await apiJson('PUT', scenarioApi('/references'), { kind: 'design', run_id: RUN.id === refId ? null : RUN.id }); RUN.design_reference_id = refs.design; RUN.references = refs; toast(T('designRefHint')); renderDesignBar(); } catch (e) { toast(e.message); rb.disabled = false; }
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// ---- live frame in the lightbox: prototype links, inspector, pinned comments ----
|
|
2530
|
+
const LIVE = { mode: null, insp: null, anchor: null, tokens: [] };
|
|
2531
|
+
function liveStage (cap, v, id = 'lbFrame', bases, view = false) {
|
|
2532
|
+
const src = view ? viewSrc(cap, bases) : liveSrc(cap, bases);
|
|
2533
|
+
if (!src) return `<p class="lb__empty">${T('noLiveOrigin')}</p>`;
|
|
2534
|
+
// an HTML view gets no forms and runs only the frame helper (its origin's CSP enforces that too)
|
|
2535
|
+
return `<div class="lb__live"><iframe id="${id}" title="${view ? T('htmlView') : T('liveMode')}" sandbox="${view ? 'allow-scripts' : 'allow-scripts allow-forms'}" referrerpolicy="no-referrer" src="${esc(src)}" style="width:${v.w}px;height:${Math.min(v.h, 2000)}px"></iframe></div>`;
|
|
2536
|
+
}
|
|
2537
|
+
const frames = () => [...document.querySelectorAll('#lbFrame, #lbFrameB')];
|
|
2538
|
+
// a live step renders at its real viewport width and is scaled to fit the stage, like the screenshot
|
|
2539
|
+
function fitLive () {
|
|
2540
|
+
for (const f of frames()) {
|
|
2541
|
+
const box = f.parentElement, host = box.parentElement;
|
|
2542
|
+
const w = parseInt(f.style.width, 10), h = parseInt(f.style.height, 10);
|
|
2543
|
+
const cs = getComputedStyle(host);
|
|
2544
|
+
const k = Math.min(1, (host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - 2) / w);
|
|
2545
|
+
f.style.transformOrigin = '0 0'; f.style.transform = `scale(${k})`;
|
|
2546
|
+
box.style.width = `${Math.floor(w * k)}px`; box.style.height = `${Math.floor(h * k)}px`;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
addEventListener('resize', () => fitLive());
|
|
2550
|
+
const toFrame = (msg) => { for (const f of frames()) try { f.contentWindow.postMessage(msg, '*'); } catch {} };
|
|
2551
|
+
function pinsFor (n, vpName) {
|
|
2552
|
+
return (RUN.reviews || []).filter((r) => r.node_id === n.id && r.anchor && (!r.locale || r.locale === S.loc) && (!r.anchor.viewport || r.anchor.viewport === vpName)).map((r, i) => ({ n: i + 1, selector: r.anchor.selector, box: r.anchor.box, resolved: !!r.resolved_at }));
|
|
2553
|
+
}
|
|
2554
|
+
function inspectorHtml () {
|
|
2555
|
+
const el = LIVE.insp; if (!el) return '';
|
|
2556
|
+
const css = el.styles.map((s) => `${s.prop}: ${s.token ? `var(--${s.token.split(' ')[0].replace(/\./g, '-')}) /* ${s.value} */` : s.value};`).join('\n');
|
|
2557
|
+
const canEdit = isDraft() && canWrite() && APP().user?.role !== 'viewer' && LIVE.mode === null;
|
|
2558
|
+
const kindOf = (prop) => /color/.test(prop) ? 'color' : prop === 'font-family' ? 'font' : 'dim';
|
|
2559
|
+
const EDITABLE = ['color', 'background-color', 'border-color', 'font-family', 'font-size', 'border-radius', 'padding', 'margin', 'gap'];
|
|
2560
|
+
const picker = (s) => { const opts = LIVE.tokens.filter((t) => t.kind === kindOf(s.prop)); return EDITABLE.includes(s.prop) && opts.length ? `<select data-edit-prop="${esc(s.prop)}"><option value="">${T('editKeep')}</option>${opts.map((t) => `<option value="${esc(`var(--${t.name.replace(/\./g, '-')}, ${t.value})`)}">${esc(t.name)} · ${esc(t.value)}</option>`).join('')}</select>` : ''; };
|
|
2561
|
+
return `<div class="sec insp" id="inspSec"><h4>${T('inspect')} <span class="strategy">${esc(el.tag)} · ${el.size.join(' × ')}</span></h4><div class="anchor-note">${esc(el.selector || '')}</div><table>${el.styles.map((s) => `<tr><td>${esc(s.prop)}</td><td>${esc(s.value)}${s.token ? ` <span class="tok">${esc(s.token)}</span>` : ` <span class="raw">${T('noToken')}</span>`}${canEdit ? `<div>${picker(s)}</div>` : ''}</td></tr>`).join('')}</table>
|
|
2562
|
+
${canEdit ? `<h4 style="margin-top:12px">${T('editTitle')}</h4>${el.editable ? `<label class="note" for="editText">${T('editText')}</label><input id="editText" value="${esc(el.text)}" style="width:100%;font:inherit;padding:6px;border:1px solid var(--line);border-radius:6px;margin:4px 0 8px">` : ''}<button class="btn btn--primary" id="editSave">${T('editSave')}</button> ` : ''}<button class="btn" id="copyCss" data-css="${esc(css)}" style="margin-top:8px">${T('copyCss')}</button></div>`;
|
|
2563
|
+
}
|
|
2564
|
+
addEventListener('message', (e) => {
|
|
2565
|
+
if (!frames().some((f) => e.source === f.contentWindow)) return;
|
|
2566
|
+
const m = e.data || {};
|
|
2567
|
+
const n = S.open && byId(S.open);
|
|
2568
|
+
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 }); }
|
|
2569
|
+
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); }
|
|
2570
|
+
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(); }
|
|
2571
|
+
if (m.type === 'qawalk:picked' && m.el) {
|
|
2572
|
+
const el = sanitizeEl(m.el);
|
|
2573
|
+
if (m.mode === 'pick') {
|
|
2574
|
+
LIVE.anchor = { selector: el.selector, box: el.box, text: el.text, viewport: S.lbVp };
|
|
2575
|
+
LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null });
|
|
2576
|
+
rerenderComments(); focusComposer();
|
|
2577
|
+
renderLiveTools();
|
|
2578
|
+
} else {
|
|
2579
|
+
// a click in the inspector pins the selection: hover stops, and the element can be edited
|
|
2580
|
+
LIVE.insp = el; LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null }); toFrame({ type: 'qawalk:highlight', selector: el.selector, box: el.box });
|
|
2581
|
+
const sec = $('inspSec'); if (sec) sec.outerHTML = inspectorHtml(); else $('lbSideTop')?.insertAdjacentHTML('afterend', inspectorHtml());
|
|
2582
|
+
renderLiveTools(); wireCopyCss();
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
if (m.type === 'qawalk:tokens' && Array.isArray(m.list)) LIVE.tokens = m.list.slice(0, 400).filter((t) => t && typeof t.name === 'string').map((t) => ({ name: String(t.name).slice(0, 120), value: String(t.value).slice(0, 120), kind: ['color', 'font', 'dim'].includes(t.kind) ? t.kind : 'other' }));
|
|
2586
|
+
});
|
|
2587
|
+
function sanitizeEl (el) {
|
|
2588
|
+
const str = (x, n) => typeof x === 'string' ? x.slice(0, n) : '';
|
|
2589
|
+
return { editable: !!el.editable, selector: str(el.selector, 500), text: str(el.text, 120), tag: str(el.tag, 20), box: Array.isArray(el.box) ? el.box.slice(0, 4).map(Number) : null, size: Array.isArray(el.size) ? el.size.slice(0, 2).map(Number) : [0, 0], styles: (Array.isArray(el.styles) ? el.styles : []).slice(0, 20).map((s) => ({ prop: str(s.prop, 40), value: str(s.value, 200), token: s.token ? str(s.token, 120) : null })) };
|
|
2590
|
+
}
|
|
2591
|
+
function wireCopyCss () {
|
|
2592
|
+
const b = $('copyCss'); if (b) b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.css); toast(T('cssCopied')); } catch { toast(b.dataset.css); } };
|
|
2593
|
+
const save = $('editSave'); const el = LIVE.insp; const n = S.open && byId(S.open);
|
|
2594
|
+
if (!save || !el || !n) return;
|
|
2595
|
+
save.onclick = async () => {
|
|
2596
|
+
const ops = [];
|
|
2597
|
+
const t = $('editText'); if (t && t.value !== el.text) ops.push({ kind: 'text', selector: el.selector, value: t.value });
|
|
2598
|
+
document.querySelectorAll('[data-edit-prop]').forEach((s) => { if (s.value) ops.push({ kind: 'style', selector: el.selector, prop: s.dataset.editProp, value: s.value }); });
|
|
2599
|
+
if (!ops.length) return;
|
|
2600
|
+
save.disabled = true;
|
|
2601
|
+
const body = { branch: RUN.draft.branch, node: n.id, locale: S.loc, base: revOf(n), ops };
|
|
2602
|
+
try {
|
|
2603
|
+
let r;
|
|
2604
|
+
try { r = await apiJson('POST', scenarioApi('/draft/edits'), body); } catch (e) { if (!/changed since/.test(e.message)) throw e; r = await apiJson('POST', scenarioApi('/draft/edits'), { ...body, rebase: true }); }
|
|
2605
|
+
toast(r.rebased ? T('editRebased') : T('editSaved')); if (r.unapplied?.length) toast(`${T('editUnapplied')}: ${r.unapplied.length}`);
|
|
2606
|
+
LIVE.insp = null; await reloadDraft();
|
|
2607
|
+
} catch (e) { toast(e.message); save.disabled = false; }
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
function renderLiveTools () {
|
|
2611
|
+
const t = $('lbLiveTools'); if (!t) return;
|
|
2612
|
+
t.innerHTML = `<button class="btn" id="lbInspect" aria-pressed="${LIVE.mode === 'inspect'}">${T('inspect')}</button>${canWrite() ? `<button class="btn" id="lbPin" aria-pressed="${LIVE.mode === 'pick'}">${T('pinComment')}</button>` : ''}`;
|
|
2613
|
+
$('lbInspect').onclick = () => { LIVE.mode = LIVE.mode === 'inspect' ? null : 'inspect'; toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); if (!LIVE.mode) { LIVE.insp = null; $('inspSec')?.remove(); } renderLiveTools(); presenceSend({ type: 'editing', step: S.open, on: !!LIVE.mode }); };
|
|
2614
|
+
const pin = $('lbPin'); if (pin) pin.onclick = () => { LIVE.mode = LIVE.mode === 'pick' ? null : 'pick'; toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); if (LIVE.mode) toast(T('pickHint')); renderLiveTools(); };
|
|
2615
|
+
}
|
|
2616
|
+
function stepStatusHtml (n) {
|
|
2617
|
+
if (!isDraft()) return '';
|
|
2618
|
+
const h = headOf(n); if (!h) return '';
|
|
2619
|
+
const opts = ['in_progress', 'ready_for_review', 'ready_for_dev'];
|
|
2620
|
+
const by = h.agent ? `${h.actor} ${T('viaAgent')} ${h.agent}` : h.actor;
|
|
2621
|
+
return `<div class="sec"><h4>${T('stepStatus')}</h4>${canWrite() && APP().user?.role !== 'viewer' ? `<select id="stepStatus">${opts.map((o) => `<option value="${o}" ${h.status === o ? 'selected' : ''}>${T('st_' + o)}</option>`).join('')}</select>` : `<span class="stchip stchip--${esc(h.status)}">${T('st_' + h.status)}</span>`}<p class="note" style="margin-top:6px">${T('revisionBy')} <span class="mono">${esc(h.revision.slice(0, 8))}</span> · ${esc(by)} · ${fmtDate(h.updated_at)}</p></div>`;
|
|
2622
|
+
}
|
|
2623
|
+
function wireStepStatus (n) {
|
|
2624
|
+
const sel = $('stepStatus'); if (!sel) return;
|
|
2625
|
+
sel.onchange = async () => { try { await apiJson('POST', scenarioApi('/draft/status'), { branch: RUN.draft.branch, node: n.id, locale: S.loc, status: sel.value }); toast(T('saved')); } catch (e) { toast(e.message); } };
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
/* ---------- boot ---------- */
|
|
2630
|
+
let tt; function toast (msg) { const el = $('toast'); el.textContent = msg; el.hidden = false; clearTimeout(tt); tt = setTimeout(() => { el.hidden = true; }, 2600); }
|
|
2631
|
+
async function load () {
|
|
2632
|
+
if (EMBEDDED) return EMBEDDED;
|
|
2633
|
+
const app = window.QAWALK_APP;
|
|
2634
|
+
if (!app) throw new Error('No run data.');
|
|
2635
|
+
if (app.draft) {
|
|
2636
|
+
// a design draft: composed by the service from the draft head, captures on the files origin (#42)
|
|
2637
|
+
const res = await fetch(`${app.api}/orgs/${app.org}/scenarios/${encodeURIComponent(app.draft.scenario)}/draft/manifest${app.draft.branch && app.draft.branch !== 'main' ? `?branch=${encodeURIComponent(app.draft.branch)}` : ''}`, { credentials: 'same-origin' });
|
|
2638
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
2639
|
+
const m = await res.json();
|
|
2640
|
+
BASE = m.files_base || ''; app.files = m.files_base;
|
|
2641
|
+
return m;
|
|
2642
|
+
}
|
|
2643
|
+
BASE = app.files || `${app.api}/orgs/${app.org}/runs/${app.run}/files/`;
|
|
2644
|
+
scheduleRefresh();
|
|
2645
|
+
// the page preloads this exact URL (see viewerPage in the service): keep the two in step
|
|
2646
|
+
const res = await fetch(`${app.api}/orgs/${app.org}/runs/${app.run}?view=canvas`, { credentials: 'same-origin' });
|
|
2647
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2648
|
+
return res.json();
|
|
2649
|
+
}
|
|
2650
|
+
/* Large runs paint from a light manifest; what only the step detail needs (page metadata, element boxes,
|
|
2651
|
+
capture meta and console lines) follows in a second request right after the first paint. */
|
|
2652
|
+
let DETAILS = null;
|
|
2653
|
+
function loadDetails () {
|
|
2654
|
+
const a = APP();
|
|
2655
|
+
if (!RUN?.details_deferred || !a?.run) return null;
|
|
2656
|
+
return DETAILS ||= fetch(`${a.api}/orgs/${a.org}/runs/${a.run}/details`, { credentials: 'same-origin' })
|
|
2657
|
+
.then((r) => (r.ok ? r.json() : null)).then((d) => { if (d) mergeDetails(d); else DETAILS = null; return d; }).catch(() => { DETAILS = null; return null; }); // a failed load is tried again next time
|
|
2658
|
+
}
|
|
2659
|
+
function mergeDetails (d) {
|
|
2660
|
+
for (const n of RUN.nodes) {
|
|
2661
|
+
const x = d.nodes?.[n.id]; if (!x) continue;
|
|
2662
|
+
if (x.metadata) n.metadata = x.metadata;
|
|
2663
|
+
for (const [l, vps] of Object.entries(x.captures || {})) for (const [vp, part] of Object.entries(vps || {})) { const c = n.captures?.[l]?.[vp]; if (c) Object.assign(c, part); }
|
|
2664
|
+
}
|
|
2665
|
+
delete RUN.details_deferred;
|
|
2666
|
+
// an open step shows its metadata and boxes now, unless someone is typing in it
|
|
2667
|
+
const typing = document.activeElement?.closest?.('#lb') && /^(TEXTAREA|INPUT|SELECT)$/.test(document.activeElement.tagName);
|
|
2668
|
+
if (S.open && !typing) openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2669
|
+
}
|
|
2670
|
+
function applyReviewerVerdicts () {
|
|
2671
|
+
const RANKv = { fail: 4, needs_review: 3, not_evaluated: 2, pass: 1, not_reached: 0 };
|
|
2672
|
+
const worstV = (l) => l.reduce((a, b) => (RANKv[b] > RANKv[a] ? b : a), 'not_reached');
|
|
2673
|
+
const statusOf = (vs, captured) => !captured ? 'skip' : !vs.length ? 'pass' : vs.includes('fail') ? 'fail' : vs.some((v) => v !== 'pass') ? 'partial' : 'pass';
|
|
2674
|
+
for (const cv of RUN.criterion_verdicts || []) {
|
|
2675
|
+
const n = byId(cv.node_id); const c = n?.criteria?.find((x) => x.id === cv.criterion_id); if (!c) continue;
|
|
2676
|
+
const locales = cv.locale ? [cv.locale] : RUN.scope.locales;
|
|
2677
|
+
for (const l of locales) { c.by_locale[l] ||= {}; for (const vp of Object.keys(RUN.scope.viewports)) { c.by_locale[l][vp] = { ...(c.by_locale[l][vp] || {}), verdict: cv.verdict, note: `${T('reviewerVerdict')}: ${cv.email}${cv.note ? ' — ' + cv.note : ''}`, human: true }; } }
|
|
2678
|
+
}
|
|
2679
|
+
if (!(RUN.criterion_verdicts || []).length) return;
|
|
2680
|
+
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 };
|
|
2681
|
+
for (const n of RUN.nodes) {
|
|
2682
|
+
if (n.kind === 'decision') continue;
|
|
2683
|
+
for (const c of n.criteria) { const vs = RUN.scope.locales.map((l) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }).filter((v) => v !== 'not_reached'); c.verdict = vs.length ? worstV(vs) : 'not_reached'; }
|
|
2684
|
+
n.by_locale = Object.fromEntries(RUN.scope.locales.map((l) => [l, statusOf(n.criteria.map((c) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }), !!n.captures?.[l])]));
|
|
2685
|
+
n.status = statusOf(n.criteria.filter((c) => c.verdict !== 'not_reached').map((c) => c.verdict), Object.keys(n.captures || {}).length > 0);
|
|
2686
|
+
summary[n.status]++;
|
|
2687
|
+
}
|
|
2688
|
+
RUN.summary = { ...RUN.summary, ...summary };
|
|
2689
|
+
}
|
|
2690
|
+
async function postJson (path, body) {
|
|
2691
|
+
const a = APP();
|
|
2692
|
+
const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
2693
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
2694
|
+
return res.json();
|
|
2695
|
+
}
|
|
2696
|
+
async function reloadRun () { const a = APP(); const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}`, { credentials: 'same-origin' }); if (res.ok) { RUN = await res.json(); applyReviewerVerdicts(); renderAll(); if (S.open) openLb(S.open, S.lbVp); } }
|
|
2697
|
+
load().then((data) => {
|
|
2698
|
+
RUN = data;
|
|
2699
|
+
requestAnimationFrame(() => setTimeout(loadDetails, 0)); // after the first paint
|
|
2700
|
+
applyReviewerVerdicts();
|
|
2701
|
+
for (const n of RUN.nodes) for (const c of n.criteria || []) {
|
|
2702
|
+
for (const vps of Object.values(c.by_locale || {})) for (const r of Object.values(vps)) {
|
|
2703
|
+
r.score ??= r.noul ?? r.jev?.noul ?? null;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
S.loc = RUN.scope.locales[0];
|
|
2707
|
+
document.documentElement.lang = UI;
|
|
2708
|
+
wireCanvas(); wireBrowser();
|
|
2709
|
+
$('lb').addEventListener('click', (e) => { if (e.target === $('lb')) closeLb(); });
|
|
2710
|
+
// the panels a person opens or closes are remembered (per screen class: wide or not)
|
|
2711
|
+
const panelKey = () => `qawalk-panels-${innerWidth >= 1600 ? 'wide' : 'narrow'}`;
|
|
2712
|
+
const savePanels = () => { try { localStorage.setItem(panelKey(), JSON.stringify({ left: S.left, right: S.right })); } catch {} };
|
|
2713
|
+
$('togLeft').onclick = () => { S.left = !S.left; setPanels(); savePanels(); };
|
|
2714
|
+
$('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
|
|
2715
|
+
$('rpBtn').onclick = () => rpOpen(null);
|
|
2716
|
+
wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
|
|
2717
|
+
// small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
|
|
2718
|
+
// wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
|
|
2719
|
+
const wideMq = matchMedia('(min-width:1500px)');
|
|
2720
|
+
const placeBar = () => {
|
|
2721
|
+
const wide = wideMq.matches; document.body.classList.toggle('wide', wide);
|
|
2722
|
+
if (wide) { $('navSlot').appendChild($('appNav')); $('viewSlot').appendChild($('viewCtls')); $('barMenu').hidden = true; }
|
|
2723
|
+
else { $('barMenu').appendChild($('appNav')); $('barMenu').appendChild($('viewCtls')); }
|
|
2724
|
+
renderPresence();
|
|
2725
|
+
};
|
|
2726
|
+
wideMq.addEventListener('change', placeBar); placeBar();
|
|
2727
|
+
const menus = [['menuBtn', 'barMenu'], ['helpBtn', 'helpPop']];
|
|
2728
|
+
const closeMenus = (except) => { for (const [b, m] of menus) if (m !== except) { $(m).hidden = true; $(b).setAttribute('aria-expanded', 'false'); } if (except !== 'pmenu' && PR.menu) { PR.menu = false; renderPresence(); } };
|
|
2729
|
+
for (const [b, m] of menus) $(b).onclick = (e) => { e.stopPropagation(); const open = $(m).hidden; closeMenus(m); $(m).hidden = !open; $(b).setAttribute('aria-expanded', String(open)); };
|
|
2730
|
+
document.addEventListener('click', (e) => { if (!e.target.closest('.popwrap, .presence')) closeMenus(null); });
|
|
2731
|
+
S.closeMenus = closeMenus;
|
|
2732
|
+
document.addEventListener('keydown', (e) => {
|
|
2733
|
+
if (e.key === 'Escape' && PR.follow) { e.preventDefault(); stopFollow(); return; }
|
|
2734
|
+
if (e.key === 'Escape' && document.querySelector('.menu:not([hidden])')) { e.preventDefault(); S.closeMenus?.(null); return; }
|
|
2735
|
+
// typing never triggers shortcuts (WCAG 2.1.4): in a text field only Escape acts, and it leaves the field
|
|
2736
|
+
// (the draft stays); the next Escape closes the overlay. Search boxes keep their own arrow and Enter keys.
|
|
2737
|
+
const field = e.target.closest?.('textarea,select,[contenteditable="true"],input:not([type=checkbox]):not([type=radio]):not([type=button])');
|
|
2738
|
+
if (field && !['siQ', 'riQ', 'brSearch'].includes(field.id)) { if (e.key === 'Escape') { e.preventDefault(); field.blur(); } return; }
|
|
2739
|
+
if (!$('si').hidden) { if (stackKey(e)) return; if (e.target.closest('input,select,textarea')) return; return; }
|
|
2740
|
+
if (S.rp && !$('rp').hidden) { if (!e.target.closest('input,select,textarea')) { e.preventDefault(); rpKey(e); } return; }
|
|
2741
|
+
if (!$('br').hidden) { if (e.key === 'Escape') closeBrowser(); return; }
|
|
2742
|
+
if (!$('lb').hidden) {
|
|
2743
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2744
|
+
if (e.key === 'Escape') closeLb();
|
|
2745
|
+
if (e.key === 'ArrowRight') $('lbNext')?.click();
|
|
2746
|
+
if (e.key === 'ArrowLeft') $('lbPrev')?.click();
|
|
2747
|
+
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); focusComposer(); }
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (S.mode === 'index' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (S.st?.inline ? stackKey(e) : riKey(e)) return; }
|
|
2751
|
+
if (e.target.closest('input,select,textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2752
|
+
if (e.key === 'i' || e.key === 'I') { setMode(S.mode === 'index' ? 'map' : 'index'); return; }
|
|
2753
|
+
if (e.key === '?') { e.preventDefault(); $('helpBtn').click(); return; }
|
|
2754
|
+
if (e.key === 'f' || e.key === 'F') { const any = S.left || S.right; S.left = S.right = !any; setPanels(); }
|
|
2755
|
+
if (e.key === 'k' || e.key === 'K' || (e.key === '/' && S.mode !== 'index')) { e.preventDefault(); openBrowser(); }
|
|
2756
|
+
if (e.key === 'r' || e.key === 'R') { e.preventDefault(); rpOpen(null); }
|
|
2757
|
+
if (e.key === '0') fit();
|
|
2758
|
+
if (e.key === '1') startView();
|
|
2759
|
+
});
|
|
2760
|
+
let first = true; new ResizeObserver(() => { if (first) { first = false; return; } requestAnimationFrame(startView); }).observe($('vp'));
|
|
2761
|
+
// below 1600 px the canvas keeps its room: only the summary panel opens by default; phones start with none
|
|
2762
|
+
if (innerWidth <= 1100 || window.QAWALK_APP?.embed) { S.left = false; S.right = false; }
|
|
2763
|
+
else { if (innerWidth < 1600) S.left = false; try { const p = JSON.parse(localStorage.getItem(panelKey()) || 'null'); if (p) { S.left = !!p.left; S.right = !!p.right; } } catch {} }
|
|
2764
|
+
if (window.QAWALK_APP?.embed) document.body.classList.add('embed');
|
|
2765
|
+
{ const h = new URLSearchParams(location.hash.slice(1)).get('mode'); let pref = null; try { pref = localStorage.getItem('qawalk-mode'); } catch {} S.mode = (h || pref) === 'index' ? 'index' : 'map'; }
|
|
2766
|
+
setPanels(); renderAll(); applyMode();
|
|
2767
|
+
requestAnimationFrame(() => requestAnimationFrame(() => { startView(); applyHash(); presenceStart(); }));
|
|
2768
|
+
addEventListener('hashchange', applyHash);
|
|
2769
|
+
}).catch((error) => { window.Sentry?.captureException(error); document.body.innerHTML = `<p style="padding:24px;font-family:sans-serif">Kaloko: ${esc(error.message)}</p>`; });
|
|
2770
|
+
</script>
|
|
2771
|
+
</body>
|
|
2772
|
+
</html>
|