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/package.json +1 -1
- package/skills/kaloko/SKILL.md +4 -2
- package/skills/kaloko/reference/acceptance.md +4 -2
- package/skills/kaloko/reference/collaboration.md +35 -6
- package/skills/kaloko/reference/execution.md +9 -0
- package/skills/kaloko/reference/mcp.md +5 -2
- package/src/cli.mjs +40 -2
- package/src/lib/signoff-core.mjs +138 -0
- package/src/lib/signoff.mjs +83 -0
- package/src/lib/stability.mjs +82 -0
- package/viewer/index.html +458 -30
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
|
|
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
|
|
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;
|
|
2490
|
-
if (!kb
|
|
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>` : ''}
|
|
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
|
|
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
|
-
|
|
4080
|
-
|
|
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 =
|
|
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(); }
|