kaloko 0.4.4 → 0.4.8
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 +20 -2
- package/package.json +2 -2
- package/skills/kaloko/SKILL.md +12 -5
- package/skills/kaloko/reference/collaboration.md +45 -2
- package/skills/kaloko/reference/evaluation.md +5 -0
- package/skills/kaloko/reference/first-run.md +51 -0
- package/skills/kaloko/reference/mcp.md +5 -0
- package/src/cli.mjs +73 -6
- package/src/lib/doctor.mjs +5 -3
- package/src/lib/login.mjs +70 -0
- package/src/lib/onboarding.mjs +33 -0
- package/src/lib/review.mjs +69 -0
- package/viewer/index.html +554 -18
package/viewer/index.html
CHANGED
|
@@ -591,6 +591,83 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
591
591
|
/* phones: the lightbox stacks stage over side panel. Placed after the base .lb__box rule, which used to win and
|
|
592
592
|
squeeze the stage to nothing on narrow screens */
|
|
593
593
|
@media (max-width:900px){.lb__box{grid-template-columns:1fr;grid-template-rows:minmax(260px,48%) 1fr}.lb__stage{border-right:0;border-bottom:1px solid var(--line)}}
|
|
594
|
+
/* the secondary tools of the detail: inline on wide screens, behind "⋯" on phones */
|
|
595
|
+
.lb__more{display:contents}.lb__moreBtn,.lb__grab{display:none}
|
|
596
|
+
/* phones: the preview gets the height; the step header and tabs are a bottom sheet that starts collapsed */
|
|
597
|
+
@media (max-width:700px){
|
|
598
|
+
.lb{padding:0}
|
|
599
|
+
.lb__box{--peek:calc(98px + env(safe-area-inset-bottom));display:block;position:relative;border-radius:0;height:100%;overflow:hidden}
|
|
600
|
+
.lb__stage{position:absolute;left:0;right:0;top:0;bottom:var(--peek);border:0}
|
|
601
|
+
.lb__tools{flex-wrap:nowrap;gap:8px;padding:8px 10px;position:relative}
|
|
602
|
+
.lb__tools #lbFocus{padding:5px 10px;flex:none}
|
|
603
|
+
.lb__moreBtn{display:inline-grid;margin-left:auto;flex:none;width:32px;height:32px}
|
|
604
|
+
.lb__more{display:none}
|
|
605
|
+
.lb__more.is-open{display:flex;position:absolute;top:calc(100% + 4px);right:8px;z-index:6;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);max-width:calc(100vw - 16px)}
|
|
606
|
+
.lb__more .spacer,.lb__more .lb__hint{display:none}
|
|
607
|
+
.lb__scroll{padding:10px}
|
|
608
|
+
.lb__side{position:absolute;left:0;right:0;bottom:0;height:var(--peek);z-index:3;background:var(--panel);border-radius:14px 14px 0 0;box-shadow:0 -6px 24px rgba(0,0,0,.18);overflow:hidden;padding-bottom:env(safe-area-inset-bottom);overscroll-behavior:contain}
|
|
609
|
+
.lb[data-sheet="half"] .lb__side{height:58%;overflow:auto}
|
|
610
|
+
.lb[data-sheet="full"] .lb__side{height:calc(100% - 48px);overflow:auto}
|
|
611
|
+
.lb__grab{display:block;position:sticky;top:0;z-index:3;height:16px;background:var(--panel);cursor:grab;touch-action:none}
|
|
612
|
+
.lb__grab::before{content:"";display:block;width:36px;height:4px;border-radius:2px;background:var(--line);margin:6px auto 0}
|
|
613
|
+
.lb__head{padding:0 14px 8px;gap:4px;top:16px;touch-action:none;cursor:pointer}
|
|
614
|
+
.lb__head .kind{display:none}.lb__head .lb__linkTxt{display:none}.lb__head #lbLink{padding:5px 8px}
|
|
615
|
+
.lb__head h3{font-size:16px}
|
|
616
|
+
.lb[data-sheet="peek"] .lb__head h3{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
617
|
+
.lb[data-sheet="peek"] .lb__side:not(.is-drag) .lbtabs,.lb[data-sheet="peek"] .lb__side:not(.is-drag) .lbtab{visibility:hidden}
|
|
618
|
+
.lbtabs{top:16px}
|
|
619
|
+
}
|
|
620
|
+
@media (max-width:359px){.lb__tools #lbFocus span{display:none}}
|
|
621
|
+
@media (max-width:700px) and (prefers-reduced-motion:no-preference){.lb__side{transition:height .2s ease}.lb__side.is-drag{transition:none}}
|
|
622
|
+
/* focus: only this step's capture or live page, full viewport, with a small floating bar that hides when idle */
|
|
623
|
+
.lb.is-focus{padding:0;background:#111110}
|
|
624
|
+
.lb.is-focus .lb__box{display:block;position:relative;height:100%;border-radius:0;background:#111110;box-shadow:none}
|
|
625
|
+
.lb.is-focus .lb__side,.lb.is-focus .lb__tools{display:none}
|
|
626
|
+
.lb.is-focus .lb__stage{position:absolute;inset:0;border:0;background:#111110}
|
|
627
|
+
.lb.is-focus .lb__scroll{padding:0;overscroll-behavior:contain}
|
|
628
|
+
.lb.is-focus .lb__scroll > p.note{display:none}
|
|
629
|
+
.lb.is-focus .lb__img{box-shadow:none}
|
|
630
|
+
.lb.is-focus .lb__img--m,.lb.is-focus .lb__img--dev{border:0;border-radius:0;box-shadow:none}
|
|
631
|
+
.lb.is-focus .lb__live{border:0;border-radius:0;box-shadow:none}
|
|
632
|
+
.lb.is-focus .lb__win{border-radius:0}
|
|
633
|
+
.lb__fbar{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);z-index:8;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:6px 8px;width:max-content;max-width:calc(100% - 16px);padding:6px 8px;border-radius:22px;color-scheme:dark;--panel:#1f1f1d;--panel-2:#262624;--ink:#f0f0ec;--muted:#a3a39c;--line:#3d3d37;background:rgba(22,22,21,.9);color:var(--ink);box-shadow:0 8px 30px rgba(0,0,0,.4);backdrop-filter:blur(8px)}
|
|
634
|
+
.lb__fbar .icon-btn{width:34px;height:34px}
|
|
635
|
+
.lb__fpos{font-size:12.5px;color:var(--muted);min-width:44px;text-align:center;font-variant-numeric:tabular-nums}
|
|
636
|
+
.lb__fshow{display:none;position:absolute;top:max(10px,env(safe-area-inset-top));right:10px;z-index:8;width:38px;height:38px;place-items:center;border-radius:50%;background:rgba(22,22,21,.6);color:#f0f0ec;opacity:.75}
|
|
637
|
+
.lb.is-idle .lb__fbar{opacity:0;pointer-events:none}
|
|
638
|
+
.lb.is-focus.is-idle .lb__fshow{display:grid}
|
|
639
|
+
@media (prefers-reduced-motion:no-preference){.lb__fbar{transition:opacity .2s ease}}
|
|
640
|
+
/* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ---------- */
|
|
641
|
+
.rvm{position:fixed;inset:0;z-index:23;background:var(--ground);display:grid;grid-template-rows:auto 1fr auto}
|
|
642
|
+
.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)}
|
|
643
|
+
.rvm__top .spacer,.rvm__foot .spacer{flex:1}
|
|
644
|
+
.rvm__prog{font-size:13px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
645
|
+
.rvm__bar{display:block;width:120px;height:6px;border-radius:999px;background:var(--skip-bg);overflow:hidden}
|
|
646
|
+
.rvm__bar i{display:block;height:100%;background:var(--pass)}
|
|
647
|
+
.rvm__body{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,420px);min-height:0}
|
|
648
|
+
.rvm__stage{overflow:auto;padding:24px;background:var(--panel-2);touch-action:pan-y pinch-zoom}
|
|
649
|
+
.rvm__stage > .lb__img,.rvm__stage > .lb__win{margin:0 auto}
|
|
650
|
+
.rvm__stage .lb__cmp{max-width:100%}
|
|
651
|
+
.rvm__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
|
|
652
|
+
.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)}
|
|
653
|
+
.rvm__foot .btn .kbd,.rvm__top .kbd{margin-left:4px}
|
|
654
|
+
.rvm__why{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
|
|
655
|
+
.rvm__dec{display:grid;gap:6px}
|
|
656
|
+
.rvm__dec p{margin:0}
|
|
657
|
+
.rvm__ret textarea{width:100%;font:inherit;padding:8px;border:1px solid var(--line-strong);border-radius:6px;background:var(--panel);color:var(--ink)}
|
|
658
|
+
.rvm__end{width:min(720px,100%);margin:0 auto;padding:28px 16px 40px;overflow:auto}
|
|
659
|
+
.rvm__end h3{font-size:22px;letter-spacing:-.01em;margin:0 0 8px;text-wrap:balance}
|
|
660
|
+
.rvm__list{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px}
|
|
661
|
+
.rvm__list li{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
662
|
+
.rvm__list button.linkish{text-align:left}
|
|
663
|
+
.rvm__loading{padding:40px;text-align:center;color:var(--muted)}
|
|
664
|
+
.rvm__wrap{overflow:auto;min-height:0}
|
|
665
|
+
#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}
|
|
666
|
+
.badge.rv-carry{margin-left:auto}
|
|
667
|
+
@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}}
|
|
668
|
+
@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}}
|
|
669
|
+
@media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
|
|
670
|
+
@media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}@media (max-width:820px){#rvBtn{order:3}}
|
|
594
671
|
</style>
|
|
595
672
|
</head>
|
|
596
673
|
<body>
|
|
@@ -606,6 +683,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
606
683
|
<kbd>K</kbd>
|
|
607
684
|
</button>
|
|
608
685
|
<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>
|
|
686
|
+
<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>
|
|
609
687
|
<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>
|
|
610
688
|
<div class="dbar" id="dbar" hidden></div>
|
|
611
689
|
<span class="cmpchip" id="cmpChip" hidden></span>
|
|
@@ -675,6 +753,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
675
753
|
</div>
|
|
676
754
|
<div class="rp" id="rp" hidden role="dialog" aria-modal="true" aria-label="Replay"></div>
|
|
677
755
|
<div class="pt" id="pt" hidden role="dialog" aria-modal="true" aria-label="Prototype"></div>
|
|
756
|
+
<div class="rvm" id="rv" hidden role="dialog" aria-modal="true" aria-label="Review"></div>
|
|
678
757
|
<div class="toast" id="toast" hidden></div>
|
|
679
758
|
<script>
|
|
680
759
|
/* Kaloko viewer. Data source:
|
|
@@ -684,7 +763,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
684
763
|
const EMBEDDED = /*__QAWALK_RUN__*/null;
|
|
685
764
|
if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
|
|
686
765
|
const I18N = {
|
|
687
|
-
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
|
|
766
|
+
cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
|
|
688
767
|
pass:'Splněno', partial:'Částečně', fail:'Nesplněno', skip:'Neprověřeno', crop:'výřez', html:'HTML snapshot', email:'e-mail', external:'třetí strana',
|
|
689
768
|
branch:'Větev', commit:'Commit', author:'Autor', pr:'PR', issue:'Issue', env:'Prostředí', duration:'Délka', noPr:'bez PR', open:'otevřený', merged:'sloučený', closed:'zavřený',
|
|
690
769
|
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í',
|
|
@@ -705,10 +784,10 @@ const I18N = {
|
|
|
705
784
|
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',
|
|
706
785
|
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',
|
|
707
786
|
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',
|
|
708
|
-
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',
|
|
787
|
+
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',
|
|
709
788
|
compare:'Porovnat', cmpWith:'Porovnáno s', cmpStop:'Ukončit porovnání', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
|
|
710
789
|
reviewTitle:'Akceptace a komentáře', approve:'Schválit', reject:'Zamítnout', comment:'Komentovat', notePh:'Co je potřeba změnit, nebo poznámka · @jméno pošle kolegovi upozornění', approved:'schváleno', rejected:'zamítnuto', commented:'komentář', resolved:'vyřešeno', resolve:'Vyřešit', openFeedback:'Otevřená zpětná vazba', noFeedback:'Žádná otevřená zpětná vazba.', saved:'Uloženo', noteRequired:'Napište, co je potřeba změnit.', expires:'Platí do' },
|
|
711
|
-
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
790
|
+
en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
|
|
712
791
|
pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
|
|
713
792
|
branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
|
|
714
793
|
older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
|
|
@@ -729,7 +808,7 @@ const I18N = {
|
|
|
729
808
|
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',
|
|
730
809
|
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',
|
|
731
810
|
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',
|
|
732
|
-
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',
|
|
811
|
+
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',
|
|
733
812
|
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',
|
|
734
813
|
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' }
|
|
735
814
|
};
|
|
@@ -757,7 +836,9 @@ const ICON = {
|
|
|
757
836
|
share:'<svg class="i" viewBox="0 0 24 24"><path d="M12 16V4M7 9l5-5 5 5M5 20h14"/></svg>',
|
|
758
837
|
copy:'<svg class="i" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>',
|
|
759
838
|
download:'<svg class="i" viewBox="0 0 24 24"><path d="M12 4v12M7 11l5 5 5-5M5 20h14"/></svg>',
|
|
760
|
-
next:'<svg class="i" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></svg>'
|
|
839
|
+
next:'<svg class="i" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></svg>',
|
|
840
|
+
focus:'<svg class="i" viewBox="0 0 24 24"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/></svg>',
|
|
841
|
+
more:'<svg class="i" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/></svg>'
|
|
761
842
|
};
|
|
762
843
|
const MARK = { pass:'✓', partial:'!', fail:'✕', skip:'–' };
|
|
763
844
|
const VSTAT = { pass:'pass', fail:'fail', needs_review:'partial', not_evaluated:'partial', not_reached:'skip' };
|
|
@@ -878,6 +959,7 @@ function rerenderComments (all = false) {
|
|
|
878
959
|
if (S.right) { const top = $('rside').scrollTop; renderReport(); $('rside').scrollTop = top; }
|
|
879
960
|
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; }
|
|
880
961
|
if (S.rp && !$('rp').hidden) renderReplay();
|
|
962
|
+
if (RV.open) { const top = $('rvSide')?.scrollTop || 0; rvRender(); if ($('rvSide')) $('rvSide').scrollTop = top; if (all) rvLoad().then(() => { if (RV.open) rvRender(); }); }
|
|
881
963
|
if (all && S.mode === 'index' && !S.st?.inline && $('riList')) riPaint();
|
|
882
964
|
}
|
|
883
965
|
// unsent text survives re-renders, navigation and reloads (per run or flow, step, and reply or edit)
|
|
@@ -1030,7 +1112,7 @@ function openThread (nodeId, threadId) {
|
|
|
1030
1112
|
requestAnimationFrame(() => requestAnimationFrame(() => { const el = threadId && document.querySelector(`#lb .c-thread[data-thread="${CSS.escape(threadId)}"]`); (el || $('lb').querySelector('#c-step'))?.scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => { C.focus = null; }, 1800); }));
|
|
1031
1113
|
}
|
|
1032
1114
|
/** Key C in the lightbox (or its comment button): the composer of the step, in view and focused. */
|
|
1033
|
-
function focusComposer () { const t
|
|
1115
|
+
function focusComposer () { if (S.focus) setFocus(false); if (lbPhone() && S.lbSheet === 'peek') setSheet('half'); const t =$('lb').querySelector('.c-main .c-text'); if (!t) return; t.scrollIntoView({ behavior: 'smooth', block: 'center' }); t.focus({ preventScroll: true }); }
|
|
1034
1116
|
/** #comments=all|run|flow: the right panel on the comments. */
|
|
1035
1117
|
function showCommentsFromHash (which) {
|
|
1036
1118
|
S.cTab = ['all', 'run', 'flow'].includes(which) ? which : 'all';
|
|
@@ -1265,7 +1347,7 @@ function renderNodes () {
|
|
|
1265
1347
|
${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>
|
|
1266
1348
|
<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>
|
|
1267
1349
|
${jevBlock(n)}
|
|
1268
|
-
<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>` :
|
|
1350
|
+
<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>
|
|
1269
1351
|
</div>`;
|
|
1270
1352
|
}).join('');
|
|
1271
1353
|
const tallest = Math.max(0, ...[...$('nodes').querySelectorAll('.node:not(.node--decision)')].map((el) => el.offsetHeight));
|
|
@@ -1876,6 +1958,7 @@ function renderChrome () {
|
|
|
1876
1958
|
document.querySelectorAll('[data-t]').forEach((el) => { el.textContent = T(el.dataset.t); });
|
|
1877
1959
|
$('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
|
|
1878
1960
|
$('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>`;
|
|
1961
|
+
$('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', rvHintItem()); // review mode (Q)
|
|
1879
1962
|
const ib = $('inboxBtn');
|
|
1880
1963
|
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})` : ''}`); }
|
|
1881
1964
|
else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
|
|
@@ -2133,7 +2216,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2133
2216
|
const lb = $('lb');
|
|
2134
2217
|
const html = `<div class="lb__box">
|
|
2135
2218
|
<div class="lb__stage">
|
|
2136
|
-
<div class="lb__tools"><div class="seg" id="lbVp"></div>${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div>
|
|
2219
|
+
<div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore">${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}<span class="note">${v.w} × ${v.h}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
|
|
2137
2220
|
<div class="lb__scroll" id="lbScroll">${(() => {
|
|
2138
2221
|
const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
|
|
2139
2222
|
if (S.lbMode === 'view' && viewSrc(cap) && !S.cmp) return `<p class="note" style="margin:0 0 8px">${T('htmlViewNote')}</p>` + liveStage(cap, v, 'lbFrame', RUN.live_bases, true);
|
|
@@ -2147,10 +2230,11 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2147
2230
|
return `<div class="lb__cmp"><figure><figcaption><b>${T('thisRun')}</b> ${fmtDate(RUN.started_at)}${RUN.git?.commit ? ' · ' + esc(RUN.git.commit) : ''}</figcaption>${img}</figure><figure><figcaption><b>${esc(cmpLabel())}</b> <span id="lbPx"></span></figcaption>${bimg}</figure></div>`;
|
|
2148
2231
|
})()}</div>
|
|
2149
2232
|
</div>
|
|
2150
|
-
<div class="lb__side">
|
|
2233
|
+
<div class="lb__side" id="lbSide">
|
|
2234
|
+
<div class="lb__grab" id="lbGrab" role="button" tabindex="0" aria-controls="lbSide" aria-expanded="${(S.lbSheet || 'peek') !== 'peek'}" aria-label="${esc(T('sheetHandle'))}"></div>
|
|
2151
2235
|
<div class="lb__head">
|
|
2152
2236
|
<div class="lb__row"><span class="kind">${ICON[n.kind]}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${n.item ? T('pageWord') : T('step')} ${i + 1} ${T('of')} ${list.length}</span>${n.item ? `<button class="btn" id="lbBack">${ICON.stack} ${T('backToIndex')}</button>` : ''}
|
|
2153
|
-
<span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external}
|
|
2237
|
+
<span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external} <span class="lb__linkTxt">${T('copyLink')}</span></button><button class="icon-btn" id="lbPrev" aria-label="Previous" ${i === 0 ? 'disabled' : ''}>${ICON.prev}</button><button class="icon-btn" id="lbNext" aria-label="Next" ${i === list.length - 1 ? 'disabled' : ''}>${ICON.next}</button><button class="icon-btn" id="lbClose" aria-label="Close">${ICON.close}</button></span></div>
|
|
2154
2238
|
<h3 id="lbTitle">${esc(loc(n.title))}</h3>
|
|
2155
2239
|
<div class="node__path">${n.session ? sessChip(n.session) + ' · ' : ''}${cap?.http_status >= 300 ? `<span class="http ${cap.http_status >= 500 ? 'http--5' : ''}" title="HTTP ${esc(cap.http_status)}">${esc(cap.http_status)}</span>` : ''}${esc(cap?.live ? cap.live.entry : cap?.url || loc(n.path))}</div>
|
|
2156
2240
|
${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}
|
|
@@ -2184,11 +2268,18 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2184
2268
|
const keep = opts.keepFrame && ['live', 'view'].includes(S.lbMode) && lb.querySelector('#lbFrame')?.getAttribute('src') === (S.lbMode === 'view' ? viewSrc(cap) : liveSrc(cap)) && lb.dataset.step === id && lb.dataset.vp === v.name;
|
|
2185
2269
|
if (keep) { const tmp = document.createElement('div'); tmp.innerHTML = html; lb.querySelector('.lb__side').replaceWith(tmp.querySelector('.lb__side')); toFrame({ type: 'qawalk:pins', pins: pinsFor(n, v.name) }); }
|
|
2186
2270
|
else lb.innerHTML = html;
|
|
2187
|
-
lb.
|
|
2271
|
+
if (lb.hidden) S.lbSheet = 'peek'; // the phone sheet starts collapsed each time the detail opens
|
|
2272
|
+
lb.dataset.step = id; lb.dataset.vp = v.name; lb.dataset.sheet = S.lbSheet || 'peek';
|
|
2273
|
+
wireLbOnce(lb);
|
|
2274
|
+
renderFocusBar(lb, n, v, cap, list, i);
|
|
2188
2275
|
lb.hidden = false;
|
|
2189
2276
|
fitLive(); requestAnimationFrame(fitLive);
|
|
2190
2277
|
setTimeout(() => presenceView(true), 0);
|
|
2191
|
-
setHash({ step: id, loc: S.loc, vp: v.name });
|
|
2278
|
+
setHash({ step: id, loc: S.loc, vp: v.name, focus: S.focus ? '1' : null });
|
|
2279
|
+
$('lbFocus').onclick = () => setFocus(true);
|
|
2280
|
+
$('lbMoreBtn').onclick = (e) => { e.stopPropagation(); const m = $('lbMore'), open = !m.classList.contains('is-open'); m.classList.toggle('is-open', open); e.currentTarget.setAttribute('aria-expanded', String(open)); };
|
|
2281
|
+
// a tool chosen in the "⋯" menu closes it, so the preview is free for the click that follows (Inspect, Comment on element)
|
|
2282
|
+
$('lbMore').onclick = (e) => { if (e.target.closest('button')) setTimeout(closeLbMore, 0); };
|
|
2192
2283
|
if ($('lbBack')) $('lbBack').onclick = () => { closeLb(); openStack(n.stack, { focus: n.itemId }); };
|
|
2193
2284
|
if ($('lbComments')) $('lbComments').onclick = () => focusComposer();
|
|
2194
2285
|
placeCommentPins($('lbImg'), n, cap, v);
|
|
@@ -2243,6 +2334,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2243
2334
|
if (!li.dataset.where) toast(n.criteria.some((x) => x.target) || n.metadata?.[S.loc] ? T('noPlaceToast') : T('noMeta'));
|
|
2244
2335
|
if (li.dataset.where === 'meta' && !n.metadata?.[S.loc]) { toast(T('noMeta')); return; }
|
|
2245
2336
|
if (li.dataset.where === 'page' && img) {
|
|
2337
|
+
if (lbPhone()) setSheet('peek'); // on a phone the sheet steps aside so the highlight is in view
|
|
2246
2338
|
const els = [...img.querySelectorAll(`.hlbox[data-ac="${CSS.escape(li.dataset.hl)}"]`)].filter((e) => parseFloat(e.style.width) > 0);
|
|
2247
2339
|
els.forEach((e, k) => { e.hidden = false; e.classList.add('on'); if (!k) e.classList.add('first'); });
|
|
2248
2340
|
if (els[0]) $('lbScroll').scrollTo({ top: Math.max(0, els[0].offsetTop - 80), behavior: 'smooth' }); else toast(T('noBoxToast'));
|
|
@@ -2264,13 +2356,96 @@ function openLb (id, vpName, opts = {}) {
|
|
|
2264
2356
|
li.addEventListener('mouseenter', () => { if (li.dataset.where !== 'page' || !img || lb.querySelector('.ac li.is-on')) return; img.querySelectorAll(`.hlbox[data-ac="${CSS.escape(li.dataset.hl)}"]`).forEach((e) => { e.hidden = false; }); });
|
|
2265
2357
|
li.addEventListener('mouseleave', () => { if (!img || lb.querySelector('.ac li.is-on')) return; img.querySelectorAll('.hlbox').forEach((e) => { e.hidden = true; }); });
|
|
2266
2358
|
});
|
|
2267
|
-
$('lbClose').focus();
|
|
2359
|
+
(S.focus ? $('lbFexit') : $('lbClose')).focus({ preventScroll: true });
|
|
2268
2360
|
if (S.pendingAc) {
|
|
2269
2361
|
const li = lb.querySelector(`[data-hl="${CSS.escape(S.pendingAc)}"]`); S.pendingAc = null;
|
|
2270
2362
|
if (li) { const im = img?.querySelector('img'); const go = () => setTimeout(() => li.click(), 60); im && !im.complete ? im.addEventListener('load', go, { once: true }) : go(); }
|
|
2271
2363
|
}
|
|
2272
2364
|
}
|
|
2273
|
-
function closeLb () { $('lb').hidden = true; S.open = null; LIVE.mode = null; LIVE.insp = null; setHash(null); presenceView(); }
|
|
2365
|
+
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(); }
|
|
2366
|
+
const lbPhone = () => matchMedia('(max-width:700px)').matches;
|
|
2367
|
+
function closeLbMore () { const m = $('lbMore'); if (!m) return; m.classList.remove('is-open'); $('lbMoreBtn')?.setAttribute('aria-expanded', 'false'); }
|
|
2368
|
+
/** The phone bottom sheet of the detail: peek (title and status), half, full. */
|
|
2369
|
+
function setSheet (st) {
|
|
2370
|
+
S.lbSheet = st; $('lb').dataset.sheet = st;
|
|
2371
|
+
$('lbGrab')?.setAttribute('aria-expanded', String(st !== 'peek'));
|
|
2372
|
+
if (st === 'peek' && $('lbSide')) $('lbSide').scrollTop = 0;
|
|
2373
|
+
}
|
|
2374
|
+
/** Focus: only this step's capture or live page, full viewport. Previous / next and the switches keep it on;
|
|
2375
|
+
the prototype (P) is a separate presentation and returns here when it ends. */
|
|
2376
|
+
function setFocus (on) {
|
|
2377
|
+
if (!S.open) return;
|
|
2378
|
+
S.focus = !!on; closeLbMore();
|
|
2379
|
+
if (on) { C.pinMode = false; LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null }); }
|
|
2380
|
+
openLb(S.open, S.lbVp, { keepFrame: true });
|
|
2381
|
+
}
|
|
2382
|
+
const FX = { timer: null, over: false };
|
|
2383
|
+
// the floating bar shows on activity and hides after a while; a tap on the preview brings it back
|
|
2384
|
+
function fxWake () {
|
|
2385
|
+
const lb = $('lb'); lb.classList.remove('is-idle'); clearTimeout(FX.timer);
|
|
2386
|
+
if (!S.focus) return;
|
|
2387
|
+
FX.timer = setTimeout(function idle () { if (!S.focus || lb.hidden) return; if (FX.over) { FX.timer = setTimeout(idle, 1500); return; } lb.classList.add('is-idle'); }, 2800);
|
|
2388
|
+
}
|
|
2389
|
+
function fxHide () { clearTimeout(FX.timer); $('lb').classList.add('is-idle'); }
|
|
2390
|
+
function renderFocusBar (lb, n, v, cap, list, i) {
|
|
2391
|
+
lb.querySelector('#lbFbar')?.remove(); lb.querySelector('#lbFshow')?.remove();
|
|
2392
|
+
lb.classList.toggle('is-focus', !!S.focus);
|
|
2393
|
+
if (!S.focus) { lb.classList.remove('is-idle'); clearTimeout(FX.timer); FX.over = false; return; }
|
|
2394
|
+
const modes = [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : [])];
|
|
2395
|
+
if (modes.length && !S.cmp) modes.push(['shot', T('shotMode')]); else modes.length = 0;
|
|
2396
|
+
const vps = n.item ? viewports().filter((x) => n.captures?.[S.loc]?.[x.name]) : viewports();
|
|
2397
|
+
lb.insertAdjacentHTML('beforeend', `<div class="lb__fbar" id="lbFbar" role="toolbar" aria-label="${esc(T('focusBar'))}"><button class="icon-btn" id="lbFexit" aria-label="${esc(T('focusExit'))}" title="${esc(T('focusExit'))} (Esc)">${ICON.close}</button><button class="icon-btn" id="lbFprev" aria-label="Previous" ${i <= 0 ? 'disabled' : ''}>${ICON.prev}</button><span class="lb__fpos" title="${esc(loc(n.title))}">${i + 1} / ${list.length}</span><button class="icon-btn" id="lbFnext" aria-label="Next" ${i >= list.length - 1 ? 'disabled' : ''}>${ICON.next}</button>${vps.length > 1 ? '<div class="seg" id="lbFvp"></div>' : ''}${modes.length ? '<div class="seg" id="lbFmode"></div>' : ''}</div><button class="lb__fshow" id="lbFshow" aria-label="${esc(T('focusShow'))}" title="${esc(T('focusShow'))}">${ICON.more}</button>`);
|
|
2398
|
+
$('lbFexit').onclick = () => setFocus(false);
|
|
2399
|
+
$('lbFprev').onclick = () => $('lbPrev')?.click();
|
|
2400
|
+
$('lbFnext').onclick = () => $('lbNext')?.click();
|
|
2401
|
+
if ($('lbFvp')) seg($('lbFvp'), vps.map((x) => [x.name, vpLabel(x)]), v.name, (nv) => openLb(n.id, nv));
|
|
2402
|
+
if ($('lbFmode')) seg($('lbFmode'), modes, S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(n.id, v.name); });
|
|
2403
|
+
$('lbFshow').onclick = (e) => { e.stopPropagation(); fxWake(); };
|
|
2404
|
+
FX.over = false; fxWake();
|
|
2405
|
+
}
|
|
2406
|
+
/** Listeners that live as long as the detail element: it re-renders its content, these stay. */
|
|
2407
|
+
function wireLbOnce (lb) {
|
|
2408
|
+
if (lb.dataset.wired) return; lb.dataset.wired = '1';
|
|
2409
|
+
let lastPointer = 'mouse', swipe = null;
|
|
2410
|
+
lb.addEventListener('pointerdown', (e) => { lastPointer = e.pointerType; }, true);
|
|
2411
|
+
lb.addEventListener('pointermove', (e) => { if (S.focus && e.pointerType === 'mouse') { FX.over = !!e.target.closest('#lbFbar'); if (lb.classList.contains('is-idle') || !FX.over) fxWake(); } });
|
|
2412
|
+
lb.addEventListener('click', (e) => {
|
|
2413
|
+
if (!e.target.closest('#lbMore, #lbMoreBtn')) closeLbMore();
|
|
2414
|
+
// focus: a tap on the preview shows or hides the bar (a mouse shows it just by moving)
|
|
2415
|
+
if (!S.focus || !e.target.closest('#lbScroll') || e.target.closest('button,a,input,select,textarea')) return;
|
|
2416
|
+
if (lb.classList.contains('is-idle')) fxWake(); else if (lastPointer !== 'mouse') fxHide();
|
|
2417
|
+
});
|
|
2418
|
+
// focus on a touch screen: a swipe sideways moves to the previous or next step, unless the page is pinch-zoomed
|
|
2419
|
+
lb.addEventListener('touchstart', (e) => { swipe = S.focus && e.touches.length === 1 && e.target.closest('#lbScroll') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
|
|
2420
|
+
lb.addEventListener('touchmove', (e) => { if (e.touches.length > 1) swipe = null; }, { passive: true });
|
|
2421
|
+
lb.addEventListener('touchend', (e) => {
|
|
2422
|
+
const sw = swipe; swipe = null; if (!sw || !S.focus) return;
|
|
2423
|
+
const t = e.changedTouches[0], dx = t.clientX - sw.x, dy = t.clientY - sw.y, sc = $('lbScroll');
|
|
2424
|
+
if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - sw.t > 900) return;
|
|
2425
|
+
if (sc && sc.scrollWidth > sc.clientWidth + 2) return; // a capture wider than the screen pans sideways instead
|
|
2426
|
+
$(dx < 0 ? 'lbNext' : 'lbPrev')?.click();
|
|
2427
|
+
}, { passive: true });
|
|
2428
|
+
// the phone sheet: drag the handle or the header to resize it, tap to open or collapse it
|
|
2429
|
+
lb.addEventListener('pointerdown', (e) => {
|
|
2430
|
+
if (!lbPhone() || S.focus || e.button > 0) return;
|
|
2431
|
+
const h = e.target.closest('#lbGrab, .lb__head'); if (!h || e.target.closest('button,a,input,select,textarea,label')) return;
|
|
2432
|
+
const side = $('lbSide'), box = side.parentElement.getBoundingClientRect(), stage = lb.querySelector('.lb__stage').getBoundingClientRect();
|
|
2433
|
+
const peekH = box.bottom - stage.bottom, fullH = box.height - 48, halfH = box.height * 0.58;
|
|
2434
|
+
const y0 = e.clientY, h0 = side.getBoundingClientRect().height; let moved = false;
|
|
2435
|
+
const move = (ev) => { const dy = ev.clientY - y0; if (!moved && Math.abs(dy) < 6) return; moved = true; side.classList.add('is-drag'); side.style.overflow = 'hidden'; side.style.height = `${Math.max(peekH, Math.min(fullH, h0 - dy))}px`; };
|
|
2436
|
+
const up = (ev) => {
|
|
2437
|
+
removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('pointercancel', up);
|
|
2438
|
+
side.classList.remove('is-drag');
|
|
2439
|
+
if (!moved) { if (ev.type === 'pointerup') setSheet(S.lbSheet === 'peek' ? 'half' : 'peek'); return; }
|
|
2440
|
+
const hh = parseFloat(side.style.height); side.style.height = ''; side.style.overflow = '';
|
|
2441
|
+
setSheet([['peek', peekH], ['half', halfH], ['full', fullH]].sort((a, b) => Math.abs(a[1] - hh) - Math.abs(b[1] - hh))[0][0]);
|
|
2442
|
+
};
|
|
2443
|
+
addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
|
|
2444
|
+
});
|
|
2445
|
+
lb.addEventListener('keydown', (e) => { if (e.target.id === 'lbGrab' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setSheet(S.lbSheet === 'peek' ? 'half' : 'peek'); } });
|
|
2446
|
+
// tabs and the comment composer need the sheet open
|
|
2447
|
+
lb.addEventListener('focusin', (e) => { if (lbPhone() && !S.focus && S.lbSheet === 'peek' && e.target.closest('.lbtabs, .lbtab')) setSheet('half'); });
|
|
2448
|
+
}
|
|
2274
2449
|
function setHash (p) {
|
|
2275
2450
|
const all = Object.fromEntries(Object.entries({ ...(p || {}), cmp: S.cmp?.id, mode: S.mode === 'index' ? 'index' : null }).filter(([, x]) => x));
|
|
2276
2451
|
const h = Object.keys(all).length ? '#' + new URLSearchParams(all).toString() : ' ';
|
|
@@ -2278,6 +2453,9 @@ function setHash (p) {
|
|
|
2278
2453
|
}
|
|
2279
2454
|
function applyHash () {
|
|
2280
2455
|
const p = new URLSearchParams(location.hash.slice(1));
|
|
2456
|
+
// review mode: #review (the first undecided step), #review=<step>, #review=end (the summary)
|
|
2457
|
+
if (p.has('review')) { const t = p.get('review') || null; if (!RV.open || (t && t !== 'end' && rvCur()?.step !== t)) rvOpen(t); return; }
|
|
2458
|
+
if (RV.open) { RV.open = false; $('rv').hidden = true; }
|
|
2281
2459
|
if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
|
|
2282
2460
|
const cmp = p.get('cmp');
|
|
2283
2461
|
if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
|
|
@@ -2293,6 +2471,7 @@ function applyHash () {
|
|
|
2293
2471
|
if (!step || !byId(step)) return;
|
|
2294
2472
|
if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); }
|
|
2295
2473
|
S.pendingAc = p.get('ac');
|
|
2474
|
+
S.focus = p.get('focus') === '1'; // #step=<id>&focus=1 opens the step's preview alone
|
|
2296
2475
|
openLb(step, p.get('vp'));
|
|
2297
2476
|
}
|
|
2298
2477
|
|
|
@@ -2391,7 +2570,7 @@ function renderReport () {
|
|
|
2391
2570
|
$('rside').innerHTML = `
|
|
2392
2571
|
<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>
|
|
2393
2572
|
${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>` : ''}
|
|
2394
|
-
${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>` : ''}
|
|
2573
|
+
${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>` : ''}
|
|
2395
2574
|
${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>
|
|
2396
2575
|
<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>
|
|
2397
2576
|
<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>` : ''}
|
|
@@ -2614,9 +2793,14 @@ const frames = () => [...document.querySelectorAll('#lbFrame, #lbFrameB, #ptIfra
|
|
|
2614
2793
|
function fitLive () {
|
|
2615
2794
|
for (const f of frames()) {
|
|
2616
2795
|
const box = f.parentElement, host = box.parentElement;
|
|
2617
|
-
|
|
2796
|
+
f.dataset.h ||= f.style.height; // the frame's own height; focus stretches it to the screen and gives it back
|
|
2797
|
+
const w = parseInt(f.style.width, 10);
|
|
2618
2798
|
const cs = getComputedStyle(host);
|
|
2619
|
-
const
|
|
2799
|
+
const focus = S.focus && host.id === 'lbScroll' && !$('lb').hidden;
|
|
2800
|
+
const k = Math.min(1, (host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - (focus ? 0 : 2)) / w);
|
|
2801
|
+
// in focus the live page fills the screen: its real width, scaled down to fit, and as tall as the screen allows
|
|
2802
|
+
const h = focus ? Math.max(1, Math.floor(host.clientHeight / k)) : parseInt(f.dataset.h, 10);
|
|
2803
|
+
f.style.height = `${h}px`;
|
|
2620
2804
|
f.style.transformOrigin = '0 0'; f.style.transform = `scale(${k})`;
|
|
2621
2805
|
box.style.width = `${Math.floor(w * k)}px`; box.style.height = `${Math.floor(h * k)}px`;
|
|
2622
2806
|
}
|
|
@@ -2665,6 +2849,7 @@ addEventListener('message', (e) => {
|
|
|
2665
2849
|
} else {
|
|
2666
2850
|
// a click in the inspector pins the selection: hover stops, and the element can be edited
|
|
2667
2851
|
LIVE.insp = el; LIVE.mode = null; toFrame({ type: 'qawalk:mode', mode: null }); toFrame({ type: 'qawalk:highlight', selector: el.selector, box: el.box });
|
|
2852
|
+
if (lbPhone() && S.lbSheet === 'peek') setSheet('half'); // the inspector's table lives in the sheet
|
|
2668
2853
|
const sec = $('inspSec'); if (sec) sec.outerHTML = inspectorHtml(); else $('lbSideTop')?.insertAdjacentHTML('afterend', inspectorHtml());
|
|
2669
2854
|
renderLiveTools(); wireCopyCss();
|
|
2670
2855
|
}
|
|
@@ -2777,6 +2962,352 @@ addEventListener('resize', () => { if (!$('pt').hidden) fitPt(); });
|
|
|
2777
2962
|
document.addEventListener('fullscreenchange', () => { const b = $('ptFull'); if (b) b.setAttribute('aria-pressed', String(!!document.fullscreenElement)); setTimeout(fitPt, 60); });
|
|
2778
2963
|
|
|
2779
2964
|
/* ---------- boot ---------- */
|
|
2965
|
+
/* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ----------
|
|
2966
|
+
The queue comes from the service (GET …/runs/<id>/review-queue: order, reasons, decisions, approvals carried over
|
|
2967
|
+
unchanged steps, the accepted run to compare with); a local preview or a public run builds a read-only queue from
|
|
2968
|
+
the run itself. Keys: J/K (→/←) next and previous, A approve, R return with a note, C comment, V this run / side
|
|
2969
|
+
by side / the accepted run, Esc leaves; on a phone a swipe and the buttons do the same. Kept apart from the
|
|
2970
|
+
lightbox (its own overlay #rv and functions rv*), so both can change independently. */
|
|
2971
|
+
Object.assign(I18N.cs, {
|
|
2972
|
+
rvTitle: 'Kontrola', rvBtnTitle: 'Kontrola: krok po kroku to, co čeká na člověka', rvDecided: 'rozhodnuto', rvCarriedN: 'převzato',
|
|
2973
|
+
rvKeys: 'J K další a předchozí · A schválit · R vrátit · C komentář · V verze · Esc konec', rvExit: 'Ukončit kontrolu',
|
|
2974
|
+
rvPrev: 'Předchozí', rvNext: 'Další', rvApprove: 'Schválit', rvReturn: 'Vrátit', rvReturnTitle: 'Co se má změnit?',
|
|
2975
|
+
rvReturnPh: 'Napište, co je špatně a jak to má být. Autor běhu to uvidí hned.', rvReturnSend: 'Vrátit krok',
|
|
2976
|
+
rvAccepted: 'přijatý běh', rvViewThis: 'Tento běh', rvViewSide: 'Vedle sebe', rvViewRef: 'Přijatý běh',
|
|
2977
|
+
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…',
|
|
2978
|
+
rvR_fail: 'Selhalo', rvR_returned: 'Vráceno', rvR_needs_review: 'Čeká na posouzení', rvR_not_evaluated: 'Nevyhodnoceno', rvR_revoked: 'Převzetí zrušeno',
|
|
2979
|
+
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',
|
|
2980
|
+
rvWaiting: 'Čeká na rozhodnutí', rvApprovedBy: 'Schváleno', rvReturnedBy: 'Vráceno',
|
|
2981
|
+
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}.`,
|
|
2982
|
+
rvCarriedWhy: 'Snímky i kritéria jsou stejné jako v přijatém běhu a automatické kontroly prošly.',
|
|
2983
|
+
rvRevoke: 'Zrušit převzaté schválení', rvRevoked: 'Převzaté schválení je zrušené; krok čeká na kontrolu.',
|
|
2984
|
+
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.',
|
|
2985
|
+
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í.',
|
|
2986
|
+
rvDone: 'Všechny kroky jsou rozhodnuté', rvLeft: (n) => `Na rozhodnutí ${n === 1 ? 'čeká 1 krok' : n < 5 ? `čekají ${n} kroky` : `čeká ${n} kroků`}`,
|
|
2987
|
+
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',
|
|
2988
|
+
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.',
|
|
2989
|
+
rvShow: 'Zobrazit', rvVerdictLead: 'Převzatá schválení se za přijetí běhu nepočítají: běh jako celek přijímá člověk.',
|
|
2990
|
+
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'
|
|
2991
|
+
})
|
|
2992
|
+
Object.assign(I18N.en, {
|
|
2993
|
+
rvTitle: 'Review', rvBtnTitle: 'Review: one step at a time, what waits for a person', rvDecided: 'decided', rvCarriedN: 'carried over',
|
|
2994
|
+
rvKeys: 'J K next and previous · A approve · R return · C comment · V version · Esc leave', rvExit: 'Leave review',
|
|
2995
|
+
rvPrev: 'Previous', rvNext: 'Next', rvApprove: 'Approve', rvReturn: 'Return', rvReturnTitle: 'What should change?',
|
|
2996
|
+
rvReturnPh: 'Say what is wrong and how it should be. The author of the run sees it right away.', rvReturnSend: 'Return the step',
|
|
2997
|
+
rvAccepted: 'accepted run', rvViewThis: 'This run', rvViewSide: 'Side by side', rvViewRef: 'Accepted run',
|
|
2998
|
+
rvNoRef: 'The accepted run has no such screenshot.', rvRefLoading: 'Loading the accepted run…', rvLoading: 'Loading what waits for review…',
|
|
2999
|
+
rvR_fail: 'Failed', rvR_returned: 'Returned', rvR_needs_review: 'Needs a person’s judgement', rvR_not_evaluated: 'Not evaluated', rvR_revoked: 'Carried approval revoked',
|
|
3000
|
+
rvR_changed: 'Changed since the accepted run', rvR_new: 'New step', rvR_comments: 'Open comments', rvR_carried: 'Approval carried over',
|
|
3001
|
+
rvWaiting: 'Waiting for a decision', rvApprovedBy: 'Approved', rvReturnedBy: 'Returned',
|
|
3002
|
+
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}.`,
|
|
3003
|
+
rvCarriedWhy: 'The screenshots and criteria are the same as in the accepted run and the automatic checks passed.',
|
|
3004
|
+
rvRevoke: 'Revoke the carried approval', rvRevoked: 'Carried approval revoked; the step waits for review.',
|
|
3005
|
+
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.',
|
|
3006
|
+
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.',
|
|
3007
|
+
rvDone: 'Every step is decided', rvLeft: (n) => `${n} step${n === 1 ? '' : 's'} still wait${n === 1 ? 's' : ''} for a decision`,
|
|
3008
|
+
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',
|
|
3009
|
+
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.',
|
|
3010
|
+
rvShow: 'Show', rvVerdictLead: 'Carried approvals do not count as accepting the run: a person accepts the run as a whole.',
|
|
3011
|
+
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'
|
|
3012
|
+
})
|
|
3013
|
+
const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [] };
|
|
3014
|
+
const rvService = () => !!(APP()?.user && APP()?.run && !isDraft());
|
|
3015
|
+
const rvWritable = () => rvService() && canWrite();
|
|
3016
|
+
const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
|
|
3017
|
+
const rvCur = () => rvItems()[RV.idx] || null;
|
|
3018
|
+
const RV_GROUP = { fail: 0, returned: 0, needs_review: 1, not_evaluated: 1, revoked: 1, changed: 2, new: 2, comments: 3 };
|
|
3019
|
+
const rvDay = (iso) => (iso ? fmtDate(iso).replace(/ \d{2}:\d{2}$/, '') : '');
|
|
3020
|
+
const rvOpenCrit = (c) => Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
|
|
3021
|
+
|
|
3022
|
+
/** A read-only queue from the run itself (local preview, export, public run): no carry-over, no comparison. */
|
|
3023
|
+
function rvLocalQueue () {
|
|
3024
|
+
const items = []; let clear = 0;
|
|
3025
|
+
flowOrder().forEach((n, position) => {
|
|
3026
|
+
const vs = (n.criteria || []).map((c) => c.verdict).filter(Boolean);
|
|
3027
|
+
const reasons = [];
|
|
3028
|
+
if (n.status === 'fail' || vs.includes('fail')) reasons.push('fail');
|
|
3029
|
+
if (vs.includes('needs_review')) reasons.push('needs_review');
|
|
3030
|
+
if (vs.includes('not_evaluated')) reasons.push('not_evaluated');
|
|
3031
|
+
const st = reviewState(n.id);
|
|
3032
|
+
if (st === 'rejected') reasons.unshift('returned');
|
|
3033
|
+
const k = APP() ? stepOpenCount(n.id) : 0;
|
|
3034
|
+
if (k) reasons.push('comments');
|
|
3035
|
+
if (!reasons.length) { clear++; return; }
|
|
3036
|
+
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 });
|
|
3037
|
+
});
|
|
3038
|
+
const g = (it) => Math.min(...it.reasons.map((r) => RV_GROUP[r] ?? 4));
|
|
3039
|
+
items.sort((a, b) => g(a) - g(b) || a.position - b.position);
|
|
3040
|
+
const decided = items.filter((it) => it.decision).length;
|
|
3041
|
+
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 };
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
async function rvLoad () {
|
|
3045
|
+
if (!rvService()) { RV.data = rvLocalQueue(); rvButton(); return RV.data; }
|
|
3046
|
+
const a = APP();
|
|
3047
|
+
const had = (RV.data?.carried || []).length;
|
|
3048
|
+
try {
|
|
3049
|
+
const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}/review-queue`, { credentials: 'same-origin' });
|
|
3050
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
3051
|
+
RV.data = await res.json();
|
|
3052
|
+
} catch (e) { if (!RV.data) RV.data = rvLocalQueue(); }
|
|
3053
|
+
rvButton();
|
|
3054
|
+
if ((RV.data.carried || []).length || had) renderNodes(); // the carried-over badges on the canvas
|
|
3055
|
+
return RV.data;
|
|
3056
|
+
}
|
|
3057
|
+
function rvButton () {
|
|
3058
|
+
const b = $('rvBtn'); if (!b) return;
|
|
3059
|
+
const n = RV.data?.counts?.pending || 0;
|
|
3060
|
+
b.title = `${T('rvBtnTitle')} (Q)`;
|
|
3061
|
+
b.querySelector('.rv-count')?.remove();
|
|
3062
|
+
if (n) b.insertAdjacentHTML('beforeend', `<b class="rv-count" aria-label="${esc(T('rvLeft')(n))}">${n}</b>`);
|
|
3063
|
+
}
|
|
3064
|
+
/** The canvas card of a step whose approval carried over from the accepted run. */
|
|
3065
|
+
function rvNodeBadge (id) {
|
|
3066
|
+
const c = (RV.data?.carried || []).find((x) => x.step === id); if (!c) return '';
|
|
3067
|
+
const why = (c.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(c.from_run, c.approved_by, rvDay(c.approved_at));
|
|
3068
|
+
return `<span class="badge st-pass rv-carry" title="${esc(why)}">✓ ${esc(T('rvCarriedShort'))}</span>`;
|
|
3069
|
+
}
|
|
3070
|
+
const rvHintItem = () => `<li><span class="kbd">Q</span> ${esc(T('rvHint'))}</li>`;
|
|
3071
|
+
/** An item for a step outside the queue (a carried or clear step opened from the summary or a link). */
|
|
3072
|
+
function rvExtraFor (step) {
|
|
3073
|
+
const n = byId(step); if (!n || n.kind === 'decision') return null;
|
|
3074
|
+
const c = (RV.data?.carried || []).find((x) => x.step === step);
|
|
3075
|
+
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 };
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
async function rvOpen (target = null) {
|
|
3079
|
+
if (!RUN) return;
|
|
3080
|
+
if (!$('lb').hidden) closeLb();
|
|
3081
|
+
if (S.rp) rpClose();
|
|
3082
|
+
if (!$('br').hidden) closeBrowser();
|
|
3083
|
+
RV.open = true; $('rv').hidden = false;
|
|
3084
|
+
if (!RV.data) { $('rv').innerHTML = `<p class="rvm__loading">${esc(T('rvLoading'))}</p>`; await rvLoad(); }
|
|
3085
|
+
if (!RV.open) return;
|
|
3086
|
+
let items = rvItems();
|
|
3087
|
+
if (target === 'end') RV.idx = items.length;
|
|
3088
|
+
else if (target && target !== '1') {
|
|
3089
|
+
let k = items.findIndex((it) => it.step === target);
|
|
3090
|
+
if (k < 0) { const x = rvExtraFor(target); if (x) { RV.extra.push(x); items = rvItems(); k = items.length - 1; } }
|
|
3091
|
+
RV.idx = k >= 0 ? k : 0;
|
|
3092
|
+
} else { const k = items.findIndex((it) => !it.decision); RV.idx = k >= 0 ? k : items.length; }
|
|
3093
|
+
rvRender();
|
|
3094
|
+
$('rvClose')?.focus({ preventScroll: true });
|
|
3095
|
+
}
|
|
3096
|
+
function rvClose () {
|
|
3097
|
+
RV.open = false; RV.ret = false; RV.extra = [];
|
|
3098
|
+
$('rv').hidden = true; $('rv').innerHTML = '';
|
|
3099
|
+
setHash(null);
|
|
3100
|
+
$('rvBtn')?.focus({ preventScroll: true });
|
|
3101
|
+
}
|
|
3102
|
+
function rvGo (d) {
|
|
3103
|
+
const n = rvItems().length;
|
|
3104
|
+
const k = Math.max(0, Math.min(n, RV.idx + d));
|
|
3105
|
+
if (k === RV.idx) return;
|
|
3106
|
+
RV.idx = k; RV.ret = false; rvRender();
|
|
3107
|
+
}
|
|
3108
|
+
/** The accepted run's files: a signed base like compare uses, loaded once per run. */
|
|
3109
|
+
function rvRefBase (id) {
|
|
3110
|
+
if (id in RV.refBase) return RV.refBase[id];
|
|
3111
|
+
RV.refBase[id] = undefined;
|
|
3112
|
+
cmpBase(id).then((b) => { RV.refBase[id] = b; }).catch(() => { RV.refBase[id] = null; }).then(() => { if (RV.open) rvRender(); });
|
|
3113
|
+
return undefined;
|
|
3114
|
+
}
|
|
3115
|
+
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;
|
|
3116
|
+
|
|
3117
|
+
function rvDecisionHtml (it) {
|
|
3118
|
+
const d = it.decision;
|
|
3119
|
+
const via = (x) => x?.via === 'agent' || x?.via === 'token' ? ` <span class="badge st-skip">${T(x.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : '';
|
|
3120
|
+
let h;
|
|
3121
|
+
if (!d) h = `<p><span class="badge st-skip">○ ${esc(T('rvWaiting'))}</span></p>`;
|
|
3122
|
+
else if (d.state === 'carried') {
|
|
3123
|
+
const c = it.carried || { kind: 'step', from_run: d.from_run, approved_by: d.by, approved_at: d.at };
|
|
3124
|
+
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>` : ''}`;
|
|
3125
|
+
} 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>` : ''}`;
|
|
3126
|
+
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>`;
|
|
3127
|
+
return `<div class="sec rvm__dec">${h}</div>`;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
function rvRender () {
|
|
3131
|
+
const box = $('rv'); if (!box || !RV.data) return;
|
|
3132
|
+
const d = RV.data, items = rvItems(), total = d.items.length, c = d.counts || {};
|
|
3133
|
+
const top = `<div class="rvm__top"><span class="brand">Kaloko <small>${esc(T('rvTitle'))}</small></span>
|
|
3134
|
+
<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>
|
|
3135
|
+
${c.carried ? `<span class="badge st-pass" title="${esc(T('rvCarriedLead'))}">✓ ${esc(c.carried)} ${esc(T('rvCarriedN'))}</span>` : ''}<span class="spacer"></span>
|
|
3136
|
+
<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>
|
|
3137
|
+
<button class="icon-btn" id="rvClose" aria-label="${esc(T('rvExit'))}" title="${esc(T('rvExit'))} (Esc)">${ICON.close}</button></div>`;
|
|
3138
|
+
const it = items[RV.idx];
|
|
3139
|
+
if (!it) { box.innerHTML = top + `<div class="rvm__wrap">${rvEndHtml()}</div>${rvFootHtml(null)}`; rvWire(null); setHash({ review: 'end' }); return; }
|
|
3140
|
+
const n = byId(it.step);
|
|
3141
|
+
if (!n) { RV.idx = Math.min(RV.idx + 1, items.length); return rvRender(); }
|
|
3142
|
+
const vps = viewports().filter((x) => n.captures?.[S.loc]?.[x.name]);
|
|
3143
|
+
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;
|
|
3144
|
+
const v = viewports().find((x) => x.name === RV.vp) || viewports()[0];
|
|
3145
|
+
const cap = n.captures?.[S.loc]?.[v.name];
|
|
3146
|
+
const ref = d.reference; const refPath = it.ref_captures?.[S.loc]?.[v.name] || null;
|
|
3147
|
+
const base = ref && refPath ? rvRefBase(ref.id) : null;
|
|
3148
|
+
const refSrc = rvRefSrc(base, refPath);
|
|
3149
|
+
const view = refPath ? (RV.view || (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this')) : 'this';
|
|
3150
|
+
const shotHtml = n.kind === 'stack'
|
|
3151
|
+
? `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(n.stack?.count || 0)} ${T('pagesWord')}</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>`
|
|
3152
|
+
: 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>`;
|
|
3153
|
+
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>`;
|
|
3154
|
+
const refCap = ref ? `<b>${esc(T('rvViewRef'))}</b> ${fmtDate(ref.started_at)}${ref.accepted_by ? ` · ${esc(ref.accepted_by)}` : ''}` : '';
|
|
3155
|
+
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>`
|
|
3156
|
+
: view === 'ref' ? `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption>${refCap}</figcaption>${refHtml}</figure></div>` : shotHtml;
|
|
3157
|
+
const s = nodeSt(n);
|
|
3158
|
+
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('');
|
|
3159
|
+
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>` : '';
|
|
3160
|
+
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>`;
|
|
3161
|
+
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>` : '';
|
|
3162
|
+
box.innerHTML = top + `<div class="rvm__body"><div class="rvm__stage" id="rvStage">${stage}</div>
|
|
3163
|
+
<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>
|
|
3164
|
+
<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>
|
|
3165
|
+
${retBox}${rvDecisionHtml(it)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
|
|
3166
|
+
${n.kind !== 'stack' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}</aside></div>${rvFootHtml(it)}`;
|
|
3167
|
+
rvWire(it, n, v);
|
|
3168
|
+
setHash({ review: it.step });
|
|
3169
|
+
}
|
|
3170
|
+
function rvFootHtml (it) {
|
|
3171
|
+
const items = rvItems();
|
|
3172
|
+
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>`
|
|
3173
|
+
: it ? `<span class="note">${esc(T(rvService() ? 'rvReadOnly' : 'rvLocal'))}</span>` : '';
|
|
3174
|
+
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>`;
|
|
3175
|
+
}
|
|
3176
|
+
function rvEndHtml () {
|
|
3177
|
+
const d = RV.data, c = d.counts || {}, items = d.items || [];
|
|
3178
|
+
const approved = items.filter((it) => it.decision?.state === 'approved' || it.decision?.state === 'carried').length;
|
|
3179
|
+
const returned = items.filter((it) => it.decision?.state === 'returned').length;
|
|
3180
|
+
const head = !items.length ? T('rvNothing') : c.pending ? T('rvLeft')(c.pending) : T('rvDone');
|
|
3181
|
+
const sum = [`${approved} ${T('rvSumApproved')}`, `${returned} ${T('rvSumReturned')}`, c.carried ? `${c.carried} ${T('rvCarriedN')}` : '', c.clear ? `${c.clear} ${T('rvSumClear')}` : ''].filter(Boolean).join(' · ');
|
|
3182
|
+
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>` : '';
|
|
3183
|
+
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>` : '';
|
|
3184
|
+
const v = RUN.verdict || d.verdict || {};
|
|
3185
|
+
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>` : ''}
|
|
3186
|
+
<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>
|
|
3187
|
+
<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>` : '';
|
|
3188
|
+
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>`;
|
|
3189
|
+
}
|
|
3190
|
+
function rvWire (it, n, v) {
|
|
3191
|
+
const box = $('rv');
|
|
3192
|
+
seg($('rvLoc'), RUN.scope.locales.map((l) => [l, l]), S.loc, (l) => { S.loc = l; renderAll(); rvRender(); });
|
|
3193
|
+
$('rvLoc').hidden = RUN.scope.locales.length < 2;
|
|
3194
|
+
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;
|
|
3195
|
+
const hasRef = !!(it && it.ref_captures?.[S.loc]?.[RV.vp]);
|
|
3196
|
+
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(); });
|
|
3197
|
+
$('rvView').hidden = !hasRef;
|
|
3198
|
+
$('rvClose').onclick = rvClose;
|
|
3199
|
+
$('rvPrev').onclick = () => rvGo(-1);
|
|
3200
|
+
if ($('rvNext')) $('rvNext').onclick = () => rvGo(1);
|
|
3201
|
+
if ($('rvOk')) $('rvOk').onclick = rvApprove;
|
|
3202
|
+
if ($('rvRet')) $('rvRet').onclick = rvReturn;
|
|
3203
|
+
if ($('rvCmt')) $('rvCmt').onclick = rvComment;
|
|
3204
|
+
if ($('rvRevoke')) $('rvRevoke').onclick = () => rvRevoke(it.step);
|
|
3205
|
+
if ($('rvStack')) $('rvStack').onclick = () => { const id = it.step; rvClose(); openStack(id); };
|
|
3206
|
+
if ($('rvRetSend')) {
|
|
3207
|
+
const t = $('rvRetNote'), k = draftKey('step', it.step, 'return');
|
|
3208
|
+
t.addEventListener('input', () => saveDraft(k, t.value));
|
|
3209
|
+
t.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); rvReturnSend(); } });
|
|
3210
|
+
$('rvRetSend').onclick = rvReturnSend;
|
|
3211
|
+
$('rvRetCancel').onclick = () => { RV.ret = false; rvRender(); };
|
|
3212
|
+
}
|
|
3213
|
+
box.querySelectorAll('[data-rv-go]').forEach((b) => { b.onclick = () => { RV.idx = Number(b.dataset.rvGo); rvRender(); }; });
|
|
3214
|
+
box.querySelectorAll('[data-rv-show]').forEach((b) => { b.onclick = () => rvOpen(b.dataset.rvShow); });
|
|
3215
|
+
box.querySelectorAll('[data-rv-revoke]').forEach((b) => { b.onclick = () => rvRevoke(b.dataset.rvRevoke); });
|
|
3216
|
+
box.querySelectorAll('[data-rv-verdict]').forEach((b) => { b.onclick = () => rvVerdict(b.dataset.rvVerdict, b); });
|
|
3217
|
+
if (!box.dataset.rvWired) {
|
|
3218
|
+
box.dataset.rvWired = '1';
|
|
3219
|
+
wireComments(box);
|
|
3220
|
+
// a phone: a swipe sideways on the screenshot moves to the next or previous step (not when it pans or zooms)
|
|
3221
|
+
let sw = null;
|
|
3222
|
+
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 });
|
|
3223
|
+
box.addEventListener('touchend', (e) => {
|
|
3224
|
+
const s0 = sw; sw = null; if (!s0) return;
|
|
3225
|
+
const t = e.changedTouches[0], dx = t.clientX - s0.x, dy = t.clientY - s0.y, sc = $('rvStage');
|
|
3226
|
+
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;
|
|
3227
|
+
if (sc && sc.scrollWidth > sc.clientWidth + 2) return;
|
|
3228
|
+
rvGo(dx < 0 ? 1 : -1);
|
|
3229
|
+
}, { passive: true });
|
|
3230
|
+
}
|
|
3231
|
+
if (RV.ret) requestAnimationFrame(() => $('rvRetNote')?.focus());
|
|
3232
|
+
$('rvStage')?.scrollTo?.(0, 0);
|
|
3233
|
+
}
|
|
3234
|
+
function rvKey (e) {
|
|
3235
|
+
const k = e.key;
|
|
3236
|
+
if (k === 'Escape') { e.preventDefault(); if (RV.ret) { RV.ret = false; rvRender(); } else rvClose(); return; }
|
|
3237
|
+
if (k === 'j' || k === 'J' || k === 'ArrowRight') { e.preventDefault(); rvGo(1); return; }
|
|
3238
|
+
if (k === 'k' || k === 'K' || k === 'ArrowLeft') { e.preventDefault(); rvGo(-1); return; }
|
|
3239
|
+
if (!rvCur()) return;
|
|
3240
|
+
if (k === 'a' || k === 'A') { e.preventDefault(); rvApprove(); }
|
|
3241
|
+
else if (k === 'r' || k === 'R') { e.preventDefault(); rvReturn(); }
|
|
3242
|
+
else if (k === 'c' || k === 'C') { e.preventDefault(); rvComment(); }
|
|
3243
|
+
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(); }
|
|
3244
|
+
}
|
|
3245
|
+
/** After a decision: the queue and the run again, then the next step nobody decided yet. */
|
|
3246
|
+
async function rvAfter (step) {
|
|
3247
|
+
await Promise.all([reloadRun().catch(() => {}), rvLoad()]);
|
|
3248
|
+
if (!RV.open) return;
|
|
3249
|
+
const items = rvItems(); const at = items.findIndex((it) => it.step === step);
|
|
3250
|
+
const next = items.findIndex((it, k) => k > at && !it.decision);
|
|
3251
|
+
const first = items.findIndex((it) => !it.decision);
|
|
3252
|
+
RV.idx = next >= 0 ? next : first >= 0 ? first : items.length;
|
|
3253
|
+
RV.ret = false; rvRender();
|
|
3254
|
+
}
|
|
3255
|
+
async function rvApprove () {
|
|
3256
|
+
const it = rvCur(); if (!it || !rvWritable() || RV.busy) return;
|
|
3257
|
+
const n = byId(it.step); if (!n) return;
|
|
3258
|
+
RV.busy = true; const b = $('rvOk'); if (b) b.disabled = true;
|
|
3259
|
+
try {
|
|
3260
|
+
// the person's look answers what the evaluator could not decide: open criteria pass, then the step is approved
|
|
3261
|
+
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: '' });
|
|
3262
|
+
await cApi('step', '', 'POST', { node_id: n.id, status: 'approved', note: '' });
|
|
3263
|
+
toast(T('rvApprovedToast'));
|
|
3264
|
+
await rvAfter(it.step);
|
|
3265
|
+
} catch (e) { toast(e.message); if (b) b.disabled = false; } finally { RV.busy = false; }
|
|
3266
|
+
}
|
|
3267
|
+
function rvReturn () { if (!rvCur() || !rvWritable()) return; RV.ret = true; rvRender(); }
|
|
3268
|
+
async function rvReturnSend () {
|
|
3269
|
+
const it = rvCur(); const t = $('rvRetNote'); if (!it || !t || RV.busy) return;
|
|
3270
|
+
const note = t.value.trim();
|
|
3271
|
+
if (!note) { toast(T('noteRequired')); t.focus(); return; }
|
|
3272
|
+
RV.busy = true; $('rvRetSend').disabled = true;
|
|
3273
|
+
try {
|
|
3274
|
+
await cApi('step', '', 'POST', { node_id: it.step, status: 'rejected', note, locale: S.loc, viewport: RV.vp || null });
|
|
3275
|
+
saveDraft(draftKey('step', it.step, 'return'), '');
|
|
3276
|
+
toast(T('rvReturnedToast'));
|
|
3277
|
+
await rvAfter(it.step);
|
|
3278
|
+
} catch (e) { toast(e.message); $('rvRetSend') && ($('rvRetSend').disabled = false); } finally { RV.busy = false; }
|
|
3279
|
+
}
|
|
3280
|
+
function rvComment () {
|
|
3281
|
+
const t = $('rv').querySelector('.c-main .c-text'); if (!t) return;
|
|
3282
|
+
if (lbPhone()) t.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
3283
|
+
t.focus({ preventScroll: !lbPhone() });
|
|
3284
|
+
}
|
|
3285
|
+
async function rvRevoke (step) {
|
|
3286
|
+
if (!rvWritable() || RV.busy) return;
|
|
3287
|
+
RV.busy = true;
|
|
3288
|
+
try {
|
|
3289
|
+
await postJson(`/carried/${encodeURIComponent(step)}/revoke`, {});
|
|
3290
|
+
toast(T('rvRevoked'));
|
|
3291
|
+
RV.extra = RV.extra.filter((x) => x.step !== step);
|
|
3292
|
+
await rvLoad();
|
|
3293
|
+
const k = rvItems().findIndex((it) => it.step === step);
|
|
3294
|
+
if (k >= 0) RV.idx = k;
|
|
3295
|
+
rvRender();
|
|
3296
|
+
} catch (e) { toast(e.message); } finally { RV.busy = false; }
|
|
3297
|
+
}
|
|
3298
|
+
async function rvVerdict (verdict, b) {
|
|
3299
|
+
const note = $('rvVNote')?.value.trim() || '';
|
|
3300
|
+
if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
|
|
3301
|
+
b.disabled = true;
|
|
3302
|
+
try { await postJson('/verdict', { verdict, note }); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
3303
|
+
}
|
|
3304
|
+
/** Starts review mode support once the run is on screen: the button, its count and the hash. */
|
|
3305
|
+
function rvInit () {
|
|
3306
|
+
$('rvBtn').onclick = () => rvOpen(null);
|
|
3307
|
+
// the count on the button and the carried-over badges follow the first paint
|
|
3308
|
+
if (rvService()) setTimeout(() => { rvLoad(); }, 0); else { RV.data = rvLocalQueue(); rvButton(); }
|
|
3309
|
+
}
|
|
3310
|
+
|
|
2780
3311
|
let tt; function toast (msg) { const el = $('toast'); el.textContent = msg; el.hidden = false; clearTimeout(tt); tt = setTimeout(() => { el.hidden = true; }, 2600); }
|
|
2781
3312
|
async function load () {
|
|
2782
3313
|
if (EMBEDDED) return EMBEDDED;
|
|
@@ -2871,6 +3402,7 @@ load().then((data) => {
|
|
|
2871
3402
|
$('togRight').onclick = () => { S.right = !S.right; setPanels(); savePanels(); };
|
|
2872
3403
|
$('rpBtn').onclick = () => rpOpen(null);
|
|
2873
3404
|
$('ptBtn').hidden = !isDesign(); $('ptBtn').onclick = () => ptOpen(null);
|
|
3405
|
+
rvInit(); // review mode (Q)
|
|
2874
3406
|
wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
|
|
2875
3407
|
// small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
|
|
2876
3408
|
// wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
|
|
@@ -2894,13 +3426,16 @@ load().then((data) => {
|
|
|
2894
3426
|
// (the draft stays); the next Escape closes the overlay. Search boxes keep their own arrow and Enter keys.
|
|
2895
3427
|
const field = e.target.closest?.('textarea,select,[contenteditable="true"],input:not([type=checkbox]):not([type=radio]):not([type=button])');
|
|
2896
3428
|
if (field && !['siQ', 'riQ', 'brSearch'].includes(field.id)) { if (e.key === 'Escape') { e.preventDefault(); field.blur(); } return; }
|
|
3429
|
+
if (RV.open) { if (!e.metaKey && !e.ctrlKey && !e.altKey) rvKey(e); return; }
|
|
2897
3430
|
if (!$('pt').hidden) { if (!e.metaKey && !e.ctrlKey && !e.altKey) ptKey(e); return; }
|
|
2898
3431
|
if (!$('si').hidden) { if (stackKey(e)) return; if (e.target.closest('input,select,textarea')) return; return; }
|
|
2899
3432
|
if (S.rp && !$('rp').hidden) { if (!e.target.closest('input,select,textarea')) { e.preventDefault(); rpKey(e); } return; }
|
|
2900
3433
|
if (!$('br').hidden) { if (e.key === 'Escape') closeBrowser(); return; }
|
|
2901
3434
|
if (!$('lb').hidden) {
|
|
2902
3435
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2903
|
-
if (
|
|
3436
|
+
if (S.focus) fxWake();
|
|
3437
|
+
if (e.key === 'Escape') { if ($('lbMore')?.classList.contains('is-open')) closeLbMore(); else if (S.focus) setFocus(false); else closeLb(); }
|
|
3438
|
+
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); setFocus(!S.focus); }
|
|
2904
3439
|
if (e.key === 'ArrowRight') $('lbNext')?.click();
|
|
2905
3440
|
if (e.key === 'ArrowLeft') $('lbPrev')?.click();
|
|
2906
3441
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); focusComposer(); }
|
|
@@ -2910,6 +3445,7 @@ load().then((data) => {
|
|
|
2910
3445
|
if (S.mode === 'index' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (S.st?.inline ? stackKey(e) : riKey(e)) return; }
|
|
2911
3446
|
if (e.target.closest('input,select,textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2912
3447
|
if (e.key === 'i' || e.key === 'I') { setMode(S.mode === 'index' ? 'map' : 'index'); return; }
|
|
3448
|
+
if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); rvOpen(null); return; }
|
|
2913
3449
|
if (e.key === '?') { e.preventDefault(); $('helpBtn').click(); return; }
|
|
2914
3450
|
if (e.key === 'f' || e.key === 'F') { const any = S.left || S.right; S.left = S.right = !any; setPanels(); }
|
|
2915
3451
|
if (e.key === 'k' || e.key === 'K' || (e.key === '/' && S.mode !== 'index')) { e.preventDefault(); openBrowser(); }
|