kaloko 0.12.0 → 0.12.2

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.
@@ -12,9 +12,9 @@ import { tmpdir } from 'node:os'
12
12
  import { join, relative, dirname } from 'node:path'
13
13
  import { writeJson, localized } from './util.mjs'
14
14
  import { addCapture, runPaths } from './run.mjs'
15
- import { encodeClip, preset, MAX_SECONDS } from './video.mjs'
15
+ import { encodeClip, preset, MAX_SECONDS, MIN_STEP_MS } from './video.mjs'
16
16
  import { PAGE_HOOK, networkEntries, actionEntries, summarizeTrace, cleanText, TRACE_LIMITS } from './trace.mjs'
17
- import { maskSpec, scenarioMasks, paintMasks, scrubDeep } from './pii.mjs'
17
+ import { maskSpec, scenarioMasks, paintMasks, scrubDeep, loadKnownPii } from './pii.mjs'
18
18
 
19
19
  export const RECORD_MODES = ['step', 'flow', 'failed']
20
20
  const QUALITIES = ['low', 'medium', 'high']
@@ -209,6 +209,7 @@ export async function recordingSession ({ page, run, scenario, locale, settings,
209
209
  // every selector the scenario masks on any step, for the whole recording (more hidden than a single capture is fine)
210
210
  const masks = scenarioMasks(scenario)
211
211
  const mask = maskSpec({ run, selectors: masks.selectors, auto: masks.auto, locale })
212
+ if (mask.active) await loadKnownPii(run.dir)
212
213
  if (anyRecord && mask.active && !(await paintMasks(page, mask))) say(`Masks could not be drawn into the recording (${label}); the recording is skipped so it shows no personal data.`)
213
214
  const painted = !anyRecord || !mask.active || await page.evaluate(() => !!window.__kalokoMask).catch(() => false)
214
215
  const obs = await observePage(page, { screencast: anyRecord && painted, trace: settings.trace, quality })
@@ -249,11 +250,17 @@ export async function recordingSession ({ page, run, scenario, locale, settings,
249
250
  const keepClip = want && (want.mode !== 'failed' || failed)
250
251
  if (!keepClip) { await obs.prune(t2); return }
251
252
  const clip = obs.clip(s.t0, error ? t2 : t1)
253
+ // the screencast sends a frame only when the picture changes, and a fast step is captured before the new page's
254
+ // frame even arrives: with fewer than two frames in the step nothing moved worth watching, so no video is kept
255
+ // and the canvas shows the screenshot (a failed step keeps its recording whatever it shows)
256
+ const still = clip.frames.filter((f) => f.t > s.t0).length < 2
257
+ if (still && !failed) { await obs.prune(t2); return }
258
+ const end = Math.max(error ? t2 : t1, s.t0 + MIN_STEP_MS)
252
259
  // the frames stay on disk until the encoder has them; pruning waits in the same queue
253
260
  enqueue(async () => {
254
261
  const base = recBase(run.dir, node.id, locale, vp)
255
262
  await mkdir(dirname(base), { recursive: true })
256
- const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end: error ? t2 : t1, out: base, quality: want.quality, maxSeconds: want.max_seconds, codec: want.codec || 'auto', mp4: want.mp4 ?? 'auto' })
263
+ const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end, out: base, quality: want.quality, maxSeconds: want.max_seconds, codec: want.codec || 'auto', mp4: want.mp4 ?? 'auto' })
257
264
  // offset_ms: where the clip starts after the step's start (a trimmed long step keeps its end); the trace aligns by it
258
265
  if (r) await saveRecording({ run, node, locale, viewport: vp, result: { ...r, offset_ms: Math.max(0, Math.round(r.start - s.t0)) }, mode: want.mode, failed, error, captured_at: records?.[0]?.captured_at || null, source: 'screencast' })
259
266
  for (const n of r?.notes || []) notes.add(n)
@@ -307,6 +314,8 @@ export async function saveRecording ({ run, node, locale, viewport, result, mode
307
314
  fps: result.fps || null,
308
315
  codec: result.codec,
309
316
  ...(result.trimmed ? { trimmed: true } : {}),
317
+ // nothing moved during the step: the canvas shows the screenshot in its place
318
+ ...(result.still ? { still: true } : {}),
310
319
  ...(result.offset_ms ? { offset_ms: result.offset_ms } : {}),
311
320
  files,
312
321
  bytes: result.bytes || {},
@@ -11,7 +11,9 @@ export const TRIAGE_MODELS = ['clef', 'clef-flash']
11
11
  export const clefModelId = (model) => `@cf/cloudflare/${TRIAGE_MODELS.includes(model) ? model : TRIAGE_MODELS[0]}`
12
12
  // Clef by default: on the first bench (#72) it found 69 % of injected defects against 54 % for Clef-flash, with no
13
13
  // false finding on clean screens either way, at about 1.2 s against 0.5 s a screen
14
- export const TRIAGE_DEFAULTS = { model: 'clef', keep: 0.7, maybe: 0.5, max_width: 1024, tile_height: 1400, max_tiles: 4, frames_per_step: 6, frames_per_run: 60, max_calls: 200 }
14
+ // Clef's context is 65,536 tokens and images take about one token per 40 pixels: two tiles of 896×1024 or four
15
+ // frames 640 wide leave room for the questions (four full tiles of 1024×1400 came to 134,000 tokens)
16
+ export const TRIAGE_DEFAULTS = { model: 'clef', keep: 0.7, maybe: 0.5, max_width: 896, tile_height: 1024, max_tiles: 2, frame_width: 640, frames_per_step: 6, frames_per_run: 60, max_calls: 200 }
15
17
  /** Workers AI: at most 4 images per request, 4 MiB each, 8 MiB together; 64 questions. */
16
18
  export const MAX_TRIAGE_IMAGES = 4
17
19
  export const MAX_TRIAGE_QUESTIONS = 64
@@ -7,6 +7,9 @@ import { readFile } from 'node:fs/promises'
7
7
  import { readJson, writeJson, sha256 } from './util.mjs'
8
8
  import { buildTriageRequest, parseTriage, mergeFindings, clefModelId, TRIAGE_PROMPT_VERSION, MAX_TRIAGE_IMAGES } from './triage-core.mjs'
9
9
 
10
+ // set once Workers AI refused the local Cloudflare token: the rest of the evaluation goes through the service
11
+ let ownKeyRefused = false
12
+
10
13
  /**
11
14
  * Asks Clef with a content-addressed cache. Returns { findings, criteria, model, usage, cached, via, ms } or
12
15
  * { skipped } / { error }. `allowCall()` is asked before every uncached request.
@@ -21,16 +24,21 @@ export async function askTriage ({ payload, settings, cacheDir, where, fetchImpl
21
24
  if (!raw) {
22
25
  const token = process.env.CLOUDFLARE_API_TOKEN; const account = process.env.CLOUDFLARE_ACCOUNT_ID
23
26
  if (!allowCall()) return { skipped: `triage call limit reached (max_calls ${settings.max_calls})` }
24
- if (token && account) {
27
+ if (token && account && !ownKeyRefused) {
25
28
  const t0 = Date.now()
26
29
  let res
27
30
  try { res = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${account}/ai/run/${clefModelId(body.model)}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) } catch (e) { return { error: `Workers AI unreachable: ${e.message}` } }
31
+ // too many tokens for the model's context: once more with half the images (a tall page loses its lower part)
32
+ if (res.status === 413 && payload.images.length > 1) return askTriage({ payload: { ...payload, images: payload.images.slice(0, Math.ceil(payload.images.length / 2)) }, settings, cacheDir, where, fetchImpl, hosted, allowCall })
33
+ // a Cloudflare token without Workers AI (a deploy token in the project's .env): the service asks instead
34
+ if ((res.status === 401 || res.status === 403) && hosted) { ownKeyRefused = true; if (hosted.ready && !(await hosted.ready())) return { skipped: 'the Cloudflare token cannot use Workers AI and the triage experiment is off for this organization' }; return askTriage({ payload, settings, cacheDir, where, fetchImpl, hosted, allowCall: () => true }) }
28
35
  if (!res.ok) return { error: `Workers AI HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` }
29
36
  const out = await res.json()
30
37
  const a = out?.result?.answers ? out.result : out
31
38
  raw = { model: a.model || settings.model, answers: a.answers || {}, usage: a.usage || null, ms: Date.now() - t0, via: 'own-key' }
32
39
  } else if (hosted) {
33
40
  const r = await hosted({ model: settings.model, ...payload })
41
+ if (r.error && /HTTP 502.*(context window|413)/i.test(r.error) && payload.images.length > 1) return askTriage({ payload: { ...payload, images: payload.images.slice(0, Math.ceil(payload.images.length / 2)) }, settings, cacheDir, where, fetchImpl, hosted, allowCall })
34
42
  if (r.skipped || r.error) return r
35
43
  raw = { model: r.response.model, answers: r.response.answers || {}, usage: r.response.usage || null, ms: r.response.ms ?? null, via: 'kaloko' }
36
44
  } else return { skipped: 'no service token (KALOKO_TOKEN) and no CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID' }
@@ -75,15 +83,19 @@ export function hostedTriageSender (config, report) {
75
83
  * at most `max` frames, each a WebP data URL `maxW` wide.
76
84
  */
77
85
  export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024 }) {
86
+ if (!(max > 0)) return []
78
87
  const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${(await readFile(file)).toString('base64')}`
79
88
  return page.evaluate(async ({ src, taps, max, maxW }) => {
80
89
  const v = document.createElement('video'); v.muted = true; v.preload = 'auto'; v.src = src
81
- await new Promise((ok, no) => { v.onloadeddata = ok; v.onerror = () => no(new Error('the recording could not be decoded')) })
90
+ // every wait has a limit: a stalled video must not hang the evaluation
91
+ const within = (p, ms) => Promise.race([p, new Promise((ok, no) => setTimeout(() => no(new Error('the recording did not answer in time')), ms))])
92
+ await within(new Promise((ok, no) => { v.onloadeddata = ok; v.onerror = () => no(new Error('the recording could not be decoded')) }), 15000)
93
+ if (!v.videoWidth || !v.videoHeight) return []
82
94
  // WebM from MediaRecorder-like encoders may report Infinity until the end is sought once
83
- if (!Number.isFinite(v.duration)) { v.currentTime = 1e9; await new Promise((ok) => { v.onseeked = ok }) }
95
+ if (!Number.isFinite(v.duration)) { v.currentTime = 1e9; await within(new Promise((ok) => { v.onseeked = ok }), 5000).catch(() => {}) }
84
96
  const dur = Number.isFinite(v.duration) ? v.duration : 0
85
97
  if (!dur) return []
86
- const seek = (t) => new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) })
98
+ const seek = (t) => within(new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) }), 5000)
87
99
  const small = document.createElement('canvas'); small.width = 64; small.height = Math.max(1, Math.round(64 * v.videoHeight / v.videoWidth))
88
100
  const sg = small.getContext('2d', { willReadFrequently: true })
89
101
  // sampled differences: 4 samples a second, at most 240
@@ -101,7 +113,7 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
101
113
  for (const c of diffs.filter((x) => x.d > 0.02).sort((a, b) => b.d - a.d).slice(0, 3)) { add(c.t - step, 'before a change'); add(c.t, 'after a change') }
102
114
  add(dur - 0.05, 'the end of the step')
103
115
  const chosen = [...pick.values()].sort((a, b) => a.t - b.t)
104
- // keep the end, then the strongest moments, within max
116
+ // within max: the earliest moments and the end of the step
105
117
  const list = chosen.length > max ? [...chosen.slice(0, max - 1), chosen.at(-1)] : chosen
106
118
  const big = document.createElement('canvas'); const s = Math.min(1, maxW / v.videoWidth)
107
119
  big.width = Math.round(v.videoWidth * s); big.height = Math.round(v.videoHeight * s)
package/src/lib/video.mjs CHANGED
@@ -257,6 +257,8 @@ export async function encoderCaps (page) {
257
257
  * { files: { webm, mp4, preview, poster }, duration_ms, width, height, codec, fps, trimmed, notes }.
258
258
  */
259
259
  // the encoder page holds one clip at a time: parallel locales and named sessions take turns
260
+ // a step clip shorter than this is held on its last frame: a one-second video jumps past before anyone can look
261
+ export const MIN_STEP_MS = 2500
260
262
  const LOCKS = new WeakMap()
261
263
  export function encodeClip (opts) {
262
264
  if (!opts.page) return encodeClipNow(opts)
package/viewer/index.html CHANGED
@@ -1604,7 +1604,8 @@ function wireComments (root) {
1604
1604
  b.disabled = true;
1605
1605
  const made = await cApi(scope, '', 'POST', body);
1606
1606
  // a comment made from a triage finding records the answer, so the experiment counts it (#70)
1607
- if (main && scope === 'step' && C.fromFinding?.node === nodeId) { const sec = document.getElementById('findSec'); const ff = C.fromFinding; C.fromFinding = null; if (sec && sec.dataset.vp === ff.vp) answerFinding(sec, ff.fid, 'comment', made?.id || made?.comment?.id || null).catch(() => {}); }
1607
+ // only when the comment still carries the finding's line, on the same step, locale and screen
1608
+ if (main && scope === 'step' && C.fromFinding?.node === nodeId) { const sec = document.getElementById('findSec'); const ff = C.fromFinding; C.fromFinding = null; const f = (byId(nodeId)?.captures?.[ff.loc]?.[ff.vp]?.findings || []).find((x) => x.id === ff.fid); if (sec && f && sec.dataset.vp === ff.vp && S.loc === ff.loc && note.startsWith(T('findNote')(T('fk_' + f.kind) || f.kind, Math.round(f.probability * 100)).trim())) answerFinding(sec, ff.fid, 'comment', made?.id || made?.comment?.id || null).catch(() => {}); }
1608
1609
  saveDraft(box.dataset.key, ''); if (main) { C.pin = null; C.pinMode = false; C.shape = null; C.draw = null; LIVE.anchor = null; } C.replyTo = null;
1609
1610
  delete C.files[box.dataset.key];
1610
1611
  toast(T('saved')); await refreshComments();
@@ -1907,7 +1908,7 @@ function shot (n, v, w, h) {
1907
1908
  if (att) return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${att.preview ? ` data-prev="${esc(asset(att.preview))}"` : ''}><img loading="lazy" src="${esc(asset(att.poster))}" alt="" style="opacity:.75"><span class="shot__rec" title="${esc(T('recFailed'))}">▶ ✕</span><span class="shot__tag">${v.w}</span></span>`;
1908
1909
  if (!c) return `<span class="shot shot--${v.mobile ? 'm' : 'd'} shot--empty" style="width:${w}px;height:${h}px">${T('noCapture')}</span>`;
1909
1910
  const prev = c.rec?.preview && !RUN.recordings_expired ? asset(c.rec.preview) : null;
1910
- return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${prev ? ` data-prev="${esc(prev)}"` : ''}><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}" alt="">${c.rec ? `<span class="shot__rec" title="${esc(T('recHint'))}">▶</span>` : ''}<span class="shot__tag">${v.w}</span></span>`;
1911
+ return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${prev ? ` data-prev="${esc(prev)}"` : ''}><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}"${c.thumb && (c.image || c.png) ? ` data-full="${esc(asset(c.image || c.png))}"` : ''} alt="">${c.rec ? `<span class="shot__rec" title="${esc(T('recHint'))}">▶</span>` : ''}<span class="shot__tag">${v.w}</span></span>`;
1911
1912
  }
1912
1913
  /* Quick signals of a capture (probabilities of "yes"): which ones deserve a look. Purpose and language are good
1913
1914
  when high, broken is good when low; the evaluator is calibrated, so 30 % / 70 % are honest cut-offs. */
@@ -1978,8 +1979,11 @@ document.addEventListener('click', async (e) => {
1978
1979
  const n = byId(sec.dataset.node); const f = (n?.captures?.[S.loc]?.[sec.dataset.vp]?.findings || []).find((x) => x.id === fid);
1979
1980
  if (!f) return;
1980
1981
  const key = draftKey('step', n.id, 'new');
1981
- if (!loadDraft(key)) saveDraft(key, T('findNote')(T('fk_' + f.kind) || f.kind, Math.round(f.probability * 100)));
1982
- C.fromFinding = { node: n.id, vp: sec.dataset.vp, fid };
1982
+ // the finding's line leads the comment, also above a draft already started
1983
+ const line = T('findNote')(T('fk_' + f.kind) || f.kind, Math.round(f.probability * 100));
1984
+ const draft = loadDraft(key);
1985
+ if (!draft.startsWith(line)) saveDraft(key, draft ? `${line}${draft}` : line);
1986
+ C.fromFinding = { node: n.id, vp: sec.dataset.vp, loc: S.loc, fid };
1983
1987
  rerenderComments();
1984
1988
  const ta = document.querySelector(`.c-compose.c-main[data-node="${CSS.escape(n.id)}"] .c-text`);
1985
1989
  if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); ta.scrollIntoView({ block: 'center' }); }
@@ -2635,7 +2639,24 @@ function setInv (v) {
2635
2639
  INV_CSS ||= $('invCss') || document.head.appendChild(document.createElement('style'));
2636
2640
  INV_CSS.textContent = `#world :focus-visible,#world .pxd,#world .lane-head,#world .node__title,#world .cur{--inv:${v}}`;
2637
2641
  }
2638
- function applyView () { const v = S.view; $('world').style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`; { const far = v.k < 0.6; if ($('world').classList.contains('far') !== far) { $('world').classList.toggle('far', far); requestAnimationFrame(drawEdges); } $('world').classList.toggle('tiny', v.k < 0.25); } setInv(String(Math.min(6, 1 / v.k))); $('zVal').textContent = Math.round(v.k * 100) + ' %'; $('vp').style.backgroundSize = `${22 * v.k}px ${22 * v.k}px`; $('vp').style.backgroundPosition = `${v.x}px ${v.y}px`; renderMini(); presenceView(); }
2642
+ /* A card's thumbnail is made for the card at about 100 %; zoomed in (or on a 2× screen with an older, smaller
2643
+ thumbnail) the visible cards load the full screenshot instead of stretching the thumbnail. */
2644
+ let sharpenT = null;
2645
+ function sharpenShots () {
2646
+ clearTimeout(sharpenT);
2647
+ sharpenT = setTimeout(() => {
2648
+ const dpr = window.devicePixelRatio || 1; const vw = innerWidth; const vh = innerHeight;
2649
+ for (const img of document.querySelectorAll('#nodes .shot img[data-full]')) {
2650
+ if (!img.complete || !img.naturalWidth || img.closest('.shot')?.dataset.playing) continue;
2651
+ const r = img.getBoundingClientRect();
2652
+ if (r.right < 0 || r.bottom < 0 || r.left > vw || r.top > vh || !r.width) continue;
2653
+ if (r.width * dpr > img.naturalWidth * 1.15) { img.src = img.dataset.full; delete img.dataset.full; }
2654
+ }
2655
+ }, 250);
2656
+ }
2657
+ // a thumbnail that finishes loading is checked too (cards render before their images arrive)
2658
+ document.addEventListener('load', (e) => { if (e.target?.matches?.('#nodes .shot img[data-full]')) sharpenShots(); }, true);
2659
+ function applyView () { sharpenShots(); const v = S.view; $('world').style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`; { const far = v.k < 0.6; if ($('world').classList.contains('far') !== far) { $('world').classList.toggle('far', far); requestAnimationFrame(drawEdges); } $('world').classList.toggle('tiny', v.k < 0.25); } setInv(String(Math.min(6, 1 / v.k))); $('zVal').textContent = Math.round(v.k * 100) + ' %'; $('vp').style.backgroundSize = `${22 * v.k}px ${22 * v.k}px`; $('vp').style.backgroundPosition = `${v.x}px ${v.y}px`; renderMini(); presenceView(); }
2639
2660
  /* minimap: every node as a status-coloured block, the visible area as a frame; click or drag to move */
2640
2661
  let MINI = null, MINI_NODES = null;
2641
2662
  function renderMini () {
@@ -2789,7 +2810,7 @@ function renderChrome () {
2789
2810
  ? laneNamesOf().map((name, i) => { const mine = order.filter((n) => n.lane === i); if (!mine.length) return ''; const c = { pass: mine.filter((n) => nodeSt(n) === 'pass').length }; return `<h3 data-lane="${i}" title="${esc(T('fitLane'))}">${esc(name)} <small class="muted">${c.pass}/${mine.length}</small></h3>${mine.map(stepLi).join('')}`; }).join('')
2790
2811
  : order.map(stepLi).join('');
2791
2812
  $('steps').onclick = (e) => { const ln = e.target.closest('[data-lane]'); if (ln) { if (S.mode === 'index') { const k = (S.ri?.entries || []).findIndex((x) => x.type === 'group' && x.label === laneNamesOf()[Number(ln.dataset.lane)]); if (k >= 0) $('riList').scrollTop = S.ri.tops[k]; return; } fitLane(Number(ln.dataset.lane)); return; } const h = e.target.closest('[data-sess]'); if (h) { S.sess = S.sess === h.dataset.sess ? null : h.dataset.sess; renderAll(); return; } const b = e.target.closest('[data-step]'); if (!b) return; if (S.mode === 'index') riFocus(b.dataset.step); else if (byId(b.dataset.step)?.kind === 'stack') { focusNode(b.dataset.step); openStack(b.dataset.step); } else focusNode(b.dataset.step); if (innerWidth <= 1100) { S.left = false; setPanels(); } };
2792
- $('scFoot').innerHTML = `${issFootHtml()}${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
2813
+ $('scFoot').innerHTML = `${issFootHtml()}${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(webHref(RUN.issue.url))}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
2793
2814
  }
2794
2815
  // the summary panel before the cards: the cards measure themselves, and that one layout then covers the panel too
2795
2816
  function renderAll () { renderChrome(); if (S.right) renderReport(); renderNodes(); presenceView(); if (S.mode === 'index') { if (S.st?.inline) paintStack(); else if ($('riList')) riPaint(); else riRender(); } }
@@ -3042,11 +3063,14 @@ function metaSection (n, cap, v) {
3042
3063
 
3043
3064
  /* ---------- recordings and traces (batch D, #12): video of a step, its markers, the trace timeline ---------- */
3044
3065
  /** A step's video: its own recording, a failed attempt's, or its chapter of the flow recording (from/to in s). */
3066
+ /* A clip where nothing moved, or one so short it only flickers (older runs, before clips were held to 2.5 s), shows
3067
+ as the screenshot instead. */
3068
+ const stillRec = (r) => !!r && (r.still === true || (Number(r.duration_ms) > 0 && Number(r.duration_ms) < 1000));
3045
3069
  function recOf (n, cap, v) {
3046
3070
  if (!n || n.kind === 'decision' || n.kind === 'stack') return null;
3047
- if (cap?.rec && (cap.rec.webm || cap.rec.mp4)) return { ...cap.rec, kind: 'step' };
3071
+ if (cap?.rec && (cap.rec.webm || cap.rec.mp4) && !stillRec(cap.rec)) return { ...cap.rec, kind: 'step' };
3048
3072
  const f = (n.recordings || []).filter((r) => r.locale === S.loc && (r.viewport === v?.name || !cap)).pop();
3049
- if (f && (f.webm || f.mp4)) return { ...f, kind: 'failed' };
3073
+ if (f && (f.webm || f.mp4) && !stillRec(f)) return { ...f, kind: 'failed' };
3050
3074
  const fl = RUN.recordings?.flow?.[S.loc]; const ch = fl?.chapters?.find((c) => c.node === n.id);
3051
3075
  if (ch && (fl.webm || fl.mp4) && (!fl.viewport || !v || fl.viewport === v.name)) return { ...fl, kind: 'chapter', from: ch.start / 1000, to: Math.max(ch.capture, ch.start + 300) / 1000, title: ch.title };
3052
3076
  return null;
@@ -3086,7 +3110,7 @@ function wireVideo (root, rec, cap) {
3086
3110
  vid.ontimeupdate = () => {
3087
3111
  const t = vid.currentTime - from;
3088
3112
  if (pos) pos.style.left = `${Math.min(100, Math.max(0, t / d * 100))}%`;
3089
- if (to && vid.currentTime >= to) vid.pause();
3113
+ if (to && vid.currentTime >= to) { vid.dataset.autoPause = '1'; vid.pause(); vid.dispatchEvent(new Event('clipend')); }
3090
3114
  if (taps && rec.taps?.length) taps.innerHTML = rec.taps.filter((x) => Math.abs(x.t / 1000 - (rec.offset_ms || 0) / 1000 - t) < 0.45).map((x) => `<span class="vtap" style="left:${x.x * 100}%;top:${x.y * 100}%"></span>`).join('');
3091
3115
  // the trace row of this moment, when the Technical tab shows the trace of this capture
3092
3116
  const ms = t * 1000 + (rec.kind === 'step' || rec.kind === 'failed' ? (rec.offset_ms || 0) : 0);
@@ -3094,7 +3118,21 @@ function wireVideo (root, rec, cap) {
3094
3118
  for (const r of rows) { r.classList.remove('is-now'); if (Number(r.dataset.t) <= ms) now = r; }
3095
3119
  now?.classList.add('is-now');
3096
3120
  };
3121
+ // once someone plays a video, the next step's video plays by itself (until they pause one)
3122
+ vid.addEventListener('play', () => { S.vAuto = true; });
3123
+ vid.addEventListener('pause', () => { if (!vid.ended && !vid.dataset.autoPause && !(to && vid.currentTime >= to - 0.05)) S.vAuto = false; delete vid.dataset.autoPause; });
3097
3124
  if (S.vseek != null) { const t = S.vseek; S.vseek = null; seekVideo(vid, t, true); }
3125
+ else if (S.vAuto) { vid.muted = true; seekVideo(vid, 0, true); }
3126
+ }
3127
+ /* The next step's video starts loading while this one plays, so moving on does not wait for the network. */
3128
+ const PRELOADED = [];
3129
+ function preloadNext (n, v) {
3130
+ const next = outEdges(n.id).map((e) => byId(e.to)).find((x) => x && x.kind !== 'decision');
3131
+ const rec = next ? recOf(next, next.captures?.[S.loc]?.[v.name], v) : null;
3132
+ const src = rec && (rec.webm && document.createElement('video').canPlayType('video/webm') ? rec.webm : rec.mp4 || rec.webm);
3133
+ if (!src || RUN.recordings_expired || PRELOADED.some((x) => x.dataset.src === src)) return;
3134
+ const pre = document.createElement('video'); pre.preload = 'auto'; pre.muted = true; pre.dataset.src = src; pre.src = asset(src);
3135
+ PRELOADED.push(pre); if (PRELOADED.length > 3) PRELOADED.shift().removeAttribute('src');
3098
3136
  }
3099
3137
  const TRACES = new Map();
3100
3138
  function loadTrace (cap) {
@@ -3161,6 +3199,8 @@ function wireReplayVideo (n, cap, v) {
3161
3199
  const rec = rpVideoOf(n, cap, v); const stage = $('rpStage'); if (!rec || !stage) return;
3162
3200
  wireVideo(stage, rec, cap);
3163
3201
  const vid = stage.querySelector('video.rec'); if (!vid) return;
3202
+ // a step's own video (not the flow's) hands over to the next step when it ends
3203
+ if (rec.kind !== 'flow') { vid.addEventListener('ended', () => rpAutoNext(n)); vid.addEventListener('clipend', () => rpAutoNext(n)); }
3164
3204
  const chaps = rec.chapters || [];
3165
3205
  stage.querySelectorAll('[data-chap]').forEach((b) => { b.onclick = () => seekVideo(vid, Number(b.dataset.seek), true); });
3166
3206
  // the replay's step and the video's chapter move together: a step change seeks, playing past a chapter marks it
@@ -3297,6 +3337,7 @@ function openLb (id, vpName, opts = {}) {
3297
3337
  if ($('lbMode')) seg($('lbMode'), [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : []), ...(recOf(n, cap, v) ? [['video', T('videoMode')]] : []), ['shot', T('shotMode')]], S.lbMode === 'video' && !recOf(n, cap, v) ? 'shot' : S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(id, v.name); });
3298
3338
  // the step's video and the trace timeline (batch D): markers seek, trace rows jump into the video
3299
3339
  wireVideo(lb, recOf(n, cap, v), cap);
3340
+ if (S.lbMode === 'video') preloadNext(n, v);
3300
3341
  if (cap?.trace || cap?.trace_inline) fillTrace(lb, n, cap, v);
3301
3342
  if ($('lbLiveTools')) renderLiveTools();
3302
3343
  if ($('lbProto')) $('lbProto').onclick = () => ptOpen(id, v.name);
@@ -3593,12 +3634,22 @@ function rpOpen (path) {
3593
3634
  $('lb').hidden = true; S.open = null; closeBrowser();
3594
3635
  renderReplay();
3595
3636
  }
3596
- function rpClose () { S.rp = null; $('rp').hidden = true; setHash(null); }
3637
+ function rpClose () { clearTimeout(S.rpTimer); S.rp = null; $('rp').hidden = true; setHash(null); }
3638
+ /* Replay with video plays on by itself: at the end of a step's video (or after a still step has shown for a while) it
3639
+ moves to the next step when there is one way on. A decision waits for the person; the end of the flow stops. */
3640
+ const STILL_MS = 3000;
3641
+ function rpAutoNext (n) {
3642
+ clearTimeout(S.rpTimer);
3643
+ if (!S.rp || !S.rpVideo || !S.vAuto || S.rp.path[S.rp.path.length - 1] !== n.id) return;
3644
+ const opts = rpNextOptions(n);
3645
+ if (opts.length === 1 && opts[0].n) rpGo(opts[0].n.id);
3646
+ }
3597
3647
  function rpGo (id) { if (!byId(id)) return; S.rp.path.push(id); renderReplay(); }
3598
3648
  function rpBack () { if (S.rp.path.length > 1) { S.rp.path.pop(); renderReplay(); } }
3599
3649
  function rpJump (i) { S.rp.path = S.rp.path.slice(0, i + 1); renderReplay(); }
3600
3650
  function rpNextOptions (n) { return outEdges(n.id).map((e, k) => ({ e, n: byId(e.to), k })); }
3601
3651
  function renderReplay () {
3652
+ clearTimeout(S.rpTimer);
3602
3653
  const cur = S.rp.path[S.rp.path.length - 1], n = byId(cur);
3603
3654
  const v = (S.vp === 'mobile' ? mVp() : dVp()) || viewports()[0];
3604
3655
  const cap = n.captures?.[S.loc]?.[v.name];
@@ -3640,7 +3691,12 @@ function renderReplay () {
3640
3691
  $('rp').querySelectorAll('[data-stack-open]').forEach((b) => { b.onclick = () => openStack(b.dataset.stackOpen); });
3641
3692
  $('rp').querySelector('[data-restart]')?.addEventListener('click', () => rpOpen(null));
3642
3693
  if ($('rpVid')) $('rpVid').onclick = () => { S.rpVideo = !S.rpVideo; renderReplay(); };
3643
- if (S.rpVideo) wireReplayVideo(n, cap, v);
3694
+ if (S.rpVideo) {
3695
+ wireReplayVideo(n, cap, v);
3696
+ // a step without a video of its own (a still page) shows its screenshot for a moment, then playing goes on
3697
+ if (S.vAuto && n.kind !== 'decision' && !rpVideoOf(n, cap, v)) S.rpTimer = setTimeout(() => rpAutoNext(n), STILL_MS);
3698
+ preloadNext(n, v);
3699
+ }
3644
3700
  $('rpStage').scrollTop = 0;
3645
3701
  setHash({ replay: 1, step: cur, path: S.rp.path.join(',') });
3646
3702
  focusNode(cur);
@@ -3696,18 +3752,25 @@ function stepNoteHtml (n) {
3696
3752
  return x && noteTxt(x.text) ? `<p class="note step-note" data-step-note><b>${T('whatChanged')}${RUN.notes?.from ? ` (v${esc(RUN.notes.from.version)} → v${esc(RUN.version)})` : ''}:</b> ${esc(noteTxt(x.text))}</p>` : '';
3697
3753
  }
3698
3754
 
3755
+ /* The run verdict buttons: both while it is pending, then only the other choice, so an accepted run offers Return
3756
+ and a returned one offers Accept; the badge beside the heading says what was decided. */
3757
+ function verdictButtons (current, attr, design) {
3758
+ const acc = `<button class="btn btn--primary" ${attr}="accepted">${design ? T('acceptVersion') : T('accept')}</button>`;
3759
+ const ret = `<button class="btn" ${attr}="returned">${design ? T('returnVersion') : T('ret')}</button>`;
3760
+ return current === 'accepted' ? ret : current === 'returned' ? acc : acc + ret;
3761
+ }
3699
3762
  function renderReport () {
3700
3763
  const c = RUN.summary, tot = screens().length || 1, pr = prevRun();
3701
3764
  const all = screens().flatMap((n) => n.criteria), ok = all.filter((x) => critSt(x) === 'pass').length;
3702
3765
  const issues = screens().flatMap((n) => n.criteria.filter((x) => ['fail', 'partial'].includes(critSt(x))).map((x) => ({ n, x })));
3703
3766
  const changes = pr ? screens().filter((n) => pr.statuses?.[n.id] && pr.statuses[n.id] !== n.status) : [];
3704
3767
  $('rside').innerHTML = `
3705
- <div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
3768
+ <div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(webHref(RUN.issue.url))}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
3706
3769
  ${APP() && isDraft() ? `<div class="sec"><h4>${T('draft')} #${esc(RUN.draft.seq)}</h4><p class="note">${T('draftInfo')}</p><div id="draftChanges" class="note"></div></div>` : ''}
3707
3770
  ${versionNotesHtml()}
3708
3771
  ${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>` : ''}
3709
3772
  ${canWrite() ? `<textarea id="rvVerdictNote" ${loadDraft(draftKey('run', '', 'verdict')) ? '' : 'hidden '}rows="2" placeholder="${T('verdictNotePh')}" data-draft="${esc(draftKey('run', '', 'verdict'))}" 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">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
3710
- <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>
3773
+ <div class="share__row">${verdictButtons(RUN.verdict?.verdict, 'data-verdict', isDesign())}</div>
3711
3774
  <div class="vmore">${loadDraft(draftKey('run', '', 'verdict')) ? '' : `<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>` : ''}
3712
3775
  ${commentsPanel()}
3713
3776
  <div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
@@ -3739,9 +3802,9 @@ function renderReport () {
3739
3802
  const sc = $('shareCopy'); if (sc) sc.onclick = async () => { const link = $('shareUrl').textContent; try { await navigator.clipboard.writeText(link); toast(T('linkCopied')); } catch { toast(link); } };
3740
3803
  const showNote = () => { const t = $('rvVerdictNote'); if (!t) return; t.hidden = false; $('rvNoteShow')?.remove(); t.focus(); };
3741
3804
  if ($('rvNoteShow')) $('rvNoteShow').onclick = showNote;
3742
- $('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3805
+ $('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note, seen: RUN.verdict?.at ?? null }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; if (/meanwhile/.test(e.message)) reloadRun().catch(() => {}); } }; });
3743
3806
  $('rside').querySelectorAll('[data-public]').forEach((b) => { b.onclick = async () => { if (b.dataset.public === 'on' && !confirm(T('publicConfirm'))) return; b.disabled = true; try { await postJson('/public', { public: b.dataset.public === 'on' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3744
- $('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3807
+ $('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set', seen: RUN.verdict?.at ?? null }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
3745
3808
  $('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => { if (byId(el.dataset.go || el.dataset.id)) openLb(el.dataset.go || el.dataset.id); }; });
3746
3809
  // the version's summary in someone's own words (designers; step texts too through the API, MCP and --notes)
3747
3810
  const ne = $('notesEdit'); if (ne) ne.onclick = async () => {
@@ -5009,7 +5072,7 @@ function issBadge (id) {
5009
5072
  /** The run's own issue (named by its branch or pull request) beside the GitHub issue in the footer. */
5010
5073
  function issFootHtml () {
5011
5074
  const l = ISS?.data?.linked; if (!l) return '';
5012
- return `<span data-iss-linked title="${esc(T('issBranch'))}">${l.url ? `<a class="pr" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.key)}</a>` : `<span class="mono">${esc(l.key)}</span>`}${l.state ? ` <span class="note">${esc(T('iss_' + l.state))}</span>` : ''} · </span>`;
5075
+ return `<span data-iss-linked title="${esc(T('issBranch'))}">${l.url ? `<a class="pr" href="${esc(webHref(l.url))}" target="_blank" rel="noopener">${esc(l.key)}</a>` : `<span class="mono">${esc(l.key)}</span>`}${l.state ? ` <span class="note">${esc(T('iss_' + l.state))}</span>` : ''} · </span>`;
5013
5076
  }
5014
5077
  function issFoot () { const f = $('scFoot'); if (f && !f.querySelector('[data-iss-linked]')) f.insertAdjacentHTML('afterbegin', issFootHtml()); }
5015
5078
  /** Issues of a step and the form to create one, in the step detail and in review mode. */
@@ -5018,7 +5081,7 @@ function issuesBlock (n, v) {
5018
5081
  const list = issOf(n.id); const trackers = issTrackers();
5019
5082
  const can = ISS.data.can_create && trackers.length && canWrite();
5020
5083
  if (!list.length && !can) return '';
5021
- const items = list.length ? `<ul class="iss-list">${list.map((i) => `<li><a class="pr" href="${esc(i.url)}" target="_blank" rel="noopener">${esc(i.key)}</a> <span class="badge st-${i.state === 'open' ? 'partial' : i.state === 'done' ? 'pass' : 'skip'}">${esc(T('iss_' + i.state))}</span>${i.criterion ? ` <span class="id">${esc(i.criterion)}</span>` : ''} <span class="note">${esc(i.title)}</span></li>`).join('')}</ul>` : '';
5084
+ const items = list.length ? `<ul class="iss-list">${list.map((i) => `<li><a class="pr" href="${esc(webHref(i.url))}" target="_blank" rel="noopener">${esc(i.key)}</a> <span class="badge st-${i.state === 'open' ? 'partial' : i.state === 'done' ? 'pass' : 'skip'}">${esc(T('iss_' + i.state))}</span>${i.criterion ? ` <span class="id">${esc(i.criterion)}</span>` : ''} <span class="note">${esc(i.title)}</span></li>`).join('')}</ul>` : '';
5022
5085
  let tail = '';
5023
5086
  if (can && ISS.form === n.id) {
5024
5087
  const crit = (n.criteria || []).slice().sort((a, b) => (a.verdict === 'fail' ? 0 : 1) - (b.verdict === 'fail' ? 0 : 1));
@@ -5281,7 +5344,7 @@ function rvEndHtml () {
5281
5344
  const v = RUN.verdict || d.verdict || {};
5282
5345
  const verdict = rvWritable() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${T('runVerdict')}</span><span class="badge st-${v.verdict === 'accepted' ? 'pass' : v.verdict === 'returned' ? 'fail' : 'skip'}">${T(v.verdict || 'pending')}</span></h4><p class="note">${esc(T('rvVerdictLead'))}${returned ? ` ${esc(T('rvReturnedHint'))}` : ''}</p>${v.approvers ? `<p class="note">${v.awaiting?.length ? `${T('awaiting')}: <b>${v.awaiting.map(esc).join(', ')}</b>` : T('approvedByAll')}</p>` : ''}
5283
5346
  <textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" data-draft="${esc(draftKey('run', '', 'verdict'))}" 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">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
5284
- <div class="share__row"><button class="btn btn--primary" data-rv-verdict="accepted">${T('accept')}</button><button class="btn" data-rv-verdict="returned">${T('ret')}</button></div></div>` : '';
5347
+ <div class="share__row">${verdictButtons(v.verdict, 'data-rv-verdict', false)}</div></div>` : '';
5285
5348
  return `<div class="rvm__end"><h3>${esc(head)}</h3><p class="note">${esc(sum)}</p>${d.reference ? `<p class="note">${esc(T('rvComparedWith'))}: <a class="pr" href="${runHref(d.reference.id)}">${fmtDate(d.reference.started_at)}${d.reference.accepted_by ? ` · ${esc(d.reference.accepted_by)}` : ''}</a></p>` : ''}${list}${carried}${verdict}</div>`;
5286
5349
  }
5287
5350
  function rvWire (it, n, v) {
@@ -5414,7 +5477,7 @@ async function rvVerdict (verdict, b) {
5414
5477
  const note = $('rvVNote')?.value.trim() || '';
5415
5478
  if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
5416
5479
  b.disabled = true;
5417
- try { await postJson('/verdict', { verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
5480
+ try { await postJson('/verdict', { verdict, note, seen: RUN.verdict?.at ?? null }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
5418
5481
  }
5419
5482
  /** Starts review mode support once the run is on screen: the button, its count and the hash. */
5420
5483
  function rvInit () {