kaloko 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/viewer/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
6
6
  <meta name="robots" content="noindex">
7
7
  <title>Kaloko</title>
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -42,6 +42,10 @@
42
42
  --edge:#8a8a83; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35);
43
43
  }
44
44
  html,body{height:100%}
45
+ /* the canvas is an app with its own zoom: the page itself never pans sideways or zooms (the canvas, screenshots
46
+ in the detail and focus mode zoom themselves); phones get 16 px fields so iOS does not zoom in on focus */
47
+ html,body{overflow-x:clip;touch-action:pan-x pan-y}
48
+ @media (max-width:820px),(pointer:coarse){input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{font-size:16px!important}}
45
49
  [hidden]{display:none!important}
46
50
  body{margin:0}
47
51
  body{background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.43;overflow:hidden}
@@ -53,8 +57,14 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
53
57
  /* top bar */
54
58
  .popwrap{position:relative;display:flex;align-items:center;gap:2px}
55
59
  .slot{display:contents}.slot .appnav{display:flex;align-items:center;gap:4px;white-space:nowrap}.slot .appnav a{display:inline-flex;align-items:center;gap:4px}.slot .viewctls{display:flex;align-items:center;gap:12px}.slot #cmpSel{max-width:220px}.slot .ctl{display:flex;align-items:center;gap:6px}
56
- #barMenu .appnav{display:grid;gap:2px;padding-bottom:8px;border-bottom:1px solid var(--line)}#barMenu .appnav a{padding:6px 8px;border-radius:6px}#barMenu .appnav a:hover{background:var(--panel-2)}#barMenu .langsw{margin:6px 8px 2px}#barMenu .viewctls{display:grid;gap:12px}
57
- #menuBtn{position:relative}body.wide #menuWrap{display:none}.menu-btn{width:26px;height:26px}
60
+ #barMenu .appnav{display:grid;gap:2px;padding-bottom:8px;border-bottom:1px solid var(--line)}#barMenu .appnav a{padding:6px 8px;border-radius:6px}#barMenu .appnav a:hover{background:var(--panel-2)}#barMenu .langsw{margin:6px 8px 2px;justify-self:start}#barMenu .viewctls{display:grid;gap:12px}
61
+ #menuBtn{position:relative}body.wide #menuWrap{display:none}
62
+ .menu-acts{display:none;gap:2px;padding-bottom:8px;border-bottom:1px solid var(--line)}.menu-acts:empty{display:none!important}.menu-acts > *{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;text-decoration:none;color:inherit;font-size:14px;text-align:left}.menu-acts > *:hover{background:var(--panel-2)}
63
+ @media (max-width:820px){.menu-acts{display:grid}}
64
+ /* a touch screen has no keyboard to speak of: the shortcuts move from the bar into ☰ */
65
+ @media (hover:none) and (pointer:coarse) and (max-width:1499px){.menu-acts{display:grid}#helpBtn{display:none}}
66
+ @media (min-width:821px){.menu-acts > :not([data-act="help"]){display:none}}
67
+ @media (min-width:821px){body:not(.wide) .bar.is-tight :is(.brand,#wtBtn,#ptBtn,#cmpChip,#dimSegs){display:none}body:not(.wide) .bar.is-tight .menu-acts{display:grid}body:not(.wide) .bar.is-tight .menu-acts > *{display:flex}body:not(.wide) .bar.is-tight #dimCtl:not(:empty){display:flex}}.menu-btn{width:26px;height:26px}
58
68
  .menu{position:absolute;top:calc(100% + 8px);left:0;min-width:210px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.18);padding:8px;z-index:40;display:grid;gap:4px}.menu[hidden]{display:none}
59
69
  #brandMenu .appnav{display:grid;gap:2px}#brandMenu .appnav a{padding:6px 8px;border-radius:6px}#brandMenu .appnav a:hover{background:var(--panel-2)}#brandMenu .langsw{margin:6px 8px 2px}
60
70
  .pop{left:auto;right:0;min-width:280px;gap:12px;padding:12px}.pop .ctl{display:grid;gap:4px}.pop .seg-label{position:static!important;width:auto!important;height:auto!important;clip:auto!important;overflow:visible!important}.pop select{width:100%}
@@ -247,7 +257,27 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
247
257
  @media (prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
248
258
  body.embed .helpwrap{display:none}
249
259
  .bar-break{display:none}
250
- @media (max-width:820px){.bar{flex-wrap:wrap;gap:6px 8px;padding:8px 10px}.bar > *{order:1}.bar-break{display:block;order:2;flex-basis:100%;height:0}.bar > .picker,#rpBtn,#ptBtn,#modeSeg,#locSeg,#dbar{order:3}.bar > .picker{flex:1 1 0;min-width:0;max-width:none}.task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}.bar .spacer{flex:1 1 auto;order:1}.picker .kchip{display:none}}
260
+ /* phones: two rows that always fit the screen. Row one: panels, brand, locale, who is here, inbox, menu; row two:
261
+ the title (cut short), replay, review and Map / Index. Prototype, walkthrough, shortcuts, compare and the
262
+ dimension switchers live in the ☰ menu; fitBar() drops the brand and extra faces when a row still does not fit */
263
+ @media (max-width:820px){
264
+ .bar{flex-wrap:wrap;gap:6px;padding:6px max(8px,env(safe-area-inset-right)) 6px max(8px,env(safe-area-inset-left))}
265
+ .bar > *{order:1;flex-shrink:0;min-width:0}
266
+ .bar .spacer{flex:1 1 0;order:2}#presence{order:3;margin-left:0}#inboxBtn{order:4;padding:4px 6px}#menuWrap,.helpwrap{order:5}#togRight{order:6}
267
+ .bar-break{display:block;order:7;flex-basis:100%;height:0}
268
+ .bar > .picker,#rpBtn,#rvBtn,#modeSeg{order:8}
269
+ .bar > .picker{flex:1 1 0;max-width:none;padding:4px 6px}
270
+ .task b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task span{display:none!important}.picker .kchip{display:none}
271
+ .bar .btn{padding:5px 9px}.bar .seg button{padding:3px 9px}
272
+ #wtBtn,#ptBtn,#helpBtn,#cmpChip,#dbar{display:none!important}
273
+ #locSeg{flex:0 1 auto}.bar .dpick .nm,.bar .dpick__bad{display:none}
274
+ .presence .muted{font-size:12px}
275
+ .bar.is-tight .brand{display:none}
276
+ /* its menus are sheets along the bottom of the screen, scrolling when taller than it */
277
+ .bar .menu{position:fixed;left:max(8px,env(safe-area-inset-left));right:max(8px,env(safe-area-inset-right));top:auto;bottom:calc(8px + env(safe-area-inset-bottom));min-width:0;max-height:calc(100dvh - 24px - env(safe-area-inset-top) - env(safe-area-inset-bottom));overflow:auto;overscroll-behavior:contain;border-radius:14px;box-shadow:0 -8px 40px rgba(0,0,0,.28);z-index:140;translate:none!important}
278
+ }
279
+ /* menus of the bar on a computer: placePop() keeps them 8 px inside the window and scrolls a tall one */
280
+ .bar .menu{max-width:calc(100vw - 16px);box-sizing:border-box}
251
281
  .legend-in{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}.legend-in span{display:inline-flex;gap:5px;align-items:center}
252
282
  /* phones: a run with many locales scrolls its locale switch sideways instead of widening the page */
253
283
  @media (max-width:820px){.bar .seg{max-width:100%;min-width:0;overflow-x:auto;scrollbar-width:none}.bar .seg::-webkit-scrollbar{display:none}.bar .seg button{flex:none}}
@@ -496,14 +526,14 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
496
526
  .dpick__btn:hover{border-color:var(--line-strong)}.dpick__btn b{font-weight:600}.dpick__btn .nm{overflow:hidden;text-overflow:ellipsis;color:var(--muted)}
497
527
  .dpick__bad{font-size:11px;font-weight:600;color:var(--fail-ink,var(--fail));background:var(--fail-bg,transparent);border-radius:999px;padding:1px 6px}
498
528
  .dpick__btn svg{width:12px;height:12px;flex:none;opacity:.6}
499
- .dpop{position:fixed;z-index:120;width:min(340px,calc(100vw - 16px));max-height:min(460px,70vh);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.22);overflow:hidden}
529
+ .dpop{position:fixed;z-index:120;box-sizing:border-box;width:min(340px,calc(100vw - 16px));max-height:min(460px,70vh);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.22);overflow:hidden}
500
530
  .dpop[hidden]{display:none}.dpop__q{margin:8px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--ink);font:inherit;font-size:13px}
501
531
  .dpop ul{list-style:none;margin:0;padding:0 6px 8px;overflow:auto}.dpop__grp{padding:8px 8px 2px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
502
532
  .dpop li[role=option]{display:grid;grid-template-columns:10px auto 1fr auto;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;font-size:13px}
503
533
  .dpop li[role=option] .nm{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dpop li[role=option].is-active{background:var(--panel-2);outline:2px solid var(--line-strong)}
504
534
  .dpop li[aria-selected=true]{font-weight:600}.dpop__cnt{display:flex;gap:6px;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}.dpop__cnt .f{color:var(--fail)}.dpop__cnt .p{color:var(--partial)}.dpop__cnt .ok{color:var(--pass)}
505
535
  .dpop__none{padding:10px 12px;color:var(--muted);font-size:13px}
506
- .dpop--sheet{left:0!important;right:0;top:auto!important;bottom:0;width:100%;max-height:70vh;border-radius:16px 16px 0 0}
536
+ .dpop--sheet{left:0!important;right:0;top:auto!important;bottom:0;width:100%;max-height:70vh;padding-bottom:env(safe-area-inset-bottom);border-radius:16px 16px 0 0}
507
537
  .dvf{position:relative;margin:0 auto;container-type:inline-size;box-sizing:content-box;background:#151514;border:10px solid #151514;border-radius:46px;box-shadow:0 0 0 2px #3a3a37,var(--shadow);overflow:hidden}
508
538
  .dvf--home-button{border-width:46px 10px 54px;border-radius:40px}.dvf--punch-hole{border-radius:30px}.dvf--fold{border-radius:22px}
509
539
  .dvf--tablet{border-width:16px;border-radius:30px}.dvf--laptop{border:12px solid #2b2b29;border-bottom-width:26px;border-radius:14px 14px 6px 6px}
@@ -716,7 +746,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
716
746
  .lb__tools #lbFocus{padding:5px 10px;flex:none}
717
747
  .lb__moreBtn{display:inline-grid;margin-left:auto;flex:none;width:32px;height:32px}
718
748
  .lb__more{display:none}
719
- .lb__more.is-open{display:flex;position:absolute;top:calc(100% + 4px);right:8px;z-index:6;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);max-width:calc(100vw - 16px)}
749
+ .lb__more.is-open{display:flex;position:absolute;top:calc(100% + 4px);right:8px;z-index:6;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);max-width:calc(100vw - 16px);box-sizing:border-box;max-height:calc(100dvh - 120px);overflow:auto;overscroll-behavior:contain}
720
750
  .lb__more .spacer,.lb__more .lb__hint{display:none}
721
751
  .lb__scroll{padding:10px}
722
752
  .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}
@@ -790,7 +820,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
790
820
  @media (max-width:900px){.rvm__body{grid-template-columns:1fr;grid-template-rows:minmax(220px,55%) 1fr}.rvm__side{border-left:0;border-top:1px solid var(--line)}.rvm__stage{padding:12px}.rvm__bar{width:72px}.rvm__keys{display:none}}
791
821
  @media (max-width:560px){.rvm__foot{gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}.rvm__foot .btn{flex:1 1 auto;justify-content:center;padding:9px 8px}.rvm__foot .spacer{display:none}.rvm__top{padding:8px 10px}.rvm__top .brand small{display:none}}
792
822
  @media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
793
- @media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}@media (max-width:820px){#rvBtn{order:3}}
823
+ @media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}
794
824
  /* batch F: swipe and onion compare, drawn regions, comment drawings and images, the grid, the acceptance record */
795
825
  .cmpov{display:grid;margin:0 auto;background:#fff;box-shadow:var(--shadow);position:relative;user-select:none;-webkit-user-select:none;overflow:hidden}
796
826
  .cmpov>img{grid-area:1/1;display:block;width:100%;height:auto;align-self:start;pointer-events:none}
@@ -843,6 +873,9 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
843
873
  @media (max-width:900px){.gvm__keys{display:none}.gvm__title{max-width:60vw}}
844
874
  @media (max-width:560px){.gvm__top{padding:8px 10px}.gvm__body{padding:0 10px 16px}.gvm__top .brand small{display:none}}
845
875
  .signoff{margin-top:10px;padding-top:10px;border-top:1px dashed var(--line)}.signoff .note{margin:0 0 6px}
876
+ .iss-badge{display:inline-flex;align-items:center;gap:3px;font:600 11px/1 system-ui,sans-serif;color:var(--ink);padding:2px 6px;border-radius:999px;margin-left:4px;background:color-mix(in srgb,var(--fail) 18%,transparent)}
877
+ .iss-list{list-style:none;padding:0;margin:0 0 8px;display:grid;gap:6px}.iss-list li{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}
878
+ .iss-form{display:grid;gap:8px}.iss-form label{display:grid;gap:3px;font-size:13px}.iss-form input,.iss-form select,.iss-form textarea{font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);max-width:100%}.iss-form .note{margin:0}
846
879
  </style>
847
880
  </head>
848
881
  <body>
@@ -871,6 +904,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
871
904
  <div class="presence" id="presence" hidden aria-label="Who else is here"></div>
872
905
  <div class="popwrap" id="menuWrap"><button class="icon-btn" id="menuBtn" aria-haspopup="true" aria-expanded="false" aria-label="Menu"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg><span class="dot-on" id="viewDot" hidden></span></button>
873
906
  <div class="menu pop" id="barMenu" hidden>
907
+ <div class="menu-acts" id="menuActs"></div>
874
908
  <nav class="appnav" id="appNav" aria-label="Project"></nav>
875
909
  <div class="viewctls" id="viewCtls">
876
910
  <div class="ctl"><div class="seg" id="vpSeg" aria-label="View"></div></div>
@@ -942,7 +976,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
942
976
  const EMBEDDED = /*__QAWALK_RUN__*/null;
943
977
  if (!window.QAWALK_APP) { const el = document.getElementById('qawalk-app'); if (el) { try { window.QAWALK_APP = JSON.parse(el.textContent); } catch {} } }
944
978
  const I18N = {
945
- cs: { makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">D</span> Dev mode (v detailu) · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
979
+ cs: { whatChanged:'Co se změnilo', sinceVersion:'od', notesBy:'Poznámky upravil(a)', notesEdit:'Napsat shrnutí vlastními slovy', notesPrompt:'Co se v této verzi změnilo a proč (uvidí to recenzenti i sledující)', makePublicVersion:'Zveřejnit verzi', draft:'Koncept', liveShared:'živě, sdíleno', publishHint:'Publikování uloží celé flow jako %V; nezměněné kroky se použijí znovu.', htmlView:'HTML pohled', htmlViewNote:'Statická kopie stránky: bez skriptů webu, s přenesenými styly a obrázky, bez tajných údajů.', editTitle:'Upravit na plachtě', editText:'Text', editKeep:'ponechat', editSave:'Uložit úpravu', editSaved:'Úprava uložena jako nová revize', editRebased:'Krok se mezitím změnil; úprava se uložila do nejnovější revize', editUnapplied:'Neuplatněno (prvek už neexistuje)', staleCapture:'Snímek je z předchozí revize; Živě ukazuje poslední úpravu.', branches:'Větve', mergeBranch:'Sloučit do main', mergeConflict:'Krok se změnil v obou větvích:', mergeTakeBranch:'Vzít z větve? (Zrušit = ponechat main)', branchMerged:'Sloučeno', acceptVersion:'Přijmout verzi', returnVersion:'Vrátit verzi', versionVerdict:'Verdikt verze', draftInfo:'Koncept je živý a sdílený: kdo ho má otevřený, vidí změny hned, i ty od agentů. Kroky ukládá kaloko sync nebo úprava na plachtě; tlačítko Publikovat verzi uloží celé flow jako novou verzi.', changedSince:'Změněno od', noChangeSince:'Beze změny od', designEnv:'design', version:'Verze', versions:'Verze flow', publish:'Publikovat verzi', publishName:'Název verze (nepovinný)', published:'Publikováno', designRef:'reference designu', setDesignRef:'Nastavit jako referenci designu', unsetDesignRef:'Zrušit referenci designu', designRefHint:'Implementace se kontroluje proti referenci designu (pack design).', openDraft:'Otevřít koncept', liveMode:'Živě', shotMode:'Snímek', inspect:'Inspektor', pinComment:'Komentář k prvku', pickHint:'Klikněte na prvek v náhledu.', pinnedOn:'K prvku', unchangedSince:'beze změny od', unchangedSteps:'kroků beze změny', changedOnly:'Jen změněné', stepStatus:'Stav kroku', st_in_progress:'rozpracováno', st_ready_for_review:'k revizi', st_ready_for_dev:'připraveno pro vývoj', present:'Prezentovat', presenting:'Prezentujete', stopPresent:'Ukončit prezentaci', updatedSteps:'upravil', viaAgent:'přes', here:'tady', copyCss:'Kopírovat CSS', cssCopied:'CSS zkopírováno', noToken:'mimo sadu tokenů', tokenSet:'token', revisionBy:'revize', noLiveOrigin:'Živé kroky nejsou na této instanci zapnuté.', draftEmpty:'Koncept zatím nemá žádné kroky; spusťte kaloko sync.', prototypeTo:'Prototyp: přechod na', outdated:'zastaralé', prototype:'Prototyp', protoTitle:'Prezentovat prototyp', protoHint:'Klikejte v prototypu; ← → předchozí / další krok, Esc konec.', protoShot:'Jen snímky: živé kroky tu nejsou k dispozici. ← → procházejí flow.', protoLocal:'Odkazy otevírají stránky z disku; ← → procházejí flow.', protoDevice:'Rámeček zařízení', protoFull:'Celá obrazovka', protoExit:'Ukončit prototyp', protoNoStep:'Odkaz nevede na žádný krok flow', focus:'Jen náhled', focusTitle:'Ukázat jen náhled přes celou obrazovku', focusExit:'Zpět do detailu', focusBar:'Ovládání náhledu', focusShow:'Ukázat ovládání', moreTools:'Další nástroje', sheetHandle:'Detail kroku: klepněte nebo táhněte', hint:'Táhněte pro posun · Ctrl + kolečko zoom · <span class="kbd">F</span> režim soustředění (v detailu jen náhled) · <span class="kbd">K</span> scénáře a běhy · <span class="kbd">R</span> projít · <span class="kbd">P</span> prototyp · <span class="kbd">D</span> Dev mode (v detailu) · <span class="kbd">I</span> mapa / index · <span class="kbd">←</span><span class="kbd">→</span> předchozí / další krok (v detailu) · <span class="kbd">Esc</span> zavřít', locale:'Testovaný jazyk', view:'Náhled', both:'Oba', shownRun:'Zobrazený běh', allRuns:'Všechny běhy', steps:'Kroky flow', fit:'Celé flow', stepsWord:'kroků', passedWord:'prošlo', fitLane:'Zobrazit celou sekci', following:'Sledujete', followEsc:'Esc ukončí', followHint:'Sledovat, co dělá', visOn:'Vidí mě ostatní', visOff:'Jsem skrytý',
946
980
  pass:'Splněno', partial:'Částečně', fail:'Nesplněno', skip:'Neprověřeno', crop:'výřez', html:'HTML snapshot', email:'e-mail', external:'třetí strana',
947
981
  branch:'Větev', commit:'Commit', author:'Autor', pr:'PR', issue:'Issue', env:'Prostředí', duration:'Délka', noPr:'bez PR', open:'otevřený', merged:'sloučený', closed:'zavřený',
948
982
  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í',
@@ -968,7 +1002,7 @@ const I18N = {
968
1002
  downloadZip:'Stáhnout ZIP', downloadZipNote:'Všechny soubory v otevřených formátech a plachta, která se otevře offline z disku', filesWord:'souborů', publicAccess:'veřejný odkaz', orgAccess:'přístup po přihlášení do organizace', projectAll:'Všechny projekty', packNote:'Kritérium z balíčku kontrol (packs ve scénáři)', coverage:'Pokrytí plánu', coverageLine:'kritérií z ACCEPTANCE.md má kontrolu', coverageExtra:'kontrol mimo plán', coverageFull:'Každé plánované kritérium má kontrolu.', coverageSteps:'Kroky v plánu bez uzlu', flakyTitle:'Nestabilní kritéria', flaky:'nestabilní', flakyNote:'Verdikt se liší mezi běhy na stejném commitu a prostředí.', people:'Lidé ve flow', peopleHint:'Kliknutím na osobu zvýrazníte její kroky; přerušované šipky ukazují předání mezi lidmi.', awaiting:'Čeká na schválení od', approvedBy:'schválili', approvedByAll:'Schválili všichni povinní schvalovatelé.', viaToken:'přes token', 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',
969
1003
  compare:'Porovnat', cmpWith:'Porovnáno s', cmpStop:'Ukončit porovnání', cmpOff:'nic', cmpBaseline:'referenční', cmpPrev:'předchozí', cmpTitle:'Rozdíly oproti', cmpNone:'Statusy, verdikty i evidence jsou stejné.', pxChanged:'pixelů jinak', cmpSide:'Vedle sebe', cmpDiff:'Rozdíl', cmpLoadErr:'Porovnávaný běh se nepodařilo načíst.', pxUnavail:'Pixelový rozdíl se počítá jen ve službě (snímky ze stejného původu).', was:'předtím', thisRun:'tento běh', cmpNoCap:'v porovnávaném běhu bez snímku', cmpAdded:'nové kroky', cmpRemoved:'kroky, které zmizely', cmpHint:'Ctrl/⌘ + klik na krok otevře porovnání',
970
1004
  reviewTitle:'Akceptace a komentáře', approve:'Schválit', reject:'Zamítnout', comment:'Komentovat', notePh:'Co je potřeba změnit, nebo poznámka · @jméno pošle kolegovi upozornění', approved:'schváleno', rejected:'zamítnuto', commented:'komentář', resolved:'vyřešeno', resolve:'Vyřešit', openFeedback:'Otevřená zpětná vazba', noFeedback:'Žádná otevřená zpětná vazba.', saved:'Uloženo', noteRequired:'Napište, co je potřeba změnit.', expires:'Platí do' },
971
- en: { makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">D</span> Dev mode (in the detail) · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
1005
+ en: { whatChanged:'What changed', sinceVersion:'since', notesBy:'Notes edited by', notesEdit:'Write the summary in your own words', notesPrompt:'What changed in this version and why (reviewers and followers see it)', makePublicVersion:'Make version public', draft:'Draft', liveShared:'live, shared', publishHint:'Publish version saves the whole flow as %V; unchanged steps are reused.', htmlView:'HTML view', htmlViewNote:'A static copy of the page: the site’s scripts removed, styles and images rehosted, secrets stripped.', editTitle:'Edit in the canvas', editText:'Text', editKeep:'keep', editSave:'Save edit', editSaved:'Edit saved as a new revision', editRebased:'The step changed meanwhile; the edit is on the latest revision', editUnapplied:'Not applied (the element no longer exists)', staleCapture:'The screenshot is from the previous revision; Live shows the latest edit.', branches:'Branches', mergeBranch:'Merge into main', mergeConflict:'Step changed on both branches:', mergeTakeBranch:'Take the branch version? (Cancel = keep main)', branchMerged:'Merged', acceptVersion:'Accept version', returnVersion:'Return version', versionVerdict:'Version verdict', draftInfo:'This draft is live and shared: everyone who has it open sees changes as they happen, including the agents’. kaloko sync and canvas edits save steps; Publish version saves the whole flow as a new version.', changedSince:'Changed since', noChangeSince:'No change since', designEnv:'design', version:'Version', versions:'Flow versions', publish:'Publish version', publishName:'Version name (optional)', published:'Published', designRef:'design reference', setDesignRef:'Set as design reference', unsetDesignRef:'Unset design reference', designRefHint:'Implementation is checked against the design reference (pack design).', openDraft:'Open the draft', liveMode:'Live', shotMode:'Screenshot', inspect:'Inspect', pinComment:'Comment on element', pickHint:'Click an element in the preview.', pinnedOn:'On element', unchangedSince:'unchanged since', unchangedSteps:'steps unchanged', changedOnly:'Changed only', stepStatus:'Step status', st_in_progress:'in progress', st_ready_for_review:'ready for review', st_ready_for_dev:'ready for dev', present:'Present', presenting:'You are presenting', stopPresent:'Stop presenting', updatedSteps:'updated', viaAgent:'via', here:'here', copyCss:'Copy CSS', cssCopied:'CSS copied', noToken:'outside the token set', tokenSet:'token', revisionBy:'revision', noLiveOrigin:'Live steps are not enabled on this instance.', draftEmpty:'The draft has no steps yet; run kaloko sync.', prototypeTo:'Prototype: go to', outdated:'outdated', prototype:'Prototype', protoTitle:'Present the prototype', protoHint:'Click through the prototype; ← → previous / next step, Esc to leave.', protoShot:'Screenshots only: live steps are not available here. ← → move through the flow.', protoLocal:'Links open the pages from disk; ← → move through the flow.', protoDevice:'Device frame', protoFull:'Full screen', protoExit:'Leave the prototype', protoNoStep:'The link does not lead to a step of the flow', focus:'Focus', focusTitle:'Show only the preview, full screen', focusExit:'Back to the detail', focusBar:'Preview controls', focusShow:'Show controls', moreTools:'More tools', sheetHandle:'Step detail: tap or drag', hint:'Drag to pan · Ctrl + wheel to zoom · <span class="kbd">F</span> focus mode (in the detail: the preview only) · <span class="kbd">K</span> scenarios and runs · <span class="kbd">R</span> replay · <span class="kbd">P</span> prototype · <span class="kbd">D</span> Dev mode (in the detail) · <span class="kbd">I</span> map / index · <span class="kbd">←</span><span class="kbd">→</span> previous / next step (in the detail) · <span class="kbd">Esc</span> close', locale:'Tested locale', view:'View', both:'Both', shownRun:'Shown run', allRuns:'All runs', steps:'Flow steps', fit:'Fit flow', stepsWord:'steps', passedWord:'passed', fitLane:'Show the whole section', following:'Following', followEsc:'Esc to stop', followHint:'Follow what they do', visOn:'Others see me', visOff:'Hidden',
972
1006
  pass:'Passed', partial:'Partial', fail:'Failed', skip:'Not checked', crop:'crop', html:'HTML snapshot', email:'e-mail', external:'third party',
973
1007
  branch:'Branch', commit:'Commit', author:'Author', pr:'PR', issue:'Issue', env:'Environment', duration:'Duration', noPr:'no PR', open:'open', merged:'merged', closed:'closed',
974
1008
  older:'Older run', newer:'Newer run', purpose:'Screen purpose from the scenario', criteria:'Acceptance criteria', jevTitle:'Semantic evaluation',
@@ -1188,10 +1222,44 @@ function locKey (e) {
1188
1222
  }
1189
1223
  /** The top bar never overflows on a computer: when it would, every picker turns compact. */
1190
1224
  function fitBar () {
1191
- const bar = document.querySelector('header.bar'); if (!bar || innerWidth <= 820) return;
1192
- if (!S.compactBar && bar.scrollWidth > bar.clientWidth + 1) { S.compactBar = true; renderChrome(); }
1193
- }
1194
- addEventListener('resize', () => { if (S.compactBar && RUN) { S.compactBar = false; renderChrome(); } });
1225
+ const bar = document.querySelector('header.bar'); if (!bar) return;
1226
+ if (phoneBar()) {
1227
+ // a phone: the first row must stay one row; when it wraps, the brand goes (the menu links home) and one face stays
1228
+ const row1 = () => { const t = $('togLeft').getBoundingClientRect().top; return [...bar.children].filter((e) => e.offsetParent && getComputedStyle(e).order < 7).every((e) => Math.abs(e.getBoundingClientRect().top - t) < 20); };
1229
+ if (!bar.classList.contains('is-tight') && !row1()) { bar.classList.add('is-tight'); renderPresence(); }
1230
+ return;
1231
+ }
1232
+ // a computer: first every picker turns compact, then the brand, prototype, walkthrough and the switchers move into ☰
1233
+ if (bar.scrollWidth <= bar.clientWidth + 1) return;
1234
+ if (!S.compactBar) { S.compactBar = true; renderChrome(); }
1235
+ else if (document.body.classList.contains('wide')) { S.unwide = true; S.placeBar?.(); } // navigation and view options back into ☰
1236
+ else if (!bar.classList.contains('is-tight')) { bar.classList.add('is-tight'); renderPresence(); }
1237
+ }
1238
+ /** Keeps an open menu of the bar inside the window: 8 px from each side, shifted sideways, scrolling when taller
1239
+ than the room below its button. On a phone the menus are bottom sheets (CSS) and this only resets them. */
1240
+ function placePop (el) {
1241
+ if (!el) return;
1242
+ el.style.translate = ''; el.style.maxHeight = ''; el.style.overflow = '';
1243
+ if (phoneBar()) return;
1244
+ const r = el.getBoundingClientRect(), M = 8;
1245
+ let dx = 0; if (r.right > innerWidth - M) dx = innerWidth - M - r.right; if (r.left + dx < M) dx = M - r.left;
1246
+ if (dx) el.style.translate = `${Math.round(dx)}px 0`;
1247
+ if (r.bottom > innerHeight - M) { el.style.maxHeight = `${Math.max(120, Math.floor(innerHeight - M - r.top))}px`; el.style.overflow = 'auto'; }
1248
+ }
1249
+ /** The phone layout of the top bar (two rows, secondary controls in ☰). */
1250
+ const PHONE_MQ = matchMedia('(max-width:820px)');
1251
+ const phoneBar = () => PHONE_MQ.matches;
1252
+ // iOS Safari ignores user-scalable=no: its page-zoom gestures stop here. The canvas and the detail preview zoom
1253
+ // themselves from touch events, which these do not affect
1254
+ for (const ev of ['gesturestart', 'gesturechange']) document.addEventListener(ev, (e) => e.preventDefault(), { passive: false });
1255
+ // a new width lets the bar try its roomier layout again (height changes, such as a phone keyboard, do not)
1256
+ let BAR_W = innerWidth;
1257
+ addEventListener('resize', () => {
1258
+ if (innerWidth === BAR_W) return; BAR_W = innerWidth;
1259
+ const bar = document.querySelector('header.bar'); const tight = bar?.classList.contains('is-tight'); bar?.classList.remove('is-tight');
1260
+ if ((S.compactBar || tight || S.unwide) && RUN) { S.compactBar = false; if (S.unwide) { S.unwide = false; S.placeBar?.(); } renderChrome(); renderPresence(); }
1261
+ });
1262
+ PHONE_MQ.addEventListener('change', () => { if (RUN) { renderChrome(); renderPresence(); } });
1195
1263
  /** Words for a key's dimensions ("WebKit (Safari) · Dark"), defaults left out. */
1196
1264
  const dimWords = (key) => { const d = keyDims(key); return DIMS.filter(([name, list, def]) => dimList(list) && (d[name] !== def || name === 'browser')).map(([name]) => dimLabel(d[name])).join(' · '); };
1197
1265
  const dVp = () => viewports().find((v) => !v.mobile);
@@ -1713,7 +1781,7 @@ function renderNodes () {
1713
1781
  ${n.session ? sessChip(n.session) : ''}<div class="node__head"><span class="kind">${ICON[n.kind]}</span><div class="node__t"><div class="node__title">${esc(loc(n.title))}</div><div class="node__path">${httpChip(n)}${esc(loc(n.path) || n.captures?.[S.loc]?.[d?.name]?.mail?.subject || '')}</div></div><span class="badge st-${s}">${MARK[s]} ${s === 'skip' ? '—' : `${ok}/${n.criteria.length}`}</span></div>
1714
1782
  <button class="shots ${S.vp === 'mobile' ? 'shots--mbig' : ''}" data-open="${esc(n.id)}" aria-label="${esc(loc(n.title))}">${S.vp !== 'mobile' && d ? shot(n, d, 256, 160) : ''}${S.vp !== 'desktop' && m ? shot(n, m, 74, 160) : ''}</button>
1715
1783
  ${jevBlock(n)}
1716
- <div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` : rvNodeBadge(n.id)}</div>
1784
+ <div class="node__foot">${attIcons(n)}${cmtBadge(n.id)}${issBadge(n.id)}${sigBadge(n)}<span class="loc-dots">${RUN.scope.locales.map((l) => `<span class="st-${esc(n.by_locale?.[l] || 'skip')}" title="${esc(l)}: ${esc(T(n.by_locale?.[l] || 'skip'))}">${esc(l)}</span>`).join('')}</span>${S.cmp && cmpNode(n.id) ? `<span class="pxd" data-pxd="${esc(n.id)}" title="${esc(cmpLabel())}">◐ …</span>${cmpCritChanges(n).length ? `<span class="cmpd" title="${esc(T('cmpTitle'))} ${esc(cmpLabel())}">Δ ${cmpCritChanges(n).length}</span>` : ''}` : ''}${changed && !S.cmp ? `<span class="delta" title="${esc(pr.git?.commit || '')}">Δ ${esc(T(before).toLowerCase())} → ${esc(T(n.status).toLowerCase())}</span>` : ''}${reviewState(n.id) ? `<span class="badge st-${reviewState(n.id) === 'approved' ? 'pass' : 'fail'}" style="margin-left:${changed || S.cmp ? '4px' : 'auto'}">${reviewState(n.id) === 'approved' ? '✓ ' + T('approved') : '✕ ' + T('rejected')}</span>` : rvNodeBadge(n.id)}</div>
1717
1785
  </div>`;
1718
1786
  }).join('');
1719
1787
  const tallest = Math.max(0, ...[...$('nodes').querySelectorAll('.node:not(.node--decision)')].map((el) => el.offsetHeight));
@@ -2242,9 +2310,13 @@ function renderPresence () {
2242
2310
  const presentBtn = PR.ws && peers.length ? `<button class="pvis" data-present aria-pressed="${PR.presenting}">${esc(PR.presenting ? T('stopPresent') : T('present'))}</button>` : '';
2243
2311
  const visBtn = `<button class="pvis" data-vis title="${esc(presenceOn() ? T('visOn') : T('visOff'))}">${presenceOn() ? '●' : '○'} ${esc(presenceOn() ? T('visOn') : T('visOff'))}</button>`;
2244
2312
  const menu = document.body.classList.contains('wide') ? presentBtn + visBtn : `<button class="pmenu-btn" data-pmenu aria-expanded="${!!PR.menu}" title="${esc(presenceOn() ? T('visOn') : T('visOff'))}">${presenceOn() ? '●' : '○'}</button>${PR.menu ? `<div class="menu pmenu">${PR.ws && peers.length ? `<button data-present aria-pressed="${PR.presenting}">${esc(PR.presenting ? T('stopPresent') : T('present'))}</button>` : ''}<button data-vis>${presenceOn() ? '●' : '○'} ${esc(presenceOn() ? T('visOn') : T('visOff'))}</button></div>` : ''}`;
2245
- el.innerHTML = [...PR.agents.values()].map((x) => `<span class="pav pav--agent" style="--c:#151514" title="${esc(x.name)} ${esc(T('viaAgent'))} ${esc(x.email)}${x.step ? ` · ${esc(x.step)}` : ''}">AI</span>`).join('') + peers.slice(0, 6).map((p) => `<button class="pav" style="--c:${peerColor(p.email)}" data-follow="${esc(p.id)}" aria-pressed="${PR.follow === p.id}" title="${esc(p.email)} · ${esc(T('followHint'))}">${esc(initials(p.name || p.email))}</button>`).join('') + (peers.length > 6 ? `<span class="muted">+${peers.length - 6}</span>` : '') + menu;
2313
+ // a phone shows two faces at most (one when the bar is tight), a computer six; the rest is a count
2314
+ const tight = !!$('presence').closest('.bar')?.classList.contains('is-tight'); const max = phoneBar() ? (tight ? 1 : 2) : tight ? 2 : 6;
2315
+ const faces = [...[...PR.agents.values()].map((x) => `<span class="pav pav--agent" style="--c:#151514" title="${esc(x.name)} ${esc(T('viaAgent'))} ${esc(x.email)}${x.step ? ` · ${esc(x.step)}` : ''}">AI</span>`), ...peers.map((p) => `<button class="pav" style="--c:${peerColor(p.email)}" data-follow="${esc(p.id)}" aria-pressed="${PR.follow === p.id}" title="${esc(p.email)} · ${esc(T('followHint'))}">${esc(initials(p.name || p.email))}</button>`)];
2316
+ el.innerHTML = faces.slice(0, max).join('') + (faces.length > max ? `<span class="muted">+${faces.length - max}</span>` : '') + menu;
2317
+ requestAnimationFrame(fitBar);
2246
2318
  el.onclick = (e) => {
2247
- if (e.target.closest('[data-pmenu]')) { e.stopPropagation(); S.closeMenus?.('pmenu'); PR.menu = !PR.menu; renderPresence(); return; }
2319
+ if (e.target.closest('[data-pmenu]')) { e.stopPropagation(); S.closeMenus?.('pmenu'); PR.menu = !PR.menu; renderPresence(); placePop(el.querySelector('.pmenu')); return; }
2248
2320
  const f = e.target.closest('[data-follow]'); if (f) { PR.follow === f.dataset.follow ? stopFollow() : startFollow(f.dataset.follow); return; }
2249
2321
  if (e.target.closest('[data-present]')) { PR.presenting = !PR.presenting; if (PR.presenting) stopFollow(); presenceView(true); renderPresence(); toast(PR.presenting ? T('presenting') : T('stopPresent')); return; }
2250
2322
  if (e.target.closest('[data-vis]')) { try { localStorage.setItem('qawalk-presence', presenceOn() ? 'off' : 'on'); } catch {} if (presenceOn()) presenceStart(); else { PR.retry = 99; PR.ws?.close(); } renderPresence(); }
@@ -2312,8 +2384,19 @@ function focusNode (id) { const el = document.querySelector(`.node[data-id="${CS
2312
2384
 
2313
2385
  function wireCanvas () {
2314
2386
  const vp = $('vp'); let drag = null;
2315
- vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.node,.hud')) return; drag = { x: e.clientX, y: e.clientY, vx: S.view.x, vy: S.view.y }; vp.setPointerCapture(e.pointerId); vp.classList.add('dragging'); });
2316
- vp.addEventListener('pointermove', (e) => { if (!drag) return; S.view.x = drag.vx + e.clientX - drag.x; S.view.y = drag.vy + e.clientY - drag.y; applyView(); });
2387
+ vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.node,.hud') || pinch) return; drag = { id: e.pointerId, x: e.clientX, y: e.clientY, vx: S.view.x, vy: S.view.y }; vp.setPointerCapture(e.pointerId); vp.classList.add('dragging'); });
2388
+ vp.addEventListener('pointermove', (e) => { if (!drag || e.pointerId !== drag.id) return; S.view.x = drag.vx + e.clientX - drag.x; S.view.y = drag.vy + e.clientY - drag.y; applyView(); });
2389
+ // two fingers pinch-zoom the canvas around their midpoint and pan with it (the page itself never zooms)
2390
+ let pinch = null;
2391
+ const two = (e) => { const [a, b] = e.touches, r = vp.getBoundingClientRect(); return { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, x: (a.clientX + b.clientX) / 2 - r.left, y: (a.clientY + b.clientY) / 2 - r.top }; };
2392
+ vp.addEventListener('touchstart', (e) => { if (e.touches.length !== 2) return; drag = null; vp.classList.remove('dragging'); const g = two(e); pinch = { ...g, k: S.view.k }; }, { passive: true });
2393
+ vp.addEventListener('touchmove', (e) => {
2394
+ if (!pinch || e.touches.length !== 2) return; e.preventDefault();
2395
+ const g = two(e); S.view.x += g.x - pinch.x; S.view.y += g.y - pinch.y; pinch.x = g.x; pinch.y = g.y;
2396
+ zoomAt(pinch.k * g.d / pinch.d / S.view.k, g.x, g.y);
2397
+ }, { passive: false });
2398
+ vp.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinch = null; }, { passive: true });
2399
+ vp.addEventListener('touchcancel', () => { pinch = null; }, { passive: true });
2317
2400
  const end = () => { drag = null; vp.classList.remove('dragging'); };
2318
2401
  vp.addEventListener('pointerup', end); vp.addEventListener('pointercancel', end);
2319
2402
  // presence: my cursor to the others; dragging or zooming myself ends following someone
@@ -2366,12 +2449,18 @@ function renderChrome () {
2366
2449
  if (app?.user) { ib.hidden = false; ib.href = `/${app.org}/inbox`; ib.innerHTML = `<svg class="i" viewBox="0 0 24 24"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>${app.unread ? `<b>${esc(app.unread)}</b>` : ''}`; ib.title = 'Inbox'; ib.setAttribute('aria-label', `Inbox${app.unread ? ` (${app.unread})` : ''}`); }
2367
2450
  else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
2368
2451
  else ib.hidden = true;
2452
+ // ☰ on a phone also holds what the bar has no room for: prototype, walkthrough video, shortcuts
2453
+ const wt = $('wtBtn');
2454
+ $('menuActs').innerHTML = (isDesign() ? `<button type="button" data-act="pt">${$('ptBtn').querySelector('svg').outerHTML}${esc(T('prototype'))}</button>` : '')
2455
+ + (!wt.hidden ? `<a data-act="wt" href="${esc(wt.getAttribute('href'))}" download title="${esc(wt.title)}">${wt.querySelector('svg').outerHTML}${esc(T('walkthrough'))}</a>` : '')
2456
+ + `<button type="button" data-act="help">${$('helpBtn').querySelector('svg').outerHTML}${esc(T('helpTitle'))}</button>`;
2457
+ $('menuActs').onclick = (e) => { const a = e.target.closest('[data-act]'); if (!a) return; if (a.dataset.act === 'pt') { S.closeMenus?.(null); ptOpen(null); } else if (a.dataset.act === 'help') { e.stopPropagation(); $('helpBtn').click(); } else S.closeMenus?.(null); };
2369
2458
  $('locSeg').hidden = RUN.scope.locales.length < 2;
2370
2459
  $('viewDot').hidden = S.vp === 'both' && S.jev === 'sum' && !S.cmp && DIMS.every(([n, list, def]) => !dimList(list) || dimNow(n) === (dimList(list).includes(def) ? def : dimList(list)[0]));
2371
2460
  $('legend').innerHTML = ['pass', 'partial', 'fail', 'skip'].map((k) => `<span><i class="dot st-${k}"></i>${T(k).toLowerCase()}</span>`).join('') + `<span>${ICON.crop}${T('crop')}</span><span>${ICON.html}${T('html')}</span><span>${ICON.email}${T('email')}</span><span>${ICON.external}${T('external')}</span>`;
2372
2461
  $('brSearch').placeholder = T('search');
2373
2462
  $('locSeg').title = T('locale'); $('vpSeg').title = T('view');
2374
- picker($('locSeg'), { name: 'loc', title: T('locale'), values: localeValues(), current: S.loc, compact: S.compactBar, onPick: (v) => { S.loc = v; renderAll(); if (S.open) openLb(S.open, S.lbVp); } });
2463
+ picker($('locSeg'), { name: 'loc', title: T('locale'), values: localeValues(), current: S.loc, compact: S.compactBar || phoneBar(), onPick: (v) => { S.loc = v; renderAll(); if (S.open) openLb(S.open, S.lbVp); } });
2375
2464
  // the switchers sit next to the locale; on a phone they move into the menu
2376
2465
  for (const el of [$('dimSegs'), $('dimCtl')]) dimSegs(el, () => { renderAll(); if (S.open) openLb(S.open, keyWith(S.lbVp) || undefined); }, el.id === 'dimSegs' && S.compactBar);
2377
2466
  // the reader's last locale, browser and scheme in this run come back next time
@@ -2405,7 +2494,7 @@ function renderChrome () {
2405
2494
  ? laneNamesOf().map((name, i) => { const mine = order.filter((n) => n.lane === i); if (!mine.length) return ''; const c = { pass: mine.filter((n) => nodeSt(n) === 'pass').length }; return `<h3 data-lane="${i}" title="${esc(T('fitLane'))}">${esc(name)} <small class="muted">${c.pass}/${mine.length}</small></h3>${mine.map(stepLi).join('')}`; }).join('')
2406
2495
  : order.map(stepLi).join('');
2407
2496
  $('steps').onclick = (e) => { const ln = e.target.closest('[data-lane]'); if (ln) { if (S.mode === 'index') { const k = (S.ri?.entries || []).findIndex((x) => x.type === 'group' && x.label === laneNamesOf()[Number(ln.dataset.lane)]); if (k >= 0) $('riList').scrollTop = S.ri.tops[k]; return; } fitLane(Number(ln.dataset.lane)); return; } const h = e.target.closest('[data-sess]'); if (h) { S.sess = S.sess === h.dataset.sess ? null : h.dataset.sess; renderAll(); return; } const b = e.target.closest('[data-step]'); if (!b) return; if (S.mode === 'index') riFocus(b.dataset.step); else if (byId(b.dataset.step)?.kind === 'stack') { focusNode(b.dataset.step); openStack(b.dataset.step); } else focusNode(b.dataset.step); if (innerWidth <= 1100) { S.left = false; setPanels(); } };
2408
- $('scFoot').innerHTML = `${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
2497
+ $('scFoot').innerHTML = `${issFootHtml()}${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
2409
2498
  }
2410
2499
  function renderAll () { renderChrome(); renderNodes(); if (S.right) renderReport(); presenceView(); if (S.mode === 'index') { if (S.st?.inline) paintStack(); else if ($('riList')) riPaint(); else riRender(); } }
2411
2500
 
@@ -2847,7 +2936,7 @@ function openLb (id, vpName, opts = {}) {
2847
2936
  <span class="lb__nav">${APP() ? `<button class="btn c-jump" id="lbComments" title="${esc(T('commentsTitle'))} (C)">${ICON.comment}${openCount('step', n.id) ? ` ${openCount('step', n.id)}` : ''}</button>` : ''}<button class="btn" id="lbLink" title="${T('copyLink')}">${ICON.external} <span class="lb__linkTxt">${T('copyLink')}</span></button><button class="icon-btn" id="lbPrev" aria-label="Previous" ${i === 0 ? 'disabled' : ''}>${ICON.prev}</button><button class="icon-btn" id="lbNext" aria-label="Next" ${i === list.length - 1 ? 'disabled' : ''}>${ICON.next}</button><button class="icon-btn" id="lbClose" aria-label="Close">${ICON.close}</button></span></div>
2848
2937
  <h3 id="lbTitle">${esc(loc(n.title))}</h3>
2849
2938
  <div class="node__path">${n.session ? sessChip(n.session) + ' · ' : ''}${cap?.http_status >= 300 ? `<span class="http ${cap.http_status >= 500 ? 'http--5' : ''}" title="HTTP ${esc(cap.http_status)}">${esc(cap.http_status)}</span>` : ''}${esc(cap?.live ? cap.live.entry : cap?.url || loc(n.path))}</div>
2850
- ${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}
2939
+ ${sameAsCmp(n) ? `<p class="note">${T('unchangedSince')} ${esc(cmpLabel())}</p>` : ''}${cap?.live?.stale_capture ? `<p class="note">${T('staleCapture')}</p>` : ''}${stepNoteHtml(n)}
2851
2940
  </div>
2852
2941
  <div class="lbtabs" role="tablist">${[...(DEV.on ? [['dev', T('devTab')]] : []), ['overview', T('tabOverview')], ['page', T('tabPage')], ['tech', T('tabTech')]].map(([k, l]) => `<button role="tab" data-lbtab="${k}" aria-selected="${(S.lbTab || 'overview') === k}">${l}</button>`).join('')}</div>
2853
2942
  ${DEV.on ? `<div class="lbtab" data-pane="dev" ${S.lbTab === 'dev' ? '' : 'hidden'}>${devPaneHtml()}</div>` : ''}
@@ -2861,6 +2950,7 @@ function openLb (id, vpName, opts = {}) {
2861
2950
  return `<li class="st-${st} ${cmpChanged ? 'cmp-changed' : ''}" data-hl="${esc(c.id)}" data-where="${esc(hasBox ? 'page' : c.target?.kind || '')}" tabindex="0"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(c.id)}</span>${esc(critText(c))} ${cmpChanged ? `<span class="dl">Δ</span>` : ''}${c.flaky ? `<span class="dl" title="${T('flakyNote')}">~ ${T('flaky')}</span>` : ''}${c.pack ? `<span class="acb st-skip" style="margin-left:6px" title="${T('packNote')}">${esc(c.pack)}</span>` : ''}</span><div class="acbody">${howBlock(c, r)}${r.evidence ? (humanEvidence(r.evidence) ? `<span class="ev ev--text">${esc(humanEvidence(r.evidence))}</span>` : `<code class="ev">${esc(r.evidence)}</code>`) : ''}${helpBlock(c, st)}${c.check || r.score != null ? `<details class="tech"><summary>${T('howTech')}</summary><code class="ev">${c.check ? esc(checkLabel(c.check)) : esc(c.type || '')}${r.score != null ? ` · score ${r.score.toFixed(2)}` : ''}${r.note && c.check?.type !== 'semantic' ? ` · ${esc(r.note)}` : ''}${r.evidence && humanEvidence(r.evidence) ? `\n${esc(r.evidence)}` : ''}</code></details>` : ''}${cmpChanged ? `<code class="ev ev--cmp">${T('was')}: ${esc(br ? `${br.verdict}${br.score != null ? ' · ' + br.score.toFixed(2) : ''}${br.evidence || br.note ? ' — ' + (br.evidence || br.note) : ''}` : T('skip').toLowerCase())}</code>` : ''}${hasBox ? `<span class="where">${where}</span>` : ['meta', 'console'].includes(c.target?.kind) ? `<button type="button" class="where where--go" data-jump="${esc(c.target.kind)}">${where} →</button>` : `<span class="where">${where}</span>`}</div></li>`; }).join('') || `<li class="st-pass"><span class="mark">–</span><span>—</span></li>`}</ul></div>
2862
2951
  ${!n.item && canWrite() && n.criteria.some((c) => c.type === 'manual' || (c.by_locale?.[S.loc]?.[v.name]?.verdict === 'needs_review')) ? `<div class="sec"><h4>${T('decideManual')}</h4>${n.criteria.filter((c) => c.type === 'manual' || c.by_locale?.[S.loc]?.[v.name]?.verdict === 'needs_review').map((c) => `<div class="mdec"><p class="mdec__t"><span class="id">${esc(c.id)}</span> ${esc(c.text)}</p><div class="mdec__row"><input type="text" data-mnote="${esc(c.id)}" placeholder="${T('manualNotePh')}" aria-label="${esc(c.id)}: ${T('manualNotePh')}"><button class="btn" data-manual="${esc(c.id)}" data-v="pass">${T('manualPass')}</button><button class="btn" data-manual="${esc(c.id)}" data-v="fail">${T('manualFail')}</button></div></div>`).join('')}</div>` : ''}
2863
2952
  ${commentsBlock({ scope: 'step', nodeId: n.id, pinnable: !!cap && !['live', 'view'].includes(S.lbMode) })}
2953
+ ${issuesBlock(n, v)}
2864
2954
  </div>
2865
2955
  <div class="lbtab" data-pane="page" ${S.lbTab === 'page' ? '' : 'hidden'}>
2866
2956
  ${sigSection(cap)}
@@ -3049,6 +3139,22 @@ function renderFocusBar (lb, n, v, cap, list, i) {
3049
3139
  $('lbFshow').onclick = (e) => { e.stopPropagation(); fxWake(); };
3050
3140
  FX.over = false; fxWake();
3051
3141
  }
3142
+ /** Zoom of the screenshot in the detail preview (touch): 1× to 5×, kept on the preview element (a new step starts at 1×). */
3143
+ const LZ = { k: 1, el: null, pinch: null, after: 0 };
3144
+ function lbZoomSync () { const sc = $('lbScroll'); if (sc !== LZ.el) { LZ.el = sc; LZ.k = 1; } return sc; }
3145
+ function lbZoom (k, cx, cy) {
3146
+ const sc = lbZoomSync(); if (!sc) return;
3147
+ k = Math.max(1, Math.min(5, k)); if (k < 1.03) k = 1;
3148
+ const r = sc.getBoundingClientRect(), px = cx - r.left, py = cy - r.top, f = k / LZ.k;
3149
+ const ox = sc.scrollLeft + px, oy = sc.scrollTop + py;
3150
+ // CSS zoom keeps the layout real (the preview scrolls over the bigger picture); a block keeps its 1× width, zoomed
3151
+ for (const c of sc.children) {
3152
+ if (k > 1) { if (!c.dataset.w0) { c.dataset.w0 = String(c.getBoundingClientRect().width); c.style.width = `${c.dataset.w0}px`; } c.style.zoom = String(k); }
3153
+ else { delete c.dataset.w0; c.style.width = ''; c.style.zoom = ''; }
3154
+ }
3155
+ LZ.k = k; sc.style.setProperty('--pz', String(k)); sc.classList.toggle('is-zoomed', k > 1);
3156
+ sc.scrollLeft = ox * f - px; sc.scrollTop = oy * f - py;
3157
+ }
3052
3158
  /** Listeners that live as long as the detail element: it re-renders its content, these stay. */
3053
3159
  function wireLbOnce (lb) {
3054
3160
  if (lb.dataset.wired) return; lb.dataset.wired = '1';
@@ -3061,13 +3167,29 @@ function wireLbOnce (lb) {
3061
3167
  if (!S.focus || !e.target.closest('#lbScroll') || e.target.closest('button,a,input,select,textarea')) return;
3062
3168
  if (lb.classList.contains('is-idle')) fxWake(); else if (lastPointer !== 'mouse') fxHide();
3063
3169
  });
3170
+ // a touch screen: two fingers zoom the screenshot inside the preview (the page itself does not zoom), a double tap
3171
+ // switches 1× / 2× at the tapped point; zoomed in, one finger pans the preview (it scrolls)
3172
+ const dist = (e) => { const [a, b] = e.touches; return { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; };
3173
+ let tap = null;
3174
+ lb.addEventListener('touchstart', (e) => {
3175
+ if (e.touches.length !== 2 || !e.target.closest('#lbScroll') || e.target.closest('.cmpov, .is-drawing')) return;
3176
+ lbZoomSync(); const g = dist(e); LZ.pinch = { d: g.d, k: LZ.k }; tap = null;
3177
+ }, { passive: true });
3178
+ lb.addEventListener('touchmove', (e) => { if (!LZ.pinch || e.touches.length !== 2) return; e.preventDefault(); const g = dist(e); lbZoom(LZ.pinch.k * g.d / LZ.pinch.d, g.x, g.y); }, { passive: false });
3179
+ lb.addEventListener('touchend', (e) => {
3180
+ if (LZ.pinch) { if (e.touches.length < 2) { LZ.pinch = null; tap = null; LZ.after = Date.now(); } return; }
3181
+ const t = e.changedTouches[0]; if (e.touches.length || !t || !e.target.closest('#lbScroll') || e.target.closest('button,a,input,select,textarea,.cmpov,.is-drawing') || Date.now() - (LZ.after || 0) < 400) { tap = null; return; }
3182
+ const now = Date.now();
3183
+ if (tap && now - tap.t < 320 && Math.hypot(t.clientX - tap.x, t.clientY - tap.y) < 30) { tap = null; lbZoomSync(); lbZoom(LZ.k > 1.05 ? 1 : 2, t.clientX, t.clientY); return; }
3184
+ tap = { t: now, x: t.clientX, y: t.clientY };
3185
+ }, { passive: true });
3064
3186
  // focus on a touch screen: a swipe sideways moves to the previous or next step, unless the page is pinch-zoomed
3065
3187
  lb.addEventListener('touchstart', (e) => { swipe = S.focus && e.touches.length === 1 && e.target.closest('#lbScroll') && !e.target.closest('.cmpov, .is-drawing') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
3066
3188
  lb.addEventListener('touchmove', (e) => { if (e.touches.length > 1) swipe = null; }, { passive: true });
3067
3189
  lb.addEventListener('touchend', (e) => {
3068
3190
  const sw = swipe; swipe = null; if (!sw || !S.focus) return;
3069
3191
  const t = e.changedTouches[0], dx = t.clientX - sw.x, dy = t.clientY - sw.y, sc = $('lbScroll');
3070
- if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - sw.t > 900) return;
3192
+ if ((window.visualViewport?.scale || 1) > 1.05 || (lbZoomSync(), LZ.k > 1.05) || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - sw.t > 900) return;
3071
3193
  if (sc && sc.scrollWidth > sc.clientWidth + 2) return; // a capture wider than the screen pans sideways instead
3072
3194
  $(dx < 0 ? 'lbNext' : 'lbPrev')?.click();
3073
3195
  }, { passive: true });
@@ -3215,6 +3337,26 @@ function issuesSections (issues) {
3215
3337
  + (looks.length ? `<div class="sec"><h4>${T('worthLook')}</h4><p class="note" style="margin:0 0 6px">${T('sigLead')}</p><ul class="issues">${looks.map(({ n, w }) => `<li><button data-go="${esc(n.id)}"><span class="badge st-partial">⚠</span><span><b>${esc(loc(n.title))}</b></span><small>${esc(w.join(' · '))}</small></button></li>`).join('')}</ul></div>` : '');
3216
3338
  }
3217
3339
 
3340
+ /* ---------- "what changed" notes of a design version (batch G) ---------- */
3341
+ const noteTxt = (t) => t == null ? '' : typeof t === 'string' ? t : (t[UI] || t.en || '');
3342
+ const stepNote = (id) => (isDesign() && !isDraft() && RUN.notes?.steps || []).find((x) => x.step === id) || null;
3343
+ function versionNotesHtml () {
3344
+ const nt = isDesign() && !isDraft() ? RUN.notes : null;
3345
+ if (!nt) return '';
3346
+ const steps = nt.steps || [];
3347
+ const mark = (r) => r === 'new' ? ['pass', '+'] : r === 'removed' ? ['fail', '−'] : r === 'note' ? ['skip', '✎'] : ['partial', 'Δ'];
3348
+ return `<div class="sec" id="versionNotes"><h4>${T('whatChanged')}${nt.from ? ` <span class="note">${T('sinceVersion')} v${esc(nt.from.version)}</span>` : ''}</h4>`
3349
+ + (noteTxt(nt.summary) ? `<p class="note" style="margin:0 0 6px;white-space:pre-line">${esc(noteTxt(nt.summary))}</p>` : '')
3350
+ + (steps.length ? `<ul class="issues">${steps.map((x) => { const [st, m] = mark(x.reason); return `<li><button data-go="${esc(x.step)}" ${byId(x.step) ? '' : 'disabled'}><span class="badge st-${st}">${m}</span><span><b>${esc(loc(byId(x.step)?.title) || x.title || x.step)}</b>${x.locales?.length && RUN.scope?.locales?.length > 1 ? ` <span class="note">${x.locales.map(esc).join(', ')}</span>` : ''}</span><small>${esc(noteTxt(x.text))}</small></button></li>`; }).join('')}</ul>` : '')
3351
+ + (nt.edited_by ? `<p class="note" style="margin:6px 0 0">${T('notesBy')} ${esc(nt.edited_by)}</p>` : '')
3352
+ + (APP() && canWrite() && !['viewer'].includes(APP().user?.role) && RUN.version ? `<button class="linkish" id="notesEdit" style="margin-top:6px">${T('notesEdit')}</button>` : '')
3353
+ + '</div>';
3354
+ }
3355
+ function stepNoteHtml (n) {
3356
+ const x = stepNote(n.id);
3357
+ return x && noteTxt(x.text) ? `<p class="note step-note" data-step-note><b>${T('whatChanged')}${RUN.notes?.from ? ` (v${esc(RUN.notes.from.version)} → v${esc(RUN.version)})` : ''}:</b> ${esc(noteTxt(x.text))}</p>` : '';
3358
+ }
3359
+
3218
3360
  function renderReport () {
3219
3361
  const c = RUN.summary, tot = screens().length || 1, pr = prevRun();
3220
3362
  const all = screens().flatMap((n) => n.criteria), ok = all.filter((x) => critSt(x) === 'pass').length;
@@ -3223,6 +3365,7 @@ function renderReport () {
3223
3365
  $('rside').innerHTML = `
3224
3366
  <div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
3225
3367
  ${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>` : ''}
3368
+ ${versionNotesHtml()}
3226
3369
  ${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)}${RUN.verdict.via === 'token' || RUN.verdict.via === 'agent' ? ` <span class="badge st-skip">${T(RUN.verdict.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : ''} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}${RUN.verdict.approved_by_tokens?.length ? ` · ${T('approvedBy')} ${T('approvedByTokens')}: ${RUN.verdict.approved_by_tokens.map((a) => esc(a.email)).join(', ')}` : ''}</p>` : ''}
3227
3370
  ${canWrite() ? `<textarea id="rvVerdictNote" hidden rows="2" placeholder="${T('verdictNotePh')}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"></textarea>
3228
3371
  <div class="share__row"><button class="btn btn--primary" data-verdict="accepted">${isDesign() ? T('acceptVersion') : T('accept')}</button><button class="btn" data-verdict="returned">${isDesign() ? T('returnVersion') : T('ret')}</button></div>
@@ -3260,7 +3403,14 @@ function renderReport () {
3260
3403
  $('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3261
3404
  $('rside').querySelectorAll('[data-public]').forEach((b) => { b.onclick = async () => { if (b.dataset.public === 'on' && !confirm(T('publicConfirm'))) return; b.disabled = true; try { await postJson('/public', { public: b.dataset.public === 'on' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3262
3405
  $('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3263
- $('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => openLb(el.dataset.go || el.dataset.id); });
3406
+ $('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => { if (byId(el.dataset.go || el.dataset.id)) openLb(el.dataset.go || el.dataset.id); }; });
3407
+ // the version's summary in someone's own words (designers; step texts too through the API, MCP and --notes)
3408
+ const ne = $('notesEdit'); if (ne) ne.onclick = async () => {
3409
+ const text = prompt(T('notesPrompt'), noteTxt(RUN.notes?.summary));
3410
+ if (text === null || !text.trim()) return;
3411
+ ne.disabled = true;
3412
+ try { await apiJson('PUT', scenarioApi(`/versions/v${RUN.version}/notes`), { notes: { summary: text.trim() } }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); ne.disabled = false; }
3413
+ };
3264
3414
  wireCommentsPanel();
3265
3415
  $('rside').querySelectorAll('.sesslegend [data-sess]').forEach((b) => { b.onclick = () => { S.sess = S.sess === b.dataset.sess ? null : b.dataset.sess; renderAll(); }; });
3266
3416
  }
@@ -3390,7 +3540,7 @@ function renderDesignBar () {
3390
3540
  const vs = (RUN.versions || []).slice(0, 8);
3391
3541
  const draftHref = `/${enc(a.org)}/scenarios/${enc(RUN.scenario.id)}/draft`;
3392
3542
  bar.innerHTML = `<div class="seg-label" style="margin:10px 0 6px">${T('versions')}</div><div class="dbar">`+`<a class="vchip vchip--draft" href="${draftHref}" ${isDraft() ? 'aria-current="true"' : ''} title="${T('openDraft')}">${T('draft')}${isDraft() ? ` #${esc(RUN.draft.seq)}` : ''}</a>`
3393
- + vs.map((v) => `<a class="vchip" href="${runHref(v.id)}" ${v.id === RUN.id ? 'aria-current="true"' : ''} title="${esc(v.name || '')} · ${esc(v.by)} · ${fmtDate(v.at)} · ${esc(T(v.verdict))}">v${esc(v.version)}${v.id === refId ? ' <span class="ref" title="' + T('designRef') + '">★</span>' : ''}${v.verdict === 'accepted' ? ' ✓' : ''}</a>`).join('')
3543
+ + vs.map((v) => `<a class="vchip" href="${runHref(v.id)}" ${v.id === RUN.id ? 'aria-current="true"' : ''} title="${esc(v.name || '')} · ${esc(v.by)} · ${fmtDate(v.at)} · ${esc(T(v.verdict))}${noteTxt(v.notes) ? ' · ' + esc(noteTxt(v.notes)) : ''}">v${esc(v.version)}${v.id === refId ? ' <span class="ref" title="' + T('designRef') + '">★</span>' : ''}${v.verdict === 'accepted' ? ' ✓' : ''}</a>`).join('')
3394
3544
  + '</div><div id="dbranches"></div>'
3395
3545
  + (isDraft() ? `<p class="note" style="margin:8px 0 0">${T('publishHint').replace('%V', `v${(RUN.versions?.[0]?.version || 0) + 1}`)}</p>` : '')
3396
3546
  + '<div class="share__row" style="margin-top:8px">'
@@ -4257,6 +4407,90 @@ async function downloadSignoff (kind, b) {
4257
4407
  }
4258
4408
  document.addEventListener('click', (e) => { const b = e.target.closest?.('[data-signoff]'); if (b) downloadSignoff(b.dataset.signoff, b); });
4259
4409
 
4410
+ /* ---------- Jira and Linear (#21): an issue from a step, the issues of the run on the canvas ----------
4411
+ GET …/runs/<id>/issues says which trackers take an issue from this run (connected and mapped for its project),
4412
+ whether this person may create one (Developer / Tester), the run's issues and the issue its branch names. The
4413
+ service writes the issue (screenshot, criterion, notes, link back) and a comment on the step that mirrors it. */
4414
+ Object.assign(I18N.cs, {
4415
+ issTitle: 'Issues', issNew: (k) => `Založit issue v ${k === 'Jira' ? 'Jiře' : 'Linearu'}`, issFormTitle: 'Nové issue', issTracker: 'Kam',
4416
+ issCriterion: 'Kritérium', issWholeStep: 'Celý krok', issName: 'Název', issNamePh: 'Prázdné: název se složí z kroku a kritéria', issNote: 'Poznámka pro vývoj',
4417
+ issSend: 'Založit issue', issSending: 'Zakládám issue…', issSent: (k) => `Issue ${k} je založené`, issWhat: 'Do issue půjde snímek, kritérium, připomínky a odkaz zpět na krok. Až bude hotové, komentář u kroku se vyřeší.',
4418
+ iss_open: 'otevřené', iss_done: 'hotové', iss_canceled: 'zrušené', issBranch: 'Issue z větve', issCount: (n) => `${n} ${n === 1 ? 'otevřené issue' : n < 5 ? 'otevřená issues' : 'otevřených issues'}`
4419
+ })
4420
+ Object.assign(I18N.en, {
4421
+ issTitle: 'Issues', issNew: (k) => `Create a ${k} issue`, issFormTitle: 'New issue', issTracker: 'Where',
4422
+ issCriterion: 'Criterion', issWholeStep: 'The whole step', issName: 'Title', issNamePh: 'Empty: made from the step and the criterion', issNote: 'Note for development',
4423
+ issSend: 'Create the issue', issSending: 'Creating the issue…', issSent: (k) => `Issue ${k} created`, issWhat: 'The issue gets the screenshot, the criterion, the notes and a link back to the step. When it is done, the comment on the step is resolved.',
4424
+ iss_open: 'open', iss_done: 'done', iss_canceled: 'cancelled', issBranch: 'Issue from the branch', issCount: (n) => `${n} open issue${n === 1 ? '' : 's'}`
4425
+ })
4426
+ var ISS = { data: null, form: null, busy: false }; // var: the footer may render before this line runs
4427
+ const issService = () => !!(APP()?.user && APP()?.run && !isDraft() && !APP()?.public);
4428
+ async function issLoad () {
4429
+ if (!issService()) return;
4430
+ try {
4431
+ const res = await fetch(runApi('/issues'), { credentials: 'same-origin' });
4432
+ if (!res.ok) return;
4433
+ ISS.data = await res.json();
4434
+ renderNodes(); issFoot();
4435
+ if ((S.open && !$('lb').hidden) || RV.open) rerenderComments(false);
4436
+ } catch {}
4437
+ }
4438
+ const issOf = (step) => (ISS.data?.issues || []).filter((i) => i.step === step);
4439
+ const issTrackers = () => (ISS.data?.trackers || []).filter((t) => t.target);
4440
+ /** The canvas card of a step with open issues. */
4441
+ function issBadge (id) {
4442
+ const n = issOf(id).filter((i) => i.state === 'open').length;
4443
+ return n ? `<span class="iss-badge" title="${esc(T('issCount')(n))}">⧉ ${n}</span>` : '';
4444
+ }
4445
+ /** The run's own issue (named by its branch or pull request) beside the GitHub issue in the footer. */
4446
+ function issFootHtml () {
4447
+ const l = ISS?.data?.linked; if (!l) return '';
4448
+ return `<span data-iss-linked title="${esc(T('issBranch'))}">${l.url ? `<a class="pr" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.key)}</a>` : `<span class="mono">${esc(l.key)}</span>`}${l.state ? ` <span class="note">${esc(T('iss_' + l.state))}</span>` : ''} · </span>`;
4449
+ }
4450
+ function issFoot () { const f = $('scFoot'); if (f && !f.querySelector('[data-iss-linked]')) f.insertAdjacentHTML('afterbegin', issFootHtml()); }
4451
+ /** Issues of a step and the form to create one, in the step detail and in review mode. */
4452
+ function issuesBlock (n, v) {
4453
+ if (!ISS.data || !n || n.kind === 'decision' || n.kind === 'stack') return '';
4454
+ const list = issOf(n.id); const trackers = issTrackers();
4455
+ const can = ISS.data.can_create && trackers.length && canWrite();
4456
+ if (!list.length && !can) return '';
4457
+ const items = list.length ? `<ul class="iss-list">${list.map((i) => `<li><a class="pr" href="${esc(i.url)}" target="_blank" rel="noopener">${esc(i.key)}</a> <span class="badge st-${i.state === 'open' ? 'partial' : i.state === 'done' ? 'pass' : 'skip'}">${esc(T('iss_' + i.state))}</span>${i.criterion ? ` <span class="id">${esc(i.criterion)}</span>` : ''} <span class="note">${esc(i.title)}</span></li>`).join('')}</ul>` : '';
4458
+ let tail = '';
4459
+ if (can && ISS.form === n.id) {
4460
+ const crit = (n.criteria || []).slice().sort((a, b) => (a.verdict === 'fail' ? 0 : 1) - (b.verdict === 'fail' ? 0 : 1));
4461
+ const pick = crit.find((c) => (c.by_locale?.[S.loc]?.[v?.name]?.verdict || c.verdict) === 'fail');
4462
+ tail = `<div class="iss-form" data-iss-form="${esc(n.id)}"><p class="note">${esc(T('issWhat'))}</p>
4463
+ ${trackers.length > 1 ? `<label>${esc(T('issTracker'))} <select id="issTracker">${trackers.map((t) => `<option value="${esc(t.kind)}">${esc(t.label)} · ${esc(t.target)}</option>`).join('')}</select></label>` : ''}
4464
+ <label>${esc(T('issCriterion'))} <select id="issCrit"><option value="">${esc(T('issWholeStep'))}</option>${crit.map((c) => `<option value="${esc(c.id)}" ${pick && pick.id === c.id ? 'selected' : ''}>${esc(c.id)} · ${esc(String(critText(c)).slice(0, 60))}</option>`).join('')}</select></label>
4465
+ <label>${esc(T('issName'))} <input id="issName" maxlength="200" placeholder="${esc(T('issNamePh'))}"></label>
4466
+ <label>${esc(T('issNote'))} <textarea id="issNote" rows="2"></textarea></label>
4467
+ <div class="share__row"><button class="btn btn--primary" data-iss-send="${esc(n.id)}" data-iss-vp="${esc(v?.name || '')}">${esc(T('issSend'))}</button><button class="btn" data-iss-cancel>${esc(T('cancel'))}</button></div></div>`;
4468
+ } else if (can) tail = `<div class="share__row">${trackers.map((t) => `<button class="btn" data-iss-new="${esc(n.id)}" data-iss-kind="${esc(t.kind)}">${esc(T('issNew')(t.label))}</button>`).join('')}</div>`;
4469
+ return `<div class="sec iss"><h4>${esc(T('issTitle'))}</h4>${items}${tail}</div>`;
4470
+ }
4471
+ async function issSend (step, vp) {
4472
+ if (ISS.busy) return;
4473
+ const a = APP(); const trackers = issTrackers();
4474
+ const to = $('issTracker')?.value || ISS.kind || trackers[0]?.kind;
4475
+ ISS.busy = true; toast(T('issSending'));
4476
+ try {
4477
+ const res = await fetch(`${a.api}/orgs/${a.org}/issues`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ run_id: a.run, step, to, criterion: $('issCrit')?.value || undefined, title: $('issName')?.value.trim() || undefined, note: $('issNote')?.value.trim() || undefined, locale: S.loc, viewport: vp || undefined }) });
4478
+ const out = await res.json().catch(() => ({}));
4479
+ if (!res.ok) throw new Error(out.error || `HTTP ${res.status}`);
4480
+ ISS.form = null;
4481
+ toast(T('issSent')(out.key));
4482
+ await issLoad();
4483
+ await refreshComments();
4484
+ } catch (e) { toast(e.message); } finally { ISS.busy = false; }
4485
+ }
4486
+ document.addEventListener('click', (e) => {
4487
+ const nb = e.target.closest?.('[data-iss-new]');
4488
+ if (nb) { ISS.form = nb.dataset.issNew; ISS.kind = nb.dataset.issKind; rerenderComments(false); setTimeout(() => { const s = $('issTracker'); if (s && ISS.kind) s.value = ISS.kind; $('issName')?.focus({ preventScroll: true }); }, 0); return; }
4489
+ if (e.target.closest?.('[data-iss-cancel]')) { ISS.form = null; rerenderComments(false); return; }
4490
+ const sb = e.target.closest?.('[data-iss-send]');
4491
+ if (sb) issSend(sb.dataset.issSend, sb.dataset.issVp);
4492
+ });
4493
+
4260
4494
  /* ---------- boot ---------- */
4261
4495
  /* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ----------
4262
4496
  The queue comes from the service (GET …/runs/<id>/review-queue: order, reasons, decisions, approvals carried over
@@ -4458,7 +4692,7 @@ function rvRender () {
4458
4692
  box.innerHTML = top + `<div class="rvm__body"><div class="rvm__stage" id="rvStage">${stage}</div>
4459
4693
  <aside class="rvm__side" id="rvSide"><div class="lb__head"><div class="lb__row"><span class="kind">${ICON[n.kind] || ''}</span><span class="badge st-${s}">${MARK[s]} ${T(s)}</span><span class="note">${esc(RV.idx + 1)} / ${esc(items.length)}</span></div>
4460
4694
  <h3 id="rvStepTitle">${esc(loc(n.title))}</h3><div class="node__path">${esc(cap?.url || loc(n.path) || '')}</div><div class="rvm__why">${chips}</div></div>
4461
- ${retBox}${rvDecisionHtml(it)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
4695
+ ${retBox}${rvDecisionHtml(it)}${issuesBlock(n, v)}${changed}${n.purpose ? `<div class="sec"><h4>${T('purpose')}</h4><p class="purpose">${esc(n.purpose)}</p></div>` : ''}${crit}
4462
4696
  ${n.kind !== 'stack' ? commentsBlock({ scope: 'step', nodeId: n.id, compact: true }) : ''}</aside></div>${rvFootHtml(it)}`;
4463
4697
  rvWire(it, n, v);
4464
4698
  setHash({ review: it.step });
@@ -4525,7 +4759,7 @@ function rvWire (it, n, v) {
4525
4759
  box.addEventListener('touchend', (e) => {
4526
4760
  const s0 = sw; sw = null; if (!s0) return;
4527
4761
  const t = e.changedTouches[0], dx = t.clientX - s0.x, dy = t.clientY - s0.y, sc = $('rvStage');
4528
- if ((window.visualViewport?.scale || 1) > 1.05 || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - s0.t > 900) return;
4762
+ if ((window.visualViewport?.scale || 1) > 1.05 || (lbZoomSync(), LZ.k > 1.05) || Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || Date.now() - s0.t > 900) return;
4529
4763
  if (sc && sc.scrollWidth > sc.clientWidth + 2) return;
4530
4764
  rvGo(dx < 0 ? 1 : -1);
4531
4765
  }, { passive: true });
@@ -4724,20 +4958,22 @@ load().then((data) => {
4724
4958
  wireRecPreviews($('nodes'));
4725
4959
  $('ptBtn').hidden = !isDesign(); $('ptBtn').onclick = () => ptOpen(null);
4726
4960
  rvInit(); // review mode (Q)
4961
+ if (issService()) setTimeout(() => { issLoad(); }, 0); // Jira and Linear issues of the run (#21)
4727
4962
  wireComments($('lb')); wireComments($('rside')); wireComments($('rp'));
4728
4963
  // small menus of the top bar: one open at a time, closed by a click elsewhere or Escape
4729
4964
  // wide screens show navigation, language and view options in the bar; narrower ones fold them into ☰
4730
4965
  const wideMq = matchMedia('(min-width:1500px)');
4731
4966
  const placeBar = () => {
4732
- const wide = wideMq.matches; document.body.classList.toggle('wide', wide);
4967
+ const wide = wideMq.matches && !S.unwide; document.body.classList.toggle('wide', wide);
4733
4968
  if (wide) { $('navSlot').appendChild($('appNav')); $('viewSlot').appendChild($('viewCtls')); $('barMenu').hidden = true; }
4734
4969
  else { $('barMenu').appendChild($('appNav')); $('barMenu').appendChild($('viewCtls')); }
4735
4970
  renderPresence();
4736
4971
  };
4737
- wideMq.addEventListener('change', placeBar); placeBar();
4972
+ wideMq.addEventListener('change', () => { S.unwide = false; placeBar(); }); placeBar(); S.placeBar = placeBar;
4738
4973
  const menus = [['menuBtn', 'barMenu'], ['helpBtn', 'helpPop']];
4739
4974
  const closeMenus = (except) => { for (const [b, m] of menus) if (m !== except) { $(m).hidden = true; $(b).setAttribute('aria-expanded', 'false'); } if (except !== 'pmenu' && PR.menu) { PR.menu = false; renderPresence(); } };
4740
- for (const [b, m] of menus) $(b).onclick = (e) => { e.stopPropagation(); const open = $(m).hidden; closeMenus(m); $(m).hidden = !open; $(b).setAttribute('aria-expanded', String(open)); };
4975
+ for (const [b, m] of menus) $(b).onclick = (e) => { e.stopPropagation(); const open = $(m).hidden; closeMenus(m); $(m).hidden = !open; $(b).setAttribute('aria-expanded', String(open)); if (open) placePop($(m)); };
4976
+ addEventListener('resize', () => document.querySelectorAll('.bar .menu:not([hidden])').forEach(placePop));
4741
4977
  document.addEventListener('click', (e) => { if (!e.target.closest('.popwrap, .presence')) closeMenus(null); });
4742
4978
  S.closeMenus = closeMenus;
4743
4979
  document.addEventListener('keydown', (e) => {