kaloko 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -8
- package/package.json +1 -1
- package/skills/kaloko/SKILL.md +9 -8
- package/skills/kaloko/reference/docs.md +8 -1
- package/skills/kaloko/reference/mcp.md +1 -0
- package/src/cli.mjs +34 -2
- package/src/lib/changelog.mjs +70 -0
- package/src/lib/keyboard.mjs +42 -12
- package/src/lib/onboarding.mjs +3 -1
- package/viewer/index.html +316 -61
package/viewer/index.html
CHANGED
|
@@ -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,7 +39,7 @@
|
|
|
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
45
|
/* the canvas is an app with its own zoom: the page itself never pans sideways or zooms (the canvas, screenshots
|
|
@@ -50,8 +50,22 @@ html,body{overflow-x:clip;touch-action:pan-x pan-y}
|
|
|
50
50
|
body{margin:0}
|
|
51
51
|
body{background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.43;overflow:hidden}
|
|
52
52
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
|
|
53
|
-
|
|
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}
|
|
54
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}
|
|
55
69
|
.bar{min-width:0}
|
|
56
70
|
|
|
57
71
|
/* top bar */
|
|
@@ -79,7 +93,8 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
|
|
|
79
93
|
.task b{font-size:14px;font-weight:600}
|
|
80
94
|
.task span{font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
81
95
|
/* the bar keeps its height while the run loads: empty title lines and switches hold their place (no layout shift) */
|
|
82
|
-
.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}
|
|
83
98
|
.spacer{flex:1}
|
|
84
99
|
.seg{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:2px;background:var(--panel-2)}
|
|
85
100
|
.seg button{padding:3px 11px;border-radius:999px;font-size:12.5px;font-weight:500;color:var(--muted)}
|
|
@@ -155,7 +170,7 @@ a.pr:hover{text-decoration-color:var(--ink)}
|
|
|
155
170
|
.side__foot code{font-family:var(--mono);font-size:11px;color:var(--ink);overflow-wrap:anywhere}
|
|
156
171
|
|
|
157
172
|
/* right: summary */
|
|
158
|
-
.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}
|
|
159
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}
|
|
160
175
|
.rside__head h3{margin:0;font-size:16px;letter-spacing:-.01em}
|
|
161
176
|
.rside .sec{padding:14px 16px}
|
|
@@ -236,13 +251,54 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
236
251
|
.runs-wrap{overflow:auto;flex:1}
|
|
237
252
|
.more{display:block;margin:12px auto}
|
|
238
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
|
+
}
|
|
239
295
|
|
|
240
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)}
|
|
241
297
|
|
|
242
298
|
@media (max-width:1100px){
|
|
243
299
|
.main{grid-template-columns:minmax(0,1fr)}
|
|
244
300
|
.viewport{grid-column:1}
|
|
245
|
-
.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)}
|
|
246
302
|
.side{left:0}.rside{right:0}
|
|
247
303
|
.task span{max-width:50vw}
|
|
248
304
|
}
|
|
@@ -267,7 +323,8 @@ body.embed .helpwrap{display:none}
|
|
|
267
323
|
.bar-break{display:block;order:7;flex-basis:100%;height:0}
|
|
268
324
|
.bar > .picker,#rpBtn,#rvBtn,#modeSeg{order:8}
|
|
269
325
|
.bar > .picker{flex:1 1 0;max-width:none;padding:4px 6px}
|
|
270
|
-
.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}
|
|
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}
|
|
271
328
|
.bar .btn{padding:5px 9px}.bar .seg button{padding:3px 9px}
|
|
272
329
|
#wtBtn,#ptBtn,#helpBtn,#cmpChip,#dbar{display:none!important}
|
|
273
330
|
#locSeg{flex:0 1 auto}.bar .dpick .nm,.bar .dpick__bad{display:none}
|
|
@@ -310,6 +367,7 @@ body.embed .helpwrap{display:none}
|
|
|
310
367
|
.presence{display:flex;align-items:center;gap:4px;margin-left:6px}
|
|
311
368
|
.presence .pav{width:26px;height:26px;border-radius:50%;border:2px solid var(--panel);background:var(--c);color:#fff;font-size:11px;font-weight:700;display:grid;place-items:center;cursor:pointer;padding:0;margin-left:-6px;box-shadow:0 0 0 1px var(--line)}
|
|
312
369
|
.presence .pav[aria-pressed="true"]{box-shadow:0 0 0 2px var(--ink)}
|
|
370
|
+
.pav:focus-visible{position:relative;z-index:3} /* the avatars overlap: the focused one rises so its ring shows */
|
|
313
371
|
.presence .pvis{background:none;border:1px solid var(--line);border-radius:999px;font-size:11px;padding:2px 8px;margin-left:6px;cursor:pointer;color:var(--muted)}
|
|
314
372
|
.cur{position:absolute;transform:scale(var(--inv,1));transform-origin:0 0;transition:left .08s linear,top .08s linear}
|
|
315
373
|
.cur svg{width:18px;height:18px;display:block}.cur path{fill:var(--c);stroke:#fff;stroke-width:1.5}
|
|
@@ -673,7 +731,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
673
731
|
.lb__live iframe{display:block;border:0;background:#fff}
|
|
674
732
|
.lb__cmp .lb__live{margin:0}
|
|
675
733
|
/* prototype presentation: full screen, one step at a time, click-through in the live page */
|
|
676
|
-
.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)}
|
|
734
|
+
.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)}
|
|
677
735
|
.pt__bar{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 14px;background:#161615;border-bottom:1px solid var(--line)}
|
|
678
736
|
.pt__bar--foot{border-bottom:0;border-top:1px solid var(--line);justify-content:center}
|
|
679
737
|
.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}
|
|
@@ -726,7 +784,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
726
784
|
.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)}
|
|
727
785
|
.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}
|
|
728
786
|
.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}
|
|
729
|
-
.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)}
|
|
787
|
+
.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)}
|
|
730
788
|
.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}
|
|
731
789
|
.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}
|
|
732
790
|
@keyframes pinflash{50%{transform:translate(-2px,-100%) scale(1.5)}}
|
|
@@ -737,6 +795,8 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
737
795
|
@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)}}
|
|
738
796
|
/* the secondary tools of the detail: inline on wide screens, behind "⋯" on phones */
|
|
739
797
|
.lb__more{display:contents}.lb__moreBtn,.lb__grab{display:none}
|
|
798
|
+
/* the explorer from the step detail: phones only (the top bar with the scenario picker is under the detail there) */
|
|
799
|
+
#lbRuns{display:none}@media (max-width:820px){#lbRuns{display:inline-flex}}
|
|
740
800
|
/* phones: the preview gets the height; the step header and tabs are a bottom sheet that starts collapsed */
|
|
741
801
|
@media (max-width:700px){
|
|
742
802
|
.lb{padding:0}
|
|
@@ -751,8 +811,12 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
751
811
|
.lb__scroll{padding:10px}
|
|
752
812
|
.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}
|
|
753
813
|
.lb[data-sheet="half"] .lb__side{height:58%;overflow:auto}
|
|
754
|
-
.lb[data-sheet="full"] .lb__side{height:calc(100% -
|
|
755
|
-
.
|
|
814
|
+
.lb[data-sheet="full"] .lb__side{height:calc(100% - var(--sheet-gap));overflow:auto}
|
|
815
|
+
.lb[data-sheet="half"] .lb__stage{bottom:58%}.lb[data-sheet="full"] .lb__stage{bottom:calc(100% - var(--sheet-gap))}
|
|
816
|
+
.lb__box{--sheet-gap:140px}
|
|
817
|
+
.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}
|
|
818
|
+
/* the sheet clips what lies outside it: the ring of the handle goes inside, all the way round */
|
|
819
|
+
.lb__grab:focus-visible{outline-offset:-3px}
|
|
756
820
|
.lb__grab::before{content:"";display:block;width:36px;height:4px;border-radius:2px;background:var(--line);margin:6px auto 0}
|
|
757
821
|
.lb__head{padding:0 14px 8px;gap:4px;top:16px;touch-action:none;cursor:pointer}
|
|
758
822
|
.lb__head .kind{display:none}.lb__head .lb__linkTxt{display:none}.lb__head #lbLink{padding:5px 8px}
|
|
@@ -770,7 +834,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
770
834
|
@media (max-width:359px){.lb__tools #lbFocus span{display:none}}
|
|
771
835
|
@media (max-width:700px) and (prefers-reduced-motion:no-preference){.lb__side{transition:height .2s ease}.lb__side.is-drag{transition:none}}
|
|
772
836
|
/* focus: only this step's capture or live page, full viewport, with a small floating bar that hides when idle */
|
|
773
|
-
.lb.is-focus{padding:0;background:#111110}
|
|
837
|
+
.lb.is-focus{padding:0;background:#111110;--focus:#ffe452}
|
|
774
838
|
.lb.is-focus .lb__box{display:block;position:relative;height:100%;border-radius:0;background:#111110;box-shadow:none}
|
|
775
839
|
.lb.is-focus .lb__side,.lb.is-focus .lb__tools{display:none}
|
|
776
840
|
.lb.is-focus .lb__stage{position:absolute;inset:0;border:0;background:#111110}
|
|
@@ -780,7 +844,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
780
844
|
.lb.is-focus .lb__img--m,.lb.is-focus .lb__img--dev{border:0;border-radius:0;box-shadow:none}
|
|
781
845
|
.lb.is-focus .lb__live{border:0;border-radius:0;box-shadow:none}
|
|
782
846
|
.lb.is-focus .lb__win{border-radius:0}
|
|
783
|
-
.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)}
|
|
847
|
+
.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)}
|
|
784
848
|
.lb__fbar .icon-btn{width:34px;height:34px}
|
|
785
849
|
.lb__fpos{font-size:12.5px;color:var(--muted);min-width:44px;text-align:center;font-variant-numeric:tabular-nums}
|
|
786
850
|
.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}
|
|
@@ -879,7 +943,8 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
879
943
|
</style>
|
|
880
944
|
</head>
|
|
881
945
|
<body>
|
|
882
|
-
<
|
|
946
|
+
<a class="skip" id="skipLink" href="#main" data-t="skipMain">Skip to content</a>
|
|
947
|
+
<div class="app" id="app" data-boot>
|
|
883
948
|
<header class="bar">
|
|
884
949
|
<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>
|
|
885
950
|
<a class="brand" id="brand" href="#" style="text-decoration:none;color:inherit">Kaloko</a>
|
|
@@ -887,7 +952,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
887
952
|
<span class="bar-break" aria-hidden="true"></span>
|
|
888
953
|
<button class="picker" id="picker" aria-label="Browse scenarios and runs">
|
|
889
954
|
<span class="kchip" id="pkKind">task</span>
|
|
890
|
-
<span class="task"><b id="taskTitle"></b><span id="runMeta"></span></span>
|
|
955
|
+
<span class="task"><b id="taskTitle"></b><i id="liveTag" class="livetag" hidden></i><span id="runMeta"></span></span>
|
|
891
956
|
<kbd>K</kbd>
|
|
892
957
|
</button>
|
|
893
958
|
<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>
|
|
@@ -917,7 +982,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
917
982
|
<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>
|
|
918
983
|
</header>
|
|
919
984
|
<main class="main" id="main">
|
|
920
|
-
<aside class="side" id="side">
|
|
985
|
+
<aside class="side" id="side" aria-label="Run and steps" data-t-label="sideLabel">
|
|
921
986
|
<h2><span data-t="shownRun"></span> <button class="btn" id="allRuns" data-t="allRuns"></button></h2>
|
|
922
987
|
<div class="runcard" id="runcard"></div>
|
|
923
988
|
<h2 data-t="steps"></h2>
|
|
@@ -942,22 +1007,22 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
942
1007
|
</div>
|
|
943
1008
|
</section>
|
|
944
1009
|
<section class="ri" id="ri" hidden aria-label="Index"></section>
|
|
945
|
-
<aside class="rside" id="rside"></aside>
|
|
1010
|
+
<aside class="rside" id="rside" aria-label="Summary" data-t-label="rsideLabel"></aside>
|
|
946
1011
|
</main>
|
|
947
1012
|
</div>
|
|
948
|
-
<div class="si" id="si" hidden role="dialog" aria-modal="true"
|
|
949
|
-
<div class="lb" id="lb" hidden role="dialog" aria-modal="true"
|
|
1013
|
+
<div class="si" id="si" hidden role="dialog" aria-modal="true"></div>
|
|
1014
|
+
<div class="lb" id="lb" hidden role="dialog" aria-modal="true"></div>
|
|
950
1015
|
<div class="br" id="br" hidden role="dialog" aria-modal="true">
|
|
951
1016
|
<div class="br__box">
|
|
952
1017
|
<div class="br__top">
|
|
953
1018
|
<input type="search" id="brSearch" autocomplete="off">
|
|
954
1019
|
<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>
|
|
955
1020
|
</div>
|
|
956
|
-
<div class="br__col">
|
|
1021
|
+
<div class="br__col" id="brScCol">
|
|
957
1022
|
<div class="br__filters"><div class="seg" id="brKind"></div><select id="brProject" aria-label="Project"></select><span class="note" id="brCount"></span></div>
|
|
958
1023
|
<div class="br__list" id="brList"></div>
|
|
959
1024
|
</div>
|
|
960
|
-
<div class="br__col">
|
|
1025
|
+
<div class="br__col" id="brRunCol">
|
|
961
1026
|
<div class="br__filters" id="brRunFilters"></div>
|
|
962
1027
|
<div class="runs-wrap" id="brRuns"></div>
|
|
963
1028
|
</div>
|
|
@@ -989,7 +1054,7 @@ const I18N = {
|
|
|
989
1054
|
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}`,
|
|
990
1055
|
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í',
|
|
991
1056
|
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í',
|
|
992
|
-
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',
|
|
1057
|
+
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',
|
|
993
1058
|
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',
|
|
994
1059
|
summary:'Souhrnný report', stepsDone:'kroků splněno', criteriaWord:'kritérií', failed:'Co neprošlo', allPassed:'Všechna prověřená kritéria prošla.',
|
|
995
1060
|
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.',
|
|
@@ -997,9 +1062,9 @@ const I18N = {
|
|
|
997
1062
|
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',
|
|
998
1063
|
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í.',
|
|
999
1064
|
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',
|
|
1000
|
-
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.',
|
|
1065
|
+
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',
|
|
1001
1066
|
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',
|
|
1002
|
-
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',
|
|
1067
|
+
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',
|
|
1003
1068
|
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í',
|
|
1004
1069
|
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' },
|
|
1005
1070
|
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',
|
|
@@ -1015,7 +1080,7 @@ const I18N = {
|
|
|
1015
1080
|
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}`,
|
|
1016
1081
|
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',
|
|
1017
1082
|
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',
|
|
1018
|
-
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',
|
|
1083
|
+
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',
|
|
1019
1084
|
execution:'Step execution', step:'Step', of:'of', hover:'Click a criterion to highlight it on the page or in the metadata', fullPage:'full page',
|
|
1020
1085
|
summary:'Summary report', stepsDone:'steps passed', criteriaWord:'criteria', failed:'What failed', allPassed:'All checked criteria passed.',
|
|
1021
1086
|
locales:'Locales', changes:'Changes since the previous run', noChanges:'No changes.', noPrev:'No older run of this scenario.',
|
|
@@ -1023,9 +1088,9 @@ const I18N = {
|
|
|
1023
1088
|
allAbove:'All questions above threshold', review:'needs review', notEvaluated:'not evaluated', search:'Search scenario, task, path…', all:'All', tasks:'Tasks', flows:'Flows',
|
|
1024
1089
|
runs:'runs', last:'last', time:'Time', result:'Result', noRuns:'No run matches the filters.', noScenario:'No scenario matches.',
|
|
1025
1090
|
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',
|
|
1026
|
-
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.',
|
|
1091
|
+
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',
|
|
1027
1092
|
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',
|
|
1028
|
-
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',
|
|
1093
|
+
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',
|
|
1029
1094
|
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',
|
|
1030
1095
|
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' }
|
|
1031
1096
|
};
|
|
@@ -1035,18 +1100,31 @@ Object.assign(I18N.en, { dim_browser:'Browser', dim_color_scheme:'Colour scheme'
|
|
|
1035
1100
|
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' });
|
|
1036
1101
|
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' });
|
|
1037
1102
|
const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
|
|
1038
|
-
|
|
1103
|
+
// #lang=cs|en in a link forces the language of the canvas for that page (a shared link, a walk in another locale):
|
|
1104
|
+
// it wins over the account, the cookie and the browser, and stays in the address while the canvas rewrites it
|
|
1105
|
+
const HASH_LANG = (() => { const l = new URLSearchParams(location.hash.slice(1)).get('lang'); return l === 'cs' || l === 'en' ? l : null; })();
|
|
1106
|
+
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'; })();
|
|
1039
1107
|
function switchLang (l) {
|
|
1040
|
-
|
|
1108
|
+
// a language forced by the link (#lang=) gives way to the one picked here
|
|
1109
|
+
const h = new URLSearchParams(location.hash.slice(1)); h.delete('lang'); const hash = h.toString() ? `#${h}` : '';
|
|
1110
|
+
if (window.QAWALK_APP) { location.href = `${location.pathname}?lang=${l}${hash}`; return; }
|
|
1041
1111
|
try { localStorage.setItem('qawalk.lang', l); } catch {}
|
|
1112
|
+
if (hash !== location.hash) { history.replaceState(null, '', location.pathname + location.search + hash); }
|
|
1042
1113
|
location.reload();
|
|
1043
1114
|
}
|
|
1044
1115
|
// recordings and traces of steps (batch D, #12, #23)
|
|
1045
1116
|
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' });
|
|
1046
1117
|
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' });
|
|
1047
1118
|
const T = (k) => I18N[UI][k] ?? I18N.en[k] ?? k;
|
|
1119
|
+
Object.assign(I18N.cs, { skipMain:'Přeskočit na obsah', sideLabel:'Běh a kroky', rsideLabel:'Shrnutí', rpSideLabel:'Krok', rvSideLabel:'Rozhodnutí o kroku' });
|
|
1120
|
+
Object.assign(I18N.en, { skipMain:'Skip to content', sideLabel:'Run and steps', rsideLabel:'Summary', rpSideLabel:'Step', rvSideLabel:'Decision on the step' });
|
|
1121
|
+
// the scenario and run explorer on a phone
|
|
1122
|
+
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' });
|
|
1123
|
+
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' });
|
|
1048
1124
|
// "n pages" with the right plural (cs: 1 stránka, 2–4 stránky, 5+ stránek; en: 1 page, n pages)
|
|
1049
1125
|
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') };
|
|
1126
|
+
// "n runs" (cs: 1 běh, 2–4 běhy, 5+ běhů)
|
|
1127
|
+
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') };
|
|
1050
1128
|
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` };
|
|
1051
1129
|
const ICON = {
|
|
1052
1130
|
comment:'<svg class="i" viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></svg>',
|
|
@@ -1260,6 +1338,10 @@ addEventListener('resize', () => {
|
|
|
1260
1338
|
if ((S.compactBar || tight || S.unwide) && RUN) { S.compactBar = false; if (S.unwide) { S.unwide = false; S.placeBar?.(); } renderChrome(); renderPresence(); }
|
|
1261
1339
|
});
|
|
1262
1340
|
PHONE_MQ.addEventListener('change', () => { if (RUN) { renderChrome(); renderPresence(); } });
|
|
1341
|
+
// a step opened on its comments: when the window turns into the phone layout (a rotated tablet), the sheet opens on them again
|
|
1342
|
+
matchMedia('(max-width:700px)').addEventListener('change', (e) => {
|
|
1343
|
+
if (e.matches && RUN && S.wantComments && S.open === S.wantComments.step && !$('lb').hidden) setTimeout(() => revealStepComments(S.wantComments.thread), 50);
|
|
1344
|
+
});
|
|
1263
1345
|
/** Words for a key's dimensions ("WebKit (Safari) · Dark"), defaults left out. */
|
|
1264
1346
|
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(' · '); };
|
|
1265
1347
|
const dVp = () => viewports().find((v) => !v.mobile);
|
|
@@ -1386,7 +1468,7 @@ function commentsBlock ({ scope, nodeId = '', pinnable = false, compact = false
|
|
|
1386
1468
|
const pins = scope === 'step' ? pinNumbers(nodeId) : new Map();
|
|
1387
1469
|
const lab = { approved: T('approved'), rejected: T('rejected') };
|
|
1388
1470
|
const item = (r, root) => C.editing === r.id ? composer({ scope, nodeId, edit: r }) : `<div class="c-item" data-cid="${esc(r.id)}">
|
|
1389
|
-
<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('
|
|
1471
|
+
<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>
|
|
1390
1472
|
${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>` : ''}
|
|
1391
1473
|
${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>
|
|
1392
1474
|
<div class="c-note">${r.deleted ? `<i class="muted">${T('commentDeleted')}</i>` : noteHtml(r.note)}</div>${!r.deleted && !isDraft() ? filesHtml(scope, r.attachments) : ''}
|
|
@@ -1530,12 +1612,35 @@ function openThread (nodeId, threadId) {
|
|
|
1530
1612
|
if (nodeId.includes('~') && !byId(nodeId)) { setHash({ step: nodeId }); applyHash(); } else openLb(nodeId, S.lbVp);
|
|
1531
1613
|
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); }));
|
|
1532
1614
|
}
|
|
1615
|
+
/** A comment link (#step=<id>&comments=step, or &thread=<id>): the step's comments in view. On a phone the detail
|
|
1616
|
+
opens as a collapsed sheet over the preview, so the sheet opens halfway and scrolls to the comments. */
|
|
1617
|
+
function revealStepComments (threadId) {
|
|
1618
|
+
if (S.focus) return;
|
|
1619
|
+
// remembered for this step: a later switch into the phone layout (a rotated tablet) opens the sheet on them again
|
|
1620
|
+
S.wantComments = { step: S.open, thread: threadId || null };
|
|
1621
|
+
if (threadId) { C.focus = threadId; C.showResolved = C.showResolved || !runOpenThreads().some((r) => r.id === threadId); rerenderComments(); }
|
|
1622
|
+
if (S.lbTab && S.lbTab !== 'overview') showLbTab('overview');
|
|
1623
|
+
if (lbPhone() && S.lbSheet === 'peek') setSheet('half');
|
|
1624
|
+
const show = () => {
|
|
1625
|
+
const lb = $('lb'), side = $('lbSide');
|
|
1626
|
+
const el = (threadId && lb.querySelector(`.c-thread[data-thread="${CSS.escape(threadId)}"]`)) || lb.querySelector('#c-step'); if (!el) return;
|
|
1627
|
+
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' });
|
|
1628
|
+
};
|
|
1629
|
+
show(); requestAnimationFrame(() => requestAnimationFrame(show));
|
|
1630
|
+
// the step's captures and comments render after the first pass (images, the comments request): bring the comments
|
|
1631
|
+
// into view again as the content settles, unless the person has scrolled the sheet meanwhile
|
|
1632
|
+
const side = $('lbSide'); let moved = false
|
|
1633
|
+
const mark = () => { moved = true }
|
|
1634
|
+
side?.addEventListener('wheel', mark, { once: true, passive: true }); side?.addEventListener('touchstart', mark, { once: true, passive: true })
|
|
1635
|
+
for (const ms of [300, 800, 1600]) setTimeout(() => { if (!moved && !$('lb').hidden) show() }, ms)
|
|
1636
|
+
if (threadId) setTimeout(() => { C.focus = null; }, 1800);
|
|
1637
|
+
}
|
|
1533
1638
|
/** Key C in the lightbox (or its comment button): the composer of the step, in view and focused. */
|
|
1534
1639
|
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 }); }
|
|
1535
1640
|
/** #comments=all|run|flow: the right panel on the comments. */
|
|
1536
1641
|
function showCommentsFromHash (which) {
|
|
1537
1642
|
S.cTab = ['all', 'run', 'flow'].includes(which) ? which : 'all';
|
|
1538
|
-
S.right = true; setPanels();
|
|
1643
|
+
S.right = true; S.lastPanel = 'right'; setPanels();
|
|
1539
1644
|
requestAnimationFrame(() => $('commentsPanel')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
1540
1645
|
}
|
|
1541
1646
|
|
|
@@ -2007,7 +2112,7 @@ async function openStack (id, opts = {}) {
|
|
|
2007
2112
|
<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>
|
|
2008
2113
|
<button class="icon-btn" id="siDir" aria-label="${esc(T('sortDir'))}">${st.dir === 'asc' ? '↑' : '↓'}</button>
|
|
2009
2114
|
</div>
|
|
2010
|
-
<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>
|
|
2115
|
+
<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>
|
|
2011
2116
|
<div class="si__foot"><span id="siCount"></span><span class="spacer" style="flex:1"></span><span>${T('stackKeys')}</span></div>
|
|
2012
2117
|
</div>`;
|
|
2013
2118
|
el.hidden = false;
|
|
@@ -2040,24 +2145,46 @@ function paintStack () {
|
|
|
2040
2145
|
const list = st.filtered; const head = st.view === 'grid' ? 0 : $('siHead').offsetHeight;
|
|
2041
2146
|
$('siCount').textContent = `${list.length} / ${data.items.length} ${pagesN(data.items.length)}`;
|
|
2042
2147
|
const img = (it) => { const t = stackThumb(it); return t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'; };
|
|
2043
|
-
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('
|
|
2148
|
+
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>`;
|
|
2044
2149
|
if (!list.length) { $('siSpacer').style.height = '0px'; $('siRows').innerHTML = `<div class="si__empty">${T('noPagesMatch')}</div>`; return; }
|
|
2045
2150
|
let html = '';
|
|
2046
2151
|
if (st.view === 'grid') {
|
|
2047
2152
|
const per = Math.max(1, Math.floor((box.clientWidth - 24) / CARD_W)); const rows = Math.ceil(list.length / per);
|
|
2048
2153
|
$('siSpacer').style.height = `${rows * CARD_H + 16}px`;
|
|
2049
2154
|
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);
|
|
2050
|
-
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>`; }
|
|
2155
|
+
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>`; }
|
|
2051
2156
|
} else {
|
|
2052
2157
|
$('siSpacer').style.height = `${list.length * ROW_H}px`;
|
|
2053
2158
|
const top = Math.max(0, box.scrollTop - head);
|
|
2054
2159
|
const k0 = Math.max(0, Math.floor(top / ROW_H) - 8), k1 = Math.min(list.length, Math.ceil((top + box.clientHeight) / ROW_H) + 8);
|
|
2055
2160
|
for (let k = k0; k < k1; k++) {
|
|
2056
2161
|
const it = list[k];
|
|
2057
|
-
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>`;
|
|
2162
|
+
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>`;
|
|
2058
2163
|
}
|
|
2059
2164
|
}
|
|
2165
|
+
const had = $('siRows').contains(document.activeElement);
|
|
2060
2166
|
$('siRows').innerHTML = html;
|
|
2167
|
+
roveRows($('siRows'), 'data-idx', st.sel, had);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* The run index and a stack folder are one tab stop each: the selected row (or the first drawn one) has tabindex 0,
|
|
2171
|
+
* the arrow keys move the selection and Tab leaves the list. The rows are drawn in a window and redrawn on scroll,
|
|
2172
|
+
* so a focus inside the list follows the selection into the new rows.
|
|
2173
|
+
*/
|
|
2174
|
+
function roveRows (box, attr, sel, hadFocus) {
|
|
2175
|
+
const rows = [...box.querySelectorAll(`[${attr}]`)];
|
|
2176
|
+
const cur = rows.find((r) => Number(r.getAttribute(attr)) === sel) || rows[0];
|
|
2177
|
+
for (const r of rows) { r.tabIndex = r === cur ? 0 : -1; r.setAttribute('aria-selected', String(Number(r.getAttribute(attr)) === sel)); }
|
|
2178
|
+
if (hadFocus && cur && Number(cur.getAttribute(attr)) === sel) cur.focus({ preventScroll: true });
|
|
2179
|
+
if (box.dataset.rove) return;
|
|
2180
|
+
// a row focused by a click or a tap becomes the selection
|
|
2181
|
+
box.dataset.rove = '1';
|
|
2182
|
+
box.addEventListener('focusin', (e) => {
|
|
2183
|
+
const b = e.target.closest(`[${attr}]`); if (!b) return;
|
|
2184
|
+
const k = Number(b.getAttribute(attr)); const state = attr === 'data-k' ? S.ri : S.st; if (!state) return;
|
|
2185
|
+
state.sel = k;
|
|
2186
|
+
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)); }
|
|
2187
|
+
});
|
|
2061
2188
|
}
|
|
2062
2189
|
function openStackItem (k) {
|
|
2063
2190
|
const st = S.st; const n = byId(st.id); const data = STACKS.get(st.id); const it = st.filtered[k]; if (!it) return;
|
|
@@ -2143,7 +2270,7 @@ function riRender (opts = {}) {
|
|
|
2143
2270
|
<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>
|
|
2144
2271
|
<button class="icon-btn" id="riDir" aria-label="${esc(T('sortDir'))}">${r.dir === 'asc' ? '↑' : '↓'}</button>
|
|
2145
2272
|
</div>
|
|
2146
|
-
<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>
|
|
2273
|
+
<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>
|
|
2147
2274
|
<div class="si__foot"><span id="riCount"></span><span class="spacer" style="flex:1"></span><span>${T('riKeys')}</span></div>
|
|
2148
2275
|
</div>`;
|
|
2149
2276
|
$('riSort').value = r.sort;
|
|
@@ -2174,14 +2301,16 @@ function riPaint () {
|
|
|
2174
2301
|
if (tops[k] + h < top - 400 || tops[k] > bottom + 400) continue;
|
|
2175
2302
|
html += riRow(out[k], k, tops[k]);
|
|
2176
2303
|
}
|
|
2304
|
+
const had = $('riRows').contains(document.activeElement);
|
|
2177
2305
|
$('riRows').innerHTML = html;
|
|
2306
|
+
roveRows($('riRows'), 'data-k', S.ri.sel, had);
|
|
2178
2307
|
}
|
|
2179
2308
|
function riRow (e, k, top) {
|
|
2180
2309
|
const sel = k === S.ri.sel ? 'is-sel' : '';
|
|
2181
2310
|
if (e.type === 'group') return `<div class="ri-group" style="top:${top}px">${esc(e.label)} <small>${e.pass}/${e.total}</small></div>`;
|
|
2182
2311
|
if (e.type === 'page') {
|
|
2183
2312
|
const it = e.it; const t = stackThumb(it);
|
|
2184
|
-
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>`;
|
|
2313
|
+
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>`;
|
|
2185
2314
|
}
|
|
2186
2315
|
const n = e.n; const s = nodeSt(n); const crit = n.criteria || [];
|
|
2187
2316
|
const ok = crit.filter((c) => critSt(c) === 'pass').length;
|
|
@@ -2193,7 +2322,7 @@ function riRow (e, k, top) {
|
|
|
2193
2322
|
const cons = Object.values(n.captures?.[S.loc] || {}).reduce((k, c) => k + (c?.console_errors || 0), 0);
|
|
2194
2323
|
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('');
|
|
2195
2324
|
const crits = n.kind === 'stack' ? `${esc(n.stack?.summary?.pass || 0)}/${esc(n.stack?.count || 0)}` : s === 'skip' ? '—' : `${ok}/${crit.length}`;
|
|
2196
|
-
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>`;
|
|
2325
|
+
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>`;
|
|
2197
2326
|
}
|
|
2198
2327
|
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(); }
|
|
2199
2328
|
function riOpen (k) {
|
|
@@ -2432,7 +2561,14 @@ const bar5 = (c) => { const tot = (c.pass || 0) + (c.partial || 0) + (c.fail ||
|
|
|
2432
2561
|
// The issue the run belongs to (from the scenario id, the branch or scenario `issue:`), linked when its URL is known.
|
|
2433
2562
|
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>` : ''}` };
|
|
2434
2563
|
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>`;
|
|
2435
|
-
function setPanels () {
|
|
2564
|
+
function setPanels () {
|
|
2565
|
+
// on a narrow screen the panels are drawers over each other: one at a time, the one opened last
|
|
2566
|
+
if (narrowPanels() && S.left && S.right) { if (S.lastPanel === 'right') S.left = false; else S.right = false; }
|
|
2567
|
+
$('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(); }
|
|
2568
|
+
/** Narrow screens: the side panels are drawers over the canvas. While one is open the canvas under it is inert
|
|
2569
|
+
(keyboard focus never lands behind a drawer) and a tap on the uncovered strip closes the drawers. */
|
|
2570
|
+
const narrowPanels = () => matchMedia('(max-width:1100px)').matches;
|
|
2571
|
+
function drawerInert () { const on = narrowPanels() && (S.left || S.right); $('vp').inert = on; $('ri').inert = on; }
|
|
2436
2572
|
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`; }
|
|
2437
2573
|
function renderChrome () {
|
|
2438
2574
|
const app = window.QAWALK_APP;
|
|
@@ -2441,6 +2577,7 @@ function renderChrome () {
|
|
|
2441
2577
|
+ `<span class="langsw">${['en', 'cs'].map((l) => `<button type="button" data-lang="${l}" aria-pressed="${l === UI}">${l.toUpperCase()}</button>`).join('')}</span>`;
|
|
2442
2578
|
$('appNav').querySelectorAll('[data-lang]').forEach((b) => { b.onclick = () => { if (b.dataset.lang !== UI) switchLang(b.dataset.lang); }; });
|
|
2443
2579
|
document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
|
|
2580
|
+
document.querySelectorAll('[data-t-label]').forEach((el) => { el.setAttribute('aria-label', T(el.dataset.tLabel)); });
|
|
2444
2581
|
$('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
|
|
2445
2582
|
$('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>`;
|
|
2446
2583
|
$('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', rvHintItem()); // review mode (Q)
|
|
@@ -2472,6 +2609,8 @@ function renderChrome () {
|
|
|
2472
2609
|
const g = RUN.git || {};
|
|
2473
2610
|
$('pkKind').textContent = RUN.scenario.kind || 'task'; $('pkKind').className = `kchip ${RUN.scenario.kind === 'flow' ? 'kchip--flow' : ''}`;
|
|
2474
2611
|
$('taskTitle').textContent = `${RUN.issue ? `${RUN.issue.repo ? RUN.issue.repo + '#' : '#'}${RUN.issue.number} · ` : ''}${RUN.scenario.title}`;
|
|
2612
|
+
// a live draft says so in the bar on a phone too, where the meta line under the title is hidden
|
|
2613
|
+
$('liveTag').hidden = !(isDesign() && isDraft()); $('liveTag').textContent = T('liveShared');
|
|
2475
2614
|
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(' · ');
|
|
2476
2615
|
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(' · ');
|
|
2477
2616
|
document.title = `${RUN.scenario.id} · Kaloko`;
|
|
@@ -2910,7 +3049,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2910
3049
|
const lb = $('lb');
|
|
2911
3050
|
const html = `<div class="lb__box">
|
|
2912
3051
|
<div class="lb__stage">
|
|
2913
|
-
<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>
|
|
3052
|
+
<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>
|
|
2914
3053
|
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2915
3054
|
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2916
3055
|
if (S.lbMode === 'video' && recOf(n, cap, v) && !S.cmp) return videoHtml(recOf(n, cap, v), v);
|
|
@@ -2965,7 +3104,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2965
3104
|
${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>` : ''}
|
|
2966
3105
|
${traceSection(cap)}
|
|
2967
3106
|
${consoleSection(cap)}
|
|
2968
|
-
<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('
|
|
3107
|
+
<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>
|
|
2969
3108
|
</div>
|
|
2970
3109
|
</div></div>`;
|
|
2971
3110
|
// a live frame keeps running when only the side panel changes (new comment, status, draft event)
|
|
@@ -2989,6 +3128,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2989
3128
|
// a tool chosen in the "⋯" menu closes it, so the preview is free for the click that follows (Inspect, Comment on element)
|
|
2990
3129
|
$('lbMore').onclick = (e) => { if (e.target.closest('button')) setTimeout(closeLbMore, 0); };
|
|
2991
3130
|
if ($('lbBack')) $('lbBack').onclick = () => { closeLb(); openStack(n.stack, { focus: n.itemId }); };
|
|
3131
|
+
$('lbRuns').onclick = () => openBrowser({ level: 'runs' });
|
|
2992
3132
|
if ($('lbComments')) $('lbComments').onclick = () => focusComposer();
|
|
2993
3133
|
placeCommentPins($('lbImg'), n, cap, v);
|
|
2994
3134
|
$('lbLink').onclick = async () => { try { await navigator.clipboard.writeText(location.href); toast(T('linkCopied')); } catch { toast(location.href); } };
|
|
@@ -3099,6 +3239,7 @@ function lbSheetFit () {
|
|
|
3099
3239
|
const line = h3 ? parseFloat(getComputedStyle(h3).lineHeight) || 20 : 20;
|
|
3100
3240
|
box.style.setProperty('--peek', `calc(${Math.max(98, Math.ceil(16 + (row?.offsetHeight || 32) + 4 + line + 10))}px + env(safe-area-inset-bottom))`);
|
|
3101
3241
|
side.style.setProperty('--lbhead', `${head.offsetHeight}px`);
|
|
3242
|
+
fitLive(); // the stage ends where the sheet starts
|
|
3102
3243
|
}
|
|
3103
3244
|
const LB_RO = typeof ResizeObserver === 'function' ? new ResizeObserver(() => lbSheetFit()) : null;
|
|
3104
3245
|
/** The phone bottom sheet of the detail: peek (title and status), half, full. */
|
|
@@ -3106,6 +3247,7 @@ function setSheet (st) {
|
|
|
3106
3247
|
S.lbSheet = st; $('lb').dataset.sheet = st;
|
|
3107
3248
|
$('lbGrab')?.setAttribute('aria-expanded', String(st !== 'peek'));
|
|
3108
3249
|
if (st === 'peek' && $('lbSide')) $('lbSide').scrollTop = 0;
|
|
3250
|
+
fitLive();
|
|
3109
3251
|
}
|
|
3110
3252
|
/** Focus: only this step's capture or live page, full viewport. Previous / next and the switches keep it on;
|
|
3111
3253
|
the prototype (P) is a separate presentation and returns here when it ends. */
|
|
@@ -3198,7 +3340,7 @@ function wireLbOnce (lb) {
|
|
|
3198
3340
|
if (!lbPhone() || S.focus || e.button > 0) return;
|
|
3199
3341
|
const h = e.target.closest('#lbGrab, .lb__head'); if (!h || e.target.closest('button,a,input,select,textarea,label')) return;
|
|
3200
3342
|
const side = $('lbSide'), box = side.parentElement.getBoundingClientRect(), stage = lb.querySelector('.lb__stage').getBoundingClientRect();
|
|
3201
|
-
const peekH = box.bottom - stage.bottom, fullH = box.height -
|
|
3343
|
+
const peekH = box.bottom - stage.bottom, fullH = box.height - (parseFloat(getComputedStyle(side.parentElement).getPropertyValue('--sheet-gap')) || 140), halfH = box.height * 0.58;
|
|
3202
3344
|
const y0 = e.clientY, h0 = side.getBoundingClientRect().height; let moved = false;
|
|
3203
3345
|
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`; };
|
|
3204
3346
|
const up = (ev) => {
|
|
@@ -3210,12 +3352,45 @@ function wireLbOnce (lb) {
|
|
|
3210
3352
|
};
|
|
3211
3353
|
addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
|
|
3212
3354
|
});
|
|
3355
|
+
// an open sheet: a tap on the preview above it lowers the sheet again (pins, drawings and Dev mode keep their taps)
|
|
3356
|
+
lb.addEventListener('click', (e) => {
|
|
3357
|
+
if (!lbPhone() || S.focus || (S.lbSheet || 'peek') === 'peek' || !e.target.closest('.lb__scroll')) return;
|
|
3358
|
+
if (C.pinMode || S.ignDraw || DEV.on || LIVE.mode || e.target.closest('button,a,input,select,textarea,label,.pinmark,.is-pinning,.is-drawing')) return;
|
|
3359
|
+
setSheet('peek');
|
|
3360
|
+
});
|
|
3213
3361
|
lb.addEventListener('keydown', (e) => { if (e.target.id === 'lbGrab' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setSheet(S.lbSheet === 'peek' ? 'half' : 'peek'); } });
|
|
3214
3362
|
// tabs and the comment composer need the sheet open
|
|
3215
3363
|
lb.addEventListener('focusin', (e) => { if (lbPhone() && !S.focus && S.lbSheet === 'peek' && e.target.closest('.lbtabs, .lbtab')) setSheet('half'); });
|
|
3216
3364
|
}
|
|
3365
|
+
/* Modal overlays (step detail, stack, browser, replay, prototype, review, grid): while one is open the canvas
|
|
3366
|
+
behind it is inert, so Tab stays in the overlay and nothing hidden behind it takes focus. Each dialog is named
|
|
3367
|
+
by its own title while that title is on screen (a reference to a missing id names nothing). */
|
|
3368
|
+
const MODALS = ['si', 'lb', 'br', 'rp', 'pt', 'rv', 'gv'];
|
|
3369
|
+
const MODAL_TITLES = { si: 'siTitle', lb: 'lbTitle' };
|
|
3370
|
+
const openModal = () => MODALS.map((id) => $(id)).find((el) => el && !el.hidden) || null;
|
|
3371
|
+
function syncModals () {
|
|
3372
|
+
for (const [id, title] of Object.entries(MODAL_TITLES)) {
|
|
3373
|
+
const el = $(id), t = document.getElementById(title); if (!el) continue;
|
|
3374
|
+
if (!el.hidden && t && el.contains(t)) el.setAttribute('aria-labelledby', title); else el.removeAttribute('aria-labelledby');
|
|
3375
|
+
}
|
|
3376
|
+
const app = $('app'); if (app) app.inert = !!openModal();
|
|
3377
|
+
}
|
|
3378
|
+
function modalA11y () {
|
|
3379
|
+
const mo = new MutationObserver(syncModals);
|
|
3380
|
+
for (const id of MODALS) { const el = $(id); if (el) mo.observe(el, { attributes: true, attributeFilter: ['hidden'], childList: true, subtree: true }); }
|
|
3381
|
+
syncModals();
|
|
3382
|
+
// the skip link: past the top bar and the side panel to the canvas or the index, or into the open overlay
|
|
3383
|
+
$('skipLink').onclick = (e) => {
|
|
3384
|
+
e.preventDefault();
|
|
3385
|
+
const dlg = openModal();
|
|
3386
|
+
const t = dlg ? dlg.querySelector('h3[id], button, [href], input, select, textarea') : S.mode === 'index' ? $('ri') : $('vp');
|
|
3387
|
+
if (!t) return;
|
|
3388
|
+
if (!t.matches('button, [href], input, select, textarea') && !t.hasAttribute('tabindex')) t.tabIndex = -1;
|
|
3389
|
+
t.focus();
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3217
3392
|
function setHash (p) {
|
|
3218
|
-
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null }).filter(([, x]) => x));
|
|
3393
|
+
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null, lang: HASH_LANG }).filter(([, x]) => x));
|
|
3219
3394
|
const h = Object.keys(all).length ? '#' + new URLSearchParams(all).toString() : ' ';
|
|
3220
3395
|
history.replaceState(null, '', h === ' ' ? location.pathname + location.search : h);
|
|
3221
3396
|
}
|
|
@@ -3226,12 +3401,14 @@ function applyHash () {
|
|
|
3226
3401
|
if (RV.open) { RV.open = false; $('rv').hidden = true; }
|
|
3227
3402
|
if (p.get('grid') && byId(p.get('grid'))) { if (!GV.open || GV.id !== p.get('grid')) gvOpen(p.get('grid')); return; }
|
|
3228
3403
|
if (GV.open) { GV.open = false; $('gv').hidden = true; $('gv').innerHTML = ''; }
|
|
3404
|
+
// #loc=<locale> picks the locale of the run on every view (map, index, replay, comments), not only a step's
|
|
3405
|
+
if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); }
|
|
3229
3406
|
if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
|
|
3230
3407
|
const cmp = p.get('cmp');
|
|
3231
3408
|
if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
|
|
3232
3409
|
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; }
|
|
3233
3410
|
if (S.rp) rpClose();
|
|
3234
|
-
if (p.get('comments')) showCommentsFromHash(p.get('comments'));
|
|
3411
|
+
if (p.get('comments') && !p.get('step')) showCommentsFromHash(p.get('comments'));
|
|
3235
3412
|
// a shared link straight into the prototype: #present=<step>&loc=…&vp=…
|
|
3236
3413
|
const pres = p.get('present');
|
|
3237
3414
|
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; }
|
|
@@ -3243,6 +3420,7 @@ function applyHash () {
|
|
|
3243
3420
|
S.pendingAc = p.get('ac');
|
|
3244
3421
|
S.focus = p.get('focus') === '1'; // #step=<id>&focus=1 opens the step's preview alone
|
|
3245
3422
|
openLb(step, p.get('vp'));
|
|
3423
|
+
if (p.get('comments') === 'step' || p.get('thread')) revealStepComments(p.get('thread'));
|
|
3246
3424
|
}
|
|
3247
3425
|
|
|
3248
3426
|
/* ---------- replay: guided walkthrough with branch choice ---------- */
|
|
@@ -3287,7 +3465,7 @@ function renderReplay () {
|
|
|
3287
3465
|
<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>
|
|
3288
3466
|
${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>
|
|
3289
3467
|
<div class="rp__body"><div class="rp__stage" id="rpStage">${stage}</div>
|
|
3290
|
-
<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>
|
|
3468
|
+
<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>
|
|
3291
3469
|
${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
3292
3470
|
${n.kind !== 'decision' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}
|
|
3293
3471
|
${n.kind !== 'decision' && n.execution ? `<div class="sec"><h4>${T('execution')}</h4><p class="instr">${esc(n.execution.instructions || '')}</p></div>` : ''}</aside></div>
|
|
@@ -3428,8 +3606,9 @@ function runsOf (scId) {
|
|
|
3428
3606
|
/* The project picked in the lists is remembered per organization (cookie qw_p_<org>, set by the pages too). */
|
|
3429
3607
|
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 ''; } }
|
|
3430
3608
|
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 {} }
|
|
3431
|
-
function renderBrowser () {
|
|
3609
|
+
function renderBrowser (opts = {}) {
|
|
3432
3610
|
const q = S.brQ.trim().toLowerCase();
|
|
3611
|
+
const had = document.activeElement?.closest?.('#brList [data-sc]')?.dataset.sc; // the catalog may arrive while a scenario has the focus
|
|
3433
3612
|
const projects = [...new Set(catalog().map((s) => s.project || '').filter(Boolean))].sort();
|
|
3434
3613
|
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)));
|
|
3435
3614
|
seg($('brKind'), [['all', T('all')], ['task', T('tasks')], ['flow', T('flows')]], S.brKind, (v) => { S.brKind = v; renderBrowser(); });
|
|
@@ -3437,11 +3616,13 @@ function renderBrowser () {
|
|
|
3437
3616
|
bp.innerHTML = `<option value="">${T('projectAll')}</option>${projects.map((p) => `<option value="${esc(p)}" ${p === S.brProject ? 'selected' : ''}>${esc(p)}</option>`).join('')}`;
|
|
3438
3617
|
bp.onchange = () => { S.brProject = bp.value; rememberProject(bp.value); renderBrowser(); };
|
|
3439
3618
|
$('brCount').textContent = `${list.length} / ${catalog().length}`;
|
|
3440
|
-
$('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} ${
|
|
3441
|
-
|
|
3619
|
+
$('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>`;
|
|
3620
|
+
if (had) [...$('brList').querySelectorAll('[data-sc]')].find((b) => b.dataset.sc === had)?.focus({ preventScroll: true });
|
|
3621
|
+
if (opts.runs !== false) renderBrRuns();
|
|
3442
3622
|
}
|
|
3443
3623
|
function renderBrRuns () {
|
|
3444
3624
|
const rs = runsOf(S.brSc);
|
|
3625
|
+
if (phoneBar()) { renderBrCards(rs); return; }
|
|
3445
3626
|
$('brRunFilters').innerHTML = `<b style="font-size:13px">${esc(S.brSc)}</b>`;
|
|
3446
3627
|
if (!rs.length) { $('brRuns').innerHTML = `<p class="empty">${T('noRuns')}</p>`; return; }
|
|
3447
3628
|
let lastDay = null; const rows = [];
|
|
@@ -3453,20 +3634,83 @@ function renderBrRuns () {
|
|
|
3453
3634
|
}
|
|
3454
3635
|
$('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>`;
|
|
3455
3636
|
}
|
|
3456
|
-
|
|
3637
|
+
/** Phones: the runs of one scenario as cards under a header with a way back to the scenarios. */
|
|
3638
|
+
function renderBrCards (rs) {
|
|
3639
|
+
const sc = catalog().find((x) => x.id === S.brSc) || { id: S.brSc, title: '' };
|
|
3640
|
+
const back = document.activeElement?.id === 'brBack';
|
|
3641
|
+
$('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>`;
|
|
3642
|
+
if (back) $('brBack').focus({ preventScroll: true });
|
|
3643
|
+
if (!rs.length) { $('brRuns').innerHTML = `<p class="empty">${T('noRuns')}</p>`; return; }
|
|
3644
|
+
// compare: the other runs of the scenario on screen (the compare list of the menu)
|
|
3645
|
+
const cmpIds = S.brSc === RUN.scenario.id && !isDesign() ? new Set(cmpCandidates().map((c) => c.id)) : new Set();
|
|
3646
|
+
let lastDay = null; const out = [];
|
|
3647
|
+
for (const r of rs) {
|
|
3648
|
+
const d = new Date(r.started_at); const day = d.toDateString();
|
|
3649
|
+
if (day !== lastDay) { lastDay = day; out.push(`<h4 class="rday">${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()}</h4>`); }
|
|
3650
|
+
const c = r.summary || {}; const tot = (c.pass || 0) + (c.partial || 0) + (c.fail || 0) + (c.skip || 0);
|
|
3651
|
+
const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
3652
|
+
const cur = r.id === RUN.id;
|
|
3653
|
+
const verdict = cur ? RUN.verdict?.verdict || r.verdict : r.verdict;
|
|
3654
|
+
const base = r.baseline || (S.brSc === RUN.scenario.id && r.id === RUN.baseline_id);
|
|
3655
|
+
const g = r.git || {};
|
|
3656
|
+
const ref = g.branch || g.commit ? `${esc(g.branch || '')}${g.branch && g.commit ? ' · ' : ''}${esc(g.commit || '')}` : '';
|
|
3657
|
+
const on = S.cmp?.id === r.id;
|
|
3658
|
+
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>`);
|
|
3659
|
+
}
|
|
3660
|
+
$('brRuns').innerHTML = `<div class="rcards">${out.join('')}</div>`;
|
|
3661
|
+
}
|
|
3662
|
+
/** Which level of the explorer a phone shows: the scenarios ('sc') or the runs of one ('runs'). */
|
|
3663
|
+
function brLevel (level) {
|
|
3664
|
+
S.brLevel = level; $('br').dataset.level = level;
|
|
3665
|
+
renderBrowser();
|
|
3666
|
+
if (!phoneBar()) return;
|
|
3667
|
+
if (level === 'runs') {
|
|
3668
|
+
$('brRuns').scrollTop = 0;
|
|
3669
|
+
$('brRuns').querySelector('.rcard[aria-current="true"]')?.scrollIntoView({ block: 'center' });
|
|
3670
|
+
$('brBack')?.focus({ preventScroll: true });
|
|
3671
|
+
} else {
|
|
3672
|
+
const sel = $('brList').querySelector('.sc[aria-selected="true"]');
|
|
3673
|
+
if (sel) { sel.scrollIntoView({ block: 'nearest' }); sel.focus({ preventScroll: true }); }
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
/** Opens a run; the open step stays open when the run has it (same scenario). */
|
|
3677
|
+
function goRun (id) {
|
|
3678
|
+
const href = runHref(id); if (href === '#') return;
|
|
3679
|
+
const keep = S.open && !$('lb').hidden && S.brSc === RUN.scenario.id;
|
|
3680
|
+
location.href = keep ? `${href}#${new URLSearchParams(Object.entries({ step: S.open, loc: S.loc, vp: S.lbVp }).filter(([, v]) => v)).toString()}` : href;
|
|
3681
|
+
}
|
|
3682
|
+
function openBrowser (opts = {}) {
|
|
3457
3683
|
S.brProject = rememberedProject(); // the project picked in the lists applies here too
|
|
3458
|
-
|
|
3684
|
+
if (!$('br').contains(document.activeElement)) S.brFrom = document.activeElement;
|
|
3685
|
+
S.brSc = RUN.scenario.id; $('br').hidden = false; $('brSearch').value = S.brQ;
|
|
3686
|
+
brLevel(opts.level || 'sc');
|
|
3687
|
+
// on a phone the keyboard would cover half of the list: the search field waits for a tap
|
|
3688
|
+
if (!phoneBar() || !matchMedia('(pointer:coarse)').matches) $('brSearch').focus();
|
|
3459
3689
|
// on the service the catalog of scenarios comes when the browser opens (the run itself loads without it)
|
|
3460
3690
|
const a = APP();
|
|
3461
|
-
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; }); }
|
|
3691
|
+
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; }); }
|
|
3692
|
+
}
|
|
3693
|
+
function closeBrowser () {
|
|
3694
|
+
if ($('br').hidden) return;
|
|
3695
|
+
$('br').hidden = true;
|
|
3696
|
+
const from = S.brFrom; S.brFrom = null;
|
|
3697
|
+
if (from?.isConnected && from.getClientRects().length) from.focus({ preventScroll: true });
|
|
3462
3698
|
}
|
|
3463
|
-
function closeBrowser () { $('br').hidden = true; }
|
|
3464
3699
|
function wireBrowser () {
|
|
3465
|
-
$('picker').onclick = openBrowser; $('allRuns').onclick = openBrowser; $('brClose').onclick = closeBrowser;
|
|
3466
|
-
$('br').addEventListener('click', (e) => { if (e.target === $('br')) closeBrowser(); });
|
|
3700
|
+
$('picker').onclick = () => openBrowser(); $('allRuns').onclick = () => openBrowser(); $('brClose').onclick = closeBrowser;
|
|
3701
|
+
$('br').addEventListener('click', (e) => { if (e.target === $('br') || e.target.closest('[data-br-close]')) closeBrowser(); else if (e.target.closest('#brBack')) brLevel('sc'); });
|
|
3467
3702
|
$('brSearch').addEventListener('input', (e) => { S.brQ = e.target.value; renderBrowser(); });
|
|
3468
|
-
$('brList').addEventListener('click', (e) => { const b = e.target.closest('[data-sc]'); if (!b) return; S.brSc = b.dataset.sc; renderBrowser(); });
|
|
3469
|
-
$('brRuns').addEventListener('click', (e) => {
|
|
3703
|
+
$('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(); });
|
|
3704
|
+
$('brRuns').addEventListener('click', (e) => {
|
|
3705
|
+
if (e.target.closest('a')) return;
|
|
3706
|
+
const cmp = e.target.closest('[data-cmp]');
|
|
3707
|
+
if (cmp) { const id = S.cmp?.id === cmp.dataset.cmp ? '' : cmp.dataset.cmp; closeBrowser(); loadCmp(id); return; }
|
|
3708
|
+
const tr = e.target.closest('tr[data-run], button[data-run]'); if (!tr) return;
|
|
3709
|
+
if (tr.dataset.run === RUN.id) { if (phoneBar()) closeBrowser(); return; }
|
|
3710
|
+
goRun(tr.dataset.run);
|
|
3711
|
+
});
|
|
3712
|
+
// turning the phone (or resizing the window) across the phone width redraws the open explorer in its layout
|
|
3713
|
+
PHONE_MQ.addEventListener?.('change', () => { if (!$('br').hidden) brLevel(phoneBar() ? S.brLevel || 'sc' : 'sc'); });
|
|
3470
3714
|
}
|
|
3471
3715
|
|
|
3472
3716
|
/* ---------- design: drafts, versions, live steps, multiplayer (#42) ---------- */
|
|
@@ -3600,9 +3844,12 @@ function fitLive () {
|
|
|
3600
3844
|
const w = parseInt(f.style.width, 10);
|
|
3601
3845
|
const cs = getComputedStyle(host);
|
|
3602
3846
|
const focus = S.focus && host.id === 'lbScroll' && !$('lb').hidden;
|
|
3847
|
+
// on a phone the detail's preview fills the room between the tools and the sheet; the page scrolls inside
|
|
3848
|
+
const phone = !focus && host.id === 'lbScroll' && !$('lb').hidden && lbPhone();
|
|
3603
3849
|
const k = Math.min(1, (host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - (focus ? 0 : 2)) / w);
|
|
3604
3850
|
// in focus the live page fills the screen: its real width, scaled down to fit, and as tall as the screen allows
|
|
3605
|
-
const
|
|
3851
|
+
const room = host.clientHeight - (phone ? parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + 2 : 0);
|
|
3852
|
+
const h = focus || phone ? Math.max(1, Math.floor(room / k)) : parseInt(f.dataset.h, 10);
|
|
3606
3853
|
f.style.height = `${h}px`;
|
|
3607
3854
|
f.style.transformOrigin = '0 0'; f.style.transform = `scale(${k})`;
|
|
3608
3855
|
box.style.width = `${Math.floor(w * k)}px`; box.style.height = `${Math.floor(h * k)}px`;
|
|
@@ -4690,7 +4937,7 @@ function rvRender () {
|
|
|
4690
4937
|
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>`;
|
|
4691
4938
|
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>` : '';
|
|
4692
4939
|
box.innerHTML = top + `<div class="rvm__body"><div class="rvm__stage" id="rvStage">${stage}</div>
|
|
4693
|
-
<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>
|
|
4940
|
+
<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>
|
|
4694
4941
|
<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>
|
|
4695
4942
|
${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}
|
|
4696
4943
|
${n.kind !== 'stack' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}</aside></div>${rvFootHtml(it)}`;
|
|
@@ -4951,8 +5198,10 @@ load().then((data) => {
|
|
|
4951
5198
|
// the panels a person opens or closes are remembered (per screen class: wide or not)
|
|
4952
5199
|
const panelKey = () => `qawalk-panels-${innerWidth >= 1600 ? 'wide' : 'narrow'}`;
|
|
4953
5200
|
const savePanels = () => { try { localStorage.setItem(panelKey(), JSON.stringify({ left: S.left, right: S.right })); } catch {} };
|
|
4954
|
-
$('togLeft').onclick = () => { S.left = !S.left; setPanels(); savePanels(); };
|
|
4955
|
-
$('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
|
|
5201
|
+
$('togLeft').onclick = () => { S.left = !S.left; if (S.left) S.lastPanel = 'left'; setPanels(); savePanels(); };
|
|
5202
|
+
$('togRight').onclick = () => { S.right = !S.right; if (S.right) S.lastPanel = 'right'; setPanels(); savePanels(); };
|
|
5203
|
+
// a window narrowed to a phone with both panels open keeps only the one opened last (drawers would overlap)
|
|
5204
|
+
matchMedia('(max-width:1100px)').addEventListener?.('change', () => { if (narrowPanels() && S.left && S.right) setPanels(); });
|
|
4956
5205
|
$('rpBtn').onclick = () => rpOpen(null);
|
|
4957
5206
|
if (RUN.walkthrough?.file && !RUN.recordings_expired) { const w = $('wtBtn'); w.hidden = false; w.href = asset(RUN.walkthrough.file); w.title = T('walkthroughTitle'); }
|
|
4958
5207
|
wireRecPreviews($('nodes'));
|
|
@@ -5022,9 +5271,15 @@ load().then((data) => {
|
|
|
5022
5271
|
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 {} }
|
|
5023
5272
|
if (window.QAWALK_APP?.embed) document.body.classList.add('embed');
|
|
5024
5273
|
{ 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'; }
|
|
5274
|
+
// drawers start under the top bar, so the bar (and its panel toggles) stays in view and in reach
|
|
5275
|
+
{ const bar = document.querySelector('.bar'); const setBarH = () => $('app').style.setProperty('--barh', `${bar.offsetHeight}px`); setBarH(); if (typeof ResizeObserver === 'function') new ResizeObserver(setBarH).observe(bar); }
|
|
5276
|
+
$('main').addEventListener('click', (e) => { if (e.target === $('main') && narrowPanels() && (S.left || S.right)) { S.left = S.right = false; setPanels(); } });
|
|
5277
|
+
addEventListener('resize', drawerInert);
|
|
5025
5278
|
setPanels(); renderAll(); applyMode();
|
|
5279
|
+
$('app').removeAttribute('data-boot');
|
|
5026
5280
|
requestAnimationFrame(() => requestAnimationFrame(() => { startView(); applyHash(); presenceStart(); }));
|
|
5027
5281
|
addEventListener('hashchange', applyHash);
|
|
5282
|
+
modalA11y();
|
|
5028
5283
|
}).catch((error) => { window.Sentry?.captureException(error); document.body.innerHTML = `<p style="padding:24px;font-family:sans-serif">Kaloko: ${esc(error.message)}</p>`; });
|
|
5029
5284
|
</script>
|
|
5030
5285
|
</body>
|