kaloko 0.4.7 → 0.4.9
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 +41 -2
- package/package.json +1 -1
- package/schemas/config.v1.json +26 -0
- package/schemas/scenario.v1.json +32 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -7
- package/skills/kaloko/reference/acceptance.md +34 -2
- package/skills/kaloko/reference/collaboration.md +45 -2
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/evaluation.md +51 -3
- package/skills/kaloko/reference/execution.md +18 -0
- package/skills/kaloko/reference/first-run.md +51 -0
- package/skills/kaloko/reference/mcp.md +8 -1
- package/src/cli.mjs +93 -9
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +94 -12
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +33 -0
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/review.mjs +69 -0
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +889 -46
package/viewer/index.html
CHANGED
|
@@ -249,6 +249,8 @@ body.embed .helpwrap{display:none}
|
|
|
249
249
|
.bar-break{display:none}
|
|
250
250
|
@media (max-width:820px){.bar{flex-wrap:wrap;gap:6px 8px;padding:8px 10px}.bar > *{order:1}.bar-break{display:block;order:2;flex-basis:100%;height:0}.bar > .picker,#rpBtn,#ptBtn,#modeSeg,#locSeg,#dbar{order:3}.bar > .picker{flex:1 1 0;min-width:0;max-width:none}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}.bar .spacer{flex:1 1 auto;order:1}.picker .kchip{display:none}}
|
|
251
251
|
.legend-in{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}.legend-in span{display:inline-flex;gap:5px;align-items:center}
|
|
252
|
+
/* phones: a run with many locales scrolls its locale switch sideways instead of widening the page */
|
|
253
|
+
@media (max-width:820px){.bar .seg{max-width:100%;min-width:0;overflow-x:auto;scrollbar-width:none}.bar .seg::-webkit-scrollbar{display:none}.bar .seg button{flex:none}}
|
|
252
254
|
@media (max-width:1100px){.bar .ctl .seg-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}#viewBtn span[data-t],#rpBtn span,#ptBtn span{display:none}.picker kbd{display:none}.bar > .picker{max-width:clamp(140px,40vw,420px)}}
|
|
253
255
|
/* canvas */
|
|
254
256
|
.viewport{position:relative;overflow:hidden;cursor:grab;touch-action:none;
|
|
@@ -293,10 +295,12 @@ body.embed .helpwrap{display:none}
|
|
|
293
295
|
.node--stack .kind{background:var(--ink);color:var(--panel)}
|
|
294
296
|
.stack-fan{position:relative;display:block;width:100%;height:162px;padding:0;border:0;background:none;cursor:pointer}
|
|
295
297
|
.stack-fan img,.stack-fan .ph{position:absolute;width:58%;height:128px;object-fit:cover;object-position:top;border:1px solid var(--line);border-radius:4px;background:var(--panel-2);box-shadow:0 2px 6px rgba(0,0,0,.12)}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
/* fan positions apply to thumbnails only: with one or two samples the count badge used to be the 2nd or 3rd child and
|
|
299
|
+
got left/top on top of its right/bottom, stretching it over the whole card */
|
|
300
|
+
.stack-fan>:is(img,.ph):nth-child(1){left:5%;top:22px;transform:rotate(-5deg)}.stack-fan>:is(img,.ph):nth-child(2){left:21%;top:12px;transform:rotate(-1deg)}.stack-fan>:is(img,.ph):nth-child(3){left:37%;top:20px;transform:rotate(4deg)}
|
|
301
|
+
.stack-fan .count{position:absolute;right:10px;bottom:6px;left:auto;top:auto;transform:none;z-index:1;line-height:1.4;background:var(--ink);color:var(--panel);font-weight:700;font-size:12px;border-radius:999px;padding:2px 9px}
|
|
298
302
|
.stack-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;margin:6px 10px 2px;background:var(--panel-2)}.stack-bar i{display:block}
|
|
299
|
-
.ri{grid-column:2;min-width:0;min-height:0;display:grid;background:var(--panel)}.ri[hidden]{display:none}
|
|
303
|
+
.ri{grid-column:2;min-width:0;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);background:var(--panel)}.ri[hidden]{display:none}
|
|
300
304
|
.ri .si__box{border-radius:0;box-shadow:none}
|
|
301
305
|
.ri-crumbs{display:flex;gap:6px;align-items:center;min-width:0;font-size:13px;color:var(--muted)}.ri-crumbs button{all:unset;cursor:pointer;color:var(--ink);text-decoration:underline;text-underline-offset:3px}.ri-crumbs b{color:var(--ink);font-size:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
302
306
|
.ri-group{position:absolute;left:0;right:0;height:36px;display:flex;align-items:center;gap:10px;padding:0 18px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);background:var(--panel-2);border-bottom:1px solid var(--line)}.ri-group small{text-transform:none;letter-spacing:0}
|
|
@@ -304,12 +308,11 @@ body.embed .helpwrap{display:none}
|
|
|
304
308
|
.ri-row.is-page{padding-left:42px}.ri-row.is-page .t b{font-weight:500}
|
|
305
309
|
.ri-row .t b .kind{display:inline-grid;vertical-align:-3px;margin-right:6px}
|
|
306
310
|
.ri-row .num{font-family:var(--mono,monospace);font-size:12.5px;text-align:right}
|
|
307
|
-
.ri-fan{position:relative;width:96px;height:58px}.ri-fan img,.ri-fan .ph{position:absolute;width:84px!important;height:50px!important}.ri-fan>:nth-child(1){left:0;top:8px;z-index:3}.ri-fan>:nth-child(2){left:6px;top:4px;z-index:2}.ri-fan>:nth-child(3){left:12px;top:0;z-index:1}
|
|
311
|
+
.ri-fan{position:relative;width:96px;height:58px}.ri-fan img,.ri-fan .ph{position:absolute;width:84px!important;height:50px!important}.ri-fan>:is(img,.ph):nth-child(1){left:0;top:8px;z-index:3}.ri-fan>:is(img,.ph):nth-child(2){left:6px;top:4px;z-index:2}.ri-fan>:is(img,.ph):nth-child(3){left:12px;top:0;z-index:1}
|
|
308
312
|
.ri-fan .cnt{position:absolute;right:0;bottom:0;z-index:4;font-size:10.5px;font-weight:700;background:var(--ink);color:var(--panel);border-radius:4px;padding:0 5px}
|
|
309
313
|
.ri-x{display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:12px;color:var(--muted)}
|
|
310
|
-
|
|
311
|
-
.
|
|
312
|
-
.si__box{background:var(--panel);border-radius:10px;display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
|
314
|
+
.si{position:fixed;inset:0;z-index:19;background:rgba(12,12,11,.62);display:grid;grid-template-columns:minmax(0,1fr);padding:clamp(0px,2vw,24px)}
|
|
315
|
+
.si__box{background:var(--panel);border-radius:10px;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto 1fr auto;min-height:0;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
|
313
316
|
.si__head{display:flex;gap:12px;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line)}.si__head h3{margin:0;font-size:18px}
|
|
314
317
|
.si__tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 18px;border-bottom:1px solid var(--line)}
|
|
315
318
|
.si__tools input[type=search]{flex:1 1 240px;min-width:180px;padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font:inherit}
|
|
@@ -326,6 +329,31 @@ body.embed .helpwrap{display:none}
|
|
|
326
329
|
.si-card img,.si-card .ph{width:100%;height:120px}.si-card b{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.si-card small{color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
327
330
|
.si__foot{display:flex;gap:10px;align-items:center;padding:8px 18px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}
|
|
328
331
|
.si__empty{padding:40px;text-align:center;color:var(--muted)}
|
|
332
|
+
.si__title{flex:1;min-width:0}
|
|
333
|
+
/* phones: placed after the base rules above, which used to win and keep the 96 px thumbnail over the title.
|
|
334
|
+
Row: thumbnail | title and path; under them the status and the criteria that need attention */
|
|
335
|
+
@media (max-width:760px){
|
|
336
|
+
.si{padding:0;grid-template-columns:minmax(0,1fr)}.si__box{border-radius:0}
|
|
337
|
+
.si__head{flex-wrap:wrap;gap:8px 10px;padding:10px 12px 10px 14px}
|
|
338
|
+
.si__title{flex:1 1 calc(100% - 84px)}
|
|
339
|
+
.si__head h3,.ri-crumbs b{font-size:17px;line-height:1.25;overflow-wrap:anywhere}.ri-crumbs b{white-space:normal}
|
|
340
|
+
.si__head .seg,#siCsv,#riCsv{order:2}#siClose{order:1;align-self:flex-start;width:32px;height:32px}
|
|
341
|
+
.si__tools{padding:8px 14px;gap:6px 8px}
|
|
342
|
+
.si__tools input[type=search]{flex:1 1 100%;min-width:0;font-size:16px}
|
|
343
|
+
.si__tools select{flex:1 1 150px;min-width:0;max-width:100%}
|
|
344
|
+
.si__foot{padding:8px 14px}.si__foot>:last-child{display:none}
|
|
345
|
+
.ri-row,.ri-head,.si-row,.si-headrow{grid-template-columns:64px max-content minmax(0,1fr)!important;column-gap:10px;padding-left:14px;padding-right:14px}
|
|
346
|
+
.ri-row.is-page{padding-left:28px}
|
|
347
|
+
.ri-head>:nth-child(n+3),.si-headrow>:nth-child(n+3){display:none}.ri-head>:nth-child(2),.si-headrow>:nth-child(2){grid-column:2/4}
|
|
348
|
+
.ri-row>:nth-child(n+4),.si-row>:nth-child(n+4){display:none}
|
|
349
|
+
.ri-row,.si-row{grid-template-rows:auto auto;row-gap:4px;align-content:center}
|
|
350
|
+
.ri-row>:first-child,.si-row>:first-child{grid-row:1/3;grid-column:1}
|
|
351
|
+
.ri-row>.t,.si-row>.t{grid-column:2/4;grid-row:1}
|
|
352
|
+
.ri-row>.badge,.si-row>.badge{grid-column:2;grid-row:2;justify-self:start}
|
|
353
|
+
.ri-row>.si-fails,.si-row>.si-fails{display:flex!important;grid-column:3;grid-row:2;overflow:hidden;flex-wrap:nowrap;min-width:0}
|
|
354
|
+
.ri-row img,.ri-row .ph,.si-row img,.si-row .ph{width:64px;height:40px}
|
|
355
|
+
.ri-fan{width:64px;height:40px}.ri-fan img,.ri-fan .ph{width:54px!important;height:32px!important}
|
|
356
|
+
}
|
|
329
357
|
.node__head{display:flex;align-items:flex-start;gap:8px;padding:9px 10px 8px}
|
|
330
358
|
.kind{width:22px;height:22px;display:grid;place-items:center;border-radius:5px;background:var(--panel-2);color:var(--muted);flex:none}
|
|
331
359
|
.node--email .kind{background:var(--accent);color:var(--accent-ink)}
|
|
@@ -385,7 +413,7 @@ body.embed .helpwrap{display:none}
|
|
|
385
413
|
.lb__head{padding:16px 18px 12px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px;position:sticky;top:0;background:var(--panel);z-index:1}
|
|
386
414
|
.lb__head h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;text-wrap:balance}
|
|
387
415
|
.lb__row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
388
|
-
.lb__nav{display:flex;gap:6px;margin-left:auto}
|
|
416
|
+
.lb__nav{display:flex;gap:6px;margin-left:auto}.lb__meta{display:contents}
|
|
389
417
|
.sec{padding:14px 18px;border-bottom:1px solid var(--line)}
|
|
390
418
|
.sec h4{margin:0 0 8px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:6px}
|
|
391
419
|
.purpose{margin:0;max-width:41.3em}
|
|
@@ -401,7 +429,7 @@ body.embed .helpwrap{display:none}
|
|
|
401
429
|
.k-err{color:#ff7a85;font-weight:600}.k-url{color:#8ab4ff}.k-pos{color:#ffd166}.k-str{color:#9ee493}.k-at{color:#8d8d86}.k-num{color:#ffd166}
|
|
402
430
|
.vhead{display:flex;align-items:center;gap:8px}.vhead .badge{text-transform:none;letter-spacing:0}.vmore{display:flex;flex-wrap:wrap;gap:4px 16px;margin-top:10px;font-size:12.5px}.vmore .linkish{color:var(--muted)}.vmore .linkish:hover{color:var(--ink)}
|
|
403
431
|
/* how a criterion was checked: kind, meaning, AI score between the thresholds */
|
|
404
|
-
.how{grid-column:2;display:grid;gap:4px;margin-top:4px;font-size:12.5px}.how__kind{justify-self:start;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:1px 7px}.how--ai{color:var(--ink);border-color:var(--ink)}.how__q{color:var(--ink)}.how__line{color:var(--muted)}
|
|
432
|
+
.how{grid-column:2;display:grid;gap:4px;margin-top:4px;font-size:12.5px}.how__kind{justify-self:start;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:1px 7px}.how--ai{color:var(--ink);border-color:var(--ink)}.how__q{color:var(--ink)}.how__line{color:var(--muted)}.how__why{color:var(--ink)}.how__why b{font-weight:600;margin-right:4px}
|
|
405
433
|
.gauge{position:relative;display:block;height:8px;border-radius:4px;background:var(--skip-bg);overflow:visible;margin:2px 0}.gauge__lo{position:absolute;left:0;top:0;bottom:0;background:var(--fail-bg);border-radius:4px 0 0 4px}.gauge__hi{position:absolute;right:0;top:0;bottom:0;background:var(--pass-bg);border-radius:0 4px 4px 0}.gauge b{position:absolute;top:-3px;width:3px;height:14px;margin-left:-1.5px;background:var(--ink);border-radius:2px}
|
|
406
434
|
.ac li .tech{grid-column:2;font-size:11.5px}.ac li .tech summary{cursor:pointer;color:var(--muted)}
|
|
407
435
|
.ac{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
|
|
@@ -442,6 +470,12 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
442
470
|
#treeSec summary{cursor:pointer;list-style:none}#treeSec summary h4{display:inline}
|
|
443
471
|
.hlbox{position:absolute;outline:3px solid #ffd200;background:rgba(255,228,82,.22);pointer-events:none;border-radius:2px}
|
|
444
472
|
.hlbox[hidden]{display:none}
|
|
473
|
+
/* regions left out of pixel diffs, regions that moved between shots (#20) and tab stops of the keyboard walk (#19) */
|
|
474
|
+
.ignbox{position:absolute;border:1.5px dashed #6b6b66;background:repeating-linear-gradient(45deg,rgba(107,107,102,.16) 0 6px,transparent 6px 12px);pointer-events:none;z-index:4}
|
|
475
|
+
.ignbox.unst{border-color:#d9822b;background:repeating-linear-gradient(45deg,rgba(217,130,43,.14) 0 6px,transparent 6px 12px)}
|
|
476
|
+
.kbstop{position:absolute;outline:2px solid #2f6fde;pointer-events:none;z-index:4}.kbstop.bad{outline-color:#e4572e}
|
|
477
|
+
.kbstop i{position:absolute;left:-2px;top:-18px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#2f6fde;color:#fff;font:600 10px/16px system-ui,sans-serif;font-style:normal;text-align:center}.kbstop.bad i{background:#e4572e}
|
|
478
|
+
.kb-list{margin:6px 0 0;padding-left:20px;font-size:12.5px;color:var(--muted)}.kb-list .bad{color:var(--ink)}
|
|
445
479
|
.hlbox.on{outline:3px solid #ffd200;background:transparent}
|
|
446
480
|
.hlbox.on.first{box-shadow:0 0 0 9999px rgba(15,15,14,.42)}
|
|
447
481
|
.elabel{z-index:4;white-space:normal;max-width:180px;text-align:center;line-height:1.25;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
@@ -570,6 +604,27 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
570
604
|
@media (max-width:700px){.pt__title{max-width:60vw}.pt__bar{padding:6px 10px}.pt__stage{padding:6px}}
|
|
571
605
|
@media (prefers-reduced-motion:no-preference){.pt__dev{transition:transform .15s ease-out}}
|
|
572
606
|
.insp{font:12px/1.4 var(--mono)}
|
|
607
|
+
.dev h5{margin:14px 0 4px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}
|
|
608
|
+
.dev table{width:100%;border-collapse:collapse;font:12px/1.45 var(--mono)}.dev td{padding:4px;border-bottom:1px solid var(--line);vertical-align:top}.dev td:first-child{color:var(--muted);white-space:nowrap;width:1%}.dev td:last-child{width:1%}
|
|
609
|
+
.dev tr.devchg td{background:color-mix(in srgb,var(--accent) 30%,transparent)}
|
|
610
|
+
.devtabs{margin:0 0 10px}.devhead{margin:0 0 8px}.devhead b{font-size:14px}
|
|
611
|
+
.devval{display:inline-flex;align-items:center;gap:6px;overflow-wrap:anywhere}
|
|
612
|
+
.devsw{display:inline-block;width:14px;height:14px;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2);flex:none;vertical-align:-2px;margin-right:4px}
|
|
613
|
+
.devtokrow{margin-top:2px;display:flex;flex-wrap:wrap;gap:4px;align-items:center}
|
|
614
|
+
.devtok{all:unset;cursor:pointer;color:#2e6bff;font:inherit;border-radius:3px;padding:0 2px}.devtok:hover,.devtok:focus-visible{background:color-mix(in srgb,#2e6bff 14%,transparent);outline:none}
|
|
615
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .devtok{color:#8fb0ff}}:root[data-theme="dark"] .devtok{color:#8fb0ff}
|
|
616
|
+
.devmode{font:600 10px/16px system-ui,sans-serif;padding:0 5px;border-radius:4px;background:var(--panel-2);color:var(--muted)}
|
|
617
|
+
.devwarn{color:var(--fail-ink);font-weight:600}.devok{color:var(--pass-ink);font-weight:600}.devmuted{color:var(--muted)}
|
|
618
|
+
.devline{width:26px;height:26px}.devline .i{width:14px;height:14px}
|
|
619
|
+
.devnav{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 6px}.devnav .btn{padding:4px 8px;font-size:12px}
|
|
620
|
+
.devstates{flex-wrap:wrap}.devstates button[disabled]{opacity:.4;cursor:not-allowed}
|
|
621
|
+
.devdist p{margin:4px 0;font-size:13px}.devdist code{font:12px var(--mono)}
|
|
622
|
+
.devassets{list-style:none;margin:0;padding:0}.devassets li{display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);font-size:12.5px}.devassets img{width:28px;height:28px;object-fit:contain;background:#fff;border-radius:4px;box-shadow:inset 0 0 0 1px var(--line)}.devassets__k{font:600 10px/28px system-ui,sans-serif;width:28px;text-align:center;background:var(--panel-2);border-radius:4px}.devassets__n{flex:1;min-width:0;overflow-wrap:anywhere}
|
|
623
|
+
.devcopy{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0 6px}
|
|
624
|
+
.devtoks{list-style:none;margin:0;padding:0}.devtoks li{border-bottom:1px solid var(--line)}.devtoks button{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;padding:5px 4px;cursor:pointer;font-size:12.5px}.devtoks button:hover,.devtoks button:focus-visible{background:var(--panel-2)}.devtoks button[aria-pressed="true"]{background:color-mix(in srgb,#c026d3 14%,transparent)}
|
|
625
|
+
.devtoks__n{font:12px var(--mono);overflow-wrap:anywhere}.devtoks__c{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums}.devtoks--plain li{padding:5px 4px;font-size:12.5px;overflow-wrap:anywhere}.devunused{margin-top:12px}.devunused summary{cursor:pointer;font-size:12.5px;color:var(--muted)}
|
|
626
|
+
.devshot{position:absolute;z-index:4;cursor:crosshair}.devshot > div{position:absolute;pointer-events:none;box-sizing:border-box}.devshot__hov{outline:1px solid #2e6bff;background:rgba(111,168,220,.25)}.devshot__sel{outline:2px solid #2e6bff}.devshot__tgt{outline:1px dashed #e4572e}.devshot__hl{outline:2px dashed #c026d3;background:rgba(192,38,211,.08)}
|
|
627
|
+
.devshot span{position:absolute;left:0;top:-19px;font:600 11px/18px system-ui,sans-serif;background:#151514;color:#fff;padding:0 6px;border-radius:4px;white-space:nowrap}.devshot__sel span{background:#2e6bff}
|
|
573
628
|
.insp table{width:100%;border-collapse:collapse}.insp td{padding:3px 4px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
574
629
|
.insp td:first-child{color:var(--muted);white-space:nowrap}
|
|
575
630
|
.insp .tok{color:#2e6bff}.insp .raw{color:#c73e1d}
|
|
@@ -615,7 +670,13 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
615
670
|
.lb__head h3{font-size:16px}
|
|
616
671
|
.lb[data-sheet="peek"] .lb__head h3{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
617
672
|
.lb[data-sheet="peek"] .lb__side:not(.is-drag) .lbtabs,.lb[data-sheet="peek"] .lb__side:not(.is-drag) .lbtab{visibility:hidden}
|
|
618
|
-
.
|
|
673
|
+
/* the header row keeps its actions at the top right; status, position and "back to index" wrap on the left.
|
|
674
|
+
The tabs stick under the header (its height is --lbhead), so the two never cover each other */
|
|
675
|
+
.lb__row{flex-wrap:nowrap;align-items:flex-start}
|
|
676
|
+
.lb__meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;flex:1;min-width:0;min-height:32px}
|
|
677
|
+
.lb__meta #lbBack{padding:4px 10px}
|
|
678
|
+
.lb__nav{flex:none;gap:4px}.lb__nav .icon-btn{width:32px;height:32px}
|
|
679
|
+
.lbtabs{top:calc(16px + var(--lbhead,0px))}
|
|
619
680
|
}
|
|
620
681
|
@media (max-width:359px){.lb__tools #lbFocus span{display:none}}
|
|
621
682
|
@media (max-width:700px) and (prefers-reduced-motion:no-preference){.lb__side{transition:height .2s ease}.lb__side.is-drag{transition:none}}
|
|
@@ -637,6 +698,40 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
637
698
|
.lb.is-idle .lb__fbar{opacity:0;pointer-events:none}
|
|
638
699
|
.lb.is-focus.is-idle .lb__fshow{display:grid}
|
|
639
700
|
@media (prefers-reduced-motion:no-preference){.lb__fbar{transition:opacity .2s ease}}
|
|
701
|
+
/* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ---------- */
|
|
702
|
+
.rvm{position:fixed;inset:0;z-index:23;background:var(--ground);display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto 1fr auto}
|
|
703
|
+
/* many locales: the switches scroll sideways instead of widening review mode past the screen */
|
|
704
|
+
.rvm__top .seg{max-width:100%;min-width:0;overflow-x:auto;scrollbar-width:none}.rvm__top .seg::-webkit-scrollbar{display:none}.rvm__top .seg button{flex:none}
|
|
705
|
+
.rvm__foot .note{min-width:0;overflow-wrap:anywhere}
|
|
706
|
+
.rvm__top{display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line-strong)}
|
|
707
|
+
.rvm__top .spacer,.rvm__foot .spacer{flex:1}
|
|
708
|
+
.rvm__prog{font-size:13px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
709
|
+
.rvm__bar{display:block;width:120px;height:6px;border-radius:999px;background:var(--skip-bg);overflow:hidden}
|
|
710
|
+
.rvm__bar i{display:block;height:100%;background:var(--pass)}
|
|
711
|
+
.rvm__body{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,420px);min-height:0}
|
|
712
|
+
.rvm__stage{overflow:auto;padding:24px;background:var(--panel-2);touch-action:pan-y pinch-zoom}
|
|
713
|
+
.rvm__stage > .lb__img,.rvm__stage > .lb__win{margin:0 auto}
|
|
714
|
+
.rvm__stage .lb__cmp{max-width:100%}
|
|
715
|
+
.rvm__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
|
|
716
|
+
.rvm__foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 16px calc(10px + env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)}
|
|
717
|
+
.rvm__foot .btn .kbd,.rvm__top .kbd{margin-left:4px}
|
|
718
|
+
.rvm__why{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
|
|
719
|
+
.rvm__dec{display:grid;gap:6px}
|
|
720
|
+
.rvm__dec p{margin:0}
|
|
721
|
+
.rvm__ret textarea{width:100%;font:inherit;padding:8px;border:1px solid var(--line-strong);border-radius:6px;background:var(--panel);color:var(--ink)}
|
|
722
|
+
.rvm__end{width:min(720px,100%);margin:0 auto;padding:28px 16px 40px;overflow:auto}
|
|
723
|
+
.rvm__end h3{font-size:22px;letter-spacing:-.01em;margin:0 0 8px;text-wrap:balance}
|
|
724
|
+
.rvm__list{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px}
|
|
725
|
+
.rvm__list li{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
726
|
+
.rvm__list button.linkish{text-align:left}
|
|
727
|
+
.rvm__loading{padding:40px;text-align:center;color:var(--muted)}
|
|
728
|
+
.rvm__wrap{overflow:auto;min-height:0}
|
|
729
|
+
#rvBtn .rv-count{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin-left:4px;border-radius:999px;background:var(--ink);color:var(--panel);font-size:11px;font-weight:600}
|
|
730
|
+
.badge.rv-carry{margin-left:auto}
|
|
731
|
+
@media (max-width:900px){.rvm__body{grid-template-columns:1fr;grid-template-rows:minmax(220px,55%) 1fr}.rvm__side{border-left:0;border-top:1px solid var(--line)}.rvm__stage{padding:12px}.rvm__bar{width:72px}.rvm__keys{display:none}}
|
|
732
|
+
@media (max-width:560px){.rvm__foot{gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}.rvm__foot .btn{flex:1 1 auto;justify-content:center;padding:9px 8px}.rvm__foot .spacer{display:none}.rvm__top{padding:8px 10px}.rvm__top .brand small{display:none}}
|
|
733
|
+
@media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
|
|
734
|
+
@media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}@media (max-width:820px){#rvBtn{order:3}}
|
|
640
735
|
</style>
|
|
641
736
|
</head>
|
|
642
737
|
<body>
|
|
@@ -652,6 +747,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
652
747
|
<kbd>K</kbd>
|
|
653
748
|
</button>
|
|
654
749
|
<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>
|
|
750
|
+
<button class="btn" id="rvBtn" title="Q"><svg class="i" viewBox="0 0 24 24"><path d="M4 6h10M4 12h10M4 18h7"/><path d="m15 17 2.5 2.5L22 15"/></svg><span data-t="rvTitle"></span></button>
|
|
655
751
|
<button class="btn" id="ptBtn" title="P" hidden><svg class="i" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="13" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg><span data-t="prototype"></span></button>
|
|
656
752
|
<div class="dbar" id="dbar" hidden></div>
|
|
657
753
|
<span class="cmpchip" id="cmpChip" hidden></span>
|
|
@@ -721,6 +817,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
721
817
|
</div>
|
|
722
818
|
<div class="rp" id="rp" hidden role="dialog" aria-modal="true" aria-label="Replay"></div>
|
|
723
819
|
<div class="pt" id="pt" hidden role="dialog" aria-modal="true" aria-label="Prototype"></div>
|
|
820
|
+
<div class="rvm" id="rv" hidden role="dialog" aria-modal="true" aria-label="Review"></div>
|
|
724
821
|
<div class="toast" id="toast" hidden></div>
|
|
725
822
|
<script>
|
|
726
823
|
/* Kaloko viewer. Data source:
|
|
@@ -730,13 +827,14 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
730
827
|
const EMBEDDED = /*__QAWALK_RUN__*/null;
|
|
731
828
|
if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
|
|
732
829
|
const I18N = {
|
|
733
|
-
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
|
|
830
|
+
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">D</span> Dev mode (v detailu) · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
|
|
734
831
|
pass:'Splněno', partial:'Částečně', fail:'Nesplněno', skip:'Neprověřeno', crop:'výřez', html:'HTML snapshot', email:'e-mail', external:'třetí strana',
|
|
735
832
|
branch:'Větev', commit:'Commit', author:'Autor', pr:'PR', issue:'Issue', env:'Prostředí', duration:'Délka', noPr:'bez PR', open:'otevřený', merged:'sloučený', closed:'zavřený',
|
|
736
833
|
older:'Starší běh', newer:'Novější běh', purpose:'Účel obrazovky ze scénáře', criteria:'Akceptační kritéria', jevTitle:'Sémantické hodnocení',
|
|
737
834
|
jevNote:'Evaluátor dostal zjednodušený stav stránky a ke každé otázce vrátil pravděpodobnost. Verdikt podle prahů a důkazy doplnil runner.', evalLabel:'Hodnocení', metaTitle:'Metadata stránky', mdBasic:'Základ', mdHeadings:'Osnova nadpisů', mdHreflang:'Jazykové verze (hreflang)', mdOg:'Open Graph', mdTwitter:'Twitter / X', mdLd:'Strukturovaná data · JSON-LD', mdMicro:'Strukturovaná data · microdata', mdLinks:'Další odkazy v hlavičce', mdOther:'Ostatní meta', missing:'Chybí', duplicate:'duplicitní', size:'Velikost', runVerdict:'Verdikt běhu', accept:'Přijmout běh', ret:'Vrátit běh', pending:'čeká', accepted:'přijato', returned:'vráceno', baseline:'Referenční běh', setBaseline:'Nastavit jako referenční', unsetBaseline:'Zrušit referenci', verdictNotePh:'Poznámka k verdiktu (u vrácení povinná)', manualPass:'Splněno', manualFail:'Nesplněno', manualNotePh:'Důvod (u nesplněno povinný)', reviewerVerdict:'verdikt kontrolujícího', decideManual:'Rozhodnout ručně', consoleErrors:'Chyby konzole', consoleLoading:'Načítám zprávy…', noConsole:'U tohoto snímku se zprávy konzole neuložily.', consoleMore:(n) => `Dalších ${n} se neuložilo (ukládá se prvních 20).`, blockedTitle:'Zablokované požadavky', copyAll:'Kopírovat vše', copyOne:'Kopírovat tuto zprávu', copied:'Zkopírováno', tabOverview:'Kritéria', tabPage:'Stránka', tabTech:'Technické', toSolve:(n) => `k řešení ${n}`, passedN:(n) => `splněno ${n}`, whyMatters:'Proč na tom záleží:', howFix:'Jak opravit:', whyAndFix:'Proč na tom záleží a jak to opravit', aiShort:(p) => `AI ${p} % ano`, noneFailed:'Nic neselhalo.', toDecide:'Rozhodne člověk', toDecideLead:'AI si nebyla jistá nebo nemohla vyhodnotit; otevřete krok, podívejte se na snímek a rozhodněte.', worthLook:'Stojí za kontrolu', jevScoreHint:'Průměrná jistota AI, že sémantická kritéria této obrazovky jsou splněná', sigTitle:'Rychlé signály', sigLead:'AI se na ně ptá u každé obrazovky spolu s kritérii. Výsledek nemění, jen ukáže obrazovky, na které se vyplatí podívat.', sigPurpose:'Plní účel kroku', sigLanguage:'Text v očekávaném jazyce', sigBroken:'Vypadá rozbitě (chyba, zástupný text, prázdno)', sigWarn:(n) => `Signály k prověření: ${n}`, sigYes:(p) => `${p} % ano`,
|
|
738
835
|
howAuto:'Automatická kontrola', howAi:'Vyhodnocení AI', howManual:'Rozhoduje člověk', howTech:'Technický detail',
|
|
739
836
|
aiPass:(p, t) => `Evaluátor si je na ${p} % jistý, že odpověď je ano; projde od ${t} %.`, aiFail:(p, t) => `Jen ${p} % ano; neprojde při ${t} % a méně.`, aiUnsure:(p, lo, hi) => `Nejisté: ${p} % ano, mezi ${lo} % a ${hi} %. Podívejte se na snímek a rozhodněte níže.`,
|
|
837
|
+
aiWhy:'Proč:', aiSaw:'AI viděla i snímek', kbTitle:'Klávesnice', kbLead:(n, all) => all ? `Tabulátor projde všechny prvky (${n}) a opustí stránku.` : `Zastávky tabulátoru, prvních ${n}.`, kbShow:'Ukázat pořadí na snímku', kbHide:'Skrýt pořadí', kbNone:'bez viditelného fokusu', kbWeak:(c) => `slabý fokus ${c} : 1`, kbHidden:'skrytý prvek', kbCovered:'zakrytý', ignTitle:'Ignorované oblasti', ignLead:(n) => `Oblasti, které se nepočítají do pixelového rozdílu (hodiny, reklamy, karusely): ${n}.`, unstLead:(n) => `Oblasti, které se mezi opakovanými snímky hýbaly: ${n}. Příkaz kaloko stability z nich navrhne ignore.`,
|
|
740
838
|
aiNotEval:'Zatím nevyhodnoceno.', aiVia:{ qawalk:'vyhodnotilo Kaloko', 'own-key':'vyhodnoceno vaším klíčem' }, aiAsked:'Otázka',
|
|
741
839
|
a_exists:(s) => `${s} je na stránce`, a_absent:(s) => `${s} na stránce není`, a_count:(s, mn, mx) => `${s} se vyskytuje ${mn != null && mn === mx ? `přesně ${mn}×` : mn != null && mx != null ? `${mn}–${mx}×` : mn != null ? `aspoň ${mn}×` : mx != null ? `nejvýš ${mx}×` : ''}`, a_text_matches:(s, p) => `${s || 'Stránka'} obsahuje ${p}`, a_text_absent:(s, p) => `${s || 'Stránka'} neobsahuje ${p}`, a_attr_equals:(s, a, v) => `${s} má ${a} = ${v}`,
|
|
742
840
|
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í',
|
|
@@ -751,16 +849,17 @@ const I18N = {
|
|
|
751
849
|
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',
|
|
752
850
|
pagesWord:'stránek', firstScreen:'první obrazovka', layout:'Rozložení', uploadedBy:'Nahrál', addNote:'Přidat poznámku', viewOptions:'Zobrazení', keyComment:'komentář ke kroku (v detailu)', keyHelp:'tato nápověda', helpTitle:'Zkratky a nápověda', flowComments:'Komentáře k flow', runComments:'Komentáře k běhu', commentsTitle:'Komentáře', allOpen:'Otevřené', openWord:'otevřených', flowCommentsLead:'Platí pro celé flow napříč běhy: chybějící krok, obecná připomínka, nápad.', noStepComments:'Ke kroku zatím nikdo nic nenapsal.', noComments:'Zatím žádné komentáře.', showResolved:'Vyřešené', hideResolved:'Skrýt vyřešené', replyAct:'Odpovědět', reopenAct:'Znovu otevřít', editAct:'Upravit', deleteAct:'Smazat', saveAct:'Uložit', cancel:'Zrušit', edited:'upraveno', commentDeleted:'Komentář byl smazán.', viaAgent:'Napsal agent', showPin:'Ukázat na snímku', pinOnShot:'Připnout na snímek', pinClickShot:'Klikněte do snímku', pinnedOnShot:'Připnuto na snímek', replyPh:'Odpověď… (@jméno upozorní)', flowPh:'Komentář k celému flow… (@jméno upozorní)', runPh:'Komentář k celému běhu… (@jméno upozorní)', commentLabel:'Komentář', ctrlEnter:'Ctrl+Enter odešle', confirmDelete:'Smazat komentář?', deletedToast:'Smazáno', openCommentsN:'Nevyřízené komentáře', showAllN:'Zobrazit všech %N%', screenWord:'obrazovka aplikace', uiTree:'UI strom', platformWord:'Platforma', treeHint:'Klikněte na prvek: ukáže ho na snímku.', treeSearch:'Hledat v UI stromu…', treeOcr:'Text rozpoznaný ze snímku (OCR): bez rolí a ovládacích prvků.', treeLoadFail:'UI strom se nepodařilo načíst.', treeEmpty:'Strom je prázdný.', mapView:'Mapa', indexView:'Index', searchSteps:'Hledat v krocích, kritériích a stránkách…', criteriaCol:'Kritéria', notesCol:'Poznámky', noStepsMatch:'Žádný krok neodpovídá filtru.', riKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · I mapa', consoleWord:'chyb v konzoli', loadingPages:'hledám i ve stránkách stacků…', sortTitle:'Název', runWord:'Běh', openIndex:'Otevřít index', pagesNeedAttention:'stránek chce pozornost', allPagesPass:'všechny stránky prošly', stackLoadFail:'Index stránek se nepodařilo načíst', semanticSample:'sémantický vzorek', downloadCsv:'Stáhnout CSV', allWord:'Vše', criterionFilter:'Nesplněné kritérium', anyCriterion:'Jakékoli kritérium', searchPages:'Hledat v titulku, URL a polích…', sortBy:'Řadit podle', sortOrder:'Pořadí', sortStatus:'Stav (nejhorší první)', sortDir:'Směr řazení', pageWord:'Stránka', statusWord:'Stav', failingCriteria:'Neprošlo', stackKeys:'/ hledat · ↑↓ vybrat · Enter otevřít · Esc zavřít', listView:'Seznam', gridView:'Mřížka', noPagesMatch:'Filtru neodpovídá žádná stránka.', backToIndex:'Zpět na index',
|
|
753
851
|
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',
|
|
754
|
-
downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', publicRun:'Veřejný běh', publicNote:'Plachta je čitelná bez přihlášení; jména hodnotitelů jsou skrytá.', makePublic:'Zveřejnit běh', makePrivate:'Zrušit zveřejnění', publicHint:'Jen pro scénáře bez osobních údajů', publicConfirm:'Zveřejnit tento běh? Uvidí ho kdokoli s odkazem, bez přihlášení. Použijte jen u scénářů bez osobních údajů.', openInApp:'Otevřít v Kaloku', signInLink:'Přihlásit', replay:'Projít', rpStep:'Krok', rpNext:'Další', rpPrev:'Zpět', rpEnd:'Konec průchodu.', rpRestart:'Znovu od začátku', rpChoose:'Kudy dál?', rpOr:'nebo', rpKeys:'← → krok · čísla volí větev · Esc zavře', rpDecision:'Rozhodnutí', rpBranch:'větev', rpAltOnly:'jiná větev',
|
|
852
|
+
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', viaAgent:'přes agenta', 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',
|
|
755
853
|
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í',
|
|
756
854
|
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' },
|
|
757
|
-
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
855
|
+
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">D</span> Dev mode (in the detail) · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
758
856
|
pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
|
|
759
857
|
branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
|
|
760
858
|
older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
|
|
761
859
|
jevNote:'The evaluator got a reduced page state and returned a probability for each question. The runner turned them into verdicts by threshold and added the evidence.', evalLabel:'Evaluation', metaTitle:'Page metadata', mdBasic:'Basics', mdHeadings:'Heading outline', mdHreflang:'Language versions (hreflang)', mdOg:'Open Graph', mdTwitter:'Twitter / X', mdLd:'Structured data · JSON-LD', mdMicro:'Structured data · microdata', mdLinks:'Other head links', mdOther:'Other meta', missing:'Missing', duplicate:'duplicate', size:'Size', runVerdict:'Run verdict', accept:'Accept run', ret:'Return run', pending:'pending', accepted:'accepted', returned:'returned', baseline:'Baseline run', setBaseline:'Set as baseline', unsetBaseline:'Unset baseline', verdictNotePh:'Verdict note (required when returning)', manualPass:'Pass', manualFail:'Fail', manualNotePh:'Reason (required for fail)', reviewerVerdict:'reviewer verdict', decideManual:'Decide manually', consoleErrors:'Console errors', consoleLoading:'Loading the messages…', noConsole:'No console messages were kept for this capture.', consoleMore:(n) => `${n} more not stored (the first 20 are kept).`, blockedTitle:'Blocked requests', copyAll:'Copy all', copyOne:'Copy this message', copied:'Copied', tabOverview:'Criteria', tabPage:'Page', tabTech:'Technical', toSolve:(n) => `${n} to solve`, passedN:(n) => `${n} passed`, whyMatters:'Why it matters:', howFix:'How to fix:', whyAndFix:'Why it matters and how to fix it', aiShort:(p) => `AI ${p} % yes`, noneFailed:'Nothing failed.', toDecide:'For a person to decide', toDecideLead:'The AI was unsure or could not evaluate; open the step, look at the screenshot and decide.', worthLook:'Worth a look', jevScoreHint:'Average confidence of the AI that the semantic criteria of this screen are met', sigTitle:'Quick signals', sigLead:'The AI asks these on every screen next to the criteria. They never change the result, only point at screens worth a look.', sigPurpose:'Does what the step is for', sigLanguage:'Text in the expected language', sigBroken:'Looks broken (error, placeholder, blank)', sigWarn:(n) => `${n} signal(s) worth a look`, sigYes:(p) => `${p} % yes`,
|
|
762
860
|
howAuto:'Automatic check', howAi:'AI evaluation', howManual:'Decided by a person', howTech:'Technical detail',
|
|
763
861
|
aiPass:(p, t) => `The evaluator is ${p} % sure the answer is yes; passes from ${t} %.`, aiFail:(p, t) => `Only ${p} % yes; fails at ${t} % or less.`, aiUnsure:(p, lo, hi) => `Unsure: ${p} % yes, between ${lo} % and ${hi} %. Look at the screenshot and decide below.`,
|
|
862
|
+
aiWhy:'Why:', aiSaw:'the AI also saw the screenshot', kbTitle:'Keyboard', kbLead:(n, all) => all ? `Tab goes through ${n} elements and leaves the page.` : `The first ${n} tab stops.`, kbShow:'Show the order on the screenshot', kbHide:'Hide the order', kbNone:'no visible focus', kbWeak:(c) => `weak focus ${c} : 1`, kbHidden:'hidden element', kbCovered:'covered', ignTitle:'Ignored regions', ignLead:(n) => `${n} region(s) do not count in the pixel diff (clocks, ads, carousels).`, unstLead:(n) => `${n} region(s) moved between repeated shots; kaloko stability suggests ignoring them.`,
|
|
764
863
|
aiNotEval:'Not evaluated yet.', aiVia:{ qawalk:'evaluated by Kaloko', 'own-key':'evaluated with your key' }, aiAsked:'Asked',
|
|
765
864
|
a_exists:(s) => `${s} is on the page`, a_absent:(s) => `${s} is not on the page`, a_count:(s, mn, mx) => `${s} appears ${mn != null && mn === mx ? `exactly ${mn}×` : mn != null && mx != null ? `${mn}–${mx}×` : mn != null ? `at least ${mn}×` : mx != null ? `at most ${mx}×` : ''}`, a_text_matches:(s, p) => `${s || 'The page'} contains ${p}`, a_text_absent:(s, p) => `${s || 'The page'} does not contain ${p}`, a_attr_equals:(s, a, v) => `${s} has ${a} = ${v}`,
|
|
766
865
|
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',
|
|
@@ -775,10 +874,13 @@ const I18N = {
|
|
|
775
874
|
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',
|
|
776
875
|
pagesWord:'pages', firstScreen:'first screen', layout:'Layout', uploadedBy:'Uploaded by', addNote:'Add a note', viewOptions:'View', keyComment:'comment on the step (in the detail)', keyHelp:'this help', helpTitle:'Shortcuts and help', flowComments:'Flow comments', runComments:'Run comments', commentsTitle:'Comments', allOpen:'Open', openWord:'open', flowCommentsLead:'For the whole flow across runs: a missing step, a general remark, an idea.', noStepComments:'No comments on this step yet.', noComments:'No comments yet.', showResolved:'Resolved', hideResolved:'Hide resolved', replyAct:'Reply', reopenAct:'Reopen', editAct:'Edit', deleteAct:'Delete', saveAct:'Save', cancel:'Cancel', edited:'edited', commentDeleted:'This comment was deleted.', viaAgent:'Written by an agent', showPin:'Show on the screenshot', pinOnShot:'Pin on the screenshot', pinClickShot:'Click on the screenshot', pinnedOnShot:'Pinned on the screenshot', replyPh:'Reply… (@name notifies)', flowPh:'Comment on the whole flow… (@name notifies)', runPh:'Comment on the whole run… (@name notifies)', commentLabel:'Comment', ctrlEnter:'Ctrl+Enter sends', confirmDelete:'Delete this comment?', deletedToast:'Deleted', openCommentsN:'Open comments', showAllN:'Show all %N%', screenWord:'app screen', uiTree:'UI tree', platformWord:'Platform', treeHint:'Click an element to show it on the screen.', treeSearch:'Search the UI tree…', treeOcr:'Text recognized from the screenshot (OCR): no roles or controls.', treeLoadFail:'The UI tree could not be loaded.', treeEmpty:'The tree is empty.', mapView:'Map', indexView:'Index', searchSteps:'Search steps, criteria and pages…', criteriaCol:'Criteria', notesCol:'Notes', noStepsMatch:'No step matches the filter.', riKeys:'/ search · ↑↓ select · Enter open · I map', consoleWord:'console errors', loadingPages:'also searching the pages of stacks…', sortTitle:'Title', runWord:'Run', openIndex:'Open index', pagesNeedAttention:'pages need attention', allPagesPass:'all pages pass', stackLoadFail:'The page index could not be loaded', semanticSample:'semantic sample', downloadCsv:'Download CSV', allWord:'All', criterionFilter:'Failing criterion', anyCriterion:'Any criterion', searchPages:'Search title, URL and fields…', sortBy:'Sort by', sortOrder:'Order', sortStatus:'Status (worst first)', sortDir:'Sort direction', pageWord:'Page', statusWord:'Status', failingCriteria:'Failing', stackKeys:'/ search · ↑↓ select · Enter open · Esc close', listView:'List', gridView:'Grid', noPagesMatch:'No page matches the filter.', backToIndex:'Back to index',
|
|
777
876
|
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',
|
|
778
|
-
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.', 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',
|
|
877
|
+
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', viaAgent:'by an agent', 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',
|
|
779
878
|
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',
|
|
780
879
|
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' }
|
|
781
880
|
};
|
|
881
|
+
// Dev mode (#42)
|
|
882
|
+
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' });
|
|
883
|
+
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' });
|
|
782
884
|
const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
|
|
783
885
|
const UI = (() => { const l = window.QAWALK_APP?.lang || new URLSearchParams(location.search).get('lang') || storedLang || (navigator.language || 'en').slice(0, 2); return l === 'cs' || l === 'sk' ? 'cs' : 'en'; })();
|
|
784
886
|
function switchLang (l) {
|
|
@@ -787,6 +889,9 @@ function switchLang (l) {
|
|
|
787
889
|
location.reload();
|
|
788
890
|
}
|
|
789
891
|
const T = (k) => I18N[UI][k] ?? I18N.en[k] ?? k;
|
|
892
|
+
// "n pages" with the right plural (cs: 1 stránka, 2–4 stránky, 5+ stránek; en: 1 page, n pages)
|
|
893
|
+
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') };
|
|
894
|
+
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` };
|
|
790
895
|
const ICON = {
|
|
791
896
|
comment:'<svg class="i" viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></svg>',
|
|
792
897
|
pin:'<svg class="i" viewBox="0 0 24 24"><path d="M12 21s-6-5.6-6-11a6 6 0 0 1 12 0c0 5.4-6 11-6 11z"/><circle cx="12" cy="10" r="2.2"/></svg>',
|
|
@@ -926,6 +1031,7 @@ function rerenderComments (all = false) {
|
|
|
926
1031
|
if (S.right) { const top = $('rside').scrollTop; renderReport(); $('rside').scrollTop = top; }
|
|
927
1032
|
if (S.open && !$('lb').hidden) { const side = $('lb').querySelector('.lb__side'); const top = side?.scrollTop || 0; openLb(S.open, S.lbVp, { keepFrame: true }); const s2 = $('lb').querySelector('.lb__side'); if (s2) s2.scrollTop = top; }
|
|
928
1033
|
if (S.rp && !$('rp').hidden) renderReplay();
|
|
1034
|
+
if (RV.open) { const top = $('rvSide')?.scrollTop || 0; rvRender(); if ($('rvSide')) $('rvSide').scrollTop = top; if (all) rvLoad().then(() => { if (RV.open) rvRender(); }); }
|
|
929
1035
|
if (all && S.mode === 'index' && !S.st?.inline && $('riList')) riPaint();
|
|
930
1036
|
}
|
|
931
1037
|
// unsent text survives re-renders, navigation and reloads (per run or flow, step, and reply or edit)
|
|
@@ -1185,7 +1291,7 @@ function cmpCritChanges (n) {
|
|
|
1185
1291
|
}
|
|
1186
1292
|
function loadImg (src) { return new Promise((res, rej) => { const i = new Image(); if (/^https?:/.test(src) && new URL(src, location.href).origin !== location.origin) i.crossOrigin = 'anonymous'; i.onload = () => res(i); i.onerror = () => rej(new Error('image')); i.src = src; }); }
|
|
1187
1293
|
/* pixel diff of two captures scaled to width W: changed pixels in red on a faded copy of the current one */
|
|
1188
|
-
async function pixelDiff (key, srcA, srcB, W) {
|
|
1294
|
+
async function pixelDiff (key, srcA, srcB, W, ign = null) {
|
|
1189
1295
|
const k = `${key}|${W}`;
|
|
1190
1296
|
if (CMP.pix.has(k)) return CMP.pix.get(k);
|
|
1191
1297
|
const job = (async () => {
|
|
@@ -1195,18 +1301,29 @@ async function pixelDiff (key, srcA, srcB, W) {
|
|
|
1195
1301
|
const A = draw(a, ha), B = draw(b, hb);
|
|
1196
1302
|
const out = document.createElement('canvas'); out.width = W; out.height = H; const ox = out.getContext('2d'); const O = ox.createImageData(W, H);
|
|
1197
1303
|
let n = 0; const da = A.data, db = B.data, dout = O.data;
|
|
1198
|
-
|
|
1304
|
+
const skip = ignoreMask(ign, W, H); let counted = 0;
|
|
1305
|
+
for (let i = 0, p = 0; i < da.length; i += 4, p++) {
|
|
1306
|
+
if (skip && skip[p]) { const g = ((p % W) + ((p / W) | 0)) % 10 < 5 ? 205 : 232; dout[i] = dout[i + 1] = dout[i + 2] = g; dout[i + 3] = 255; continue; }
|
|
1307
|
+
counted++;
|
|
1199
1308
|
const d = Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]);
|
|
1200
1309
|
if (d > 48) { n++; dout[i] = 220; dout[i + 1] = 20; dout[i + 2] = 60; dout[i + 3] = 255; }
|
|
1201
1310
|
else { dout[i] = 255 - (255 - da[i]) * 0.35; dout[i + 1] = 255 - (255 - da[i + 1]) * 0.35; dout[i + 2] = 255 - (255 - da[i + 2]) * 0.35; dout[i + 3] = 255; }
|
|
1202
1311
|
}
|
|
1203
1312
|
ox.putImageData(O, 0, 0);
|
|
1204
|
-
return { ratio: n / (
|
|
1313
|
+
return { ratio: n / Math.max(1, counted), canvas: out, ha, hb, H, ignored: W * H - counted };
|
|
1205
1314
|
})();
|
|
1206
1315
|
CMP.pix.set(k, job);
|
|
1207
1316
|
job.catch(() => CMP.pix.delete(k));
|
|
1208
1317
|
return job;
|
|
1209
1318
|
}
|
|
1319
|
+
/* ignored regions of both captures (#20) as a W × H mask: CSS px of the page scaled to the diff width */
|
|
1320
|
+
function ignoreMask (ign, W, H) {
|
|
1321
|
+
if (!ign?.rects?.length) return null;
|
|
1322
|
+
const k = W / (ign.cssWidth || W); const m = new Uint8Array(W * H);
|
|
1323
|
+
for (const r of ign.rects) { const x0 = Math.max(0, Math.floor(r.x * k)), x1 = Math.min(W, Math.ceil((r.x + r.w) * k)); for (let y = Math.max(0, Math.floor(r.y * k)); y < Math.min(H, Math.ceil((r.y + r.h) * k)); y++) m.fill(1, y * W + x0, y * W + x1); }
|
|
1324
|
+
return m;
|
|
1325
|
+
}
|
|
1326
|
+
const ignOf = (a, b) => { const rects = [...(a?.ignore || []), ...(b?.ignore || [])]; return rects.length ? { rects, cssWidth: a?.size?.[0] || b?.size?.[0] || null } : null; };
|
|
1210
1327
|
const pxClass = (r) => r > 0.1 ? 'lv2' : r > 0.01 ? 'lv1' : 'lv0';
|
|
1211
1328
|
const pxText = (r) => `${(r * 100).toFixed(r < 0.1 ? 1 : 0)} % ${T('pxChanged')}`;
|
|
1212
1329
|
/* fills the per-node pixel badges after the nodes are rendered */
|
|
@@ -1227,7 +1344,7 @@ function fillPixelBadges () {
|
|
|
1227
1344
|
if (!ca || !cb) { el.textContent = '—'; el.title = T('cmpNoCap'); return; }
|
|
1228
1345
|
const small = ca.thumb && cb.thumb;
|
|
1229
1346
|
try {
|
|
1230
|
-
const r = await pixelDiff(`${n.id}|${S.loc}|${v.name}${small ? '|thumb' : ''}`, asset(small ? ca.thumb : ca.image || ca.png), S.cmp.base + (small ? cb.thumb : cb.image || cb.png), 256);
|
|
1347
|
+
const r = await pixelDiff(`${n.id}|${S.loc}|${v.name}${small ? '|thumb' : ''}`, asset(small ? ca.thumb : ca.image || ca.png), S.cmp.base + (small ? cb.thumb : cb.image || cb.png), 256, ignOf(ca, cb));
|
|
1231
1348
|
el.textContent = `◐ ${pxText(r.ratio)}`; el.className = `pxd ${pxClass(r.ratio)}`;
|
|
1232
1349
|
} catch { el.textContent = '◐ ?'; el.title = T('pxUnavail'); }
|
|
1233
1350
|
};
|
|
@@ -1313,7 +1430,7 @@ function renderNodes () {
|
|
|
1313
1430
|
${n.session ? sessChip(n.session) : ''}<div class="node__head"><span class="kind">${ICON[n.kind]}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${httpChip(n)}${esc(loc(n.path) || n.captures?.[S.loc]?.[d?.name]?.mail?.subject || '')}</div></div><span class="badge st-${s}">${MARK[s]} ${s === 'skip' ? '—' : `${ok}/${n.criteria.length}`}</span></div>
|
|
1314
1431
|
<button class="shots ${S.vp === 'mobile' ? 'shots--mbig' : ''}" data-open="${esc(n.id)}" aria-label="${esc(loc(n.title))}">${S.vp !== 'mobile' && d ? shot(n, d, 256, 160) : ''}${S.vp !== 'desktop' && m ? shot(n, m, 74, 160) : ''}</button>
|
|
1315
1432
|
${jevBlock(n)}
|
|
1316
|
-
<div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` :
|
|
1433
|
+
<div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` : rvNodeBadge(n.id)}</div>
|
|
1317
1434
|
</div>`;
|
|
1318
1435
|
}).join('');
|
|
1319
1436
|
const tallest = Math.max(0, ...[...$('nodes').querySelectorAll('.node:not(.node--decision)')].map((el) => el.offsetHeight));
|
|
@@ -1441,10 +1558,10 @@ function stackNodeHtml (n, p) {
|
|
|
1441
1558
|
const thumbs = (st.sample || []).slice(0, 3).map((x) => x.thumb ? `<img loading="lazy" src="${esc(asset(x.thumb))}" alt="">` : '<span class="ph"></span>').join('') || '<span class="ph"></span><span class="ph"></span><span class="ph"></span>';
|
|
1442
1559
|
const bad = (st.summary.fail || 0) + (st.summary.partial || 0);
|
|
1443
1560
|
return `<div class="node node--stack st-${s} ${S.sess && n.session !== S.sess ? 'dim' : ''}" data-id="${esc(n.id)}" style="left:${p.x}px;top:${p.y}px">
|
|
1444
|
-
<div class="node__head"><span class="kind">${ICON.stack}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${esc(st.count)} ${
|
|
1561
|
+
<div class="node__head"><span class="kind">${ICON.stack}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${esc(st.count)} ${pagesN(st.count)}${st.source ? ` · ${esc(st.source)}` : ''}</div></div><span class="ok st-${s}">${esc(st.summary.pass || 0)}/${esc(st.count)}</span></div>
|
|
1445
1562
|
<button class="stack-fan" data-open="${esc(n.id)}" aria-label="${esc(T('openIndex'))}: ${esc(loc(n.title))}">${thumbs}<span class="count">${esc(st.count)}</span></button>
|
|
1446
1563
|
<div class="stack-bar">${['pass', 'partial', 'fail', 'skip'].map((k) => st.summary[k] ? `<i style="width:${st.summary[k] / tot * 100}%;background:var(--${k})"></i>` : '').join('')}</div>
|
|
1447
|
-
<div class="node__foot"><span class="note">${bad ?
|
|
1564
|
+
<div class="node__foot"><span class="note">${bad ? needAttention(bad) : T('allPagesPass')}</span>${cmtBadge(n.id)}<span class="note">${T('openIndex')} →</span></div>
|
|
1448
1565
|
</div>`;
|
|
1449
1566
|
}
|
|
1450
1567
|
async function loadStack (n) {
|
|
@@ -1494,7 +1611,7 @@ async function openStack (id, opts = {}) {
|
|
|
1494
1611
|
const crits = (data.criteria || []).map((c) => { const k = n.criteria.find((x) => x.id === c.id)?.counts || {}; return { ...c, bad: (k.fail || 0) + (k.needs_review || 0) }; });
|
|
1495
1612
|
const el = st.inline ? $('ri') : $('si');
|
|
1496
1613
|
el.innerHTML = `<div class="si__box">
|
|
1497
|
-
<div class="si__head"><span class="kind" style="background:var(--ink);color:var(--panel)">${ICON.stack}</span><div
|
|
1614
|
+
<div class="si__head"><span class="kind" style="background:var(--ink);color:var(--panel)">${ICON.stack}</span><div class="si__title">${st.inline ? `<div class="ri-crumbs"><button id="siUp">${esc(RUN.scenario.title)}</button><span>›</span><b id="siTitle">${esc(loc(n.title))}</b></div>` : `<h3 id="siTitle">${esc(loc(n.title))}</h3>`}<div class="note">${esc(n.stack.count)} ${pagesN(n.stack.count)}${n.stack.source ? ` · ${esc(n.stack.source)}` : ''}${n.stack.semantic_sample != null && crits.some((c) => c.type === 'semantic') ? ` · ${T('semanticSample')} ${esc(Math.min(n.stack.semantic_sample, n.stack.count))}` : ''}</div></div>
|
|
1498
1615
|
<div class="seg" id="siView"></div><button class="btn" id="siCsv">${T('downloadCsv')}</button>${st.inline ? '' : `<button class="icon-btn" id="siClose" aria-label="Close">${ICON.close}</button>`}</div>
|
|
1499
1616
|
<div class="si__tools">
|
|
1500
1617
|
${['all', 'fail', 'partial', 'pass', 'skip'].filter((k) => k === 'all' || sum[k]).map((k) => `<button class="si-chip" data-st="${k}" aria-pressed="${st.status === k}">${k === 'all' ? T('allWord') : `${MARK[k]} ${T(k)}`} ${k === 'all' ? esc(n.stack.count) : esc(sum[k] || 0)}</button>`).join('')}
|
|
@@ -1534,7 +1651,7 @@ function paintStack () {
|
|
|
1534
1651
|
const st = S.st; const box = $('siList'); if (!st || !box || (st.inline ? $('ri').hidden : $('si').hidden)) return;
|
|
1535
1652
|
const data = STACKS.get(st.id); const n = byId(st.id); const cols = (data.fields || []).filter((f) => f.id !== 'title').slice(0, 4);
|
|
1536
1653
|
const list = st.filtered; const head = st.view === 'grid' ? 0 : $('siHead').offsetHeight;
|
|
1537
|
-
$('siCount').textContent = `${list.length} / ${data.items.length} ${
|
|
1654
|
+
$('siCount').textContent = `${list.length} / ${data.items.length} ${pagesN(data.items.length)}`;
|
|
1538
1655
|
const img = (it) => { const t = stackThumb(it); return t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'; };
|
|
1539
1656
|
const fails = (it) => `<span class="si-fails">${(it.fails || []).map((id) => `<span title="${esc((data.criteria || []).find((c) => c.id === id)?.text || '')}">${esc(id)}</span>`).join('')}${(it.review || []).slice(0, 3).map((id) => `<span class="rv" title="${esc(T('needs_review'))}">${esc(id)}</span>`).join('')}</span>`;
|
|
1540
1657
|
if (!list.length) { $('siSpacer').style.height = '0px'; $('siRows').innerHTML = `<div class="si__empty">${T('noPagesMatch')}</div>`; return; }
|
|
@@ -1631,7 +1748,7 @@ function riRender (opts = {}) {
|
|
|
1631
1748
|
const r = S.ri; const sum = RUN.summary || {}; const all = screens();
|
|
1632
1749
|
const count = (k) => all.filter((n) => nodeSt(n) === k).length;
|
|
1633
1750
|
$('ri').innerHTML = `<div class="si__box">
|
|
1634
|
-
<div class="si__head"><span class="kind" style="background:var(--ink);color:var(--panel)">${ICON.stack}</span><div
|
|
1751
|
+
<div class="si__head"><span class="kind" style="background:var(--ink);color:var(--panel)">${ICON.stack}</span><div class="si__title"><h3>${esc(RUN.scenario.title)}</h3><div class="note">${all.length} ${T('stepsWord')} · ${esc(sum.pass || 0)} ${T('passedWord')}${all.some((n) => n.kind === 'stack') ? ` · ${all.filter((n) => n.kind === 'stack').reduce((k, n) => k + (n.stack?.count || 0), 0)} ${pagesN(all.filter((n) => n.kind === 'stack').reduce((k, n) => k + (n.stack?.count || 0), 0))}` : ''}</div></div>
|
|
1635
1752
|
<button class="btn" id="riCsv">${T('downloadCsv')}</button></div>
|
|
1636
1753
|
<div class="si__tools">
|
|
1637
1754
|
${['all', 'fail', 'partial', 'pass', 'skip'].filter((k) => k === 'all' || count(k)).map((k) => `<button class="si-chip" data-st="${k}" aria-pressed="${r.status === k}">${k === 'all' ? T('allWord') : `${MARK[k]} ${T(k)}`} ${k === 'all' ? all.length : count(k)}</button>`).join('')}
|
|
@@ -1660,7 +1777,7 @@ function riPaint () {
|
|
|
1660
1777
|
const tops = []; let y = 0; for (const e of out) { tops.push(y); y += e.type === 'group' ? RI_H.group : RI_H.row; }
|
|
1661
1778
|
S.ri.tops = tops;
|
|
1662
1779
|
const nSteps = out.filter((e) => e.type === 'step').length, nPages = out.filter((e) => e.type === 'page').length;
|
|
1663
|
-
$('riCount').textContent = `${nSteps} / ${screens().length} ${T('stepsWord')}${nPages ? ` · ${nPages} ${
|
|
1780
|
+
$('riCount').textContent = `${nSteps} / ${screens().length} ${T('stepsWord')}${nPages ? ` · ${nPages} ${pagesN(nPages)}` : ''}${waiting ? ` · ${T('loadingPages')}` : ''}`;
|
|
1664
1781
|
if (!out.length) { $('riSpacer').style.height = '0px'; $('riRows').innerHTML = `<div class="si__empty">${T('noStepsMatch')}</div>`; return; }
|
|
1665
1782
|
$('riSpacer').style.height = `${y}px`;
|
|
1666
1783
|
const head = $('riHead').offsetHeight; const top = Math.max(0, box.scrollTop - head); const bottom = top + box.clientHeight;
|
|
@@ -1685,7 +1802,7 @@ function riRow (e, k, top) {
|
|
|
1685
1802
|
const thumb = n.kind === 'stack'
|
|
1686
1803
|
? `<span class="ri-fan">${((n.stack?.sample || []).slice(0, 3).map((x) => x.thumb ? `<img loading="lazy" src="${esc(asset(x.thumb))}" alt="">` : '<span class="ph"></span>').join('') || '<span class="ph"></span><span class="ph"></span><span class="ph"></span>')}<span class="cnt">${esc(n.stack?.count || 0)}</span></span>`
|
|
1687
1804
|
: (() => { const t = riThumb(n); return t ? `<img loading="lazy" src="${esc(asset(t))}" alt="">` : '<span class="ph"></span>'; })();
|
|
1688
|
-
const sub = n.kind === 'stack' ? `${esc(n.stack?.count || 0)} ${
|
|
1805
|
+
const sub = n.kind === 'stack' ? `${esc(n.stack?.count || 0)} ${pagesN(n.stack?.count || 0)}${n.stack?.source ? ` · ${esc(n.stack.source)}` : ''}` : esc(loc(n.path) || '');
|
|
1689
1806
|
const cons = Object.values(n.captures?.[S.loc] || {}).reduce((k, c) => k + (c?.console_errors || 0), 0);
|
|
1690
1807
|
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('');
|
|
1691
1808
|
const crits = n.kind === 'stack' ? `${esc(n.stack?.summary?.pass || 0)}/${esc(n.stack?.count || 0)}` : s === 'skip' ? '—' : `${ok}/${crit.length}`;
|
|
@@ -1923,7 +2040,8 @@ function renderChrome () {
|
|
|
1923
2040
|
$('appNav').querySelectorAll('[data-lang]').forEach((b) => { b.onclick = () => { if (b.dataset.lang !== UI) switchLang(b.dataset.lang); }; });
|
|
1924
2041
|
document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
|
|
1925
2042
|
$('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
|
|
1926
|
-
$('hint').innerHTML = `<ul>${T('hint').split(' · ').map((x) => `<li>${x}</li>`).join('')}<li><span class="kbd">C</span> ${esc(T('keyComment'))}</li><li><span class="kbd">?</span> ${esc(T('keyHelp'))}</li></ul>`;
|
|
2043
|
+
$('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><li><span class="kbd">?</span> ${esc(T('keyHelp'))}</li></ul>`;
|
|
2044
|
+
$('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', rvHintItem()); // review mode (Q)
|
|
1927
2045
|
const ib = $('inboxBtn');
|
|
1928
2046
|
if (app?.user) { ib.hidden = false; ib.href = `/${app.org}/inbox`; ib.innerHTML = `<svg class="i" viewBox="0 0 24 24"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>${app.unread ? `<b>${esc(app.unread)}</b>` : ''}`; ib.title = 'Inbox'; ib.setAttribute('aria-label', `Inbox${app.unread ? ` (${app.unread})` : ''}`); }
|
|
1929
2047
|
else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
|
|
@@ -1955,7 +2073,7 @@ function renderChrome () {
|
|
|
1955
2073
|
<dt>${T('duration')}</dt><dd>${dur()}</dd></dl>${isDesign() ? '<div class="dvers" id="dvers"></div>' : ''}`;
|
|
1956
2074
|
renderDesignBar();
|
|
1957
2075
|
const order = screens().slice().sort((a, b) => a.col - b.col || a.lane - b.lane);
|
|
1958
|
-
const stepLi = (n) => `<li><button data-step="${esc(n.id)}" class="st-${nodeSt(n)}"><span class="dot"></span><span>${n.kind === 'stack' ? `${ICON.stack} ` : ''}${esc(loc(n.title))}</span><span class="path">${n.kind === 'stack' ? `${esc(n.stack?.count || 0)} ${
|
|
2076
|
+
const stepLi = (n) => `<li><button data-step="${esc(n.id)}" class="st-${nodeSt(n)}"><span class="dot"></span><span>${n.kind === 'stack' ? `${ICON.stack} ` : ''}${esc(loc(n.title))}</span><span class="path">${n.kind === 'stack' ? `${esc(n.stack?.count || 0)} ${pagesN(n.stack?.count || 0)}` : esc(loc(n.path))}</span></button></li>`;
|
|
1959
2077
|
$('steps').innerHTML = sessions().length
|
|
1960
2078
|
? sessions().map((k) => { const mine = order.filter((n) => n.session === k); if (!mine.length) return ''; const c = RUN.summary.by_session?.[k] || {}; return `<h3 data-sess="${esc(k)}" aria-pressed="${S.sess === k}" title="${esc(k)}"><i class="dot" style="background:${sessColor(k)}"></i>${esc(sessTitle(k))}${bar5(c)}</h3>${mine.map(stepLi).join('')}`; }).join('') + (order.some((n) => !n.session) ? order.filter((n) => !n.session).map(stepLi).join('') : '')
|
|
1961
2079
|
: laneNamesOf().length > 1
|
|
@@ -1987,6 +2105,13 @@ const EVIDENCE = [
|
|
|
1987
2105
|
[/^heading level skipped: (.+)$/, (m) => [`A heading level is skipped: ${m[1]}`, `Přeskočená úroveň nadpisu: ${m[1]}`]],
|
|
1988
2106
|
[/^no main landmark$/, () => ['The page has no <main> area', 'Stránka nemá hlavní oblast <main>']],
|
|
1989
2107
|
[/^duplicate ids: (.+)$/, (m) => [`Repeated ids: ${m[1]}`, `Opakovaná id: ${m[1]}`]],
|
|
2108
|
+
[/^(\d+) of (\d+) focus stop\(s\) without a clear indicator: (.+)$/, (m) => [`${m[1]} of ${m[2]} tab stops show no clear focus: ${m[3]}`, `${m[1]} z ${m[2]} zastávek tabulátoru nemá zřetelný fokus: ${m[3]}`]],
|
|
2109
|
+
[/^all (\d+) checked focus stop\(s\) show an indicator with at least 3:1 contrast$/, (m) => [`All ${m[1]} checked tab stops show a clear focus`, `Všechny kontrolované zastávky (${m[1]}) mají zřetelný fokus`]],
|
|
2110
|
+
[/^Tab goes through all (\d+) stop\(s\) and leaves the page$/, (m) => [`Tab goes through all ${m[1]} stops and leaves the page`, `Tabulátor projde všechny zastávky (${m[1]}) a opustí stránku`]],
|
|
2111
|
+
[/^skip link (.+)$/, (m) => [`Skip link ${m[1]}`, `Odkaz pro přeskočení ${m[1]}`]],
|
|
2112
|
+
[/^(\d+) of (\d+) control\(s\) have no name in the accessibility tree: (.+)$/, (m) => [`${m[1]} of ${m[2]} controls have no name for screen readers: ${m[3]}`, `${m[1]} z ${m[2]} ovládacích prvků nemá pro čtečky název: ${m[3]}`]],
|
|
2113
|
+
[/^all (\d+) control\(s\) in the accessibility tree have a name$/, (m) => [`All ${m[1]} controls have a name for screen readers`, `Všechny ovládací prvky (${m[1]}) mají pro čtečky název`]],
|
|
2114
|
+
[/^roles and aria-\* attributes are valid$/, () => ['ARIA roles and attributes are valid', 'Role a atributy ARIA jsou platné']],
|
|
1990
2115
|
[/^(\d+) text\(s\) below WCAG AA contrast: (.+)$/, (m) => [`${m[1]} text(s) with too little contrast: ${m[2]}`, `Texty s nízkým kontrastem (${m[1]}): ${m[2]}`]],
|
|
1991
2116
|
[/^(\d+) texts meet WCAG AA contrast$/, (m) => [`All ${m[1]} texts have enough contrast`, `Všech ${m[1]} textů má dostatečný kontrast`]],
|
|
1992
2117
|
[/^LCP (.+) since page load \(limit (.+)\)$/, (m) => [`The largest content appeared after ${m[1]} (limit ${m[2]})`, `Největší obsah se zobrazil za ${m[1]} (limit ${m[2]})`]],
|
|
@@ -2079,7 +2204,33 @@ function aiGauge (r) {
|
|
|
2079
2204
|
const [lo, hi] = r.th || [0.15, 0.85];
|
|
2080
2205
|
const pct = (x) => Math.round(x * 100);
|
|
2081
2206
|
const line = r.verdict === 'pass' ? T('aiPass')(pct(r.score), pct(hi)) : r.verdict === 'fail' ? T('aiFail')(pct(r.score), pct(lo)) : T('aiUnsure')(pct(r.score), pct(lo), pct(hi));
|
|
2082
|
-
return `<span class="gauge" role="img" aria-label="${esc(line)}"><i class="gauge__lo" style="width:${pct(lo)}%"></i><i class="gauge__hi" style="left:${pct(hi)}%"></i><b style="left:${pct(r.score)}%"></b></span><span class="how__line">${esc(line)}${r.via ? ` <span class="muted">· ${esc(T('aiVia')[r.via] || '')}</span>` : ''}</span
|
|
2207
|
+
return `<span class="gauge" role="img" aria-label="${esc(line)}"><i class="gauge__lo" style="width:${pct(lo)}%"></i><i class="gauge__hi" style="left:${pct(hi)}%"></i><b style="left:${pct(r.score)}%"></b></span><span class="how__line">${esc(line)}${r.via ? ` <span class="muted">· ${esc(T('aiVia')[r.via] || '')}</span>` : ''}</span>${aiReason(r)}`;
|
|
2208
|
+
}
|
|
2209
|
+
/* the evaluator's one sentence next to the score (#17); marked when it saw the screenshot */
|
|
2210
|
+
/* dashed boxes for ignored and moving regions, numbered tab stops when asked (step detail → Keyboard) */
|
|
2211
|
+
function overlayRegions (box, cap, v) {
|
|
2212
|
+
const im = box.querySelector('img'); if (!im) return;
|
|
2213
|
+
const place = () => {
|
|
2214
|
+
box.querySelectorAll('.ignbox, .kbstop').forEach((e) => e.remove());
|
|
2215
|
+
const k = im.clientWidth / (cap.size?.[0] || v.w);
|
|
2216
|
+
const rect = (cls, r, title) => { const d = document.createElement('span'); d.className = cls; if (title) d.title = title; Object.assign(d.style, { left: `${r.x * k}px`, top: `${r.y * k}px`, width: `${Math.max(2, r.w * k)}px`, height: `${Math.max(2, r.h * k)}px` }); box.appendChild(d); return d; };
|
|
2217
|
+
for (const r of cap.ignore || []) rect('ignbox', r, `${T('ignTitle')}${r.selector ? ': ' + r.selector : ''}`);
|
|
2218
|
+
for (const r of cap.unstable || []) rect('ignbox unst', r, T('unstLead')(1));
|
|
2219
|
+
if (S.kbShow && cap.keyboard) for (const s of cap.keyboard.stops || []) { if (s.hidden || !s.w) continue; const d = rect(`kbstop${s.focus && s.focus !== 'ok' ? ' bad' : ''}`, s, `${s.n} ${s.name || s.el}`); const i = document.createElement('i'); i.textContent = s.n; d.appendChild(i); }
|
|
2220
|
+
};
|
|
2221
|
+
im.complete ? place() : im.addEventListener('load', place);
|
|
2222
|
+
}
|
|
2223
|
+
/* Keyboard section of the step detail: the tab order with what is wrong at each stop, and a toggle for the overlay */
|
|
2224
|
+
function keyboardBlock (cap) {
|
|
2225
|
+
const kb = cap?.keyboard; const ign = cap?.ignore?.length || 0; const unst = cap?.unstable?.length || 0;
|
|
2226
|
+
if (!kb && !ign && !unst) return '';
|
|
2227
|
+
const flag = (s) => s.hidden ? T('kbHidden') : s.focus === 'none' ? T('kbNone') : s.focus === 'weak' ? T('kbWeak')(s.contrast) : s.focus === 'obscured' ? T('kbCovered') : '';
|
|
2228
|
+
const list = kb ? `<ol class="kb-list">${(kb.stops || []).slice(0, 12).map((s) => { const f = flag(s); return `<li class="${f ? 'bad' : ''}">${esc(s.name || s.el)}${f ? ` · ${esc(f)}` : ''}</li>`; }).join('')}</ol>` : '';
|
|
2229
|
+
return `${kb ? `<div class="sec"><h4>${T('kbTitle')}</h4><p class="note">${esc(T('kbLead')(kb.total, kb.complete))}</p><button type="button" class="pr" id="kbToggle">${S.kbShow ? T('kbHide') : T('kbShow')}</button>${list}</div>` : ''}${ign || unst ? `<div class="sec"><h4>${T('ignTitle')}</h4>${ign ? `<p class="note">${esc(T('ignLead')(ign))}</p>` : ''}${unst ? `<p class="note">${esc(T('unstLead')(unst))}</p>` : ''}</div>` : ''}`;
|
|
2230
|
+
}
|
|
2231
|
+
function aiReason (r) {
|
|
2232
|
+
if (!r.reason) return '';
|
|
2233
|
+
return `<span class="how__why"><b>${T('aiWhy')}</b>${esc(r.reason)}${r.vision ? ` <span class="muted">· ${T('aiSaw')}</span>` : ''}</span>`;
|
|
2083
2234
|
}
|
|
2084
2235
|
function howBlock (c, r) {
|
|
2085
2236
|
const k = c.check || {};
|
|
@@ -2170,18 +2321,22 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2170
2321
|
if (RUN.details_deferred) loadDetails();
|
|
2171
2322
|
const n0 = byId(id); if (n0?.kind === 'stack') { openStack(id); return; }
|
|
2172
2323
|
const n = n0; const list = n.item ? (S.itemList || [id]).map(byId).filter(Boolean) : S.mode === 'index' && S.riList?.includes(id) ? S.riList.map(byId) : flowOrder(), i = list.indexOf(n);
|
|
2173
|
-
if (S.open !== id) { LIVE.insp = null; LIVE.anchor = null; LIVE.mode = null; }
|
|
2324
|
+
if (S.open !== id) { LIVE.insp = null; LIVE.anchor = null; LIVE.mode = null; devReset(); }
|
|
2174
2325
|
S.lbMode ||= isDesign() ? 'live' : 'shot';
|
|
2175
2326
|
S.open = id; S.lbVp = vpName || (S.vp === 'mobile' ? mVp() : dVp())?.name || viewports()[0].name;
|
|
2176
2327
|
if (n.item && !n.captures?.[S.loc]?.[S.lbVp]) S.lbVp = Object.keys(n.captures?.[S.loc] || {})[0] || S.lbVp; // stack pages: the captured viewport
|
|
2177
2328
|
const v = viewports().find((x) => x.name === S.lbVp) || viewports()[0];
|
|
2178
2329
|
const cap = n.captures?.[S.loc]?.[v.name];
|
|
2330
|
+
// Dev mode stays on from step to step: on the live page where the frame helper runs, else on the screenshot
|
|
2331
|
+
if (DEV.on && !devAvailable(cap)) { DEV.on = false; devReset(); }
|
|
2332
|
+
if (DEV.on) { const shot = !devLive(cap); if (shot !== DEV.shot) devReset(); DEV.shot = shot; LIVE.mode = shot ? (LIVE.mode === 'dev' ? null : LIVE.mode) : 'dev'; }
|
|
2333
|
+
if (!DEV.on && S.lbTab === 'dev') S.lbTab = 'overview';
|
|
2179
2334
|
const s = nodeSt(n); const j = jevFor(n, v.name);
|
|
2180
2335
|
const g = RUN.git || {};
|
|
2181
2336
|
const lb = $('lb');
|
|
2182
2337
|
const html = `<div class="lb__box">
|
|
2183
2338
|
<div class="lb__stage">
|
|
2184
|
-
<div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore">${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
|
|
2339
|
+
<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>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
|
|
2185
2340
|
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2186
2341
|
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2187
2342
|
if (S.lbMode === 'view' && viewSrc(cap) && !S.cmp) return `<p class="note" style="margin:0 0 8px">${T('htmlViewNote')}</p>` + liveStage(cap, v, 'lbFrame', RUN.live_bases, true);
|
|
@@ -2198,13 +2353,14 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2198
2353
|
<div class="lb__side" id="lbSide">
|
|
2199
2354
|
<div class="lb__grab" id="lbGrab" role="button" tabindex="0" aria-controls="lbSide" aria-expanded="${(S.lbSheet || 'peek') !== 'peek'}" aria-label="${esc(T('sheetHandle'))}"></div>
|
|
2200
2355
|
<div class="lb__head">
|
|
2201
|
-
<div class="lb__row"><span class="kind">${ICON[n.kind]}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${n.item ? T('pageWord') : T('step')} ${i + 1} ${T('of')} ${list.length}</span>${n.item ? `<button class="btn" id="lbBack">${ICON.stack} ${T('backToIndex')}</button>` : ''}
|
|
2356
|
+
<div class="lb__row"><span class="kind">${ICON[n.kind]}</span><span class="lb__meta"><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${n.item ? T('pageWord') : T('step')} ${i + 1} ${T('of')} ${list.length}</span>${n.item ? `<button class="btn" id="lbBack">${ICON.stack} ${T('backToIndex')}</button>` : ''}</span>
|
|
2202
2357
|
<span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external} <span class="lb__linkTxt">${T('copyLink')}</span></button><button class="icon-btn" id="lbPrev" aria-label="Previous" ${i === 0 ? 'disabled' : ''}>${ICON.prev}</button><button class="icon-btn" id="lbNext" aria-label="Next" ${i === list.length - 1 ? 'disabled' : ''}>${ICON.next}</button><button class="icon-btn" id="lbClose" aria-label="Close">${ICON.close}</button></span></div>
|
|
2203
2358
|
<h3 id="lbTitle">${esc(loc(n.title))}</h3>
|
|
2204
2359
|
<div class="node__path">${n.session ? sessChip(n.session) + ' · ' : ''}${cap?.http_status >= 300 ? `<span class="http ${cap.http_status >= 500 ? 'http--5' : ''}" title="HTTP ${esc(cap.http_status)}">${esc(cap.http_status)}</span>` : ''}${esc(cap?.live ? cap.live.entry : cap?.url || loc(n.path))}</div>
|
|
2205
2360
|
${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}
|
|
2206
2361
|
</div>
|
|
2207
|
-
<div class="lbtabs" role="tablist">${[['overview', T('tabOverview')], ['page', T('tabPage')], ['tech', T('tabTech')]].map(([k, l]) => `<button role="tab" data-lbtab="${k}" aria-selected="${(S.lbTab || 'overview') === k}">${l}</button>`).join('')}</div>
|
|
2362
|
+
<div class="lbtabs" role="tablist">${[...(DEV.on ? [['dev', T('devTab')]] : []), ['overview', T('tabOverview')], ['page', T('tabPage')], ['tech', T('tabTech')]].map(([k, l]) => `<button role="tab" data-lbtab="${k}" aria-selected="${(S.lbTab || 'overview') === k}">${l}</button>`).join('')}</div>
|
|
2363
|
+
${DEV.on ? `<div class="lbtab" data-pane="dev" ${S.lbTab === 'dev' ? '' : 'hidden'}>${devPaneHtml()}</div>` : ''}
|
|
2208
2364
|
<div class="lbtab" data-pane="overview" ${(S.lbTab || 'overview') === 'overview' ? '' : 'hidden'}>
|
|
2209
2365
|
<div id="lbSideTop"></div>${inspectorHtml()}
|
|
2210
2366
|
${stepStatusHtml(n)}
|
|
@@ -2218,6 +2374,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2218
2374
|
</div>
|
|
2219
2375
|
<div class="lbtab" data-pane="page" ${S.lbTab === 'page' ? '' : 'hidden'}>
|
|
2220
2376
|
${sigSection(cap)}
|
|
2377
|
+
${keyboardBlock(cap)}
|
|
2221
2378
|
${metaSection(n, cap, v)}
|
|
2222
2379
|
${(n.attachments || []).length ? `<div class="sec"><h4>${T('attachments')}</h4><div class="files">${n.attachments.map((a) => `<a class="file" href="${esc(asset(a.file))}" target="_blank" rel="noopener"><span class="file__prev"><img src="${esc(asset(a.file))}" alt="" style="max-width:100%;max-height:74px"></span><span class="file__name">${ICON.crop}${esc(a.title)}${a.locale ? ` · ${esc(a.locale)}` : ''}</span></a>`).join('')}</div></div>` : ''}
|
|
2223
2380
|
${cap?.tree ? `<details class="sec" id="treeSec"><summary><h4>${ICON.html} ${T('uiTree')}</h4></summary><p class="note" id="treeNote">${T('treeHint')}</p><input type="search" class="tree-q" id="treeQ" placeholder="${esc(T('treeSearch'))}"><div class="tree" id="treeBody"></div></details>` : ''}
|
|
@@ -2238,6 +2395,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2238
2395
|
wireLbOnce(lb);
|
|
2239
2396
|
renderFocusBar(lb, n, v, cap, list, i);
|
|
2240
2397
|
lb.hidden = false;
|
|
2398
|
+
lbSheetFit(); LB_RO?.disconnect(); LB_RO?.observe(lb.querySelector('.lb__head'));
|
|
2241
2399
|
fitLive(); requestAnimationFrame(fitLive);
|
|
2242
2400
|
setTimeout(() => presenceView(true), 0);
|
|
2243
2401
|
setHash({ step: id, loc: S.loc, vp: v.name, focus: S.focus ? '1' : null });
|
|
@@ -2253,14 +2411,21 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2253
2411
|
if ($('lbMode')) seg($('lbMode'), [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : []), ['shot', T('shotMode')]], S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(id, v.name); });
|
|
2254
2412
|
if ($('lbLiveTools')) renderLiveTools();
|
|
2255
2413
|
if ($('lbProto')) $('lbProto').onclick = () => ptOpen(id, v.name);
|
|
2256
|
-
|
|
2414
|
+
if ($('lbDev')) $('lbDev').onclick = () => setDev(!DEV.on);
|
|
2415
|
+
wireStepStatus(n); wireCopyCss(); wireDev();
|
|
2416
|
+
if (DEV.on && DEV.shot) {
|
|
2417
|
+
const im = $('lbImg')?.querySelector('img');
|
|
2418
|
+
loadShotSpec(cap).then(() => { if (S.open !== id) return; if (im && !im.complete) im.addEventListener('load', drawShot, { once: true }); else drawShot(); renderDev(); });
|
|
2419
|
+
}
|
|
2257
2420
|
// pins of comments made on elements, drawn over the screenshot too
|
|
2258
2421
|
const pimg = $('lbImg');
|
|
2422
|
+
if (pimg && cap) overlayRegions(pimg, cap, v);
|
|
2423
|
+
if ($('kbToggle')) $('kbToggle').onclick = () => { S.kbShow = !S.kbShow; $('kbToggle').textContent = S.kbShow ? T('kbHide') : T('kbShow'); overlayRegions(pimg, cap, v); };
|
|
2259
2424
|
if (pimg) { const place = () => { const k = pimg.querySelector('img').clientWidth / (cap?.size?.[0] || v.w); for (const p of pinsFor(n, v.name)) { if (!p.box) continue; const d = document.createElement('span'); d.className = 'pinmark'; d.textContent = p.n; Object.assign(d.style, { left: `${(p.box[0] + p.box[2]) * k}px`, top: `${p.box[1] * k}px`, opacity: p.resolved ? 0.5 : 1 }); pimg.appendChild(d); } }; const im = pimg.querySelector('img'); if (im) im.complete ? place() : im.addEventListener('load', place); }
|
|
2260
2425
|
if (S.cmp) {
|
|
2261
2426
|
if ($('lbCmpSeg')) seg($('lbCmpSeg'), [['side', T('cmpSide')], ['diff', T('cmpDiff')]], S.lbCmp, (m) => { S.lbCmp = m; openLb(id, v.name); });
|
|
2262
2427
|
const bcap = cmpNode(n.id)?.captures?.[S.loc]?.[v.name];
|
|
2263
|
-
if (cap && bcap) pixelDiff(`${n.id}|${S.loc}|${v.name}`, asset(cap.image || cap.png), S.cmp.base + (bcap.image || bcap.png), Math.min(1000, v.w)).then((r) => {
|
|
2428
|
+
if (cap && bcap) pixelDiff(`${n.id}|${S.loc}|${v.name}`, asset(cap.image || cap.png), S.cmp.base + (bcap.image || bcap.png), Math.min(1000, v.w), ignOf(cap, bcap)).then((r) => {
|
|
2264
2429
|
const px = $('lbPx'); if (px) { px.innerHTML = `<span class="pxd ${pxClass(r.ratio)}">◐ ${pxText(r.ratio)}</span>`; }
|
|
2265
2430
|
const d = $('lbDiff'); if (d) { d.innerHTML = ''; d.appendChild(r.canvas); }
|
|
2266
2431
|
}).catch(() => { const px = $('lbPx'); if (px) px.textContent = T('pxUnavail'); });
|
|
@@ -2327,9 +2492,20 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2327
2492
|
if (li) { const im = img?.querySelector('img'); const go = () => setTimeout(() => li.click(), 60); im && !im.complete ? im.addEventListener('load', go, { once: true }) : go(); }
|
|
2328
2493
|
}
|
|
2329
2494
|
}
|
|
2330
|
-
function closeLb () { const lb = $('lb'); lb.hidden = true; lb.classList.remove('is-focus', 'is-idle'); clearTimeout(FX.timer); S.open = null; S.focus = false; LIVE.mode = null; LIVE.insp = null; setHash(null); presenceView(); }
|
|
2495
|
+
function closeLb () { DEV.on = false; devReset(); if (S.lbTab === 'dev') S.lbTab = 'overview'; const lb = $('lb'); lb.hidden = true; lb.classList.remove('is-focus', 'is-idle'); clearTimeout(FX.timer); S.open = null; S.focus = false; LIVE.mode = null; LIVE.insp = null; setHash(null); presenceView(); }
|
|
2331
2496
|
const lbPhone = () => matchMedia('(max-width:700px)').matches;
|
|
2332
2497
|
function closeLbMore () { const m = $('lbMore'); if (!m) return; m.classList.remove('is-open'); $('lbMoreBtn')?.setAttribute('aria-expanded', 'false'); }
|
|
2498
|
+
/** Phone sheet geometry: the collapsed sheet shows the whole header row and one line of the title (the row wraps
|
|
2499
|
+
when a page has "back to index"), and the tabs stick right under the header instead of over it. */
|
|
2500
|
+
function lbSheetFit () {
|
|
2501
|
+
const lb = $('lb'), box = lb.querySelector('.lb__box'), head = lb.querySelector('.lb__head'), side = $('lbSide'); if (!box || !head || !side) return;
|
|
2502
|
+
if (!lbPhone() || S.focus) { box.style.removeProperty('--peek'); side.style.removeProperty('--lbhead'); return; }
|
|
2503
|
+
const row = head.querySelector('.lb__row'), h3 = head.querySelector('h3');
|
|
2504
|
+
const line = h3 ? parseFloat(getComputedStyle(h3).lineHeight) || 20 : 20;
|
|
2505
|
+
box.style.setProperty('--peek', `calc(${Math.max(98, Math.ceil(16 + (row?.offsetHeight || 32) + 4 + line + 10))}px + env(safe-area-inset-bottom))`);
|
|
2506
|
+
side.style.setProperty('--lbhead', `${head.offsetHeight}px`);
|
|
2507
|
+
}
|
|
2508
|
+
const LB_RO = typeof ResizeObserver === 'function' ? new ResizeObserver(() => lbSheetFit()) : null;
|
|
2333
2509
|
/** The phone bottom sheet of the detail: peek (title and status), half, full. */
|
|
2334
2510
|
function setSheet (st) {
|
|
2335
2511
|
S.lbSheet = st; $('lb').dataset.sheet = st;
|
|
@@ -2341,7 +2517,7 @@ function setSheet (st) {
|
|
|
2341
2517
|
function setFocus (on) {
|
|
2342
2518
|
if (!S.open) return;
|
|
2343
2519
|
S.focus = !!on; closeLbMore();
|
|
2344
|
-
if (on) { C.pinMode = false; LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null }); }
|
|
2520
|
+
if (on) { C.pinMode = false; LIVE.mode = null; DEV.on = false; devReset(); if (S.lbTab === 'dev') S.lbTab = 'overview'; toFrame({ type: 'qawalk:mode', mode: null }); }
|
|
2345
2521
|
openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2346
2522
|
}
|
|
2347
2523
|
const FX = { timer: null, over: false };
|
|
@@ -2418,6 +2594,9 @@ function setHash (p) {
|
|
|
2418
2594
|
}
|
|
2419
2595
|
function applyHash () {
|
|
2420
2596
|
const p = new URLSearchParams(location.hash.slice(1));
|
|
2597
|
+
// review mode: #review (the first undecided step), #review=<step>, #review=end (the summary)
|
|
2598
|
+
if (p.has('review')) { const t = p.get('review') || null; if (!RV.open || (t && t !== 'end' && rvCur()?.step !== t)) rvOpen(t); return; }
|
|
2599
|
+
if (RV.open) { RV.open = false; $('rv').hidden = true; }
|
|
2421
2600
|
if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
|
|
2422
2601
|
const cmp = p.get('cmp');
|
|
2423
2602
|
if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
|
|
@@ -2465,7 +2644,7 @@ function renderReplay () {
|
|
|
2465
2644
|
foot = '';
|
|
2466
2645
|
} else if (n.kind === 'stack') {
|
|
2467
2646
|
const st = n.stack || { sample: [], summary: {} };
|
|
2468
|
-
stage = `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(st.count)} ${
|
|
2647
|
+
stage = `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(st.count)} ${pagesN(st.count)}</div><h3>${esc(loc(n.title))}</h3><div class="stack-fan" style="max-width:520px;height:220px;margin:12px auto">${(st.sample || []).slice(0, 3).map((x) => x.thumb ? `<img src="${esc(asset(x.thumb))}" alt="" style="height:180px">` : '<span class="ph" style="height:180px"></span>').join('')}</div><p class="note">${['pass', 'partial', 'fail', 'skip'].filter((k) => st.summary[k]).map((k) => `${MARK[k]} ${T(k)} ${st.summary[k]}`).join(' · ')}</p><button class="btn btn--primary" data-stack-open="${esc(n.id)}">${T('openIndex')}</button></div>`;
|
|
2469
2648
|
foot = opts.length ? `${optBtn(opts[0], true)}${opts.length > 1 ? `<span class="rp__alts">${T('rpOr')} ${opts.slice(1).map((o) => optBtn(o, false)).join('')}</span>` : ''}` : `<span class="note">${T('rpEnd')}</span>`;
|
|
2470
2649
|
} else {
|
|
2471
2650
|
stage = cap ? `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}" style="max-width:${v.mobile ? v.w + 16 : v.w}px;width:100%"><img src="${esc(asset(cap.image || cap.png))}" alt="${esc(loc(n.title))}"></div>` : `<p class="lb__empty">${T('notReached')}</p>`;
|
|
@@ -2505,7 +2684,7 @@ function rpKey (e) {
|
|
|
2505
2684
|
/* The run's problems, sorted by what to do about them: what failed (with the reason in words), what a person
|
|
2506
2685
|
should decide (AI unsure or not evaluated) and screens whose quick signals look off. */
|
|
2507
2686
|
function whyOf (n, x) {
|
|
2508
|
-
if (x.counts) return `${(x.counts.fail || 0) + (x.counts.needs_review || 0)}/${n.stack.count} ${
|
|
2687
|
+
if (x.counts) return `${(x.counts.fail || 0) + (x.counts.needs_review || 0)}/${n.stack.count} ${pagesN(n.stack.count)}`;
|
|
2509
2688
|
const cells = viewports().map((v) => ({ v, r: x.by_locale?.[S.loc]?.[v.name] || {} })).filter(({ r }) => r.verdict && r.verdict !== 'pass' && r.verdict !== 'not_reached');
|
|
2510
2689
|
const c = cells.find(({ r }) => r.verdict === 'fail') || cells[0];
|
|
2511
2690
|
if (!c) return x.evidence || '';
|
|
@@ -2532,7 +2711,7 @@ function renderReport () {
|
|
|
2532
2711
|
$('rside').innerHTML = `
|
|
2533
2712
|
<div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
|
|
2534
2713
|
${APP() && isDraft() ? `<div class="sec"><h4>${T('draft')} #${esc(RUN.draft.seq)}</h4><p class="note">${T('draftInfo')}</p><div id="draftChanges" class="note"></div></div>` : ''}
|
|
2535
|
-
${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}</p>` : ''}
|
|
2714
|
+
${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)}${RUN.verdict.via === 'token' || RUN.verdict.via === 'agent' ? ` <span class="badge st-skip">${T(RUN.verdict.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : ''} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}${RUN.verdict.approved_by_tokens?.length ? ` · ${T('approvedBy')} ${T('approvedByTokens')}: ${RUN.verdict.approved_by_tokens.map((a) => esc(a.email)).join(', ')}` : ''}</p>` : ''}
|
|
2536
2715
|
${canWrite() ? `<textarea id="rvVerdictNote" hidden rows="2" placeholder="${T('verdictNotePh')}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"></textarea>
|
|
2537
2716
|
<div class="share__row"><button class="btn btn--primary" data-verdict="accepted">${isDesign() ? T('acceptVersion') : T('accept')}</button><button class="btn" data-verdict="returned">${isDesign() ? T('returnVersion') : T('ret')}</button></div>
|
|
2538
2717
|
<div class="vmore"><button class="linkish" id="rvNoteShow">${T('addNote')}</button>${RUN.verdict?.verdict === 'accepted' && !isDesign() ? `<button class="linkish" data-baseline="${RUN.verdict.baseline ? 'unset' : 'set'}">${RUN.verdict.baseline ? T('unsetBaseline') : T('setBaseline')}</button>` : ''}${APP().user?.role === 'admin' ? `<button class="linkish" data-public="${RUN.public ? 'off' : 'on'}" title="${T('publicHint')}">${RUN.public ? T('makePrivate') : isDesign() ? T('makePublicVersion') : T('makePublic')}</button>` : ''}</div>` : ''}${RUN.public ? `<p class="note" style="margin:6px 0 0"><span class="badge st-partial">${T('publicRun')}</span> ${T('publicNote')}</p>` : ''}</div>` : ''}
|
|
@@ -2793,7 +2972,10 @@ addEventListener('message', (e) => {
|
|
|
2793
2972
|
if (m.type === 'qawalk:key') ptKey({ key: String(m.key), preventDefault () {} });
|
|
2794
2973
|
return;
|
|
2795
2974
|
}
|
|
2796
|
-
if (m.type === '
|
|
2975
|
+
if (typeof m.type === 'string' && m.type.startsWith('qawalk:dev-')) { if (DEV.on && !DEV.shot) devMessage(m); return; }
|
|
2976
|
+
// keys pressed while the page has focus: Dev mode (D, M, Esc)
|
|
2977
|
+
if (m.type === 'qawalk:key' && !$('lb').hidden) { devKey(String(m.key).slice(0, 12), false); return; }
|
|
2978
|
+
if (m.type === 'qawalk:ready' && n) { toFrame({ type: 'qawalk:pins', pins: pinsFor(n, S.lbVp) }); if (LIVE.mode) toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); if (LIVE.mode === 'dev') { DEV.spec = DEV.base = DEV.measure = null; DEV.usage = null; toFrame({ type: 'qawalk:dev-usage' }); renderDev(); } }
|
|
2797
2979
|
if (m.type === 'qawalk:go' && typeof m.node === 'string' && byId(m.node)) {
|
|
2798
2980
|
toast(`${T('prototypeTo')} ${loc(byId(m.node).title)}`); openLb(m.node, S.lbVp);
|
|
2799
2981
|
// a link to a page inside the step's folder opens that page in the live frame, not the step's entry
|
|
@@ -2844,8 +3026,319 @@ function wireCopyCss () {
|
|
|
2844
3026
|
function renderLiveTools () {
|
|
2845
3027
|
const t = $('lbLiveTools'); if (!t) return;
|
|
2846
3028
|
t.innerHTML = `<button class="btn" id="lbInspect" aria-pressed="${LIVE.mode === 'inspect'}">${T('inspect')}</button>${canWrite() ? `<button class="btn" id="lbPin" aria-pressed="${LIVE.mode === 'pick'}">${T('pinComment')}</button>` : ''}`;
|
|
2847
|
-
|
|
2848
|
-
|
|
3029
|
+
const devOff = () => { if (!DEV.on) return; DEV.on = false; devReset(); if (S.lbTab === 'dev') S.lbTab = 'overview'; openLb(S.open, S.lbVp, { keepFrame: true }); };
|
|
3030
|
+
$('lbInspect').onclick = () => { devOff(); LIVE.mode = LIVE.mode === 'inspect' ? null : 'inspect'; toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); if (!LIVE.mode) { LIVE.insp = null; $('inspSec')?.remove(); } renderLiveTools(); presenceSend({ type: 'editing', step: S.open, on: !!LIVE.mode }); };
|
|
3031
|
+
const pin = $('lbPin'); if (pin) pin.onclick = () => { devOff(); LIVE.mode = LIVE.mode === 'pick' ? null : 'pick'; toFrame({ type: 'qawalk:mode', mode: LIVE.mode }); if (LIVE.mode) toast(T('pickHint')); renderLiveTools(); };
|
|
3032
|
+
}
|
|
3033
|
+
// ---- Dev mode (#42): measurements, tokens, states and assets of design steps, for developers and their agents ----
|
|
3034
|
+
// Live steps: the frame helper runs the spec engine (src/lib/devspec.mjs) in the page and answers qawalk:dev-*.
|
|
3035
|
+
// Screenshots: the step spec the CLI captured with the same engine (steps/<step>/<locale>/<viewport>.spec.json).
|
|
3036
|
+
const DEV = { on: false, tab: 'el', spec: null, base: null, measure: null, measureMode: false, usage: null, hl: null, shot: false, shotSpec: undefined, shotKey: null, shotSel: -1, shotTarget: -1 };
|
|
3037
|
+
const DEV_STATES = ['default', 'hover', 'focus', 'active', 'disabled'];
|
|
3038
|
+
const DEV_GROUPS = ['type', 'color', 'space', 'size', 'shape', 'layout'];
|
|
3039
|
+
const HEX = /^#[0-9a-f]{3,8}$/i;
|
|
3040
|
+
const devCapOf = () => { const n = S.open && byId(S.open); return n?.captures?.[S.loc]?.[S.lbVp] || null; };
|
|
3041
|
+
/** Dev mode works on a live step (frame helper on the service) or on a capture with a spec or boxes. */
|
|
3042
|
+
function devAvailable (cap) { return !!cap && (isDesign() || !!liveSrc(cap) || !!viewSrc(cap) || !!cap.spec || !!cap.spec_inline); }
|
|
3043
|
+
const devLive = (cap = devCapOf()) => !!APP() && ['live', 'view'].includes(S.lbMode) && !S.cmp && !!(S.lbMode === 'view' ? viewSrc(cap) : liveSrc(cap));
|
|
3044
|
+
function devReset () { Object.assign(DEV, { spec: null, base: null, measure: null, measureMode: false, usage: null, hl: null, shotSel: -1, shotTarget: -1 }); }
|
|
3045
|
+
function setDev (on) {
|
|
3046
|
+
if (!S.open) return;
|
|
3047
|
+
const cap = devCapOf();
|
|
3048
|
+
if (on && !devAvailable(cap)) return;
|
|
3049
|
+
DEV.on = !!on; devReset();
|
|
3050
|
+
if (on && S.focus) S.focus = false; // the inspector lives in the side panel
|
|
3051
|
+
if (on && !APP() && S.lbMode === 'live') { S.lbMode = 'shot'; toast(T('devLocalLive')); } // from disk the live page has no helper
|
|
3052
|
+
DEV.shot = on && !devLive(cap);
|
|
3053
|
+
if (on) { LIVE.insp = null; C.pinMode = false; }
|
|
3054
|
+
LIVE.mode = on && !DEV.shot ? 'dev' : LIVE.mode === 'dev' ? null : LIVE.mode;
|
|
3055
|
+
if (on) S.lbTab = 'dev'; else if (S.lbTab === 'dev') S.lbTab = 'overview';
|
|
3056
|
+
openLb(S.open, S.lbVp, { keepFrame: true });
|
|
3057
|
+
$('lbDev')?.setAttribute('aria-pressed', String(DEV.on)); // a kept frame keeps its tool bar too
|
|
3058
|
+
if (!DEV.on) renderLiveTools();
|
|
3059
|
+
toFrame({ type: 'qawalk:mode', mode: LIVE.mode });
|
|
3060
|
+
if (on && !DEV.shot) toFrame({ type: 'qawalk:dev-usage' });
|
|
3061
|
+
if (on && lbPhone() && S.lbSheet === 'peek') setSheet('half');
|
|
3062
|
+
presenceSend({ type: 'editing', step: S.open, on: !!on });
|
|
3063
|
+
}
|
|
3064
|
+
/** Escape in Dev mode: first the measurement, then the selection, then the mode itself. */
|
|
3065
|
+
function devEsc () {
|
|
3066
|
+
if (DEV.measureMode || DEV.measure || DEV.shotTarget >= 0) { devMeasureMode(false); DEV.measure = null; DEV.shotTarget = -1; drawShot(); renderDev(); return; }
|
|
3067
|
+
if (DEV.spec) { DEV.spec = DEV.base = null; DEV.shotSel = -1; toFrame({ type: 'qawalk:dev-clear' }); drawShot(); renderDev(); return; }
|
|
3068
|
+
setDev(false);
|
|
3069
|
+
}
|
|
3070
|
+
function devMeasureMode (on) { DEV.measureMode = !!on; toFrame({ type: 'qawalk:dev-measure-mode', on: DEV.measureMode }); if (!on) { DEV.measure = null; DEV.shotTarget = -1; drawShot(); } renderDev(); }
|
|
3071
|
+
function devNav (dir) { if (DEV.shot) shotNav(dir); else toFrame({ type: 'qawalk:dev-nav', dir }); }
|
|
3072
|
+
|
|
3073
|
+
// ---- data from the frame: never trusted, every field cut to size ----
|
|
3074
|
+
const sStr = (x, n) => typeof x === 'string' ? x.slice(0, n) : x == null ? '' : String(x).slice(0, n);
|
|
3075
|
+
const sNum = (x) => Number.isFinite(+x) ? +x : 0;
|
|
3076
|
+
function sTok (t) {
|
|
3077
|
+
if (!t || typeof t !== 'object' || !['exact', 'nearest', 'none'].includes(t.m)) return null;
|
|
3078
|
+
const o = { m: t.m };
|
|
3079
|
+
for (const [k, n] of [['name', 160], ['value', 200], ['mode', 40], ['via', 80], ['hard', 200]]) if (t[k] != null && t[k] !== false) o[k] = sStr(typeof t[k] === 'object' ? JSON.stringify(t[k]) : t[k], n);
|
|
3080
|
+
return o;
|
|
3081
|
+
}
|
|
3082
|
+
function sanitizeSpec (s) {
|
|
3083
|
+
if (!s || typeof s !== 'object') return null;
|
|
3084
|
+
const rows = (Array.isArray(s.rows) ? s.rows : []).slice(0, 60).filter((r) => r && DEV_GROUPS.includes(r.g)).map((r) => ({ g: r.g, prop: sStr(r.prop, 40), value: sStr(r.value, 300), tok: sTok(r.tok), ...(Array.isArray(r.parts) ? { parts: r.parts.slice(0, 4).map((p) => ({ value: sStr(p?.value, 60), tok: sTok(p?.tok) })) } : {}) }));
|
|
3085
|
+
const c = s.contrast && typeof s.contrast === 'object' ? { ratio: sNum(s.contrast.ratio), aa: !!s.contrast.aa, aaa: !!s.contrast.aaa, large: !!s.contrast.large, approx: !!s.contrast.approx, fg: HEX.test(s.contrast.fg) ? s.contrast.fg : null, bg: HEX.test(s.contrast.bg) ? s.contrast.bg : null } : null;
|
|
3086
|
+
const preview = (p) => typeof p === 'string' && p.length < 60000 && /^data:image\/svg\+xml;base64,[A-Za-z0-9+/=]+$/.test(p) ? p : null;
|
|
3087
|
+
return {
|
|
3088
|
+
sel: sStr(s.sel, 500), tag: sStr(s.tag, 20), role: s.role ? sStr(s.role, 40) : null, name: sStr(s.name, 80),
|
|
3089
|
+
box: Array.isArray(s.box) ? s.box.slice(0, 4).map(sNum) : [0, 0, 0, 0], state: DEV_STATES.includes(s.state) ? s.state : 'default',
|
|
3090
|
+
rows, contrast: c, states: (Array.isArray(s.states) ? s.states : []).filter((x) => DEV_STATES.includes(x)),
|
|
3091
|
+
parent: s.parent && typeof s.parent === 'object' ? { sel: sStr(s.parent.sel, 500), tag: sStr(s.parent.tag, 20), inset: Array.isArray(s.parent.inset) ? s.parent.inset.slice(0, 4).map(sNum) : null } : null,
|
|
3092
|
+
assets: (Array.isArray(s.assets) ? s.assets : []).slice(0, 30).map((a, i) => ({ i: Number.isInteger(a?.i) ? a.i : i, kind: ['img', 'svg', 'background'].includes(a?.kind) ? a.kind : 'img', name: sStr(a?.name, 80).replace(/[^\w.\- ]+/g, '-') || 'asset', size: Array.isArray(a?.size) ? a.size.slice(0, 2).map(sNum) : null, alt: a?.alt ? sStr(a.alt, 120) : null, preview: preview(a?.preview) }))
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
function sanitizeUsage (u) {
|
|
3096
|
+
if (!u || typeof u !== 'object') return null;
|
|
3097
|
+
const used = {}; for (const [k, v] of Object.entries(u.used || {}).slice(0, 600)) used[sStr(k, 160)] = sNum(v);
|
|
3098
|
+
return {
|
|
3099
|
+
used,
|
|
3100
|
+
off: (Array.isArray(u.off) ? u.off : []).slice(0, 60).map((o) => ({ prop: sStr(o?.prop, 40), value: sStr(o?.value, 200), count: sNum(o?.count), nearest: o?.nearest ? sStr(o.nearest, 160) : null, sel: sStr(o?.sel, 300) })),
|
|
3101
|
+
hard: (Array.isArray(u.hard) ? u.hard : []).slice(0, 60).map((o) => ({ sel: sStr(o?.sel, 160), prop: sStr(o?.prop, 40), value: sStr(o?.value, 200), token: sStr(o?.token, 160) })),
|
|
3102
|
+
all: (Array.isArray(u.all) ? u.all : []).slice(0, 600).map((t) => ({ name: sStr(t?.name, 160), value: sStr(t?.value, 120), kind: sStr(t?.kind, 10), mode: t?.mode ? sStr(t.mode, 40) : null })),
|
|
3103
|
+
modes: (Array.isArray(u.modes) ? u.modes : []).slice(0, 10).map((m) => sStr(m, 40))
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// ---- screenshots: the captured spec drawn over the image ----
|
|
3108
|
+
const SPEC_GROUP = (p) => /^(font|line-height|letter-spacing|text-)/.test(p) ? 'type' : /color$|^background/.test(p) ? 'color' : /^(padding|margin|gap|row-gap|column-gap)$/.test(p) ? 'space' : /^(border-radius|border-width|box-shadow|opacity)$/.test(p) ? 'shape' : /^(display|flex|justify|align|grid|position)/.test(p) ? 'layout' : 'size';
|
|
3109
|
+
/** A spec element (compact styles) in the inspector's form. */
|
|
3110
|
+
function specElView (e, list) {
|
|
3111
|
+
const tokOf = (s) => s.token ? { m: 'exact', name: s.token, value: s.value, ...(s.mode ? { mode: s.mode } : {}), ...(s.hard ? { hard: s.value } : {}) } : s.nearest ? { m: 'nearest', name: s.nearest } : s.token === null ? { m: 'none' } : null;
|
|
3112
|
+
const rows = Object.entries(e.styles || {}).map(([prop, s]) => {
|
|
3113
|
+
const parts = String(s.value).split(' ');
|
|
3114
|
+
const names = String(s.token || s.nearest || '').split(' ');
|
|
3115
|
+
const multi = parts.length > 1 && names.length === parts.length && /^(padding|margin|border-radius|border-width)$/.test(prop);
|
|
3116
|
+
return { g: SPEC_GROUP(prop), prop, value: String(s.value), tok: multi ? null : tokOf(s), ...(multi ? { parts: parts.map((v, i) => ({ value: v, tok: names[i] === '0' ? null : s.token ? { m: 'exact', name: names[i] } : { m: 'nearest', name: names[i] } })) } : {}) };
|
|
3117
|
+
});
|
|
3118
|
+
if (e.box) rows.push({ g: 'size', prop: 'width', value: `${e.box[2]}px` }, { g: 'size', prop: 'height', value: `${e.box[3]}px` });
|
|
3119
|
+
const p = e.parent >= 0 ? list[e.parent] : null;
|
|
3120
|
+
const inset = p && e.box && p.box ? [e.box[1] - p.box[1], p.box[0] + p.box[2] - e.box[0] - e.box[2], p.box[1] + p.box[3] - e.box[1] - e.box[3], e.box[0] - p.box[0]] : null;
|
|
3121
|
+
return sanitizeSpec({ sel: e.sel, tag: e.tag, role: e.role, name: e.name, box: e.box, rows, contrast: e.contrast ? { ratio: e.contrast.ratio, aa: e.contrast.aa } : null, states: [], parent: p ? { sel: p.sel, tag: p.tag, inset } : null, assets: [] });
|
|
3122
|
+
}
|
|
3123
|
+
async function loadShotSpec (cap) {
|
|
3124
|
+
const key = `${S.open}|${S.loc}|${S.lbVp}`;
|
|
3125
|
+
if (DEV.shotKey === key) return DEV.shotSpec;
|
|
3126
|
+
DEV.shotKey = key; DEV.shotSpec = null;
|
|
3127
|
+
if (cap?.spec_inline) DEV.shotSpec = cap.spec_inline;
|
|
3128
|
+
else if (cap?.spec && APP()) { try { const r = await fetch(asset(cap.spec), { credentials: 'same-origin' }); if (r.ok) DEV.shotSpec = await r.json(); } catch {} }
|
|
3129
|
+
if (DEV.shotSpec && !Array.isArray(DEV.shotSpec.elements)) DEV.shotSpec = null;
|
|
3130
|
+
return DEV.shotSpec;
|
|
3131
|
+
}
|
|
3132
|
+
/** Elements to pick on a screenshot: the spec's, else the criteria boxes the capture recorded. */
|
|
3133
|
+
function shotElements (cap) {
|
|
3134
|
+
if (DEV.shotSpec) return DEV.shotSpec.elements.filter((e) => Array.isArray(e.box)).slice(0, 400);
|
|
3135
|
+
return Object.entries(cap?.boxes || {}).flatMap(([id, list]) => (list || []).map((b) => ({ i: -1, parent: -1, tag: id, sel: id, box: [b.x, b.y, b.w, b.h], styles: {} }))).filter((e) => e.box[2] > 0 && e.box[3] > 0);
|
|
3136
|
+
}
|
|
3137
|
+
function shotGaps (a, b) {
|
|
3138
|
+
const R = (x) => ({ l: x[0], t: x[1], r: x[0] + x[2], b: x[1] + x[3] });
|
|
3139
|
+
const A = R(a), B = R(b), inside = (o, i) => i.l >= o.l && i.r <= o.r && i.t >= o.t && i.b <= o.b;
|
|
3140
|
+
if (inside(B, A) || inside(A, B)) { const [o, i] = inside(B, A) ? [B, A] : [A, B]; return { inside: true, d: { top: i.t - o.t, right: o.r - i.r, bottom: o.b - i.b, left: i.l - o.l } }; }
|
|
3141
|
+
const d = {};
|
|
3142
|
+
if (B.l >= A.r) d.right = B.l - A.r; else if (A.l >= B.r) d.left = A.l - B.r;
|
|
3143
|
+
if (B.t >= A.b) d.bottom = B.t - A.b; else if (A.t >= B.b) d.top = A.t - B.b;
|
|
3144
|
+
return { inside: false, d };
|
|
3145
|
+
}
|
|
3146
|
+
function drawShot () {
|
|
3147
|
+
const host = $('lbImg'); if (!host) return;
|
|
3148
|
+
host.querySelector('.devshot')?.remove();
|
|
3149
|
+
if (!DEV.on || !DEV.shot) return;
|
|
3150
|
+
const cap = devCapOf(), img = host.querySelector('img'); if (!img || !cap) return;
|
|
3151
|
+
const k = img.clientWidth / (cap.size?.[0] || img.naturalWidth || 1);
|
|
3152
|
+
const els = shotElements(cap);
|
|
3153
|
+
const layer = document.createElement('div'); layer.className = 'devshot';
|
|
3154
|
+
Object.assign(layer.style, { left: `${img.offsetLeft}px`, top: `${img.offsetTop}px`, width: `${img.clientWidth}px`, height: `${img.clientHeight}px` });
|
|
3155
|
+
const box = (b, cls, label) => { const d = document.createElement('div'); d.className = cls; Object.assign(d.style, { left: `${b[0] * k}px`, top: `${b[1] * k}px`, width: `${b[2] * k}px`, height: `${b[3] * k}px` }); if (label) { const t = document.createElement('span'); t.textContent = label; d.appendChild(t); } layer.appendChild(d); };
|
|
3156
|
+
const sel = els[DEV.shotSel], tgt = els[DEV.shotTarget];
|
|
3157
|
+
if (DEV.hl) for (const e of els) { const hit = Object.entries(e.styles || {}).some(([p, s]) => DEV.hl.token ? String(s.token || '').split(' ').includes(DEV.hl.token) : p === DEV.hl.prop && s.value === DEV.hl.value); if (hit) box(e.box, 'devshot__hl'); }
|
|
3158
|
+
if (tgt) box(tgt.box, 'devshot__tgt');
|
|
3159
|
+
if (sel) box(sel.box, 'devshot__sel', `${sel.box[2]} × ${sel.box[3]}`);
|
|
3160
|
+
const hover = document.createElement('div'); hover.className = 'devshot__hov'; hover.hidden = true; const hl = document.createElement('span'); hover.appendChild(hl); layer.appendChild(hover);
|
|
3161
|
+
const at = (ev) => { const r = layer.getBoundingClientRect(), x = (ev.clientX - r.left) / k, y = (ev.clientY - r.top) / k; let best = -1, area = Infinity; els.forEach((e, i) => { const [bx, by, bw, bh] = e.box; if (x >= bx && x <= bx + bw && y >= by && y <= by + bh && bw * bh < area) { area = bw * bh; best = i; } }); return best; };
|
|
3162
|
+
layer.addEventListener('mousemove', (ev) => {
|
|
3163
|
+
const i = at(ev);
|
|
3164
|
+
if (i >= 0 && DEV.shotSel >= 0 && i !== DEV.shotSel && (ev.altKey || DEV.measureMode)) { if (i !== DEV.shotTarget) shotMeasure(i); hover.hidden = true; return; }
|
|
3165
|
+
if (i < 0) { hover.hidden = true; return; }
|
|
3166
|
+
const b = els[i].box; Object.assign(hover.style, { left: `${b[0] * k}px`, top: `${b[1] * k}px`, width: `${b[2] * k}px`, height: `${b[3] * k}px` }); hl.textContent = `${els[i].tag} · ${b[2]} × ${b[3]}`; hover.hidden = false;
|
|
3167
|
+
});
|
|
3168
|
+
layer.addEventListener('mouseleave', () => { hover.hidden = true; });
|
|
3169
|
+
layer.addEventListener('click', (ev) => { const i = at(ev); if (i < 0) return; if (DEV.shotSel >= 0 && (ev.altKey || DEV.measureMode)) { shotMeasure(i); return; } shotSelect(i); });
|
|
3170
|
+
host.appendChild(layer);
|
|
3171
|
+
}
|
|
3172
|
+
function shotSelect (i) {
|
|
3173
|
+
const els = shotElements(devCapOf()); if (!els[i]) return;
|
|
3174
|
+
DEV.shotSel = i; DEV.shotTarget = -1; DEV.measure = null;
|
|
3175
|
+
DEV.spec = DEV.base = specElView(els[i], DEV.shotSpec?.elements || els);
|
|
3176
|
+
if (lbPhone() && S.lbSheet === 'peek') setSheet('half');
|
|
3177
|
+
drawShot(); renderDev();
|
|
3178
|
+
}
|
|
3179
|
+
function shotMeasure (i) {
|
|
3180
|
+
const els = shotElements(devCapOf()); const a = els[DEV.shotSel], b = els[i]; if (!a || !b) return;
|
|
3181
|
+
DEV.shotTarget = i; const g = shotGaps(a.box, b.box);
|
|
3182
|
+
DEV.measure = { target: { sel: b.sel, tag: b.tag, name: b.name || '' }, inside: g.inside, d: g.d };
|
|
3183
|
+
drawShot(); renderDev();
|
|
3184
|
+
}
|
|
3185
|
+
function shotNav (dir) {
|
|
3186
|
+
const els = shotElements(devCapOf()); if (!els.length) return;
|
|
3187
|
+
const cur = els[DEV.shotSel];
|
|
3188
|
+
let i = -1;
|
|
3189
|
+
if (!cur || dir === 'start') i = Math.max(0, els.findIndex((e) => e.role === 'main'));
|
|
3190
|
+
else if (dir === 'parent') i = cur.parent >= 0 ? els.findIndex((e) => e.i === cur.parent) : -1;
|
|
3191
|
+
else if (dir === 'child') i = els.findIndex((e) => e.parent === cur.i && cur.i >= 0);
|
|
3192
|
+
else { const sib = els.map((e, k) => [e, k]).filter(([e]) => e.parent === cur.parent); const at = sib.findIndex(([, k]) => k === DEV.shotSel); const nx = sib[at + (dir === 'next' ? 1 : -1)]; i = nx ? nx[1] : -1; }
|
|
3193
|
+
if (i >= 0) shotSelect(i);
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
// ---- the panel ----
|
|
3197
|
+
const W_NAMES = { color: ['barvu textu', 'text color'], 'background-color': ['pozadí', 'background'], 'border-color': ['barvu rámečku', 'border color'], 'font-family': ['písmo', 'font family'], 'font-size': ['velikost písma', 'font size'], 'font-weight': ['řez písma', 'font weight'], 'line-height': ['výšku řádku', 'line height'], 'letter-spacing': ['prostrkání', 'letter spacing'], padding: ['vnitřní odsazení', 'padding'], margin: ['vnější odsazení', 'margin'], gap: ['mezery mezi potomky', 'gap'], 'row-gap': ['mezery mezi řádky', 'row gap'], 'column-gap': ['mezery mezi sloupci', 'column gap'], 'border-radius': ['zaoblení rohů', 'corner radius'], 'border-width': ['šířku rámečku', 'border width'], 'box-shadow': ['stín', 'shadow'] };
|
|
3198
|
+
const devVar = (name) => `var(--${String(name).replace(/\./g, '-')})`;
|
|
3199
|
+
const devTokName = (r) => r.parts ? (r.parts.every((p) => !p.tok || p.tok.m === 'exact') && r.parts.some((p) => p.tok) ? r.parts.map((p) => p.tok ? p.tok.name : '0').join(' ') : null) : r.tok?.m === 'exact' ? r.tok.name : null;
|
|
3200
|
+
const devNearest = (r) => r.parts ? (r.parts.some((p) => p.tok && p.tok.m !== 'exact') ? r.parts.map((p) => p.tok?.name || (p.tok ? '?' : '0')).join(' ') : null) : r.tok?.m === 'nearest' ? r.tok.name : null;
|
|
3201
|
+
function devElLabel (s) { const q = UI === 'cs' ? ['„', '“'] : ['"', '"']; return `${s.tag}${s.name ? ` ${q[0]}${s.name}${q[1]}` : ''}`; }
|
|
3202
|
+
function devPromptLine (r, s) {
|
|
3203
|
+
const w = W_NAMES[r.prop]?.[UI === 'cs' ? 0 : 1] || r.prop, el = devElLabel(s);
|
|
3204
|
+
const tok = devTokName(r), near = devNearest(r);
|
|
3205
|
+
if (tok) return T('promptUse').replace('%W', w).replace('%T', tok).replace('%E', el);
|
|
3206
|
+
if (near) return T('promptOff').replace('%P', r.prop).replace('%V', r.value).replace('%T', near);
|
|
3207
|
+
if (r.tok?.m === 'none') return T('promptOffNone').replace('%P', r.prop).replace('%V', r.value);
|
|
3208
|
+
return null;
|
|
3209
|
+
}
|
|
3210
|
+
function devCopies (s) {
|
|
3211
|
+
const n = byId(S.open);
|
|
3212
|
+
const rows = s.rows.filter((r) => r.g !== 'size');
|
|
3213
|
+
const css = `/* ${s.sel}${s.state !== 'default' ? ` :${s.state}` : ''} */\n` + rows.map((r) => `${r.prop}: ${r.value};`).join('\n');
|
|
3214
|
+
const vars = `/* ${s.sel}${s.state !== 'default' ? ` :${s.state}` : ''} */\n` + rows.map((r) => {
|
|
3215
|
+
const tok = devTokName(r), near = devNearest(r);
|
|
3216
|
+
if (tok) return `${r.prop}: ${tok.split(' ').map((t) => t === '0' ? '0' : devVar(t)).join(' ')}; /* ${r.value} */`;
|
|
3217
|
+
return `${r.prop}: ${r.value};${near ? ` /* ${T('notToken')}, ${T('nearest')} ${near} */` : r.tok?.m === 'none' ? ` /* ${T('notToken')} */` : ''}`;
|
|
3218
|
+
}).join('\n');
|
|
3219
|
+
const tokens = {}, off = {};
|
|
3220
|
+
for (const r of rows) { const tok = devTokName(r); if (tok) tokens[r.prop] = tok.includes(' ') ? tok.split(' ') : tok; else if (devNearest(r) || r.tok?.m === 'none') off[r.prop] = { value: r.value, nearest: devNearest(r) }; }
|
|
3221
|
+
const json = JSON.stringify({ element: s.sel, ...(s.state !== 'default' ? { state: s.state } : {}), tokens, ...(Object.keys(off).length ? { not_tokens: off } : {}) }, null, 2);
|
|
3222
|
+
const lines = rows.map((r) => devPromptLine(r, s)).filter(Boolean);
|
|
3223
|
+
const prompt = [T('promptHead').replace('%E', `${devElLabel(s)} (${s.sel})`).replace('%S', `${n ? loc(n.title) : S.open}${s.state !== 'default' ? `, :${s.state}` : ''}`), ...lines.map((l) => `- ${l}`), T('promptSpec').replace('%N', S.open)].join('\n');
|
|
3224
|
+
return { css, vars, json, prompt };
|
|
3225
|
+
}
|
|
3226
|
+
function devTokCell (t, r) {
|
|
3227
|
+
if (!t) return '';
|
|
3228
|
+
if (t.m === 'exact') return `<button type="button" class="devtok" data-dev-copy="${esc(t.name)}" title="${esc(T('copyTokenPath'))}">${esc(t.name)}</button>${t.mode ? ` <span class="devmode">${esc(t.mode)}</span>` : ''}${t.hard ? ` <span class="devwarn" title="${esc(T('typedRaw'))}">${esc(T('typedRawShort'))}</span>` : ''}`;
|
|
3229
|
+
if (t.m === 'nearest') return `<span class="devwarn">${esc(T('notToken'))}</span> · ${esc(T('nearest'))} <button type="button" class="devtok devtok--near" data-dev-copy="${esc(t.name)}">${esc(t.name)}</button>${t.value ? ` <span class="devmuted">${esc(t.value)}</span>` : ''}`;
|
|
3230
|
+
return `<span class="devwarn">${esc(T('notToken'))}</span>${t.via ? ` <span class="devmuted">${esc(t.via)}</span>` : ''}`;
|
|
3231
|
+
}
|
|
3232
|
+
function devRowHtml (r, s, i) {
|
|
3233
|
+
const changed = DEV.base && s.state !== 'default' && DEV.base.rows.find((b) => b.prop === r.prop)?.value !== r.value;
|
|
3234
|
+
const sw = r.g === 'color' && HEX.test(r.value) ? `<span class="devsw" style="background:${r.value}"></span>` : '';
|
|
3235
|
+
const tok = r.parts ? r.parts.map((p) => p.tok ? devTokCell(p.tok) : '<span class="devmuted">0</span>').join(' ') : devTokCell(r.tok);
|
|
3236
|
+
const line = devPromptLine(r, s);
|
|
3237
|
+
return `<tr class="${changed ? 'devchg' : ''}" data-prop="${esc(r.prop)}"><td>${esc(r.prop)}</td><td><span class="devval">${sw}${esc(r.value)}</span>${tok ? `<div class="devtokrow">${tok}</div>` : ''}</td><td>${line ? `<button type="button" class="icon-btn devline" data-dev-line="${i}" aria-label="${esc(T('copyLine'))}" title="${esc(T('copyLine'))}">${ICON.copy}</button>` : ''}</td></tr>`;
|
|
3238
|
+
}
|
|
3239
|
+
function devElHtml () {
|
|
3240
|
+
const s = DEV.spec;
|
|
3241
|
+
const nav = `<div class="devnav" role="group" aria-label="${esc(T('devNavLabel'))}">${[['parent', '↑', T('navParent')], ['child', '↓', T('navChild')], ['prev', '←', T('navPrev')], ['next', '→', T('navNext')]].map(([d, a, l]) => `<button type="button" class="btn" data-dev-nav="${d}" title="${esc(l)} (Shift + ${a})" ${s ? '' : 'disabled'}>${a} ${esc(l)}</button>`).join('')}</div>`;
|
|
3242
|
+
if (!s) return `${DEV.shot ? `<p class="note">${esc(DEV.shotSpec ? T('devShotNote') : T('devNoSpec'))}</p>` : ''}<p class="note">${esc(T('devEmpty'))}</p><button type="button" class="btn" data-dev-nav="start">${esc(T('devStart'))}</button>`;
|
|
3243
|
+
const groups = DEV_GROUPS.map((g) => {
|
|
3244
|
+
const rows = s.rows.map((r, i) => [r, i]).filter(([r]) => r.g === g);
|
|
3245
|
+
const contrast = g === 'color' && s.contrast ? `<tr class="devcontrast"><td>${esc(T('contrast'))}</td><td colspan="2"><b>${s.contrast.ratio} : 1</b> · <span class="${s.contrast.aa ? 'devok' : 'devwarn'}">${esc(s.contrast.aa ? T('aaPass') : T('aaFail'))}</span>${s.contrast.aa ? '' : ` (${esc(T('aaNeed'))} ${s.contrast.large ? 3 : 4.5} : 1)`}${s.contrast.large ? ` · ${esc(T('largeText'))}` : ''}${s.contrast.approx ? `<div class="devmuted">${esc(T('contrastApprox'))}</div>` : ''}</td></tr>` : '';
|
|
3246
|
+
return rows.length || contrast ? `<h5>${esc(T('g_' + g))}</h5><table>${rows.map(([r, i]) => devRowHtml(r, s, i)).join('')}${contrast}</table>` : '';
|
|
3247
|
+
}).join('');
|
|
3248
|
+
const st = DEV.shot ? '' : `<h5>${esc(T('states'))}</h5><div class="seg devstates" role="group" aria-label="${esc(T('states'))}">${DEV_STATES.map((x) => `<button type="button" data-dev-state="${x}" aria-pressed="${s.state === x}" ${x === 'default' || s.states.includes(x) ? '' : 'disabled'}>${x === 'default' ? esc(T('stDefault')) : x === 'disabled' ? 'disabled' : ':' + x}</button>`).join('')}</div>${s.states.length ? (s.state !== 'default' ? `<p class="note">${esc(T('changedInState'))}</p>` : '') : `<p class="note">${esc(T('stNone'))}</p>`}`;
|
|
3249
|
+
const inset = s.parent?.inset;
|
|
3250
|
+
const arrows = (d) => ['top', 'right', 'bottom', 'left'].filter((k) => d[k] != null).map((k) => `<span data-gap="${k}">${{ top: '↑', right: '→', bottom: '↓', left: '←' }[k]} ${Math.round(d[k] * 10) / 10}</span>`).join(' · ');
|
|
3251
|
+
const dist = `<h5>${esc(T('distances'))}</h5><div class="devdist">${inset ? `<p>${esc(T('toParent'))} <code>${esc(s.parent.tag)}</code>: ${arrows({ top: inset[0], right: inset[1], bottom: inset[2], left: inset[3] })}</p>` : ''}${DEV.measure ? `<p id="devMeasure">${esc(T('toTarget'))} <code>${esc(DEV.measure.target.tag)}</code>${DEV.measure.target.name ? ` ${esc(devElLabel(DEV.measure.target).slice(DEV.measure.target.tag.length))}` : ''}: ${arrows(DEV.measure.d) || '0'}</p>` : ''}<p class="note"><button type="button" class="btn" id="devMeasureBtn" aria-pressed="${DEV.measureMode}">${esc(T('measure'))} (M)</button> ${esc(T('measureHint'))}</p></div>`;
|
|
3252
|
+
const assets = s.assets.length ? `<h5>${esc(T('assets'))}</h5><ul class="devassets">${s.assets.map((a) => `<li>${a.preview ? `<img src="${esc(a.preview)}" alt="">` : `<span class="devassets__k">${esc(a.kind)}</span>`}<span class="devassets__n">${esc(a.name)}${a.size ? ` <span class="devmuted">${a.size[0]} × ${a.size[1]}</span>` : ''}${a.alt ? `<span class="devmuted"> · alt „${esc(a.alt)}“</span>` : ''}</span><button type="button" class="btn" data-dev-asset="${a.i}">${esc(T('download'))}</button></li>`).join('')}</ul>` : '';
|
|
3253
|
+
const pin = canWrite() && APP() && !DEV.shot ? `<button type="button" class="btn" id="devPin">${esc(T('pinFromDev'))}</button>` : '';
|
|
3254
|
+
return `<div class="devhead"><b>${esc(devElLabel(s))}</b> <span class="devmuted">${s.box[2]} × ${s.box[3]}${s.role && s.role !== s.tag ? ` · ${esc(s.role)}` : ''}</span><div class="anchor-note">${esc(s.sel)}</div></div>${nav}${dist}${st}${groups}${assets}
|
|
3255
|
+
<div class="devcopy"><button type="button" class="btn" data-dev-copyall="css">${esc(T('copyCss'))}</button><button type="button" class="btn" data-dev-copyall="vars">${esc(T('copyCssVars'))}</button><button type="button" class="btn" data-dev-copyall="json">${esc(T('copyTokens'))}</button><button type="button" class="btn btn--primary" data-dev-copyall="prompt">${esc(T('copyPrompt'))}</button>${pin}</div>
|
|
3256
|
+
<p class="note">${esc(T('devAgentHint').replace('%N', S.open))}</p>`;
|
|
3257
|
+
}
|
|
3258
|
+
function devTokensHtml () {
|
|
3259
|
+
const u = DEV.shot ? (DEV.shotSpec ? { used: DEV.shotSpec.used || {}, off: DEV.shotSpec.off || [], hard: [], all: [] } : null) : DEV.usage;
|
|
3260
|
+
if (!u) return `<p class="note">${esc(DEV.shot ? T('devNoSpec') : T('devLoading'))}</p>`;
|
|
3261
|
+
const all = new Map(u.all.map((t) => [t.name, t]));
|
|
3262
|
+
const used = Object.entries(u.used).sort((a, b) => b[1] - a[1]);
|
|
3263
|
+
if (!used.length && !u.all.length && !u.off.length) return `<p class="note">${esc(T('tokNone'))}</p>`;
|
|
3264
|
+
const on = (q) => DEV.hl && ((q.token && DEV.hl.token === q.token) || (q.prop && DEV.hl.prop === q.prop && DEV.hl.value === q.value));
|
|
3265
|
+
const sw = (v) => HEX.test(v || '') ? `<span class="devsw" style="background:${v}"></span>` : '';
|
|
3266
|
+
const usedNames = new Set(used.map(([k]) => k));
|
|
3267
|
+
const unused = u.all.filter((t) => !usedNames.has(t.name) && !t.name.split('.').slice(0, -1).some((_, i, a) => usedNames.has(t.name.split('.').slice(0, a.length - i).join('.'))));
|
|
3268
|
+
return `<p class="note">${esc(T('tokHint'))}</p>
|
|
3269
|
+
<h5>${esc(T('tokUsed'))} (${used.length})</h5><ul class="devtoks">${used.map(([name, n]) => { const t = all.get(name); return `<li><button type="button" data-dev-hl-token="${esc(name)}" aria-pressed="${!!on({ token: name })}">${sw(t?.value)}<span class="devtoks__n">${esc(name)}</span>${t ? `<span class="devmuted">${esc(t.value)}${t.mode ? ` · ${esc(t.mode)}` : ''}</span>` : ''}<span class="devtoks__c">×${n}</span></button></li>`; }).join('')}</ul>
|
|
3270
|
+
${u.off.length ? `<h5>${esc(T('tokOff'))} (${u.off.length})</h5><ul class="devtoks">${u.off.map((o) => `<li><button type="button" data-dev-hl-prop="${esc(o.prop)}" data-dev-hl-value="${esc(o.value)}" aria-pressed="${!!on(o)}">${sw(o.value)}<span class="devtoks__n">${esc(o.prop)}: ${esc(o.value)}</span><span class="devmuted">${o.nearest ? `${esc(T('nearest'))} ${esc(o.nearest)}` : esc(T('notToken'))}</span><span class="devtoks__c">×${o.count}</span></button></li>`).join('')}</ul>` : ''}
|
|
3271
|
+
${u.hard.length ? `<h5>${esc(T('tokHard'))} (${u.hard.length})</h5><ul class="devtoks devtoks--plain">${u.hard.map((h) => `<li><code>${esc(h.sel)}</code> ${esc(h.prop)}: ${esc(h.value)} → <code>${esc(devVar(h.token))}</code></li>`).join('')}</ul>` : ''}
|
|
3272
|
+
${unused.length ? `<details class="devunused"><summary>${esc(T('tokUnused'))} (${unused.length})</summary><ul class="devtoks devtoks--plain">${unused.map((t) => `<li>${sw(t.value)}<code>${esc(t.name)}</code> <span class="devmuted">${esc(t.value)}${t.mode ? ` · ${esc(t.mode)}` : ''}</span></li>`).join('')}</ul></details>` : ''}`;
|
|
3273
|
+
}
|
|
3274
|
+
function devPaneHtml () {
|
|
3275
|
+
if (!DEV.on) return '';
|
|
3276
|
+
return `<div class="sec dev" id="devPane"><div class="devtabs seg" role="tablist">${[['el', T('devTabEl')], ['tokens', T('devTabTokens')]].map(([k, l]) => `<button type="button" role="tab" data-dev-tab="${k}" aria-selected="${DEV.tab === k}" aria-pressed="${DEV.tab === k}">${esc(l)}</button>`).join('')}</div><div id="devBody">${DEV.tab === 'el' ? devElHtml() : devTokensHtml()}</div></div>`;
|
|
3277
|
+
}
|
|
3278
|
+
function renderDev () {
|
|
3279
|
+
const p = $('devPane'); if (!p) return;
|
|
3280
|
+
p.outerHTML = devPaneHtml();
|
|
3281
|
+
wireDev();
|
|
3282
|
+
}
|
|
3283
|
+
async function devCopy (text) { try { await navigator.clipboard.writeText(text); toast(T('copied')); } catch { toast(text.slice(0, 200)); } }
|
|
3284
|
+
function wireDev () {
|
|
3285
|
+
const p = $('devPane'); if (!p) return;
|
|
3286
|
+
p.onclick = (e) => {
|
|
3287
|
+
const b = e.target.closest('button'); if (!b || b.disabled) return;
|
|
3288
|
+
const s = DEV.spec;
|
|
3289
|
+
if (b.dataset.devTab) { DEV.tab = b.dataset.devTab; if (DEV.tab === 'tokens' && !DEV.shot && !DEV.usage) toFrame({ type: 'qawalk:dev-usage' }); renderDev(); return; }
|
|
3290
|
+
if (b.dataset.devNav) { devNav(b.dataset.devNav); return; }
|
|
3291
|
+
if (b.dataset.devState && s) { toFrame({ type: 'qawalk:dev-state', state: b.dataset.devState }); return; }
|
|
3292
|
+
if (b.dataset.devCopy) { devCopy(b.dataset.devCopy); return; }
|
|
3293
|
+
if (b.dataset.devLine && s) { const r = s.rows[Number(b.dataset.devLine)]; const l = r && devPromptLine(r, s); if (l) devCopy(l); return; }
|
|
3294
|
+
if (b.dataset.devCopyall && s) { devCopy(devCopies(s)[b.dataset.devCopyall]); return; }
|
|
3295
|
+
if (b.dataset.devAsset != null) { toFrame({ type: 'qawalk:dev-asset', i: Number(b.dataset.devAsset) }); return; }
|
|
3296
|
+
if (b.id === 'devMeasureBtn') { devMeasureMode(!DEV.measureMode); return; }
|
|
3297
|
+
if (b.id === 'devPin' && s) { LIVE.anchor = { selector: s.sel, box: s.box, text: s.name, viewport: S.lbVp }; showLbTab('overview'); rerenderComments(); focusComposer(); return; }
|
|
3298
|
+
const q = b.dataset.devHlToken ? { token: b.dataset.devHlToken } : b.dataset.devHlProp ? { prop: b.dataset.devHlProp, value: b.dataset.devHlValue } : null;
|
|
3299
|
+
if (q) {
|
|
3300
|
+
const same = DEV.hl && JSON.stringify(DEV.hl) === JSON.stringify(q);
|
|
3301
|
+
DEV.hl = same ? null : q;
|
|
3302
|
+
if (DEV.shot) { drawShot(); if (DEV.hl && lbPhone()) setSheet('peek'); } else toFrame({ type: 'qawalk:dev-highlight', ...(DEV.hl || {}) });
|
|
3303
|
+
renderDev();
|
|
3304
|
+
}
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
/** Messages of the frame helper in Dev mode. */
|
|
3308
|
+
function devMessage (m) {
|
|
3309
|
+
if (m.type === 'qawalk:dev-spec') {
|
|
3310
|
+
const s = sanitizeSpec(m.spec); if (!s) return;
|
|
3311
|
+
const first = !DEV.spec;
|
|
3312
|
+
if (s.state === 'default' || !DEV.base || DEV.base.sel !== s.sel) DEV.base = s;
|
|
3313
|
+
DEV.spec = s; DEV.measure = null;
|
|
3314
|
+
if (DEV.tab !== 'el') DEV.tab = 'el';
|
|
3315
|
+
if (S.lbTab !== 'dev') showLbTab('dev');
|
|
3316
|
+
if (first && lbPhone() && S.lbSheet === 'peek') setSheet('half');
|
|
3317
|
+
renderDev();
|
|
3318
|
+
}
|
|
3319
|
+
if (m.type === 'qawalk:dev-measure') {
|
|
3320
|
+
const x = m.m;
|
|
3321
|
+
DEV.measure = x && typeof x === 'object' ? { target: { sel: sStr(x.target?.sel, 500), tag: sStr(x.target?.tag, 20), name: sStr(x.target?.name, 60) }, inside: !!x.inside, d: Object.fromEntries(['top', 'right', 'bottom', 'left'].filter((k) => x.d?.[k] != null).map((k) => [k, sNum(x.d[k])])) } : null;
|
|
3322
|
+
renderDev();
|
|
3323
|
+
}
|
|
3324
|
+
if (m.type === 'qawalk:dev-usage') { DEV.usage = sanitizeUsage(m.usage); if (DEV.tab === 'tokens') renderDev(); }
|
|
3325
|
+
if (m.type === 'qawalk:dev-highlighted' && DEV.hl) toast(`${T('tokHlN')} ${sNum(m.count)}`);
|
|
3326
|
+
if (m.type === 'qawalk:dev-asset') {
|
|
3327
|
+
if (m.error || !(m.data instanceof ArrayBuffer) || m.data.byteLength > 20 * 1024 * 1024) { toast(T('assetFail')); return; }
|
|
3328
|
+
// an octet-stream blob downloads and never renders on this origin (an SVG could carry script)
|
|
3329
|
+
const url = URL.createObjectURL(new Blob([m.data], { type: 'application/octet-stream' }));
|
|
3330
|
+
const a = document.createElement('a'); a.href = url; a.download = sStr(m.name, 80).replace(/[^\w.\- ]+/g, '-') || 'asset'; document.body.appendChild(a); a.click(); a.remove();
|
|
3331
|
+
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
/** Keys of the step detail in Dev mode; true when handled. */
|
|
3335
|
+
function devKey (key, shift) {
|
|
3336
|
+
if (key === 'd' || key === 'D') { if (DEV.on || devAvailable(devCapOf())) { setDev(!DEV.on); return true; } return false; }
|
|
3337
|
+
if (!DEV.on) return false;
|
|
3338
|
+
if (key === 'm' || key === 'M') { devMeasureMode(!DEV.measureMode); return true; }
|
|
3339
|
+
if (key === 'Escape') { devEsc(); return true; }
|
|
3340
|
+
if (shift && /^Arrow(Up|Down|Left|Right)$/.test(key)) { devNav(DEV.spec ? { ArrowUp: 'parent', ArrowDown: 'child', ArrowLeft: 'prev', ArrowRight: 'next' }[key] : 'start'); return true; }
|
|
3341
|
+
return false;
|
|
2849
3342
|
}
|
|
2850
3343
|
function stepStatusHtml (n) {
|
|
2851
3344
|
if (!isDraft()) return '';
|
|
@@ -2867,7 +3360,7 @@ function ptOpen (id, vpName, extra = {}) {
|
|
|
2867
3360
|
const list = ptList(); if (!list.length) return;
|
|
2868
3361
|
const n = list.includes(byId(id)) ? byId(id) : list[0];
|
|
2869
3362
|
if ($('pt').hidden) { PT.from = $('lb').hidden ? null : S.open; PT.device = innerWidth > 700; }
|
|
2870
|
-
$('lb').hidden = true; LIVE.mode = null;
|
|
3363
|
+
$('lb').hidden = true; LIVE.mode = null; DEV.on = false; devReset(); if (S.lbTab === 'dev') S.lbTab = 'overview';
|
|
2871
3364
|
PT.id = n.id; PT.query = extra.query || ''; PT.hash = extra.hash || ''; PT.path = extra.path || '';
|
|
2872
3365
|
PT.vp = viewports().some((v) => v.name === vpName) ? vpName : PT.vp || S.lbVp || (S.vp === 'mobile' ? mVp() : dVp())?.name || viewports()[0].name;
|
|
2873
3366
|
renderPt();
|
|
@@ -2924,6 +3417,352 @@ addEventListener('resize', () => { if (!$('pt').hidden) fitPt(); });
|
|
|
2924
3417
|
document.addEventListener('fullscreenchange', () => { const b = $('ptFull'); if (b) b.setAttribute('aria-pressed', String(!!document.fullscreenElement)); setTimeout(fitPt, 60); });
|
|
2925
3418
|
|
|
2926
3419
|
/* ---------- boot ---------- */
|
|
3420
|
+
/* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ----------
|
|
3421
|
+
The queue comes from the service (GET …/runs/<id>/review-queue: order, reasons, decisions, approvals carried over
|
|
3422
|
+
unchanged steps, the accepted run to compare with); a local preview or a public run builds a read-only queue from
|
|
3423
|
+
the run itself. Keys: J/K (→/←) next and previous, A approve, R return with a note, C comment, V this run / side
|
|
3424
|
+
by side / the accepted run, Esc leaves; on a phone a swipe and the buttons do the same. Kept apart from the
|
|
3425
|
+
lightbox (its own overlay #rv and functions rv*), so both can change independently. */
|
|
3426
|
+
Object.assign(I18N.cs, {
|
|
3427
|
+
rvTitle: 'Kontrola', rvBtnTitle: 'Kontrola: krok po kroku to, co čeká na člověka', rvDecided: 'rozhodnuto', rvCarriedN: 'převzato',
|
|
3428
|
+
rvKeys: 'J K další a předchozí · A schválit · R vrátit · C komentář · V verze · Esc konec', rvExit: 'Ukončit kontrolu',
|
|
3429
|
+
rvPrev: 'Předchozí', rvNext: 'Další', rvApprove: 'Schválit', rvReturn: 'Vrátit', rvReturnTitle: 'Co se má změnit?',
|
|
3430
|
+
rvReturnPh: 'Napište, co je špatně a jak to má být. Autor běhu to uvidí hned.', rvReturnSend: 'Vrátit krok',
|
|
3431
|
+
rvAccepted: 'přijatý běh', rvViewThis: 'Tento běh', rvViewSide: 'Vedle sebe', rvViewRef: 'Přijatý běh',
|
|
3432
|
+
rvNoRef: 'V přijatém běhu tento snímek není.', rvRefLoading: 'Načítám přijatý běh…', rvLoading: 'Načítám, co čeká na kontrolu…',
|
|
3433
|
+
rvR_fail: 'Selhalo', rvR_returned: 'Vráceno', rvR_needs_review: 'Čeká na posouzení', rvR_not_evaluated: 'Nevyhodnoceno', rvR_revoked: 'Převzetí zrušeno',
|
|
3434
|
+
rvR_changed: 'Změna od přijatého běhu', rvR_new: 'Nový krok', rvR_comments: 'Otevřené komentáře', rvR_carried: 'Schválení převzato',
|
|
3435
|
+
rvWaiting: 'Čeká na rozhodnutí', rvApprovedBy: 'Schváleno', rvReturnedBy: 'Vráceno',
|
|
3436
|
+
rvCarriedStep: (run, by, at) => `Převzato z běhu ${run}, schválil(a) ${by} ${at}.`, rvCarriedRun: (run, by, at) => `Převzato z běhu ${run}, který ${by} přijal(a) ${at}.`,
|
|
3437
|
+
rvCarriedWhy: 'Snímky i kritéria jsou stejné jako v přijatém běhu a automatické kontroly prošly.',
|
|
3438
|
+
rvRevoke: 'Zrušit převzaté schválení', rvRevoked: 'Převzaté schválení je zrušené; krok čeká na kontrolu.',
|
|
3439
|
+
rvUnconfirmed: 'Rozhodnutí agenta nebo tokenu; za rozhodnutí člověka se nepočítá', rvChangedIn: 'Změněné snímky', rvCritChanged: 'Kritéria se od přijatého běhu změnila.',
|
|
3440
|
+
rvApprovedToast: 'Schváleno', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
|
|
3441
|
+
rvDone: 'Všechny kroky jsou rozhodnuté', rvLeft: (n) => `Na rozhodnutí ${n === 1 ? 'čeká 1 krok' : n < 5 ? `čekají ${n} kroky` : `čeká ${n} kroků`}`,
|
|
3442
|
+
rvNothing: 'Nic nečeká na člověka: kroky prošly a od přijatého běhu se nezměnily.', rvSumApproved: 'schváleno', rvSumReturned: 'vráceno', rvSumClear: 'beze změny a bez připomínek',
|
|
3443
|
+
rvCarriedTitle: 'Převzatá schválení', rvCarriedLead: 'Tyto kroky vypadají stejně jako v přijatém běhu a jejich automatické kontroly prošly. U každého je, kdo ho schválil.',
|
|
3444
|
+
rvShow: 'Zobrazit', rvVerdictLead: 'Převzatá schválení se za přijetí běhu nepočítají: běh jako celek přijímá člověk.',
|
|
3445
|
+
rvReturnedHint: 'Vrácené kroky uvidí autor běhu v inboxu a agent v kaloko feedback.', rvCarriedShort: 'převzato', rvToEnd: 'Souhrn', rvHint: 'kontrola krok po kroku', rvQueue: 'Kroky ke kontrole', rvRefFailed: 'Přijatý běh se nepodařilo načíst.', rvComparedWith: 'Porovnáno s přijatým během'
|
|
3446
|
+
})
|
|
3447
|
+
Object.assign(I18N.en, {
|
|
3448
|
+
rvTitle: 'Review', rvBtnTitle: 'Review: one step at a time, what waits for a person', rvDecided: 'decided', rvCarriedN: 'carried over',
|
|
3449
|
+
rvKeys: 'J K next and previous · A approve · R return · C comment · V version · Esc leave', rvExit: 'Leave review',
|
|
3450
|
+
rvPrev: 'Previous', rvNext: 'Next', rvApprove: 'Approve', rvReturn: 'Return', rvReturnTitle: 'What should change?',
|
|
3451
|
+
rvReturnPh: 'Say what is wrong and how it should be. The author of the run sees it right away.', rvReturnSend: 'Return the step',
|
|
3452
|
+
rvAccepted: 'accepted run', rvViewThis: 'This run', rvViewSide: 'Side by side', rvViewRef: 'Accepted run',
|
|
3453
|
+
rvNoRef: 'The accepted run has no such screenshot.', rvRefLoading: 'Loading the accepted run…', rvLoading: 'Loading what waits for review…',
|
|
3454
|
+
rvR_fail: 'Failed', rvR_returned: 'Returned', rvR_needs_review: 'Needs a person’s judgement', rvR_not_evaluated: 'Not evaluated', rvR_revoked: 'Carried approval revoked',
|
|
3455
|
+
rvR_changed: 'Changed since the accepted run', rvR_new: 'New step', rvR_comments: 'Open comments', rvR_carried: 'Approval carried over',
|
|
3456
|
+
rvWaiting: 'Waiting for a decision', rvApprovedBy: 'Approved', rvReturnedBy: 'Returned',
|
|
3457
|
+
rvCarriedStep: (run, by, at) => `Carried over from run ${run}: approved by ${by} on ${at}.`, rvCarriedRun: (run, by, at) => `Carried over from run ${run}, which ${by} accepted on ${at}.`,
|
|
3458
|
+
rvCarriedWhy: 'The screenshots and criteria are the same as in the accepted run and the automatic checks passed.',
|
|
3459
|
+
rvRevoke: 'Revoke the carried approval', rvRevoked: 'Carried approval revoked; the step waits for review.',
|
|
3460
|
+
rvUnconfirmed: 'An agent’s or token’s decision; it does not count as a person’s', rvChangedIn: 'Changed screenshots', rvCritChanged: 'The criteria changed since the accepted run.',
|
|
3461
|
+
rvApprovedToast: 'Approved', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
|
|
3462
|
+
rvDone: 'Every step is decided', rvLeft: (n) => `${n} step${n === 1 ? '' : 's'} still wait${n === 1 ? 's' : ''} for a decision`,
|
|
3463
|
+
rvNothing: 'Nothing waits for a person: the steps passed and did not change since the accepted run.', rvSumApproved: 'approved', rvSumReturned: 'returned', rvSumClear: 'unchanged, nothing to look at',
|
|
3464
|
+
rvCarriedTitle: 'Carried-over approvals', rvCarriedLead: 'These steps look the same as in the accepted run and their automatic checks passed. Each says who approved it.',
|
|
3465
|
+
rvShow: 'Show', rvVerdictLead: 'Carried approvals do not count as accepting the run: a person accepts the run as a whole.',
|
|
3466
|
+
rvReturnedHint: 'The author of the run sees returned steps in the inbox, the agent in kaloko feedback.', rvCarriedShort: 'carried over', rvToEnd: 'Summary', rvHint: 'review step by step', rvQueue: 'Steps to review', rvRefFailed: 'The accepted run could not be loaded.', rvComparedWith: 'Compared with the accepted run'
|
|
3467
|
+
})
|
|
3468
|
+
const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [] };
|
|
3469
|
+
const rvService = () => !!(APP()?.user && APP()?.run && !isDraft());
|
|
3470
|
+
const rvWritable = () => rvService() && canWrite();
|
|
3471
|
+
const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
|
|
3472
|
+
const rvCur = () => rvItems()[RV.idx] || null;
|
|
3473
|
+
const RV_GROUP = { fail: 0, returned: 0, needs_review: 1, not_evaluated: 1, revoked: 1, changed: 2, new: 2, comments: 3 };
|
|
3474
|
+
const rvDay = (iso) => (iso ? fmtDate(iso).replace(/ \d{2}:\d{2}$/, '') : '');
|
|
3475
|
+
const rvOpenCrit = (c) => Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
|
|
3476
|
+
|
|
3477
|
+
/** A read-only queue from the run itself (local preview, export, public run): no carry-over, no comparison. */
|
|
3478
|
+
function rvLocalQueue () {
|
|
3479
|
+
const items = []; let clear = 0;
|
|
3480
|
+
flowOrder().forEach((n, position) => {
|
|
3481
|
+
const vs = (n.criteria || []).map((c) => c.verdict).filter(Boolean);
|
|
3482
|
+
const reasons = [];
|
|
3483
|
+
if (n.status === 'fail' || vs.includes('fail')) reasons.push('fail');
|
|
3484
|
+
if (vs.includes('needs_review')) reasons.push('needs_review');
|
|
3485
|
+
if (vs.includes('not_evaluated')) reasons.push('not_evaluated');
|
|
3486
|
+
const st = reviewState(n.id);
|
|
3487
|
+
if (st === 'rejected') reasons.unshift('returned');
|
|
3488
|
+
const k = APP() ? stepOpenCount(n.id) : 0;
|
|
3489
|
+
if (k) reasons.push('comments');
|
|
3490
|
+
if (!reasons.length) { clear++; return; }
|
|
3491
|
+
items.push({ step: n.id, title: n.title, reasons, position, open_comments: k, decision: st ? { state: st === 'approved' ? 'approved' : 'returned' } : null, changed: null, ref_captures: null });
|
|
3492
|
+
});
|
|
3493
|
+
const g = (it) => Math.min(...it.reasons.map((r) => RV_GROUP[r] ?? 4));
|
|
3494
|
+
items.sort((a, b) => g(a) - g(b) || a.position - b.position);
|
|
3495
|
+
const decided = items.filter((it) => it.decision).length;
|
|
3496
|
+
return { local: true, run: { id: RUN.id }, reference: null, policy: { carry_over: false }, counts: { steps: screens().length, queue: items.length, decided, pending: items.length - decided, carried: 0, clear }, items, carried: [], verdict: RUN.verdict || null };
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
async function rvLoad () {
|
|
3500
|
+
if (!rvService()) { RV.data = rvLocalQueue(); rvButton(); return RV.data; }
|
|
3501
|
+
const a = APP();
|
|
3502
|
+
const had = (RV.data?.carried || []).length;
|
|
3503
|
+
try {
|
|
3504
|
+
const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}/review-queue`, { credentials: 'same-origin' });
|
|
3505
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
3506
|
+
RV.data = await res.json();
|
|
3507
|
+
} catch (e) { if (!RV.data) RV.data = rvLocalQueue(); }
|
|
3508
|
+
rvButton();
|
|
3509
|
+
if ((RV.data.carried || []).length || had) renderNodes(); // the carried-over badges on the canvas
|
|
3510
|
+
return RV.data;
|
|
3511
|
+
}
|
|
3512
|
+
function rvButton () {
|
|
3513
|
+
const b = $('rvBtn'); if (!b) return;
|
|
3514
|
+
const n = RV.data?.counts?.pending || 0;
|
|
3515
|
+
b.title = `${T('rvBtnTitle')} (Q)`;
|
|
3516
|
+
b.querySelector('.rv-count')?.remove();
|
|
3517
|
+
if (n) b.insertAdjacentHTML('beforeend', `<b class="rv-count" aria-label="${esc(T('rvLeft')(n))}">${n}</b>`);
|
|
3518
|
+
}
|
|
3519
|
+
/** The canvas card of a step whose approval carried over from the accepted run. */
|
|
3520
|
+
function rvNodeBadge (id) {
|
|
3521
|
+
const c = (RV.data?.carried || []).find((x) => x.step === id); if (!c) return '';
|
|
3522
|
+
const why = (c.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(c.from_run, c.approved_by, rvDay(c.approved_at));
|
|
3523
|
+
return `<span class="badge st-pass rv-carry" title="${esc(why)}">✓ ${esc(T('rvCarriedShort'))}</span>`;
|
|
3524
|
+
}
|
|
3525
|
+
const rvHintItem = () => `<li><span class="kbd">Q</span> ${esc(T('rvHint'))}</li>`;
|
|
3526
|
+
/** An item for a step outside the queue (a carried or clear step opened from the summary or a link). */
|
|
3527
|
+
function rvExtraFor (step) {
|
|
3528
|
+
const n = byId(step); if (!n || n.kind === 'decision') return null;
|
|
3529
|
+
const c = (RV.data?.carried || []).find((x) => x.step === step);
|
|
3530
|
+
return { step, title: n.title, reasons: c ? ['carried'] : [], extra: true, decision: c ? { state: 'carried', by: c.approved_by, via: c.approved_via, at: c.approved_at, from_run: c.from_run } : null, carried: c || null, ref_captures: null };
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
async function rvOpen (target = null) {
|
|
3534
|
+
if (!RUN) return;
|
|
3535
|
+
if (!$('lb').hidden) closeLb();
|
|
3536
|
+
if (S.rp) rpClose();
|
|
3537
|
+
if (!$('br').hidden) closeBrowser();
|
|
3538
|
+
RV.open = true; $('rv').hidden = false;
|
|
3539
|
+
if (!RV.data) { $('rv').innerHTML = `<p class="rvm__loading">${esc(T('rvLoading'))}</p>`; await rvLoad(); }
|
|
3540
|
+
if (!RV.open) return;
|
|
3541
|
+
let items = rvItems();
|
|
3542
|
+
if (target === 'end') RV.idx = items.length;
|
|
3543
|
+
else if (target && target !== '1') {
|
|
3544
|
+
let k = items.findIndex((it) => it.step === target);
|
|
3545
|
+
if (k < 0) { const x = rvExtraFor(target); if (x) { RV.extra.push(x); items = rvItems(); k = items.length - 1; } }
|
|
3546
|
+
RV.idx = k >= 0 ? k : 0;
|
|
3547
|
+
} else { const k = items.findIndex((it) => !it.decision); RV.idx = k >= 0 ? k : items.length; }
|
|
3548
|
+
rvRender();
|
|
3549
|
+
$('rvClose')?.focus({ preventScroll: true });
|
|
3550
|
+
}
|
|
3551
|
+
function rvClose () {
|
|
3552
|
+
RV.open = false; RV.ret = false; RV.extra = [];
|
|
3553
|
+
$('rv').hidden = true; $('rv').innerHTML = '';
|
|
3554
|
+
setHash(null);
|
|
3555
|
+
$('rvBtn')?.focus({ preventScroll: true });
|
|
3556
|
+
}
|
|
3557
|
+
function rvGo (d) {
|
|
3558
|
+
const n = rvItems().length;
|
|
3559
|
+
const k = Math.max(0, Math.min(n, RV.idx + d));
|
|
3560
|
+
if (k === RV.idx) return;
|
|
3561
|
+
RV.idx = k; RV.ret = false; rvRender();
|
|
3562
|
+
}
|
|
3563
|
+
/** The accepted run's files: a signed base like compare uses, loaded once per run. */
|
|
3564
|
+
function rvRefBase (id) {
|
|
3565
|
+
if (id in RV.refBase) return RV.refBase[id];
|
|
3566
|
+
RV.refBase[id] = undefined;
|
|
3567
|
+
cmpBase(id).then((b) => { RV.refBase[id] = b; }).catch(() => { RV.refBase[id] = null; }).then(() => { if (RV.open) rvRender(); });
|
|
3568
|
+
return undefined;
|
|
3569
|
+
}
|
|
3570
|
+
const rvRefSrc = (base, p) => base && typeof p === 'string' && /^(steps|attachments)\/[A-Za-z0-9._\/-]+\.(png|jpe?g|webp)$/.test(p) && !p.includes('..') ? base + p : null;
|
|
3571
|
+
|
|
3572
|
+
function rvDecisionHtml (it) {
|
|
3573
|
+
const d = it.decision;
|
|
3574
|
+
const via = (x) => x?.via === 'agent' || x?.via === 'token' ? ` <span class="badge st-skip">${T(x.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : '';
|
|
3575
|
+
let h;
|
|
3576
|
+
if (!d) h = `<p><span class="badge st-skip">○ ${esc(T('rvWaiting'))}</span></p>`;
|
|
3577
|
+
else if (d.state === 'carried') {
|
|
3578
|
+
const c = it.carried || { kind: 'step', from_run: d.from_run, approved_by: d.by, approved_at: d.at };
|
|
3579
|
+
h = `<p><span class="badge st-pass">✓ ${esc(T('rvR_carried'))}</span>${via(d)}</p><p class="note">${esc((c.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(c.from_run, c.approved_by, rvDay(c.approved_at)))} ${esc(T('rvCarriedWhy'))}</p>${rvWritable() ? `<p><button class="linkish" id="rvRevoke">${esc(T('rvRevoke'))}</button></p>` : ''}`;
|
|
3580
|
+
} else h = `<p><span class="badge st-${d.state === 'approved' ? 'pass' : 'fail'}">${d.state === 'approved' ? '✓' : '↩'} ${esc(T(d.state === 'approved' ? 'rvApprovedBy' : 'rvReturnedBy'))}</span>${via(d)} <span class="note">${esc([d.by, rvDay(d.at)].filter(Boolean).join(' · '))}</span></p>${d.note ? `<p class="note">${noteHtml(d.note)}</p>` : ''}`;
|
|
3581
|
+
if (it.unconfirmed) h += `<p class="note">${esc(T('rvUnconfirmed'))}: ${esc(it.unconfirmed.state === 'approved' ? T('rvApprovedBy') : T('rvReturnedBy'))} · ${esc(it.unconfirmed.by || '')}${it.unconfirmed.agent ? ` (${esc(it.unconfirmed.agent)})` : ''}</p>`;
|
|
3582
|
+
return `<div class="sec rvm__dec">${h}</div>`;
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
function rvRender () {
|
|
3586
|
+
const box = $('rv'); if (!box || !RV.data) return;
|
|
3587
|
+
const d = RV.data, items = rvItems(), total = d.items.length, c = d.counts || {};
|
|
3588
|
+
const top = `<div class="rvm__top"><span class="brand">Kaloko <small>${esc(T('rvTitle'))}</small></span>
|
|
3589
|
+
<span class="rvm__prog" role="status" aria-live="polite"><b>${esc(c.decided || 0)}</b> ${esc(T('of'))} ${esc(total)} ${esc(T('rvDecided'))}</span><span class="rvm__bar" aria-hidden="true"><i style="width:${total ? Math.round((c.decided || 0) / total * 100) : 100}%"></i></span>
|
|
3590
|
+
${c.carried ? `<span class="badge st-pass" title="${esc(T('rvCarriedLead'))}">✓ ${esc(c.carried)} ${esc(T('rvCarriedN'))}</span>` : ''}<span class="spacer"></span>
|
|
3591
|
+
<div class="seg" id="rvLoc"></div><div class="seg" id="rvVp"></div><div class="seg" id="rvView"></div><span class="note rvm__keys">${esc(T('rvKeys'))}</span>
|
|
3592
|
+
<button class="icon-btn" id="rvClose" aria-label="${esc(T('rvExit'))}" title="${esc(T('rvExit'))} (Esc)">${ICON.close}</button></div>`;
|
|
3593
|
+
const it = items[RV.idx];
|
|
3594
|
+
if (!it) { box.innerHTML = top + `<div class="rvm__wrap">${rvEndHtml()}</div>${rvFootHtml(null)}`; rvWire(null); setHash({ review: 'end' }); return; }
|
|
3595
|
+
const n = byId(it.step);
|
|
3596
|
+
if (!n) { RV.idx = Math.min(RV.idx + 1, items.length); return rvRender(); }
|
|
3597
|
+
const vps = viewports().filter((x) => n.captures?.[S.loc]?.[x.name]);
|
|
3598
|
+
if (!RV.vp || !vps.some((x) => x.name === RV.vp)) RV.vp = (vps.find((x) => x.name === (S.vp === 'mobile' ? mVp() : dVp())?.name) || vps[0] || viewports()[0]).name;
|
|
3599
|
+
const v = viewports().find((x) => x.name === RV.vp) || viewports()[0];
|
|
3600
|
+
const cap = n.captures?.[S.loc]?.[v.name];
|
|
3601
|
+
const ref = d.reference; const refPath = it.ref_captures?.[S.loc]?.[v.name] || null;
|
|
3602
|
+
const base = ref && refPath ? rvRefBase(ref.id) : null;
|
|
3603
|
+
const refSrc = rvRefSrc(base, refPath);
|
|
3604
|
+
const view = refPath ? (RV.view || (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this')) : 'this';
|
|
3605
|
+
const shotHtml = n.kind === 'stack'
|
|
3606
|
+
? `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(n.stack?.count || 0)} ${pagesN(n.stack?.count || 0)}</div><div class="stack-fan" style="max-width:520px;height:220px;margin:12px auto">${(n.stack?.sample || []).slice(0, 3).map((x) => x.thumb ? `<img src="${esc(asset(x.thumb))}" alt="" style="height:180px">` : '<span class="ph" style="height:180px"></span>').join('')}</div><button class="btn btn--primary" id="rvStack">${T('openIndex')}</button></div>`
|
|
3607
|
+
: cap ? frameImg(cap, v, `<img src="${esc(asset(cap.image || cap.png))}" alt="${esc(loc(n.title))}">`, 'rvImg') : `<p class="lb__empty">${T('notReached')}</p>`;
|
|
3608
|
+
const refHtml = refSrc ? `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}" style="max-width:${v.mobile ? v.w + 16 : v.w}px"><img src="${esc(refSrc)}" alt="${esc(T('rvAccepted'))}"></div>` : `<p class="note">${esc(base === null ? T('rvRefFailed') : T('rvRefLoading'))}</p>`;
|
|
3609
|
+
const refCap = ref ? `<b>${esc(T('rvViewRef'))}</b> ${fmtDate(ref.started_at)}${ref.accepted_by ? ` · ${esc(ref.accepted_by)}` : ''}` : '';
|
|
3610
|
+
const stage = view === 'side' ? `<div class="lb__cmp"><figure><figcaption><b>${esc(T('rvViewThis'))}</b> ${fmtDate(RUN.started_at)}</figcaption>${shotHtml}</figure><figure><figcaption>${refCap}</figcaption>${refHtml}</figure></div>`
|
|
3611
|
+
: view === 'ref' ? `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption>${refCap}</figcaption>${refHtml}</figure></div>` : shotHtml;
|
|
3612
|
+
const s = nodeSt(n);
|
|
3613
|
+
const chips = it.reasons.map((r) => `<span class="badge st-${['fail', 'returned'].includes(r) ? 'fail' : ['needs_review', 'not_evaluated', 'revoked', 'changed'].includes(r) ? 'partial' : r === 'carried' ? 'pass' : 'skip'}">${esc(T('rvR_' + r))}</span>`).join('');
|
|
3614
|
+
const changed = it.changed ? `<div class="sec"><h4>${esc(T('rvChangedIn'))}</h4><p class="note">${(it.changed.captures || []).map((x) => `${esc(x.locale)}/${esc(x.viewport)}`).join(', ') || '—'}${it.changed.criteria ? `<br>${esc(T('rvCritChanged'))}` : ''}</p></div>` : '';
|
|
3615
|
+
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>`;
|
|
3616
|
+
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>` : '';
|
|
3617
|
+
box.innerHTML = top + `<div class="rvm__body"><div class="rvm__stage" id="rvStage">${stage}</div>
|
|
3618
|
+
<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>
|
|
3619
|
+
<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>
|
|
3620
|
+
${retBox}${rvDecisionHtml(it)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
3621
|
+
${n.kind !== 'stack' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}</aside></div>${rvFootHtml(it)}`;
|
|
3622
|
+
rvWire(it, n, v);
|
|
3623
|
+
setHash({ review: it.step });
|
|
3624
|
+
}
|
|
3625
|
+
function rvFootHtml (it) {
|
|
3626
|
+
const items = rvItems();
|
|
3627
|
+
const act = it && rvWritable() ? `<button class="btn" id="rvRet">${esc(T('rvReturn'))} <span class="kbd">R</span></button><button class="btn" id="rvCmt">${ICON.comment} ${esc(T('comment'))} <span class="kbd">C</span></button><button class="btn btn--primary" id="rvOk">${esc(T('rvApprove'))} <span class="kbd">A</span></button>`
|
|
3628
|
+
: it ? `<span class="note">${esc(T(rvService() ? 'rvReadOnly' : 'rvLocal'))}</span>` : '';
|
|
3629
|
+
return `<div class="rvm__foot"><button class="btn" id="rvPrev" ${RV.idx <= 0 ? 'disabled' : ''}>${ICON.prev} ${esc(T('rvPrev'))} <span class="kbd">K</span></button><span class="spacer"></span>${act}${RV.idx < items.length ? `<button class="btn" id="rvNext">${esc(RV.idx === items.length - 1 ? T('rvToEnd') : T('rvNext'))} ${ICON.next} <span class="kbd">J</span></button>` : ''}</div>`;
|
|
3630
|
+
}
|
|
3631
|
+
function rvEndHtml () {
|
|
3632
|
+
const d = RV.data, c = d.counts || {}, items = d.items || [];
|
|
3633
|
+
const approved = items.filter((it) => it.decision?.state === 'approved' || it.decision?.state === 'carried').length;
|
|
3634
|
+
const returned = items.filter((it) => it.decision?.state === 'returned').length;
|
|
3635
|
+
const head = !items.length ? T('rvNothing') : c.pending ? T('rvLeft')(c.pending) : T('rvDone');
|
|
3636
|
+
const sum = [`${approved} ${T('rvSumApproved')}`, `${returned} ${T('rvSumReturned')}`, c.carried ? `${c.carried} ${T('rvCarriedN')}` : '', c.clear ? `${c.clear} ${T('rvSumClear')}` : ''].filter(Boolean).join(' · ');
|
|
3637
|
+
const list = items.length ? `<div class="sec"><h4>${esc(T('rvQueue'))}</h4><ul class="rvm__list">${items.map((it, k) => `<li><span class="badge st-${!it.decision ? 'skip' : it.decision.state === 'returned' ? 'fail' : 'pass'}">${!it.decision ? '○' : it.decision.state === 'returned' ? '↩' : '✓'}</span><button class="linkish" data-rv-go="${k}">${esc(loc(byId(it.step)?.title) || it.step)}</button><span class="note">${it.reasons.map((r) => esc(T('rvR_' + r))).join(', ')}</span></li>`).join('')}</ul></div>` : '';
|
|
3638
|
+
const carried = (d.carried || []).length ? `<div class="sec"><h4>${esc(T('rvCarriedTitle'))}</h4><p class="note">${esc(T('rvCarriedLead'))}</p><ul class="rvm__list">${d.carried.map((x) => `<li><span class="badge st-pass">✓</span><button class="linkish" data-rv-show="${esc(x.step)}">${esc(loc(byId(x.step)?.title) || x.step)}</button><span class="note">${esc((x.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(x.from_run, x.approved_by, rvDay(x.approved_at)))}</span>${rvWritable() ? `<button class="linkish" data-rv-revoke="${esc(x.step)}">${esc(T('rvRevoke'))}</button>` : ''}</li>`).join('')}</ul></div>` : '';
|
|
3639
|
+
const v = RUN.verdict || d.verdict || {};
|
|
3640
|
+
const verdict = rvWritable() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${T('runVerdict')}</span><span class="badge st-${v.verdict === 'accepted' ? 'pass' : v.verdict === 'returned' ? 'fail' : 'skip'}">${T(v.verdict || 'pending')}</span></h4><p class="note">${esc(T('rvVerdictLead'))}${returned ? ` ${esc(T('rvReturnedHint'))}` : ''}</p>${v.approvers ? `<p class="note">${v.awaiting?.length ? `${T('awaiting')}: <b>${v.awaiting.map(esc).join(', ')}</b>` : T('approvedByAll')}</p>` : ''}
|
|
3641
|
+
<textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"></textarea>
|
|
3642
|
+
<div class="share__row"><button class="btn btn--primary" data-rv-verdict="accepted">${T('accept')}</button><button class="btn" data-rv-verdict="returned">${T('ret')}</button></div></div>` : '';
|
|
3643
|
+
return `<div class="rvm__end"><h3>${esc(head)}</h3><p class="note">${esc(sum)}</p>${d.reference ? `<p class="note">${esc(T('rvComparedWith'))}: <a class="pr" href="${runHref(d.reference.id)}">${fmtDate(d.reference.started_at)}${d.reference.accepted_by ? ` · ${esc(d.reference.accepted_by)}` : ''}</a></p>` : ''}${list}${carried}${verdict}</div>`;
|
|
3644
|
+
}
|
|
3645
|
+
function rvWire (it, n, v) {
|
|
3646
|
+
const box = $('rv');
|
|
3647
|
+
seg($('rvLoc'), RUN.scope.locales.map((l) => [l, l]), S.loc, (l) => { S.loc = l; renderAll(); rvRender(); });
|
|
3648
|
+
$('rvLoc').hidden = RUN.scope.locales.length < 2;
|
|
3649
|
+
if (n && n.kind !== 'stack') { const vps = viewports().filter((x) => n.captures?.[S.loc]?.[x.name]); seg($('rvVp'), vps.map((x) => [x.name, vpLabel(x)]), RV.vp, (nv) => { RV.vp = nv; rvRender(); }); $('rvVp').hidden = vps.length < 2; } else $('rvVp').hidden = true;
|
|
3650
|
+
const hasRef = !!(it && it.ref_captures?.[S.loc]?.[RV.vp]);
|
|
3651
|
+
if (hasRef) seg($('rvView'), [['this', T('rvViewThis')], ['side', T('rvViewSide')], ['ref', T('rvViewRef')]], RV.view || (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this'), (m) => { RV.view = m; rvRender(); });
|
|
3652
|
+
$('rvView').hidden = !hasRef;
|
|
3653
|
+
$('rvClose').onclick = rvClose;
|
|
3654
|
+
$('rvPrev').onclick = () => rvGo(-1);
|
|
3655
|
+
if ($('rvNext')) $('rvNext').onclick = () => rvGo(1);
|
|
3656
|
+
if ($('rvOk')) $('rvOk').onclick = rvApprove;
|
|
3657
|
+
if ($('rvRet')) $('rvRet').onclick = rvReturn;
|
|
3658
|
+
if ($('rvCmt')) $('rvCmt').onclick = rvComment;
|
|
3659
|
+
if ($('rvRevoke')) $('rvRevoke').onclick = () => rvRevoke(it.step);
|
|
3660
|
+
if ($('rvStack')) $('rvStack').onclick = () => { const id = it.step; rvClose(); openStack(id); };
|
|
3661
|
+
if ($('rvRetSend')) {
|
|
3662
|
+
const t = $('rvRetNote'), k = draftKey('step', it.step, 'return');
|
|
3663
|
+
t.addEventListener('input', () => saveDraft(k, t.value));
|
|
3664
|
+
t.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); rvReturnSend(); } });
|
|
3665
|
+
$('rvRetSend').onclick = rvReturnSend;
|
|
3666
|
+
$('rvRetCancel').onclick = () => { RV.ret = false; rvRender(); };
|
|
3667
|
+
}
|
|
3668
|
+
box.querySelectorAll('[data-rv-go]').forEach((b) => { b.onclick = () => { RV.idx = Number(b.dataset.rvGo); rvRender(); }; });
|
|
3669
|
+
box.querySelectorAll('[data-rv-show]').forEach((b) => { b.onclick = () => rvOpen(b.dataset.rvShow); });
|
|
3670
|
+
box.querySelectorAll('[data-rv-revoke]').forEach((b) => { b.onclick = () => rvRevoke(b.dataset.rvRevoke); });
|
|
3671
|
+
box.querySelectorAll('[data-rv-verdict]').forEach((b) => { b.onclick = () => rvVerdict(b.dataset.rvVerdict, b); });
|
|
3672
|
+
if (!box.dataset.rvWired) {
|
|
3673
|
+
box.dataset.rvWired = '1';
|
|
3674
|
+
wireComments(box);
|
|
3675
|
+
// a phone: a swipe sideways on the screenshot moves to the next or previous step (not when it pans or zooms)
|
|
3676
|
+
let sw = null;
|
|
3677
|
+
box.addEventListener('touchstart', (e) => { sw = e.touches.length === 1 && e.target.closest('#rvStage') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
|
|
3678
|
+
box.addEventListener('touchend', (e) => {
|
|
3679
|
+
const s0 = sw; sw = null; if (!s0) return;
|
|
3680
|
+
const t = e.changedTouches[0], dx = t.clientX - s0.x, dy = t.clientY - s0.y, sc = $('rvStage');
|
|
3681
|
+
if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - s0.t > 900) return;
|
|
3682
|
+
if (sc && sc.scrollWidth > sc.clientWidth + 2) return;
|
|
3683
|
+
rvGo(dx < 0 ? 1 : -1);
|
|
3684
|
+
}, { passive: true });
|
|
3685
|
+
}
|
|
3686
|
+
if (RV.ret) requestAnimationFrame(() => $('rvRetNote')?.focus());
|
|
3687
|
+
$('rvStage')?.scrollTo?.(0, 0);
|
|
3688
|
+
}
|
|
3689
|
+
function rvKey (e) {
|
|
3690
|
+
const k = e.key;
|
|
3691
|
+
if (k === 'Escape') { e.preventDefault(); if (RV.ret) { RV.ret = false; rvRender(); } else rvClose(); return; }
|
|
3692
|
+
if (k === 'j' || k === 'J' || k === 'ArrowRight') { e.preventDefault(); rvGo(1); return; }
|
|
3693
|
+
if (k === 'k' || k === 'K' || k === 'ArrowLeft') { e.preventDefault(); rvGo(-1); return; }
|
|
3694
|
+
if (!rvCur()) return;
|
|
3695
|
+
if (k === 'a' || k === 'A') { e.preventDefault(); rvApprove(); }
|
|
3696
|
+
else if (k === 'r' || k === 'R') { e.preventDefault(); rvReturn(); }
|
|
3697
|
+
else if (k === 'c' || k === 'C') { e.preventDefault(); rvComment(); }
|
|
3698
|
+
else if ((k === 'v' || k === 'V') && $('rvView') && !$('rvView').hidden) { e.preventDefault(); const order = ['this', 'side', 'ref']; const cur = $('rvView').querySelector('[aria-pressed="true"]')?.dataset.v || 'this'; RV.view = order[(order.indexOf(cur) + 1) % 3]; rvRender(); }
|
|
3699
|
+
}
|
|
3700
|
+
/** After a decision: the queue and the run again, then the next step nobody decided yet. */
|
|
3701
|
+
async function rvAfter (step) {
|
|
3702
|
+
await Promise.all([reloadRun().catch(() => {}), rvLoad()]);
|
|
3703
|
+
if (!RV.open) return;
|
|
3704
|
+
const items = rvItems(); const at = items.findIndex((it) => it.step === step);
|
|
3705
|
+
const next = items.findIndex((it, k) => k > at && !it.decision);
|
|
3706
|
+
const first = items.findIndex((it) => !it.decision);
|
|
3707
|
+
RV.idx = next >= 0 ? next : first >= 0 ? first : items.length;
|
|
3708
|
+
RV.ret = false; rvRender();
|
|
3709
|
+
}
|
|
3710
|
+
async function rvApprove () {
|
|
3711
|
+
const it = rvCur(); if (!it || !rvWritable() || RV.busy) return;
|
|
3712
|
+
const n = byId(it.step); if (!n) return;
|
|
3713
|
+
RV.busy = true; const b = $('rvOk'); if (b) b.disabled = true;
|
|
3714
|
+
try {
|
|
3715
|
+
// the person's look answers what the evaluator could not decide: open criteria pass, then the step is approved
|
|
3716
|
+
if (n.kind !== 'stack') for (const c of (n.criteria || []).filter((x) => x.type !== 'deterministic' && rvOpenCrit(x))) await postJson('/criteria', { node_id: n.id, criterion_id: c.id, verdict: 'pass', note: '' });
|
|
3717
|
+
await cApi('step', '', 'POST', { node_id: n.id, status: 'approved', note: '' });
|
|
3718
|
+
toast(T('rvApprovedToast'));
|
|
3719
|
+
await rvAfter(it.step);
|
|
3720
|
+
} catch (e) { toast(e.message); if (b) b.disabled = false; } finally { RV.busy = false; }
|
|
3721
|
+
}
|
|
3722
|
+
function rvReturn () { if (!rvCur() || !rvWritable()) return; RV.ret = true; rvRender(); }
|
|
3723
|
+
async function rvReturnSend () {
|
|
3724
|
+
const it = rvCur(); const t = $('rvRetNote'); if (!it || !t || RV.busy) return;
|
|
3725
|
+
const note = t.value.trim();
|
|
3726
|
+
if (!note) { toast(T('noteRequired')); t.focus(); return; }
|
|
3727
|
+
RV.busy = true; $('rvRetSend').disabled = true;
|
|
3728
|
+
try {
|
|
3729
|
+
await cApi('step', '', 'POST', { node_id: it.step, status: 'rejected', note, locale: S.loc, viewport: RV.vp || null });
|
|
3730
|
+
saveDraft(draftKey('step', it.step, 'return'), '');
|
|
3731
|
+
toast(T('rvReturnedToast'));
|
|
3732
|
+
await rvAfter(it.step);
|
|
3733
|
+
} catch (e) { toast(e.message); $('rvRetSend') && ($('rvRetSend').disabled = false); } finally { RV.busy = false; }
|
|
3734
|
+
}
|
|
3735
|
+
function rvComment () {
|
|
3736
|
+
const t = $('rv').querySelector('.c-main .c-text'); if (!t) return;
|
|
3737
|
+
if (lbPhone()) t.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
3738
|
+
t.focus({ preventScroll: !lbPhone() });
|
|
3739
|
+
}
|
|
3740
|
+
async function rvRevoke (step) {
|
|
3741
|
+
if (!rvWritable() || RV.busy) return;
|
|
3742
|
+
RV.busy = true;
|
|
3743
|
+
try {
|
|
3744
|
+
await postJson(`/carried/${encodeURIComponent(step)}/revoke`, {});
|
|
3745
|
+
toast(T('rvRevoked'));
|
|
3746
|
+
RV.extra = RV.extra.filter((x) => x.step !== step);
|
|
3747
|
+
await rvLoad();
|
|
3748
|
+
const k = rvItems().findIndex((it) => it.step === step);
|
|
3749
|
+
if (k >= 0) RV.idx = k;
|
|
3750
|
+
rvRender();
|
|
3751
|
+
} catch (e) { toast(e.message); } finally { RV.busy = false; }
|
|
3752
|
+
}
|
|
3753
|
+
async function rvVerdict (verdict, b) {
|
|
3754
|
+
const note = $('rvVNote')?.value.trim() || '';
|
|
3755
|
+
if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
|
|
3756
|
+
b.disabled = true;
|
|
3757
|
+
try { await postJson('/verdict', { verdict, note }); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
3758
|
+
}
|
|
3759
|
+
/** Starts review mode support once the run is on screen: the button, its count and the hash. */
|
|
3760
|
+
function rvInit () {
|
|
3761
|
+
$('rvBtn').onclick = () => rvOpen(null);
|
|
3762
|
+
// the count on the button and the carried-over badges follow the first paint
|
|
3763
|
+
if (rvService()) setTimeout(() => { rvLoad(); }, 0); else { RV.data = rvLocalQueue(); rvButton(); }
|
|
3764
|
+
}
|
|
3765
|
+
|
|
2927
3766
|
let tt; function toast (msg) { const el = $('toast'); el.textContent = msg; el.hidden = false; clearTimeout(tt); tt = setTimeout(() => { el.hidden = true; }, 2600); }
|
|
2928
3767
|
async function load () {
|
|
2929
3768
|
if (EMBEDDED) return EMBEDDED;
|
|
@@ -3018,6 +3857,7 @@ load().then((data) => {
|
|
|
3018
3857
|
$('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
|
|
3019
3858
|
$('rpBtn').onclick = () => rpOpen(null);
|
|
3020
3859
|
$('ptBtn').hidden = !isDesign(); $('ptBtn').onclick = () => ptOpen(null);
|
|
3860
|
+
rvInit(); // review mode (Q)
|
|
3021
3861
|
wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
|
|
3022
3862
|
// small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
|
|
3023
3863
|
// wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
|
|
@@ -3041,6 +3881,7 @@ load().then((data) => {
|
|
|
3041
3881
|
// (the draft stays); the next Escape closes the overlay. Search boxes keep their own arrow and Enter keys.
|
|
3042
3882
|
const field = e.target.closest?.('textarea,select,[contenteditable="true"],input:not([type=checkbox]):not([type=radio]):not([type=button])');
|
|
3043
3883
|
if (field && !['siQ', 'riQ', 'brSearch'].includes(field.id)) { if (e.key === 'Escape') { e.preventDefault(); field.blur(); } return; }
|
|
3884
|
+
if (RV.open) { if (!e.metaKey && !e.ctrlKey && !e.altKey) rvKey(e); return; }
|
|
3044
3885
|
if (!$('pt').hidden) { if (!e.metaKey && !e.ctrlKey && !e.altKey) ptKey(e); return; }
|
|
3045
3886
|
if (!$('si').hidden) { if (stackKey(e)) return; if (e.target.closest('input,select,textarea')) return; return; }
|
|
3046
3887
|
if (S.rp && !$('rp').hidden) { if (!e.target.closest('input,select,textarea')) { e.preventDefault(); rpKey(e); } return; }
|
|
@@ -3048,10 +3889,11 @@ load().then((data) => {
|
|
|
3048
3889
|
if (!$('lb').hidden) {
|
|
3049
3890
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3050
3891
|
if (S.focus) fxWake();
|
|
3892
|
+
if (!(e.key === 'Escape' && $('lbMore')?.classList.contains('is-open')) && devKey(e.key, e.shiftKey)) { e.preventDefault(); return; }
|
|
3051
3893
|
if (e.key === 'Escape') { if ($('lbMore')?.classList.contains('is-open')) closeLbMore(); else if (S.focus) setFocus(false); else closeLb(); }
|
|
3052
3894
|
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); setFocus(!S.focus); }
|
|
3053
|
-
if (e.key === 'ArrowRight') $('lbNext')?.click();
|
|
3054
|
-
if (e.key === 'ArrowLeft') $('lbPrev')?.click();
|
|
3895
|
+
if (e.key === 'ArrowRight' && !e.shiftKey) $('lbNext')?.click();
|
|
3896
|
+
if (e.key === 'ArrowLeft' && !e.shiftKey) $('lbPrev')?.click();
|
|
3055
3897
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); focusComposer(); }
|
|
3056
3898
|
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); ptOpen(S.open, S.lbVp); }
|
|
3057
3899
|
return;
|
|
@@ -3059,6 +3901,7 @@ load().then((data) => {
|
|
|
3059
3901
|
if (S.mode === 'index' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (S.st?.inline ? stackKey(e) : riKey(e)) return; }
|
|
3060
3902
|
if (e.target.closest('input,select,textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3061
3903
|
if (e.key === 'i' || e.key === 'I') { setMode(S.mode === 'index' ? 'map' : 'index'); return; }
|
|
3904
|
+
if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); rvOpen(null); return; }
|
|
3062
3905
|
if (e.key === '?') { e.preventDefault(); $('helpBtn').click(); return; }
|
|
3063
3906
|
if (e.key === 'f' || e.key === 'F') { const any = S.left || S.right; S.left = S.right = !any; setPanels(); }
|
|
3064
3907
|
if (e.key === 'k' || e.key === 'K' || (e.key === '/' && S.mode !== 'index')) { e.preventDefault(); openBrowser(); }
|