kaloko 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/viewer/index.html CHANGED
@@ -791,6 +791,58 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
791
791
  @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
792
  @media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
793
793
  @media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}@media (max-width:820px){#rvBtn{order:3}}
794
+ /* batch F: swipe and onion compare, drawn regions, comment drawings and images, the grid, the acceptance record */
795
+ .cmpov{display:grid;margin:0 auto;background:#fff;box-shadow:var(--shadow);position:relative;user-select:none;-webkit-user-select:none;overflow:hidden}
796
+ .cmpov>img{grid-area:1/1;display:block;width:100%;height:auto;align-self:start;pointer-events:none}
797
+ .cmpov--swipe{cursor:ew-resize;touch-action:pan-y pinch-zoom}
798
+ .cmpov--swipe .cmpov__a{clip-path:inset(0 calc(100% - var(--pos)) 0 0)}
799
+ .cmpov--onion .cmpov__a{opacity:var(--op)}
800
+ .cmpov__line{position:absolute;top:0;bottom:0;left:var(--pos);width:2px;margin-left:-1px;background:#ffd200;box-shadow:0 0 0 1px rgba(15,15,14,.35);pointer-events:none}
801
+ .cmpov__line i{position:absolute;top:calc(50% - 13px);left:-12px;width:26px;height:26px;border-radius:50%;background:#ffd200;box-shadow:0 1px 4px rgba(0,0,0,.35)}
802
+ .cmpov__line i::before{content:"‹ ›";position:absolute;inset:0;font:700 12px/26px system-ui,sans-serif;color:#151514;text-align:center}
803
+ .cmpov__ctl{display:inline-flex;gap:6px;align-items:center;margin-left:auto;font-size:11.5px;color:var(--muted)}.cmpov__range{width:140px;accent-color:var(--ink)}
804
+ .lb__img.is-drawing,.lb__img.is-drawing img{cursor:crosshair;touch-action:none}
805
+ .c-shapes{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible}
806
+ .c-shapes .shp{fill:none;stroke:#e4572e;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke;filter:drop-shadow(0 0 1.5px rgba(255,255,255,.95))}
807
+ .c-shapes .shp--head{fill:#e4572e}.c-shapes .shp.is-done{stroke:#8a8a84}.c-shapes .shp--head.is-done{fill:#8a8a84}
808
+ .c-shapes .shp.is-new{stroke:var(--ink);stroke-dasharray:6 4}.c-shapes .shp--head.is-new{fill:var(--ink);stroke-dasharray:none}
809
+ .c-shapes .shp.is-ign{stroke:#2f6fde;stroke-dasharray:6 4;fill:rgba(47,111,222,.12)}
810
+ .ignbox.drawn{border-color:#2f6fde;background:repeating-linear-gradient(45deg,rgba(47,111,222,.14) 0 6px,transparent 6px 12px)}
811
+ .ignx{position:absolute;right:-9px;top:-9px;width:20px;height:20px;border-radius:50%;border:0;background:#2f6fde;color:#fff;font:600 13px/20px system-ui,sans-serif;cursor:pointer;pointer-events:auto;padding:0}
812
+ .c-tools button{display:inline-flex;gap:4px;align-items:center}
813
+ .c-attachrow{display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:12px;margin:0 0 6px}
814
+ .c-chip{display:inline-flex;gap:5px;align-items:center;padding:2px 6px;border:1px solid var(--line);border-radius:6px;max-width:220px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.c-chip img{width:22px;height:22px;object-fit:cover;border-radius:3px}
815
+ .c-attach{cursor:pointer}
816
+ .c-files{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0 2px}.c-file{display:flex;flex-direction:column;gap:3px;width:96px;font-size:11px;color:var(--muted);text-decoration:none}.c-file img{width:96px;height:72px;object-fit:cover;border:1px solid var(--line);border-radius:6px;background:var(--panel-2)}.c-file span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
817
+ .gvm{position:fixed;inset:0;z-index:24;background:var(--ground);display:grid;grid-template-rows:auto 1fr}
818
+ .gvm__top{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line-strong)}
819
+ .gvm__title{margin:0;font-size:15px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46vw}
820
+ .gvm__opt{display:inline-flex;gap:5px;align-items:center;font-size:12.5px}
821
+ .gvm__body{overflow:auto;padding:0 16px 24px;background:var(--panel-2);overscroll-behavior:contain}
822
+ .gv__grid{display:grid;grid-template-columns:minmax(80px,110px) repeat(var(--cols),220px);gap:12px;align-items:start;width:max-content}
823
+ .gv__row{display:contents}
824
+ .gv__row>*{content-visibility:auto;contain-intrinsic-size:auto 180px}
825
+ .gv__head>*{position:sticky;top:0;z-index:2;background:var(--panel-2);padding:12px 0 8px;font-size:12px;font-weight:600;content-visibility:visible}
826
+ .gv__colh small,.gv__rowh small{display:block;font-weight:400;color:var(--muted);font-size:11px}
827
+ .gv__rowh{position:sticky;left:0;z-index:1;background:var(--panel-2);font-size:12.5px;padding-top:6px;align-self:stretch}
828
+ .gv__corner{left:0;z-index:3}
829
+ .gv__cell{position:relative;display:block;width:100%;padding:0;border:2px solid var(--line);border-radius:8px;background:#fff;overflow:hidden;cursor:pointer;aspect-ratio:var(--ar);text-align:left}
830
+ .gv__grid.is-full .gv__cell,.gv__cards.is-full .gv__cell{aspect-ratio:auto;min-height:80px}
831
+ .gv__cell img{display:block;width:100%;height:100%;object-fit:cover;object-position:top}
832
+ .gv__grid.is-full .gv__cell img,.gv__cards.is-full .gv__cell img{height:auto}
833
+ .gv__cell.st-fail{border-color:var(--fail)}.gv__cell.st-partial{border-color:var(--partial)}
834
+ .gv__cell:focus-visible{outline:3px solid #ffd200;outline-offset:2px}
835
+ .gv__cell:hover{box-shadow:var(--shadow)}
836
+ .gv__st{position:absolute;right:6px;top:6px}
837
+ .gv__cmt{position:absolute;left:6px;bottom:6px;display:inline-flex;gap:3px;align-items:center;font-size:11px;padding:1px 6px;border-radius:999px;background:var(--panel);border:1px solid var(--line)}.gv__cmt svg.i{width:12px;height:12px}
838
+ .gv__none{display:grid;place-items:center;height:100%;min-height:60px;font-size:12px;color:var(--muted);background:var(--panel-2)}
839
+ .gv__cap{position:absolute;left:6px;top:6px;z-index:1;font:600 11px/1 var(--mono,monospace);padding:3px 6px;border-radius:5px;background:var(--panel);border:1px solid var(--line)}
840
+ .gv__cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding-top:12px}.gv__crow{display:contents}
841
+ .gv__empty{padding:24px 0}
842
+ .rvm__stage .gv__grid,.rvm__stage .gv__cards{padding-bottom:12px}
843
+ @media (max-width:900px){.gvm__keys{display:none}.gvm__title{max-width:60vw}}
844
+ @media (max-width:560px){.gvm__top{padding:8px 10px}.gvm__body{padding:0 10px 16px}.gvm__top .brand small{display:none}}
845
+ .signoff{margin-top:10px;padding-top:10px;border-top:1px dashed var(--line)}.signoff .note{margin:0 0 6px}
794
846
  </style>
795
847
  </head>
796
848
  <body>
@@ -880,6 +932,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
880
932
  <div class="rp" id="rp" hidden role="dialog" aria-modal="true" aria-label="Replay"></div>
881
933
  <div class="pt" id="pt" hidden role="dialog" aria-modal="true" aria-label="Prototype"></div>
882
934
  <div class="rvm" id="rv" hidden role="dialog" aria-modal="true" aria-label="Review"></div>
935
+ <div class="gvm" id="gv" hidden role="dialog" aria-modal="true" aria-label="Grid"></div>
883
936
  <div class="toast" id="toast" hidden></div>
884
937
  <script>
885
938
  /* Kaloko viewer. Data source:
@@ -1253,7 +1306,7 @@ const noteHtml = (s) => esc(s).replace(/(^|\s)@([\w.+-]+(?:@[\w.-]+)?)/g, '$1<b
1253
1306
  /** Numbers of the threads pinned on this step's screenshot (the same numbers as the marks on the image). */
1254
1307
  function pinNumbers (nodeId) {
1255
1308
  const m = new Map(); let k = 0;
1256
- for (const t of threads('step', nodeId)) if (t.pin && !t.deleted && (C.showResolved || isOpenT(t))) m.set(t.id, ++k);
1309
+ for (const t of threads('step', nodeId)) if ((t.pin || t.shape) && !t.deleted && (C.showResolved || isOpenT(t))) m.set(t.id, ++k);
1257
1310
  return m;
1258
1311
  }
1259
1312
  function commentsBlock ({ scope, nodeId = '', pinnable = false, compact = false }) {
@@ -1266,9 +1319,9 @@ function commentsBlock ({ scope, nodeId = '', pinnable = false, compact = false
1266
1319
  const lab = { approved: T('approved'), rejected: T('rejected') };
1267
1320
  const item = (r, root) => C.editing === r.id ? composer({ scope, nodeId, edit: r }) : `<div class="c-item" data-cid="${esc(r.id)}">
1268
1321
  <div class="c-meta"><span class="av">${esc(initials(r.email))}</span><span class="c-who">${esc(r.email)}${r.agent ? ` <span class="c-agent" title="${esc(T('viaAgent'))}">${esc(r.agent)}</span>` : ''}</span><span class="id">${fmtDate(r.created_at)}${r.updated_at ? ` · ${T('edited')}` : ''}${root && r.locale && scope === 'step' ? ` · ${esc(r.locale)}` : ''}</span>
1269
- ${root && pins.has(r.id) ? `<button class="c-pin" data-c-act="showpin" data-id="${esc(r.id)}" title="${esc(T('showPin'))}">${pins.get(r.id)}</button>` : ''}${root && r.anchor ? `<span class="c-anchor" title="${esc(r.anchor.selector || '')}">⌖</span>` : ''}
1322
+ ${root && pins.has(r.id) ? `<button class="c-pin" data-c-act="showpin" data-id="${esc(r.id)}" title="${esc(T('showPin'))}${r.shape ? ` · ${esc(T('shape_' + r.shape.kind))}` : ''}">${pins.get(r.id)}</button>` : ''}${root && r.anchor ? `<span class="c-anchor" title="${esc(r.anchor.selector || '')}">⌖</span>` : ''}
1270
1323
  ${root && lab[r.status] ? `<span class="badge st-${r.status === 'approved' ? 'pass' : 'fail'}">${esc(lab[r.status])}</span>` : ''}${root && r.resolved_at ? `<span class="badge st-skip" title="${esc(r.resolved_by || '')}">${T('resolved')}</span>` : ''}</div>
1271
- <div class="c-note">${r.deleted ? `<i class="muted">${T('commentDeleted')}</i>` : noteHtml(r.note)}</div>
1324
+ <div class="c-note">${r.deleted ? `<i class="muted">${T('commentDeleted')}</i>` : noteHtml(r.note)}</div>${!r.deleted && !isDraft() ? filesHtml(scope, r.attachments) : ''}
1272
1325
  ${canWrite() && !r.deleted ? `<div class="c-acts">${root && isOpenT(r) ? `<button data-c-act="reply" data-id="${esc(r.id)}">${T('replyAct')}</button>` : ''}${root ? (r.resolved_at ? `<button data-c-act="reopen" data-id="${esc(r.id)}">${T('reopenAct')}</button>` : r.status !== 'approved' ? `<button data-c-act="resolve" data-id="${esc(r.id)}">${T('resolve')}</button>` : '') : ''}${r.email === me ? `<button data-c-act="edit" data-id="${esc(r.id)}">${T('editAct')}</button>` : ''}${r.email === me || admin ? `<button data-c-act="delete" data-id="${esc(r.id)}">${T('deleteAct')}</button>` : ''}</div>` : ''}
1273
1326
  </div>`;
1274
1327
  const card = (t) => `<li class="c-thread ${isOpenT(t) ? '' : 'is-done'} ${C.focus === t.id ? 'is-focus' : ''}" data-thread="${esc(t.id)}">${item(t, true)}${t.replies.length ? `<ul class="c-replies">${t.replies.map((r) => `<li>${item(r, false)}</li>`).join('')}</ul>` : ''}${C.replyTo === t.id ? composer({ scope, nodeId, parent: t.id }) : ''}</li>`;
@@ -1287,8 +1340,8 @@ function composer ({ scope, nodeId, parent = null, edit = null, pinnable = false
1287
1340
  const step = scope === 'step' && main;
1288
1341
  const placeholder = T(parent ? 'replyPh' : scope === 'flow' ? 'flowPh' : scope === 'run' ? 'runPh' : 'notePh');
1289
1342
  return `<div class="c-compose ${main ? 'c-main' : ''}" data-key="${esc(key)}" data-scope="${scope}" data-node="${esc(nodeId)}"${parent ? ` data-parent="${esc(parent)}"` : ''}${edit ? ` data-edit="${esc(edit.id)}"` : ''}>
1290
- ${step && (pinnable || LIVE.anchor) ? `<div class="c-pinrow">${C.pin ? `<span class="c-pinned">${ICON.pin} ${T('pinnedOnShot')} <button class="linkish" data-c-act="unpin">${T('cancel')}</button></span>` : pinnable ? `<button class="btn c-pinbtn" data-c-act="pinmode" aria-pressed="${C.pinMode}">${ICON.pin} ${T(C.pinMode ? 'pinClickShot' : 'pinOnShot')}</button>` : ''}${LIVE.anchor ? `<span class="c-pinned">⌖ ${esc(LIVE.anchor.text || LIVE.anchor.selector || '')}</span>` : ''}</div>` : ''}
1291
- <textarea rows="${main ? 3 : 2}" class="c-text" placeholder="${esc(placeholder)}" aria-label="${esc(T('commentLabel'))}">${esc(val)}</textarea>
1343
+ ${step && (pinnable || LIVE.anchor || C.shape) ? `<div class="c-pinrow">${drawToolsHtml(pinnable)}${LIVE.anchor ? `<span class="c-pinned">⌖ ${esc(LIVE.anchor.text || LIVE.anchor.selector || '')}</span>` : ''}</div>` : ''}
1344
+ <textarea rows="${main ? 3 : 2}" class="c-text" placeholder="${esc(placeholder)}" aria-label="${esc(T('commentLabel'))}">${esc(val)}</textarea>${edit ? '' : attachHtml(key)}
1292
1345
  <div class="share__row c-btns">${edit ? `<button class="btn btn--primary" data-c-act="save">${T('saveAct')}</button><button class="btn" data-c-act="cancel">${T('cancel')}</button>`
1293
1346
  : parent ? `<button class="btn btn--primary" data-c-act="send">${T('replyAct')}</button><button class="btn" data-c-act="cancel">${T('cancel')}</button>`
1294
1347
  : `<button class="btn btn--primary" data-c-act="send" data-status="comment">${T('comment')}</button>${step ? `<button class="btn" data-c-act="send" data-status="rejected">${T('reject')}</button><button class="btn" data-c-act="send" data-status="approved">${T('approve')}</button>` : ''}`}
@@ -1299,6 +1352,9 @@ function wireComments (root) {
1299
1352
  if (!root || root.dataset.cWired) return;
1300
1353
  root.dataset.cWired = '1';
1301
1354
  root.addEventListener('input', (e) => { const t = e.target.closest?.('.c-text'); if (t) saveDraft(t.closest('.c-compose').dataset.key, t.value); });
1355
+ // images for the comment: picked, or pasted into the text (batch F)
1356
+ root.addEventListener('change', (e) => { const f = e.target.closest?.('.c-attach input[type=file]'); if (f && f.files?.length) { attachFiles(f.closest('.c-compose'), f.files); f.value = ''; } });
1357
+ root.addEventListener('paste', (e) => { const t = e.target.closest?.('.c-text'); const box = t?.closest('.c-compose'); if (!box || isDraft() || !APP()?.user) return; const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/')); if (files.length) { e.preventDefault(); attachFiles(box, files); } });
1302
1358
  root.addEventListener('keydown', (e) => {
1303
1359
  const t = e.target.closest?.('.c-text'); if (!t) return;
1304
1360
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); t.closest('.c-compose').querySelector('[data-c-act="send"],[data-c-act="save"]')?.click(); }
@@ -1316,6 +1372,13 @@ function wireComments (root) {
1316
1372
  if (act === 'cancel') { saveDraft(box.dataset.key, ''); C.replyTo = null; C.editing = null; rerenderComments(); return; }
1317
1373
  if (act === 'pinmode') { C.pinMode = !C.pinMode; $('lbImg')?.classList.toggle('is-pinning', C.pinMode); b.setAttribute('aria-pressed', C.pinMode); b.lastChild.textContent = ` ${T(C.pinMode ? 'pinClickShot' : 'pinOnShot')}`; if (C.pinMode) toast(T('pinClickShot')); return; }
1318
1374
  if (act === 'unpin') { C.pin = null; rerenderComments(); return; }
1375
+ if (act === 'unshape') { C.shape = null; rerenderComments(); return; }
1376
+ if (act === 'unattach') { const list = C.files[box.dataset.key] || []; list.splice(Number(b.dataset.i), 1); rerenderComments(); return; }
1377
+ if (act === 'tool') {
1378
+ const tool = b.dataset.tool; const cur = C.pinMode ? 'pin' : C.draw;
1379
+ if (tool === 'pin') { armDraw(null); C.pinMode = cur !== 'pin'; $('lbImg')?.classList.toggle('is-pinning', C.pinMode); $('lb').querySelectorAll('.c-tools [data-tool]').forEach((x) => x.setAttribute('aria-pressed', String(C.pinMode && x.dataset.tool === 'pin'))); if (C.pinMode) toast(T('pinClickShot')); return; }
1380
+ armDraw(cur === tool ? null : tool); if (C.draw) toast(T('drawHint')); return;
1381
+ }
1319
1382
  if (act === 'showpin') { const mark = document.querySelector(`.pinmark[data-thread="${CSS.escape(id)}"]`); if (mark) { mark.classList.remove('flash'); void mark.offsetWidth; mark.classList.add('flash'); $('lbScroll')?.scrollTo({ top: Math.max(0, mark.offsetTop - 120), behavior: 'smooth' }); } return; }
1320
1383
  if (act === 'delete') { if (!confirm(T('confirmDelete'))) return; b.disabled = true; await cApi(scope, `/${encodeURIComponent(id)}`, 'DELETE'); toast(T('deletedToast')); await refreshComments(); return; }
1321
1384
  if (act === 'resolve' || act === 'reopen') { b.disabled = true; await cApi(scope, `/${encodeURIComponent(id)}/${act}`, 'POST', act === 'resolve' ? { note: '' } : {}); await refreshComments(); return; }
@@ -1328,10 +1391,13 @@ function wireComments (root) {
1328
1391
  const note = box.querySelector('.c-text').value.trim(); const status = b.dataset.status || 'comment';
1329
1392
  if (status !== 'approved' && !note) { toast(T('noteRequired')); box.querySelector('.c-text').focus(); return; }
1330
1393
  const main = !box.dataset.parent;
1331
- const body = { note, status, node_id: nodeId, locale: scope === 'step' ? S.loc : null, ...(scope === 'step' && main ? { viewport: S.lbVp || null, ...(C.pin ? { pin: C.pin } : {}), ...(LIVE.anchor ? { anchor: LIVE.anchor, revision_id: revOf(byId(nodeId)) } : {}) } : {}), ...(box.dataset.parent ? { parent_id: box.dataset.parent } : {}) };
1394
+ const files = C.files[box.dataset.key] || [];
1395
+ if (files.some((f) => !f.id)) { toast(T('attachUploading')); return; }
1396
+ const body = { note, status, node_id: nodeId, locale: scope === 'step' ? S.loc : null, ...(scope === 'step' && main ? { viewport: S.lbVp || null, ...(C.pin ? { pin: C.pin } : {}), ...(C.shape ? { shape: C.shape } : {}), ...(LIVE.anchor ? { anchor: LIVE.anchor, revision_id: revOf(byId(nodeId)) } : {}) } : {}), ...(box.dataset.parent ? { parent_id: box.dataset.parent } : {}), ...(files.length ? { attachments: files.map((f) => f.id) } : {}) };
1332
1397
  b.disabled = true;
1333
1398
  await cApi(scope, '', 'POST', body);
1334
- saveDraft(box.dataset.key, ''); if (main) { C.pin = null; C.pinMode = false; LIVE.anchor = null; } C.replyTo = null;
1399
+ saveDraft(box.dataset.key, ''); if (main) { C.pin = null; C.pinMode = false; C.shape = null; C.draw = null; LIVE.anchor = null; } C.replyTo = null;
1400
+ delete C.files[box.dataset.key];
1335
1401
  toast(T('saved')); await refreshComments();
1336
1402
  }
1337
1403
  } catch (err) { window.Sentry?.captureException(err); toast(err.message); b.disabled = false; }
@@ -1352,14 +1418,16 @@ function placeCommentPins (img, n, cap, v) {
1352
1418
  img.appendChild(d);
1353
1419
  }
1354
1420
  if (C.pin && (!C.pin.viewport || C.pin.viewport === v.name)) { const d = document.createElement('span'); d.className = 'pinmark pin-xy is-new'; d.textContent = '+'; Object.assign(d.style, { left: `${C.pin.x * w}px`, top: `${C.pin.y * h}px` }); img.appendChild(d); }
1421
+ drawCommentShapes(img, n, v, nums);
1355
1422
  };
1356
1423
  const im = img.querySelector('img'); if (im) im.complete ? draw() : im.addEventListener('load', draw, { once: true });
1357
1424
  img.classList.toggle('is-pinning', C.pinMode);
1425
+ if (C.draw) armDraw(C.draw);
1358
1426
  img.onclick = (e) => {
1359
1427
  if (!C.pinMode || e.target.closest('.pinmark')) return;
1360
1428
  const r = img.querySelector('img').getBoundingClientRect();
1361
1429
  C.pin = { x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)), y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)), viewport: v.name };
1362
- C.pinMode = false; rerenderComments();
1430
+ C.pinMode = false; C.shape = null; rerenderComments();
1363
1431
  requestAnimationFrame(() => { const t = $('lb').querySelector('.c-main .c-text'); t?.scrollIntoView({ block: 'center' }); t?.focus(); });
1364
1432
  };
1365
1433
  }
@@ -1534,7 +1602,7 @@ function ignoreMask (ign, W, H) {
1534
1602
  for (const r of ign.rects) { const x0 = Math.max(0, Math.floor(r.x * k)), x1 = Math.min(W, Math.ceil((r.x + r.w) * k)); for (let y = Math.max(0, Math.floor(r.y * k)); y < Math.min(H, Math.ceil((r.y + r.h) * k)); y++) m.fill(1, y * W + x0, y * W + x1); }
1535
1603
  return m;
1536
1604
  }
1537
- const ignOf = (a, b) => { const rects = [...(a?.ignore || []), ...(b?.ignore || [])]; return rects.length ? { rects, cssWidth: a?.size?.[0] || b?.size?.[0] || null } : null; };
1605
+ const ignOf = (a, b, nodeId = null, key = null) => { const rects = [...(a?.ignore || []), ...(b?.ignore || []), ...(nodeId && key ? drawnRects(nodeId, key) : [])]; return rects.length ? { rects, cssWidth: a?.size?.[0] || b?.size?.[0] || null } : null; };
1538
1606
  const pxClass = (r) => r > 0.1 ? 'lv2' : r > 0.01 ? 'lv1' : 'lv0';
1539
1607
  const pxText = (r) => `${(r * 100).toFixed(r < 0.1 ? 1 : 0)} % ${T('pxChanged')}`;
1540
1608
  /* fills the per-node pixel badges after the nodes are rendered */
@@ -1555,7 +1623,7 @@ function fillPixelBadges () {
1555
1623
  if (!ca || !cb) { el.textContent = '—'; el.title = T('cmpNoCap'); return; }
1556
1624
  const small = ca.thumb && cb.thumb;
1557
1625
  try {
1558
- const r = await pixelDiff(`${n.id}|${S.loc}|${v.name}${small ? '|thumb' : ''}`, asset(small ? ca.thumb : ca.image || ca.png), S.cmp.base + (small ? cb.thumb : cb.image || cb.png), 256, ignOf(ca, cb));
1626
+ const r = await pixelDiff(`${n.id}|${S.loc}|${v.name}${small ? '|thumb' : ''}`, asset(small ? ca.thumb : ca.image || ca.png), S.cmp.base + (small ? cb.thumb : cb.image || cb.png), 256, ignOf(ca, cb, n.id, v.name));
1559
1627
  el.textContent = `◐ ${pxText(r.ratio)}`; el.className = `pxd ${pxClass(r.ratio)}`;
1560
1628
  } catch { el.textContent = '◐ ?'; el.title = T('pxUnavail'); }
1561
1629
  };
@@ -2293,6 +2361,7 @@ function renderChrome () {
2293
2361
  $('helpBtn').title = `${T('helpTitle')} (?)`; $('helpBtn').setAttribute('aria-label', T('helpTitle'));
2294
2362
  $('hint').innerHTML = `<ul>${T('hint').split(' · ').map((x) => `<li>${x}</li>`).join('')}<li><span class="kbd">C</span> ${esc(T('keyComment'))}</li><li><span class="kbd">D</span> ${esc(T('keyDev'))}</li>${RUN.scope.locales.length > 1 ? `<li><span class="kbd">L</span> <span class="kbd">[</span> <span class="kbd">]</span> ${esc(T('keyLoc'))}</li>` : ''}<li><span class="kbd">?</span> ${esc(T('keyHelp'))}</li></ul>`;
2295
2363
  $('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', rvHintItem()); // review mode (Q)
2364
+ if (gridUseful()) $('hint').querySelector('ul')?.insertAdjacentHTML('beforeend', `<li><span class="kbd">G</span> ${esc(T('gridHint'))}</li>`);
2296
2365
  const ib = $('inboxBtn');
2297
2366
  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})` : ''}`); }
2298
2367
  else if (app && !app.embed) { ib.hidden = false; ib.href = '/login'; ib.textContent = T('signInLink'); }
@@ -2472,7 +2541,7 @@ function aiGauge (r) {
2472
2541
  }
2473
2542
  /* the evaluator's one sentence next to the score (#17); marked when it saw the screenshot */
2474
2543
  /* dashed boxes for ignored and moving regions, numbered tab stops when asked (step detail → Keyboard) */
2475
- function overlayRegions (box, cap, v) {
2544
+ function overlayRegions (box, cap, v, n = null) {
2476
2545
  const im = box.querySelector('img'); if (!im) return;
2477
2546
  const place = () => {
2478
2547
  box.querySelectorAll('.ignbox, .kbstop').forEach((e) => e.remove());
@@ -2480,17 +2549,22 @@ function overlayRegions (box, cap, v) {
2480
2549
  const rect = (cls, r, title) => { const d = document.createElement('span'); d.className = cls; if (title) d.title = title; Object.assign(d.style, { left: `${r.x * k}px`, top: `${r.y * k}px`, width: `${Math.max(2, r.w * k)}px`, height: `${Math.max(2, r.h * k)}px` }); box.appendChild(d); return d; };
2481
2550
  for (const r of cap.ignore || []) rect('ignbox', r, `${T('ignTitle')}${r.selector ? ': ' + r.selector : ''}`);
2482
2551
  for (const r of cap.unstable || []) rect('ignbox unst', r, T('unstLead')(1));
2552
+ // drawn by hand on the canvas, waiting for kaloko stability --pull (batch F)
2553
+ if (n) for (const d of drawnRegions(n.id, v.name)) {
2554
+ const el = rect('ignbox drawn', { x: d.rect[0], y: d.rect[1], w: d.rect[2], h: d.rect[3] }, `${T('ignDrawnBy')} ${d.created_by}${d.note ? ' · ' + d.note : ''}`);
2555
+ if (canDraw()) { const x = document.createElement('button'); x.type = 'button'; x.className = 'ignx'; x.textContent = '×'; x.title = x.ariaLabel = T('ignRemove'); x.onclick = (e) => { e.stopPropagation(); removeDrawn(d.id); }; el.appendChild(x); }
2556
+ }
2483
2557
  if (S.kbShow && cap.keyboard) for (const s of cap.keyboard.stops || []) { if (s.hidden || !s.w) continue; const d = rect(`kbstop${s.focus && s.focus !== 'ok' ? ' bad' : ''}`, s, `${s.n} ${s.name || s.el}`); const i = document.createElement('i'); i.textContent = s.n; d.appendChild(i); }
2484
2558
  };
2485
2559
  im.complete ? place() : im.addEventListener('load', place);
2486
2560
  }
2487
2561
  /* Keyboard section of the step detail: the tab order with what is wrong at each stop, and a toggle for the overlay */
2488
2562
  function keyboardBlock (cap) {
2489
- const kb = cap?.keyboard; const ign = cap?.ignore?.length || 0; const unst = cap?.unstable?.length || 0;
2490
- if (!kb && !ign && !unst) return '';
2563
+ const kb = cap?.keyboard;
2564
+ if (!kb) return '';
2491
2565
  const flag = (s) => s.hidden ? T('kbHidden') : s.focus === 'none' ? T('kbNone') : s.focus === 'weak' ? T('kbWeak')(s.contrast) : s.focus === 'obscured' ? T('kbCovered') : '';
2492
2566
  const list = kb ? `<ol class="kb-list">${(kb.stops || []).slice(0, 12).map((s) => { const f = flag(s); return `<li class="${f ? 'bad' : ''}">${esc(s.name || s.el)}${f ? ` · ${esc(f)}` : ''}</li>`; }).join('')}</ol>` : '';
2493
- return `${kb ? `<div class="sec"><h4>${T('kbTitle')}</h4><p class="note">${esc(T('kbLead')(kb.total, kb.complete))}</p><button type="button" class="pr" id="kbToggle">${S.kbShow ? T('kbHide') : T('kbShow')}</button>${list}</div>` : ''}${ign || unst ? `<div class="sec"><h4>${T('ignTitle')}</h4>${ign ? `<p class="note">${esc(T('ignLead')(ign))}</p>` : ''}${unst ? `<p class="note">${esc(T('unstLead')(unst))}</p>` : ''}</div>` : ''}`;
2567
+ return `${kb ? `<div class="sec"><h4>${T('kbTitle')}</h4><p class="note">${esc(T('kbLead')(kb.total, kb.complete))}</p><button type="button" class="pr" id="kbToggle">${S.kbShow ? T('kbHide') : T('kbShow')}</button>${list}</div>` : ''}`;
2494
2568
  }
2495
2569
  function aiReason (r) {
2496
2570
  if (!r.reason) return '';
@@ -2747,7 +2821,7 @@ function openLb (id, vpName, opts = {}) {
2747
2821
  const lb = $('lb');
2748
2822
  const html = `<div class="lb__box">
2749
2823
  <div class="lb__stage">
2750
- <div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore">${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${devAvailable(cap) ? `<button class="btn" id="lbDev" aria-pressed="${DEV.on}" title="${esc(T('devTitle'))} (D)">${T('devMode')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) || recOf(n, cap, v) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}${v.device ? `<button class="btn" id="lbBezel" aria-pressed="${S.frame}" title="${esc(T('frameTitle'))}">${T('frame')}</button><button class="btn" id="lbSafe" aria-pressed="${S.safe}" title="${esc(T('safeTitle'))}">${T('safeAreas')}</button>` : ''}${altOptions(v).length && !S.cmp ? `<label class="lb__alt">${T('altCompare')} <select id="lbAlt"><option value="">—</option>${altOptions(v).map((o) => `<option value="${esc(o.key)}" ${S.lbAlt === o.over ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>` : ''}<span class="note">${v.w} × ${v.h}${v.device && !v.device.app ? ` · ${esc(v.device.title)} ${esc(v.device.dpr)}×` : ''}${dimWords(v.name) ? ` · ${esc(dimWords(v.name))}` : ''}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
2824
+ <div class="lb__tools"><div class="seg" id="lbVp"></div><button class="btn" id="lbFocus" title="${esc(T('focusTitle'))} (F)" aria-pressed="${!!S.focus}">${ICON.focus}<span>${T('focus')}</span></button><div class="lb__more" id="lbMore">${isDesign() || liveSrc(cap) ? `<button class="btn" id="lbProto" title="${esc(T('protoTitle'))} (P)">${T('prototype')}</button>` : ''}${devAvailable(cap) ? `<button class="btn" id="lbDev" aria-pressed="${DEV.on}" title="${esc(T('devTitle'))} (D)">${T('devMode')}</button>` : ''}${!n.item && gridUseful() && Object.keys(n.captures || {}).length ? `<button class="btn" id="lbGrid" title="${esc(T('gridBtnTitle'))} (G)">${T('gridBtn')}</button>` : ''}${canDraw() && cap && !n.item && !['live', 'view', 'video'].includes(S.lbMode) ? `<button class="btn" id="lbIgn" aria-pressed="${!!S.ignDraw}" title="${esc(T('ignDrawTitle'))}">${T('ignDraw')}</button>` : ''}${liveSrc(cap) || viewSrc(cap) || recOf(n, cap, v) ? `<div class="seg" id="lbMode"></div>` : ''}${(liveSrc(cap) && S.lbMode === 'live') || (viewSrc(cap) && S.lbMode === 'view') ? `<span id="lbLiveTools" class="share__row"></span>` : ''}${S.cmp ? `<div class="seg" id="lbCmpSeg"></div>` : ''}${v.device ? `<button class="btn" id="lbBezel" aria-pressed="${S.frame}" title="${esc(T('frameTitle'))}">${T('frame')}</button><button class="btn" id="lbSafe" aria-pressed="${S.safe}" title="${esc(T('safeTitle'))}">${T('safeAreas')}</button>` : ''}${altOptions(v).length && !S.cmp ? `<label class="lb__alt">${T('altCompare')} <select id="lbAlt"><option value="">—</option>${altOptions(v).map((o) => `<option value="${esc(o.key)}" ${S.lbAlt === o.over ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>` : ''}<span class="note">${v.w} × ${v.h}${v.device && !v.device.app ? ` · ${esc(v.device.title)} ${esc(v.device.dpr)}×` : ''}${dimWords(v.name) ? ` · ${esc(dimWords(v.name))}` : ''}, ${cap?.platform && cap.platform !== 'electron' ? T('screenWord') : cap?.first_screen ? T('firstScreen') : T('fullPage')} · ${esc(S.loc)}</span><span class="spacer"></span><span class="note lb__hint">${S.cmp ? `${T('cmpTitle')} ${esc(cmpLabel())}` : T('hover')}</span></div><button class="icon-btn lb__moreBtn" id="lbMoreBtn" aria-expanded="false" aria-controls="lbMore" aria-label="${esc(T('moreTools'))}" title="${esc(T('moreTools'))}">${ICON.more}</button></div>
2751
2825
  <div class="lb__scroll" id="lbScroll">${(() => {
2752
2826
  const bn0 = S.cmp && cmpNode(n.id), bcap0 = bn0?.captures?.[S.loc]?.[v.name];
2753
2827
  if (S.lbMode === 'video' && recOf(n, cap, v) && !S.cmp) return videoHtml(recOf(n, cap, v), v);
@@ -2761,6 +2835,7 @@ function openLb (id, vpName, opts = {}) {
2761
2835
  if (!S.cmp) return img;
2762
2836
  const bn = cmpNode(n.id), bcap = bn?.captures?.[S.loc]?.[v.name];
2763
2837
  const bimg = bcap ? `<div class="lb__img ${v.mobile ? 'lb__img--m' : ''}" style="max-width:${v.mobile ? v.w + 16 : v.w}px"><img src="${esc(S.cmp.base + (bcap.image || bcap.png))}" alt=""></div>` : `<p class="lb__empty">${T('cmpNoCap')}</p>`;
2838
+ if ((S.lbCmp === 'swipe' || S.lbCmp === 'onion') && cap && bcap) return overlayCmpHtml(S.lbCmp, asset(cap.image || cap.png), S.cmp.base + (bcap.image || bcap.png), v, T('thisRun'), cmpLabel());
2764
2839
  if (S.lbCmp === 'diff' && cap && bcap) return `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption><b>${T('cmpDiff')}</b> <span id="lbPx">…</span></figcaption><div class="lb__diff" id="lbDiff" style="max-width:${v.mobile ? v.w + 16 : v.w}px"></div></figure></div>`;
2765
2840
  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>`;
2766
2841
  })()}</div>
@@ -2790,6 +2865,7 @@ function openLb (id, vpName, opts = {}) {
2790
2865
  <div class="lbtab" data-pane="page" ${S.lbTab === 'page' ? '' : 'hidden'}>
2791
2866
  ${sigSection(cap)}
2792
2867
  ${keyboardBlock(cap)}
2868
+ ${ignoreSection(n, cap, v)}
2793
2869
  ${metaSection(n, cap, v)}
2794
2870
  ${(n.attachments || []).length ? `<div class="sec"><h4>${T('attachments')}</h4><div class="files">${n.attachments.map((a) => `<a class="file" href="${esc(asset(a.file))}" target="_blank" rel="noopener"><span class="file__prev"><img src="${esc(asset(a.file))}" alt="" style="max-width:100%;max-height:74px"></span><span class="file__name">${ICON.crop}${esc(a.title)}${a.locale ? ` · ${esc(a.locale)}` : ''}</span></a>`).join('')}</div></div>` : ''}
2795
2871
  ${cap?.tree ? `<details class="sec" id="treeSec"><summary><h4>${ICON.html} ${T('uiTree')}</h4></summary><p class="note" id="treeNote">${T('treeHint')}</p><input type="search" class="tree-q" id="treeQ" placeholder="${esc(T('treeSearch'))}"><div class="tree" id="treeBody"></div></details>` : ''}
@@ -2841,13 +2917,20 @@ function openLb (id, vpName, opts = {}) {
2841
2917
  }
2842
2918
  // pins of comments made on elements, drawn over the screenshot too
2843
2919
  const pimg = $('lbImg');
2844
- if (pimg && cap) overlayRegions(pimg, cap, v);
2845
- if ($('kbToggle')) $('kbToggle').onclick = () => { S.kbShow = !S.kbShow; $('kbToggle').textContent = S.kbShow ? T('kbHide') : T('kbShow'); overlayRegions(pimg, cap, v); };
2920
+ if (pimg && cap) overlayRegions(pimg, cap, v, n);
2921
+ if ($('kbToggle')) $('kbToggle').onclick = () => { S.kbShow = !S.kbShow; $('kbToggle').textContent = S.kbShow ? T('kbHide') : T('kbShow'); overlayRegions(pimg, cap, v, n); };
2922
+ // batch F: the grid of the step, regions drawn to ignore, the swipe divider and onion slider
2923
+ S.lastStep = n.item ? S.lastStep : id;
2924
+ if ($('lbGrid')) $('lbGrid').onclick = () => gvOpen(id);
2925
+ if ($('lbIgn')) $('lbIgn').onclick = () => armIgnore(!S.ignDraw);
2926
+ lb.querySelectorAll('[data-ign-draw]').forEach((b) => { b.onclick = () => { if (lbPhone()) setSheet('peek'); armIgnore(true); }; });
2927
+ if (S.ignDraw) armIgnore(true, true);
2928
+ wireOverlay($('lbScroll'));
2846
2929
  if (pimg) { const place = () => { const k = pimg.querySelector('img').clientWidth / (cap?.size?.[0] || v.w); for (const p of pinsFor(n, v.name)) { if (!p.box) continue; const d = document.createElement('span'); d.className = 'pinmark'; d.textContent = p.n; Object.assign(d.style, { left: `${(p.box[0] + p.box[2]) * k}px`, top: `${p.box[1] * k}px`, opacity: p.resolved ? 0.5 : 1 }); pimg.appendChild(d); } }; const im = pimg.querySelector('img'); if (im) im.complete ? place() : im.addEventListener('load', place); }
2847
2930
  if (S.cmp) {
2848
- if ($('lbCmpSeg')) seg($('lbCmpSeg'), [['side', T('cmpSide')], ['diff', T('cmpDiff')]], S.lbCmp, (m) => { S.lbCmp = m; openLb(id, v.name); });
2931
+ if ($('lbCmpSeg')) seg($('lbCmpSeg'), [['side', T('cmpSide')], ['swipe', T('cmpSwipe')], ['onion', T('cmpOnion')], ['diff', T('cmpDiff')]], S.lbCmp, (m) => { S.lbCmp = m; openLb(id, v.name); });
2849
2932
  const bcap = cmpNode(n.id)?.captures?.[S.loc]?.[v.name];
2850
- if (cap && bcap) pixelDiff(`${n.id}|${S.loc}|${v.name}`, asset(cap.image || cap.png), S.cmp.base + (bcap.image || bcap.png), Math.min(1000, v.w), ignOf(cap, bcap)).then((r) => {
2933
+ if (cap && bcap) pixelDiff(`${n.id}|${S.loc}|${v.name}`, asset(cap.image || cap.png), S.cmp.base + (bcap.image || bcap.png), Math.min(1000, v.w), ignOf(cap, bcap, n.id, v.name)).then((r) => {
2851
2934
  const px = $('lbPx'); if (px) { px.innerHTML = `<span class="pxd ${pxClass(r.ratio)}">◐ ${pxText(r.ratio)}</span>`; }
2852
2935
  const d = $('lbDiff'); if (d) { d.innerHTML = ''; d.appendChild(r.canvas); }
2853
2936
  }).catch(() => { const px = $('lbPx'); if (px) px.textContent = T('pxUnavail'); });
@@ -2939,7 +3022,7 @@ function setSheet (st) {
2939
3022
  function setFocus (on) {
2940
3023
  if (!S.open) return;
2941
3024
  S.focus = !!on; closeLbMore();
2942
- if (on) { C.pinMode = false; LIVE.mode = null; DEV.on = false; devReset(); if (S.lbTab === 'dev') S.lbTab = 'overview'; toFrame({ type: 'qawalk:mode', mode: null }); }
3025
+ if (on) { C.pinMode = false; C.draw = null; S.ignDraw = false; LIVE.mode = null; DEV.on = false; devReset(); if (S.lbTab === 'dev') S.lbTab = 'overview'; toFrame({ type: 'qawalk:mode', mode: null }); }
2943
3026
  openLb(S.open, S.lbVp, { keepFrame: true });
2944
3027
  }
2945
3028
  const FX = { timer: null, over: false };
@@ -2979,7 +3062,7 @@ function wireLbOnce (lb) {
2979
3062
  if (lb.classList.contains('is-idle')) fxWake(); else if (lastPointer !== 'mouse') fxHide();
2980
3063
  });
2981
3064
  // focus on a touch screen: a swipe sideways moves to the previous or next step, unless the page is pinch-zoomed
2982
- 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 });
3065
+ 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 });
2983
3066
  lb.addEventListener('touchmove', (e) => { if (e.touches.length > 1) swipe = null; }, { passive: true });
2984
3067
  lb.addEventListener('touchend', (e) => {
2985
3068
  const sw = swipe; swipe = null; if (!sw || !S.focus) return;
@@ -3019,6 +3102,8 @@ function applyHash () {
3019
3102
  // review mode: #review (the first undecided step), #review=<step>, #review=end (the summary)
3020
3103
  if (p.has('review')) { const t = p.get('review') || null; if (!RV.open || (t && t !== 'end' && rvCur()?.step !== t)) rvOpen(t); return; }
3021
3104
  if (RV.open) { RV.open = false; $('rv').hidden = true; }
3105
+ if (p.get('grid') && byId(p.get('grid'))) { if (!GV.open || GV.id !== p.get('grid')) gvOpen(p.get('grid')); return; }
3106
+ if (GV.open) { GV.open = false; $('gv').hidden = true; $('gv').innerHTML = ''; }
3022
3107
  if ((p.get('mode') === 'index') !== (S.mode === 'index') && p.has('mode')) { S.mode = p.get('mode') === 'index' ? 'index' : 'map'; applyMode(); }
3023
3108
  const cmp = p.get('cmp');
3024
3109
  if (cmp && cmp !== S.cmp?.id && cmp !== RUN.id) { loadCmp(cmp).then(() => { if (p.get('step') || p.get('replay')) applyHash(); }); return; }
@@ -3141,7 +3226,7 @@ function renderReport () {
3141
3226
  ${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>` : ''}
3142
3227
  ${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>
3143
3228
  <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>
3144
- <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>` : ''}
3229
+ <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>` : ''}${signoffHtml()}</div>` : ''}
3145
3230
  ${commentsPanel()}
3146
3231
  <div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
3147
3232
  <div class="stack">${['pass', 'partial', 'fail', 'skip'].map((k) => `<i style="width:${c[k] / tot * 100}%;background:var(--${k})"></i>`).join('')}</div>
@@ -3843,6 +3928,335 @@ function fitPt () {
3843
3928
  addEventListener('resize', () => { if (!$('pt').hidden) fitPt(); });
3844
3929
  document.addEventListener('fullscreenchange', () => { const b = $('ptFull'); if (b) b.setAttribute('aria-pressed', String(!!document.fullscreenElement)); setTimeout(fitPt, 60); });
3845
3930
 
3931
+ /* ---------- batch F: swipe and onion compare, drawn ignore regions (#7), the locale × viewport grid (#8),
3932
+ drawings and images on comments (#9), the signed acceptance record (#10) ---------- */
3933
+ Object.assign(I18N.cs, {
3934
+ cmpSwipe: 'Posuvník', cmpOnion: 'Prolnutí', cmpSwipeCtl: 'Hranice', cmpOnionCtl: 'Tento běh', cmpSwipeHint: 'Táhněte po snímku: vlevo tento běh, vpravo porovnávaný.',
3935
+ ignDraw: 'Označit proměnlivou oblast', ignDrawTitle: 'Táhnutím označte oblast, která se mezi běhy mění (reklama, datum, karusel). Pixelový rozdíl ji pak přeskočí.',
3936
+ ignDrawing: 'Táhněte po snímku přes oblast, která se mění.', ignSaved: 'Oblast je uložená. Do scénáře ji zapíše kaloko stability --pull.', ignRemoved: 'Oblast je odebraná.',
3937
+ ignDrawnLead: (n) => `Ručně označené oblasti, které čekají na kaloko stability --pull: ${n}. Pixelový rozdíl je už teď přeskakuje.`, ignRemove: 'Odebrat oblast', ignDrawnBy: 'Označil(a)',
3938
+ ignNone: 'Proměnlivé oblasti (reklamy, data, karusely) se dají označit přímo na snímku.',
3939
+ drawBox: 'Rámeček', drawArrow: 'Šipka', drawPen: 'Čára', drawHint: 'Táhněte po snímku', drawnOnShot: 'Zakresleno na snímku', drawTools: 'Ukázat na snímku',
3940
+ shape_box: 'rámeček', shape_arrow: 'šipka', shape_pen: 'čára',
3941
+ attach: 'Přiložit obrázek', attachUploading: 'Nahrávám obrázek…', attachRemove: 'Odebrat přílohu', attachType: 'Přiložit jde obrázek PNG, JPEG, WebP nebo GIF.', attachBig: 'Obrázek může mít nejvýš 5 MB.', attachMax: 'Ke komentáři jde přiložit nejvýš 5 obrázků.',
3942
+ gridTitle: 'Mřížka', gridBtn: 'Mřížka', gridBtnTitle: 'Krok ve všech jazycích a zobrazeních', gridLead: 'Krok ve všech jazycích a zobrazeních vedle sebe. Klik otevře detail.',
3943
+ gridAll: 'Všechny prohlížeče a schémata', gridProblems: 'Jen problémy', gridFull: 'Celá výška', gridNoCap: 'bez snímku', gridNothing: 'Žádná kombinace nemá problém.', gridHint: 'mřížka kroku (jazyky × zobrazení)',
3944
+ gridPrevStep: 'Předchozí krok', gridNextStep: 'Další krok', gridKeys: 'Šipky pohyb · Enter detail · J K další a předchozí krok · Esc konec', gridColumn: 'Zobrazení', gridClose: 'Zavřít mřížku',
3945
+ signoffTitle: 'Záznam o přijetí', signoffLead: 'Podepsaný záznam pro klienta nebo audit: kdo co schválil a kdy, výsledky kritérií, náhledy a otisk běhu. Ověří ho kaloko signoff --verify.',
3946
+ signoffPdf: 'Stáhnout PDF', signoffJson: 'JSON', signoffWait: 'Připravuji záznam…'
3947
+ });
3948
+ Object.assign(I18N.en, {
3949
+ cmpSwipe: 'Swipe', cmpOnion: 'Onion skin', cmpSwipeCtl: 'Divider', cmpOnionCtl: 'This run', cmpSwipeHint: 'Drag across the screenshot: this run on the left, the compared one on the right.',
3950
+ ignDraw: 'Mark a changing region', ignDrawTitle: 'Drag over an area that changes between runs (an ad, a date, a carousel). Pixel diffs then skip it.',
3951
+ ignDrawing: 'Drag across the area that changes.', ignSaved: 'Region saved. kaloko stability --pull writes it into the scenario.', ignRemoved: 'Region removed.',
3952
+ ignDrawnLead: (n) => `${n} region(s) drawn by hand wait for kaloko stability --pull. Pixel diffs already skip them.`, ignRemove: 'Remove the region', ignDrawnBy: 'Drawn by',
3953
+ ignNone: 'Areas that change on every run (ads, dates, carousels) can be marked right on the screenshot.',
3954
+ drawBox: 'Box', drawArrow: 'Arrow', drawPen: 'Line', drawHint: 'Drag on the screenshot', drawnOnShot: 'Drawn on the screenshot', drawTools: 'Point at it',
3955
+ shape_box: 'box', shape_arrow: 'arrow', shape_pen: 'line',
3956
+ attach: 'Attach an image', attachUploading: 'Uploading the image…', attachRemove: 'Remove the attachment', attachType: 'Attach a PNG, JPEG, WebP or GIF image.', attachBig: 'An image may have at most 5 MB.', attachMax: 'A comment takes at most 5 images.',
3957
+ gridTitle: 'Grid', gridBtn: 'Grid', gridBtnTitle: 'This step in every locale and viewport', gridLead: 'The step in every locale and viewport side by side. Click a cell for the detail.',
3958
+ gridAll: 'Every browser and scheme', gridProblems: 'Problems only', gridFull: 'Full height', gridNoCap: 'no capture', gridNothing: 'No combination has a problem.', gridHint: 'grid of the step (locales × viewports)',
3959
+ gridPrevStep: 'Previous step', gridNextStep: 'Next step', gridKeys: 'Arrows move · Enter opens · J K next and previous step · Esc leaves', gridColumn: 'Viewport', gridClose: 'Close the grid',
3960
+ signoffTitle: 'Acceptance record', signoffLead: 'A signed record for the client or an audit: who approved what and when, the criteria results, thumbnails and the run’s hash. kaloko signoff --verify checks it.',
3961
+ signoffPdf: 'Download PDF', signoffJson: 'JSON', signoffWait: 'Preparing the record…'
3962
+ });
3963
+ const runApi = (p = '') => { const a = APP(); return `${a.api}/orgs/${a.org}/runs/${a.run}${p}`; };
3964
+ /** Developer / Tester and up on a shared run: may mark regions to ignore (the service checks it again). */
3965
+ const canDraw = () => !!(APP()?.user && APP()?.run && !isDraft() && ['tester', 'admin'].includes(APP().user.role));
3966
+
3967
+ /* swipe (a divider dragged across: this run left, the compared one right) and onion skin (this run faded over it) */
3968
+ function overlayCmpHtml (mode, srcA, srcB, v, labA, labB) {
3969
+ const val = Math.round(mode === 'swipe' ? (S.cmpPos ?? 50) : (S.cmpOp ?? 50));
3970
+ return `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption><b>${esc(labA)}</b> ${mode === 'swipe' ? '◧' : '◐'} <b>${esc(labB)}</b> <span id="lbPx"></span><label class="cmpov__ctl">${esc(T(mode === 'swipe' ? 'cmpSwipeCtl' : 'cmpOnionCtl'))} <input type="range" class="cmpov__range" min="0" max="100" step="1" value="${val}"></label></figcaption><div class="cmpov cmpov--${mode}" style="max-width:${v.mobile ? v.w + 16 : v.w}px;--pos:${val}%;--op:${val / 100}" ${mode === 'swipe' ? `title="${esc(T('cmpSwipeHint'))}"` : ''}><img class="cmpov__b" src="${esc(srcB)}" alt="${esc(labB)}" draggable="false"><img class="cmpov__a" src="${esc(srcA)}" alt="${esc(labA)}" draggable="false">${mode === 'swipe' ? '<span class="cmpov__line" aria-hidden="true"><i></i></span>' : ''}</div></figure></div>`;
3971
+ }
3972
+ function wireOverlay (root) {
3973
+ const ov = root?.querySelector('.cmpov'); if (!ov) return;
3974
+ const swipe = ov.classList.contains('cmpov--swipe'); const range = root.querySelector('.cmpov__range');
3975
+ const set = (p) => { p = Math.max(0, Math.min(100, p)); if (swipe) { S.cmpPos = p; ov.style.setProperty('--pos', `${p}%`); } else { S.cmpOp = p; ov.style.setProperty('--op', String(p / 100)); } if (range && Number(range.value) !== Math.round(p)) range.value = String(Math.round(p)); };
3976
+ if (range) range.oninput = () => set(Number(range.value));
3977
+ if (!swipe) return;
3978
+ let drag = null;
3979
+ const at = (e) => { const r = ov.getBoundingClientRect(); return (e.clientX - r.left) / r.width * 100; };
3980
+ ov.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; drag = { id: e.pointerId, x: e.clientX, y: e.clientY, on: e.pointerType === 'mouse' }; if (drag.on) { ov.setPointerCapture(e.pointerId); set(at(e)); } });
3981
+ // a finger moves the divider once the drag is sideways; an upward drag scrolls the page as usual
3982
+ ov.addEventListener('pointermove', (e) => { if (!drag || drag.id !== e.pointerId) return; if (!drag.on) { if (Math.abs(e.clientX - drag.x) > 8 && Math.abs(e.clientX - drag.x) > Math.abs(e.clientY - drag.y)) { drag.on = true; try { ov.setPointerCapture(e.pointerId); } catch {} } else return; } set(at(e)); });
3983
+ const end = () => { drag = null; };
3984
+ ov.addEventListener('pointerup', end); ov.addEventListener('pointercancel', end);
3985
+ }
3986
+
3987
+ /* ---------- drawing on a screenshot: boxes, arrows and lines for comments, rectangles to ignore ---------- */
3988
+ /** A shape from the points of a drag, in fractions of the image; null when it is too small to mean anything. */
3989
+ function shapeFrom (kind, pts, W, H) {
3990
+ if (!pts || pts.length < 2) return null;
3991
+ const [a, b] = [pts[0], pts[pts.length - 1]];
3992
+ const r4 = (n) => Math.round(n * 10000) / 10000;
3993
+ if (kind === 'box' || kind === 'rect') { const x = Math.min(a[0], b[0]), y = Math.min(a[1], b[1]), w = Math.abs(a[0] - b[0]), h = Math.abs(a[1] - b[1]); return w * W < 6 || h * H < 6 ? null : { kind: 'box', box: [r4(x), r4(y), r4(w), r4(h)] }; }
3994
+ if (kind === 'arrow') return Math.hypot((a[0] - b[0]) * W, (a[1] - b[1]) * H) < 12 ? null : { kind: 'arrow', from: [r4(a[0]), r4(a[1])], to: [r4(b[0]), r4(b[1])] };
3995
+ return pts.length < 3 ? null : { kind: 'pen', points: pts.slice(0, 400).map((p) => [r4(p[0]), r4(p[1])]) };
3996
+ }
3997
+ /** SVG markup of a shape in pixels of a W × H image. */
3998
+ function shapeSvg (sh, W, H, cls = '') {
3999
+ if (!sh) return '';
4000
+ const P = (p) => `${(p[0] * W).toFixed(1)},${(p[1] * H).toFixed(1)}`;
4001
+ if (sh.kind === 'box') { const [x, y, w, h] = sh.box; return `<rect class="shp ${cls}" x="${x * W}" y="${y * H}" width="${w * W}" height="${h * H}" rx="3"/>`; }
4002
+ if (sh.kind === 'arrow') {
4003
+ const [x1, y1] = [sh.from[0] * W, sh.from[1] * H], [x2, y2] = [sh.to[0] * W, sh.to[1] * H];
4004
+ const ang = Math.atan2(y2 - y1, x2 - x1), L = 16;
4005
+ const head = [[x2, y2], [x2 - L * Math.cos(ang - 0.42), y2 - L * Math.sin(ang - 0.42)], [x2 - L * Math.cos(ang + 0.42), y2 - L * Math.sin(ang + 0.42)]].map((p) => p.map((n) => n.toFixed(1)).join(',')).join(' ');
4006
+ return `<line class="shp ${cls}" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/><polygon class="shp shp--head ${cls}" points="${head}"/>`;
4007
+ }
4008
+ return `<polyline class="shp ${cls}" points="${sh.points.map(P).join(' ')}"/>`;
4009
+ }
4010
+ /** Where a shape's number goes: the box's corner, the arrow's tail, the line's start. */
4011
+ const shapeAnchor = (sh) => sh.kind === 'box' ? [sh.box[0], sh.box[1]] : sh.kind === 'arrow' ? sh.from : sh.points[0];
4012
+ /**
4013
+ * One drag on the image inside `box` (#lbImg): `kind` box | rect | arrow | pen, previewed as it is drawn;
4014
+ * done(shape | null). Returns a function that stops listening.
4015
+ */
4016
+ function drawSession (box, kind, done) {
4017
+ const im = box?.querySelector('img'); if (!im) return () => {};
4018
+ box.classList.add('is-drawing');
4019
+ let pts = null, live = null;
4020
+ const frac = (e) => { const r = im.getBoundingClientRect(); return [Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)), Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))]; };
4021
+ const paint = () => { live.setAttribute('viewBox', `0 0 ${im.clientWidth} ${im.clientHeight}`); live.innerHTML = shapeSvg(shapeFrom(kind, pts.length < 2 ? [pts[0], pts[0]] : pts, im.clientWidth, im.clientHeight) || (kind === 'pen' && pts.length > 1 ? { kind: 'pen', points: pts } : null), im.clientWidth, im.clientHeight, kind === 'rect' ? 'is-ign' : 'is-new'); };
4022
+ const down = (e) => {
4023
+ if (e.button !== 0 || e.target.closest('.pinmark, .ignx, button, a')) return;
4024
+ e.preventDefault(); try { box.setPointerCapture(e.pointerId); } catch {}
4025
+ pts = [frac(e)];
4026
+ live = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); live.setAttribute('class', 'c-shapes c-shapes--live'); box.appendChild(live); paint();
4027
+ };
4028
+ const move = (e) => {
4029
+ if (!pts) return; const p = frac(e);
4030
+ if (kind === 'pen') { const l = pts[pts.length - 1]; if (Math.hypot((p[0] - l[0]) * im.clientWidth, (p[1] - l[1]) * im.clientHeight) >= 3) pts.push(p); } else pts[1] = p;
4031
+ paint();
4032
+ };
4033
+ const up = () => { if (!pts) return; const s = shapeFrom(kind, pts, im.clientWidth, im.clientHeight); pts = null; live?.remove(); stop(); done(s); };
4034
+ const cancel = () => { pts = null; live?.remove(); };
4035
+ box.addEventListener('pointerdown', down); box.addEventListener('pointermove', move); box.addEventListener('pointerup', up); box.addEventListener('pointercancel', cancel);
4036
+ const stop = () => { box.classList.remove('is-drawing'); box.removeEventListener('pointerdown', down); box.removeEventListener('pointermove', move); box.removeEventListener('pointerup', up); box.removeEventListener('pointercancel', cancel); };
4037
+ box._stopDraw?.(); box._stopDraw = stop;
4038
+ return stop;
4039
+ }
4040
+
4041
+ /* ---------- ignore regions drawn by hand (#7): kept per flow until kaloko stability --pull ---------- */
4042
+ /** Drawn regions of a step that apply to a capture key: same viewport (any browser or scheme), this locale or all. */
4043
+ const drawnRegions = (nodeId, key) => (RUN.ignore_drawn || []).filter((d) => d.node_id === nodeId && String(d.viewport).split('.')[0] === String(key).split('.')[0] && (!d.locale || d.locale === S.loc));
4044
+ const drawnRects = (nodeId, key) => drawnRegions(nodeId, key).map((d) => ({ x: d.rect[0], y: d.rect[1], w: d.rect[2], h: d.rect[3] }));
4045
+ async function reloadDrawn () {
4046
+ try { const res = await fetch(runApi('/ignore-regions'), { credentials: 'same-origin' }); if (res.ok) RUN.ignore_drawn = await res.json(); } catch {}
4047
+ CMP.pix.clear();
4048
+ }
4049
+ /** Starts drawing a region to ignore on the open step's screenshot. */
4050
+ function armIgnore (on = true, quiet = false) {
4051
+ S.ignDraw = !!on; C.draw = null; C.pinMode = false;
4052
+ const box = $('lbImg'); $('lbIgn')?.setAttribute('aria-pressed', String(S.ignDraw));
4053
+ box?._stopDraw?.(); box?.classList.remove('is-drawing');
4054
+ if (!S.ignDraw || !box) return;
4055
+ const n = byId(S.open); const v = viewports().find((x) => x.name === S.lbVp) || viewports()[0]; const cap = n?.captures?.[S.loc]?.[v.name];
4056
+ if (!cap) { S.ignDraw = false; return; }
4057
+ if (!quiet) toast(T('ignDrawing'));
4058
+ drawSession(box, 'rect', async (sh) => {
4059
+ S.ignDraw = false; $('lbIgn')?.setAttribute('aria-pressed', 'false');
4060
+ if (!sh) return;
4061
+ const im = box.querySelector('img'); const cssW = cap.size?.[0] || v.w; const cssH = im.naturalWidth ? cssW * im.naturalHeight / im.naturalWidth : (cap.size?.[1] || v.h);
4062
+ const [x, y, w, h] = sh.box; const rect = [x * cssW, y * cssH, w * cssW, h * cssH].map((q) => Math.round(q));
4063
+ try {
4064
+ const res = await fetch(runApi('/ignore-regions'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: n.id, viewport: v.name, rect }) });
4065
+ const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
4066
+ RUN.ignore_drawn = [...(RUN.ignore_drawn || []), data]; CMP.pix.clear();
4067
+ toast(T('ignSaved')); openLb(n.id, v.name, { keepFrame: true }); if (S.cmp) fillPixelBadges();
4068
+ } catch (e) { toast(e.message); }
4069
+ });
4070
+ }
4071
+ async function removeDrawn (id) {
4072
+ try {
4073
+ const res = await fetch(runApi(`/ignore-regions/${encodeURIComponent(id)}`), { method: 'DELETE', credentials: 'same-origin' });
4074
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
4075
+ RUN.ignore_drawn = (RUN.ignore_drawn || []).filter((d) => d.id !== id); CMP.pix.clear();
4076
+ toast(T('ignRemoved')); if (S.open) openLb(S.open, S.lbVp, { keepFrame: true });
4077
+ } catch (e) { toast(e.message); }
4078
+ }
4079
+ /** The step detail's section on ignored regions: from the scenario, seen moving, drawn by hand; and the draw button. */
4080
+ function ignoreSection (n, cap, v) {
4081
+ const ign = cap?.ignore?.length || 0; const unst = cap?.unstable?.length || 0; const drawn = n && cap ? drawnRegions(n.id, v.name).length : 0;
4082
+ if (!ign && !unst && !drawn && !(canDraw() && cap)) return '';
4083
+ return `<div class="sec"><h4>${T('ignTitle')}</h4>${ign ? `<p class="note">${esc(T('ignLead')(ign))}</p>` : ''}${unst ? `<p class="note">${esc(T('unstLead')(unst))}</p>` : ''}${drawn ? `<p class="note">${esc(T('ignDrawnLead')(drawn))}</p>` : ''}${!ign && !unst && !drawn ? `<p class="note">${esc(T('ignNone'))}</p>` : ''}${canDraw() && cap ? `<button type="button" class="btn" data-ign-draw title="${esc(T('ignDrawTitle'))}">${ICON.crop} ${esc(T('ignDraw'))}</button>` : ''}</div>`;
4084
+ }
4085
+
4086
+ /* ---------- drawings and images on comments (#9) ---------- */
4087
+ C.draw = null; C.shape = null; C.files = {};
4088
+ const attUrl = (scope, id) => `${commentsBase(scope)}/attachments/${encodeURIComponent(id)}`;
4089
+ const filesHtml = (scope, list) => list?.length ? `<div class="c-files">${list.map((a) => `<a class="c-file" href="${esc(attUrl(scope, a.id))}" target="_blank" rel="noopener" title="${esc(a.name)}"><img src="${esc(attUrl(scope, a.id))}" alt="${esc(a.name)}" loading="lazy"><span>${esc(a.name)}</span></a>`).join('')}</div>` : '';
4090
+ /** The pin and drawing tools of the step composer, or what is already placed with a way to cancel it. */
4091
+ function drawToolsHtml (pinnable) {
4092
+ if (C.pin) return `<span class="c-pinned">${ICON.pin} ${T('pinnedOnShot')} <button class="linkish" data-c-act="unpin">${T('cancel')}</button></span>`;
4093
+ if (C.shape) return `<span class="c-pinned">${ICON.crop} ${T('drawnOnShot')} · ${esc(T('shape_' + C.shape.kind))} <button class="linkish" data-c-act="unshape">${T('cancel')}</button></span>`;
4094
+ if (!pinnable) return '';
4095
+ const tool = C.pinMode ? 'pin' : C.draw;
4096
+ return `<span class="seg c-tools" role="group" aria-label="${esc(T('drawTools'))}"><button type="button" data-c-act="tool" data-tool="pin" aria-pressed="${tool === 'pin'}">${ICON.pin} ${T(tool === 'pin' ? 'pinClickShot' : 'pinOnShot')}</button><button type="button" data-c-act="tool" data-tool="box" aria-pressed="${tool === 'box'}">▭ ${T('drawBox')}</button><button type="button" data-c-act="tool" data-tool="arrow" aria-pressed="${tool === 'arrow'}">↗ ${T('drawArrow')}</button><button type="button" data-c-act="tool" data-tool="pen" aria-pressed="${tool === 'pen'}">✎ ${T('drawPen')}</button></span>`;
4097
+ }
4098
+ const attachHtml = (key) => isDraft() ? '' : `<div class="c-attachrow">${(C.files[key] || []).map((f, i) => `<span class="c-chip">${f.preview ? `<img src="${esc(f.preview)}" alt="">` : ''}${esc(f.name)}${f.id ? '' : ' …'} <button type="button" class="linkish" data-c-act="unattach" data-i="${i}" aria-label="${esc(T('attachRemove'))}">×</button></span>`).join('')}<label class="linkish c-attach">${esc(T('attach'))}<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden></label></div>`;
4099
+ /** Uploads images for the comment being written in `compose`. */
4100
+ async function attachFiles (compose, files) {
4101
+ const key = compose.dataset.key; const scope = compose.dataset.scope;
4102
+ const list = (C.files[key] ||= []);
4103
+ for (const f of [...files]) {
4104
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(f.type)) { toast(T('attachType')); continue; }
4105
+ if (f.size > 5 * 1024 * 1024) { toast(T('attachBig')); continue; }
4106
+ if (list.length >= 5) { toast(T('attachMax')); break; }
4107
+ // a data: preview (the canvas's content policy allows data: images, not blob:)
4108
+ const item = { name: f.name || 'image', type: f.type, preview: await new Promise((ok) => { const r = new FileReader(); r.onload = () => ok(String(r.result)); r.onerror = () => ok(''); r.readAsDataURL(f); }) }; list.push(item);
4109
+ rerenderComments(); toast(T('attachUploading'));
4110
+ try {
4111
+ const res = await fetch(`${commentsBase(scope)}/attachments?name=${encodeURIComponent(item.name)}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': f.type }, body: f });
4112
+ const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
4113
+ item.id = data.id;
4114
+ } catch (e) { list.splice(list.indexOf(item), 1); toast(e.message); }
4115
+ rerenderComments();
4116
+ }
4117
+ }
4118
+ /** Arms the chosen drawing tool on the open step's screenshot (box, arrow, line). */
4119
+ function armDraw (tool) {
4120
+ const box = $('lbImg'); C.draw = tool || null; S.ignDraw = false; if (tool) C.pinMode = false;
4121
+ box?._stopDraw?.(); box?.classList.remove('is-drawing'); box?.classList.toggle('is-pinning', C.pinMode);
4122
+ $('lb').querySelectorAll('.c-tools [data-tool]').forEach((b) => b.setAttribute('aria-pressed', String((C.pinMode ? 'pin' : C.draw) === b.dataset.tool)));
4123
+ if (!C.draw || !box) return;
4124
+ const v = viewports().find((x) => x.name === S.lbVp) || viewports()[0]; const cap = byId(S.open)?.captures?.[S.loc]?.[v.name];
4125
+ drawSession(box, C.draw, (sh) => {
4126
+ C.draw = null;
4127
+ if (sh) {
4128
+ const im = box.querySelector('img'); const cssW = cap?.size?.[0] || v.w; const cssH = im?.naturalWidth ? Math.round(cssW * im.naturalHeight / im.naturalWidth) : (cap?.size?.[1] || v.h);
4129
+ C.shape = { ...sh, viewport: v.name, size: [cssW, cssH] }; C.pin = null;
4130
+ }
4131
+ rerenderComments();
4132
+ requestAnimationFrame(() => { const t = $('lb').querySelector('.c-main .c-text'); t?.scrollIntoView({ block: 'center' }); t?.focus(); });
4133
+ });
4134
+ }
4135
+ /** Drawings of the threads on the step's screenshot (numbered like pins) and the one being placed. */
4136
+ function drawCommentShapes (img, n, v, nums) {
4137
+ img.querySelectorAll('svg.c-shapes:not(.c-shapes--live)').forEach((x) => x.remove());
4138
+ const im = img.querySelector('img'); const W = im?.clientWidth || 0, H = im?.clientHeight || 0; if (!W) return;
4139
+ let body = '';
4140
+ for (const t of threads('step', n.id)) {
4141
+ if (!t.shape || !nums.has(t.id) || (t.shape.viewport && t.shape.viewport !== v.name) || (t.locale && t.locale !== S.loc)) continue;
4142
+ body += `<g data-thread="${esc(t.id)}">${shapeSvg(t.shape, W, H, isOpenT(t) ? '' : 'is-done')}</g>`;
4143
+ const [ax, ay] = shapeAnchor(t.shape);
4144
+ const d = document.createElement('button'); d.type = 'button'; d.className = `pinmark pin-xy${isOpenT(t) ? '' : ' is-done'}`; d.dataset.thread = t.id; d.textContent = nums.get(t.id); d.title = t.note.slice(0, 120);
4145
+ Object.assign(d.style, { left: `${ax * W}px`, top: `${ay * H}px` });
4146
+ d.onclick = (e) => { e.stopPropagation(); C.focus = t.id; const el = document.querySelector(`.c-thread[data-thread="${CSS.escape(t.id)}"]`); el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); el?.classList.add('is-focus'); setTimeout(() => el?.classList.remove('is-focus'), 1600); };
4147
+ img.appendChild(d);
4148
+ }
4149
+ if (C.shape && (!C.shape.viewport || C.shape.viewport === v.name)) body += shapeSvg(C.shape, W, H, 'is-new');
4150
+ if (!body) return;
4151
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'c-shapes'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('aria-hidden', 'true'); svg.innerHTML = body;
4152
+ img.insertBefore(svg, img.querySelector('.pinmark'));
4153
+ }
4154
+
4155
+ /* ---------- the grid (#8): one step in every locale × viewport (× browser and scheme) ---------- */
4156
+ const GV = { open: false, id: null, all: false, problems: false, full: false, col: null, io: null };
4157
+ const gridKeys = () => GV.all && RUN.scope.variants ? Object.entries(RUN.scope.viewports).map(vpOf) : viewports();
4158
+ const gridUseful = () => RUN.scope.locales.length > 1 || gridKeys().length > 1;
4159
+ /** A cell's status: the worst verdict of the step's criteria for that locale and capture. */
4160
+ function gridCellSt (n, l, key) {
4161
+ if (!n.captures?.[l]?.[key]) return 'skip';
4162
+ const vs = (n.criteria || []).map((c) => c.by_locale?.[l]?.[key]).filter((r) => r && !r.na).map((r) => r.verdict).filter((x) => x && x !== 'not_reached');
4163
+ return !vs.length ? 'pass' : vs.includes('fail') ? 'fail' : vs.some((x) => x !== 'pass') ? 'partial' : 'pass';
4164
+ }
4165
+ /** The grid of step `n`; cells carry data-gl (locale) and data-gk (capture key), images load when scrolled near. */
4166
+ function gridHtml (n) {
4167
+ const keys = gridKeys(); const phone = lbPhone();
4168
+ if (!GV.col || !keys.some((k) => k.name === GV.col)) GV.col = keys[0]?.name || null;
4169
+ const cols = phone ? keys.filter((k) => k.name === GV.col) : keys;
4170
+ let locs = localeValues().map((x) => x.v);
4171
+ if (GV.problems) locs = locs.filter((l) => cols.some((v) => ['fail', 'partial'].includes(gridCellSt(n, l, v.name))));
4172
+ const cell = (l, v) => {
4173
+ const cap = n.captures?.[l]?.[v.name]; const st = gridCellSt(n, l, v.name);
4174
+ const src = cap ? asset(cap.thumb || cap.image || cap.png) : null;
4175
+ const k = APP() ? (RUN.reviews || []).filter((r) => r.node_id === n.id && !r.parent_id && isOpenT(r) && (!r.locale || r.locale === l)).length : 0;
4176
+ return `<button type="button" class="gv__cell st-${st}" role="gridcell" tabindex="-1" data-gl="${esc(l)}" data-gk="${esc(v.name)}" aria-label="${esc(`${l} · ${vpLabel(v)}${dimWords(v.name) ? ' · ' + dimWords(v.name) : ''}: ${T(st)}`)}" style="--ar:${v.w} / ${v.h}">${phone ? `<span class="gv__cap">${esc(l)}</span>` : ''}${src ? `<img data-src="${esc(src)}" alt="" draggable="false">` : `<span class="gv__none">${esc(T('gridNoCap'))}</span>`}<span class="gv__st badge st-${st}">${MARK[st]}</span>${k ? `<span class="gv__cmt">${ICON.comment}${k}</span>` : ''}</button>`;
4177
+ };
4178
+ if (!locs.length) return `<p class="note gv__empty">${esc(T('gridNothing'))}</p>`;
4179
+ if (phone) return `<div class="gv__cards ${GV.full ? 'is-full' : ''}" role="grid" aria-label="${esc(loc(n.title))}">${locs.map((l) => `<div role="row" class="gv__crow">${cell(l, cols[0])}</div>`).join('')}</div>`;
4180
+ // one row height for every capture: a desktop cell is wide, a phone cell narrow, and their tops line up
4181
+ const H = cols.length > 6 ? 150 : 210;
4182
+ const widths = cols.map((v) => `${Math.max(90, Math.min(420, Math.round(H * v.w / v.h)))}px`).join(' ');
4183
+ return `<div class="gv__grid ${GV.full ? 'is-full' : ''}" role="grid" aria-label="${esc(loc(n.title))}" style="--cols:${cols.length};grid-template-columns:minmax(80px,110px) ${widths}">
4184
+ <div class="gv__row gv__head" role="row"><span class="gv__corner" role="columnheader"></span>${cols.map((v) => `<span class="gv__colh" role="columnheader">${esc(vpLabel(v))}${dimWords(v.name) ? `<small>${esc(dimWords(v.name))}</small>` : ''}</span>`).join('')}</div>
4185
+ ${locs.map((l) => `<div class="gv__row" role="row"><span class="gv__rowh" role="rowheader"><b>${esc(l)}</b>${nativeName(l) ? `<small>${esc(nativeName(l))}</small>` : ''}</span>${cols.map((v) => cell(l, v)).join('')}</div>`).join('')}</div>`;
4186
+ }
4187
+ /** Images load only for cells near the visible part (twenty locales do not fetch everything at once). */
4188
+ function gridLazy (root, scroller) {
4189
+ GV.io?.disconnect();
4190
+ const imgs = [...root.querySelectorAll('img[data-src]')];
4191
+ if (typeof IntersectionObserver !== 'function') { imgs.forEach((i) => { i.src = i.dataset.src; }); return; }
4192
+ GV.io = new IntersectionObserver((es) => { for (const e of es) if (e.isIntersecting) { const i = e.target; i.src = i.dataset.src; i.removeAttribute('data-src'); GV.io.unobserve(i); } }, { root: scroller, rootMargin: '600px 200px' });
4193
+ imgs.forEach((i) => GV.io.observe(i));
4194
+ }
4195
+ /** Arrow keys between cells, Enter opens one (a roving tabindex: one cell in the tab order). */
4196
+ function gridNav (root, onPick) {
4197
+ const cells = () => [...root.querySelectorAll('.gv__cell')];
4198
+ const first = cells()[0]; if (first) first.tabIndex = 0;
4199
+ root.onclick = (e) => { const c = e.target.closest('.gv__cell'); if (c) onPick(c.dataset.gl, c.dataset.gk); };
4200
+ root.onkeydown = (e) => {
4201
+ const c = e.target.closest?.('.gv__cell'); if (!c) return;
4202
+ const all = cells(); const i = all.indexOf(c); const per = lbPhone() ? Math.max(1, Math.round(root.clientWidth / (c.offsetWidth || 1))) : Number(getComputedStyle(root.querySelector('.gv__grid') || root).getPropertyValue('--cols')) || 1;
4203
+ const go = { ArrowRight: i + 1, ArrowLeft: i - 1, ArrowDown: i + per, ArrowUp: i - per, Home: 0, End: all.length - 1 }[e.key];
4204
+ if (go != null) { e.preventDefault(); e.stopPropagation(); const t = all[Math.max(0, Math.min(all.length - 1, go))]; all.forEach((x) => { x.tabIndex = -1; }); t.tabIndex = 0; t.focus(); t.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }
4205
+ };
4206
+ }
4207
+ const gridSteps = () => flowOrder().filter((x) => x.kind !== 'stack' && Object.keys(x.captures || {}).length);
4208
+ function gvOpen (id) {
4209
+ if (!RUN) return;
4210
+ const list = gridSteps(); const n = byId(id) && list.includes(byId(id)) ? byId(id) : list[0]; if (!n) return;
4211
+ if (!$('lb').hidden) closeLb();
4212
+ GV.open = true; GV.id = n.id; $('gv').hidden = false; gvRender();
4213
+ ($('gv').querySelector('.gv__cell[tabindex="0"]') || $('gvClose'))?.focus({ preventScroll: true });
4214
+ }
4215
+ function gvClose () { GV.open = false; GV.io?.disconnect(); $('gv').hidden = true; $('gv').innerHTML = ''; setHash(null); }
4216
+ function gvStep (d) { const list = gridSteps(); const i = list.findIndex((x) => x.id === GV.id); const t = list[i + d]; if (t) { GV.id = t.id; gvRender(); $('gv').querySelector('.gv__cell[tabindex="0"]')?.focus({ preventScroll: true }); } }
4217
+ function gvRender () {
4218
+ const n = byId(GV.id); const box = $('gv'); if (!n || !box) return;
4219
+ const list = gridSteps(); const i = list.indexOf(n); const keys = gridKeys();
4220
+ box.innerHTML = `<div class="gvm__top"><span class="brand">Kaloko <small>${esc(T('gridTitle'))}</small></span><button class="icon-btn" id="gvPrev" aria-label="${esc(T('gridPrevStep'))}" title="${esc(T('gridPrevStep'))} (K)" ${i <= 0 ? 'disabled' : ''}>${ICON.prev}</button><h3 class="gvm__title" id="gvTitle">${esc(loc(n.title))} <span class="note">${i + 1} / ${list.length}</span></h3><button class="icon-btn" id="gvNext" aria-label="${esc(T('gridNextStep'))}" title="${esc(T('gridNextStep'))} (J)" ${i >= list.length - 1 ? 'disabled' : ''}>${ICON.next}</button><span class="spacer"></span>
4221
+ ${lbPhone() && keys.length > 1 ? `<label class="gvm__opt">${esc(T('gridColumn'))} <select id="gvCol">${keys.map((v) => `<option value="${esc(v.name)}" ${v.name === GV.col ? 'selected' : ''}>${esc(vpLabel(v))}${dimWords(v.name) ? ` · ${esc(dimWords(v.name))}` : ''}</option>`).join('')}</select></label>` : ''}
4222
+ ${RUN.scope.variants ? `<label class="gvm__opt"><input type="checkbox" id="gvAll" ${GV.all ? 'checked' : ''}> ${esc(T('gridAll'))}</label>` : ''}<label class="gvm__opt"><input type="checkbox" id="gvProblems" ${GV.problems ? 'checked' : ''}> ${esc(T('gridProblems'))}</label><label class="gvm__opt"><input type="checkbox" id="gvFull" ${GV.full ? 'checked' : ''}> ${esc(T('gridFull'))}</label>
4223
+ <span class="note gvm__keys">${esc(T('gridKeys'))}</span><button class="icon-btn" id="gvClose" aria-label="${esc(T('gridClose'))}" title="Esc">${ICON.close}</button></div>
4224
+ <div class="gvm__body" id="gvBody">${gridHtml(n)}</div>`;
4225
+ $('gvClose').onclick = gvClose; $('gvPrev').onclick = () => gvStep(-1); $('gvNext').onclick = () => gvStep(1);
4226
+ const opt = (id, k) => { if ($(id)) $(id).onchange = (e) => { GV[k] = e.target.checked; gvRender(); }; };
4227
+ opt('gvAll', 'all'); opt('gvProblems', 'problems'); opt('gvFull', 'full');
4228
+ if ($('gvCol')) $('gvCol').onchange = (e) => { GV.col = e.target.value; gvRender(); };
4229
+ gridLazy($('gvBody'), $('gvBody'));
4230
+ gridNav($('gvBody'), (l, key) => { gvClose(); if (l !== S.loc) S.loc = l; syncDims(key); renderAll(); openLb(n.id, key); });
4231
+ setHash({ grid: n.id });
4232
+ }
4233
+ function gvKey (e) {
4234
+ const k = e.key;
4235
+ if (k === 'Escape') { e.preventDefault(); gvClose(); return; }
4236
+ if (e.target.closest?.('.gv__cell') && /^Arrow|^Home$|^End$/.test(k)) return;
4237
+ if (k === 'j' || k === 'J') { e.preventDefault(); gvStep(1); }
4238
+ else if (k === 'k' || k === 'K') { e.preventDefault(); gvStep(-1); }
4239
+ else if (k === 'g' || k === 'G') { e.preventDefault(); gvClose(); }
4240
+ }
4241
+ addEventListener('resize', () => { if (GV.open) { const phone = lbPhone(); if (phone !== GV.phone) { GV.phone = phone; gvRender(); } } });
4242
+
4243
+ /* ---------- the signed acceptance record (#10) ---------- */
4244
+ function signoffHtml () {
4245
+ if (!APP()?.user || !APP()?.run || isDraft() || isDesign() || RUN.verdict?.verdict !== 'accepted') return '';
4246
+ return `<div class="signoff"><p class="note"><b>${esc(T('signoffTitle'))}.</b> ${esc(T('signoffLead'))}</p><div class="share__row"><button class="btn" data-signoff="pdf">${ICON.download} ${esc(T('signoffPdf'))}</button><button class="linkish" data-signoff="json">${esc(T('signoffJson'))}</button></div></div>`;
4247
+ }
4248
+ async function downloadSignoff (kind, b) {
4249
+ b.disabled = true; toast(T('signoffWait'));
4250
+ try {
4251
+ const res = await fetch(runApi(kind === 'pdf' ? '/signoff.pdf' : '/signoff'), { credentials: 'same-origin' });
4252
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
4253
+ const blob = await res.blob(); const a = document.createElement('a');
4254
+ a.href = URL.createObjectURL(blob); a.download = `kaloko-signoff-${RUN.id}.${kind}`; document.body.appendChild(a); a.click(); a.remove();
4255
+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
4256
+ } catch (e) { toast(e.message); } finally { b.disabled = false; }
4257
+ }
4258
+ document.addEventListener('click', (e) => { const b = e.target.closest?.('[data-signoff]'); if (b) downloadSignoff(b.dataset.signoff, b); });
4259
+
3846
4260
  /* ---------- boot ---------- */
3847
4261
  /* ---------- review mode (Kontrola): one step at a time from the queue of steps that need a person ----------
3848
4262
  The queue comes from the service (GET …/runs/<id>/review-queue: order, reasons, decisions, approvals carried over
@@ -3852,7 +4266,7 @@ document.addEventListener('fullscreenchange', () => { const b = $('ptFull'); if
3852
4266
  lightbox (its own overlay #rv and functions rv*), so both can change independently. */
3853
4267
  Object.assign(I18N.cs, {
3854
4268
  rvTitle: 'Kontrola', rvBtnTitle: 'Kontrola: krok po kroku to, co čeká na člověka', rvDecided: 'rozhodnuto', rvCarriedN: 'převzato',
3855
- rvKeys: 'J K další a předchozí · A schválit · R vrátit · C komentář · V verze · Esc konec', rvExit: 'Ukončit kontrolu',
4269
+ rvKeys: 'J K další a předchozí · A schválit · R vrátit · C komentář · V verze · G mřížka · Esc konec', rvExit: 'Ukončit kontrolu',
3856
4270
  rvPrev: 'Předchozí', rvNext: 'Další', rvApprove: 'Schválit', rvReturn: 'Vrátit', rvReturnTitle: 'Co se má změnit?',
3857
4271
  rvReturnPh: 'Napište, co je špatně a jak to má být. Autor běhu to uvidí hned.', rvReturnSend: 'Vrátit krok',
3858
4272
  rvAccepted: 'přijatý běh', rvViewThis: 'Tento běh', rvViewSide: 'Vedle sebe', rvViewRef: 'Přijatý běh',
@@ -3873,7 +4287,7 @@ Object.assign(I18N.cs, {
3873
4287
  })
3874
4288
  Object.assign(I18N.en, {
3875
4289
  rvTitle: 'Review', rvBtnTitle: 'Review: one step at a time, what waits for a person', rvDecided: 'decided', rvCarriedN: 'carried over',
3876
- rvKeys: 'J K next and previous · A approve · R return · C comment · V version · Esc leave', rvExit: 'Leave review',
4290
+ rvKeys: 'J K next and previous · A approve · R return · C comment · V version · G grid · Esc leave', rvExit: 'Leave review',
3877
4291
  rvPrev: 'Previous', rvNext: 'Next', rvApprove: 'Approve', rvReturn: 'Return', rvReturnTitle: 'What should change?',
3878
4292
  rvReturnPh: 'Say what is wrong and how it should be. The author of the run sees it right away.', rvReturnSend: 'Return the step',
3879
4293
  rvAccepted: 'accepted run', rvViewThis: 'This run', rvViewSide: 'Side by side', rvViewRef: 'Accepted run',
@@ -4028,13 +4442,13 @@ function rvRender () {
4028
4442
  const ref = d.reference; const refPath = it.ref_captures?.[S.loc]?.[v.name] || null;
4029
4443
  const base = ref && refPath ? rvRefBase(ref.id) : null;
4030
4444
  const refSrc = rvRefSrc(base, refPath);
4031
- const view = refPath ? (RV.view || (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this')) : 'this';
4445
+ const view = RV.view === 'grid' && gridUseful() && n.kind !== 'stack' ? 'grid' : refPath ? (RV.view && RV.view !== 'grid' ? RV.view : (it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this')) : 'this';
4032
4446
  const shotHtml = n.kind === 'stack'
4033
4447
  ? `<div class="rp__decision"><div class="seg-label">${ICON.stack} ${esc(n.stack?.count || 0)} ${pagesN(n.stack?.count || 0)}</div><div class="stack-fan" style="max-width:520px;height:220px;margin:12px auto">${(n.stack?.sample || []).slice(0, 3).map((x) => x.thumb ? `<img src="${esc(asset(x.thumb))}" alt="" style="height:180px">` : '<span class="ph" style="height:180px"></span>').join('')}</div><button class="btn btn--primary" id="rvStack">${T('openIndex')}</button></div>`
4034
4448
  : 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>`;
4035
4449
  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>`;
4036
4450
  const refCap = ref ? `<b>${esc(T('rvViewRef'))}</b> ${fmtDate(ref.started_at)}${ref.accepted_by ? ` · ${esc(ref.accepted_by)}` : ''}` : '';
4037
- 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>`
4451
+ const stage = view === 'grid' ? gridHtml(n) : (view === 'swipe' || view === 'onion') && cap && refSrc ? overlayCmpHtml(view, asset(cap.image || cap.png), refSrc, v, T('rvViewThis'), T('rvViewRef')) : 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>`
4038
4452
  : view === 'ref' ? `<div class="lb__cmp" style="grid-template-columns:1fr"><figure><figcaption>${refCap}</figcaption>${refHtml}</figure></div>` : shotHtml;
4039
4453
  const s = nodeSt(n);
4040
4454
  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('');
@@ -4076,8 +4490,13 @@ function rvWire (it, n, v) {
4076
4490
  $('rvLoc').hidden = RUN.scope.locales.length < 2;
4077
4491
  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;
4078
4492
  const hasRef = !!(it && it.ref_captures?.[S.loc]?.[RV.vp]);
4079
- 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(); });
4080
- $('rvView').hidden = !hasRef;
4493
+ const grid = !!(n && n.kind !== 'stack' && gridUseful());
4494
+ const views = rvViews(it, n);
4495
+ if (views.length > 1) seg($('rvView'), views.map((k) => [k, T({ this: 'rvViewThis', side: 'rvViewSide', swipe: 'cmpSwipe', onion: 'cmpOnion', ref: 'rvViewRef', grid: 'gridBtn' }[k])]), RV.view && views.includes(RV.view) ? RV.view : (hasRef && it.reasons.includes('changed') && !lbPhone() ? 'side' : 'this'), (m) => { RV.view = m; rvRender(); });
4496
+ $('rvView').hidden = views.length < 2;
4497
+ // the grid in review: a cell switches the locale and the viewport and shows that capture
4498
+ if (grid && RV.view === 'grid' && $('rvStage')) { gridLazy($('rvStage'), $('rvStage')); gridNav($('rvStage'), (l, key) => { S.loc = l; syncDims(key); RV.vp = key; RV.view = 'this'; renderAll(); rvRender(); }); }
4499
+ wireOverlay($('rvStage'));
4081
4500
  $('rvClose').onclick = rvClose;
4082
4501
  $('rvPrev').onclick = () => rvGo(-1);
4083
4502
  if ($('rvNext')) $('rvNext').onclick = () => rvGo(1);
@@ -4102,7 +4521,7 @@ function rvWire (it, n, v) {
4102
4521
  wireComments(box);
4103
4522
  // a phone: a swipe sideways on the screenshot moves to the next or previous step (not when it pans or zooms)
4104
4523
  let sw = null;
4105
- 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 });
4524
+ box.addEventListener('touchstart', (e) => { sw = e.touches.length === 1 && e.target.closest('#rvStage') && !e.target.closest('.cmpov, .gv__cell') ? { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() } : null; }, { passive: true });
4106
4525
  box.addEventListener('touchend', (e) => {
4107
4526
  const s0 = sw; sw = null; if (!s0) return;
4108
4527
  const t = e.changedTouches[0], dx = t.clientX - s0.x, dy = t.clientY - s0.y, sc = $('rvStage');
@@ -4114,6 +4533,11 @@ function rvWire (it, n, v) {
4114
4533
  if (RV.ret) requestAnimationFrame(() => $('rvRetNote')?.focus());
4115
4534
  $('rvStage')?.scrollTo?.(0, 0);
4116
4535
  }
4536
+ /** What the review stage can show for an item: this run, the accepted run beside it, swiped or faded over it, or the grid. */
4537
+ function rvViews (it, n) {
4538
+ const hasRef = !!(it && it.ref_captures?.[S.loc]?.[RV.vp]);
4539
+ return [...(hasRef ? ['this', 'side', 'swipe', 'onion', 'ref'] : ['this']), ...(n && n.kind !== 'stack' && gridUseful() ? ['grid'] : [])];
4540
+ }
4117
4541
  function rvKey (e) {
4118
4542
  const k = e.key;
4119
4543
  if (k === 'Escape') { e.preventDefault(); if (RV.ret) { RV.ret = false; rvRender(); } else rvClose(); return; }
@@ -4123,7 +4547,8 @@ function rvKey (e) {
4123
4547
  if (k === 'a' || k === 'A') { e.preventDefault(); rvApprove(); }
4124
4548
  else if (k === 'r' || k === 'R') { e.preventDefault(); rvReturn(); }
4125
4549
  else if (k === 'c' || k === 'C') { e.preventDefault(); rvComment(); }
4126
- 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(); }
4550
+ else if ((k === 'v' || k === 'V') && $('rvView') && !$('rvView').hidden) { e.preventDefault(); const order = rvViews(rvCur(), byId(rvCur()?.step)).filter((x) => x !== 'grid'); const cur = $('rvView').querySelector('[aria-pressed="true"]')?.dataset.v || 'this'; RV.view = order[(order.indexOf(cur) + 1) % order.length]; rvRender(); }
4551
+ else if ((k === 'g' || k === 'G') && rvViews(rvCur(), byId(rvCur()?.step)).includes('grid')) { e.preventDefault(); RV.view = RV.view === 'grid' ? null : 'grid'; rvRender(); }
4127
4552
  }
4128
4553
  /** After a decision: the queue and the run again, then the next step nobody decided yet. */
4129
4554
  async function rvAfter (step) {
@@ -4322,6 +4747,7 @@ load().then((data) => {
4322
4747
  // (the draft stays); the next Escape closes the overlay. Search boxes keep their own arrow and Enter keys.
4323
4748
  const field = e.target.closest?.('textarea,select,[contenteditable="true"],input:not([type=checkbox]):not([type=radio]):not([type=button])');
4324
4749
  if (field && !['siQ', 'riQ', 'brSearch'].includes(field.id)) { if (e.key === 'Escape') { e.preventDefault(); field.blur(); } return; }
4750
+ if (GV.open) { if (!e.metaKey && !e.ctrlKey && !e.altKey) gvKey(e); return; }
4325
4751
  if (locKey(e)) return;
4326
4752
  if (RV.open) { if (!e.metaKey && !e.ctrlKey && !e.altKey) rvKey(e); return; }
4327
4753
  if (!$('pt').hidden) { if (!e.metaKey && !e.ctrlKey && !e.altKey) ptKey(e); return; }
@@ -4338,12 +4764,14 @@ load().then((data) => {
4338
4764
  if (e.key === 'ArrowLeft' && !e.shiftKey) $('lbPrev')?.click();
4339
4765
  if (e.key === 'c' || e.key === 'C') { e.preventDefault(); focusComposer(); }
4340
4766
  if (e.key === 'p' || e.key === 'P') { e.preventDefault(); ptOpen(S.open, S.lbVp); }
4767
+ if ((e.key === 'g' || e.key === 'G') && $('lbGrid')) { e.preventDefault(); gvOpen(S.open); }
4341
4768
  return;
4342
4769
  }
4343
4770
  if (S.mode === 'index' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (S.st?.inline ? stackKey(e) : riKey(e)) return; }
4344
4771
  if (e.target.closest('input,select,textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
4345
4772
  if (e.key === 'i' || e.key === 'I') { setMode(S.mode === 'index' ? 'map' : 'index'); return; }
4346
4773
  if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); rvOpen(null); return; }
4774
+ if ((e.key === 'g' || e.key === 'G') && gridUseful()) { e.preventDefault(); gvOpen(S.lastStep); return; }
4347
4775
  if (e.key === '?') { e.preventDefault(); $('helpBtn').click(); return; }
4348
4776
  if (e.key === 'f' || e.key === 'F') { const any = S.left || S.right; S.left = S.right = !any; setPanels(); }
4349
4777
  if (e.key === 'k' || e.key === 'K' || (e.key === '/' && S.mode !== 'index')) { e.preventDefault(); openBrowser(); }