feat(dashboard): compare mode UI, rating sort/badge, scoreboard modal

Blind two-up compare overlay (keys, reveal, undo, vote queue with debounced save),
cog PAIRWISE REVIEW section, Rating sort option and label badge, scoreboard modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:06:55 -08:00
co-authored by Claude Fable 5.1
parent 02f0766566
commit fadcd0405f
11 changed files with 1204 additions and 5 deletions
+2
View File
@@ -17,10 +17,12 @@ def get_html_template(title, manifest_data, node_id):
js_files = [
"logic_state.js", # Global variables
"logic_utils.js", # API & Helper functions
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
"logic_ui.js", # DOM Creation, Modals, JSON Bars
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
"logic_pipeline.js", # Sorting & Filtering Logic
"logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal
"logic_events.js", # Event Listeners (Message, Keyboard)
"logic_init.js" # Initialization
]
+824
View File
@@ -0,0 +1,824 @@
/**
* PAIRWISE REVIEW - DOM + STATE GLUE
* Compare overlay, vote/undo/reveal flow, persistence queue, scoreboard modal,
* settings bindings, and the rating getters used by the grid (sort + label badge).
* Pure logic lives in logic_ratings.js (no DOM there). Everything here is prefixed
* cmp / _cmp because all logic_*.js files share one inlined <script> scope.
*/
var _cmpSettings = { mode: 'matched', axis: '', reveal: 1000, blind: true, restrict: true };
var _cmp = {
open: false,
src: null,
srcKey: '',
state: { votesById: new Map(), votedPairs: new Set(), skipped: new Set(), allowRepeats: false },
current: null, // {a, b (items), repeating, axis}
next: null, // prefetched pair
revealing: false,
revealTimer: null,
unblind: false,
sessionVotes: 0,
undoStack: [], // votes cast in this compare session (objects living in comparisons)
lastTs: 0,
pairsEstimate: 0
};
// Persistence queue
var _cmpPendingAppend = [];
var _cmpPendingRemove = [];
var _cmpInflightKeys = new Set();
var _cmpFlushTimer = null;
var _cmpInFlight = false;
var _cmpRetryMs = 0;
var _cmpSinceFlush = 0;
var _cmpSess = '';
var _cmpLastServer = []; // last list known to be on disk for _cmpSess
var _cmpVersion = 0; // bumped on any change of fullManifest.comparisons
var _cmpRatingCache = { version: -1, n: -1, byId: new Map() };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function cmpSessionName() {
var el = document.getElementById('session-input');
return (el && el.value) || 'default';
}
function cmpList() {
if (!fullManifest) return [];
if (!Array.isArray(fullManifest.comparisons)) fullManifest.comparisons = [];
return fullManifest.comparisons;
}
function cmpSetStatus(msg, isError) {
var el = document.getElementById('cmp-status');
if (!el) return;
el.textContent = msg || '';
el.style.color = isError ? '#ff5555' : '#666';
}
function cmpBump() {
_cmpVersion++;
}
function cmpItemsMap() {
var m = new Map();
var items = (fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) m.set(items[i].id, items[i]);
return m;
}
// ---------------------------------------------------------------------------
// Ratings (used by the Rating sort and the Rating label badge)
// ---------------------------------------------------------------------------
function cmpRatings() {
var n = fullManifest && fullManifest.items ? fullManifest.items.length : 0;
if (_cmpRatingCache.version !== _cmpVersion || _cmpRatingCache.n !== n) {
var ids = new Set();
var items = (fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) ids.add(items[i].id);
_cmpRatingCache = { version: _cmpVersion, n: n, byId: computeBradleyTerry(cmpList(), ids) };
}
return _cmpRatingCache.byId;
}
function getItemRating(id) {
return cmpRatings().get(id) || null;
}
/** Sort comparator: rated items first (descending), unrated last, never NaN. */
function compareRatings(a, b) {
var ra = getItemRating(a.id), rb = getItemRating(b.id);
if (!ra && !rb) return 0;
if (!ra) return 1;
if (!rb) return -1;
var d = rb.rating - ra.rating;
return isFinite(d) ? d : 0;
}
// ---------------------------------------------------------------------------
// Settings (cog panel)
// ---------------------------------------------------------------------------
function cmpLoadSettings() {
try {
var raw = localStorage.getItem('ultimate_grid_compare');
if (raw) {
var p = JSON.parse(raw);
if (['matched', 'seed', 'one_axis', 'random'].indexOf(p.mode) >= 0) _cmpSettings.mode = p.mode;
if (typeof p.axis === 'string' && (p.axis === '' || CMP_AXES.indexOf(p.axis) >= 0)) _cmpSettings.axis = p.axis;
if ([0, 1000, 2000].indexOf(+p.reveal) >= 0) _cmpSettings.reveal = +p.reveal;
if (typeof p.blind === 'boolean') _cmpSettings.blind = p.blind;
if (typeof p.restrict === 'boolean') _cmpSettings.restrict = p.restrict;
}
} catch (e) { /* ignore */ }
}
function cmpSaveSettings() {
try { localStorage.setItem('ultimate_grid_compare', JSON.stringify(_cmpSettings)); } catch (e) { /* ignore */ }
}
function cmpSyncSettingsUI() {
var set = function (id, fn) { var el = document.getElementById(id); if (el) fn(el); };
set('cmp-mode', function (el) { el.value = _cmpSettings.mode; });
set('cmp-axis', function (el) { el.value = _cmpSettings.axis; });
set('cmp-reveal', function (el) { el.value = String(_cmpSettings.reveal); });
set('cmp-blind', function (el) { el.checked = _cmpSettings.blind; });
set('cmp-restrict', function (el) { el.checked = _cmpSettings.restrict; });
}
function cmpSettingsChanged() {
var v = function (id) { var el = document.getElementById(id); return el; };
var mode = v('cmp-mode'), axis = v('cmp-axis'), reveal = v('cmp-reveal'), blind = v('cmp-blind'), restrict = v('cmp-restrict');
if (mode) _cmpSettings.mode = mode.value;
if (axis) _cmpSettings.axis = axis.value;
if (reveal) _cmpSettings.reveal = +reveal.value;
if (blind) _cmpSettings.blind = blind.checked;
if (restrict) _cmpSettings.restrict = restrict.checked;
cmpSaveSettings();
if (_cmp.open) {
cmpEnsureSource(true);
cmpShowNext(true);
}
}
function initCompareUI() {
cmpLoadSettings();
cmpSyncSettingsUI();
_cmpSess = cmpSessionName();
window.addEventListener('pagehide', function () { cmpFlushNow(true); });
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') cmpFlushNow(true);
});
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
function cmpScheduleFlush() {
if (_cmpFlushTimer) clearTimeout(_cmpFlushTimer);
_cmpFlushTimer = setTimeout(function () { _cmpFlushTimer = null; cmpFlushNow(false); }, 1500);
}
function cmpApplyPending(list) {
var out = mergeComparisons(list, _cmpPendingAppend);
if (_cmpPendingRemove.length) {
var rm = new Set(_cmpPendingRemove);
out = out.filter(function (c) { return !rm.has(cmpVoteKey(c)); });
}
return out;
}
async function cmpFlushNow(keepalive) {
if (_cmpFlushTimer) { clearTimeout(_cmpFlushTimer); _cmpFlushTimer = null; }
if (_cmpInFlight) return;
if (!_cmpPendingAppend.length && !_cmpPendingRemove.length) return;
var sentA = _cmpPendingAppend.slice();
var sentR = _cmpPendingRemove.slice();
var session = _cmpSess || cmpSessionName();
_cmpInFlight = true;
_cmpInflightKeys = new Set(sentA.map(cmpVoteKey));
try {
var res = await fetch('/config_tester/save_comparisons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: !!keepalive,
body: JSON.stringify({ session_name: session, append: sentA, remove: sentR })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
var data = await res.json();
var sentASet = new Set(sentA);
_cmpPendingAppend = _cmpPendingAppend.filter(function (c) { return !sentASet.has(c); });
var sentRSet = new Set(sentR);
_cmpPendingRemove = _cmpPendingRemove.filter(function (k) { return !sentRSet.has(k); });
_cmpSinceFlush = 0;
_cmpRetryMs = 0;
if (session === cmpSessionName()) {
_cmpLastServer = Array.isArray(data.comparisons) ? data.comparisons : _cmpLastServer;
if (fullManifest) fullManifest.comparisons = cmpApplyPending(_cmpLastServer);
cmpBump();
cmpRefreshAfterSync();
}
cmpSetStatus('Votes saved (' + (data.total !== undefined ? data.total : cmpList().length) + ' total)', false);
} catch (e) {
_cmpRetryMs = Math.min(_cmpRetryMs ? _cmpRetryMs * 2 : 2000, 30000);
cmpSetStatus('Saving votes failed (' + (e && e.message || e) + '), retrying in ' + Math.round(_cmpRetryMs / 1000) + 's', true);
if (_cmpFlushTimer) clearTimeout(_cmpFlushTimer);
_cmpFlushTimer = setTimeout(function () { _cmpFlushTimer = null; cmpFlushNow(false); }, _cmpRetryMs);
} finally {
_cmpInFlight = false;
_cmpInflightKeys = new Set();
// votes cast while the request was running
if ((_cmpPendingAppend.length || _cmpPendingRemove.length) && !_cmpFlushTimer) cmpScheduleFlush();
}
}
/** Called after the manifest was replaced (loadSession / full-manifest push). */
function onComparisonsReplaced() {
var now = cmpSessionName();
if (_cmpSess && _cmpSess !== now) {
// Session switched: send what is still queued to the OLD session (best effort), then reset
cmpFlushNow(true);
_cmpPendingAppend = [];
_cmpPendingRemove = [];
_cmpLastServer = [];
if (_cmp.open) cmpCloseCompare(true);
}
_cmpSess = now;
if (fullManifest) {
if (Array.isArray(fullManifest.comparisons)) _cmpLastServer = fullManifest.comparisons;
else fullManifest.comparisons = _cmpLastServer.slice(); // e.g. a generation push without votes
fullManifest.comparisons = cmpApplyPending(fullManifest.comparisons);
}
cmpBump();
cmpRefreshAfterSync();
}
function cmpRefreshAfterSync() {
if (_cmp.open) {
cmpRebuildVoteState();
cmpUpdateProgress();
}
}
function cmpRecordVote(c) {
cmpList().push(c);
_cmpPendingAppend.push(c);
_cmpSess = cmpSessionName();
cmpBump();
_cmpSinceFlush++;
if (_cmpSinceFlush >= 20) cmpFlushNow(false); else cmpScheduleFlush();
}
function cmpRetractVote(c) {
var key = cmpVoteKey(c);
var list = cmpList();
for (var i = list.length - 1; i >= 0; i--) {
if (list[i] === c || cmpVoteKey(list[i]) === key) { list.splice(i, 1); break; }
}
var idx = _cmpPendingAppend.indexOf(c);
if (idx >= 0 && !_cmpInflightKeys.has(key)) _cmpPendingAppend.splice(idx, 1);
else {
if (idx >= 0) _cmpPendingAppend.splice(idx, 1);
_cmpPendingRemove.push(key); // already sent (or in flight): ask the server to drop it
}
cmpBump();
cmpScheduleFlush();
}
async function clearComparisons() {
var n = cmpList().length;
if (!n) { cmpSetStatus('No votes to clear', false); return; }
if (!window.confirm('Delete all ' + n + ' votes saved in this session? This cannot be undone.')) return;
try {
var res = await fetch('/config_tester/clear_comparisons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_name: cmpSessionName() })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
_cmpPendingAppend = [];
_cmpPendingRemove = [];
_cmpLastServer = [];
if (fullManifest) fullManifest.comparisons = [];
_cmp.undoStack = [];
_cmp.state.skipped.clear();
cmpBump();
cmpRefreshAfterSync();
cmpAfterRatingsChanged();
cmpSetStatus('Cleared ' + n + ' votes', false);
} catch (e) {
cmpSetStatus('Clearing votes failed: ' + (e && e.message || e), true);
}
}
function cmpAfterRatingsChanged() {
if (typeof refreshLabelOverlays === 'function') refreshLabelOverlays();
if ((currentSort === 'rating' || currentSecondarySort === 'rating') && typeof updateDataPipeline === 'function') {
updateDataPipeline();
}
}
// ---------------------------------------------------------------------------
// Pair source / queue
// ---------------------------------------------------------------------------
function cmpBuildPool() {
var out = [];
var src = processedData || [];
for (var i = 0; i < src.length; i++) {
var d = src[i];
if (d && d.file && !d.rejected) out.push(d); // a reject is a decision already made
}
return out;
}
function cmpRebuildVoteState() {
var ids = new Set();
var items = (fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) ids.add(items[i].id);
var votesById = new Map(), votedPairs = new Set();
var list = cmpList();
for (var j = 0; j < list.length; j++) {
var c = list[j];
if (!c || !ids.has(c.a) || !ids.has(c.b) || c.a === c.b) continue;
votedPairs.add(cmpPairKey(c.a, c.b));
votesById.set(c.a, (votesById.get(c.a) || 0) + 1);
votesById.set(c.b, (votesById.get(c.b) || 0) + 1);
}
_cmp.state.votesById = votesById;
_cmp.state.votedPairs = votedPairs;
cmpComputeRemaining();
}
function cmpComputeRemaining() {
if (!_cmp.src) { _cmp.pairsEstimate = 0; return; }
var inPool = new Set();
_cmp.src.buckets.forEach(function (b) { b.items.forEach(function (e) { inPool.add(e.id); }); });
var voted = 0;
_cmp.state.votedPairs.forEach(function (k) {
var p = k.split('|');
if (inPool.has(+p[0]) && inPool.has(+p[1])) voted++;
});
_cmp.pairsEstimate = Math.max(0, _cmp.src.estimate - voted);
}
function cmpEnsureSource(force) {
var key = '';
try { key = getFilterKey(); } catch (e) { key = ''; }
key += '|' + activeData.length + '|' + _cmpSettings.mode + '|' + _cmpSettings.axis;
if (!force && _cmp.src && _cmp.srcKey === key) return;
_cmp.srcKey = key;
_cmp.src = buildPairSource(cmpBuildPool(), _cmpSettings.mode, _cmpSettings.axis, { meta: meta });
_cmp.next = null;
cmpRebuildVoteState();
}
function cmpPickPair() {
var p = _cmp.next;
_cmp.next = null;
if (p) {
var k = cmpPairKey(p.a.id, p.b.id);
var stale = _cmp.state.skipped.has(k) || (!p.repeating && _cmp.state.votedPairs.has(k));
if (!stale) return p;
}
return nextPair(_cmp.src, _cmp.state, Math.random);
}
function cmpPrefetch() {
_cmp.next = nextPair(_cmp.src, _cmp.state, Math.random);
if (_cmp.next) {
[_cmp.next.a, _cmp.next.b].forEach(function (d) {
if (d.media_type !== 'video') { var im = new Image(); im.src = d.file; }
});
}
}
// ---------------------------------------------------------------------------
// Overlay rendering
// ---------------------------------------------------------------------------
function cmpModeLabel() {
var m = _cmpSettings.mode;
if (m === 'matched') return 'Matched pairs';
if (m === 'seed') return 'Seed pairs';
if (m === 'random') return 'Random pairs';
return 'One axis' + (_cmpSettings.axis ? ': ' + _cmpSettings.axis : '');
}
function cmpUpdateProgress() {
var el = document.getElementById('cmp-progress');
if (!el) return;
var total = 0;
var list = cmpList();
for (var i = 0; i < list.length; i++) total++;
var poolN = _cmp.src ? _cmp.src.poolSize : 0;
var rep = document.getElementById('cmp-repeat-badge');
if (rep) rep.style.display = (_cmp.current && _cmp.current.repeating) ? '' : 'none';
el.textContent = 'Session ' + _cmp.sessionVotes + ' · Total ' + total + ' · Pool ' + poolN.toLocaleString() +
' items · ~' + Math.round(_cmp.pairsEstimate).toLocaleString() + ' unvoted pairs';
var ml = document.getElementById('cmp-mode-label');
if (ml) ml.textContent = cmpModeLabel();
var ub = document.getElementById('cmp-unblind-btn');
if (ub) ub.classList.toggle('active', _cmp.unblind);
}
function cmpFillMedia(el, d, asVideo) {
el.textContent = '';
var m;
if (asVideo) {
m = document.createElement('video');
m.src = d.file;
m.autoplay = true; m.muted = true; m.loop = true; m.playsInline = true;
m.setAttribute('muted', ''); m.setAttribute('playsinline', '');
var p = m.play && m.play(); if (p && p.catch) p.catch(function () { });
} else {
m = document.createElement('img');
m.src = d.file;
m.draggable = false;
}
el.appendChild(m);
}
function cmpRenderChips(show) {
var ca = document.getElementById('cmp-chips-a'), cb = document.getElementById('cmp-chips-b');
if (ca) ca.textContent = '';
if (cb) cb.textContent = '';
if (!show || !_cmp.current) return;
var A = _cmp.current.a, B = _cmp.current.b;
var df = axisDiff(A, B, { meta: meta });
var sa = itemSig(A, meta), sb = itemSig(B, meta);
var add = function (host, axis, sig) {
var s = document.createElement('span');
s.className = 'cmp-chip';
s.textContent = axis + ': ' + sig.labels[axis];
s.title = axis + ': ' + sig.axes[axis];
host.appendChild(s);
};
df.axes.forEach(function (ax) { add(ca, ax, sa); add(cb, ax, sb); });
if (!df.axes.length || !df.ctxEqual) {
[ca, cb].forEach(function (h) {
var s = document.createElement('span');
s.className = 'cmp-chip';
s.textContent = df.axes.length ? 'other settings differ' : (df.ctxEqual ? 'identical settings' : 'other settings differ');
h.appendChild(s);
});
}
}
function cmpRenderPair() {
var pair = _cmp.current;
var empty = document.getElementById('cmp-empty');
var panes = document.getElementById('cmp-panes');
var bar = document.getElementById('cmp-bar');
if (!pair) {
if (empty) { empty.style.display = 'flex'; }
if (panes) panes.style.visibility = 'hidden';
if (bar) bar.style.visibility = 'hidden';
cmpUpdateProgress();
return;
}
if (empty) empty.style.display = 'none';
if (panes) panes.style.visibility = '';
if (bar) bar.style.visibility = '';
var video = pair.a.media_type === 'video' && pair.b.media_type === 'video';
cmpFillMedia(document.getElementById('cmp-media-a'), pair.a, video);
cmpFillMedia(document.getElementById('cmp-media-b'), pair.b, video);
['a', 'b'].forEach(function (s) {
var p = document.getElementById('cmp-pane-' + s);
if (p) p.classList.remove('cmp-winner', 'cmp-tie');
});
cmpRenderChips(_cmp.unblind || !_cmpSettings.blind);
cmpUpdateProgress();
}
function cmpEmptyMessage() {
var el = document.getElementById('cmp-empty');
if (!el) return;
var pool = _cmp.src ? _cmp.src.poolSize : 0;
var msg = 'No comparable pairs for this mode with the current filters.';
if (_cmp.src && _cmp.src.buckets.length && _cmp.state.skipped.size) {
msg = 'Every remaining pair was skipped in this compare session.';
} else {
var pairs = cmpBuildPool();
var tries = [['matched', ''], ['seed', ''], ['one_axis', ''], ['random', '']];
var best = null;
tries.forEach(function (t) {
if (t[0] === _cmpSettings.mode && t[1] === _cmpSettings.axis) return;
var s = buildPairSource(pairs, t[0], t[1], { meta: meta });
if (s.estimate > 0 && (!best || s.estimate > best.n)) best = { mode: t[0], n: s.estimate };
});
var names = { matched: 'Matched', seed: 'Seed pairs', one_axis: 'One axis', random: 'Random' };
if (best) msg += ' Try the "' + names[best.mode] + '" pairing mode (about ' + Math.round(best.n).toLocaleString() + ' pairs) in Settings > Pairwise Review.';
else msg += ' The pool has ' + pool + ' item(s); widen the filters or generate more images.';
}
el.textContent = msg;
}
function cmpShowNext(rebuilt) {
if (!_cmp.open) return;
_cmp.revealing = false;
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
_cmp.current = _cmp.src ? cmpPickPair() : null;
if (!_cmp.current) cmpEmptyMessage();
cmpRenderPair();
if (_cmp.current) cmpPrefetch();
}
function cmpShowPair(pair) {
_cmp.revealing = false;
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
_cmp.current = pair;
var empty = document.getElementById('cmp-empty');
if (empty) empty.style.display = 'none';
cmpRenderPair();
_cmp.next = null;
cmpPrefetch();
}
// ---------------------------------------------------------------------------
// Vote / skip / undo
// ---------------------------------------------------------------------------
function cmpAdvance() {
cmpShowNext(false);
}
function cmpVoteSide(side) {
if (!_cmp.open) return;
if (_cmp.revealing) { cmpAdvance(); return; } // tap during reveal: advance, no vote
var pair = _cmp.current;
if (!pair || (side !== 'a' && side !== 'b' && side !== 'tie')) return;
var ts = Math.max(Date.now(), _cmp.lastTs + 1);
_cmp.lastTs = ts;
var c = { a: pair.a.id, b: pair.b.id, winner: side, ts: ts, mode: _cmpSettings.mode };
if (!(_cmpSettings.blind && !_cmp.unblind)) c.blind = false;
cmpRecordVote(c);
_cmp.undoStack.push(c);
_cmp.sessionVotes++;
var pk = cmpPairKey(c.a, c.b);
_cmp.state.votedPairs.add(pk);
_cmp.state.votesById.set(c.a, (_cmp.state.votesById.get(c.a) || 0) + 1);
_cmp.state.votesById.set(c.b, (_cmp.state.votesById.get(c.b) || 0) + 1);
cmpComputeRemaining();
cmpUpdateProgress();
var ms = _cmpSettings.reveal;
if (ms > 0) {
_cmp.revealing = true;
var pa = document.getElementById('cmp-pane-a'), pb = document.getElementById('cmp-pane-b');
if (side === 'tie') { pa.classList.add('cmp-tie'); pb.classList.add('cmp-tie'); }
else (side === 'a' ? pa : pb).classList.add('cmp-winner');
cmpRenderChips(true);
_cmp.revealTimer = setTimeout(cmpAdvance, ms);
} else {
cmpAdvance();
}
}
function cmpSkip() {
if (!_cmp.open) return;
if (_cmp.revealing) { cmpAdvance(); return; }
var pair = _cmp.current;
if (!pair) return;
_cmp.state.skipped.add(cmpPairKey(pair.a.id, pair.b.id)); // memory only, never persisted
cmpAdvance();
}
function cmpUndo() {
if (!_cmp.open || !_cmp.undoStack.length) return;
var c = _cmp.undoStack.pop();
cmpRetractVote(c);
_cmp.sessionVotes = Math.max(0, _cmp.sessionVotes - 1);
cmpRebuildVoteState();
var items = cmpItemsMap();
var A = items.get(c.a), B = items.get(c.b);
if (A && B) cmpShowPair({ a: A, b: B, repeating: false, axis: null });
else cmpShowNext(false);
}
function cmpToggleUnblind() {
_cmp.unblind = !_cmp.unblind;
if (_cmp.current && !_cmp.revealing) cmpRenderChips(_cmp.unblind || !_cmpSettings.blind);
cmpUpdateProgress();
}
// ---------------------------------------------------------------------------
// Open / close + keys
// ---------------------------------------------------------------------------
function cmpKeyHandler(e) {
if (!window._compareOpen) return;
var t = e.target && e.target.tagName;
if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return;
var chord = e.ctrlKey || e.metaKey || e.altKey;
var key = e.key;
var isUndoChord = (e.ctrlKey || e.metaKey) && !e.altKey && (key === 'z' || key === 'Z');
if (chord && !isUndoChord) return; // Ctrl+S etc. keep reaching their own handlers
var sbOpen = !!document.getElementById('scoreboard-modal');
var stop = function () { e.preventDefault(); e.stopPropagation(); };
if (key === 'Escape') {
stop();
var sb = document.getElementById('scoreboard-modal');
if (sb) sb.remove(); else closeCompareMode();
return;
}
if (sbOpen) return; // scoreboard on top: only Escape is ours
if (e.repeat) { stop(); return; }
var k = key.length === 1 ? key.toLowerCase() : key;
if (k === 'z' || k === 'Backspace') { stop(); cmpUndo(); return; }
if (_cmp.revealing) {
// any other key during the reveal advances and does not vote
if (key === 'Tab' || key.indexOf('F') === 0 && key.length > 1) return;
stop(); cmpAdvance(); return;
}
switch (k) {
case 'a': case 'ArrowLeft': stop(); cmpVoteSide('a'); return;
case 'd': case 'ArrowRight': stop(); cmpVoteSide('b'); return;
case 's': case 'ArrowDown': stop(); cmpVoteSide('tie'); return;
case 'w': case 'ArrowUp': stop(); cmpSkip(); return;
case 'i': stop(); cmpToggleUnblind(); return;
case 'b': stop(); openScoreboard(); return;
}
// swallow everything else (digits would favourite grid cards, f/r would fullscreen/reload)
if (key.length === 1 || key.indexOf('Arrow') === 0 || key === 'Enter') stop();
}
function openCompareMode() {
if (_cmp.open) return;
if (!fullManifest || !(fullManifest.items || []).length) {
cmpSetStatus('Load a session with images first', true);
return;
}
cmpLoadSettings();
cmpSyncSettingsUI();
// close the settings panel if it is open (the overlay is above it anyway)
var cog = document.getElementById('cog-menu-dropdown');
if (cog && cog.style.display !== 'none' && cog.style.display !== '' && typeof toggleCogMenu === 'function') toggleCogMenu();
_cmpSess = cmpSessionName();
if (!Array.isArray(fullManifest.comparisons)) fullManifest.comparisons = cmpApplyPending(_cmpLastServer.slice());
_cmp.open = true;
_cmp.unblind = false;
_cmp.sessionVotes = 0;
_cmp.undoStack = [];
_cmp.state.skipped = new Set();
_cmp.state.allowRepeats = false;
_cmp.lastTs = 0;
window._compareOpen = true;
window._modalOpen = true;
document.getElementById('compare-overlay').style.display = 'flex';
document.removeEventListener('keydown', window._cmpKeyHandler, true);
window._cmpKeyHandler = cmpKeyHandler;
document.addEventListener('keydown', window._cmpKeyHandler, true);
cmpEnsureSource(true);
cmpShowNext(true);
}
function cmpCloseCompare(silent) {
if (!_cmp.open) return;
_cmp.open = false;
window._compareOpen = false;
var m = document.getElementById('modal');
window._modalOpen = !!(m && m.style.display === 'flex');
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
_cmp.revealing = false;
document.removeEventListener('keydown', window._cmpKeyHandler, true);
window._cmpKeyHandler = null;
var ov = document.getElementById('compare-overlay');
if (ov) ov.style.display = 'none';
['cmp-media-a', 'cmp-media-b'].forEach(function (id) { var el = document.getElementById(id); if (el) el.textContent = ''; });
var sb = document.getElementById('scoreboard-modal');
if (sb && !silent) sb.remove();
cmpFlushNow(false);
if (!silent) cmpAfterRatingsChanged();
}
function closeCompareMode() {
cmpCloseCompare(false);
}
// ---------------------------------------------------------------------------
// Scoreboard modal
// ---------------------------------------------------------------------------
function cmpCopyText(text, btn) {
var done = function () {
if (!btn) return;
var o = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function () { btn.textContent = o; }, 1500);
};
var fallback = function () {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
document.body.removeChild(ta);
done();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fallback);
} else fallback();
}
function cmpCompareMore(axis) {
var sb = document.getElementById('scoreboard-modal');
if (sb) sb.remove();
if (axis === 'seed') { _cmpSettings.mode = 'seed'; _cmpSettings.axis = ''; }
else { _cmpSettings.mode = 'one_axis'; _cmpSettings.axis = axis; }
cmpSaveSettings();
cmpSyncSettingsUI();
if (_cmp.open) { cmpEnsureSource(true); cmpShowNext(true); }
else openCompareMode();
}
function cmpEl(tag, cls, text) {
var el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined) el.textContent = text;
return el;
}
function cmpPct(x) { return (x * 100).toFixed(0) + '%'; }
function openScoreboard() {
var existing = document.getElementById('scoreboard-modal');
if (existing) existing.remove();
var items = cmpItemsMap();
var restrict = null;
if (_cmpSettings.restrict) {
restrict = new Set();
(processedData || []).forEach(function (d) { restrict.add(d.id); });
}
var ratings = cmpRatings();
var sb = computeScoreboard(cmpList(), items, ratings, { restrictIds: restrict, minN: 3, meta: meta });
var overlay = cmpEl('div', 'analytics-modal-overlay');
overlay.id = 'scoreboard-modal';
var popup = cmpEl('div', 'analytics-modal-popup');
var head = cmpEl('div', 'analytics-modal-header');
head.style.borderBottomColor = '#e0c040';
var title = cmpEl('span', 'analytics-modal-title', 'Pairwise scoreboard');
title.style.color = '#e0c040';
var closeBtn = cmpEl('button', 'close-popup-btn');
closeBtn.innerHTML = '&#10005;';
closeBtn.addEventListener('click', function () { overlay.remove(); });
head.appendChild(title); head.appendChild(closeBtn);
popup.appendChild(head);
var summary = cmpEl('div', 'analytics-summary');
summary.appendChild(cmpEl('span', '', sb.total + ' votes' + (restrict ? ' inside the current filter' : '')));
summary.appendChild(cmpEl('span', '', '|'));
summary.appendChild(cmpEl('span', '', 'win % counts ties as half; best = highest 95% Wilson lower bound with n >= 3'));
summary.title = 'Pairs are chosen with a preference for low-vote items and respect your filters, so win % is not a controlled effect size.';
popup.appendChild(summary);
var body = cmpEl('div', 'analytics-modal-body');
var order = CMP_AXES.concat(['seed']);
order.forEach(function (ax) {
var blk = sb.axes[ax];
var det = cmpEl('details', 'sb-axis');
det.open = blk.rows.length > 0;
var sm = cmpEl('summary', '', ax === 'seed' ? 'seed (noise floor)' : ax);
sm.appendChild(cmpEl('span', 'sb-meta', blk.matchedVotes + ' matched votes'));
det.appendChild(sm);
var anyEnough = blk.rows.some(function (r) { return r.enough; });
if (!anyEnough) {
det.appendChild(cmpEl('div', 'sb-empty', 'not enough data (' + blk.matchedVotes + ' matched votes)'));
}
if (blk.rows.length) {
var table = cmpEl('table', 'analytics-table');
var thead = cmpEl('thead');
var hr = cmpEl('tr');
['value', 'n', 'W-L-T', 'win %', '95% CI', 'mean rating', ''].forEach(function (h) { hr.appendChild(cmpEl('th', 'analytics-th', h)); });
thead.appendChild(hr); table.appendChild(thead);
var tb = cmpEl('tbody');
blk.rows.forEach(function (r) {
var tr = cmpEl('tr', 'analytics-row' + (r.enough ? '' : ' sb-row-dim') + (r.value === blk.best ? ' sb-row-best' : ''));
var tdv = cmpEl('td', 'sb-val', r.label); tdv.title = r.value;
tr.appendChild(tdv);
tr.appendChild(cmpEl('td', 'sb-num', String(r.n)));
tr.appendChild(cmpEl('td', 'sb-num', r.wins + '-' + r.losses + '-' + r.ties));
tr.appendChild(cmpEl('td', 'sb-num', r.enough ? cmpPct(r.winPct) : 'not enough data'));
tr.appendChild(cmpEl('td', 'sb-num', r.enough ? cmpPct(r.wilsonLow) + '–' + cmpPct(r.wilsonHigh) : ''));
tr.appendChild(cmpEl('td', 'sb-num', r.meanRating === null ? '' : String(Math.round(r.meanRating))));
tr.appendChild(cmpEl('td', '', ''));
tb.appendChild(tr);
});
table.appendChild(tb);
det.appendChild(table);
}
var more = cmpEl('div', 'sb-link', ax === 'seed' ? 'compare more seeds' : 'compare more');
more.addEventListener('click', function () { cmpCompareMore(ax); });
det.appendChild(more);
body.appendChild(det);
});
popup.appendChild(body);
var foot = cmpEl('div', 'sb-footer');
var seedVotes = sb.axes.seed.matchedVotes;
var txt = sb.attributable + ' of ' + sb.total + ' votes attributable to exactly one axis';
if (seedVotes) txt += ' (+ ' + seedVotes + ' seed-only votes in the noise-floor block)';
foot.appendChild(cmpEl('div', '', txt));
if (sb.topExtraKeys.length) {
foot.appendChild(cmpEl('div', '', 'Other differences in the remaining votes: ' +
sb.topExtraKeys.map(function (k) { return k.key + ' (' + k.count + ')'; }).join(', ')));
}
popup.appendChild(foot);
var actions = cmpEl('div', 'sb-actions');
var bj = cmpEl('button', '', 'Copy JSON'), bc = cmpEl('button', '', 'Copy CSV'), bx = cmpEl('button', '', 'Close');
bj.addEventListener('click', function () { cmpCopyText(JSON.stringify(sb, null, 2), bj); });
bc.addEventListener('click', function () { cmpCopyText(scoreboardToCSV(sb), bc); });
bx.addEventListener('click', function () { overlay.remove(); });
actions.appendChild(bj); actions.appendChild(bc); actions.appendChild(bx);
popup.appendChild(actions);
overlay.appendChild(popup);
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
// close the settings panel so the modal is not stacked over it
var cog = document.getElementById('cog-menu-dropdown');
if (cog && cog.style.display !== 'none' && cog.style.display !== '' && typeof toggleCogMenu === 'function') toggleCogMenu();
}
+1
View File
@@ -85,6 +85,7 @@ window.addEventListener('message', (event) => {
fullManifest = payload.manifest;
activeData = fullManifest.items || [];
meta = payload.meta || {};
if (typeof onComparisonsReplaced === 'function') onComparisonsReplaced();
// Recompute label global values with new data
if (typeof computeLabelGlobalValues === 'function' && labelMode && labelMode.enabled) {
+5
View File
@@ -50,6 +50,11 @@ function init() {
loadLabelPreferences();
}
// Pairwise review settings (cog panel) + vote sync
if (typeof initCompareUI === 'function') {
initCompareUI();
}
// Start
refreshIndices();
+2
View File
@@ -310,6 +310,8 @@ function compareItems(a, b, sortKey) {
return (a.sampler || "").toLowerCase().localeCompare((b.sampler || "").toLowerCase());
case 'size':
return (a.width * a.height) - (b.width * b.height);
case 'rating':
return (typeof compareRatings === 'function') ? compareRatings(a, b) : 0;
default: return 0;
}
}
+4 -2
View File
@@ -54,7 +54,8 @@ const sortOptionsList = [
{ id: 'lora', label: 'LoRA' },
{ id: 'sampler', label: 'Sampler' },
{ id: 'seed', label: 'Seed' },
{ id: 'size', label: 'Size' }
{ id: 'size', label: 'Size' },
{ id: 'rating', label: 'Rating' }
];
// --- SEARCH FILTER STATE ---
@@ -122,7 +123,8 @@ var labelMode = labelMode || {
upscaleHiresSteps: false,
upscaleTiling: false,
hiresPromptBehavior: false,
hiresPromptText: false
hiresPromptText: false,
rating: false // Pairwise-review rating badge
}
};
+16 -2
View File
@@ -295,6 +295,13 @@ function buildLabelOverlay(d) {
}
}
if (labelMode.fields.rating && typeof getItemRating === 'function') {
const rt = getItemRating(d.id);
if (rt && rt.votes > 0) {
tags.push(`<span class="label-tag label-rating" title="Pairwise rating (Bradley-Terry) and number of votes">★${Math.round(rt.rating)} · ${rt.votes}</span>`);
}
}
if (tags.length === 0) return '';
return `<div class="label-overlay">${tags.join('')}</div>`;
}
@@ -320,7 +327,7 @@ function toggleLabelMode(enabled) {
function updateLabelFields() {
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
'hiresPromptBehavior', 'hiresPromptText'];
'hiresPromptBehavior', 'hiresPromptText', 'rating'];
fields.forEach(f => {
const cb = document.getElementById(`label-field-${f}`);
if (cb) labelMode.fields[f] = cb.checked;
@@ -388,7 +395,7 @@ function loadLabelPreferences() {
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
'hiresPromptBehavior', 'hiresPromptText'];
'hiresPromptBehavior', 'hiresPromptText', 'rating'];
fields.forEach(f => {
const cb = document.getElementById(`label-field-${f}`);
if (cb) cb.checked = labelMode.fields[f] || false;
@@ -524,6 +531,13 @@ document.addEventListener('keydown', (e) => {
return;
}
// Close pairwise-review scoreboard
const scoreboardModal = document.getElementById('scoreboard-modal');
if (scoreboardModal) {
scoreboardModal.remove();
return;
}
// Close analytics results modal
const analyticsModal = document.getElementById('analytics-results-modal');
if (analyticsModal) {
+2
View File
@@ -14,6 +14,7 @@ function toggleFullscreen() {
function setupKeyReloadFullscreen() {
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (window._compareOpen) return; // compare mode swallows f / r
switch (e.key) {
case 'f':
@@ -94,6 +95,7 @@ async function loadSession() {
fullManifest = data;
activeData = fullManifest.items || [];
meta = fullManifest.meta || {};
if (typeof onComparisonsReplaced === 'function') onComparisonsReplaced();
console.log(`[Load] 📦 activeData now has ${activeData.length} items`);
+8
View File
@@ -906,6 +906,14 @@ function setupKeyboardShortcuts() {
updateTransform();
updateVisibleItems();
break;
case 'c':
case 'C':
// Pairwise review (compare mode); plain C only, so Ctrl+C copy is untouched
if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof openCompareMode === 'function') {
e.preventDefault();
openCompareMode();
}
break;
}
// 1-9 (no modifiers): Quick favorite — toggle favorite on Nth visible card
+218 -1
View File
@@ -1734,4 +1734,221 @@ body {
.analytics-count {
font-size: 11px;
}
}
}
/* ============================================================
PAIRWISE REVIEW (compare overlay + scoreboard)
============================================================ */
.cmp-setting-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 10px;
color: #999;
}
.cmp-setting-row label { flex: 0 0 auto; user-select: none; }
.cmp-setting-row select {
flex: 1;
min-width: 0;
background: #1a1a1a;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
padding: 4px;
font-size: 11px;
}
#compare-overlay {
position: fixed;
inset: 0;
z-index: 9000;
background: #050505;
flex-direction: column;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
#cmp-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #0a0a0a;
border-bottom: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
font-size: 11px;
color: #aaa;
}
#cmp-mode-label { font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
#cmp-progress { color: #888; }
#cmp-repeat-badge {
background: #7a4a00;
color: #ffd27a;
border-radius: 3px;
padding: 1px 6px;
font-size: 10px;
}
.cmp-spacer { flex: 1; }
.cmp-head-btn {
background: #1a1a1a;
color: #ccc;
border: 1px solid #444;
border-radius: 4px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
}
.cmp-head-btn:hover { border-color: var(--accent); color: var(--accent); }
.cmp-head-btn.active { background: #3a2f00; border-color: #e0c040; color: #e0c040; }
#cmp-panes {
flex: 1;
min-height: 0;
display: flex;
gap: 6px;
padding: 6px;
}
.cmp-pane {
flex: 1 1 50%;
min-width: 0;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #0d0d0d;
border: 3px solid transparent;
border-radius: 6px;
cursor: pointer;
overflow: hidden;
}
.cmp-pane.cmp-winner { border-color: #2ecc71; }
.cmp-pane.cmp-tie { border-color: #888; }
.cmp-media { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
.cmp-media img, .cmp-media video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
}
.cmp-label {
position: absolute;
top: 8px;
left: 10px;
z-index: 2;
font-size: 18px;
font-weight: 800;
color: rgba(255, 255, 255, 0.55);
text-shadow: 0 1px 4px #000;
pointer-events: none;
}
.cmp-chips {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
pointer-events: none;
}
.cmp-chips:empty { display: none; }
.cmp-chip {
background: rgba(0, 0, 0, 0.75);
border: 1px solid #555;
color: #fff;
border-radius: 3px;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#cmp-empty {
position: absolute;
left: 0;
right: 0;
top: 48px;
bottom: 64px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
color: #aaa;
font-size: 14px;
line-height: 1.6;
background: #050505;
z-index: 3;
}
#cmp-bar {
display: flex;
gap: 8px;
justify-content: center;
padding: 8px;
background: #0a0a0a;
border-top: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
.cmp-btn {
min-height: 44px;
min-width: 88px;
padding: 8px 16px;
background: #1c1c1c;
color: #eee;
border: 1px solid #555;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.cmp-btn:hover { border-color: var(--accent); }
.cmp-btn:active { background: #2a2a2a; }
.cmp-btn-undo { background: #14202a; }
@media (max-aspect-ratio: 1/1) and (max-width: 700px) {
#cmp-panes { flex-direction: column; }
.cmp-pane { flex: 1 1 50%; }
.cmp-btn { min-width: 0; flex: 1; padding: 8px 6px; font-size: 12px; }
}
.label-tag.label-rating { background: rgba(110, 90, 0, 0.7); }
/* Scoreboard (reuses the analytics modal classes) */
.sb-axis { border-bottom: 1px solid #222; }
.sb-axis > summary {
cursor: pointer;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
color: #ddd;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #0d0d0d;
}
.sb-axis > summary .sb-meta { color: #777; font-weight: 400; text-transform: none; margin-left: 8px; font-size: 10px; }
.sb-row-dim td { opacity: 0.45; }
.sb-row-best { background: rgba(0, 209, 178, 0.14); }
.sb-row-best td { color: #fff; }
.sb-num { text-align: right; font-family: 'Consolas', monospace; white-space: nowrap; padding: 5px 10px; }
.sb-val { padding: 5px 10px; color: #ddd; font-family: 'Consolas', monospace; font-size: 10px; word-break: break-all; }
.sb-link { color: var(--accent-sch); cursor: pointer; text-decoration: underline; font-size: 10px; padding: 5px 10px; white-space: nowrap; }
.sb-empty { padding: 10px 14px; color: #777; font-size: 11px; }
.sb-footer { padding: 8px 14px; font-size: 10px; color: #888; background: #0d0d0d; border-top: 1px solid #222; flex-shrink: 0; }
.sb-actions { display: flex; gap: 6px; padding: 8px 14px; flex-shrink: 0; border-top: 1px solid #222; background: #0a0a0a; }
.sb-actions button {
background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 6px 12px; font-size: 11px; cursor: pointer;
}
.sb-actions button:hover { border-color: var(--accent); color: var(--accent); }
+122
View File
@@ -45,6 +45,13 @@
</div>
</div>
<button class="fs-btn" id="compare-open-btn" onclick="openCompareMode()" title="Compare (C)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="8" height="16" rx="1.5"></rect>
<rect x="14" y="4" width="8" height="16" rx="1.5"></rect>
</svg>
</button>
<button class="fs-btn" onclick="loadSession()" title="Reload Session (R)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
@@ -215,6 +222,10 @@
<input type="checkbox" id="label-field-hiresPromptText" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-hiresPromptText" style="font-size: 10px; color: #d06080; cursor: pointer; user-select: none;">HiRes Prompt Text</label>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<input type="checkbox" id="label-field-rating" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-rating" style="font-size: 10px; color: #e0c040; cursor: pointer; user-select: none;" title="Pairwise rating and vote count (voted cards only)">Rating</label>
</div>
</div>
<div style="margin-top: 8px; display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="label-unique-only" checked
@@ -324,6 +335,71 @@
<div id="delete-status" style="margin-top: 4px; font-size: 10px; color: #666; min-height: 16px;"></div>
</div>
<!-- Pairwise review (blind A/B compare + scoreboard) -->
<div class="cog-panel-section">
<div class="cog-panel-section-title">PAIRWISE REVIEW</div>
<div style="font-size: 9px; color: #666; margin-bottom: 8px;">
Blind two-up compare. Votes are saved in the session manifest and rolled up per setting.
</div>
<button class="session-action-btn"
style="background: #e0a020; color: #000; width: 100%; font-size: 11px; padding: 10px; margin-bottom: 6px;"
onclick="openCompareMode()" title="Start comparing pairs (C)">
START COMPARE (C)
</button>
<button class="session-action-btn"
style="background: #3e8ed0; width: 100%; font-size: 11px; padding: 10px; margin-bottom: 8px;"
onclick="openScoreboard()" title="Win rates per setting from all votes">
SCOREBOARD
</button>
<div class="cmp-setting-row">
<label for="cmp-mode">Pairing</label>
<select id="cmp-mode" onchange="cmpSettingsChanged()">
<option value="matched">Matched (same prompt + seed)</option>
<option value="seed">Same settings, different seed</option>
<option value="one_axis">One axis at a time</option>
<option value="random">Random (same media type)</option>
</select>
</div>
<div class="cmp-setting-row">
<label for="cmp-axis">Axis focus</label>
<select id="cmp-axis" onchange="cmpSettingsChanged()">
<option value="">Any axis</option>
<option value="model">Model</option>
<option value="lora">LoRA</option>
<option value="sampler">Sampler</option>
<option value="scheduler">Scheduler</option>
<option value="cfg">CFG</option>
<option value="steps">Steps</option>
<option value="denoise">Denoise</option>
<option value="resolution">Resolution</option>
<option value="prompt">Prompt</option>
<option value="upscale">Upscale</option>
</select>
</div>
<div class="cmp-setting-row">
<label for="cmp-reveal">Reveal after vote</label>
<select id="cmp-reveal" onchange="cmpSettingsChanged()">
<option value="0">Off</option>
<option value="1000" selected>1 s</option>
<option value="2000">2 s</option>
</select>
</div>
<div class="cmp-setting-row">
<input type="checkbox" id="cmp-blind" checked onchange="cmpSettingsChanged()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="cmp-blind" style="cursor: pointer;">Blind (hide settings until you vote)</label>
</div>
<div class="cmp-setting-row">
<input type="checkbox" id="cmp-restrict" checked onchange="cmpSettingsChanged()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="cmp-restrict" style="cursor: pointer;">Scoreboard: only votes inside the current filter</label>
</div>
<button class="session-action-btn"
style="background: #cc2222; width: 100%; font-size: 11px; padding: 8px; margin-top: 8px; border: 2px solid #ff4444;"
onclick="clearComparisons()" title="Delete every vote saved in this session">
CLEAR VOTES
</button>
<div id="cmp-status" style="margin-top: 6px; font-size: 10px; color: #666; min-height: 14px;"></div>
</div>
<!-- Section 5: Manifest Analytics -->
<div class="cog-panel-section">
<div class="cog-panel-section-title">MANIFEST ANALYTICS</div>
@@ -406,6 +482,18 @@
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">R</td>
<td style="padding: 4px 8px; color: #ccc;">Reload session</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">C</td>
<td style="padding: 4px 8px; color: #ccc;">Compare mode (blind A/B review)</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">A/D, S, W</td>
<td style="padding: 4px 8px; color: #ccc;">In compare: A wins / B wins, tie, skip (arrows work too)</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">Z, I, B</td>
<td style="padding: 4px 8px; color: #ccc;">In compare: undo, unblind, scoreboard</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">1-9</td>
<td style="padding: 4px 8px; color: #ccc;">Quick favorite Nth visible card</td>
@@ -684,6 +772,40 @@
onclick="selectJSON('json-bar-bad')">[]</div>
</div>
<!-- Pairwise review overlay (see logic_compare.js) -->
<div id="compare-overlay" style="display: none;">
<div id="cmp-head">
<span id="cmp-mode-label"></span>
<span id="cmp-progress"></span>
<span id="cmp-repeat-badge" style="display: none;">repeating voted pairs</span>
<span class="cmp-spacer"></span>
<button class="cmp-head-btn" id="cmp-unblind-btn" onclick="cmpToggleUnblind()" title="Show what differs before you vote (I)">Unblind (I)</button>
<button class="cmp-head-btn" onclick="openScoreboard()" title="Scoreboard (B)">Scoreboard (B)</button>
<button class="cmp-head-btn" onclick="cmpUndo()" title="Undo last vote (Z)">Undo (Z)</button>
<button class="cmp-head-btn cmp-close" onclick="closeCompareMode()" title="Close (Esc)">&#10005;</button>
</div>
<div id="cmp-panes">
<div class="cmp-pane" id="cmp-pane-a" onclick="cmpVoteSide('a')">
<div class="cmp-label">A</div>
<div class="cmp-media" id="cmp-media-a"></div>
<div class="cmp-chips" id="cmp-chips-a"></div>
</div>
<div class="cmp-pane" id="cmp-pane-b" onclick="cmpVoteSide('b')">
<div class="cmp-label">B</div>
<div class="cmp-media" id="cmp-media-b"></div>
<div class="cmp-chips" id="cmp-chips-b"></div>
</div>
</div>
<div id="cmp-empty" style="display: none;"></div>
<div id="cmp-bar">
<button class="cmp-btn" onclick="cmpVoteSide('a')">&#9664; A wins</button>
<button class="cmp-btn" onclick="cmpVoteSide('tie')">Tie</button>
<button class="cmp-btn" onclick="cmpSkip()">Skip</button>
<button class="cmp-btn" onclick="cmpVoteSide('b')">B wins &#9654;</button>
<button class="cmp-btn cmp-btn-undo" onclick="cmpUndo()">&#8630; Undo</button>
</div>
</div>
<div id="modal">
<button class="modal-close-x" onclick="closeM()" title="Close (Esc)">&times;</button>
<!-- Lightbox navigation bar -->