Hugdrigo's picture
Upload 3 files
7a99d91 verified
Raw History Blame Contribute Delete
48.8 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#101d32"><meta name="description" content="FraudLens is an offline, explainable transaction-risk simulation and investigation workspace. All examples are synthetic.">
<title>FraudLens | Transaction Risk Intelligence Studio</title>
<style>
:root{font:13px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;--bg:#f3f6fa;--paper:#fff;--ink:#172740;--muted:#6a7890;--edge:#e2e9f1;--soft:#f5f7fb;--nav:#101d32;--accent:#534fd8;--teal:#14a897;--orange:#de9b40;--red:#d95d6b;--shadow:0 14px 40px #1725400a}
body.dark{color-scheme:dark;--bg:#0b1423;--paper:#152339;--ink:#eef3fb;--muted:#9bacc3;--edge:#293c54;--soft:#1d3046;--accent:#a5a1ff;--teal:#64e0ca;--orange:#ffca77;--red:#ff9a9e;--shadow:none}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #56daca;outline-offset:2px}a{color:var(--accent)}small{font-size:10px}.shell{display:grid;grid-template-columns:235px minmax(0,1fr);min-height:100vh}.sidebar{background:radial-gradient(circle at top left,#24375e,transparent 49%),linear-gradient(165deg,#121e35,#101a2e);color:#eaf4ff;position:sticky;top:0;height:100vh;padding:27px 16px 18px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:11px;margin:0 7px 35px}.mark{height:42px;width:42px;display:grid;place-items:center;border-radius:13px;background:linear-gradient(130deg,#a9f8e3,#8aa8ff);color:#12314c;font-size:24px;font-weight:950}.brand strong{font-size:18px;letter-spacing:-.7px}.brand b{color:#86eadc}.brand small{display:block;color:#9aaec7;text-transform:uppercase;letter-spacing:1.5px;font-size:8px;font-weight:900}.nav-heading{color:#8699b8;margin:0 12px 15px;font-size:9px;font-weight:900;letter-spacing:2px}.nav{display:grid;gap:6px}.nav button{background:transparent;text-align:left;color:#b1c0d3;border:1px solid transparent;border-radius:10px;padding:12px;display:flex;gap:12px;align-items:center;font-weight:720;font-size:11px}.nav button span{font-size:18px;width:23px;font-weight:400}.nav button:hover{color:#fff;background:#263754}.nav button.active{background:#303c61;color:white;border-color:#485d8a}.sidebar-bottom{margin-top:auto;border-top:1px solid #32435e;padding:17px 9px 0;color:#9caec8;font-size:10px}.live-dot{display:inline-block;height:7px;width:7px;background:#77efc8;border-radius:50%;margin-right:8px}.sidebar-bottom strong{color:#acf5dd}.main{width:100%;max-width:1660px;margin:0 auto;padding:25px clamp(15px,3vw,46px) 60px;min-width:0}.top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:19px}.crumb{font-size:10px;font-weight:900;letter-spacing:1.5px;color:var(--muted)}.crumb strong{color:var(--ink)}.top-actions,.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.btn{border:1px solid var(--edge);border-radius:9px;background:var(--paper);color:var(--ink);font-size:11px;font-weight:800;padding:9px 13px;white-space:nowrap}.btn:hover,.mini:hover{border-color:var(--teal)}.btn.primary{background:#5552d7;color:white;border-color:#5552d7}.btn.ghost{background:transparent}.btn.warn{color:var(--red)}.btn.icon{font-size:16px;min-width:36px;min-height:35px;padding:5px}.avatar{border-radius:50%;background:#223f55;color:#95f9dc;height:35px;width:35px;display:grid;place-items:center;font-size:10px;font-weight:900}.view{display:none}.view.active{display:block}.hero{display:flex;justify-content:space-between;gap:25px;align-items:center;padding:28px 30px;border-radius:19px;background:linear-gradient(110deg,#172544,#353e80 65%,#157d84);color:#fff;position:relative;overflow:hidden}.hero:after{content:"";position:absolute;right:-85px;top:-175px;border:1px solid #ffffff30;border-radius:50%;width:430px;height:430px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff07;pointer-events:none}.eyebrow{font-size:10px;letter-spacing:1.7px;text-transform:uppercase;font-weight:900;color:#6ae6d1}.hero h1{letter-spacing:-1.25px;font-size:clamp(25px,3vw,36px);line-height:1.18;margin:8px 0 10px}.hero p{color:#d5dff4;max-width:565px;font-size:11.5px;margin:0}.hero .actions{margin-top:19px}.hero .btn{background:#e8fff7;border-color:#b6f4df;color:#176777}.hero .btn.alt{background:#ffffff16;color:white;border-color:#ffffff50}.hero-side{min-width:188px;z-index:1;border:1px solid #ffffff40;border-radius:15px;padding:17px;background:#ffffff16}.hero-side small{display:block;color:#d0e9f0}.hero-side strong{display:block;font-size:33px;letter-spacing:-1.2px;margin:3px 0}.hero-side em{color:#abffe4;font-size:10px;font-style:normal;font-weight:800}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin:16px 0}.metric,.card{background:var(--paper);border:1px solid var(--edge);border-radius:15px;box-shadow:var(--shadow);min-width:0}.metric{padding:17px}.metric .label{display:flex;justify-content:space-between;gap:6px;font-size:10px;font-weight:750;color:var(--muted)}.metric .label b{font-size:16px;color:var(--teal)}.metric strong{display:block;font-size:clamp(23px,2.6vw,31px);margin:7px 0 2px;letter-spacing:-1px}.metric small{color:var(--muted)}.rowhead{display:flex;justify-content:space-between;gap:14px;align-items:flex-end;margin:25px 0 13px}.rowhead h2{margin:0;font-size:17px;letter-spacing:-.4px}.rowhead p{margin:4px 0 0;color:var(--muted);font-size:11px}.grid2{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(275px,1fr);gap:15px}.halves{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.card{padding:19px}.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:15px}.card-head h3{margin:0;font-size:13px}.card-head p{font-size:10px;color:var(--muted);margin:4px 0 0}.pill{font-size:9px;font-weight:900;padding:5px 8px;border-radius:7px;background:var(--soft);border:1px solid var(--edge);color:var(--teal);white-space:nowrap}.risk{display:inline-block;border-radius:20px;padding:5px 9px;font-size:9px;font-weight:900;white-space:nowrap}.risk.high{color:#c84d5d;background:#fff0f1}.risk.mid{color:#ae791c;background:#fff6e7}.risk.low{color:#138d81;background:#eaf9f5}body.dark .risk.high{background:#59313b;color:#ffafb6}body.dark .risk.mid{background:#553e22;color:#ffcd84}body.dark .risk.low{background:#12433f;color:#9ce8d8}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;white-space:nowrap;text-align:left;font-size:11px}th{text-transform:uppercase;letter-spacing:.8px;font-size:9px;font-weight:900;color:var(--muted);padding:12px;border-bottom:1px solid var(--edge)}td{border-bottom:1px solid var(--edge);padding:12px}tbody tr:last-child td{border-bottom:none}.num{text-align:right;font-variant-numeric:tabular-nums}.mini{padding:6px 9px;border:1px solid var(--edge);border-radius:7px;background:var(--soft);color:var(--ink);font-weight:850;font-size:10px;cursor:pointer}.mini.red{color:var(--red)}.empty{padding:35px;text-align:center;color:var(--muted);font-size:11px}.chart{width:100%;height:auto;max-height:270px;display:block}.chart text{font-family:inherit;fill:var(--muted);font-size:10px}.legend{display:flex;flex-wrap:wrap;gap:13px;margin-top:9px;color:var(--muted);font-size:10px}.legend i{height:9px;width:9px;display:inline-block;border-radius:3px;margin-right:5px;background:var(--accent)}.legend .red i{background:var(--red)}.legend .teal i{background:var(--teal)}.baritem{margin-bottom:14px}.barhead{display:flex;justify-content:space-between;font-size:10px;margin-bottom:6px}.barhead span{color:var(--muted)}.track{background:var(--soft);border-radius:20px;height:9px;overflow:hidden}.fill{background:linear-gradient(90deg,#6c6bfc,#25b3b0);height:100%;border-radius:20px}.intro{display:flex;align-items:end;justify-content:space-between;gap:14px;margin:5px 0 22px}.intro h1{margin:5px 0;font-size:29px;letter-spacing:-.8px}.intro p{margin:0;color:var(--muted);font-size:11.5px;max-width:660px}.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.filters input,.filters select,.field input,.field select{min-width:0;width:100%;border:1px solid var(--edge);border-radius:9px;background:var(--paper);color:var(--ink);padding:10px 11px;font-size:11px}.filters input{max-width:270px}.filters select{width:155px}.casegrid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(270px,1fr);gap:14px}.case{position:sticky;top:10px;align-self:start}.detail{background:var(--soft);border:1px solid var(--edge);border-radius:12px;padding:14px}.detail h3{margin:0 0 7px;font-size:14px}.detail p{color:var(--muted);font-size:10px}.detailline{display:flex;justify-content:space-between;gap:8px;padding:9px 0;border-bottom:1px solid var(--edge);font-size:11px}.detailline:last-child{border:0}.detailline span{color:var(--muted)}.signal{margin:8px 0;border-radius:8px;padding:9px 10px;background:var(--paper);display:flex;justify-content:space-between;gap:5px;font-size:10px}.signal span{color:var(--muted)}.control{margin-bottom:18px}.control label{display:flex;justify-content:space-between;gap:10px;font-size:11px;font-weight:800;margin-bottom:10px}.control label span{color:var(--accent)}input[type=range]{width:100%;accent-color:#615bdc}.presets{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-bottom:15px}.preset{padding:15px;min-width:0;text-align:left;background:var(--paper);color:var(--ink);border:1px solid var(--edge);border-radius:12px}.preset.selected{outline:2px solid var(--teal);background:var(--soft)}.preset b{display:block;font-size:11px;margin:7px 0 4px}.preset small{display:block;color:var(--muted);font-size:10px}.preset .emoji{font-size:22px}.minikpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin:12px 0}.minikpi{padding:14px;background:var(--soft);border:1px solid var(--edge);border-radius:10px}.minikpi small{display:block;color:var(--muted)}.minikpi b{font-size:22px}.matrix{display:grid;grid-template-columns:90px 1fr 1fr;gap:8px;align-items:stretch;text-align:center;font-size:10px}.matrix > div{padding:14px 5px;border-radius:8px;background:var(--soft)}.matrix .head{display:grid;place-items:center;font-weight:900;color:var(--muted)}.matrix strong{font-size:25px;display:block}.matrix .correct{background:#e7f8f2;color:#147b6c}.matrix .incorrect{background:#fff0ef;color:#a24d56}body.dark .matrix .correct{background:#174239;color:#9be8c7}body.dark .matrix .incorrect{background:#4e2938;color:#ffb4bb}.notice{border:1px solid var(--edge);border-radius:10px;background:var(--soft);padding:12px;color:var(--muted);font-size:10.5px;margin:12px 0}.notice b{color:var(--ink)}.field{display:grid;gap:5px}.field label{color:var(--muted);font-weight:750;font-size:10px}.formgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.checkline{display:flex;align-items:center;gap:9px;font-size:11px}.checkline input{accent-color:#5b5ae2}.modalback{position:fixed;inset:0;background:#08152cc0;display:none;align-items:center;justify-content:center;padding:15px;z-index:20}.modalback.open{display:flex}.modal{width:100%;max-width:640px;max-height:min(90vh,870px);overflow:auto;border-radius:17px;background:var(--paper);color:var(--ink);box-shadow:0 20px 70px #0005;padding:24px}.modal h2{margin:0 0 15px;font-size:20px}.modal footer{display:flex;justify-content:flex-end;gap:9px;margin-top:19px}.toast{position:fixed;right:20px;bottom:18px;background:#182b42;color:#fff;box-shadow:0 9px 33px #0005;border-radius:10px;padding:12px 17px;z-index:30;opacity:0;transform:translateY(10px);transition:.2s;pointer-events:none;font-size:11px}.toast.show{opacity:1;transform:translateY(0)}.fineprint{margin-top:20px;color:var(--muted);font-size:10px}.methodgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.methodgrid h3{margin:0 0 7px;font-size:13px}.methodgrid p,.methodgrid li{color:var(--muted);font-size:11px}.methodgrid ul{padding-left:18px}.type-label{font-weight:850;color:var(--teal)}.sm-note{color:var(--muted);font-size:10px}.inline-note{font-size:9px;color:var(--muted)}
@media(max-width:1100px){.shell{grid-template-columns:195px minmax(0,1fr)}.grid2,.casegrid{grid-template-columns:1fr}.case{position:static}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:740px){.shell{display:block}.sidebar{position:static;width:100%;height:auto;padding:13px 15px}.brand{margin:0 0 11px}.brand strong{font-size:16px}.mark{height:35px;width:35px}.nav-heading,.sidebar-bottom{display:none}.nav{display:flex;overflow:auto;gap:5px}.nav button{flex-shrink:0;padding:8px 11px;font-size:10px}.nav button span{font-size:15px;width:auto}.main{padding:16px 13px 40px}.top{flex-wrap:wrap}.crumb{font-size:9px}.hero{padding:23px 20px}.hero-side{display:none}.hero h1{font-size:25px}.metrics{gap:9px}.metric{padding:13px}.metric strong{font-size:24px}.halves,.methodgrid{grid-template-columns:1fr}.presets{grid-template-columns:repeat(2,minmax(0,1fr))}.minikpis{grid-template-columns:repeat(2,minmax(0,1fr))}.card{padding:14px}.intro{display:block}.intro h1{font-size:25px}.top-actions .btn:first-child{display:none}.matrix{grid-template-columns:65px 1fr 1fr}.matrix > div{padding:12px 3px}.formgrid{grid-template-columns:1fr}.filters input{max-width:none}.filters select{flex:1;width:140px}.modal{padding:18px}.top .avatar{display:none}}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand"><div class="mark">◇</div><div><strong>Fraud<b>Lens</b></strong><small>Transaction risk studio</small></div></div>
<p class="nav-heading">WORKSPACE</p>
<nav class="nav" aria-label="Main navigation">
<button class="active" data-view="overview"><span>▦</span> Overview</button>
<button data-view="review"><span>◉</span> Investigation queue</button>
<button data-view="lab"><span>◈</span> Threshold laboratory</button>
<button data-view="workspace"><span>▤</span> Transactions</button>
<button data-view="method"><span>◎</span> Methodology</button>
</nav>
<div class="sidebar-bottom"><span class="live-dot"></span><strong>Offline simulator</strong><p>PROJECT 090 · HUGDRIGO<br>All records and labels are synthetic. No real payment processing.</p></div>
</aside>
<main class="main">
<header class="top"><div class="crumb">HUGDRIGO / PROJECT 090 / <strong id="crumbView">OVERVIEW</strong></div><div class="top-actions"><button class="btn" id="exportTop">⇩ Export backup</button><button class="btn icon" id="themeBtn" aria-label="Toggle color theme">☾</button><button class="btn primary" data-add>+ Add transaction</button><span class="avatar">HF</span></div></header>
<section class="view active" id="view-overview">
<div class="hero"><div><div class="eyebrow">EXPLAINABLE RISK INTELLIGENCE</div><h1>Turn signals into decisions.</h1><p>Explore transaction risk with transparent scoring, a live review queue and synthetic ground-truth metrics. Built for learning, not real-world payment decisions.</p><div class="actions"><button class="btn" data-go="review">Review high-risk activity ↗</button><button class="btn alt" data-go="lab">Open threshold lab →</button></div></div><div class="hero-side"><small>Current alert threshold</small><strong id="heroThreshold">60</strong><em>Adjustable in the laboratory</em></div></div>
<div class="metrics" id="overviewMetrics"></div>
<div class="grid2"><div class="card"><div class="card-head"><div><h3>Risk distribution</h3><p>Active synthetic transactions grouped by rule-based score</p></div><span class="pill">LIVE MODEL</span></div><div id="riskChart"></div><div class="legend"><span><i></i>Below threshold</span><span class="red"><i></i>At or above threshold</span></div></div><div class="card"><div class="card-head"><div><h3>Primary risk signals</h3><p>Number of active records with each contributing signal</p></div><span class="pill">EXPLAINABLE</span></div><div id="signalBars"></div></div></div>
<div class="rowhead"><div><h2>Priority investigations</h2><p>Highest-scoring active records needing analyst attention</p></div><button class="btn" data-go="review">View all cases →</button></div>
<div class="card table-wrap" id="priorityTable"></div>
</section>
<section class="view" id="view-review"><div class="intro"><div><div class="eyebrow">INVESTIGATION DESK</div><h1>Review with context.</h1><p>Every score has an auditable explanation. Change case statuses without affecting the model's synthetic ground-truth label.</p></div><button class="btn primary" data-add>+ Add transaction</button></div>
<div class="filters"><input id="reviewSearch" type="search" placeholder="Search merchant or transaction ID" aria-label="Search transactions"><select id="reviewRisk" aria-label="Filter alert state"><option value="all">All risk scores</option><option value="alert">Alerts only</option><option value="clear">Below threshold</option></select><select id="reviewStatus" aria-label="Filter case status"><option value="all">All case statuses</option><option value="new">New</option><option value="investigating">Investigating</option><option value="closed">Closed</option></select></div>
<div class="casegrid"><div class="card table-wrap" id="reviewTable"></div><div class="card case" id="caseDetail"><div class="empty">Select a transaction to inspect its risk signals.</div></div></div>
</section>
<section class="view" id="view-lab"><div class="intro"><div><div class="eyebrow">THRESHOLD LABORATORY</div><h1>Explore detection trade-offs.</h1><p>Change alert cutoffs and simulated attack conditions. Precision and recall are calculated only against fictional demonstration labels.</p></div><button class="btn" id="resetLab">↺ Reset scenario</button></div>
<div class="rowhead"><div><h2>Scenario presets</h2><p>Apply synthetic changes to the same stored transactions</p></div></div><div class="presets" id="presetGrid"></div>
<div class="grid2"><div class="card"><div class="card-head"><div><h3>Risk rule controls</h3><p>All modifications are visible and reversible</p></div><span class="pill">WHAT IF?</span></div>
<div class="control"><label for="threshold">Alert cutoff <span id="thresholdOut">60 / 100</span></label><input id="threshold" type="range" min="0" max="100" step="1"></div>
<div class="control"><label for="velocity">Velocity multiplier <span id="velocityOut">1.0×</span></label><input id="velocity" type="range" min="0.5" max="3" step="0.1"></div>
<div class="control"><label for="failures">Added failed attempts <span id="failuresOut">+0</span></label><input id="failures" type="range" min="0" max="4" step="1"></div>
<div class="control"><label for="amountFactor">Amount multiplier <span id="amountOut">1.0×</span></label><input id="amountFactor" type="range" min="0.5" max="3" step="0.1"></div>
<p class="notice"><b>Scenario only:</b> Amount, velocity and failure adjustments change computed scores, but do not modify stored transaction inputs or fictional truth labels.</p>
</div><div class="card"><div class="card-head"><div><h3>Synthetic confusion matrix</h3><p>Alert decision versus simulated fraud ground truth</p></div><span class="pill">VALIDATION DEMO</span></div><div id="matrix" class="matrix"></div><div id="labKpis" class="minikpis"></div><p class="sm-note">If the denominator is zero, precision or recall is shown as N/A rather than 100%.</p></div></div>
<div class="rowhead"><div><h2>Threshold response</h2><p>Precision, recall and alert volume across candidate cutoffs</p></div></div><div class="card" id="thresholdChart"></div>
</section>
<section class="view" id="view-workspace"><div class="intro"><div><div class="eyebrow">DATA WORKSPACE</div><h1>Manage the sample ledger.</h1><p>Add, edit, archive or restore transactions. Everything stays in this browser unless you explicitly export a file.</p></div><button class="btn primary" data-add>+ Add transaction</button></div>
<div class="metrics" id="workspaceMetrics"></div><div class="card"><div class="card-head"><div><h3>Active records</h3><p>Model inputs, labels and review statuses</p></div><div class="actions"><button class="btn" id="csvBtn">⇩ Export CSV</button><button class="btn" id="backupBtn">⇩ Backup JSON</button></div></div><div class="table-wrap" id="workspaceTable"></div></div>
<div class="rowhead"><div><h2>Archived transactions</h2><p>Archived records are excluded from all current calculations</p></div></div><div class="card table-wrap" id="archiveTable"></div>
<div class="rowhead"><div><h2>Workspace controls</h2><p>Import validated data or reload the illustrative demo</p></div></div><div class="card"><div class="actions"><button class="btn" id="importBtn">⇧ Import JSON backup</button><button class="btn warn" id="demoReset">↺ Restore demonstration</button></div><input id="fileImport" type="file" accept="application/json,.json" hidden><p class="notice">Only FraudLens version 1 backups are accepted (maximum 300 records and 1 MB). Import replaces this local workspace only after confirmation. Export a backup first if you wish to keep your changes.</p></div>
</section>
<section class="view" id="view-method"><div class="intro"><div><div class="eyebrow">TRANSPARENT METHODOLOGY</div><h1>Every point is explainable.</h1><p>This demonstration deliberately uses fixed, readable rules. It does not claim to be trained on real fraud data or to detect actual financial crime.</p></div></div><div class="methodgrid"><div class="card"><h3>Risk features and points</h3><div id="rulesDescription"></div></div><div class="card"><h3>How metrics work</h3><p>A transaction is considered an <b>alert</b> when its computed risk score is at or above the adjustable cutoff.</p><ul><li><b>Precision = TP / (TP + FP)</b>, when alerts exist.</li><li><b>Recall = TP / (TP + FN)</b>, when labeled fraud exists.</li><li><b>False positives</b> are alerts on simulated legitimate cases.</li><li><b>True labels</b> are independent, synthetic demonstration annotations.</li></ul><p>Thresholds and signals do not learn from analyst actions or archived records.</p></div><div class="card"><h3>Limits and safeguards</h3><ul><li>All transaction identities, merchants and fraud labels are fictional.</li><li>Rule-based scoring is not a calibrated likelihood or probability.</li><li>There is no API, model inference, external service, or payment processing.</li><li>Local browser storage is not encrypted or suitable for sensitive personal data.</li><li>Scenario results are illustrative, not out-of-sample validation.</li></ul></div><div class="card"><h3>Portfolio implementation</h3><p>Built for Hugging Face Static Spaces with one self-contained HTML file, responsive layout, SVG graphs, offline JavaScript scoring, CSV export, validated JSON import and optional dark mode.</p><p>Portfolio owner: <strong>Hugdrigo</strong><br>Project number: <strong>#90</strong><br>License: <strong>MIT</strong></p></div></div></section>
<p class="fineprint">FraudLens © 2026 · HUGDRIGO / PROJECT 090 · Demonstration software only. Never use sample scores to make real financial decisions.</p>
</main>
</div>
<div class="modalback" id="modalBack" role="presentation"><div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle"><h2 id="modalTitle">Add transaction</h2><form id="txForm"><div class="formgrid">
<div class="field"><label for="merchant">Merchant / description</label><input id="merchant" name="merchant" maxlength="65" required placeholder="e.g. Northstar Commerce"></div>
<div class="field"><label for="amount">Amount (USD)</label><input id="amount" name="amount" type="number" min="0" max="1000000" step="0.01" required></div>
<div class="field"><label for="channel">Channel</label><select id="channel" name="channel"><option value="card-present">Card present</option><option value="card-not-present">Card not present</option><option value="bank-transfer">Bank transfer</option></select></div>
<div class="field"><label for="velocityInput">Transactions in past hour</label><input id="velocityInput" name="velocity" type="number" min="0" max="100" step="1" required></div>
<div class="field"><label for="failedInput">Failed attempts</label><input id="failedInput" name="failures" type="number" min="0" max="50" step="1" required></div>
<div class="field"><label for="ageInput">Account age in days</label><input id="ageInput" name="age" type="number" min="0" max="10000" step="1" required></div>
<div class="field"><label for="hourInput">Transaction hour (0-23)</label><input id="hourInput" name="hour" type="number" min="0" max="23" step="1" required></div>
<div class="field"><label for="truthInput">Fictional ground-truth label</label><select id="truthInput" name="truth"><option value="0">Legitimate</option><option value="1">Simulated fraud</option></select></div>
<label class="checkline"><input id="newDeviceInput" name="newDevice" type="checkbox"> New device</label><label class="checkline"><input id="newCountryInput" name="newCountry" type="checkbox"> Unusual country</label>
</div><footer><button class="btn" type="button" id="cancelModal">Cancel</button><button class="btn primary" type="submit">Save transaction</button></footer></form></div></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
'use strict';
const STORE='fraudlens-v1-hugdrigo',MAX_RECORDS=300;
const el=id=>document.getElementById(id),clamp=(n,a,b)=>Math.min(b,Math.max(a,n));
const fmt=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n),money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:2}).format(n);
const escapeHTML=x=>String(x).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sampleRows=[
['Northstar Market',42,'card-present',1,0,840,14,0,0,0],['Summit Electronics',1490,'card-not-present',8,4,4,2,1,1,1],['Urban Coffee',12,'card-present',1,0,1200,8,0,0,0],['Nova Digital Goods',680,'card-not-present',6,3,7,1,1,1,1],['Bluebridge Apparel',92,'card-not-present',2,0,260,19,0,0,0],['Metro Transit',7,'card-present',1,0,490,9,0,0,0],['Acorn Supplies',390,'bank-transfer',5,2,23,3,1,1,1],['Harbor Books',34,'card-present',1,0,960,11,0,0,0],['Aurora Subscriptions',65,'card-not-present',4,0,130,21,0,0,0],['Horizon Gadgets',1280,'card-not-present',7,2,6,4,1,0,1],['Willow Grocers',54,'card-present',2,0,330,12,0,0,0],['Pioneer Fitness',84,'card-not-present',3,0,450,16,1,0,0],['Seabrook Travel',740,'card-not-present',4,1,190,23,0,1,0],['Atlas Express',410,'bank-transfer',6,3,12,2,1,1,1],['Evergreen Hardware',210,'card-present',1,0,610,17,0,0,0],['Fable Online',300,'card-not-present',5,1,72,1,1,0,1],['Cedar Restaurant',49,'card-present',1,0,820,20,0,0,0],['Helix Cloud Store',2200,'card-not-present',8,3,1,0,1,1,1],['Orchid Fashion',188,'card-not-present',2,1,44,15,0,0,0],['Oakside Pharmacy',28,'card-present',1,0,975,10,0,0,0]
];
const seeded=()=>sampleRows.map((r,i)=>({id:'FL-'+String(1001+i),merchant:r[0],amount:r[1],channel:r[2],velocity:r[3],failures:r[4],age:r[5],hour:r[6],newDevice:!!r[7],newCountry:!!r[8],truth:r[9],status:'new',archived:false}));
const presets={baseline:{name:'Baseline',icon:'◎',desc:'Original transaction conditions',velocity:1,failures:0,amount:1},surge:{name:'Velocity surge',icon:'↗',desc:'More transactions per hour',velocity:2,failures:0,amount:1},credential:{name:'Account attack',icon:'⌁',desc:'Multiple failed attempts',velocity:1,failures:3,amount:1},highvalue:{name:'High-value wave',icon:'◇',desc:'Higher transaction amounts',velocity:1.2,failures:0,amount:2.2}};
const defaults=()=>({version:1,theme:'light',threshold:60,scenario:{...presets.baseline,preset:'baseline'},transactions:seeded()});
function txValid(t){const s=v=>typeof v==='string'&&v.trim().length>0&&v.length<=65,inte=(n,a,b)=>Number.isInteger(n)&&n>=a&&n<=b,num=(n,a,b)=>typeof n==='number'&&Number.isFinite(n)&&n>=a&&n<=b;return t&&typeof t==='object'&&s(t.id)&&s(t.merchant)&&num(t.amount,0,1e6)&&['card-present','card-not-present','bank-transfer'].includes(t.channel)&&inte(t.velocity,0,100)&&inte(t.failures,0,50)&&inte(t.age,0,10000)&&inte(t.hour,0,23)&&typeof t.newDevice==='boolean'&&typeof t.newCountry==='boolean'&&[0,1].includes(t.truth)&&['new','investigating','closed'].includes(t.status)&&typeof t.archived==='boolean';}
function validState(s){return !!(s&&s.version===1&&['light','dark'].includes(s.theme)&&Number.isInteger(s.threshold)&&s.threshold>=0&&s.threshold<=100&&s.scenario&&typeof s.scenario==='object'&&Object.keys(presets).concat('custom').includes(s.scenario.preset)&&typeof s.scenario.velocity==='number'&&s.scenario.velocity>=.5&&s.scenario.velocity<=3&&Number.isInteger(s.scenario.failures)&&s.scenario.failures>=0&&s.scenario.failures<=4&&typeof s.scenario.amount==='number'&&s.scenario.amount>=.5&&s.scenario.amount<=3&&Array.isArray(s.transactions)&&s.transactions.length<=MAX_RECORDS&&s.transactions.every(txValid)&&new Set(s.transactions.map(x=>x.id)).size===s.transactions.length);}
function load(){try{const raw=localStorage.getItem(STORE);if(raw){const obj=JSON.parse(raw);if(validState(obj))return obj;}}catch(e){}return defaults();}
let state=load(),page='overview',selected=null,editing=null,toastTimer;
function save(){try{localStorage.setItem(STORE,JSON.stringify(state));}catch(e){toast('Browser storage is unavailable; export a backup.');}}
function scoreTransaction(t,sc=state.scenario){const a=t.amount*sc.amount,v=t.velocity*sc.velocity,f=t.failures+sc.failures,signals=[];const add=(pts,label)=>{if(pts>0)signals.push({points:pts,label});};add(a>=1000?14:a>=500?9:a>=250?5:0,'Higher transaction amount');add(v>=6?20:v>=4?12:v>=2?5:0,'High recent velocity');add(f>=3?21:f>=1?10:0,'Failed payment attempts');add(t.newDevice?13:0,'Unrecognized device');add(t.newCountry?12:0,'Unusual country');add(t.age<14?13:t.age<90?6:0,'Newer customer account');add(t.hour<=5?6:0,'Overnight activity');add(t.channel==='card-not-present'?5:0,'Remote card transaction');return {score:clamp(signals.reduce((n,s)=>n+s.points,0),0,100),signals};}
const active=()=>state.transactions.filter(t=>!t.archived);
const evaluated=()=>active().map(t=>({...t,risk:scoreTransaction(t),alert:scoreTransaction(t).score>=state.threshold}));
function confusion(rows=evaluated(),threshold=state.threshold){let tp=0,fp=0,tn=0,fn=0;for(const r of rows){const alert=r.risk.score>=threshold;if(alert&&r.truth)tp++;else if(alert)fp++;else if(r.truth)fn++;else tn++;}return {tp,fp,tn,fn,precision:tp+fp?tp/(tp+fp):null,recall:tp+fn?tp/(tp+fn):null};}
const percent=v=>v===null?'N/A':(v*100).toFixed(1)+'%';
const pill=r=>'<span class="risk '+(r>=70?'high':r>=40?'mid':'low')+'">'+r+'/100</span>';
function metric(title,value,note,icon){return '<div class="metric"><div class="label"><span>'+title+'</span><b>'+icon+'</b></div><strong>'+value+'</strong><small>'+note+'</small></div>';}
function toast(message){el('toast').textContent=message;el('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el('toast').classList.remove('show'),2700);}
function switchPage(name){if(!['overview','review','lab','workspace','method'].includes(name))return;page=name;document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.id==='view-'+name));document.querySelectorAll('[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===name));el('crumbView').textContent=({overview:'OVERVIEW',review:'INVESTIGATION QUEUE',lab:'THRESHOLD LAB',workspace:'TRANSACTIONS',method:'METHODOLOGY'})[name];render();window.scrollTo(0,0);}
function riskSVG(rows){let bins=Array(10).fill(0), alerts=Array(10).fill(0);rows.forEach(r=>{const bin=Math.min(9,Math.floor(r.risk.score/10));bins[bin]++;if(r.alert)alerts[bin]++;});const M=Math.max(1,...bins),parts=[];for(let i=0;i<10;i++){const x=38+i*60;const h=165*bins[i]/M,ha=165*alerts[i]/M;parts.push('<rect x="'+x+'" y="'+(200-h)+'" width="37" height="'+h+'" rx="5" fill="var(--accent)" opacity=".62"/><rect x="'+x+'" y="'+(200-ha)+'" width="37" height="'+ha+'" rx="5" fill="var(--red)"/><text x="'+(x+18)+'" y="219" text-anchor="middle">'+(i*10)+'</text>');}return '<svg class="chart" viewBox="0 0 658 243" role="img" aria-label="Histogram of synthetic transaction risk scores"><line x1="28" y1="200" x2="644" y2="200" stroke="var(--edge)"/>'+parts.join('')+'<text x="15" y="15">'+M+' records at maximum bin</text><text x="640" y="238" text-anchor="end">Risk score bins (10 points)</text></svg>';}
function renderOverview(){const rows=evaluated(),c=confusion(rows),alerts=rows.filter(r=>r.alert),open=alerts.filter(r=>r.status!=='closed');el('heroThreshold').textContent=state.threshold+'/100';el('overviewMetrics').innerHTML=metric('ACTIVE TRANSACTIONS',fmt(rows.length),'Synthetic ledger records','▦')+metric('CURRENT ALERTS',fmt(alerts.length),fmt(open.length)+' not closed','◉')+metric('DEMO PRECISION',percent(c.precision),'Measured on fictional labels','◈')+metric('DEMO RECALL',percent(c.recall),'Measured on fictional labels','◎');el('riskChart').innerHTML=riskSVG(rows);
const keys=[['High recent velocity','velocity',r=>r.velocity*state.scenario.velocity>=4],['Failed attempts','failures',r=>r.failures+state.scenario.failures>=1],['New device','device',r=>r.newDevice],['Unusual country','country',r=>r.newCountry],['New account','age',r=>r.age<90]];el('signalBars').innerHTML=keys.map(([label,key,test])=>{const n=rows.filter(test).length;return '<div class="baritem"><div class="barhead"><b>'+label+'</b><span>'+n+' / '+rows.length+'</span></div><div class="track"><div class="fill" style="width:'+(rows.length?100*n/rows.length:0)+'%"></div></div></div>';}).join('');
const items=rows.filter(r=>r.status!=='closed').sort((a,b)=>b.risk.score-a.risk.score).slice(0,6);el('priorityTable').innerHTML=items.length?'<table><thead><tr><th>Transaction</th><th>Merchant</th><th class="num">Amount</th><th>Risk</th><th>Case state</th><th>Action</th></tr></thead><tbody>'+items.map(r=>'<tr><td><b>'+escapeHTML(r.id)+'</b></td><td>'+escapeHTML(r.merchant)+'</td><td class="num">'+money(r.amount)+'</td><td>'+pill(r.risk.score)+'</td><td>'+escapeHTML(r.status)+'</td><td><button class="mini" data-inspect="'+escapeHTML(r.id)+'">Inspect →</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">No active cases.</div>';}
function renderReview(){const query=el('reviewSearch').value.trim().toLowerCase(),risk=el('reviewRisk').value,status=el('reviewStatus').value;const rows=evaluated().filter(r=>(r.id.toLowerCase().includes(query)||r.merchant.toLowerCase().includes(query))&&(risk==='all'||(risk==='alert'&&r.alert)||(risk==='clear'&&!r.alert))&&(status==='all'||r.status===status)).sort((a,b)=>b.risk.score-a.risk.score);if(!selected||!active().some(t=>t.id===selected))selected=rows.length?rows[0].id:null;el('reviewTable').innerHTML=rows.length?'<table><thead><tr><th>Record</th><th>Merchant</th><th class="num">Amount</th><th>Risk</th><th>Case</th><th></th></tr></thead><tbody>'+rows.map(r=>'<tr><td><b>'+escapeHTML(r.id)+'</b></td><td>'+escapeHTML(r.merchant)+'</td><td class="num">'+money(r.amount)+'</td><td>'+pill(r.risk.score)+'</td><td>'+escapeHTML(r.status)+'</td><td><button class="mini" data-inspect="'+escapeHTML(r.id)+'">Details</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">No transactions match these filters.</div>';const t=evaluated().find(x=>x.id===selected);if(!t){el('caseDetail').innerHTML='<div class="empty">Select a transaction to inspect its risk signals.</div>';return;}
const detail='<div class="card-head"><div><h3>'+escapeHTML(t.id)+'</h3><p>'+escapeHTML(t.merchant)+'</p></div>'+pill(t.risk.score)+'</div><div class="detail"><div class="detailline"><span>Amount</span><b>'+money(t.amount)+'</b></div><div class="detailline"><span>Channel</span><b>'+escapeHTML(t.channel)+'</b></div><div class="detailline"><span>Case status</span><b>'+escapeHTML(t.status)+'</b></div><div class="detailline"><span>Simulated truth</span><b>'+(t.truth?'Fraud':'Legitimate')+'</b></div><div class="detailline"><span>Alert decision</span><b>'+(t.alert?'Alert':'Below cutoff')+'</b></div></div><div class="rowhead"><div><h2>Score breakdown</h2><p>Transparent points per signal</p></div></div>'+(t.risk.signals.length?t.risk.signals.map(s=>'<div class="signal"><span>'+escapeHTML(s.label)+'</span><strong>+'+s.points+'</strong></div>').join(''):'<p class="sm-note">No positive risk signals.</p>')+'<div class="notice">These indicators are deterministic rules, not probabilities of fraud.</div><div class="actions"><button class="btn" data-status="investigating" data-id="'+escapeHTML(t.id)+'">Investigate</button><button class="btn" data-status="closed" data-id="'+escapeHTML(t.id)+'">Close case</button><button class="btn ghost" data-status="new" data-id="'+escapeHTML(t.id)+'">Reopen</button></div>';
el('caseDetail').innerHTML=detail;}
function renderLab(){for(const [key,input,out,unit] of [['threshold','threshold','thresholdOut',' / 100'],['velocity','velocity','velocityOut','×'],['failures','failures','failuresOut',''],['amount','amountFactor','amountOut','×']]){const val=key==='threshold'?state.threshold:state.scenario[key];el(input).value=val;el(out).textContent=key==='failures'?'+'+val:key==='threshold'?val+unit:Number(val).toFixed(1)+unit;}el('presetGrid').innerHTML=Object.entries(presets).map(([key,x])=>'<button class="preset '+(state.scenario.preset===key?'selected':'')+'" data-preset="'+key+'"><span class="emoji">'+x.icon+'</span><b>'+x.name+'</b><small>'+x.desc+'</small></button>').join('');const rows=evaluated(),c=confusion(rows);el('matrix').innerHTML='<div class="head">Actual →<br>Prediction ↓</div><div class="head">Fictional fraud</div><div class="head">Fictional legit</div><div class="head">Alert</div><div class="correct"><strong>'+c.tp+'</strong>True positive</div><div class="incorrect"><strong>'+c.fp+'</strong>False positive</div><div class="head">No alert</div><div class="incorrect"><strong>'+c.fn+'</strong>False negative</div><div class="correct"><strong>'+c.tn+'</strong>True negative</div>';el('labKpis').innerHTML='<div class="minikpi"><small>Precision</small><b>'+percent(c.precision)+'</b></div><div class="minikpi"><small>Recall</small><b>'+percent(c.recall)+'</b></div><div class="minikpi"><small>Alerts</small><b>'+(c.tp+c.fp)+'</b></div><div class="minikpi"><small>False alarms</small><b>'+c.fp+'</b></div>';el('thresholdChart').innerHTML=thresholdSVG(rows);}
function thresholdSVG(rows){const vals=[];for(let t=0;t<=100;t+=5){const c=confusion(rows,t);vals.push({t,p:c.precision,r:c.recall,alerts:c.tp+c.fp});}const X=t=>42+t*5.9,Y=v=>180-145*v,poly=field=>vals.filter(x=>x[field]!==null).map(x=>X(x.t)+','+Y(x[field])).join(' '),grid=[0,.25,.5,.75,1].map(n=>'<line x1="42" x2="635" y1="'+Y(n)+'" y2="'+Y(n)+'" stroke="var(--edge)"/><text x="7" y="'+(Y(n)+4)+'">'+Math.round(n*100)+'%</text>').join('');let thresholdX=X(state.threshold);return '<svg class="chart" viewBox="0 0 660 234" role="img" aria-label="Precision and recall by alert cutoff, using synthetic labels">'+grid+'<polyline fill="none" stroke="var(--accent)" stroke-width="3" stroke-linejoin="round" points="'+poly('p')+'"/><polyline fill="none" stroke="var(--teal)" stroke-width="3" stroke-linejoin="round" points="'+poly('r')+'"/><line x1="'+thresholdX+'" x2="'+thresholdX+'" y1="20" y2="182" stroke="var(--red)" stroke-dasharray="5 4"/><text x="'+clamp(thresholdX+7,44,560)+'" y="16">Cutoff '+state.threshold+'</text>'+[0,20,40,60,80,100].map(t=>'<text x="'+X(t)+'" y="204" text-anchor="middle">'+t+'</text>').join('')+'<text x="630" y="224" text-anchor="end">Alert cutoff</text></svg><div class="legend"><span><i></i>Precision</span><span class="teal"><i></i>Recall</span><span class="red"><i></i>Current cutoff</span></div><div class="notice">These curves are calculated on the same small synthetic dataset. Metrics are descriptive, not evidence of model accuracy in production.</div>';}
function renderWorkspace(){const rows=evaluated(),arch=state.transactions.filter(t=>t.archived),c=confusion(rows);el('workspaceMetrics').innerHTML=metric('ACTIVE RECORDS',fmt(rows.length),'Included in current metrics','▦')+metric('ARCHIVED',fmt(arch.length),'Recoverable later','▤')+metric('SYNTHETIC FRAUD',fmt(rows.filter(x=>x.truth).length),'Fictional annotations','◉')+metric('ALERT VOLUME',fmt(c.tp+c.fp),'At current cutoff','◎');el('workspaceTable').innerHTML=rows.length?'<table><thead><tr><th>ID</th><th>Merchant</th><th class="num">Amount</th><th>Channel</th><th>Score</th><th>Label</th><th>Actions</th></tr></thead><tbody>'+rows.map(r=>'<tr><td><b>'+escapeHTML(r.id)+'</b></td><td>'+escapeHTML(r.merchant)+'</td><td class="num">'+money(r.amount)+'</td><td>'+escapeHTML(r.channel)+'</td><td>'+pill(r.risk.score)+'</td><td>'+(r.truth?'Fraud':'Legit')+'</td><td><div class="actions"><button class="mini" data-edit="'+escapeHTML(r.id)+'">Edit</button><button class="mini red" data-archive="'+escapeHTML(r.id)+'">Archive</button></div></td></tr>').join('')+'</tbody></table>':'<div class="empty">No active records. Add a transaction to begin.</div>';el('archiveTable').innerHTML=arch.length?'<table><thead><tr><th>Record</th><th>Merchant</th><th class="num">Amount</th><th>Action</th></tr></thead><tbody>'+arch.map(t=>'<tr><td>'+escapeHTML(t.id)+'</td><td>'+escapeHTML(t.merchant)+'</td><td class="num">'+money(t.amount)+'</td><td><button class="mini" data-restore="'+escapeHTML(t.id)+'">Restore</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Nothing is archived.</div>';}
function renderMethod(){const rules=[['Transaction amount','≥ $250: +5 · ≥ $500: +9 · ≥ $1,000: +14'],['Hourly velocity','≥ 2: +5 · ≥ 4: +12 · ≥ 6: +20'],['Failed attempts','≥ 1: +10 · ≥ 3: +21'],['Unrecognized device','+13'],['Unusual country','+12'],['Account age','< 90 days: +6 · < 14 days: +13'],['Hours 00:00 to 05:59','+6'],['Card-not-present channel','+5']];el('rulesDescription').innerHTML=rules.map(x=>'<div class="detailline"><span>'+escapeHTML(x[0])+'</span><b>'+escapeHTML(x[1])+'</b></div>').join('')+'<p class="sm-note">Final score = sum of triggered points, capped at 100. Scenario settings modify eligible input features before rules are applied.</p>';}
function render(){document.body.classList.toggle('dark',state.theme==='dark');el('themeBtn').textContent=state.theme==='dark'?'☀':'☾';({overview:renderOverview,review:renderReview,lab:renderLab,workspace:renderWorkspace,method:renderMethod})[page]();}
function updateTx(id,fn){const t=state.transactions.find(x=>x.id===id);if(!t)return;fn(t);save();render();}
function openModal(id){editing=id||null;const t=id?state.transactions.find(x=>x.id===id):null;el('modalTitle').textContent=t?'Edit transaction':'Add transaction';const defaults={merchant:'',amount:150,channel:'card-not-present',velocity:1,failures:0,age:180,hour:12,truth:0,newDevice:false,newCountry:false};const item=t||defaults;for(const key of ['merchant','amount','channel','velocity','failures','age','hour','truth'])el('txForm').elements[key].value=item[key];for(const key of ['newDevice','newCountry'])el('txForm').elements[key].checked=item[key];el('modalBack').classList.add('open');el('merchant').focus();}
function closeModal(){el('modalBack').classList.remove('open');editing=null;}
function uniqueId(){let result;do{result='FL-'+Date.now().toString(36).toUpperCase()+'-'+Math.random().toString(36).slice(2,6).toUpperCase();}while(state.transactions.some(x=>x.id===result));return result;}
function submitTx(e){e.preventDefault();const f=e.target.elements,t={id:editing||uniqueId(),merchant:f.merchant.value.trim(),amount:Number(f.amount.value),channel:f.channel.value,velocity:Number(f.velocity.value),failures:Number(f.failures.value),age:Number(f.age.value),hour:Number(f.hour.value),newDevice:f.newDevice.checked,newCountry:f.newCountry.checked,truth:Number(f.truth.value),status:editing?(state.transactions.find(x=>x.id===editing)?.status||'new'):'new',archived:false};if(!txValid(t)){toast('Check transaction values and try again.');return;}if(!editing&&state.transactions.length>=MAX_RECORDS){toast('Maximum 300 records reached.');return;}if(editing){const i=state.transactions.findIndex(x=>x.id===editing);if(i<0)return;t.archived=state.transactions[i].archived;state.transactions[i]=t;}else state.transactions.push(t);selected=t.id;closeModal();save();render();toast('Transaction saved locally.');}
function download(filename,content,type){const a=document.createElement('a'),url=URL.createObjectURL(new Blob([content],{type}));a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500);}
function csvField(v){let s=String(v);if(/^\s*[=+@\-]/.test(s)&&!/^\s*-?\d+(?:\.\d+)?\s*$/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';}
function exportCSV(){const columns=['id','merchant','amount_usd','channel','velocity_per_hour','failed_attempts','account_age_days','hour','new_device','unusual_country','fictional_fraud_truth','review_status','risk_score','alert_at_cutoff','contributing_signals'];const rows=evaluated().map(t=>[t.id,t.merchant,t.amount,t.channel,t.velocity,t.failures,t.age,t.hour,t.newDevice,t.newCountry,t.truth,t.status,t.risk.score,t.alert,t.risk.signals.map(x=>x.label+':'+x.points).join('; ')]);download('fraudlens_synthetic_analysis.csv','\ufeff'+[columns,...rows].map(x=>x.map(csvField).join(',')).join('\r\n'),'text/csv;charset=utf-8');toast('CSV exported.');}
function backup(){download('fraudlens_workspace_backup.json',JSON.stringify(state,null,2),'application/json');toast('JSON backup exported.');}
async function importFile(file){if(!file)return;if(file.size>1024*1024){toast('Backup exceeds the 1 MB limit.');return;}try{const raw=await file.text(),obj=JSON.parse(raw);if(!validState(obj))throw Error('schema');if(!confirm('Replace this local workspace with the imported backup?'))return;state=obj;selected=null;save();render();toast('Validated backup restored.');}catch(e){toast('Invalid FraudLens version 1 backup.');}finally{el('fileImport').value='';}}
document.addEventListener('click',e=>{const target=e.target.closest('button');if(!target)return;const d=target.dataset;if(d.view)switchPage(d.view);else if(d.go)switchPage(d.go);else if(target.hasAttribute('data-add'))openModal();else if(d.inspect){selected=d.inspect;switchPage('review');}else if(d.edit)openModal(d.edit);else if(d.archive)updateTx(d.archive,t=>t.archived=true);else if(d.restore)updateTx(d.restore,t=>t.archived=false);else if(d.status&&d.id){updateTx(d.id,t=>t.status=d.status);toast('Case status updated.');}else if(d.preset){const p=presets[d.preset];state.scenario={...p,preset:d.preset};save();render();toast(p.name+' scenario loaded.');}});
for(const id of ['reviewSearch','reviewRisk','reviewStatus'])el(id).addEventListener(id==='reviewSearch'?'input':'change',()=>{if(page==='review')renderReview();});
for(const [id,key] of [['threshold','threshold'],['velocity','velocity'],['failures','failures'],['amountFactor','amount']])el(id).addEventListener('input',e=>{const n=Number(e.target.value);if(key==='threshold')state.threshold=n;else{state.scenario[key]=n;state.scenario.preset='custom';}save();renderLab();});
el('themeBtn').addEventListener('click',()=>{state.theme=state.theme==='dark'?'light':'dark';save();render();});
el('resetLab').addEventListener('click',()=>{state.threshold=60;state.scenario={...presets.baseline,preset:'baseline'};save();render();toast('Default laboratory settings restored.');});
el('txForm').addEventListener('submit',submitTx);el('cancelModal').addEventListener('click',closeModal);el('modalBack').addEventListener('click',e=>{if(e.target===el('modalBack'))closeModal();});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&el('modalBack').classList.contains('open'))closeModal();});
el('csvBtn').addEventListener('click',exportCSV);el('backupBtn').addEventListener('click',backup);el('exportTop').addEventListener('click',backup);el('importBtn').addEventListener('click',()=>el('fileImport').click());el('fileImport').addEventListener('change',e=>importFile(e.target.files[0]));el('demoReset').addEventListener('click',()=>{if(!confirm('Replace the local workspace with the original synthetic demonstration records?'))return;state=defaults();selected=null;save();render();toast('Demonstration workspace restored.');});
render();
</script>
</body></html>