/* ReturnGuard UI: hash router, forms, live pipeline view, dashboard, evaluation, hero animation. */
'use strict';
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const ANGLES = ['front', 'back', 'side'];
const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const pct = (x, d = 1) => (x == null || isNaN(x)) ? '—' : (x * 100).toFixed(d) + '%';
const store = {
get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* storage unavailable */ } },
};
const VERDICT = {
ACCEPT: 'Accepted', HUMAN_REVIEW: 'Human review', REJECT: 'Rejected',
RETAKE_REQUIRED: 'Retake photos', ERROR: 'Error', PROCESSING: 'Processing',
};
const FRAUD_LABEL = {
substitution: 'Product substitution', label_removed: 'Label removed / unit swap', wardrobing: 'Wardrobing (worn and returned)',
false_damage_claim: 'False damage claim', damage_after_delivery: 'Damaged after delivery',
};
const NODES = [
['intake', 'Intake', 'Background removal, quality gate, features'],
['policy', 'Policy agent', 'Return window and category rules'],
['history', 'Customer history agent', 'Prior returns set the cut-offs'],
['screen', 'Visual screen', 'Object-only DINOv2 + colour, no LLM'],
['triage', 'Triage router', 'Fast path, or call an agent?'],
['identity', 'Identity agent', 'Blind label read, compared in code', true],
['inspector', 'Visual inspector', 'Six photos, independent verdict', true],
['decide', 'Decision agent', 'Evidence-weighted risk score'],
];
/* ── API ─────────────────────────────────────────────────────────────── */
async function api(url, opts) {
const res = await fetch(url, opts);
let data = {};
try { data = await res.json(); } catch (e) { /* non-JSON error page */ }
if (!res.ok || data.success === false) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
/* ── toast / busy ────────────────────────────────────────────────────── */
let toastTimer;
function toast(msg, type = '') {
const t = $('#toast');
t.textContent = msg;
t.className = `toast show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 4500);
}
function busy(on, title = '', sub = '', steps = null) {
$('#busy').classList.toggle('show', on);
$('#busy-spinner').classList.toggle('hidden', on && !!steps);
$('#busy-steps').classList.toggle('hidden', !(on && steps));
if (on) { $('#busy-title').textContent = title; $('#busy-sub').textContent = sub; }
if (on && steps) {
$('#busy-steps').innerHTML = steps.map(s => `
${esc(s.label)}
`).join('');
busyStep(steps[0].key);
}
}
// Mark every step before `key` done and `key` itself active (with an optional detail like "front view (1/3)").
function busyStep(key, detail = '') {
const items = $$('#busy-steps li');
const at = items.findIndex(li => li.dataset.step === key);
if (at < 0) return;
items.forEach((li, i) => {
li.className = i < at ? 'done' : i === at ? 'active' : '';
$('.ls-detail', li).textContent = i === at && detail ? ` · ${detail}` : '';
});
}
const DELIVERY_STEPS = [
{ key: 'upload', label: 'Uploading photos' },
{ key: 'background', label: 'Removing backgrounds' },
{ key: 'quality', label: 'Checking photo quality' },
{ key: 'embeddings', label: 'Computing DINOv2 embeddings and colour' },
{ key: 'save', label: 'Saving the order' },
{ key: 'labels', label: 'Gemini reading labels and serials' },
];
const newJobId = () => (crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`);
/* ── theme ───────────────────────────────────────────────────────────── */
function initTheme() {
const apply = t => {
document.documentElement.setAttribute('data-theme', t);
$('.i-sun').classList.toggle('hidden', t === 'light');
$('.i-moon').classList.toggle('hidden', t !== 'light');
};
apply(document.documentElement.getAttribute('data-theme') || 'dark');
$('#theme-toggle').addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
apply(next);
store.set('rg-theme', next);
});
}
/* ── router ──────────────────────────────────────────────────────────── */
const PAGES = ['home', 'deliver', 'return', 'result', 'dashboard', 'evaluation', 'architecture', 'about', 'account'];
let pollTimer = null;
let lastPage = null;
function route() {
const [page, arg] = location.hash.replace(/^#/, '').split('/');
const name = PAGES.includes(page) ? page : 'home';
// after signing in, go back to the page that asked for it
if (name === 'account' && ['deliver', 'return', 'dashboard'].includes(lastPage)) store.set('rg-after-signin', `#${lastPage}`);
lastPage = name;
$$('.page').forEach(p => p.classList.toggle('active', p.id === `page-${name}`));
$$('[data-nav]').forEach(a => a.classList.toggle('active', a.dataset.nav === (name === 'result' ? 'return' : name)));
$('#mobile-nav').classList.remove('open');
window.scrollTo(0, 0);
clearTimeout(pollTimer);
scene.setVisible(name === 'home');
if (name === 'dashboard') loadDashboard();
if (name === 'evaluation') loadEvaluation();
if (name === 'return') prefillReturn();
if (name === 'deliver' || name === 'return') refreshMe();
if (name === 'account') loadAccount();
if (name === 'result' && /^\d+$/.test(arg || '')) openResult(Number(arg));
}
/* ── uploads & samples ───────────────────────────────────────────────── */
function setPreview(zone, file) {
if (!file || !file.type.startsWith('image/')) return;
zone.querySelector('img.preview')?.remove();
zone.querySelector('.u-tag')?.remove();
const img = document.createElement('img');
img.className = 'preview';
img.alt = '';
img.src = URL.createObjectURL(file);
zone.prepend(img);
const tag = document.createElement('span');
tag.className = 'u-tag';
tag.textContent = zone.querySelector('.u-label').textContent;
zone.append(tag);
zone.classList.add('has-file');
}
function clearPreviews(form) {
$$('.upload', form).forEach(z => { z.classList.remove('has-file'); z.querySelector('img.preview')?.remove(); z.querySelector('.u-tag')?.remove(); });
}
function initUploads() {
$$('.upload').forEach(zone => {
const input = $('input', zone);
input.addEventListener('change', () => setPreview(zone, input.files[0]));
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag');
const f = e.dataTransfer.files[0];
if (!f) return;
const dt = new DataTransfer();
dt.items.add(f);
input.files = dt.files;
setPreview(zone, f);
});
});
}
async function loadSample(set, formId) {
const form = document.getElementById(formId);
try {
for (const a of ANGLES) {
const blob = await (await fetch(`/static/samples/${set}_${a}.jpg`)).blob();
const file = new File([blob], `${set}_${a}.jpg`, { type: 'image/jpeg' });
const input = $(`input[name="${a}_image"]`, form);
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
setPreview(input.closest('.upload'), file);
}
if (formId === 'delivery-form') {
$('#d-product').value = 'Portronics SoundDrum 1 speaker (blue)';
$('#d-category').value = 'electronics';
}
toast('Sample photos loaded. Submit when ready.', 'success');
} catch (e) {
toast('Could not load the sample photos.', 'error');
}
}
function initSamples() {
$$('[data-load]').forEach(b => b.addEventListener('click', e => { e.preventDefault(); loadSample(b.dataset.load, b.dataset.form); }));
$$('[data-sample]').forEach(a => a.addEventListener('click', () => setTimeout(() => loadSample(a.dataset.sample, 'delivery-form'), 60)));
}
/* ── delivery ────────────────────────────────────────────────────────── */
function initDelivery() {
const form = $('#delivery-form');
form.addEventListener('submit', async e => {
e.preventDefault();
if (ANGLES.some(a => !$(`input[name="${a}_image"]`, form).files.length)) return toast('Add all three photos (front, back, side).', 'error');
busy(true, 'Registering delivery', 'Live progress from the server:', DELIVERY_STEPS);
const jobId = newJobId();
const body = new FormData(form);
body.append('job_id', jobId);
let polling = true;
(async function poll() {
while (polling) {
try {
const p = await api(`/api/deliveries/progress/${jobId}`);
if (polling && p.stage) busyStep(p.stage, p.detail);
} catch (e) { /* progress is best effort */ }
await new Promise(r => setTimeout(r, 450));
}
})();
try {
const data = await api('/api/deliveries', { method: 'POST', body });
polling = false;
$$('#busy-steps li').forEach(li => { li.className = 'done'; });
store.set('rg-last-order', data.order.order_id);
renderDelivery(data.order, data.images);
clearPreviews(form);
form.reset();
refreshMe();
toast(`Delivery registered as order #${data.order.order_id}`, 'success');
} catch (err) {
toast(err.message, 'error');
} finally {
polling = false;
busy(false);
}
});
}
function renderDelivery(o, images) {
const fp = o.fingerprint;
const ids = fp?.identifiers?.length
? fp.identifiers.map(i => `${esc(i.text)}`).join('')
: 'None visible';
const list = arr => arr?.length ? arr.map(x => esc(typeof x === 'string' ? x : `${x.description} (${x.view})`)).join('; ') : 'None';
$('#delivery-result').innerHTML = `
Delivery fingerprint saved
#${o.order_id}order ID · ${esc(o.category)}
${fp ? `
${esc(fp.product_description)}
` : `
Gemini could not read the labels for this delivery, so the label check will be skipped for this order. Reason: ${esc(o.fingerprint_error || 'unavailable')}
`}
Identifiers
${ids}
Marks
${list(fp?.distinctive_marks)}
Accessories
${list(fp?.accessories)}
Tags
${list(fp?.tags_attached)}
${ANGLES.map(a => `
${a}
`).join('')}
Process a return for #${o.order_id} →`;
}
/* ── return ──────────────────────────────────────────────────────────── */
function prefillReturn() {
const last = store.get('rg-last-order');
if (last && !$('#r-order').value) $('#r-order').value = last;
}
function initReturn() {
const form = $('#return-form');
form.addEventListener('submit', async e => {
e.preventDefault();
if (!/^#?\d+$/.test($('#r-order').value.trim())) return toast('Enter the numeric order ID from the delivery.', 'error');
if (ANGLES.some(a => !$(`input[name="${a}_image"]`, form).files.length)) return toast('Add all three photos (front, back, side).', 'error');
busy(true, 'Uploading return photos', '');
try {
const data = await api('/api/returns', { method: 'POST', body: new FormData(form) });
clearPreviews(form);
$$('input[type=file]', form).forEach(i => { i.value = ''; });
location.hash = `#result/${data.return_id}`;
} catch (err) {
toast(err.message, 'error');
} finally {
busy(false);
}
});
}
/* ── result (live) ───────────────────────────────────────────────────── */
const LEVEL_BADGE = { good: 'b-ok', ok: 'b-ok', warn: 'b-warn', bad: 'b-bad', info: 'b-dim' };
const LEVEL_ICON = { good: '✓', ok: '✓', warn: '!', bad: '✕', info: 'i' };
async function openResult(id) {
['#res-verdict', '#res-identity', '#res-inspection', '#res-llm', '#res-views'].forEach(s => { $(s).innerHTML = ''; });
$('#res-checks').innerHTML = '
${p.prior_returns} earlier return(s), ${p.prior_flagged} flagged. Your next return is auto-approved below risk ${p.accept_below} (standard: 50) and rejected from ${p.reject_from}.
${kpi(m.f0_5.toFixed(3), 'F0.5 score', 'precision-weighted (β = 0.5)', true)}
${sp ? kpi(pct(sp.precision), 'Precision, stress-tested', `${num(sp.tp)} of ${num(sp.flags)} flags were fraud · 95% interval ${pct(sp.precision_ci[0])}–${pct(sp.precision_ci[1])} · main set ${m.tp}/${m.tp + m.fp}`)
: kpi(pct(m.precision), 'Precision', `${m.tp} of ${m.tp + m.fp} flags were fraud · ${ci(m.precision_ci)}`)}
${kpi(pct(m.recall), 'Recall', `${m.tp} of ${m.tp + m.fn} frauds flagged · ${ci(m.recall_ci)}`)}
${kpi(m.f1.toFixed(3), 'F1 score')}
${kpi(pct(m.false_rejection_rate), 'Genuine returns flagged', `${m.fp} of ${m.fp + m.tn} · ${ci(m.false_rejection_rate_ci)}`)}
${r.held_out ? `
Held-out test · ${r.held_out.items.map(esc).join(' + ')}
${kpi(r.held_out.f0_5.toFixed(3), 'F0.5 on held-out items', `${r.held_out.pairs} pairs (${r.held_out.genuine} genuine, ${r.held_out.fraud} fraud)`, true)}
${kpi(pct(r.held_out.precision), 'Precision', `${r.held_out.tp}/${r.held_out.tp + r.held_out.fp} · ${ci(r.held_out.precision_ci)}`)}
${kpi(pct(r.held_out.recall), 'Recall', `${r.held_out.tp}/${r.held_out.tp + r.held_out.fn} · ${ci(r.held_out.recall_ci)}`)}
${kpi(pct(r.held_out.false_rejection_rate), 'Genuine flagged', `${r.held_out.fp} of ${r.held_out.fp + r.held_out.tn}`)}
${kpi('frozen', 'Thresholds & weights', 'not changed after these items were added')}
` : ''}
Positive class = fraud. A return is "flagged" when it is not auto-accepted (human review, reject${r.retakes?.count ? `, or one of the ${r.retakes.count} retake requests` : ''}). F0.5 weights precision twice as much as recall, because wrongly blocking a genuine customer costs trust. The 95% intervals show how much a small test set can say: a perfect score on a few dozen pairs is still compatible with a noticeably lower true rate.
Each row shows the two strongest pieces of evidence behind the wrong decision. Fixes suggested by these errors are
not applied before this run: they come from the same data, so they need new items to be validated honestly. Full per-pair log: results/eval_pairs.csv.
`
: '
None.
'}
${r.real_recapture ? `
Real re-photographs vs simulated
Genuine pairs
Pairs
Auto-accepted
Flagged
${r.real_recapture.map(x => `
${esc(x.kind)}
${x.pairs}
${x.accepted}
${x.flagged}
`).join('')}
Simulated re-captures re-use the delivery photo, so they are easy. Real ones are separate photo sessions (new place, angle and lighting) and are the honest test.
` : ''}
Read these numbers with care. ${esc(r.dataset.note)} ${r.dataset.pairs} pairs is small, and the thresholds and weights were chosen by looking at the first 6 items${r.held_out ? ' (the held-out items above were added only after that)' : ''}.
Reproduce with python scripts/evaluate.py. Generated ${esc(r.generated_at)} with ${esc(r.model)}.
`;
}
function robustness(s, kpi) {
const p = s.pooled, sum = s.summary;
const byLabel = {};
s.conditions.forEach(c => { (byLabel[c.label] = byLabel[c.label] || {})[c.level] = c; });
const lvl = c => c ? `
${c.fp}/${c.genuine}
${pct(c.precision)}
` : '
—
—
';
return `
Robustness · precision under bad photos
${kpi(pct(p.precision), 'Pooled precision', `${num(p.tp)} of ${num(p.flags)} flags were fraud · 95% interval ${pct(p.precision_ci[0])}–${pct(p.precision_ci[1])}`, true)}
${kpi(pct(p.recall), 'Recall', `every fraud flagged in all ${p.conditions} conditions`)}
${kpi(`${sum.genuine_flagged_by_local_stage}/${sum.genuine_returns_tested}`, 'Genuine returns flagged', `${sum.hard_false_rejections} hard rejections · ${sum.sent_to_gemini} sent to Gemini · ${sum.retakes} retakes`)}
Each return photo distorted ${p.conditions / 2} ways at two strengths (${num(p.pairs)} pairs)
Condition
Mild · genuine flagged
Precision
Strong · genuine flagged
Precision
${Object.entries(byLabel).map(([k, v]) => `
${esc(k)}
${lvl(v.mild)}${lvl(v.strong)}
`).join('')}
Only the local stage (DINOv2 + colour) was run, so a genuine return that would be sent to Gemini counts as a false flag here. That makes these figures a worst case.
The weak spots are strong warm or cool light, very heavy JPEG compression and photos taken from far away. Colour-cast correction and a compression quality gate are the planned fixes.
Precision also depends on how common fraud is: this test has 7 fraud pairs per genuine one, while a real store might see around 15% fraud, which would give a much lower precision for the local stage alone.
Reproduce with python scripts/stress_test_precision.py.
`;
}
/* ── hero animation ──────────────────────────────────────────────────── */
const scene = (() => {
const el = $('#scene');
if (!el) return { setVisible() {} };
const steps = [
{ t: 700, cap: 'A delivery agent brings a new speaker to the customer.', day: 'Day 0 · Delivery' },
{ t: 1900, cap: 'At the door, before handing it over…' },
{ t: 3400, cap: 'the agent photographs it in ReturnGuard: 3 views, label read, fingerprint saved.' },
{ t: 2400, cap: 'The customer receives the blue speaker.' },
{ t: 2600, cap: 'The customer asks for a return and hands back… a speaker.', day: 'Day 5 · Return pickup', fade: true },
{ t: 3600, cap: 'The agent scans it: same model, but the colour is off and the batch label is gone.' },
{ t: 4400, cap: 'Fraud caught at the doorstep. The refund is held and the seller gets the evidence.' },
];
let i = 0, timer = null, playing = !REDUCED_MOTION, visible = true;
const dots = $('#scene-dots');
dots.innerHTML = steps.map((_, k) => ``).join('');
function show(n) {
i = n;
el.dataset.step = n;
$('#scene-agent').classList.toggle('walking', n <= 1);
$('#scene-caption').textContent = steps[n].cap;
const day = steps.slice(0, n + 1).reverse().find(s => s.day);
$('#scene-day').textContent = day.day;
$$('button', dots).forEach((b, k) => b.classList.toggle('on', k === n));
}
function schedule() {
clearTimeout(timer);
if (playing && visible && !document.hidden) timer = setTimeout(next, steps[i].t);
}
function next() {
const n = (i + 1) % steps.length;
if (steps[n].fade) {
el.classList.add('transition');
timer = setTimeout(() => { show(n); el.classList.remove('transition'); schedule(); }, 1400);
} else {
show(n);
schedule();
}
}
$$('button', dots).forEach((b, k) => b.addEventListener('click', () => { show(k); schedule(); }));
$('#scene-toggle').addEventListener('click', () => {
playing = !playing;
$('#scene-toggle').textContent = playing ? 'Pause' : 'Play';
schedule();
});
document.addEventListener('visibilitychange', schedule);
show(REDUCED_MOTION ? 6 : 0);
if (REDUCED_MOTION) $('#scene-toggle').textContent = 'Play';
schedule();
return { setVisible(v) { visible = v; schedule(); } };
})();
/* ── init ────────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
initTheme();
initUploads();
initSamples();
initDelivery();
initReturn();
initAccount();
refreshMe();
$('#menu-btn').addEventListener('click', () => $('#mobile-nav').classList.toggle('open'));
$('#dash-refresh').addEventListener('click', loadDashboard);
window.addEventListener('hashchange', route);
route();
loadHomeMetrics();
});