kaloko 0.5.2 → 0.7.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 +97 -6
- package/package.json +1 -1
- package/schemas/changelog.v1.json +83 -0
- package/schemas/run.v1.json +19 -0
- package/schemas/scenario.v1.json +6 -1
- package/skills/kaloko/SKILL.md +35 -13
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +130 -4
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +8 -1
- package/src/cli.mjs +241 -11
- package/src/lib/changelog.mjs +144 -8
- package/src/lib/ci.mjs +71 -0
- package/src/lib/design-cli.mjs +22 -2
- package/src/lib/design-notes.mjs +191 -0
- package/src/lib/docs-core.mjs +450 -0
- package/src/lib/docs-export.mjs +271 -0
- package/src/lib/docs.mjs +221 -0
- package/src/lib/figma.mjs +584 -0
- package/src/lib/git.mjs +23 -3
- package/src/lib/integrations-cli.mjs +211 -0
- package/src/lib/keyboard.mjs +42 -12
- package/src/lib/onboarding.mjs +3 -1
- package/src/lib/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +580 -90
package/viewer/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|
6
6
|
<meta name="robots" content="noindex">
|
|
7
7
|
<title>Kaloko</title>
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
:root{
|
|
16
16
|
--ground:#ecece8; --dots:#c9c9c3; --panel:#ffffff; --panel-2:#f5f5f2;
|
|
17
17
|
--ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514;
|
|
18
|
-
--accent:#ffe452; --accent-ink:#151514;
|
|
18
|
+
--accent:#ffe452; --accent-ink:#151514; --focus:#151514;
|
|
19
19
|
--pass:#1d8249; --fail:#c8102e; --partial:#b86e00; --skip:#8d8d86;
|
|
20
20
|
--pass-bg:#e3f3e9; --fail-bg:#fbe5e8; --partial-bg:#fbf0dc; --skip-bg:#ececea;
|
|
21
21
|
--pass-ink:#176f3b; --fail-ink:#b40f2a; --partial-ink:#8f5500; --skip-ink:#5f5f59;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
31
31
|
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
32
32
|
--pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
|
|
33
|
-
--edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35);
|
|
33
|
+
--edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35); --focus:#ffe452;
|
|
34
34
|
}}
|
|
35
35
|
:root[data-theme="dark"]{
|
|
36
36
|
color-scheme:dark;
|
|
@@ -39,22 +39,46 @@
|
|
|
39
39
|
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
40
40
|
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
41
41
|
--pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
|
|
42
|
-
--edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35);
|
|
42
|
+
--edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35); --focus:#ffe452;
|
|
43
43
|
}
|
|
44
44
|
html,body{height:100%}
|
|
45
|
+
/* the canvas is an app with its own zoom: the page itself never pans sideways or zooms (the canvas, screenshots
|
|
46
|
+
in the detail and focus mode zoom themselves); phones get 16 px fields so iOS does not zoom in on focus */
|
|
47
|
+
html,body{overflow-x:clip;touch-action:pan-x pan-y}
|
|
48
|
+
@media (max-width:820px),(pointer:coarse){input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{font-size:16px!important}}
|
|
45
49
|
[hidden]{display:none!important}
|
|
46
50
|
body{margin:0}
|
|
47
51
|
body{background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.43;overflow:hidden}
|
|
48
52
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
|
|
49
|
-
|
|
53
|
+
/* keyboard focus: one ring for every control, at least 3:1 against light and dark surfaces (WCAG 2.4.7 / 2.4.13).
|
|
54
|
+
--focus is ink on light surfaces and yellow on dark ones; dark overlays (focus mode, the grid) set it themselves.
|
|
55
|
+
Controls inside clipping strips (segments, lists) draw the ring inside so it is not cut off. */
|
|
56
|
+
:focus-visible{outline:2px solid var(--focus)!important;outline-offset:2px}
|
|
57
|
+
.seg :focus-visible,.langsw :focus-visible,.steps :focus-visible,.ri :focus-visible,.si :focus-visible,.lbtabs :focus-visible,.c-tabs :focus-visible,.br__list :focus-visible,.runs-wrap :focus-visible,.trow:focus-visible{outline-offset:-2px}
|
|
58
|
+
[aria-pressed="true"]:focus-visible,[aria-selected="true"]:focus-visible,.btn--primary:focus-visible{outline-offset:2px}
|
|
59
|
+
.seg [aria-pressed="true"]:focus-visible,.langsw [aria-pressed="true"]:focus-visible,.seg [aria-selected="true"]:focus-visible{outline-color:var(--panel)!important;outline-offset:-4px}
|
|
60
|
+
/* the selected row of the run index and of a stack folder: the ring inside the row (the list clips what is outside) */
|
|
61
|
+
.ri [role=option]:focus-visible,.si [role=option]:focus-visible{outline-offset:-3px}
|
|
62
|
+
.world :focus-visible{outline-width:calc(2px * var(--inv,1))!important;outline-offset:calc(2px * var(--inv,1))}.lb__scroll:focus-visible,.rp__stage:focus-visible{outline-offset:-3px}
|
|
63
|
+
.skip{position:fixed;left:8px;top:8px;z-index:100;padding:8px 14px;border-radius:6px;background:var(--ink);color:var(--panel);font-weight:600;text-decoration:none;transform:translateY(-200%)}
|
|
64
|
+
.skip:focus,.skip:focus-visible{transform:none}
|
|
50
65
|
.app{display:grid;grid-template-rows:auto 1fr;grid-template-columns:minmax(0,1fr);height:100%}
|
|
66
|
+
/* until the run is loaded and the panels are set for the screen, the app is laid out but not shown: the top bar
|
|
67
|
+
filling in and the side panels closing would otherwise shift the canvas (layout shift) */
|
|
68
|
+
.app[data-boot]{visibility:hidden}
|
|
51
69
|
.bar{min-width:0}
|
|
52
70
|
|
|
53
71
|
/* top bar */
|
|
54
72
|
.popwrap{position:relative;display:flex;align-items:center;gap:2px}
|
|
55
73
|
.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}
|
|
56
|
-
#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}
|
|
57
|
-
#menuBtn{position:relative}body.wide #menuWrap{display:none}
|
|
74
|
+
#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;justify-self:start}#barMenu .viewctls{display:grid;gap:12px}
|
|
75
|
+
#menuBtn{position:relative}body.wide #menuWrap{display:none}
|
|
76
|
+
.menu-acts{display:none;gap:2px;padding-bottom:8px;border-bottom:1px solid var(--line)}.menu-acts:empty{display:none!important}.menu-acts > *{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;text-decoration:none;color:inherit;font-size:14px;text-align:left}.menu-acts > *:hover{background:var(--panel-2)}
|
|
77
|
+
@media (max-width:820px){.menu-acts{display:grid}}
|
|
78
|
+
/* a touch screen has no keyboard to speak of: the shortcuts move from the bar into ☰ */
|
|
79
|
+
@media (hover:none) and (pointer:coarse) and (max-width:1499px){.menu-acts{display:grid}#helpBtn{display:none}}
|
|
80
|
+
@media (min-width:821px){.menu-acts > :not([data-act="help"]){display:none}}
|
|
81
|
+
@media (min-width:821px){body:not(.wide) .bar.is-tight :is(.brand,#wtBtn,#ptBtn,#cmpChip,#dimSegs){display:none}body:not(.wide) .bar.is-tight .menu-acts{display:grid}body:not(.wide) .bar.is-tight .menu-acts > *{display:flex}body:not(.wide) .bar.is-tight #dimCtl:not(:empty){display:flex}}.menu-btn{width:26px;height:26px}
|
|
58
82
|
.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}
|
|
59
83
|
#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}
|
|
60
84
|
.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%}
|
|
@@ -69,7 +93,8 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
|
|
|
69
93
|
.task b{font-size:14px;font-weight:600}
|
|
70
94
|
.task span{font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
71
95
|
/* the bar keeps its height while the run loads: empty title lines and switches hold their place (no layout shift) */
|
|
72
|
-
.task b:empty::before,.task span:empty::before{content:"\00a0"}
|
|
96
|
+
.task b:empty::before,.task span:empty::before{content:"\00a0"}
|
|
97
|
+
.livetag{display:none;font-style:normal}.bar .seg:empty{min-height:29px}
|
|
73
98
|
.spacer{flex:1}
|
|
74
99
|
.seg{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:2px;background:var(--panel-2)}
|
|
75
100
|
.seg button{padding:3px 11px;border-radius:999px;font-size:12.5px;font-weight:500;color:var(--muted)}
|
|
@@ -145,7 +170,7 @@ a.pr:hover{text-decoration-color:var(--ink)}
|
|
|
145
170
|
.side__foot code{font-family:var(--mono);font-size:11px;color:var(--ink);overflow-wrap:anywhere}
|
|
146
171
|
|
|
147
172
|
/* right: summary */
|
|
148
|
-
.rside{background:var(--panel);border-left:1px solid var(--line);overflow:auto;display:flex;flex-direction:column;min-width:0}
|
|
173
|
+
.rside{background:var(--panel);border-left:1px solid var(--line);overflow:auto;scroll-padding-top:96px;display:flex;flex-direction:column;min-width:0}
|
|
149
174
|
.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}
|
|
150
175
|
.rside__head h3{margin:0;font-size:16px;letter-spacing:-.01em}
|
|
151
176
|
.rside .sec{padding:14px 16px}
|
|
@@ -226,13 +251,54 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
226
251
|
.runs-wrap{overflow:auto;flex:1}
|
|
227
252
|
.more{display:block;margin:12px auto}
|
|
228
253
|
.empty{padding:24px 14px;color:var(--muted);font-size:13px}
|
|
254
|
+
/* phones: the explorer is a full-screen sheet in two levels: the scenarios, then the runs of one as cards */
|
|
255
|
+
.rcards{display:none}
|
|
256
|
+
@media (max-width:820px){
|
|
257
|
+
.br{padding:0;background:var(--panel)}
|
|
258
|
+
.br__box{width:100%;height:100%;border-radius:0;box-shadow:none;display:flex;flex-direction:column}
|
|
259
|
+
.br__top{padding:max(8px,env(safe-area-inset-top)) max(10px,env(safe-area-inset-right)) 8px max(10px,env(safe-area-inset-left))}
|
|
260
|
+
.br__top input[type="search"]{min-height:44px;box-sizing:border-box}
|
|
261
|
+
.br .icon-btn{width:44px;height:44px;flex:none}
|
|
262
|
+
.br__col{flex:1 1 0;min-height:0}
|
|
263
|
+
.br__col + .br__col{border:0}
|
|
264
|
+
.br[data-level="sc"] #brRunCol,.br[data-level="runs"] .br__top,.br[data-level="runs"] #brScCol{display:none}
|
|
265
|
+
.br__filters{padding:8px max(12px,env(safe-area-inset-right)) 8px max(12px,env(safe-area-inset-left))}
|
|
266
|
+
.br__filters .seg button{min-height:36px;padding:4px 12px}
|
|
267
|
+
.br__filters select{min-height:40px;max-width:100%}
|
|
268
|
+
.br__list,.runs-wrap{overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
|
|
269
|
+
.sc{min-height:60px;padding:10px max(14px,env(safe-area-inset-right)) 10px max(14px,env(safe-area-inset-left));align-content:center}
|
|
270
|
+
.sc__title{flex-direction:column;align-items:stretch;gap:1px}
|
|
271
|
+
.sc__title span:last-child{font-size:14px}
|
|
272
|
+
.br__runhead{display:flex;align-items:center;gap:8px;width:100%;min-width:0}
|
|
273
|
+
#brRunFilters{padding-top:max(8px,env(safe-area-inset-top))}
|
|
274
|
+
.br__rh{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
275
|
+
.br__rh b{font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
276
|
+
.br__rh small{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
277
|
+
.rcards{display:block;padding:0 0 calc(16px + env(safe-area-inset-bottom))}
|
|
278
|
+
.rday{position:sticky;top:0;z-index:1;margin:0;padding:6px max(14px,env(safe-area-inset-left));background:var(--panel-2);border-bottom:1px solid var(--line);font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
|
|
279
|
+
.rcard{display:flex;align-items:center;gap:8px;padding:0 max(12px,env(safe-area-inset-right)) 0 0;border-bottom:1px solid var(--line);content-visibility:auto;contain-intrinsic-size:auto 96px}
|
|
280
|
+
.rcard[aria-current="true"]{background:var(--panel-2);box-shadow:inset 4px 0 0 var(--accent)}
|
|
281
|
+
.rcard__main{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px;text-align:left;padding:12px 4px 12px max(14px,env(safe-area-inset-left));min-height:64px}
|
|
282
|
+
.rcard__l1{display:flex;align-items:center;flex-wrap:wrap;gap:6px}
|
|
283
|
+
.rcard__l1 .mono{font-family:var(--mono);font-size:14px;font-weight:600}
|
|
284
|
+
.rcard__git{font-family:var(--mono);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
285
|
+
.rcard__msg{color:var(--muted);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
286
|
+
.rcard__res{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px}
|
|
287
|
+
.rcard__res .bar5{flex:1;max-width:160px}
|
|
288
|
+
.rmark{font-size:11px;font-weight:600;padding:1px 6px;border-radius:4px;background:var(--skip-bg);color:var(--muted);white-space:nowrap}
|
|
289
|
+
.rmark--ok{background:var(--pass-bg);color:var(--pass-ink)}.rmark--back{background:var(--fail-bg);color:var(--fail-ink)}
|
|
290
|
+
.rmark--base{background:var(--ink);color:var(--panel)}.rmark--cur{background:var(--accent);color:var(--accent-ink)}
|
|
291
|
+
.rcard__cmp{flex:none;min-height:40px}
|
|
292
|
+
.rcard__cmp[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
293
|
+
#brRunCol{border-top:0}
|
|
294
|
+
}
|
|
229
295
|
|
|
230
296
|
.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:999px;font-size:13px;z-index:30;box-shadow:var(--shadow)}
|
|
231
297
|
|
|
232
298
|
@media (max-width:1100px){
|
|
233
299
|
.main{grid-template-columns:minmax(0,1fr)}
|
|
234
300
|
.viewport{grid-column:1}
|
|
235
|
-
.side,.rside{position:fixed;top:
|
|
301
|
+
.side,.rside{position:fixed;top:var(--barh,0px);bottom:0;z-index:12;width:min(340px,88vw);box-shadow:0 0 40px rgba(0,0,0,.18)}
|
|
236
302
|
.side{left:0}.rside{right:0}
|
|
237
303
|
.task span{max-width:50vw}
|
|
238
304
|
}
|
|
@@ -247,7 +313,28 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
247
313
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
|
|
248
314
|
body.embed .helpwrap{display:none}
|
|
249
315
|
.bar-break{display:none}
|
|
250
|
-
|
|
316
|
+
/* phones: two rows that always fit the screen. Row one: panels, brand, locale, who is here, inbox, menu; row two:
|
|
317
|
+
the title (cut short), replay, review and Map / Index. Prototype, walkthrough, shortcuts, compare and the
|
|
318
|
+
dimension switchers live in the ☰ menu; fitBar() drops the brand and extra faces when a row still does not fit */
|
|
319
|
+
@media (max-width:820px){
|
|
320
|
+
.bar{flex-wrap:wrap;gap:6px;padding:6px max(8px,env(safe-area-inset-right)) 6px max(8px,env(safe-area-inset-left))}
|
|
321
|
+
.bar > *{order:1;flex-shrink:0;min-width:0}
|
|
322
|
+
.bar .spacer{flex:1 1 0;order:2}#presence{order:3;margin-left:0}#inboxBtn{order:4;padding:4px 6px}#menuWrap,.helpwrap{order:5}#togRight{order:6}
|
|
323
|
+
.bar-break{display:block;order:7;flex-basis:100%;height:0}
|
|
324
|
+
.bar > .picker,#rpBtn,#rvBtn,#modeSeg{order:8}
|
|
325
|
+
.bar > .picker{flex:1 1 0;max-width:none;padding:4px 6px}
|
|
326
|
+
.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}
|
|
327
|
+
.livetag:not([hidden]){display:block;font-family:var(--mono);font-size:10.5px;color:var(--pass-ink,var(--muted));white-space:nowrap}.picker .kchip{display:none}
|
|
328
|
+
.bar .btn{padding:5px 9px}.bar .seg button{padding:3px 9px}
|
|
329
|
+
#wtBtn,#ptBtn,#helpBtn,#cmpChip,#dbar{display:none!important}
|
|
330
|
+
#locSeg{flex:0 1 auto}.bar .dpick .nm,.bar .dpick__bad{display:none}
|
|
331
|
+
.presence .muted{font-size:12px}
|
|
332
|
+
.bar.is-tight .brand{display:none}
|
|
333
|
+
/* its menus are sheets along the bottom of the screen, scrolling when taller than it */
|
|
334
|
+
.bar .menu{position:fixed;left:max(8px,env(safe-area-inset-left));right:max(8px,env(safe-area-inset-right));top:auto;bottom:calc(8px + env(safe-area-inset-bottom));min-width:0;max-height:calc(100dvh - 24px - env(safe-area-inset-top) - env(safe-area-inset-bottom));overflow:auto;overscroll-behavior:contain;border-radius:14px;box-shadow:0 -8px 40px rgba(0,0,0,.28);z-index:140;translate:none!important}
|
|
335
|
+
}
|
|
336
|
+
/* menus of the bar on a computer: placePop() keeps them 8 px inside the window and scrolls a tall one */
|
|
337
|
+
.bar .menu{max-width:calc(100vw - 16px);box-sizing:border-box}
|
|
251
338
|
.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}
|
|
252
339
|
/* phones: a run with many locales scrolls its locale switch sideways instead of widening the page */
|
|
253
340
|
@media (max-width:820px){.bar .seg{max-width:100%;min-width:0;overflow-x:auto;scrollbar-width:none}.bar .seg::-webkit-scrollbar{display:none}.bar .seg button{flex:none}}
|
|
@@ -496,14 +583,14 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
496
583
|
.dpick__btn:hover{border-color:var(--line-strong)}.dpick__btn b{font-weight:600}.dpick__btn .nm{overflow:hidden;text-overflow:ellipsis;color:var(--muted)}
|
|
497
584
|
.dpick__bad{font-size:11px;font-weight:600;color:var(--fail-ink,var(--fail));background:var(--fail-bg,transparent);border-radius:999px;padding:1px 6px}
|
|
498
585
|
.dpick__btn svg{width:12px;height:12px;flex:none;opacity:.6}
|
|
499
|
-
.dpop{position:fixed;z-index:120;width:min(340px,calc(100vw - 16px));max-height:min(460px,70vh);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.22);overflow:hidden}
|
|
586
|
+
.dpop{position:fixed;z-index:120;box-sizing:border-box;width:min(340px,calc(100vw - 16px));max-height:min(460px,70vh);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.22);overflow:hidden}
|
|
500
587
|
.dpop[hidden]{display:none}.dpop__q{margin:8px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--ink);font:inherit;font-size:13px}
|
|
501
588
|
.dpop ul{list-style:none;margin:0;padding:0 6px 8px;overflow:auto}.dpop__grp{padding:8px 8px 2px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
502
589
|
.dpop li[role=option]{display:grid;grid-template-columns:10px auto 1fr auto;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;font-size:13px}
|
|
503
590
|
.dpop li[role=option] .nm{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dpop li[role=option].is-active{background:var(--panel-2);outline:2px solid var(--line-strong)}
|
|
504
591
|
.dpop li[aria-selected=true]{font-weight:600}.dpop__cnt{display:flex;gap:6px;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}.dpop__cnt .f{color:var(--fail)}.dpop__cnt .p{color:var(--partial)}.dpop__cnt .ok{color:var(--pass)}
|
|
505
592
|
.dpop__none{padding:10px 12px;color:var(--muted);font-size:13px}
|
|
506
|
-
.dpop--sheet{left:0!important;right:0;top:auto!important;bottom:0;width:100%;max-height:70vh;border-radius:16px 16px 0 0}
|
|
593
|
+
.dpop--sheet{left:0!important;right:0;top:auto!important;bottom:0;width:100%;max-height:70vh;padding-bottom:env(safe-area-inset-bottom);border-radius:16px 16px 0 0}
|
|
507
594
|
.dvf{position:relative;margin:0 auto;container-type:inline-size;box-sizing:content-box;background:#151514;border:10px solid #151514;border-radius:46px;box-shadow:0 0 0 2px #3a3a37,var(--shadow);overflow:hidden}
|
|
508
595
|
.dvf--home-button{border-width:46px 10px 54px;border-radius:40px}.dvf--punch-hole{border-radius:30px}.dvf--fold{border-radius:22px}
|
|
509
596
|
.dvf--tablet{border-width:16px;border-radius:30px}.dvf--laptop{border:12px solid #2b2b29;border-bottom-width:26px;border-radius:14px 14px 6px 6px}
|
|
@@ -643,7 +730,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
643
730
|
.lb__live iframe{display:block;border:0;background:#fff}
|
|
644
731
|
.lb__cmp .lb__live{margin:0}
|
|
645
732
|
/* prototype presentation: full screen, one step at a time, click-through in the live page */
|
|
646
|
-
.pt{position:fixed;inset:0;z-index:30;display:grid;grid-template-rows:auto 1fr auto;color-scheme:dark;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#9d9d96;--line:#35352f;background:#111110;color:var(--ink)}
|
|
733
|
+
.pt{position:fixed;inset:0;z-index:30;display:grid;grid-template-rows:auto 1fr auto;color-scheme:dark;--focus:#ffe452;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#9d9d96;--line:#35352f;background:#111110;color:var(--ink)}
|
|
647
734
|
.pt__bar{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 14px;background:#161615;border-bottom:1px solid var(--line)}
|
|
648
735
|
.pt__bar--foot{border-bottom:0;border-top:1px solid var(--line);justify-content:center}
|
|
649
736
|
.pt__bar .spacer{flex:1}.pt__title{font-weight:600;font-size:15px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40vw}
|
|
@@ -696,7 +783,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
696
783
|
.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)}
|
|
697
784
|
.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}
|
|
698
785
|
.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}
|
|
699
|
-
.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)}
|
|
786
|
+
.c-hint{margin-left:auto;font-size:11.5px}@media (hover:none) and (pointer:coarse){.c-hint{display:none}}.c-pinrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}.c-pinned{display:inline-flex;gap:5px;align-items:center}.c-pinbtn[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
700
787
|
.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}
|
|
701
788
|
.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}
|
|
702
789
|
@keyframes pinflash{50%{transform:translate(-2px,-100%) scale(1.5)}}
|
|
@@ -707,6 +794,8 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
707
794
|
@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)}}
|
|
708
795
|
/* the secondary tools of the detail: inline on wide screens, behind "⋯" on phones */
|
|
709
796
|
.lb__more{display:contents}.lb__moreBtn,.lb__grab{display:none}
|
|
797
|
+
/* the explorer from the step detail: phones only (the top bar with the scenario picker is under the detail there) */
|
|
798
|
+
#lbRuns{display:none}@media (max-width:820px){#lbRuns{display:inline-flex}}
|
|
710
799
|
/* phones: the preview gets the height; the step header and tabs are a bottom sheet that starts collapsed */
|
|
711
800
|
@media (max-width:700px){
|
|
712
801
|
.lb{padding:0}
|
|
@@ -716,13 +805,17 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
716
805
|
.lb__tools #lbFocus{padding:5px 10px;flex:none}
|
|
717
806
|
.lb__moreBtn{display:inline-grid;margin-left:auto;flex:none;width:32px;height:32px}
|
|
718
807
|
.lb__more{display:none}
|
|
719
|
-
.lb__more.is-open{display:flex;position:absolute;top:calc(100% + 4px);right:8px;z-index:6;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);max-width:calc(100vw - 16px)}
|
|
808
|
+
.lb__more.is-open{display:flex;position:absolute;top:calc(100% + 4px);right:8px;z-index:6;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);max-width:calc(100vw - 16px);box-sizing:border-box;max-height:calc(100dvh - 120px);overflow:auto;overscroll-behavior:contain}
|
|
720
809
|
.lb__more .spacer,.lb__more .lb__hint{display:none}
|
|
721
810
|
.lb__scroll{padding:10px}
|
|
722
811
|
.lb__side{position:absolute;left:0;right:0;bottom:0;height:var(--peek);z-index:3;background:var(--panel);border-radius:14px 14px 0 0;box-shadow:0 -6px 24px rgba(0,0,0,.18);overflow:hidden;padding-bottom:env(safe-area-inset-bottom);overscroll-behavior:contain}
|
|
723
812
|
.lb[data-sheet="half"] .lb__side{height:58%;overflow:auto}
|
|
724
|
-
.lb[data-sheet="full"] .lb__side{height:calc(100% -
|
|
725
|
-
.
|
|
813
|
+
.lb[data-sheet="full"] .lb__side{height:calc(100% - var(--sheet-gap));overflow:auto}
|
|
814
|
+
.lb[data-sheet="half"] .lb__stage{bottom:58%}.lb[data-sheet="full"] .lb__stage{bottom:calc(100% - var(--sheet-gap))}
|
|
815
|
+
.lb__box{--sheet-gap:140px}
|
|
816
|
+
.lb__grab{display:block;position:sticky;top:0;z-index:3;height:16px;background:var(--panel);cursor:grab;touch-action:none;border-radius:14px 14px 0 0}
|
|
817
|
+
/* the sheet clips what lies outside it: the ring of the handle goes inside, all the way round */
|
|
818
|
+
.lb__grab:focus-visible{outline-offset:-3px}
|
|
726
819
|
.lb__grab::before{content:"";display:block;width:36px;height:4px;border-radius:2px;background:var(--line);margin:6px auto 0}
|
|
727
820
|
.lb__head{padding:0 14px 8px;gap:4px;top:16px;touch-action:none;cursor:pointer}
|
|
728
821
|
.lb__head .kind{display:none}.lb__head .lb__linkTxt{display:none}.lb__head #lbLink{padding:5px 8px}
|
|
@@ -740,7 +833,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
740
833
|
@media (max-width:359px){.lb__tools #lbFocus span{display:none}}
|
|
741
834
|
@media (max-width:700px) and (prefers-reduced-motion:no-preference){.lb__side{transition:height .2s ease}.lb__side.is-drag{transition:none}}
|
|
742
835
|
/* focus: only this step's capture or live page, full viewport, with a small floating bar that hides when idle */
|
|
743
|
-
.lb.is-focus{padding:0;background:#111110}
|
|
836
|
+
.lb.is-focus{padding:0;background:#111110;--focus:#ffe452}
|
|
744
837
|
.lb.is-focus .lb__box{display:block;position:relative;height:100%;border-radius:0;background:#111110;box-shadow:none}
|
|
745
838
|
.lb.is-focus .lb__side,.lb.is-focus .lb__tools{display:none}
|
|
746
839
|
.lb.is-focus .lb__stage{position:absolute;inset:0;border:0;background:#111110}
|
|
@@ -750,7 +843,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
750
843
|
.lb.is-focus .lb__img--m,.lb.is-focus .lb__img--dev{border:0;border-radius:0;box-shadow:none}
|
|
751
844
|
.lb.is-focus .lb__live{border:0;border-radius:0;box-shadow:none}
|
|
752
845
|
.lb.is-focus .lb__win{border-radius:0}
|
|
753
|
-
.lb__fbar{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);z-index:8;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:6px 8px;width:max-content;max-width:calc(100% - 16px);padding:6px 8px;border-radius:22px;color-scheme:dark;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#a3a39c;--line:#3d3d37;background:rgba(22,22,21,.9);color:var(--ink);box-shadow:0 8px 30px rgba(0,0,0,.4);backdrop-filter:blur(8px)}
|
|
846
|
+
.lb__fbar{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);z-index:8;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:6px 8px;width:max-content;max-width:calc(100% - 16px);padding:6px 8px;border-radius:22px;color-scheme:dark;--focus:#ffe452;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#a3a39c;--line:#3d3d37;background:rgba(22,22,21,.9);color:var(--ink);box-shadow:0 8px 30px rgba(0,0,0,.4);backdrop-filter:blur(8px)}
|
|
754
847
|
.lb__fbar .icon-btn{width:34px;height:34px}
|
|
755
848
|
.lb__fpos{font-size:12.5px;color:var(--muted);min-width:44px;text-align:center;font-variant-numeric:tabular-nums}
|
|
756
849
|
.lb__fshow{display:none;position:absolute;top:max(10px,env(safe-area-inset-top));right:10px;z-index:8;width:38px;height:38px;place-items:center;border-radius:50%;background:rgba(22,22,21,.6);color:#f0f0ec;opacity:.75}
|
|
@@ -790,7 +883,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
790
883
|
@media (max-width:900px){.rvm__body{grid-template-columns:1fr;grid-template-rows:minmax(220px,55%) 1fr}.rvm__side{border-left:0;border-top:1px solid var(--line)}.rvm__stage{padding:12px}.rvm__bar{width:72px}.rvm__keys{display:none}}
|
|
791
884
|
@media (max-width:560px){.rvm__foot{gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}.rvm__foot .btn{flex:1 1 auto;justify-content:center;padding:9px 8px}.rvm__foot .spacer{display:none}.rvm__top{padding:8px 10px}.rvm__top .brand small{display:none}}
|
|
792
885
|
@media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
|
|
793
|
-
@media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}
|
|
886
|
+
@media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}
|
|
794
887
|
/* batch F: swipe and onion compare, drawn regions, comment drawings and images, the grid, the acceptance record */
|
|
795
888
|
.cmpov{display:grid;margin:0 auto;background:#fff;box-shadow:var(--shadow);position:relative;user-select:none;-webkit-user-select:none;overflow:hidden}
|
|
796
889
|
.cmpov>img{grid-area:1/1;display:block;width:100%;height:auto;align-self:start;pointer-events:none}
|
|
@@ -843,10 +936,14 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
843
936
|
@media (max-width:900px){.gvm__keys{display:none}.gvm__title{max-width:60vw}}
|
|
844
937
|
@media (max-width:560px){.gvm__top{padding:8px 10px}.gvm__body{padding:0 10px 16px}.gvm__top .brand small{display:none}}
|
|
845
938
|
.signoff{margin-top:10px;padding-top:10px;border-top:1px dashed var(--line)}.signoff .note{margin:0 0 6px}
|
|
939
|
+
.iss-badge{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;margin-left:4px;background:color-mix(in srgb,var(--fail) 18%,transparent)}
|
|
940
|
+
.iss-list{list-style:none;padding:0;margin:0 0 8px;display:grid;gap:6px}.iss-list li{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}
|
|
941
|
+
.iss-form{display:grid;gap:8px}.iss-form label{display:grid;gap:3px;font-size:13px}.iss-form input,.iss-form select,.iss-form textarea{font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);max-width:100%}.iss-form .note{margin:0}
|
|
846
942
|
</style>
|
|
847
943
|
</head>
|
|
848
944
|
<body>
|
|
849
|
-
<
|
|
945
|
+
<a class="skip" id="skipLink" href="#main" data-t="skipMain">Skip to content</a>
|
|
946
|
+
<div class="app" id="app" data-boot>
|
|
850
947
|
<header class="bar">
|
|
851
948
|
<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>
|
|
852
949
|
<a class="brand" id="brand" href="#" style="text-decoration:none;color:inherit">Kaloko</a>
|
|
@@ -854,7 +951,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
854
951
|
<span class="bar-break" aria-hidden="true"></span>
|
|
855
952
|
<button class="picker" id="picker" aria-label="Browse scenarios and runs">
|
|
856
953
|
<span class="kchip" id="pkKind">task</span>
|
|
857
|
-
<span class="task"><b id="taskTitle"></b><span id="runMeta"></span></span>
|
|
954
|
+
<span class="task"><b id="taskTitle"></b><i id="liveTag" class="livetag" hidden></i><span id="runMeta"></span></span>
|
|
858
955
|
<kbd>K</kbd>
|
|
859
956
|
</button>
|
|
860
957
|
<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>
|
|
@@ -871,6 +968,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
871
968
|
<div class="presence" id="presence" hidden aria-label="Who else is here"></div>
|
|
872
969
|
<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>
|
|
873
970
|
<div class="menu pop" id="barMenu" hidden>
|
|
971
|
+
<div class="menu-acts" id="menuActs"></div>
|
|
874
972
|
<nav class="appnav" id="appNav" aria-label="Project"></nav>
|
|
875
973
|
<div class="viewctls" id="viewCtls">
|
|
876
974
|
<div class="ctl"><div class="seg" id="vpSeg" aria-label="View"></div></div>
|
|
@@ -883,7 +981,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
883
981
|
<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>
|
|
884
982
|
</header>
|
|
885
983
|
<main class="main" id="main">
|
|
886
|
-
<aside class="side" id="side">
|
|
984
|
+
<aside class="side" id="side" aria-label="Run and steps" data-t-label="sideLabel">
|
|
887
985
|
<h2><span data-t="shownRun"></span> <button class="btn" id="allRuns" data-t="allRuns"></button></h2>
|
|
888
986
|
<div class="runcard" id="runcard"></div>
|
|
889
987
|
<h2 data-t="steps"></h2>
|
|
@@ -908,22 +1006,22 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
908
1006
|
</div>
|
|
909
1007
|
</section>
|
|
910
1008
|
<section class="ri" id="ri" hidden aria-label="Index"></section>
|
|
911
|
-
<aside class="rside" id="rside"></aside>
|
|
1009
|
+
<aside class="rside" id="rside" aria-label="Summary" data-t-label="rsideLabel"></aside>
|
|
912
1010
|
</main>
|
|
913
1011
|
</div>
|
|
914
|
-
<div class="si" id="si" hidden role="dialog" aria-modal="true"
|
|
915
|
-
<div class="lb" id="lb" hidden role="dialog" aria-modal="true"
|
|
1012
|
+
<div class="si" id="si" hidden role="dialog" aria-modal="true"></div>
|
|
1013
|
+
<div class="lb" id="lb" hidden role="dialog" aria-modal="true"></div>
|
|
916
1014
|
<div class="br" id="br" hidden role="dialog" aria-modal="true">
|
|
917
1015
|
<div class="br__box">
|
|
918
1016
|
<div class="br__top">
|
|
919
1017
|
<input type="search" id="brSearch" autocomplete="off">
|
|
920
1018
|
<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>
|
|
921
1019
|
</div>
|
|
922
|
-
<div class="br__col">
|
|
1020
|
+
<div class="br__col" id="brScCol">
|
|
923
1021
|
<div class="br__filters"><div class="seg" id="brKind"></div><select id="brProject" aria-label="Project"></select><span class="note" id="brCount"></span></div>
|
|
924
1022
|
<div class="br__list" id="brList"></div>
|
|
925
1023
|
</div>
|
|
926
|
-
<div class="br__col">
|
|
1024
|
+
<div class="br__col" id="brRunCol">
|
|
927
1025
|
<div class="br__filters" id="brRunFilters"></div>
|
|
928
1026
|
<div class="runs-wrap" id="brRuns"></div>
|
|
929
1027
|
</div>
|
|
@@ -942,7 +1040,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
942
1040
|
const EMBEDDED = /*__QAWALK_RUN__*/null;
|
|
943
1041
|
if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
|
|
944
1042
|
const I18N = {
|
|
945
|
-
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">D</span> Dev mode (v detailu) · <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ý',
|
|
1043
|
+
cs: { whatChanged:'Co se změnilo', sinceVersion:'od', notesBy:'Poznámky upravil(a)', notesEdit:'Napsat shrnutí vlastními slovy', notesPrompt:'Co se v této verzi změnilo a proč (uvidí to recenzenti i sledující)', makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">D</span> Dev mode (v detailu) · <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ý',
|
|
946
1044
|
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',
|
|
947
1045
|
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ý',
|
|
948
1046
|
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í',
|
|
@@ -955,7 +1053,7 @@ const I18N = {
|
|
|
955
1053
|
a_url_matches:(p) => `Adresa stránky odpovídá ${p}`, a_url_equals:(v) => `Adresa stránky je ${v}`, a_style:(s, p, v) => `${s} má ${p} ${v}`, a_js_equals:(e) => `Hodnota ve stránce: ${e}`,
|
|
956
1054
|
a_no_overflow:'Nic nepřetéká šířku obrazovky', a_no_console_errors:'V konzoli prohlížeče nejsou chyby', a_title_matches:(p) => `Titulek stránky odpovídá ${p}`, a_meta_matches:(s, p) => `Metadata ${s} odpovídají ${p}`, a_link_matches:(s) => `Odkazy ${s} vedou, kam mají`, a_http_status:(v) => `Server odpovídá ${v || '2xx'}`, a_link_home:'Stránka odkazuje zpět na úvod', a_links_ok:'Interní odkazy fungují (žádné 404)', a_hreflang_pairs:'Jazykové verze (hreflang) na sebe vzájemně odkazují',
|
|
957
1055
|
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í',
|
|
958
|
-
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',
|
|
1056
|
+
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', 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',
|
|
959
1057
|
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',
|
|
960
1058
|
summary:'Souhrnný report', stepsDone:'kroků splněno', criteriaWord:'kritérií', failed:'Co neprošlo', allPassed:'Všechna prověřená kritéria prošla.',
|
|
961
1059
|
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.',
|
|
@@ -963,12 +1061,12 @@ const I18N = {
|
|
|
963
1061
|
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',
|
|
964
1062
|
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í.',
|
|
965
1063
|
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',
|
|
966
|
-
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.',
|
|
1064
|
+
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.', agentWrote:'Napsal agent', showPin:'Ukázat na snímku', pinOnShot:'Připnout na snímek', pinClickShot:'Klikněte do snímku', pinnedOnShot:'Připnuto na snímek', replyPh:'Odpověď… (@jméno upozorní)', flowPh:'Komentář k celému flow… (@jméno upozorní)', runPh:'Komentář k celému běhu… (@jméno upozorní)', commentLabel:'Komentář', ctrlEnter:'Ctrl+Enter odešle', confirmDelete:'Smazat komentář?', deletedToast:'Smazáno', openCommentsN:'Nevyřízené komentáře', showAllN:'Zobrazit všech %N%', screenWord:'obrazovka aplikace', uiTree:'UI strom', platformWord:'Platforma', treeHint:'Klikněte na prvek: ukáže ho na snímku.', treeSearch:'Hledat v UI stromu…', treeOcr:'Text rozpoznaný ze snímku (OCR): bez rolí a ovládacích prvků.', treeLoadFail:'UI strom se nepodařilo načíst.', treeEmpty:'Strom je prázdný.', mapView:'Mapa', indexView:'Index', searchSteps:'Hledat v krocích, kritériích a stránkách…', criteriaCol:'Kritéria', notesCol:'Poznámky', noStepsMatch:'Žádný krok neodpovídá filtru.', riKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · I mapa', consoleWord:'chyb v konzoli', loadingPages:'hledám i ve stránkách stacků…', sortTitle:'Název', runWord:'Běh', openIndex:'Otevřít index', pagesNeedAttention:'stránek chce pozornost', allPagesPass:'všechny stránky prošly', stackLoadFail:'Index stránek se nepodařilo načíst', semanticSample:'sémantický vzorek', downloadCsv:'Stáhnout CSV', allWord:'Vše', criterionFilter:'Nesplněné kritérium', anyCriterion:'Jakékoli kritérium', searchPages:'Hledat v titulku, URL a polích…', sortBy:'Řadit podle', sortOrder:'Pořadí', sortStatus:'Stav (nejhorší první)', sortDir:'Směr řazení', pageWord:'Stránka', statusWord:'Stav', failingCriteria:'Neprošlo', stackKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · Esc zavřít', listView:'Seznam', gridView:'Mřížka', noPagesMatch:'Filtru neodpovídá žádná stránka.', backToIndex:'Zpět na index',
|
|
967
1065
|
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',
|
|
968
|
-
downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', viaToken:'přes token',
|
|
1066
|
+
downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', viaToken:'přes token', approvedByTokens:'přes token nebo agenta', 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',
|
|
969
1067
|
compare:'Porovnat', cmpWith:'Porovnáno s', cmpStop:'Ukončit porovnání', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
|
|
970
1068
|
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' },
|
|
971
|
-
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">D</span> Dev mode (in the detail) · <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',
|
|
1069
|
+
en: { whatChanged:'What changed', sinceVersion:'since', notesBy:'Notes edited by', notesEdit:'Write the summary in your own words', notesPrompt:'What changed in this version and why (reviewers and followers see it)', makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">D</span> Dev mode (in the detail) · <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',
|
|
972
1070
|
pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
|
|
973
1071
|
branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
|
|
974
1072
|
older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
|
|
@@ -981,7 +1079,7 @@ const I18N = {
|
|
|
981
1079
|
a_url_matches:(p) => `The page address matches ${p}`, a_url_equals:(v) => `The page address is ${v}`, a_style:(s, p, v) => `${s} has ${p} ${v}`, a_js_equals:(e) => `Value in the page: ${e}`,
|
|
982
1080
|
a_no_overflow:'Nothing overflows the screen width', a_no_console_errors:'No errors in the browser console', a_title_matches:(p) => `The page title matches ${p}`, a_meta_matches:(s, p) => `Page metadata ${s} matches ${p}`, a_link_matches:(s) => `Links ${s} point where they should`, a_http_status:(v) => `The server answers ${v || '2xx'}`, a_link_home:'The page links back home', a_links_ok:'Internal links work (no 404)', a_hreflang_pairs:'Language versions (hreflang) link to each other',
|
|
983
1081
|
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',
|
|
984
|
-
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',
|
|
1082
|
+
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', 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',
|
|
985
1083
|
execution:'Step execution', step:'Step', of:'of', hover:'Click a criterion to highlight it on the page or in the metadata', fullPage:'full page',
|
|
986
1084
|
summary:'Summary report', stepsDone:'steps passed', criteriaWord:'criteria', failed:'What failed', allPassed:'All checked criteria passed.',
|
|
987
1085
|
locales:'Locales', changes:'Changes since the previous run', noChanges:'No changes.', noPrev:'No older run of this scenario.',
|
|
@@ -989,9 +1087,9 @@ const I18N = {
|
|
|
989
1087
|
allAbove:'All questions above threshold', review:'needs review', notEvaluated:'not evaluated', search:'Search scenario, task, path…', all:'All', tasks:'Tasks', flows:'Flows',
|
|
990
1088
|
runs:'runs', last:'last', time:'Time', result:'Result', noRuns:'No run matches the filters.', noScenario:'No scenario matches.',
|
|
991
1089
|
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',
|
|
992
|
-
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.',
|
|
1090
|
+
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.', agentWrote:'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',
|
|
993
1091
|
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',
|
|
994
|
-
downloadZip:'Download ZIP', downloadZipNote:'Every file in open formats and the canvas, which opens offline from disk', 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.', viaToken:'by token',
|
|
1092
|
+
downloadZip:'Download ZIP', downloadZipNote:'Every file in open formats and the canvas, which opens offline from disk', 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.', viaToken:'by token', approvedByTokens:'by token or agent', 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',
|
|
995
1093
|
compare:'Compare', cmpWith:'Compared with', cmpStop:'Stop comparing', 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',
|
|
996
1094
|
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' }
|
|
997
1095
|
};
|
|
@@ -1001,18 +1099,31 @@ Object.assign(I18N.en, { dim_browser:'Browser', dim_color_scheme:'Colour scheme'
|
|
|
1001
1099
|
Object.assign(I18N.cs, { devMode:'Dev mode', devTab:'Dev', devTitle:'Míry, tokeny a stavy prvků pro vývoj', devTabEl:'Prvek', devTabTokens:'Tokeny', devEmpty:'Najeďte myší na prvek v náhledu a klikněte na něj. Shift + šipky procházejí strom prvků, se stisknutým Alt uvidíte vzdálenosti k dalšímu prvku.', devStart:'Vybrat hlavní obsah', devNavLabel:'Procházet prvky', navParent:'Rodič', navChild:'Potomek', navPrev:'Předchozí', navNext:'Další', g_type:'Typografie', g_color:'Barvy', g_space:'Mezery', g_size:'Rozměry', g_shape:'Tvar', g_layout:'Rozložení', notToken:'není token', nearest:'nejbližší', typedRaw:'Hodnota je v CSS napsaná natvrdo, i když token existuje; použijte proměnnou.', typedRawShort:'natvrdo', copyTokenPath:'Kopírovat cestu tokenu', contrast:'Kontrast', aaPass:'splňuje AA', aaFail:'nesplňuje AA', aaNeed:'potřeba', largeText:'velký text', contrastApprox:'Pod textem je obrázek, kontrast je přibližný.', states:'Stavy', stDefault:'výchozí', stNone:'Styly stránky pro tento prvek žádné stavy nedefinují.', changedInState:'Zvýrazněné hodnoty se v tomto stavu mění.', distances:'Vzdálenosti', toParent:'K rodiči', toTarget:'K prvku', measure:'Měřit', measureHint:'Podržte Alt a najeďte na jiný prvek, nebo zapněte Měřit a klepněte na něj.', assets:'Obrázky a ikony', download:'Stáhnout', assetFail:'Soubor se nepodařilo stáhnout.', copyCssVars:'Kopírovat CSS s proměnnými', copyTokens:'Kopírovat tokeny (JSON)', copyPrompt:'Kopírovat pokyn pro agenta', copyLine:'Kopírovat pokyn k této hodnotě', pinFromDev:'Komentovat tento prvek', tokUsed:'Použité na tomto kroku', tokOff:'Hodnoty mimo sadu tokenů', tokHard:'V CSS natvrdo místo tokenu', tokUnused:'Nepoužité', tokNone:'Krok nemá sadu tokenů (tokens.json).', tokHlN:'Zvýrazněno prvků:', tokHint:'Klikněte na token a náhled zvýrazní prvky, které ho používají.', devShotNote:'Míry a tokeny jsou ze snímku, jak ho zachytilo CLI; stavy a stahování obrázků potřebují živou stránku.', devNoSpec:'Tento snímek nemá spec. Projděte krok znovu aktuálním CLI (kaloko walk).', devLocalLive:'Ze souboru na disku čte Dev mode snímek; živou stránku měří na kaloko.app.', devAgentHint:'Totéž dostane agent: kaloko spec %N nebo MCP get_step_spec.', devLoading:'Načítám…', keyDev:'Dev mode v detailu kroku: míry, tokeny a stavy prvků (M měří, Shift + šipky procházejí prvky)', promptHead:'Postavte %E podle schváleného návrhu v Kaloku (krok %S).', promptUse:'Pro %W použijte token %T.', promptOff:'%P %V není token; nejbližší je %T. Pevnou hodnotu použijte jen po domluvě s designérem.', promptOffNone:'%P %V není token.', promptSpec:'Spec celého kroku: kaloko spec %N' });
|
|
1002
1100
|
Object.assign(I18N.en, { devMode:'Dev mode', devTab:'Dev', devTitle:'Measurements, tokens and states of elements for development', devTabEl:'Element', devTabTokens:'Tokens', devEmpty:'Hover an element in the preview and click it. Shift + arrows move through the element tree; hold Alt to see the distances to another element.', devStart:'Select the main content', devNavLabel:'Move through elements', navParent:'Parent', navChild:'Child', navPrev:'Previous', navNext:'Next', g_type:'Typography', g_color:'Colors', g_space:'Spacing', g_size:'Size', g_shape:'Shape', g_layout:'Layout', notToken:'not a token', nearest:'nearest', typedRaw:'The CSS has this value typed raw although a token exists; use the custom property.', typedRawShort:'typed raw', copyTokenPath:'Copy the token path', contrast:'Contrast', aaPass:'passes AA', aaFail:'fails AA', aaNeed:'needs', largeText:'large text', contrastApprox:'There is an image behind the text; the contrast is approximate.', states:'States', stDefault:'default', stNone:'The page styles define no states for this element.', changedInState:'Highlighted values change in this state.', distances:'Distances', toParent:'To the parent', toTarget:'To', measure:'Measure', measureHint:'Hold Alt and hover another element, or turn on Measure and tap it.', assets:'Images and icons', download:'Download', assetFail:'The file could not be downloaded.', copyCssVars:'Copy CSS with variables', copyTokens:'Copy tokens (JSON)', copyPrompt:'Copy prompt for an agent', copyLine:'Copy the prompt line for this value', pinFromDev:'Comment on this element', tokUsed:'Used on this step', tokOff:'Values outside the token set', tokHard:'Typed raw in the CSS instead of a token', tokUnused:'Not used', tokNone:'This step has no token set (tokens.json).', tokHlN:'Elements highlighted:', tokHint:'Click a token to highlight the elements that use it.', devShotNote:'Measurements and tokens come from the screenshot as the CLI captured it; states and image downloads need the live page.', devNoSpec:'This capture has no spec. Walk the step again with the current CLI (kaloko walk).', devLocalLive:'From disk, Dev mode reads the screenshot; the live page is measured on kaloko.app.', devAgentHint:'An agent gets the same: kaloko spec %N or MCP get_step_spec.', devLoading:'Loading…', keyDev:'Dev mode in the step detail: measurements, tokens and states of elements (M measures, Shift + arrows move through elements)', promptHead:'Build %E as approved in Kaloko (step %S).', promptUse:'Use token %T for the %W.', promptOff:'%P %V is not a token; the nearest is %T. Use the raw value only if the designer agrees.', promptOffNone:'%P %V is not a token.', promptSpec:'Spec of the whole step: kaloko spec %N' });
|
|
1003
1101
|
const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
|
|
1004
|
-
|
|
1102
|
+
// #lang=cs|en in a link forces the language of the canvas for that page (a shared link, a walk in another locale):
|
|
1103
|
+
// it wins over the account, the cookie and the browser, and stays in the address while the canvas rewrites it
|
|
1104
|
+
const HASH_LANG = (() => { const l = new URLSearchParams(location.hash.slice(1)).get('lang'); return l === 'cs' || l === 'en' ? l : null; })();
|
|
1105
|
+
const UI = (() => { const l = HASH_LANG || window.QAWALK_APP?.lang || new URLSearchParams(location.search).get('lang') || storedLang || (navigator.language || 'en').slice(0, 2); return l === 'cs' || l === 'sk' ? 'cs' : 'en'; })();
|
|
1005
1106
|
function switchLang (l) {
|
|
1006
|
-
|
|
1107
|
+
// a language forced by the link (#lang=) gives way to the one picked here
|
|
1108
|
+
const h = new URLSearchParams(location.hash.slice(1)); h.delete('lang'); const hash = h.toString() ? `#${h}` : '';
|
|
1109
|
+
if (window.QAWALK_APP) { location.href = `${location.pathname}?lang=${l}${hash}`; return; }
|
|
1007
1110
|
try { localStorage.setItem('qawalk.lang', l); } catch {}
|
|
1111
|
+
if (hash !== location.hash) { history.replaceState(null, '', location.pathname + location.search + hash); }
|
|
1008
1112
|
location.reload();
|
|
1009
1113
|
}
|
|
1010
1114
|
// recordings and traces of steps (batch D, #12, #23)
|
|
1011
1115
|
Object.assign(I18N.cs, { videoMode:'Video', recExpired:(d) => `Záznam vypršel ${d}; snímky zůstávají.`, recUntil:(d) => `záznam do ${d}`, recFailed:'Záznam neúspěšného pokusu', recChapter:'Úsek videa celého flow', recStart:'Začátek kroku', recCapture:'Snímek', recLoadFail:'Video se nepodařilo načíst.', recMarks:'Značky ve videu', recHint:'Video kroku: od předchozí obrazovky po tento snímek', traceTitle:'Průběh kroku', traceLead:'Akce, požadavky a zprávy konzole od začátku kroku do snímku. Klikněte na řádek a video skočí na ten okamžik.', traceActions:'Akce', traceRequests:'Požadavky', traceFailed:'neúspěšné', traceErrors:'Chyby v konzoli', traceLoading:'Načítám průběh…', traceFail:'Průběh se nepodařilo načíst.', traceEmpty:'Během kroku se nic nezaznamenalo.', trAct:'akce', trNet:'síť', trCon:'konzole', walkthrough:'Video průchodu', walkthroughTitle:'Video celého průchodu s titulky kroků (stáhnout)', flowVideo:'Video flow', chapters:'Kapitoly' });
|
|
1012
1116
|
Object.assign(I18N.en, { videoMode:'Video', recExpired:(d) => `The recording expired on ${d}; the screenshots stay.`, recUntil:(d) => `recording until ${d}`, recFailed:'Recording of the failed attempt', recChapter:'Part of the flow recording', recStart:'Start of the step', recCapture:'Capture', recLoadFail:'The video could not be loaded.', recMarks:'Markers in the video', recHint:'The step on video: from the previous screen to this capture', traceTitle:'What happened in the step', traceLead:'Actions, requests and console messages from the start of the step to its capture. Click a row and the video jumps there.', traceActions:'Actions', traceRequests:'Requests', traceFailed:'failed', traceErrors:'Console errors', traceLoading:'Loading the trace…', traceFail:'The trace could not be loaded.', traceEmpty:'Nothing was recorded during the step.', trAct:'action', trNet:'network', trCon:'console', walkthrough:'Walkthrough video', walkthroughTitle:'Video of the whole walkthrough with step titles (download)', flowVideo:'Flow video', chapters:'Chapters' });
|
|
1013
1117
|
const T = (k) => I18N[UI][k] ?? I18N.en[k] ?? k;
|
|
1118
|
+
Object.assign(I18N.cs, { skipMain:'Přeskočit na obsah', sideLabel:'Běh a kroky', rsideLabel:'Shrnutí', rpSideLabel:'Krok', rvSideLabel:'Rozhodnutí o kroku' });
|
|
1119
|
+
Object.assign(I18N.en, { skipMain:'Skip to content', sideLabel:'Run and steps', rsideLabel:'Summary', rpSideLabel:'Step', rvSideLabel:'Decision on the step' });
|
|
1120
|
+
// the scenario and run explorer on a phone
|
|
1121
|
+
Object.assign(I18N.cs, { close:'Zavřít', allScenarios:'Všechny scénáře', brShown:'Zobrazený', cmpRun:'Porovnat s během z', cmpActive:'Porovnává se', otherRuns:'Jiné běhy scénáře', listPages:'Stránky' });
|
|
1122
|
+
Object.assign(I18N.en, { close:'Close', allScenarios:'All scenarios', brShown:'Shown', cmpRun:'Compare with the run of', cmpActive:'Comparing', otherRuns:'Other runs of this scenario', listPages:'Pages' });
|
|
1014
1123
|
// "n pages" with the right plural (cs: 1 stránka, 2–4 stránky, 5+ stránek; en: 1 page, n pages)
|
|
1015
1124
|
const pagesN = (n) => { n = Number(n) || 0; return UI === 'cs' ? (n === 1 ? 'stránka' : n >= 2 && n <= 4 ? 'stránky' : 'stránek') : (n === 1 ? 'page' : 'pages') };
|
|
1125
|
+
// "n runs" (cs: 1 běh, 2–4 běhy, 5+ běhů)
|
|
1126
|
+
const runsN = (n) => { n = Number(n) || 0; return UI === 'cs' ? (n === 1 ? 'běh' : n >= 2 && n <= 4 ? 'běhy' : 'běhů') : (n === 1 ? 'run' : 'runs') };
|
|
1016
1127
|
const needAttention = (n) => { n = Number(n) || 0; return UI === 'cs' ? `${n} ${pagesN(n)} ${n === 1 ? 'chce' : 'chtějí'} pozornost` : `${n} ${pagesN(n)} ${n === 1 ? 'needs' : 'need'} attention` };
|
|
1017
1128
|
const ICON = {
|
|
1018
1129
|
comment:'<svg class="i" viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></svg>',
|
|
@@ -1188,10 +1299,48 @@ function locKey (e) {
|
|
|
1188
1299
|
}
|
|
1189
1300
|
/** The top bar never overflows on a computer: when it would, every picker turns compact. */
|
|
1190
1301
|
function fitBar () {
|
|
1191
|
-
const bar = document.querySelector('header.bar'); if (!bar
|
|
1192
|
-
if (
|
|
1193
|
-
|
|
1194
|
-
|
|
1302
|
+
const bar = document.querySelector('header.bar'); if (!bar) return;
|
|
1303
|
+
if (phoneBar()) {
|
|
1304
|
+
// a phone: the first row must stay one row; when it wraps, the brand goes (the menu links home) and one face stays
|
|
1305
|
+
const row1 = () => { const t = $('togLeft').getBoundingClientRect().top; return [...bar.children].filter((e) => e.offsetParent && getComputedStyle(e).order < 7).every((e) => Math.abs(e.getBoundingClientRect().top - t) < 20); };
|
|
1306
|
+
if (!bar.classList.contains('is-tight') && !row1()) { bar.classList.add('is-tight'); renderPresence(); }
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
// a computer: first every picker turns compact, then the brand, prototype, walkthrough and the switchers move into ☰
|
|
1310
|
+
if (bar.scrollWidth <= bar.clientWidth + 1) return;
|
|
1311
|
+
if (!S.compactBar) { S.compactBar = true; renderChrome(); }
|
|
1312
|
+
else if (document.body.classList.contains('wide')) { S.unwide = true; S.placeBar?.(); } // navigation and view options back into ☰
|
|
1313
|
+
else if (!bar.classList.contains('is-tight')) { bar.classList.add('is-tight'); renderPresence(); }
|
|
1314
|
+
}
|
|
1315
|
+
/** Keeps an open menu of the bar inside the window: 8 px from each side, shifted sideways, scrolling when taller
|
|
1316
|
+
than the room below its button. On a phone the menus are bottom sheets (CSS) and this only resets them. */
|
|
1317
|
+
function placePop (el) {
|
|
1318
|
+
if (!el) return;
|
|
1319
|
+
el.style.translate = ''; el.style.maxHeight = ''; el.style.overflow = '';
|
|
1320
|
+
if (phoneBar()) return;
|
|
1321
|
+
const r = el.getBoundingClientRect(), M = 8;
|
|
1322
|
+
let dx = 0; if (r.right > innerWidth - M) dx = innerWidth - M - r.right; if (r.left + dx < M) dx = M - r.left;
|
|
1323
|
+
if (dx) el.style.translate = `${Math.round(dx)}px 0`;
|
|
1324
|
+
if (r.bottom > innerHeight - M) { el.style.maxHeight = `${Math.max(120, Math.floor(innerHeight - M - r.top))}px`; el.style.overflow = 'auto'; }
|
|
1325
|
+
}
|
|
1326
|
+
/** The phone layout of the top bar (two rows, secondary controls in ☰). */
|
|
1327
|
+
const PHONE_MQ = matchMedia('(max-width:820px)');
|
|
1328
|
+
const phoneBar = () => PHONE_MQ.matches;
|
|
1329
|
+
// iOS Safari ignores user-scalable=no: its page-zoom gestures stop here. The canvas and the detail preview zoom
|
|
1330
|
+
// themselves from touch events, which these do not affect
|
|
1331
|
+
for (const ev of ['gesturestart', 'gesturechange']) document.addEventListener(ev, (e) => e.preventDefault(), { passive: false });
|
|
1332
|
+
// a new width lets the bar try its roomier layout again (height changes, such as a phone keyboard, do not)
|
|
1333
|
+
let BAR_W = innerWidth;
|
|
1334
|
+
addEventListener('resize', () => {
|
|
1335
|
+
if (innerWidth === BAR_W) return; BAR_W = innerWidth;
|
|
1336
|
+
const bar = document.querySelector('header.bar'); const tight = bar?.classList.contains('is-tight'); bar?.classList.remove('is-tight');
|
|
1337
|
+
if ((S.compactBar || tight || S.unwide) && RUN) { S.compactBar = false; if (S.unwide) { S.unwide = false; S.placeBar?.(); } renderChrome(); renderPresence(); }
|
|
1338
|
+
});
|
|
1339
|
+
PHONE_MQ.addEventListener('change', () => { if (RUN) { renderChrome(); renderPresence(); } });
|
|
1340
|
+
// a step opened on its comments: when the window turns into the phone layout (a rotated tablet), the sheet opens on them again
|
|
1341
|
+
matchMedia('(max-width:700px)').addEventListener('change', (e) => {
|
|
1342
|
+
if (e.matches && RUN && S.wantComments && S.open === S.wantComments.step && !$('lb').hidden) setTimeout(() => revealStepComments(S.wantComments.thread), 50);
|
|
1343
|
+
});
|
|
1195
1344
|
/** Words for a key's dimensions ("WebKit (Safari) · Dark"), defaults left out. */
|
|
1196
1345
|
const dimWords = (key) => { const d = keyDims(key); return DIMS.filter(([name, list, def]) => dimList(list) && (d[name] !== def || name === 'browser')).map(([name]) => dimLabel(d[name])).join(' · '); };
|
|
1197
1346
|
const dVp = () => viewports().find((v) => !v.mobile);
|
|
@@ -1318,7 +1467,7 @@ function commentsBlock ({ scope, nodeId = '', pinnable = false, compact = false
|
|
|
1318
1467
|
const pins = scope === 'step' ? pinNumbers(nodeId) : new Map();
|
|
1319
1468
|
const lab = { approved: T('approved'), rejected: T('rejected') };
|
|
1320
1469
|
const item = (r, root) => C.editing === r.id ? composer({ scope, nodeId, edit: r }) : `<div class="c-item" data-cid="${esc(r.id)}">
|
|
1321
|
-
<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('
|
|
1470
|
+
<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('agentWrote'))}">${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>
|
|
1322
1471
|
${root && pins.has(r.id) ? `<button class="c-pin" data-c-act="showpin" data-id="${esc(r.id)}" title="${esc(T('showPin'))}${r.shape ? ` · ${esc(T('shape_' + r.shape.kind))}` : ''}">${pins.get(r.id)}</button>` : ''}${root && r.anchor ? `<span class="c-anchor" title="${esc(r.anchor.selector || '')}">⌖</span>` : ''}
|
|
1323
1472
|
${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>
|
|
1324
1473
|
<div class="c-note">${r.deleted ? `<i class="muted">${T('commentDeleted')}</i>` : noteHtml(r.note)}</div>${!r.deleted && !isDraft() ? filesHtml(scope, r.attachments) : ''}
|
|
@@ -1462,12 +1611,35 @@ function openThread (nodeId, threadId) {
|
|
|
1462
1611
|
if (nodeId.includes('~') && !byId(nodeId)) { setHash({ step: nodeId }); applyHash(); } else openLb(nodeId, S.lbVp);
|
|
1463
1612
|
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); }));
|
|
1464
1613
|
}
|
|
1614
|
+
/** A comment link (#step=<id>&comments=step, or &thread=<id>): the step's comments in view. On a phone the detail
|
|
1615
|
+
opens as a collapsed sheet over the preview, so the sheet opens halfway and scrolls to the comments. */
|
|
1616
|
+
function revealStepComments (threadId) {
|
|
1617
|
+
if (S.focus) return;
|
|
1618
|
+
// remembered for this step: a later switch into the phone layout (a rotated tablet) opens the sheet on them again
|
|
1619
|
+
S.wantComments = { step: S.open, thread: threadId || null };
|
|
1620
|
+
if (threadId) { C.focus = threadId; C.showResolved = C.showResolved || !runOpenThreads().some((r) => r.id === threadId); rerenderComments(); }
|
|
1621
|
+
if (S.lbTab && S.lbTab !== 'overview') showLbTab('overview');
|
|
1622
|
+
if (lbPhone() && S.lbSheet === 'peek') setSheet('half');
|
|
1623
|
+
const show = () => {
|
|
1624
|
+
const lb = $('lb'), side = $('lbSide');
|
|
1625
|
+
const el = (threadId && lb.querySelector(`.c-thread[data-thread="${CSS.escape(threadId)}"]`)) || lb.querySelector('#c-step'); if (!el) return;
|
|
1626
|
+
if (lbPhone() && side) { const tabs = side.querySelector('.lbtabs'); const top = tabs ? tabs.getBoundingClientRect().bottom : side.getBoundingClientRect().top; side.scrollTop += el.getBoundingClientRect().top - top - 8; } else el.scrollIntoView({ block: 'nearest' });
|
|
1627
|
+
};
|
|
1628
|
+
show(); requestAnimationFrame(() => requestAnimationFrame(show));
|
|
1629
|
+
// the step's captures and comments render after the first pass (images, the comments request): bring the comments
|
|
1630
|
+
// into view again as the content settles, unless the person has scrolled the sheet meanwhile
|
|
1631
|
+
const side = $('lbSide'); let moved = false
|
|
1632
|
+
const mark = () => { moved = true }
|
|
1633
|
+
side?.addEventListener('wheel', mark, { once: true, passive: true }); side?.addEventListener('touchstart', mark, { once: true, passive: true })
|
|
1634
|
+
for (const ms of [300, 800, 1600]) setTimeout(() => { if (!moved && !$('lb').hidden) show() }, ms)
|
|
1635
|
+
if (threadId) setTimeout(() => { C.focus = null; }, 1800);
|
|
1636
|
+
}
|
|
1465
1637
|
/** Key C in the lightbox (or its comment button): the composer of the step, in view and focused. */
|
|
1466
1638
|
function focusComposer () { if (S.focus) setFocus(false); if (lbPhone() && S.lbSheet === 'peek') setSheet('half'); const t =$('lb').querySelector('.c-main .c-text'); if (!t) return; t.scrollIntoView({ behavior: 'smooth', block: 'center' }); t.focus({ preventScroll: true }); }
|
|
1467
1639
|
/** #comments=all|run|flow: the right panel on the comments. */
|
|
1468
1640
|
function showCommentsFromHash (which) {
|
|
1469
1641
|
S.cTab = ['all', 'run', 'flow'].includes(which) ? which : 'all';
|
|
1470
|
-
S.right = true; setPanels();
|
|
1642
|
+
S.right = true; S.lastPanel = 'right'; setPanels();
|
|
1471
1643
|
requestAnimationFrame(() => $('commentsPanel')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
1472
1644
|
}
|
|
1473
1645
|
|
|
@@ -1713,7 +1885,7 @@ function renderNodes () {
|
|
|
1713
1885
|
${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>
|
|
1714
1886
|
<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>
|
|
1715
1887
|
${jevBlock(n)}
|
|
1716
|
-
<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>` : rvNodeBadge(n.id)}</div>
|
|
1888
|
+
<div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${issBadge(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>` : rvNodeBadge(n.id)}</div>
|
|
1717
1889
|
</div>`;
|
|
1718
1890
|
}).join('');
|
|
1719
1891
|
const tallest = Math.max(0, ...[...$('nodes').querySelectorAll('.node:not(.node--decision)')].map((el) => el.offsetHeight));
|
|
@@ -1939,7 +2111,7 @@ async function openStack (id, opts = {}) {
|
|
|
1939
2111
|
<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>
|
|
1940
2112
|
<button class="icon-btn" id="siDir" aria-label="${esc(T('sortDir'))}">${st.dir === 'asc' ? '↑' : '↓'}</button>
|
|
1941
2113
|
</div>
|
|
1942
|
-
<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>
|
|
2114
|
+
<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" role="listbox" aria-label="${esc(T('listPages'))}"></div></div></div>
|
|
1943
2115
|
<div class="si__foot"><span id="siCount"></span><span class="spacer" style="flex:1"></span><span>${T('stackKeys')}</span></div>
|
|
1944
2116
|
</div>`;
|
|
1945
2117
|
el.hidden = false;
|
|
@@ -1972,24 +2144,46 @@ function paintStack () {
|
|
|
1972
2144
|
const list = st.filtered; const head = st.view === 'grid' ? 0 : $('siHead').offsetHeight;
|
|
1973
2145
|
$('siCount').textContent = `${list.length} / ${data.items.length} ${pagesN(data.items.length)}`;
|
|
1974
2146
|
const img = (it) => { const t = stackThumb(it); return t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'; };
|
|
1975
|
-
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('
|
|
2147
|
+
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('review'))}">${esc(id)}</span>`).join('')}</span>`;
|
|
1976
2148
|
if (!list.length) { $('siSpacer').style.height = '0px'; $('siRows').innerHTML = `<div class="si__empty">${T('noPagesMatch')}</div>`; return; }
|
|
1977
2149
|
let html = '';
|
|
1978
2150
|
if (st.view === 'grid') {
|
|
1979
2151
|
const per = Math.max(1, Math.floor((box.clientWidth - 24) / CARD_W)); const rows = Math.ceil(list.length / per);
|
|
1980
2152
|
$('siSpacer').style.height = `${rows * CARD_H + 16}px`;
|
|
1981
2153
|
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);
|
|
1982
|
-
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>`; }
|
|
2154
|
+
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' : ''}" role="option" tabindex="-1" 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>`; }
|
|
1983
2155
|
} else {
|
|
1984
2156
|
$('siSpacer').style.height = `${list.length * ROW_H}px`;
|
|
1985
2157
|
const top = Math.max(0, box.scrollTop - head);
|
|
1986
2158
|
const k0 = Math.max(0, Math.floor(top / ROW_H) - 8), k1 = Math.min(list.length, Math.ceil((top + box.clientHeight) / ROW_H) + 8);
|
|
1987
2159
|
for (let k = k0; k < k1; k++) {
|
|
1988
2160
|
const it = list[k];
|
|
1989
|
-
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>`;
|
|
2161
|
+
html += `<button class="si-row st-${esc(it.status)} ${k === st.sel ? 'is-sel' : ''}" role="option" tabindex="-1" 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>`;
|
|
1990
2162
|
}
|
|
1991
2163
|
}
|
|
2164
|
+
const had = $('siRows').contains(document.activeElement);
|
|
1992
2165
|
$('siRows').innerHTML = html;
|
|
2166
|
+
roveRows($('siRows'), 'data-idx', st.sel, had);
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* The run index and a stack folder are one tab stop each: the selected row (or the first drawn one) has tabindex 0,
|
|
2170
|
+
* the arrow keys move the selection and Tab leaves the list. The rows are drawn in a window and redrawn on scroll,
|
|
2171
|
+
* so a focus inside the list follows the selection into the new rows.
|
|
2172
|
+
*/
|
|
2173
|
+
function roveRows (box, attr, sel, hadFocus) {
|
|
2174
|
+
const rows = [...box.querySelectorAll(`[${attr}]`)];
|
|
2175
|
+
const cur = rows.find((r) => Number(r.getAttribute(attr)) === sel) || rows[0];
|
|
2176
|
+
for (const r of rows) { r.tabIndex = r === cur ? 0 : -1; r.setAttribute('aria-selected', String(Number(r.getAttribute(attr)) === sel)); }
|
|
2177
|
+
if (hadFocus && cur && Number(cur.getAttribute(attr)) === sel) cur.focus({ preventScroll: true });
|
|
2178
|
+
if (box.dataset.rove) return;
|
|
2179
|
+
// a row focused by a click or a tap becomes the selection
|
|
2180
|
+
box.dataset.rove = '1';
|
|
2181
|
+
box.addEventListener('focusin', (e) => {
|
|
2182
|
+
const b = e.target.closest(`[${attr}]`); if (!b) return;
|
|
2183
|
+
const k = Number(b.getAttribute(attr)); const state = attr === 'data-k' ? S.ri : S.st; if (!state) return;
|
|
2184
|
+
state.sel = k;
|
|
2185
|
+
for (const r of box.querySelectorAll(`[${attr}]`)) { const on = r === b; r.tabIndex = on ? 0 : -1; r.classList.toggle('is-sel', on); r.setAttribute('aria-selected', String(on)); }
|
|
2186
|
+
});
|
|
1993
2187
|
}
|
|
1994
2188
|
function openStackItem (k) {
|
|
1995
2189
|
const st = S.st; const n = byId(st.id); const data = STACKS.get(st.id); const it = st.filtered[k]; if (!it) return;
|
|
@@ -2075,7 +2269,7 @@ function riRender (opts = {}) {
|
|
|
2075
2269
|
<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>
|
|
2076
2270
|
<button class="icon-btn" id="riDir" aria-label="${esc(T('sortDir'))}">${r.dir === 'asc' ? '↑' : '↓'}</button>
|
|
2077
2271
|
</div>
|
|
2078
|
-
<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>
|
|
2272
|
+
<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" role="listbox" aria-label="${esc(T('steps'))}"></div></div></div>
|
|
2079
2273
|
<div class="si__foot"><span id="riCount"></span><span class="spacer" style="flex:1"></span><span>${T('riKeys')}</span></div>
|
|
2080
2274
|
</div>`;
|
|
2081
2275
|
$('riSort').value = r.sort;
|
|
@@ -2106,14 +2300,16 @@ function riPaint () {
|
|
|
2106
2300
|
if (tops[k] + h < top - 400 || tops[k] > bottom + 400) continue;
|
|
2107
2301
|
html += riRow(out[k], k, tops[k]);
|
|
2108
2302
|
}
|
|
2303
|
+
const had = $('riRows').contains(document.activeElement);
|
|
2109
2304
|
$('riRows').innerHTML = html;
|
|
2305
|
+
roveRows($('riRows'), 'data-k', S.ri.sel, had);
|
|
2110
2306
|
}
|
|
2111
2307
|
function riRow (e, k, top) {
|
|
2112
2308
|
const sel = k === S.ri.sel ? 'is-sel' : '';
|
|
2113
2309
|
if (e.type === 'group') return `<div class="ri-group" style="top:${top}px">${esc(e.label)} <small>${e.pass}/${e.total}</small></div>`;
|
|
2114
2310
|
if (e.type === 'page') {
|
|
2115
2311
|
const it = e.it; const t = stackThumb(it);
|
|
2116
|
-
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>`;
|
|
2312
|
+
return `<button class="si-row ri-row is-page st-${esc(it.status)} ${sel}" role="option" tabindex="-1" 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>`;
|
|
2117
2313
|
}
|
|
2118
2314
|
const n = e.n; const s = nodeSt(n); const crit = n.criteria || [];
|
|
2119
2315
|
const ok = crit.filter((c) => critSt(c) === 'pass').length;
|
|
@@ -2125,7 +2321,7 @@ function riRow (e, k, top) {
|
|
|
2125
2321
|
const cons = Object.values(n.captures?.[S.loc] || {}).reduce((k, c) => k + (c?.console_errors || 0), 0);
|
|
2126
2322
|
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('');
|
|
2127
2323
|
const crits = n.kind === 'stack' ? `${esc(n.stack?.summary?.pass || 0)}/${esc(n.stack?.count || 0)}` : s === 'skip' ? '—' : `${ok}/${crit.length}`;
|
|
2128
|
-
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>`;
|
|
2324
|
+
return `<button class="si-row ri-row st-${s} ${sel}" role="option" tabindex="-1" 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>`;
|
|
2129
2325
|
}
|
|
2130
2326
|
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(); }
|
|
2131
2327
|
function riOpen (k) {
|
|
@@ -2242,9 +2438,13 @@ function renderPresence () {
|
|
|
2242
2438
|
const presentBtn = PR.ws && peers.length ? `<button class="pvis" data-present aria-pressed="${PR.presenting}">${esc(PR.presenting ? T('stopPresent') : T('present'))}</button>` : '';
|
|
2243
2439
|
const visBtn = `<button class="pvis" data-vis title="${esc(presenceOn() ? T('visOn') : T('visOff'))}">${presenceOn() ? '●' : '○'} ${esc(presenceOn() ? T('visOn') : T('visOff'))}</button>`;
|
|
2244
2440
|
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>` : ''}`;
|
|
2245
|
-
|
|
2441
|
+
// a phone shows two faces at most (one when the bar is tight), a computer six; the rest is a count
|
|
2442
|
+
const tight = !!$('presence').closest('.bar')?.classList.contains('is-tight'); const max = phoneBar() ? (tight ? 1 : 2) : tight ? 2 : 6;
|
|
2443
|
+
const faces = [...[...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>`), ...peers.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>`)];
|
|
2444
|
+
el.innerHTML = faces.slice(0, max).join('') + (faces.length > max ? `<span class="muted">+${faces.length - max}</span>` : '') + menu;
|
|
2445
|
+
requestAnimationFrame(fitBar);
|
|
2246
2446
|
el.onclick = (e) => {
|
|
2247
|
-
if (e.target.closest('[data-pmenu]')) { e.stopPropagation(); S.closeMenus?.('pmenu'); PR.menu = !PR.menu; renderPresence(); return; }
|
|
2447
|
+
if (e.target.closest('[data-pmenu]')) { e.stopPropagation(); S.closeMenus?.('pmenu'); PR.menu = !PR.menu; renderPresence(); placePop(el.querySelector('.pmenu')); return; }
|
|
2248
2448
|
const f = e.target.closest('[data-follow]'); if (f) { PR.follow === f.dataset.follow ? stopFollow() : startFollow(f.dataset.follow); return; }
|
|
2249
2449
|
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; }
|
|
2250
2450
|
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(); }
|
|
@@ -2312,8 +2512,19 @@ function focusNode (id) { const el = document.querySelector(`.node[data-id="${CS
|
|
|
2312
2512
|
|
|
2313
2513
|
function wireCanvas () {
|
|
2314
2514
|
const vp = $('vp'); let drag = null;
|
|
2315
|
-
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'); });
|
|
2316
|
-
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(); });
|
|
2515
|
+
vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.node,.hud') || pinch) return; drag = { id: e.pointerId, x: e.clientX, y: e.clientY, vx: S.view.x, vy: S.view.y }; vp.setPointerCapture(e.pointerId); vp.classList.add('dragging'); });
|
|
2516
|
+
vp.addEventListener('pointermove', (e) => { if (!drag || e.pointerId !== drag.id) return; S.view.x = drag.vx + e.clientX - drag.x; S.view.y = drag.vy + e.clientY - drag.y; applyView(); });
|
|
2517
|
+
// two fingers pinch-zoom the canvas around their midpoint and pan with it (the page itself never zooms)
|
|
2518
|
+
let pinch = null;
|
|
2519
|
+
const two = (e) => { const [a, b] = e.touches, r = vp.getBoundingClientRect(); return { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, x: (a.clientX + b.clientX) / 2 - r.left, y: (a.clientY + b.clientY) / 2 - r.top }; };
|
|
2520
|
+
vp.addEventListener('touchstart', (e) => { if (e.touches.length !== 2) return; drag = null; vp.classList.remove('dragging'); const g = two(e); pinch = { ...g, k: S.view.k }; }, { passive: true });
|
|
2521
|
+
vp.addEventListener('touchmove', (e) => {
|
|
2522
|
+
if (!pinch || e.touches.length !== 2) return; e.preventDefault();
|
|
2523
|
+
const g = two(e); S.view.x += g.x - pinch.x; S.view.y += g.y - pinch.y; pinch.x = g.x; pinch.y = g.y;
|
|
2524
|
+
zoomAt(pinch.k * g.d / pinch.d / S.view.k, g.x, g.y);
|
|
2525
|
+
}, { passive: false });
|
|
2526
|
+
vp.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinch = null; }, { passive: true });
|
|
2527
|
+
vp.addEventListener('touchcancel', () => { pinch = null; }, { passive: true });
|
|
2317
2528
|
const end = () => { drag = null; vp.classList.remove('dragging'); };
|
|
2318
2529
|
vp.addEventListener('pointerup', end); vp.addEventListener('pointercancel', end);
|
|
2319
2530
|
// presence: my cursor to the others; dragging or zooming myself ends following someone
|
|
@@ -2349,7 +2560,14 @@ const bar5 = (c) => { const tot = (c.pass || 0) + (c.partial || 0) + (c.fail ||
|
|
|
2349
2560
|
// The issue the run belongs to (from the scenario id, the branch or scenario `issue:`), linked when its URL is known.
|
|
2350
2561
|
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>` : ''}` };
|
|
2351
2562
|
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>`;
|
|
2352
|
-
function setPanels () {
|
|
2563
|
+
function setPanels () {
|
|
2564
|
+
// on a narrow screen the panels are drawers over each other: one at a time, the one opened last
|
|
2565
|
+
if (narrowPanels() && S.left && S.right) { if (S.lastPanel === 'right') S.left = false; else S.right = false; }
|
|
2566
|
+
$('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(); drawerInert(); }
|
|
2567
|
+
/** Narrow screens: the side panels are drawers over the canvas. While one is open the canvas under it is inert
|
|
2568
|
+
(keyboard focus never lands behind a drawer) and a tap on the uncovered strip closes the drawers. */
|
|
2569
|
+
const narrowPanels = () => matchMedia('(max-width:1100px)').matches;
|
|
2570
|
+
function drawerInert () { const on = narrowPanels() && (S.left || S.right); $('vp').inert = on; $('ri').inert = on; }
|
|
2353
2571
|
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`; }
|
|
2354
2572
|
function renderChrome () {
|
|
2355
2573
|
const app = window.QAWALK_APP;
|
|
@@ -2358,6 +2576,7 @@ function renderChrome () {
|
|
|
2358
2576
|
+ `<span class="langsw">${['en', 'cs'].map((l) => `<button type="button" data-lang="${l}" aria-pressed="${l === UI}">${l.toUpperCase()}</button>`).join('')}</span>`;
|
|
2359
2577
|
$('appNav').querySelectorAll('[data-lang]').forEach((b) => { b.onclick = () => { if (b.dataset.lang !== UI) switchLang(b.dataset.lang); }; });
|
|
2360
2578
|
document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
|
|
2579
|
+
document.querySelectorAll('[data-t-label]').forEach((el) => { el.setAttribute('aria-label', T(el.dataset.tLabel)); });
|
|
2361
2580
|
$('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
|
|
2362
2581
|
$('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">D</span> ${esc(T('keyDev'))}</li>${RUN.scope.locales.length > 1 ? `<li><span class="kbd">L</span> <span class="kbd">[</span> <span class="kbd">]</span> ${esc(T('keyLoc'))}</li>` : ''}<li><span class="kbd">?</span> ${esc(T('keyHelp'))}</li></ul>`;
|
|
2363
2582
|
$('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', rvHintItem()); // review mode (Q)
|
|
@@ -2366,12 +2585,18 @@ function renderChrome () {
|
|
|
2366
2585
|
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})` : ''}`); }
|
|
2367
2586
|
else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
|
|
2368
2587
|
else ib.hidden = true;
|
|
2588
|
+
// ☰ on a phone also holds what the bar has no room for: prototype, walkthrough video, shortcuts
|
|
2589
|
+
const wt = $('wtBtn');
|
|
2590
|
+
$('menuActs').innerHTML = (isDesign() ? `<button type="button" data-act="pt">${$('ptBtn').querySelector('svg').outerHTML}${esc(T('prototype'))}</button>` : '')
|
|
2591
|
+
+ (!wt.hidden ? `<a data-act="wt" href="${esc(wt.getAttribute('href'))}" download title="${esc(wt.title)}">${wt.querySelector('svg').outerHTML}${esc(T('walkthrough'))}</a>` : '')
|
|
2592
|
+
+ `<button type="button" data-act="help">${$('helpBtn').querySelector('svg').outerHTML}${esc(T('helpTitle'))}</button>`;
|
|
2593
|
+
$('menuActs').onclick = (e) => { const a = e.target.closest('[data-act]'); if (!a) return; if (a.dataset.act === 'pt') { S.closeMenus?.(null); ptOpen(null); } else if (a.dataset.act === 'help') { e.stopPropagation(); $('helpBtn').click(); } else S.closeMenus?.(null); };
|
|
2369
2594
|
$('locSeg').hidden = RUN.scope.locales.length < 2;
|
|
2370
2595
|
$('viewDot').hidden = S.vp === 'both' && S.jev === 'sum' && !S.cmp && DIMS.every(([n, list, def]) => !dimList(list) || dimNow(n) === (dimList(list).includes(def) ? def : dimList(list)[0]));
|
|
2371
2596
|
$('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>`;
|
|
2372
2597
|
$('brSearch').placeholder = T('search');
|
|
2373
2598
|
$('locSeg').title = T('locale'); $('vpSeg').title = T('view');
|
|
2374
|
-
picker($('locSeg'), { name: 'loc', title: T('locale'), values: localeValues(), current: S.loc, compact: S.compactBar, onPick: (v) => { S.loc = v; renderAll(); if (S.open) openLb(S.open, S.lbVp); } });
|
|
2599
|
+
picker($('locSeg'), { name: 'loc', title: T('locale'), values: localeValues(), current: S.loc, compact: S.compactBar || phoneBar(), onPick: (v) => { S.loc = v; renderAll(); if (S.open) openLb(S.open, S.lbVp); } });
|
|
2375
2600
|
// the switchers sit next to the locale; on a phone they move into the menu
|
|
2376
2601
|
for (const el of [$('dimSegs'), $('dimCtl')]) dimSegs(el, () => { renderAll(); if (S.open) openLb(S.open, keyWith(S.lbVp) || undefined); }, el.id === 'dimSegs' && S.compactBar);
|
|
2377
2602
|
// the reader's last locale, browser and scheme in this run come back next time
|
|
@@ -2383,6 +2608,8 @@ function renderChrome () {
|
|
|
2383
2608
|
const g = RUN.git || {};
|
|
2384
2609
|
$('pkKind').textContent = RUN.scenario.kind || 'task'; $('pkKind').className = `kchip ${RUN.scenario.kind === 'flow' ? 'kchip--flow' : ''}`;
|
|
2385
2610
|
$('taskTitle').textContent = `${RUN.issue ? `${RUN.issue.repo ? RUN.issue.repo + '#' : '#'}${RUN.issue.number} · ` : ''}${RUN.scenario.title}`;
|
|
2611
|
+
// a live draft says so in the bar on a phone too, where the meta line under the title is hidden
|
|
2612
|
+
$('liveTag').hidden = !(isDesign() && isDraft()); $('liveTag').textContent = T('liveShared');
|
|
2386
2613
|
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(' · ');
|
|
2387
2614
|
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(' · ');
|
|
2388
2615
|
document.title = `${RUN.scenario.id} · Kaloko`;
|
|
@@ -2405,7 +2632,7 @@ function renderChrome () {
|
|
|
2405
2632
|
? 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('')
|
|
2406
2633
|
: order.map(stepLi).join('');
|
|
2407
2634
|
$('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(); } };
|
|
2408
|
-
$('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>`;
|
|
2635
|
+
$('scFoot').innerHTML = `${issFootHtml()}${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>`;
|
|
2409
2636
|
}
|
|
2410
2637
|
function renderAll () { renderChrome(); renderNodes(); if (S.right) renderReport(); presenceView(); if (S.mode === 'index') { if (S.st?.inline) paintStack(); else if ($('riList')) riPaint(); else riRender(); } }
|
|
2411
2638
|
|
|
@@ -2821,7 +3048,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2821
3048
|
const lb = $('lb');
|
|
2822
3049
|
const html = `<div class="lb__box">
|
|
2823
3050
|
<div class="lb__stage">
|
|
2824
|
-
<div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore">${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${devAvailable(cap) ? `<button class="btn" id="lbDev" aria-pressed="${DEV.on}" title="${esc(T('devTitle'))} (D)">${T('devMode')}</button>` : ''}${!n.item && gridUseful() && Object.keys(n.captures || {}).length ? `<button class="btn" id="lbGrid" title="${esc(T('gridBtnTitle'))} (G)">${T('gridBtn')}</button>` : ''}${canDraw() && cap && !n.item && !['live', 'view', 'video'].includes(S.lbMode) ? `<button class="btn" id="lbIgn" aria-pressed="${!!S.ignDraw}" title="${esc(T('ignDrawTitle'))}">${T('ignDraw')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) || recOf(n, cap, v) ? `<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>` : ''}${v.device ? `<button class="btn" id="lbBezel" aria-pressed="${S.frame}" title="${esc(T('frameTitle'))}">${T('frame')}</button><button class="btn" id="lbSafe" aria-pressed="${S.safe}" title="${esc(T('safeTitle'))}">${T('safeAreas')}</button>` : ''}${altOptions(v).length && !S.cmp ? `<label class="lb__alt">${T('altCompare')} <select id="lbAlt"><option value="">—</option>${altOptions(v).map((o) => `<option value="${esc(o.key)}" ${S.lbAlt === o.over ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>` : ''}<span class="note">${v.w} × ${v.h}${v.device && !v.device.app ? ` · ${esc(v.device.title)} ${esc(v.device.dpr)}×` : ''}${dimWords(v.name) ? ` · ${esc(dimWords(v.name))}` : ''}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
|
|
3051
|
+
<div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore"><button class="btn" id="lbRuns">${T('otherRuns')}</button>${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${devAvailable(cap) ? `<button class="btn" id="lbDev" aria-pressed="${DEV.on}" title="${esc(T('devTitle'))} (D)">${T('devMode')}</button>` : ''}${!n.item && gridUseful() && Object.keys(n.captures || {}).length ? `<button class="btn" id="lbGrid" title="${esc(T('gridBtnTitle'))} (G)">${T('gridBtn')}</button>` : ''}${canDraw() && cap && !n.item && !['live', 'view', 'video'].includes(S.lbMode) ? `<button class="btn" id="lbIgn" aria-pressed="${!!S.ignDraw}" title="${esc(T('ignDrawTitle'))}">${T('ignDraw')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) || recOf(n, cap, v) ? `<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>` : ''}${v.device ? `<button class="btn" id="lbBezel" aria-pressed="${S.frame}" title="${esc(T('frameTitle'))}">${T('frame')}</button><button class="btn" id="lbSafe" aria-pressed="${S.safe}" title="${esc(T('safeTitle'))}">${T('safeAreas')}</button>` : ''}${altOptions(v).length && !S.cmp ? `<label class="lb__alt">${T('altCompare')} <select id="lbAlt"><option value="">—</option>${altOptions(v).map((o) => `<option value="${esc(o.key)}" ${S.lbAlt === o.over ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>` : ''}<span class="note">${v.w} × ${v.h}${v.device && !v.device.app ? ` · ${esc(v.device.title)} ${esc(v.device.dpr)}×` : ''}${dimWords(v.name) ? ` · ${esc(dimWords(v.name))}` : ''}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
|
|
2825
3052
|
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2826
3053
|
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2827
3054
|
if (S.lbMode === 'video' && recOf(n, cap, v) && !S.cmp) return videoHtml(recOf(n, cap, v), v);
|
|
@@ -2847,7 +3074,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2847
3074
|
<span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external} <span class="lb__linkTxt">${T('copyLink')}</span></button><button class="icon-btn" id="lbPrev" aria-label="Previous" ${i === 0 ? 'disabled' : ''}>${ICON.prev}</button><button class="icon-btn" id="lbNext" aria-label="Next" ${i === list.length - 1 ? 'disabled' : ''}>${ICON.next}</button><button class="icon-btn" id="lbClose" aria-label="Close">${ICON.close}</button></span></div>
|
|
2848
3075
|
<h3 id="lbTitle">${esc(loc(n.title))}</h3>
|
|
2849
3076
|
<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>
|
|
2850
|
-
${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}
|
|
3077
|
+
${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}${stepNoteHtml(n)}
|
|
2851
3078
|
</div>
|
|
2852
3079
|
<div class="lbtabs" role="tablist">${[...(DEV.on ? [['dev', T('devTab')]] : []), ['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>
|
|
2853
3080
|
${DEV.on ? `<div class="lbtab" data-pane="dev" ${S.lbTab === 'dev' ? '' : 'hidden'}>${devPaneHtml()}</div>` : ''}
|
|
@@ -2861,6 +3088,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2861
3088
|
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>
|
|
2862
3089
|
${!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>` : ''}
|
|
2863
3090
|
${commentsBlock({ scope: 'step', nodeId: n.id, pinnable: !!cap && !['live', 'view'].includes(S.lbMode) })}
|
|
3091
|
+
${issuesBlock(n, v)}
|
|
2864
3092
|
</div>
|
|
2865
3093
|
<div class="lbtab" data-pane="page" ${S.lbTab === 'page' ? '' : 'hidden'}>
|
|
2866
3094
|
${sigSection(cap)}
|
|
@@ -2875,7 +3103,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2875
3103
|
${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>` : ''}
|
|
2876
3104
|
${traceSection(cap)}
|
|
2877
3105
|
${consoleSection(cap)}
|
|
2878
|
-
<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('
|
|
3106
|
+
<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('filesWord')}${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>
|
|
2879
3107
|
</div>
|
|
2880
3108
|
</div></div>`;
|
|
2881
3109
|
// a live frame keeps running when only the side panel changes (new comment, status, draft event)
|
|
@@ -2899,6 +3127,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2899
3127
|
// a tool chosen in the "⋯" menu closes it, so the preview is free for the click that follows (Inspect, Comment on element)
|
|
2900
3128
|
$('lbMore').onclick = (e) => { if (e.target.closest('button')) setTimeout(closeLbMore, 0); };
|
|
2901
3129
|
if ($('lbBack')) $('lbBack').onclick = () => { closeLb(); openStack(n.stack, { focus: n.itemId }); };
|
|
3130
|
+
$('lbRuns').onclick = () => openBrowser({ level: 'runs' });
|
|
2902
3131
|
if ($('lbComments')) $('lbComments').onclick = () => focusComposer();
|
|
2903
3132
|
placeCommentPins($('lbImg'), n, cap, v);
|
|
2904
3133
|
$('lbLink').onclick = async () => { try { await navigator.clipboard.writeText(location.href); toast(T('linkCopied')); } catch { toast(location.href); } };
|
|
@@ -3009,6 +3238,7 @@ function lbSheetFit () {
|
|
|
3009
3238
|
const line = h3 ? parseFloat(getComputedStyle(h3).lineHeight) || 20 : 20;
|
|
3010
3239
|
box.style.setProperty('--peek', `calc(${Math.max(98, Math.ceil(16 + (row?.offsetHeight || 32) + 4 + line + 10))}px + env(safe-area-inset-bottom))`);
|
|
3011
3240
|
side.style.setProperty('--lbhead', `${head.offsetHeight}px`);
|
|
3241
|
+
fitLive(); // the stage ends where the sheet starts
|
|
3012
3242
|
}
|
|
3013
3243
|
const LB_RO = typeof ResizeObserver === 'function' ? new ResizeObserver(() => lbSheetFit()) : null;
|
|
3014
3244
|
/** The phone bottom sheet of the detail: peek (title and status), half, full. */
|
|
@@ -3016,6 +3246,7 @@ function setSheet (st) {
|
|
|
3016
3246
|
S.lbSheet = st; $('lb').dataset.sheet = st;
|
|
3017
3247
|
$('lbGrab')?.setAttribute('aria-expanded', String(st !== 'peek'));
|
|
3018
3248
|
if (st === 'peek' && $('lbSide')) $('lbSide').scrollTop = 0;
|
|
3249
|
+
fitLive();
|
|
3019
3250
|
}
|
|
3020
3251
|
/** Focus: only this step's capture or live page, full viewport. Previous / next and the switches keep it on;
|
|
3021
3252
|
the prototype (P) is a separate presentation and returns here when it ends. */
|
|
@@ -3049,6 +3280,22 @@ function renderFocusBar (lb, n, v, cap, list, i) {
|
|
|
3049
3280
|
$('lbFshow').onclick = (e) => { e.stopPropagation(); fxWake(); };
|
|
3050
3281
|
FX.over = false; fxWake();
|
|
3051
3282
|
}
|
|
3283
|
+
/** Zoom of the screenshot in the detail preview (touch): 1× to 5×, kept on the preview element (a new step starts at 1×). */
|
|
3284
|
+
const LZ = { k: 1, el: null, pinch: null, after: 0 };
|
|
3285
|
+
function lbZoomSync () { const sc = $('lbScroll'); if (sc !== LZ.el) { LZ.el = sc; LZ.k = 1; } return sc; }
|
|
3286
|
+
function lbZoom (k, cx, cy) {
|
|
3287
|
+
const sc = lbZoomSync(); if (!sc) return;
|
|
3288
|
+
k = Math.max(1, Math.min(5, k)); if (k < 1.03) k = 1;
|
|
3289
|
+
const r = sc.getBoundingClientRect(), px = cx - r.left, py = cy - r.top, f = k / LZ.k;
|
|
3290
|
+
const ox = sc.scrollLeft + px, oy = sc.scrollTop + py;
|
|
3291
|
+
// CSS zoom keeps the layout real (the preview scrolls over the bigger picture); a block keeps its 1× width, zoomed
|
|
3292
|
+
for (const c of sc.children) {
|
|
3293
|
+
if (k > 1) { if (!c.dataset.w0) { c.dataset.w0 = String(c.getBoundingClientRect().width); c.style.width = `${c.dataset.w0}px`; } c.style.zoom = String(k); }
|
|
3294
|
+
else { delete c.dataset.w0; c.style.width = ''; c.style.zoom = ''; }
|
|
3295
|
+
}
|
|
3296
|
+
LZ.k = k; sc.style.setProperty('--pz', String(k)); sc.classList.toggle('is-zoomed', k > 1);
|
|
3297
|
+
sc.scrollLeft = ox * f - px; sc.scrollTop = oy * f - py;
|
|
3298
|
+
}
|
|
3052
3299
|
/** Listeners that live as long as the detail element: it re-renders its content, these stay. */
|
|
3053
3300
|
function wireLbOnce (lb) {
|
|
3054
3301
|
if (lb.dataset.wired) return; lb.dataset.wired = '1';
|
|
@@ -3061,13 +3308,29 @@ function wireLbOnce (lb) {
|
|
|
3061
3308
|
if (!S.focus || !e.target.closest('#lbScroll') || e.target.closest('button,a,input,select,textarea')) return;
|
|
3062
3309
|
if (lb.classList.contains('is-idle')) fxWake(); else if (lastPointer !== 'mouse') fxHide();
|
|
3063
3310
|
});
|
|
3311
|
+
// a touch screen: two fingers zoom the screenshot inside the preview (the page itself does not zoom), a double tap
|
|
3312
|
+
// switches 1× / 2× at the tapped point; zoomed in, one finger pans the preview (it scrolls)
|
|
3313
|
+
const dist = (e) => { const [a, b] = e.touches; return { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; };
|
|
3314
|
+
let tap = null;
|
|
3315
|
+
lb.addEventListener('touchstart', (e) => {
|
|
3316
|
+
if (e.touches.length !== 2 || !e.target.closest('#lbScroll') || e.target.closest('.cmpov, .is-drawing')) return;
|
|
3317
|
+
lbZoomSync(); const g = dist(e); LZ.pinch = { d: g.d, k: LZ.k }; tap = null;
|
|
3318
|
+
}, { passive: true });
|
|
3319
|
+
lb.addEventListener('touchmove', (e) => { if (!LZ.pinch || e.touches.length !== 2) return; e.preventDefault(); const g = dist(e); lbZoom(LZ.pinch.k * g.d / LZ.pinch.d, g.x, g.y); }, { passive: false });
|
|
3320
|
+
lb.addEventListener('touchend', (e) => {
|
|
3321
|
+
if (LZ.pinch) { if (e.touches.length < 2) { LZ.pinch = null; tap = null; LZ.after = Date.now(); } return; }
|
|
3322
|
+
const t = e.changedTouches[0]; if (e.touches.length || !t || !e.target.closest('#lbScroll') || e.target.closest('button,a,input,select,textarea,.cmpov,.is-drawing') || Date.now() - (LZ.after || 0) < 400) { tap = null; return; }
|
|
3323
|
+
const now = Date.now();
|
|
3324
|
+
if (tap && now - tap.t < 320 && Math.hypot(t.clientX - tap.x, t.clientY - tap.y) < 30) { tap = null; lbZoomSync(); lbZoom(LZ.k > 1.05 ? 1 : 2, t.clientX, t.clientY); return; }
|
|
3325
|
+
tap = { t: now, x: t.clientX, y: t.clientY };
|
|
3326
|
+
}, { passive: true });
|
|
3064
3327
|
// focus on a touch screen: a swipe sideways moves to the previous or next step, unless the page is pinch-zoomed
|
|
3065
3328
|
lb.addEventListener('touchstart', (e) => { swipe = S.focus && e.touches.length === 1 && e.target.closest('#lbScroll') && !e.target.closest('.cmpov, .is-drawing') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
|
|
3066
3329
|
lb.addEventListener('touchmove', (e) => { if (e.touches.length > 1) swipe = null; }, { passive: true });
|
|
3067
3330
|
lb.addEventListener('touchend', (e) => {
|
|
3068
3331
|
const sw = swipe; swipe = null; if (!sw || !S.focus) return;
|
|
3069
3332
|
const t = e.changedTouches[0], dx = t.clientX - sw.x, dy = t.clientY - sw.y, sc = $('lbScroll');
|
|
3070
|
-
if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - sw.t > 900) return;
|
|
3333
|
+
if ((window.visualViewport?.scale || 1) > 1.05 || (lbZoomSync(), LZ.k > 1.05) || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - sw.t > 900) return;
|
|
3071
3334
|
if (sc && sc.scrollWidth > sc.clientWidth + 2) return; // a capture wider than the screen pans sideways instead
|
|
3072
3335
|
$(dx < 0 ? 'lbNext' : 'lbPrev')?.click();
|
|
3073
3336
|
}, { passive: true });
|
|
@@ -3076,7 +3339,7 @@ function wireLbOnce (lb) {
|
|
|
3076
3339
|
if (!lbPhone() || S.focus || e.button > 0) return;
|
|
3077
3340
|
const h = e.target.closest('#lbGrab, .lb__head'); if (!h || e.target.closest('button,a,input,select,textarea,label')) return;
|
|
3078
3341
|
const side = $('lbSide'), box = side.parentElement.getBoundingClientRect(), stage = lb.querySelector('.lb__stage').getBoundingClientRect();
|
|
3079
|
-
const peekH = box.bottom - stage.bottom, fullH = box.height -
|
|
3342
|
+
const peekH = box.bottom - stage.bottom, fullH = box.height - (parseFloat(getComputedStyle(side.parentElement).getPropertyValue('--sheet-gap')) || 140), halfH = box.height * 0.58;
|
|
3080
3343
|
const y0 = e.clientY, h0 = side.getBoundingClientRect().height; let moved = false;
|
|
3081
3344
|
const move = (ev) => { const dy = ev.clientY - y0; if (!moved && Math.abs(dy) < 6) return; moved = true; side.classList.add('is-drag'); side.style.overflow = 'hidden'; side.style.height = `${Math.max(peekH, Math.min(fullH, h0 - dy))}px`; };
|
|
3082
3345
|
const up = (ev) => {
|
|
@@ -3088,12 +3351,45 @@ function wireLbOnce (lb) {
|
|
|
3088
3351
|
};
|
|
3089
3352
|
addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
|
|
3090
3353
|
});
|
|
3354
|
+
// an open sheet: a tap on the preview above it lowers the sheet again (pins, drawings and Dev mode keep their taps)
|
|
3355
|
+
lb.addEventListener('click', (e) => {
|
|
3356
|
+
if (!lbPhone() || S.focus || (S.lbSheet || 'peek') === 'peek' || !e.target.closest('.lb__scroll')) return;
|
|
3357
|
+
if (C.pinMode || S.ignDraw || DEV.on || LIVE.mode || e.target.closest('button,a,input,select,textarea,label,.pinmark,.is-pinning,.is-drawing')) return;
|
|
3358
|
+
setSheet('peek');
|
|
3359
|
+
});
|
|
3091
3360
|
lb.addEventListener('keydown', (e) => { if (e.target.id === 'lbGrab' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setSheet(S.lbSheet === 'peek' ? 'half' : 'peek'); } });
|
|
3092
3361
|
// tabs and the comment composer need the sheet open
|
|
3093
3362
|
lb.addEventListener('focusin', (e) => { if (lbPhone() && !S.focus && S.lbSheet === 'peek' && e.target.closest('.lbtabs, .lbtab')) setSheet('half'); });
|
|
3094
3363
|
}
|
|
3364
|
+
/* Modal overlays (step detail, stack, browser, replay, prototype, review, grid): while one is open the canvas
|
|
3365
|
+
behind it is inert, so Tab stays in the overlay and nothing hidden behind it takes focus. Each dialog is named
|
|
3366
|
+
by its own title while that title is on screen (a reference to a missing id names nothing). */
|
|
3367
|
+
const MODALS = ['si', 'lb', 'br', 'rp', 'pt', 'rv', 'gv'];
|
|
3368
|
+
const MODAL_TITLES = { si: 'siTitle', lb: 'lbTitle' };
|
|
3369
|
+
const openModal = () => MODALS.map((id) => $(id)).find((el) => el && !el.hidden) || null;
|
|
3370
|
+
function syncModals () {
|
|
3371
|
+
for (const [id, title] of Object.entries(MODAL_TITLES)) {
|
|
3372
|
+
const el = $(id), t = document.getElementById(title); if (!el) continue;
|
|
3373
|
+
if (!el.hidden && t && el.contains(t)) el.setAttribute('aria-labelledby', title); else el.removeAttribute('aria-labelledby');
|
|
3374
|
+
}
|
|
3375
|
+
const app = $('app'); if (app) app.inert = !!openModal();
|
|
3376
|
+
}
|
|
3377
|
+
function modalA11y () {
|
|
3378
|
+
const mo = new MutationObserver(syncModals);
|
|
3379
|
+
for (const id of MODALS) { const el = $(id); if (el) mo.observe(el, { attributes: true, attributeFilter: ['hidden'], childList: true, subtree: true }); }
|
|
3380
|
+
syncModals();
|
|
3381
|
+
// the skip link: past the top bar and the side panel to the canvas or the index, or into the open overlay
|
|
3382
|
+
$('skipLink').onclick = (e) => {
|
|
3383
|
+
e.preventDefault();
|
|
3384
|
+
const dlg = openModal();
|
|
3385
|
+
const t = dlg ? dlg.querySelector('h3[id], button, [href], input, select, textarea') : S.mode === 'index' ? $('ri') : $('vp');
|
|
3386
|
+
if (!t) return;
|
|
3387
|
+
if (!t.matches('button, [href], input, select, textarea') && !t.hasAttribute('tabindex')) t.tabIndex = -1;
|
|
3388
|
+
t.focus();
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3095
3391
|
function setHash (p) {
|
|
3096
|
-
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null }).filter(([, x]) => x));
|
|
3392
|
+
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null, lang: HASH_LANG }).filter(([, x]) => x));
|
|
3097
3393
|
const h = Object.keys(all).length ? '#' + new URLSearchParams(all).toString() : ' ';
|
|
3098
3394
|
history.replaceState(null, '', h === ' ' ? location.pathname + location.search : h);
|
|
3099
3395
|
}
|
|
@@ -3104,12 +3400,14 @@ function applyHash () {
|
|
|
3104
3400
|
if (RV.open) { RV.open = false; $('rv').hidden = true; }
|
|
3105
3401
|
if (p.get('grid') && byId(p.get('grid'))) { if (!GV.open || GV.id !== p.get('grid')) gvOpen(p.get('grid')); return; }
|
|
3106
3402
|
if (GV.open) { GV.open = false; $('gv').hidden = true; $('gv').innerHTML = ''; }
|
|
3403
|
+
// #loc=<locale> picks the locale of the run on every view (map, index, replay, comments), not only a step's
|
|
3404
|
+
if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); }
|
|
3107
3405
|
if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
|
|
3108
3406
|
const cmp = p.get('cmp');
|
|
3109
3407
|
if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
|
|
3110
3408
|
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; }
|
|
3111
3409
|
if (S.rp) rpClose();
|
|
3112
|
-
if (p.get('comments')) showCommentsFromHash(p.get('comments'));
|
|
3410
|
+
if (p.get('comments') && !p.get('step')) showCommentsFromHash(p.get('comments'));
|
|
3113
3411
|
// a shared link straight into the prototype: #present=<step>&loc=…&vp=…
|
|
3114
3412
|
const pres = p.get('present');
|
|
3115
3413
|
if (pres && byId(pres)) { if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); } ptOpen(pres, p.get('vp')); return; }
|
|
@@ -3121,6 +3419,7 @@ function applyHash () {
|
|
|
3121
3419
|
S.pendingAc = p.get('ac');
|
|
3122
3420
|
S.focus = p.get('focus') === '1'; // #step=<id>&focus=1 opens the step's preview alone
|
|
3123
3421
|
openLb(step, p.get('vp'));
|
|
3422
|
+
if (p.get('comments') === 'step' || p.get('thread')) revealStepComments(p.get('thread'));
|
|
3124
3423
|
}
|
|
3125
3424
|
|
|
3126
3425
|
/* ---------- replay: guided walkthrough with branch choice ---------- */
|
|
@@ -3165,7 +3464,7 @@ function renderReplay () {
|
|
|
3165
3464
|
<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>
|
|
3166
3465
|
${rpHasVideo(v) ? `<button class="btn" id="rpVid" aria-pressed="${!!S.rpVideo}" title="${esc(T('recHint'))}">▶ ${T('videoMode')}</button>` : ''}${walkthroughLink()}<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>
|
|
3167
3466
|
<div class="rp__body"><div class="rp__stage" id="rpStage">${stage}</div>
|
|
3168
|
-
<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>
|
|
3467
|
+
<aside class="rp__side" aria-label="${esc(T('rpSideLabel'))}"><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>
|
|
3169
3468
|
${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
3170
3469
|
${n.kind !== 'decision' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}
|
|
3171
3470
|
${n.kind !== 'decision' && n.execution ? `<div class="sec"><h4>${T('execution')}</h4><p class="instr">${esc(n.execution.instructions || '')}</p></div>` : ''}</aside></div>
|
|
@@ -3215,6 +3514,26 @@ function issuesSections (issues) {
|
|
|
3215
3514
|
+ (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>` : '');
|
|
3216
3515
|
}
|
|
3217
3516
|
|
|
3517
|
+
/* ---------- "what changed" notes of a design version (batch G) ---------- */
|
|
3518
|
+
const noteTxt = (t) => t == null ? '' : typeof t === 'string' ? t : (t[UI] || t.en || '');
|
|
3519
|
+
const stepNote = (id) => (isDesign() && !isDraft() && RUN.notes?.steps || []).find((x) => x.step === id) || null;
|
|
3520
|
+
function versionNotesHtml () {
|
|
3521
|
+
const nt = isDesign() && !isDraft() ? RUN.notes : null;
|
|
3522
|
+
if (!nt) return '';
|
|
3523
|
+
const steps = nt.steps || [];
|
|
3524
|
+
const mark = (r) => r === 'new' ? ['pass', '+'] : r === 'removed' ? ['fail', '−'] : r === 'note' ? ['skip', '✎'] : ['partial', 'Δ'];
|
|
3525
|
+
return `<div class="sec" id="versionNotes"><h4>${T('whatChanged')}${nt.from ? ` <span class="note">${T('sinceVersion')} v${esc(nt.from.version)}</span>` : ''}</h4>`
|
|
3526
|
+
+ (noteTxt(nt.summary) ? `<p class="note" style="margin:0 0 6px;white-space:pre-line">${esc(noteTxt(nt.summary))}</p>` : '')
|
|
3527
|
+
+ (steps.length ? `<ul class="issues">${steps.map((x) => { const [st, m] = mark(x.reason); return `<li><button data-go="${esc(x.step)}" ${byId(x.step) ? '' : 'disabled'}><span class="badge st-${st}">${m}</span><span><b>${esc(loc(byId(x.step)?.title) || x.title || x.step)}</b>${x.locales?.length && RUN.scope?.locales?.length > 1 ? ` <span class="note">${x.locales.map(esc).join(', ')}</span>` : ''}</span><small>${esc(noteTxt(x.text))}</small></button></li>`; }).join('')}</ul>` : '')
|
|
3528
|
+
+ (nt.edited_by ? `<p class="note" style="margin:6px 0 0">${T('notesBy')} ${esc(nt.edited_by)}</p>` : '')
|
|
3529
|
+
+ (APP() && canWrite() && !['viewer'].includes(APP().user?.role) && RUN.version ? `<button class="linkish" id="notesEdit" style="margin-top:6px">${T('notesEdit')}</button>` : '')
|
|
3530
|
+
+ '</div>';
|
|
3531
|
+
}
|
|
3532
|
+
function stepNoteHtml (n) {
|
|
3533
|
+
const x = stepNote(n.id);
|
|
3534
|
+
return x && noteTxt(x.text) ? `<p class="note step-note" data-step-note><b>${T('whatChanged')}${RUN.notes?.from ? ` (v${esc(RUN.notes.from.version)} → v${esc(RUN.version)})` : ''}:</b> ${esc(noteTxt(x.text))}</p>` : '';
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3218
3537
|
function renderReport () {
|
|
3219
3538
|
const c = RUN.summary, tot = screens().length || 1, pr = prevRun();
|
|
3220
3539
|
const all = screens().flatMap((n) => n.criteria), ok = all.filter((x) => critSt(x) === 'pass').length;
|
|
@@ -3223,6 +3542,7 @@ function renderReport () {
|
|
|
3223
3542
|
$('rside').innerHTML = `
|
|
3224
3543
|
<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>
|
|
3225
3544
|
${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>` : ''}
|
|
3545
|
+
${versionNotesHtml()}
|
|
3226
3546
|
${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)}${RUN.verdict.via === 'token' || RUN.verdict.via === 'agent' ? ` <span class="badge st-skip">${T(RUN.verdict.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : ''} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}${RUN.verdict.approved_by_tokens?.length ? ` · ${T('approvedBy')} ${T('approvedByTokens')}: ${RUN.verdict.approved_by_tokens.map((a) => esc(a.email)).join(', ')}` : ''}</p>` : ''}
|
|
3227
3547
|
${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>
|
|
3228
3548
|
<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>
|
|
@@ -3260,7 +3580,14 @@ function renderReport () {
|
|
|
3260
3580
|
$('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; } }; });
|
|
3261
3581
|
$('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; } }; });
|
|
3262
3582
|
$('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; } }; });
|
|
3263
|
-
$('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => openLb(el.dataset.go || el.dataset.id); });
|
|
3583
|
+
$('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => { if (byId(el.dataset.go || el.dataset.id)) openLb(el.dataset.go || el.dataset.id); }; });
|
|
3584
|
+
// the version's summary in someone's own words (designers; step texts too through the API, MCP and --notes)
|
|
3585
|
+
const ne = $('notesEdit'); if (ne) ne.onclick = async () => {
|
|
3586
|
+
const text = prompt(T('notesPrompt'), noteTxt(RUN.notes?.summary));
|
|
3587
|
+
if (text === null || !text.trim()) return;
|
|
3588
|
+
ne.disabled = true;
|
|
3589
|
+
try { await apiJson('PUT', scenarioApi(`/versions/v${RUN.version}/notes`), { notes: { summary: text.trim() } }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); ne.disabled = false; }
|
|
3590
|
+
};
|
|
3264
3591
|
wireCommentsPanel();
|
|
3265
3592
|
$('rside').querySelectorAll('.sesslegend [data-sess]').forEach((b) => { b.onclick = () => { S.sess = S.sess === b.dataset.sess ? null : b.dataset.sess; renderAll(); }; });
|
|
3266
3593
|
}
|
|
@@ -3278,8 +3605,9 @@ function runsOf (scId) {
|
|
|
3278
3605
|
/* The project picked in the lists is remembered per organization (cookie qw_p_<org>, set by the pages too). */
|
|
3279
3606
|
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 ''; } }
|
|
3280
3607
|
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 {} }
|
|
3281
|
-
function renderBrowser () {
|
|
3608
|
+
function renderBrowser (opts = {}) {
|
|
3282
3609
|
const q = S.brQ.trim().toLowerCase();
|
|
3610
|
+
const had = document.activeElement?.closest?.('#brList [data-sc]')?.dataset.sc; // the catalog may arrive while a scenario has the focus
|
|
3283
3611
|
const projects = [...new Set(catalog().map((s) => s.project || '').filter(Boolean))].sort();
|
|
3284
3612
|
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)));
|
|
3285
3613
|
seg($('brKind'), [['all', T('all')], ['task', T('tasks')], ['flow', T('flows')]], S.brKind, (v) => { S.brKind = v; renderBrowser(); });
|
|
@@ -3287,11 +3615,13 @@ function renderBrowser () {
|
|
|
3287
3615
|
bp.innerHTML = `<option value="">${T('projectAll')}</option>${projects.map((p) => `<option value="${esc(p)}" ${p === S.brProject ? 'selected' : ''}>${esc(p)}</option>`).join('')}`;
|
|
3288
3616
|
bp.onchange = () => { S.brProject = bp.value; rememberProject(bp.value); renderBrowser(); };
|
|
3289
3617
|
$('brCount').textContent = `${list.length} / ${catalog().length}`;
|
|
3290
|
-
$('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} ${
|
|
3291
|
-
|
|
3618
|
+
$('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} ${runsN(rs.length)}</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>`;
|
|
3619
|
+
if (had) [...$('brList').querySelectorAll('[data-sc]')].find((b) => b.dataset.sc === had)?.focus({ preventScroll: true });
|
|
3620
|
+
if (opts.runs !== false) renderBrRuns();
|
|
3292
3621
|
}
|
|
3293
3622
|
function renderBrRuns () {
|
|
3294
3623
|
const rs = runsOf(S.brSc);
|
|
3624
|
+
if (phoneBar()) { renderBrCards(rs); return; }
|
|
3295
3625
|
$('brRunFilters').innerHTML = `<b style="font-size:13px">${esc(S.brSc)}</b>`;
|
|
3296
3626
|
if (!rs.length) { $('brRuns').innerHTML = `<p class="empty">${T('noRuns')}</p>`; return; }
|
|
3297
3627
|
let lastDay = null; const rows = [];
|
|
@@ -3303,20 +3633,83 @@ function renderBrRuns () {
|
|
|
3303
3633
|
}
|
|
3304
3634
|
$('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>`;
|
|
3305
3635
|
}
|
|
3306
|
-
|
|
3636
|
+
/** Phones: the runs of one scenario as cards under a header with a way back to the scenarios. */
|
|
3637
|
+
function renderBrCards (rs) {
|
|
3638
|
+
const sc = catalog().find((x) => x.id === S.brSc) || { id: S.brSc, title: '' };
|
|
3639
|
+
const back = document.activeElement?.id === 'brBack';
|
|
3640
|
+
$('brRunFilters').innerHTML = `<div class="br__runhead"><button class="icon-btn" id="brBack" aria-label="${esc(T('allScenarios'))}" title="${esc(T('allScenarios'))}"><svg class="i" viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7"/></svg></button><div class="br__rh"><b>${esc(sc.title || sc.id)}</b><small><span class="mono">${esc(sc.id)}</span> · ${rs.length} ${runsN(rs.length)}</small></div><button class="icon-btn" data-br-close aria-label="${esc(T('close'))}" title="${esc(T('close'))}">${ICON.close}</button></div>`;
|
|
3641
|
+
if (back) $('brBack').focus({ preventScroll: true });
|
|
3642
|
+
if (!rs.length) { $('brRuns').innerHTML = `<p class="empty">${T('noRuns')}</p>`; return; }
|
|
3643
|
+
// compare: the other runs of the scenario on screen (the compare list of the menu)
|
|
3644
|
+
const cmpIds = S.brSc === RUN.scenario.id && !isDesign() ? new Set(cmpCandidates().map((c) => c.id)) : new Set();
|
|
3645
|
+
let lastDay = null; const out = [];
|
|
3646
|
+
for (const r of rs) {
|
|
3647
|
+
const d = new Date(r.started_at); const day = d.toDateString();
|
|
3648
|
+
if (day !== lastDay) { lastDay = day; out.push(`<h4 class="rday">${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()}</h4>`); }
|
|
3649
|
+
const c = r.summary || {}; const tot = (c.pass || 0) + (c.partial || 0) + (c.fail || 0) + (c.skip || 0);
|
|
3650
|
+
const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
3651
|
+
const cur = r.id === RUN.id;
|
|
3652
|
+
const verdict = cur ? RUN.verdict?.verdict || r.verdict : r.verdict;
|
|
3653
|
+
const base = r.baseline || (S.brSc === RUN.scenario.id && r.id === RUN.baseline_id);
|
|
3654
|
+
const g = r.git || {};
|
|
3655
|
+
const ref = g.branch || g.commit ? `${esc(g.branch || '')}${g.branch && g.commit ? ' · ' : ''}${esc(g.commit || '')}` : '';
|
|
3656
|
+
const on = S.cmp?.id === r.id;
|
|
3657
|
+
out.push(`<div class="rcard" aria-current="${cur}"><button class="rcard__main" data-run="${esc(r.id)}"><span class="rcard__l1"><span class="mono">${time}</span>${r.environment?.name ? `<span class="run__env run__env--${esc(r.environment.name)}">${esc(r.environment.name)}</span>` : ''}${cur ? `<span class="rmark rmark--cur">${T('brShown')}</span>` : ''}${base ? `<span class="rmark rmark--base">${T('cmpBaseline')}</span>` : ''}${verdict === 'accepted' ? `<span class="rmark rmark--ok">✓ ${T('accepted')}</span>` : verdict === 'returned' ? `<span class="rmark rmark--back">↩ ${T('returned')}</span>` : ''}</span>${ref ? `<span class="rcard__git">${ref}</span>` : ''}${g.message ? `<span class="rcard__msg">${esc(g.message)}</span>` : ''}<span class="rcard__res">${bar5(c)}<span>${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></button>${cmpIds.has(r.id) ? `<button class="btn rcard__cmp" data-cmp="${esc(r.id)}" aria-pressed="${on}" aria-label="${esc(`${on ? T('cmpStop') : T('cmpRun')} ${d.getDate()}. ${d.getMonth() + 1}. ${time}`)}">${on ? T('cmpActive') : T('compare')}</button>` : ''}</div>`);
|
|
3658
|
+
}
|
|
3659
|
+
$('brRuns').innerHTML = `<div class="rcards">${out.join('')}</div>`;
|
|
3660
|
+
}
|
|
3661
|
+
/** Which level of the explorer a phone shows: the scenarios ('sc') or the runs of one ('runs'). */
|
|
3662
|
+
function brLevel (level) {
|
|
3663
|
+
S.brLevel = level; $('br').dataset.level = level;
|
|
3664
|
+
renderBrowser();
|
|
3665
|
+
if (!phoneBar()) return;
|
|
3666
|
+
if (level === 'runs') {
|
|
3667
|
+
$('brRuns').scrollTop = 0;
|
|
3668
|
+
$('brRuns').querySelector('.rcard[aria-current="true"]')?.scrollIntoView({ block: 'center' });
|
|
3669
|
+
$('brBack')?.focus({ preventScroll: true });
|
|
3670
|
+
} else {
|
|
3671
|
+
const sel = $('brList').querySelector('.sc[aria-selected="true"]');
|
|
3672
|
+
if (sel) { sel.scrollIntoView({ block: 'nearest' }); sel.focus({ preventScroll: true }); }
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
/** Opens a run; the open step stays open when the run has it (same scenario). */
|
|
3676
|
+
function goRun (id) {
|
|
3677
|
+
const href = runHref(id); if (href === '#') return;
|
|
3678
|
+
const keep = S.open && !$('lb').hidden && S.brSc === RUN.scenario.id;
|
|
3679
|
+
location.href = keep ? `${href}#${new URLSearchParams(Object.entries({ step: S.open, loc: S.loc, vp: S.lbVp }).filter(([, v]) => v)).toString()}` : href;
|
|
3680
|
+
}
|
|
3681
|
+
function openBrowser (opts = {}) {
|
|
3307
3682
|
S.brProject = rememberedProject(); // the project picked in the lists applies here too
|
|
3308
|
-
|
|
3683
|
+
if (!$('br').contains(document.activeElement)) S.brFrom = document.activeElement;
|
|
3684
|
+
S.brSc = RUN.scenario.id; $('br').hidden = false; $('brSearch').value = S.brQ;
|
|
3685
|
+
brLevel(opts.level || 'sc');
|
|
3686
|
+
// on a phone the keyboard would cover half of the list: the search field waits for a tap
|
|
3687
|
+
if (!phoneBar() || !matchMedia('(pointer:coarse)').matches) $('brSearch').focus();
|
|
3309
3688
|
// on the service the catalog of scenarios comes when the browser opens (the run itself loads without it)
|
|
3310
3689
|
const a = APP();
|
|
3311
|
-
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; }); }
|
|
3690
|
+
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({ runs: S.brSc !== RUN.scenario.id }); } }).catch(() => {}).finally(() => { S.catLoading = false; }); }
|
|
3691
|
+
}
|
|
3692
|
+
function closeBrowser () {
|
|
3693
|
+
if ($('br').hidden) return;
|
|
3694
|
+
$('br').hidden = true;
|
|
3695
|
+
const from = S.brFrom; S.brFrom = null;
|
|
3696
|
+
if (from?.isConnected && from.getClientRects().length) from.focus({ preventScroll: true });
|
|
3312
3697
|
}
|
|
3313
|
-
function closeBrowser () { $('br').hidden = true; }
|
|
3314
3698
|
function wireBrowser () {
|
|
3315
|
-
$('picker').onclick = openBrowser; $('allRuns').onclick = openBrowser; $('brClose').onclick = closeBrowser;
|
|
3316
|
-
$('br').addEventListener('click', (e) => { if (e.target === $('br')) closeBrowser(); });
|
|
3699
|
+
$('picker').onclick = () => openBrowser(); $('allRuns').onclick = () => openBrowser(); $('brClose').onclick = closeBrowser;
|
|
3700
|
+
$('br').addEventListener('click', (e) => { if (e.target === $('br') || e.target.closest('[data-br-close]')) closeBrowser(); else if (e.target.closest('#brBack')) brLevel('sc'); });
|
|
3317
3701
|
$('brSearch').addEventListener('input', (e) => { S.brQ = e.target.value; renderBrowser(); });
|
|
3318
|
-
$('brList').addEventListener('click', (e) => { const b = e.target.closest('[data-sc]'); if (!b) return; S.brSc = b.dataset.sc; renderBrowser(); });
|
|
3319
|
-
$('brRuns').addEventListener('click', (e) => {
|
|
3702
|
+
$('brList').addEventListener('click', (e) => { const b = e.target.closest('[data-sc]'); if (!b) return; S.brSc = b.dataset.sc; if (phoneBar()) brLevel('runs'); else renderBrowser(); });
|
|
3703
|
+
$('brRuns').addEventListener('click', (e) => {
|
|
3704
|
+
if (e.target.closest('a')) return;
|
|
3705
|
+
const cmp = e.target.closest('[data-cmp]');
|
|
3706
|
+
if (cmp) { const id = S.cmp?.id === cmp.dataset.cmp ? '' : cmp.dataset.cmp; closeBrowser(); loadCmp(id); return; }
|
|
3707
|
+
const tr = e.target.closest('tr[data-run], button[data-run]'); if (!tr) return;
|
|
3708
|
+
if (tr.dataset.run === RUN.id) { if (phoneBar()) closeBrowser(); return; }
|
|
3709
|
+
goRun(tr.dataset.run);
|
|
3710
|
+
});
|
|
3711
|
+
// turning the phone (or resizing the window) across the phone width redraws the open explorer in its layout
|
|
3712
|
+
PHONE_MQ.addEventListener?.('change', () => { if (!$('br').hidden) brLevel(phoneBar() ? S.brLevel || 'sc' : 'sc'); });
|
|
3320
3713
|
}
|
|
3321
3714
|
|
|
3322
3715
|
/* ---------- design: drafts, versions, live steps, multiplayer (#42) ---------- */
|
|
@@ -3390,7 +3783,7 @@ function renderDesignBar () {
|
|
|
3390
3783
|
const vs = (RUN.versions || []).slice(0, 8);
|
|
3391
3784
|
const draftHref = `/${enc(a.org)}/scenarios/${enc(RUN.scenario.id)}/draft`;
|
|
3392
3785
|
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>`
|
|
3393
|
-
+ 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('')
|
|
3786
|
+
+ 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))}${noteTxt(v.notes) ? ' · ' + esc(noteTxt(v.notes)) : ''}">v${esc(v.version)}${v.id === refId ? ' <span class="ref" title="' + T('designRef') + '">★</span>' : ''}${v.verdict === 'accepted' ? ' ✓' : ''}</a>`).join('')
|
|
3394
3787
|
+ '</div><div id="dbranches"></div>'
|
|
3395
3788
|
+ (isDraft() ? `<p class="note" style="margin:8px 0 0">${T('publishHint').replace('%V', `v${(RUN.versions?.[0]?.version || 0) + 1}`)}</p>` : '')
|
|
3396
3789
|
+ '<div class="share__row" style="margin-top:8px">'
|
|
@@ -3450,9 +3843,12 @@ function fitLive () {
|
|
|
3450
3843
|
const w = parseInt(f.style.width, 10);
|
|
3451
3844
|
const cs = getComputedStyle(host);
|
|
3452
3845
|
const focus = S.focus && host.id === 'lbScroll' && !$('lb').hidden;
|
|
3846
|
+
// on a phone the detail's preview fills the room between the tools and the sheet; the page scrolls inside
|
|
3847
|
+
const phone = !focus && host.id === 'lbScroll' && !$('lb').hidden && lbPhone();
|
|
3453
3848
|
const k = Math.min(1, (host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - (focus ? 0 : 2)) / w);
|
|
3454
3849
|
// in focus the live page fills the screen: its real width, scaled down to fit, and as tall as the screen allows
|
|
3455
|
-
const
|
|
3850
|
+
const room = host.clientHeight - (phone ? parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + 2 : 0);
|
|
3851
|
+
const h = focus || phone ? Math.max(1, Math.floor(room / k)) : parseInt(f.dataset.h, 10);
|
|
3456
3852
|
f.style.height = `${h}px`;
|
|
3457
3853
|
f.style.transformOrigin = '0 0'; f.style.transform = `scale(${k})`;
|
|
3458
3854
|
box.style.width = `${Math.floor(w * k)}px`; box.style.height = `${Math.floor(h * k)}px`;
|
|
@@ -4257,6 +4653,90 @@ async function downloadSignoff (kind, b) {
|
|
|
4257
4653
|
}
|
|
4258
4654
|
document.addEventListener('click', (e) => { const b = e.target.closest?.('[data-signoff]'); if (b) downloadSignoff(b.dataset.signoff, b); });
|
|
4259
4655
|
|
|
4656
|
+
/* ---------- Jira and Linear (#21): an issue from a step, the issues of the run on the canvas ----------
|
|
4657
|
+
GET …/runs/<id>/issues says which trackers take an issue from this run (connected and mapped for its project),
|
|
4658
|
+
whether this person may create one (Developer / Tester), the run's issues and the issue its branch names. The
|
|
4659
|
+
service writes the issue (screenshot, criterion, notes, link back) and a comment on the step that mirrors it. */
|
|
4660
|
+
Object.assign(I18N.cs, {
|
|
4661
|
+
issTitle: 'Issues', issNew: (k) => `Založit issue v ${k === 'Jira' ? 'Jiře' : 'Linearu'}`, issFormTitle: 'Nové issue', issTracker: 'Kam',
|
|
4662
|
+
issCriterion: 'Kritérium', issWholeStep: 'Celý krok', issName: 'Název', issNamePh: 'Prázdné: název se složí z kroku a kritéria', issNote: 'Poznámka pro vývoj',
|
|
4663
|
+
issSend: 'Založit issue', issSending: 'Zakládám issue…', issSent: (k) => `Issue ${k} je založené`, issWhat: 'Do issue půjde snímek, kritérium, připomínky a odkaz zpět na krok. Až bude hotové, komentář u kroku se vyřeší.',
|
|
4664
|
+
iss_open: 'otevřené', iss_done: 'hotové', iss_canceled: 'zrušené', issBranch: 'Issue z větve', issCount: (n) => `${n} ${n === 1 ? 'otevřené issue' : n < 5 ? 'otevřená issues' : 'otevřených issues'}`
|
|
4665
|
+
})
|
|
4666
|
+
Object.assign(I18N.en, {
|
|
4667
|
+
issTitle: 'Issues', issNew: (k) => `Create a ${k} issue`, issFormTitle: 'New issue', issTracker: 'Where',
|
|
4668
|
+
issCriterion: 'Criterion', issWholeStep: 'The whole step', issName: 'Title', issNamePh: 'Empty: made from the step and the criterion', issNote: 'Note for development',
|
|
4669
|
+
issSend: 'Create the issue', issSending: 'Creating the issue…', issSent: (k) => `Issue ${k} created`, issWhat: 'The issue gets the screenshot, the criterion, the notes and a link back to the step. When it is done, the comment on the step is resolved.',
|
|
4670
|
+
iss_open: 'open', iss_done: 'done', iss_canceled: 'cancelled', issBranch: 'Issue from the branch', issCount: (n) => `${n} open issue${n === 1 ? '' : 's'}`
|
|
4671
|
+
})
|
|
4672
|
+
var ISS = { data: null, form: null, busy: false }; // var: the footer may render before this line runs
|
|
4673
|
+
const issService = () => !!(APP()?.user && APP()?.run && !isDraft() && !APP()?.public);
|
|
4674
|
+
async function issLoad () {
|
|
4675
|
+
if (!issService()) return;
|
|
4676
|
+
try {
|
|
4677
|
+
const res = await fetch(runApi('/issues'), { credentials: 'same-origin' });
|
|
4678
|
+
if (!res.ok) return;
|
|
4679
|
+
ISS.data = await res.json();
|
|
4680
|
+
renderNodes(); issFoot();
|
|
4681
|
+
if ((S.open && !$('lb').hidden) || RV.open) rerenderComments(false);
|
|
4682
|
+
} catch {}
|
|
4683
|
+
}
|
|
4684
|
+
const issOf = (step) => (ISS.data?.issues || []).filter((i) => i.step === step);
|
|
4685
|
+
const issTrackers = () => (ISS.data?.trackers || []).filter((t) => t.target);
|
|
4686
|
+
/** The canvas card of a step with open issues. */
|
|
4687
|
+
function issBadge (id) {
|
|
4688
|
+
const n = issOf(id).filter((i) => i.state === 'open').length;
|
|
4689
|
+
return n ? `<span class="iss-badge" title="${esc(T('issCount')(n))}">⧉ ${n}</span>` : '';
|
|
4690
|
+
}
|
|
4691
|
+
/** The run's own issue (named by its branch or pull request) beside the GitHub issue in the footer. */
|
|
4692
|
+
function issFootHtml () {
|
|
4693
|
+
const l = ISS?.data?.linked; if (!l) return '';
|
|
4694
|
+
return `<span data-iss-linked title="${esc(T('issBranch'))}">${l.url ? `<a class="pr" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.key)}</a>` : `<span class="mono">${esc(l.key)}</span>`}${l.state ? ` <span class="note">${esc(T('iss_' + l.state))}</span>` : ''} · </span>`;
|
|
4695
|
+
}
|
|
4696
|
+
function issFoot () { const f = $('scFoot'); if (f && !f.querySelector('[data-iss-linked]')) f.insertAdjacentHTML('afterbegin', issFootHtml()); }
|
|
4697
|
+
/** Issues of a step and the form to create one, in the step detail and in review mode. */
|
|
4698
|
+
function issuesBlock (n, v) {
|
|
4699
|
+
if (!ISS.data || !n || n.kind === 'decision' || n.kind === 'stack') return '';
|
|
4700
|
+
const list = issOf(n.id); const trackers = issTrackers();
|
|
4701
|
+
const can = ISS.data.can_create && trackers.length && canWrite();
|
|
4702
|
+
if (!list.length && !can) return '';
|
|
4703
|
+
const items = list.length ? `<ul class="iss-list">${list.map((i) => `<li><a class="pr" href="${esc(i.url)}" target="_blank" rel="noopener">${esc(i.key)}</a> <span class="badge st-${i.state === 'open' ? 'partial' : i.state === 'done' ? 'pass' : 'skip'}">${esc(T('iss_' + i.state))}</span>${i.criterion ? ` <span class="id">${esc(i.criterion)}</span>` : ''} <span class="note">${esc(i.title)}</span></li>`).join('')}</ul>` : '';
|
|
4704
|
+
let tail = '';
|
|
4705
|
+
if (can && ISS.form === n.id) {
|
|
4706
|
+
const crit = (n.criteria || []).slice().sort((a, b) => (a.verdict === 'fail' ? 0 : 1) - (b.verdict === 'fail' ? 0 : 1));
|
|
4707
|
+
const pick = crit.find((c) => (c.by_locale?.[S.loc]?.[v?.name]?.verdict || c.verdict) === 'fail');
|
|
4708
|
+
tail = `<div class="iss-form" data-iss-form="${esc(n.id)}"><p class="note">${esc(T('issWhat'))}</p>
|
|
4709
|
+
${trackers.length > 1 ? `<label>${esc(T('issTracker'))} <select id="issTracker">${trackers.map((t) => `<option value="${esc(t.kind)}">${esc(t.label)} · ${esc(t.target)}</option>`).join('')}</select></label>` : ''}
|
|
4710
|
+
<label>${esc(T('issCriterion'))} <select id="issCrit"><option value="">${esc(T('issWholeStep'))}</option>${crit.map((c) => `<option value="${esc(c.id)}" ${pick && pick.id === c.id ? 'selected' : ''}>${esc(c.id)} · ${esc(String(critText(c)).slice(0, 60))}</option>`).join('')}</select></label>
|
|
4711
|
+
<label>${esc(T('issName'))} <input id="issName" maxlength="200" placeholder="${esc(T('issNamePh'))}"></label>
|
|
4712
|
+
<label>${esc(T('issNote'))} <textarea id="issNote" rows="2"></textarea></label>
|
|
4713
|
+
<div class="share__row"><button class="btn btn--primary" data-iss-send="${esc(n.id)}" data-iss-vp="${esc(v?.name || '')}">${esc(T('issSend'))}</button><button class="btn" data-iss-cancel>${esc(T('cancel'))}</button></div></div>`;
|
|
4714
|
+
} else if (can) tail = `<div class="share__row">${trackers.map((t) => `<button class="btn" data-iss-new="${esc(n.id)}" data-iss-kind="${esc(t.kind)}">${esc(T('issNew')(t.label))}</button>`).join('')}</div>`;
|
|
4715
|
+
return `<div class="sec iss"><h4>${esc(T('issTitle'))}</h4>${items}${tail}</div>`;
|
|
4716
|
+
}
|
|
4717
|
+
async function issSend (step, vp) {
|
|
4718
|
+
if (ISS.busy) return;
|
|
4719
|
+
const a = APP(); const trackers = issTrackers();
|
|
4720
|
+
const to = $('issTracker')?.value || ISS.kind || trackers[0]?.kind;
|
|
4721
|
+
ISS.busy = true; toast(T('issSending'));
|
|
4722
|
+
try {
|
|
4723
|
+
const res = await fetch(`${a.api}/orgs/${a.org}/issues`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ run_id: a.run, step, to, criterion: $('issCrit')?.value || undefined, title: $('issName')?.value.trim() || undefined, note: $('issNote')?.value.trim() || undefined, locale: S.loc, viewport: vp || undefined }) });
|
|
4724
|
+
const out = await res.json().catch(() => ({}));
|
|
4725
|
+
if (!res.ok) throw new Error(out.error || `HTTP ${res.status}`);
|
|
4726
|
+
ISS.form = null;
|
|
4727
|
+
toast(T('issSent')(out.key));
|
|
4728
|
+
await issLoad();
|
|
4729
|
+
await refreshComments();
|
|
4730
|
+
} catch (e) { toast(e.message); } finally { ISS.busy = false; }
|
|
4731
|
+
}
|
|
4732
|
+
document.addEventListener('click', (e) => {
|
|
4733
|
+
const nb = e.target.closest?.('[data-iss-new]');
|
|
4734
|
+
if (nb) { ISS.form = nb.dataset.issNew; ISS.kind = nb.dataset.issKind; rerenderComments(false); setTimeout(() => { const s = $('issTracker'); if (s && ISS.kind) s.value = ISS.kind; $('issName')?.focus({ preventScroll: true }); }, 0); return; }
|
|
4735
|
+
if (e.target.closest?.('[data-iss-cancel]')) { ISS.form = null; rerenderComments(false); return; }
|
|
4736
|
+
const sb = e.target.closest?.('[data-iss-send]');
|
|
4737
|
+
if (sb) issSend(sb.dataset.issSend, sb.dataset.issVp);
|
|
4738
|
+
});
|
|
4739
|
+
|
|
4260
4740
|
/* ---------- boot ---------- */
|
|
4261
4741
|
/* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ----------
|
|
4262
4742
|
The queue comes from the service (GET …/runs/<id>/review-queue: order, reasons, decisions, approvals carried over
|
|
@@ -4456,9 +4936,9 @@ function rvRender () {
|
|
|
4456
4936
|
const crit = n.kind === 'decision' ? '' : `<div class="sec"><h4>${T('criteria')} ${n.kind === 'stack' ? '' : critCounts(n, v)}</h4><ul class="ac">${(n.kind === 'stack' ? n.criteria : critOrder(n, v)).map((x) => { const r = x.by_locale?.[S.loc]?.[v.name] || {}; const st = n.kind === 'stack' ? critSt(x) : VSTAT[r.verdict || 'not_reached']; const ev = r.evidence || r.note || ''; return `<li class="st-${st}"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(x.id)}</span>${esc(critText(x))}${r.score != null ? ` <span class="id">${esc(Number(r.score).toFixed(2))}</span>` : ''}</span>${ev ? (humanEvidence(ev) ? `<span class="ev ev--text">${esc(humanEvidence(ev))}</span>` : `<code class="ev">${esc(ev)}</code>`) : ''}</li>`; }).join('') || `<li class="st-pass"><span class="mark">–</span><span>—</span></li>`}</ul></div>`;
|
|
4457
4937
|
const retBox = RV.ret && rvWritable() ? `<div class="sec rvm__ret"><h4><label for="rvRetNote">${esc(T('rvReturnTitle'))}</label></h4><textarea id="rvRetNote" rows="3" placeholder="${esc(T('rvReturnPh'))}">${esc(loadDraft(draftKey('step', n.id, 'return')))}</textarea><div class="share__row" style="margin-top:6px"><button class="btn btn--primary" id="rvRetSend">${esc(T('rvReturnSend'))}</button><button class="btn" id="rvRetCancel">${esc(T('cancel'))}</button><span class="note">${T('ctrlEnter')}</span></div></div>` : '';
|
|
4458
4938
|
box.innerHTML = top + `<div class="rvm__body"><div class="rvm__stage" id="rvStage">${stage}</div>
|
|
4459
|
-
<aside class="rvm__side" id="rvSide"><div class="lb__head"><div class="lb__row"><span class="kind">${ICON[n.kind] || ''}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${esc(RV.idx + 1)} / ${esc(items.length)}</span></div>
|
|
4939
|
+
<aside class="rvm__side" id="rvSide" aria-label="${esc(T('rvSideLabel'))}"><div class="lb__head"><div class="lb__row"><span class="kind">${ICON[n.kind] || ''}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${esc(RV.idx + 1)} / ${esc(items.length)}</span></div>
|
|
4460
4940
|
<h3 id="rvStepTitle">${esc(loc(n.title))}</h3><div class="node__path">${esc(cap?.url || loc(n.path) || '')}</div><div class="rvm__why">${chips}</div></div>
|
|
4461
|
-
${retBox}${rvDecisionHtml(it)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
4941
|
+
${retBox}${rvDecisionHtml(it)}${issuesBlock(n, v)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
4462
4942
|
${n.kind !== 'stack' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}</aside></div>${rvFootHtml(it)}`;
|
|
4463
4943
|
rvWire(it, n, v);
|
|
4464
4944
|
setHash({ review: it.step });
|
|
@@ -4525,7 +5005,7 @@ function rvWire (it, n, v) {
|
|
|
4525
5005
|
box.addEventListener('touchend', (e) => {
|
|
4526
5006
|
const s0 = sw; sw = null; if (!s0) return;
|
|
4527
5007
|
const t = e.changedTouches[0], dx = t.clientX - s0.x, dy = t.clientY - s0.y, sc = $('rvStage');
|
|
4528
|
-
if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - s0.t > 900) return;
|
|
5008
|
+
if ((window.visualViewport?.scale || 1) > 1.05 || (lbZoomSync(), LZ.k > 1.05) || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - s0.t > 900) return;
|
|
4529
5009
|
if (sc && sc.scrollWidth > sc.clientWidth + 2) return;
|
|
4530
5010
|
rvGo(dx < 0 ? 1 : -1);
|
|
4531
5011
|
}, { passive: true });
|
|
@@ -4717,27 +5197,31 @@ load().then((data) => {
|
|
|
4717
5197
|
// the panels a person opens or closes are remembered (per screen class: wide or not)
|
|
4718
5198
|
const panelKey = () => `qawalk-panels-${innerWidth >= 1600 ? 'wide' : 'narrow'}`;
|
|
4719
5199
|
const savePanels = () => { try { localStorage.setItem(panelKey(), JSON.stringify({ left: S.left, right: S.right })); } catch {} };
|
|
4720
|
-
$('togLeft').onclick = () => { S.left = !S.left; setPanels(); savePanels(); };
|
|
4721
|
-
$('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
|
|
5200
|
+
$('togLeft').onclick = () => { S.left = !S.left; if (S.left) S.lastPanel = 'left'; setPanels(); savePanels(); };
|
|
5201
|
+
$('togRight').onclick = () => { S.right = !S.right; if (S.right) S.lastPanel = 'right'; setPanels(); savePanels(); };
|
|
5202
|
+
// a window narrowed to a phone with both panels open keeps only the one opened last (drawers would overlap)
|
|
5203
|
+
matchMedia('(max-width:1100px)').addEventListener?.('change', () => { if (narrowPanels() && S.left && S.right) setPanels(); });
|
|
4722
5204
|
$('rpBtn').onclick = () => rpOpen(null);
|
|
4723
5205
|
if (RUN.walkthrough?.file && !RUN.recordings_expired) { const w = $('wtBtn'); w.hidden = false; w.href = asset(RUN.walkthrough.file); w.title = T('walkthroughTitle'); }
|
|
4724
5206
|
wireRecPreviews($('nodes'));
|
|
4725
5207
|
$('ptBtn').hidden = !isDesign(); $('ptBtn').onclick = () => ptOpen(null);
|
|
4726
5208
|
rvInit(); // review mode (Q)
|
|
5209
|
+
if (issService()) setTimeout(() => { issLoad(); }, 0); // Jira and Linear issues of the run (#21)
|
|
4727
5210
|
wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
|
|
4728
5211
|
// small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
|
|
4729
5212
|
// wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
|
|
4730
5213
|
const wideMq = matchMedia('(min-width:1500px)');
|
|
4731
5214
|
const placeBar = () => {
|
|
4732
|
-
const wide = wideMq.matches; document.body.classList.toggle('wide', wide);
|
|
5215
|
+
const wide = wideMq.matches && !S.unwide; document.body.classList.toggle('wide', wide);
|
|
4733
5216
|
if (wide) { $('navSlot').appendChild($('appNav')); $('viewSlot').appendChild($('viewCtls')); $('barMenu').hidden = true; }
|
|
4734
5217
|
else { $('barMenu').appendChild($('appNav')); $('barMenu').appendChild($('viewCtls')); }
|
|
4735
5218
|
renderPresence();
|
|
4736
5219
|
};
|
|
4737
|
-
wideMq.addEventListener('change', placeBar); placeBar();
|
|
5220
|
+
wideMq.addEventListener('change', () => { S.unwide = false; placeBar(); }); placeBar(); S.placeBar = placeBar;
|
|
4738
5221
|
const menus = [['menuBtn', 'barMenu'], ['helpBtn', 'helpPop']];
|
|
4739
5222
|
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(); } };
|
|
4740
|
-
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)); };
|
|
5223
|
+
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)); if (open) placePop($(m)); };
|
|
5224
|
+
addEventListener('resize', () => document.querySelectorAll('.bar .menu:not([hidden])').forEach(placePop));
|
|
4741
5225
|
document.addEventListener('click', (e) => { if (!e.target.closest('.popwrap, .presence')) closeMenus(null); });
|
|
4742
5226
|
S.closeMenus = closeMenus;
|
|
4743
5227
|
document.addEventListener('keydown', (e) => {
|
|
@@ -4786,9 +5270,15 @@ load().then((data) => {
|
|
|
4786
5270
|
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 {} }
|
|
4787
5271
|
if (window.QAWALK_APP?.embed) document.body.classList.add('embed');
|
|
4788
5272
|
{ 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'; }
|
|
5273
|
+
// drawers start under the top bar, so the bar (and its panel toggles) stays in view and in reach
|
|
5274
|
+
{ const bar = document.querySelector('.bar'); const setBarH = () => $('app').style.setProperty('--barh', `${bar.offsetHeight}px`); setBarH(); if (typeof ResizeObserver === 'function') new ResizeObserver(setBarH).observe(bar); }
|
|
5275
|
+
$('main').addEventListener('click', (e) => { if (e.target === $('main') && narrowPanels() && (S.left || S.right)) { S.left = S.right = false; setPanels(); } });
|
|
5276
|
+
addEventListener('resize', drawerInert);
|
|
4789
5277
|
setPanels(); renderAll(); applyMode();
|
|
5278
|
+
$('app').removeAttribute('data-boot');
|
|
4790
5279
|
requestAnimationFrame(() => requestAnimationFrame(() => { startView(); applyHash(); presenceStart(); }));
|
|
4791
5280
|
addEventListener('hashchange', applyHash);
|
|
5281
|
+
modalA11y();
|
|
4792
5282
|
}).catch((error) => { window.Sentry?.captureException(error); document.body.innerHTML = `<p style="padding:24px;font-family:sans-serif">Kaloko: ${esc(error.message)}</p>`; });
|
|
4793
5283
|
</script>
|
|
4794
5284
|
</body>
|