Files
Bike-Tinder/app/static/index.html
Torres ac613e680a feat(v1.1): item 4 — minimal rubric (gauge + %) and rubric_debug 'why' toggle
- prefs API accepts the new rubric_debug key (off|on), the only new runtime
  write in v1.1; every section_state body now carries prefs.rubric_debug.
- fillRank renders the tank gauge + rounded % by default; the breakdown
  lines, audit footer and the quiet listing-enrichment fetch run only when
  the pref is on. List rows and the card face stay rubric-free (unchanged).
- Trends 'Actions' gains a 'Why breakdown: off | on' row in the setMode
  style; updateSectionUI repaints it on every section hydrate.
2026-11-13 13:31:21 -04:00

1349 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Bike-Tinder</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0b0b0e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Bike-Tinder">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
:root{
--bg:#0b0b0e; --card:#141419; --text:#fff; --mut:rgba(255,255,255,.62);
--accent1:#ff7854; --accent2:#fd267d; --like:#46d369; --nope:#fd4d5c; --maybe:#ffb43a;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}
.app{position:relative;display:flex;flex-direction:column;height:100%;max-width:520px;margin:0 auto}
/* header */
header{height:calc(50px + env(safe-area-inset-top,0px));display:flex;align-items:center;justify-content:space-between;padding:env(safe-area-inset-top,0px) 16px 0}
.brand{display:flex;align-items:center;gap:8px;font-weight:800;font-size:16px;letter-spacing:.2px}
.flame{width:18px;height:18px;border-radius:6px;background:linear-gradient(135deg,var(--accent1),var(--accent2))}
.pill{font-size:12px;color:var(--mut);background:#1b1b21;border:1px solid #26262e;border-radius:999px;padding:5px 10px}
.pill b{color:#fff}
/* stage + cards */
.stage{position:relative;flex:1;padding:6px 14px 2px;min-height:0}
.card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card);
box-shadow:0 0 0 1px rgba(255,255,255,.07), 0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none}
.nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6}
.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))}
.phblur{position:absolute;inset:-18px;background-size:cover;background-position:center;background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08);pointer-events:none}
.card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)}
.card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3}
.chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
.chip{font-size:11px;font-weight:600;letter-spacing:.4px;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22)}
.title{font-size:23px;font-weight:800;line-height:1.15;text-shadow:0 1px 8px rgba(0,0,0,.5);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.price{font-size:20px;font-weight:600;margin-top:2px}
.sub{font-size:13px;color:var(--mut);margin-top:3px}
.stamp{position:absolute;top:26px;z-index:4;font-weight:800;letter-spacing:2px;font-size:26px;padding:6px 12px;border:3px solid;border-radius:10px;opacity:0;pointer-events:none}
.stamp.like{left:20px;transform:rotate(-14deg);color:var(--like);border-color:var(--like);background:rgba(70,211,105,.08)}
.stamp.nope{right:20px;transform:rotate(14deg);color:var(--nope);border-color:var(--nope);background:rgba(253,77,92,.08)}
.stamp.maybe{left:50%;top:auto;bottom:130px;transform:translateX(-50%);color:var(--maybe);border-color:var(--maybe);background:rgba(255,180,58,.08)}
/* actions */
.actions{height:86px;display:flex;align-items:center;justify-content:center;gap:16px}
.act{border:0;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .12s}
.act:active{transform:scale(.92)}
.undo{width:42px;height:42px;background:#1d1d24;color:#f5c451;font-size:18px}
.x{width:58px;height:58px;background:#fff;color:#fd5068;font-size:26px;font-weight:700;box-shadow:0 6px 18px rgba(0,0,0,.4)}
.s{width:58px;height:58px;background:#1d1d24;color:var(--maybe);font-size:24px;border:1px solid #2a2a33}
.h{width:62px;height:62px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:26px;box-shadow:0 8px 22px rgba(253,38,125,.35)}
/* tabs */
nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items:stretch;border-top:1px solid #1d1d24;background:#0d0d10;padding-bottom:calc(6px + env(safe-area-inset-bottom,0px));position:relative;z-index:60}
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-size:10px;letter-spacing:.3px;color:#6f6f7a;cursor:pointer}
.tab.on{color:#fff}
.tab svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
/* fullscreen detail dossier — iOS-style sheet: top gap + grabber, drag down to close */
.dback{position:absolute;inset:0;z-index:69;background:rgba(0,0,0,.38);opacity:0;pointer-events:none;transition:opacity .28s}
.dback.on{opacity:1}
.detail{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:70;background:#0f0f13;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.45);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);display:flex;flex-direction:column;overflow:hidden}
.detail.on{transform:translateY(0)}
.dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none}
.dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
.dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer}
.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none;overflow:hidden}
.dblur{position:absolute;inset:-18px;background-size:cover;background-position:center;background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08);pointer-events:none}
.dphoto{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))}
.dphoto.ph{opacity:.25}
.dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))}
.dbody{flex:1;overflow-y:auto;padding:14px 18px 20px}
.dtitle{font-size:20px;font-weight:800;line-height:1.2}
.drow{display:flex;align-items:baseline;gap:10px;margin-top:5px}
.dprice{font-size:19px;font-weight:700}
.dsub{font-size:12.5px;color:var(--mut)}
.dchips{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.dsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:18px 0 8px}
.specs{border-top:1px solid #1f1f27}
.spec{display:flex;justify-content:space-between;gap:14px;padding:9px 2px;border-bottom:1px solid #1f1f27;font-size:13.5px}
.spec .k{color:#8f8f9c}
.spec .v{font-weight:600;text-align:right}
.spec .v.z{color:#5a5a66;font-weight:500}
.dnotes{font-size:13.5px;line-height:1.5;color:#b9b9c2}
.dopen{display:inline-block;margin-top:16px;color:#ff9a76;font-weight:600;font-size:14px;text-decoration:none}
.dacts{flex:none;display:flex;gap:10px;padding:12px 16px calc(14px + env(safe-area-inset-bottom, 0px));border-top:1px solid #1d1d24;background:#0d0d10}
.dact{flex:1;height:48px;border-radius:14px;border:1px solid #2a2a33;background:#16161b;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
.dact:active{transform:scale(.97)}
.dact.no{color:#fd7b89}
.dact.mb{color:var(--maybe)}
.dact.lk{background:linear-gradient(135deg,var(--accent1),var(--accent2));border:0;box-shadow:0 8px 22px rgba(253,38,125,.3)}
.ibtn{position:absolute;top:14px;right:14px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.25);color:#fff;font-size:14px;z-index:4;cursor:pointer}
/* empty */
.empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px}
.empty.on{display:flex}
.empty b{color:#fff;font-size:16px}
/* list screen */
.list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
.list.on{display:flex}
.lhead{padding:14px 16px 8px}
.lhead h1{font-size:20px;font-weight:800}
.lhead .sub{margin-top:2px}
.filters{display:flex;gap:7px;padding:4px 16px 10px;overflow-x:auto}
.fchip{font-size:12px;padding:6px 11px;border-radius:999px;background:#1b1b21;border:1px solid #26262e;color:var(--mut);white-space:nowrap;cursor:pointer}
.fchip.on{background:#fff;color:#111;border-color:#fff;font-weight:600}
.lrows{flex:1;overflow-y:auto;padding:0 10px 12px}
.row{display:flex;gap:11px;align-items:center;padding:9px 8px;border-radius:14px;cursor:pointer}
.row + .row{border-top:1px solid #1a1a1f}
.thumb{width:56px;height:56px;border-radius:12px;object-fit:cover;background:#1a1a20;flex:none}
.thumb.ph{display:flex;align-items:center;justify-content:center;color:#4a4a55;font-size:20px}
.rmain{flex:1;min-width:0}
.rtitle{font-size:14.5px;font-weight:650;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rsub{font-size:12px;color:var(--mut);margin-top:2px}
.rright{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.rprice{font-size:14px;font-weight:700}
.rc{display:flex;gap:5px}
.rbtn{width:26px;height:26px;border-radius:50%;border:1px solid #2a2a33;background:#16161b;color:#8a8a95;font-size:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
.rbtn.on.like{background:rgba(70,211,105,.15);border-color:var(--like);color:var(--like)}
.rbtn.on.maybe{background:rgba(255,180,58,.15);border-color:var(--maybe);color:var(--maybe)}
.rbtn.on.nope{background:rgba(253,77,92,.15);border-color:var(--nope);color:var(--nope)}
/* matches screen (production) */
.matches{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
.matches.on{display:flex}
.msec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:10px 18px 0}
.mempty{margin:10px 18px;font-size:13.5px;color:var(--mut)}
/* stub screens */
.stub{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--bg);z-index:50;color:var(--mut);text-align:center;padding:40px;font-size:13.5px}
.stub.on{display:flex}
.stub b{color:#fff;font-size:17px}
/* production additions (UI-SPEC §7 deltas) */
.search{margin:0 16px 8px;width:calc(100% - 32px);background:#1b1b21;border:1px solid #26262e;border-radius:10px;color:#fff;font-size:14px;padding:9px 14px;outline:none}
.search::placeholder{color:#6f6f7a}
.toast{position:fixed;left:50%;bottom:150px;transform:translateX(-50%);background:#1d1d24;border:1px solid #2a2a33;color:#fff;padding:10px 16px;border-radius:12px;font-size:13px;z-index:80;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;max-width:92vw;overflow:hidden;text-overflow:ellipsis}
.toast.on{opacity:1}
/* P3 additions */
.sect{margin-left:4px;font-size:12px;font-weight:650;color:var(--mut);white-space:nowrap}
.sect.hot{color:var(--accent1)}
.band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none}
.band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)}
.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)}
.report{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px;z-index:3}
.report.on{display:flex}
.report b{color:#fff;font-size:16px}
.rforge{margin-top:10px;border:0;border-radius:14px;background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;font-size:15px;font-weight:700;padding:12px 22px;cursor:pointer;box-shadow:0 8px 22px rgba(253,38,125,.3)}
.rforge:disabled{background:#23232b;color:#6f6f7a;box-shadow:none;cursor:default}
.rstatus{font-size:12.5px;min-height:16px}
.rmode{display:flex;align-items:center;gap:7px;font-size:12px;margin-top:6px;flex-wrap:wrap;justify-content:center}
.rmode .ron{color:#fff;border-bottom:1px solid #fff}
.rmode span{cursor:pointer;color:var(--mut)}
.rmode .sep{color:#4a4a55;cursor:default}
.rcap{flex-basis:100%;color:var(--mut);font-size:12px}
/* tank gauge (non-numeric) */
.tank{width:18px;height:22px;flex:none;display:block}
.tank.big{width:28px;height:34px}
.tank .tf{transition:y .25s ease,height .25s ease}
.rpt{display:flex;align-items:center;gap:7px}
/* dossier why-this-rank */
.drank{display:flex;gap:12px;align-items:flex-start;margin-top:10px;padding:10px;background:#15151b;border-radius:12px}
.drank .tankwrap{flex:none;padding-top:2px}
.dpts{font-size:17px;font-weight:700}
.dlines{font-size:12.5px;color:var(--mut);line-height:1.55}
.dlines .pos{color:#9ee8a8}
.dlines .neg{color:#f0a5ab}
.dfoot{margin-top:6px;font-size:11.5px;color:#7b7b88}
/* trends screen */
.trends{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px;overflow-y:auto}
.trends.on{display:flex}
.tsec{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:#7b7b88;margin:16px 18px 6px}
.tlead{font-size:18px;font-weight:700;margin:0 18px;line-height:1.35}
.tsub{font-size:12.5px;color:var(--mut);margin:4px 18px 0}
.tbullets{margin:0 18px;font-size:14px;color:#d7d7dd;line-height:1.6}
.tminis{display:flex;gap:10px;margin:0 18px}
.tmini{flex:1;background:#141419;border:1px solid #1f1f27;border-radius:12px;padding:12px 10px;text-align:center}
.tmv{font-size:19px;font-weight:800}
.tmk{font-size:10.5px;color:var(--mut);margin-top:4px;letter-spacing:.3px;text-transform:uppercase}
.tacts{margin:8px 18px 0;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.tstatus{font-size:12.5px;color:var(--mut)}
.trow{display:flex;align-items:center;gap:8px;margin:12px 18px 0;font-size:13px;color:var(--mut)}
.trow .on{color:#fff;border-bottom:1px solid #fff}
.trow span{cursor:pointer}
.trow i{color:#4a4a55;font-style:normal;cursor:default}
.tmuted{font-size:12px;color:#6f6f7a;margin:10px 18px 20px}
/* B1 §11 — photo galleries: segments, tap zones, fullscreen viewer */
.segments{position:absolute;top:8px;left:50%;transform:translateX(-50%);display:flex;gap:4px;z-index:4;pointer-events:none}
.segments i{width:22px;height:3px;border-radius:2px;background:rgba(255,255,255,.35);transition:background .15s}
.segments i.on{background:#fff}
.card.hasSegs .band{top:22px} /* §11.2 amendment: bands drop below the bars */
.vback{position:absolute;inset:0;z-index:71;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .28s}
.vback.on{opacity:1;pointer-events:auto}
.viewer{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:72;background:#0b0b0f;display:flex;flex-direction:column;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.5);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);overflow:hidden}
.viewer.on{transform:translateY(0)}
.vgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none}
.vgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
.vclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:16px;z-index:6;cursor:pointer;display:flex;align-items:center;justify-content:center}
.vphoto{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none}
.vimg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transform-origin:center center}
.viewer .segments{top:34px}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><span class="flame"></span>Bike-Tinder</div>
<div style="display:flex;align-items:center">
<div class="pill" id="pill">—</div>
<div class="sect" id="sect"></div>
</div>
</header>
<div class="stage" id="stage">
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
<div class="report" id="report">
<b>Section complete.</b>
<div id="repCounts"></div>
<div id="repSub">The file is digesting your strikes.</div>
<div id="repLine2"></div>
<button class="rforge" id="btnForge">Forge next 25</button>
<div class="rstatus" id="forgeStatus"></div>
<div class="rmode" id="modeRow">
<span id="modeOnd" class="on">on demand</span><i class="sep">⇄</i><span id="modeStr">strict</span>
<div class="rcap" id="strictCap" style="display:none">Waiting for the nightly read — next section after the agent runs.</div>
</div>
</div>
</div>
<div class="actions" id="actions">
<button class="act undo" id="btnUndo" title="Undo">↺</button>
<button class="act x" id="btnNope" title="Nope">✕</button>
<button class="act s" id="btnMaybe" title="Maybe">★</button>
<button class="act h" id="btnLike" title="Like">♥</button>
</div>
<nav>
<div class="tab on" data-tab="deck"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="13" height="16" rx="2.5"/><path d="M8.5 2.5h9A2.5 2.5 0 0 1 20 5v12"/></svg>Deck</div>
<div class="tab" data-tab="list"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>List</div>
<div class="tab" data-tab="matches"><svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Matches</div>
<div class="tab" data-tab="trends"><svg viewBox="0 0 24 24"><path d="M5 20V10M12 20V4M19 20v-7"/></svg>Trends</div>
</nav>
<div class="dback" id="dback"></div>
<div class="detail" id="detail">
<div class="dgrab" id="dgrab"><i></i></div>
<button class="dclose" id="dclose">⌄</button>
<div class="dhero"><div class="dblur" id="dblur"></div><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
<div class="dbody" id="dbody">
<div class="dtitle" id="dtitle"></div>
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
<div class="dchips" id="dchips"></div>
<div class="drank" id="drank" style="display:none">
<div class="tankwrap" id="drankTank"></div>
<div style="flex:1;min-width:0">
<div class="dpts" id="dpts"></div>
<div class="dlines" id="dlines"></div>
<div class="dfoot" id="dfoot"></div>
</div>
</div>
<div class="dsec">The machine</div>
<div class="specs" id="dspecs"></div>
<div class="dsec">Seller's notes</div>
<div class="dnotes" id="dnotes"></div>
<a class="dopen" id="dopen" href="#" target="_blank" rel="noopener">Open on Kijiji ↗</a>
</div>
<div class="dacts">
<button class="dact no" id="dNo">✕ Nope</button>
<button class="dact mb" id="dMb">★ Maybe</button>
<button class="dact lk" id="dLk">♥ Like</button>
</div>
</div>
<div class="vback" id="vback"></div>
<div class="viewer" id="viewer">
<div class="vgrab" id="vgrab"><i></i></div>
<button class="vclose" id="vclose">✕</button>
<div class="segments" id="vsegs"></div>
<div class="vphoto" id="vphoto"><img class="vimg" id="vimg" alt="" draggable="false"></div>
</div>
<div class="list" id="listScreen">
<div class="lhead"><h1>All bikes</h1><div class="sub" id="lsub">—</div></div>
<input class="search" id="lsearch" type="search" placeholder="Search titles" autocomplete="off">
<div class="filters" id="fdec">
<div class="fchip on" data-d="all">All</div><div class="fchip" data-d="like">Liked</div><div class="fchip" data-d="maybe">Maybe</div><div class="fchip" data-d="nope">Noped</div><div class="fchip" data-d="undecided">Undecided</div>
</div>
<div class="filters" id="fsort">
<div class="fchip on" data-s="score">Score</div><div class="fchip" data-s="price" id="fprice">Price ↕</div><div class="fchip" data-s="newest">Newest</div><div class="fchip" data-s="km">km</div>
</div>
<div class="lrows" id="lrows"></div>
</div>
<div class="matches" id="matchesScreen">
<div class="lhead"><h1>Your shortlist</h1></div>
<div class="msec" id="mlikeSec">♥ Liked (0)</div>
<div class="lrows" id="mlike"></div>
<div class="msec" id="mmaybeSec">★ Maybe (0)</div>
<div class="lrows" id="mmaybe"></div>
<div class="mempty" id="mempty">No likes yet — swipe right on the deck.</div>
</div>
<div class="trends" id="trendsScreen">
<div class="lhead"><h1>Trends</h1></div>
<div class="tsec">Your read</div>
<div class="tlead" id="tLead">—</div>
<div class="tsub" id="tReadAt"></div>
<div class="tsub" id="tCold" style="display:none">Swipe ~10 more and the read sharpens.</div>
<div class="tsec" id="tLeanSec" style="display:none">Leanings</div>
<div class="tbullets" id="tLeanings"></div>
<div class="tsec">Stats</div>
<div class="tminis">
<div class="tmini"><div class="tmv" id="tmHit">—</div><div class="tmk">Hit rate</div></div>
<div class="tmini"><div class="tmv" id="tmRet">—</div><div class="tmk">Returns re-struck</div></div>
<div class="tmini"><div class="tmv" id="tmWild">—</div><div class="tmk">Wildcards hit</div></div>
</div>
<div class="tsec" id="tSinceSec" style="display:none">Since last read</div>
<div class="tsub" id="tSince" style="display:none"></div>
<div class="tsec">Actions</div>
<div class="tacts">
<button class="rforge" id="btnRunAgent" style="margin-top:0">Run agent now</button>
<div class="tstatus" id="runStatus"></div>
</div>
<div class="trow"><span>Forge:</span><span id="tOnd" class="on">on demand</span><i>⇄</i><span id="tStr">strict</span></div>
<div class="trow"><span>Why breakdown:</span><span id="tRubOff" class="on">off</span><i>⇄</i><span id="tRubOn">on</span></div>
<div class="tmuted" id="tLastRun"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $=id=>document.getElementById(id);
const stage=$('stage');
const DEPTH=5; // a 5-card live stack at all times
const SC=.026, TY=16, DIM=.08; // per-depth step: scale, y-offset, dimming
let pile=[]; // top-first [{d,el}] — every card its own swipable object
let hist=[]; // undo history: full payloads
let dragEl=null, sx=0, sy=0, dx=0, dy=0, moved=false;
let buffer=[]; // deck feed (payloads, pinned section order in P3)
let deckCursor=null, deckLoaded=false, deckLoading=false;
let section=null; // P3: latest GET /api/section body
let legacyMode=false; // true when the API has no /api/section (P2 API)
let forgeBusy=false, runBusy=false;
/* ---------- production wiring: API + retry queue + toast ---------- */
let toastTimer=null;
function toast(msg){
const t=$('toast'); t.textContent=msg; t.classList.add('on');
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('on'),2500);
}
async function getJSON(url){
try{
const res=await fetch(url);
if(!res.ok){ if(!navigator.onLine) toast('Offline — retrying'); return null; }
return await res.json();
}catch(e){ if(!navigator.onLine) toast('Offline — retrying'); return null; }
}
/* swipe persistence: optimistic, never waits; failures retry 2s/5s/10s then
every 15s while the tab lives. Last write per listing wins; the queue runs
serially so reorderings can't land out of sequence. */
const pend=new Map(); // listing_id -> {act, secId, photoIdx, fails, warned}
let qRunning=false, qTimer=null, qWarned=false;
function enqueue(listingId, act, secId=null, photoIdx=null){ // act: 'like'|'nope'|'maybe'|null (delete)
pend.set(listingId,{act,secId,photoIdx,fails:0});
scheduleQueue();
}
function swipeBody(id, act, secId, photoIdx){
const b={listing_id:id,action:act};
if(secId!=null) b.section_id=secId;
if(photoIdx!=null) b.photo_idx=photoIdx; // B1 §11.5: photo on screen at strike
return b;
}
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
function scheduleQueue(){
if(qRunning||!pend.size||qTimer) return;
qTimer=setTimeout(runQueue,0);
}
async function runQueue(){
qTimer=null; qRunning=true;
try{
const ops=[...pend.entries()];
let minDelay=0;
for(const [id,op] of ops){
if(!pend.has(id)) continue; // replaced mid-batch
const p=pend.get(id);
if(p!==op) continue;
try{
const res= p.act==null
? await fetch('/api/swipe/'+id,{method:'DELETE'})
: await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(swipeBody(id,p.act,p.secId,p.photoIdx))});
if(res.ok){ pend.delete(id); }
else { p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); } minDelay=Math.max(minDelay,queDelay(p)); }
}catch(e){
p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); }
minDelay=Math.max(minDelay,queDelay(p));
}
}
}finally{ qRunning=false; }
if(pend.size){ qTimer=setTimeout(runQueue,Math.max(minDelay||15000,1000)); }
}
/* optimistic decision set for list/matches + deck commits share this.
localDecisions remembers the latest intent per listing so section
progress stays correct while the persistence queue is mid-flight. */
const localDecisions=new Map(); // listing_id -> action|null
let lastResyncAt=0;
function setDecision(id, act, secId=null, photoIdx=null){
localDecisions.set(id, act);
updateRowDecision(id, act); // optimistic paint
enqueue(id, act, secId, photoIdx);
}
/* ---------- payload -> display helpers ---------- */
const REGION_NAMES={ns:'NS',nb:'NB'};
function fmtNum(n){ return n==null?null:Number(n).toLocaleString('en-US'); }
function cardChips(it){ // max 3: seller chip + first 2 extras
const out=[];
if(it.seller_type==='dealer') out.push('Dealer');
out.push(...(it.extras||[]).slice(0,2));
return out.slice(0,3);
}
function subFor(it){
const parts=[];
if(it.location) parts.push(it.location);
parts.push(REGION_NAMES[it.region]||it.region||'');
let s=parts.filter(Boolean).join(', ');
if(it.seller_type==='dealer') s+=' · dealer';
else if(it.seller_type==='private') s+=' · private';
return s;
}
function priceFor(it){
if(it.price_display) return it.price_display;
if(it.price_num!=null) return '$'+fmtNum(it.price_num);
return '—';
}
function relSeen(fs){
if(!fs) return '—';
const d=new Date(fs), now=new Date();
if(isNaN(d)) return '—';
const days=Math.floor((now-d)/86400000);
return days<=0?'Today':(days===1?'1 day ago':days+' days ago');
}
function relTime(ts){
if(!ts) return '—';
const d=new Date(ts), now=new Date();
if(isNaN(d)) return '—';
const sec=Math.max(0,Math.floor((now-d)/1000));
if(sec<60) return 'just now';
const min=Math.floor(sec/60);
if(min<60) return min+'m ago';
const hr=Math.floor(min/60);
if(hr<24) return hr+'h ago';
const days=Math.floor(hr/24);
return days===1?'1 day ago':days+' days ago';
}
/* P3: non-numeric tank gauge (filled per points, colored per band) */
const TANK_COLORS={like:'var(--like)',maybe:'var(--maybe)',nope:'var(--nope)'};
function tankSVG(tank,big){
const v= tank&&tank.value!=null?Math.max(0,Math.min(100,tank.value)):0;
const color= TANK_COLORS[tank&&tank.band]||'var(--maybe)';
const fillH=13.5*v/100, y=18-fillH;
return `<svg class="tank${big?' big':''}" viewBox="0 0 18 22" aria-hidden="true">`+
`<rect x="5" y="4.5" width="8" height="13.5" rx="4" fill="rgba(255,255,255,.12)"/>`+
`<rect x="7.4" y="5.6" width="3.2" height="2.2" rx="1.1" fill="rgba(255,255,255,.28)"/>`+
`<rect class="tf" x="6.4" y="${y}" width="5.2" height="${fillH}" rx="2.2" fill="${color}"/>`+
`</svg>`;
}
function ccFrom(it){
const m=((it.title||'')+' '+((it.desc||'').split(' [also listed:')[0])).match(/(\d[\d,]*)\s*cc/i);
return m?(fmtNum(m[1].replace(/,/g,''))+' cc'):null;
}
function catFromUrl(url){
const m=(url||'').match(/\/v-([^/]+)\//);
if(!m) return null;
const words=m[1].split('-').filter(Boolean).map(w=>w[0].toUpperCase()+w.slice(1));
return words.length?words.join(' '):null;
}
/* ---------- docs/mockup core: cards, layer, pile ---------- */
function isTop(el){ return pile[0]&&pile[0].el===el; }
function setStamps(el,a,b,c){
el.querySelector('.stamp.like').style.opacity=a;
el.querySelector('.stamp.nope').style.opacity=b;
el.querySelector('.stamp.maybe').style.opacity=c;
}
function bandHTML(item){ // P3 system bands — the ONLY card-face additions
if(item.slot==='wildcard') return `<div class="band wild">WILDCARD · maybe you&rsquo;ll like this one instead</div>`;
if(item.slot==='return'){
const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen';
return `<div class="band sl">SECOND LOOK — you ${verb} this</div>`;
}
return '';
}
/* ---------- B1 §11: photo galleries ---------- */
function nPhotos(it){ // payload n_photos; tolerant of old APIs
const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0);
return Math.max(0, Math.trunc(n));
}
function leadFor(it){ // §11.5 repeat rotation: (last+1) % n
const n=nPhotos(it);
if(n<2) return 0;
const li=it.last_photo_idx;
if(!Number.isInteger(li)||li<0) return 0;
return (li+1)%n;
}
function photoSrc(it,i){ if(!it||!it.img) return null; return i>0?('/img/'+it.id+'?i='+i):it.img; }
function segBars(n,cur){ // the <i> bars only (no wrapper)
let h='';
for(let k=0;k<n;k++) h+=`<i${k===cur?' class="on"':''}></i>`;
return h;
}
function segHTML(n,cur){
if(!n||n<2) return '';
return `<div class="segments">${segBars(n,cur)}</div>`;
}
function escAttr(s){ return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function phwrapHTML(src){
const img=src?`<div class="phblur" style="background-image:url('${escAttr(src)}')"></div><img class="photo" src="${escAttr(src)}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`;
return `<div class="phwrap">${img}</div>`;
}
function attachPhError(entry){
const wrap=entry.el.querySelector('.phwrap');
const im=wrap.querySelector('img');
if(im) im.addEventListener('error',()=>{ wrap.innerHTML='<div class="nophoto">Photo pending — thumbnail backfill queued</div>'; });
}
function paintSegs(entry){
const segs=entry.el.querySelector('.segments');
if(segs) [...segs.children].forEach((x,k)=>x.classList.toggle('on',k===(entry.cur||0)));
}
/* §11.2 flip — instant, clamped at the ends (no wrap), commits nothing */
function flipCard(entry,dir){
const n=nPhotos(entry.d);
if(n<2) return;
const cur=(entry.cur||0)+dir;
if(cur<0||cur>=n) return;
entry.cur=cur;
const wrap=entry.el.querySelector('.phwrap');
wrap.innerHTML= phwrapHTML(photoSrc(entry.d,cur));
attachPhError(entry);
paintSegs(entry);
preloadPhotos();
}
/* §11.6 preload: top card's next photo + next card's lead photo, off-path */
function preloadPhotos(){
pile.slice(0,2).forEach((entry,pos)=>{
const d=entry.d;
if(!d) return;
const targets= pos===0
? ((()=>{ const n=nPhotos(d); const nx=(entry.cur||0)+1;
return (n>1&&nx<n)?[photoSrc(d,nx)]:[]; })())
: [photoSrc(d,leadFor(d))];
for(const s of targets){
if(!s) continue;
const im=new Image(); im.decoding='async'; im.src=s;
}
});
}
function cardNode(item){
const el=document.createElement('div');
el.className='card';
const n=nPhotos(item);
const lead=leadFor(item);
if(n>=2) el.classList.add('hasSegs');
el.innerHTML=`${phwrapHTML(photoSrc(item,lead))}
<div class="grad"></div>
${bandHTML(item)}
${segHTML(n,lead)}
<div class="stamp like">LIKE</div>
<div class="stamp nope">NOPE</div>
<div class="stamp maybe">MAYBE</div>
<button class="ibtn">ⓘ</button>
<div class="info"><div class="chips">${cardChips(item).map(x=>`<span class="chip">${x}</span>`).join('')}</div><div class="title"></div><div class="price"></div><div class="sub"></div></div>`;
// text set via textContent — data is untrusted (titles carry junk chars)
const info=el.querySelector('.info');
info.querySelector('.title').textContent=item.title||'';
info.querySelector('.price').textContent=priceFor(item);
info.querySelector('.sub').textContent=subFor(item);
const entry={d:item,el,cur:lead};
attachPhError(entry);
el.addEventListener('pointerdown',down);
el.addEventListener('pointermove',move);
el.addEventListener('pointerup',upH);
el.addEventListener('pointercancel',upH);
const ib=el.querySelector('.ibtn');
ib.addEventListener('pointerdown',e=>e.stopPropagation());
ib.addEventListener('click',e=>{ e.stopPropagation(); if(isTop(el)) openDetail(item,{type:'deck'}); });
return entry;
}
function layer(el,d,anim){
el.classList.toggle('topcard',d===0);
el.style.transition=anim?'transform .22s cubic-bezier(.2,.8,.3,1), filter .22s ease, opacity .22s ease':'none';
el.style.transform=`translateY(${TY*d}px) scale(${1-SC*d})`;
el.style.filter=`brightness(${1-DIM*d})`;
el.style.opacity='1';
el.style.zIndex=10-d;
el.style.pointerEvents=d===0?'auto':'none';
}
function relayer(){ pile.forEach((c,d)=>layer(c.el,d,true)); }
function fillPile(){
while(pile.length<DEPTH&&buffer.length){
const item=buffer.shift();
const entry=cardNode(item), d=pile.length;
entry.el.style.transition='none';
entry.el.style.transform=`translateY(${TY*(d+2)}px) scale(${1-SC*(d+2)})`;
entry.el.style.opacity='0';
stage.appendChild(entry.el);
pile.push(entry);
void entry.el.offsetWidth;
layer(entry.el,d,true);
}
relayer();
preloadPhotos();
}
/* deck feed: P3 sections (pinned order) with a legacy /api/deck fallback */
async function loadDeck(){ // legacy cursor feed (pre-P3 API)
if(deckLoading||deckLoaded) return;
deckLoading=true;
try{
const res=await getJSON('/api/deck?limit=40'+(deckCursor?('&cursor='+encodeURIComponent(deckCursor)):''));
if(!res) { setTimeout(()=>loadDeck(),15000); return; }
if(res.items&&res.items.length){
buffer.push(...res.items);
fillPile();
}
deckCursor=res.next_cursor||null;
if(!deckCursor) deckLoaded=true;
}finally{ deckLoading=false; }
maybeRefill();
}
async function loadSection(){
if(deckLoading) return;
deckLoading=true;
try{
const res=await fetch('/api/section');
if(res.status===404){ // pre-P3 server: fall back to the deck feed
legacyMode=true;
deckLoading=false;
loadDeck();
return;
}
if(!res.ok){ setTimeout(()=>loadSection(),15000); return; }
const body=await res.json();
section=body;
const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id),...buffer.map(b=>b.id)]);
/* P3: second-look returns carry prior decisions — they must still enter
the swipe buffer; only ids already handled in this session are skipped. */
const fresh=(body.items||[]).filter(it=>(it.decision===null||it.slot==='return')&&!live.has(it.id));
buffer.push(...fresh);
fillPile();
updateSectionUI(body);
}catch(e){ setTimeout(()=>loadSection(),15000); }
finally{ deckLoading=false; }
maybeRefill();
}
function maybeRefill(){
if(legacyMode){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
if(!pile.length&&!buffer.length&&(deckLoaded||(!deckCursor&&!deckLoading))) $('empty').classList.add('on');
return;
}
if(!pile.length&&!buffer.length&&!deckLoading){
if(section&&sectDecided>=sectTotal){
// locally exhausted (last POSTs may still be flushing — the report
// shows from localDecisions; a quiet resync freshens counts and the
// server's report flip, rate-limited so a failing last POST can't
// cause a resync loop)
$('report').classList.add('on');
$('empty').classList.remove('on');
if(!section.exhausted&&Date.now()-lastResyncAt>2000){
lastResyncAt=Date.now();
loadSection();
}
}else if(section){
$('empty').classList.remove('on'); $('report').classList.remove('on');
}
}else{
$('empty').classList.remove('on');
$('report').classList.remove('on');
}
}
/* ---------- docs/mockup core: commit / fly / undo — no lock ---------- */
function commit(el,dir){
if(!isTop(el)) return;
const t=pile.shift();
hist.push(t.d);
const act= dir==='maybe'?'maybe':dir;
setDecision(t.d.id, act, (!legacyMode&&section&&!section.exhausted)?section.section.id:null,
nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike
sectDecided++;
paintSect();
flyOut(el,dir);
fillPile(); relayer();
maybeRefill();
}
function flyOut(el,dir){
const x= dir==='maybe'?0:(dir==='like'?620:-620);
const y= dir==='maybe'?-780:40;
const rot= dir==='maybe'?0:(dir==='like'?26:-26);
setStamps(el, dir==='like'?1:0, dir==='nope'?1:0, dir==='maybe'?1:0);
el.classList.remove('topcard');
el.style.zIndex=12; el.style.pointerEvents='none';
el.style.transition='transform .3s ease-in, opacity .3s';
el.style.transform=`translate(${x}px,${y}px) rotate(${rot}deg)`;
el.style.opacity=.15;
setTimeout(()=>{ el.remove(); },420);
}
function undo(){
if(!hist.length) return;
const item=hist.pop();
setDecision(item.id, null); // DELETE through the queue
sectDecided=Math.max(0,sectDecided-1);
paintSect();
const entry=cardNode(item);
entry.el.style.transition='none';
entry.el.style.transform='translateY(-46px) scale(1.02)';
entry.el.style.opacity='0';
stage.appendChild(entry.el);
pile.unshift(entry);
if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); buffer.unshift(drop.d); }
void entry.el.offsetWidth;
relayer();
preloadPhotos();
$('empty').classList.remove('on');
$('report').classList.remove('on');
}
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
$('btnMaybe').onclick=()=>{ if(pile[0]) commit(pile[0].el,'maybe'); };
$('btnUndo').onclick=undo;
/* ---------- docs/mockup core: drag — only the top card is grabbable ---------- */
function down(e){
const el=e.currentTarget;
if(!isTop(el)) return;
dragEl=el; moved=false; sx=e.clientX; sy=e.clientY; dx=0; dy=0;
try{ el.setPointerCapture(e.pointerId) }catch(_){}
el.style.transition='none';
}
function move(e){
const el=e.currentTarget;
if(dragEl!==el) return;
dx=e.clientX-sx; dy=e.clientY-sy;
if(Math.abs(dx)>6||Math.abs(dy)>6) moved=true;
const rot=Math.max(-12,Math.min(12,dx/12));
el.style.transform=`translate(${dx}px,${dy}px) rotate(${rot}deg)`;
setStamps(el, Math.max(0,Math.min(1,dx/120)), Math.max(0,Math.min(1,-dx/120)), dy<-20?Math.max(0,Math.min(1,-dy/130)):0);
}
function upH(e){
const el=e.currentTarget;
if(dragEl!==el) return;
dragEl=null;
const w=stage.clientWidth||360;
el.style.transition='transform .3s cubic-bezier(.2,.8,.3,1)';
if(dx>w*0.35) commit(el,'like');
else if(dx<-w*0.35) commit(el,'nope');
else if(dy<-140) commit(el,'maybe');
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); }
}
/* §11.4 v1.1 tap zones: sides flip (clamped), CENTER = dossier (bike data
first — the old center-tap→fullscreen and tap-anywhere→dossier gestures
are retired). The deck dossier remembers its pile entry so a viewer
opened from its hero walks back to it on Esc. */
function tapZone(entry,e){
const r=stage.getBoundingClientRect();
const rel=e.clientX-r.left, w=r.width||360;
if(rel>=w/3 && rel<=w*2/3){
dEntry=entry; // live pile entry (cur photo index)
openDetail(entry.d,{type:'deck'});
}else{
flipCard(entry, rel<w/3?-1:1); // no-ops on single/no-photo cards
}
}
/* ---------- docs/mockup core: fullscreen dossier ---------- */
let dCtx=null;
let dData=null; // B1: dossier payload (hero -> viewer)
let dEntry=null; // v1.1: pile entry when opened from the deck
function openDetail(data,ctx){
dCtx=ctx||null;
dData=data;
dEntry=null; // only tapZone re-sets it (deck path)
const ph=$('dphoto');
const blur=$('dblur');
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); blur.style.backgroundImage='url("'+data.img+'")'; }
else { ph.removeAttribute('src'); ph.classList.add('ph'); blur.style.backgroundImage='none'; }
ph.onerror=()=>{ ph.removeAttribute('src'); ph.classList.add('ph'); blur.style.backgroundImage='none'; };
$('dtitle').textContent=data.title||'';
$('dprice').textContent=priceFor(data);
$('dsub').textContent=subFor(data);
$('dchips').innerHTML=cardChips(data).map(x=>`<span class="chip">${x}</span>`).join('');
const rows=[
["Year",data.year!=null?String(data.year):null],
["Odometer",data.km!=null?(fmtNum(data.km)+' km'):null],
["Engine",ccFrom(data)],
["Family",data.family],
["Category",catFromUrl(data.url)],
["Location",subFor(data).split(' · ')[0]],
["Seller",data.seller_type==='dealer'?'Dealer':(data.seller_type==='private'?'Private':null)],
["Source","Kijiji"],
["First seen",relSeen(data.first_seen)],
];
$('dspecs').innerHTML=rows.map(r=>`<div class="spec"><span class="k">${r[0]}</span><span class="v${(!r[1]||r[1]==='—')?' z':''}">${r[1]||'—'}</span></div>`).join('');
$('dnotes').textContent=(data.desc||'').split(' [also listed:')[0]||'';
$('dopen').href=data.url||'#';
fillRank(data); // P3 "Why this rank" (may be progressive)
$('detail').classList.add('on'); $('dback').classList.add('on');
$('dbody').scrollTop=0;
}
/* P3 dossier rank block: gauge + points% minimal; the full "why" breakdown
and the audit footer render only when prefs.rubric_debug === 'on' (§10.5). */
function rubricOn(){ return !!(section&&section.prefs&&section.prefs.rubric_debug==='on'); }
function fillRank(data){
if(!data||data.tank==null||data.tank.value==null){
$('drank').style.display='none';
return;
}
$('drank').style.display='flex';
$('drankTank').innerHTML=tankSVG(data.tank,true);
$('dpts').textContent=(data.tank.value|0)+'%';
if(rubricOn()){
const lines=(data.points_breakdown||[]).slice()
.sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4);
$('dlines').innerHTML=lines.map(l=>{
const lbl= l.source==='agent'?('agent: '+l.label):l.label;
const n= l.value>=0?(' +'+l.value):(' '+l.value);
return `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`;
}).join('');
const ri= section&&section.read_info;
$('dfoot').textContent= ri?`from ${ri.events} strikes · read ${relTime(ri.profile_at)}`:'';
// list/matches payloads carry no breakdown — fill it on the quiet
if(!data.points_breakdown&&data.id&&!legacyMode){
getJSON('/api/listing/'+data.id).then(full=>{
if(full&&full.points_breakdown&&dCtx) fillRank(full);
});
}
}else{
// minimal display: gauge + % only; skip the quiet enrichment fetch
$('dlines').innerHTML='';
$('dfoot').textContent='';
}
}
function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; dEntry=null; }
$('dclose').onclick=closeDetail;
$('dclose').addEventListener('pointerdown',e=>e.stopPropagation());
/* pull-down-to-close (grabber bar + photo) */
let dDragY=null;
function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); $('detail').style.transform=`translateY(${dy}px)`; }
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
if(dy>110){ closeDetail(); }
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform='';
/* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at
photo 0. When the dossier sits over the deck, the viewer back-chain
walks Esc back to the dossier (item 2), then to the deck. */
if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList
&& e.currentTarget.classList.contains('dhero') && dData&&dData.img){
openViewer(dData,0,{type:'dossier', backToDetail:dEntry});
} } }
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
function dAction(a){
const ctx=dCtx; closeDetail();
if(!ctx) return;
if(ctx.type==='deck'){
if(pile[0]) commit(pile[0].el, a==='like'?'like':(a==='nope'?'nope':'maybe'));
} else {
setDecision(ctx.it.id, a==='like'?'like':(a==='nope'?'nope':'maybe'));
if(ctx.screen==='list') renderList();
else renderMatches();
}
}
$('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like');
/* ---------- B1 §11.3: fullscreen viewer (swipe nav, pinch/double-tap zoom,
✕ + pull-down close) — returns to the same card & photo position ---------- */
let vCtx=null; // {item, idx, back} — deck cards untouched
let vScale=1, vTx=0, vTy=0;
let vDrag=null; // single-pointer gesture state
const vPtrs=new Map(); // pointerId -> {x,y}
let vPinch=null; // {d0, scale0}
let vLastTap=0;
function vCount(){ return vCtx&&vCtx.item?nPhotos(vCtx.item):0; }
function vApply(){ if(!vCtx) return; if(vScale<=1){ vTx=0; vTy=0; } $('vimg').style.transform=`translate(${vTx}px,${vTy}px) scale(${vScale})`; }
function vPaint(){
const item=vCtx.item, i=vCtx.idx, n=vCount();
$('vsegs').innerHTML= n>=2?segBars(n,i):'';
const im=$('vimg'), s=photoSrc(item,i);
if(s){ im.src=s; im.style.opacity=1; }
else { im.removeAttribute('src'); im.style.opacity=.25; }
im.onerror=()=>{ im.style.opacity=.25; };
vApply();
}
function openViewer(item, idx, back){
const n=nPhotos(item);
if(!n||!item||!item.img) return;
vCtx={item, idx:Math.max(0,Math.min(idx||0,n-1)), back:back||null};
vScale=1; vTx=0; vTy=0;
vPaint();
$('viewer').classList.add('on'); $('vback').classList.add('on');
}
function closeViewer(){
const v=$('viewer');
v.classList.remove('on'); $('vback').classList.remove('on');
v.style.transition=''; v.style.transform='';
vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null;
}
/* v1.1 walk-back chain: when the viewer was reached from the deck dossier
hero, Esc returns to the dossier (photo 0) instead of the deck. */
function vWalkBack(){
const back=vCtx&&vCtx.back;
if(back&&back.backToDetail){
const item=vCtx.item;
closeViewer();
openDetail(item, back.backToDetail);
dEntry=back.backToDetail.entry||null; // keep the chain repeatable
} else {
closeViewer();
}
}
$('vback').addEventListener('click',closeViewer);
$('vclose').onclick=closeViewer;
$('vclose').addEventListener('pointerdown',e=>e.stopPropagation());
const vph=$('vphoto');
function vDist(){
const pts=[...vPtrs.values()];
return pts.length>=2?Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y):null;
}
vph.addEventListener('pointerdown',e=>{
if(!vCtx) return;
vPtrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
try{ vph.setPointerCapture(e.pointerId); }catch(_){}
if(vPtrs.size===1){ vDrag={sx:e.clientX,sy:e.clientY,px:vTx,py:vTy,scale0:vScale,mode:null}; }
else if(vPtrs.size===2){ vDrag=null; vPinch={d0:vDist()||1,scale0:vScale}; }
});
vph.addEventListener('pointermove',e=>{
if(!vCtx) return;
const pt=vPtrs.get(e.pointerId); if(pt){ pt.x=e.clientX; pt.y=e.clientY; }
if(vPtrs.size>=2&&vPinch){
const dd=vDist(); if(!dd) return;
vScale=Math.max(1,Math.min(4,vPinch.scale0*dd/vPinch.d0)); // 1×–4×
vApply(); return;
}
if(!vDrag) return;
const dx=e.clientX-vDrag.sx, dy=e.clientY-vDrag.sy;
if(!vDrag.mode){
if(Math.abs(dx)<=6&&Math.abs(dy)<=6) return;
vDrag.mode= vScale>1?'pan':(Math.abs(dx)>=Math.abs(dy)?'nav':'pull');
}
if(vDrag.mode==='pull'){
const v=$('viewer'); v.style.transition='none';
v.style.transform=`translateY(${Math.max(0,dy)}px)`;
} else if(vDrag.mode==='nav'){
vTx=Math.max(-70,Math.min(70,dx)); vApply(); // live edge-feedback
} else { vTx=vDrag.px+dx; vTy=vDrag.py+dy; vApply(); } // pan while zoomed
});
function vUp(e){
const wasDrag=vDrag, dx=wasDrag?e.clientX-wasDrag.sx:0, dy=wasDrag?e.clientY-wasDrag.sy:0;
if(wasDrag&&vPtrs.size<=1){
const mode=wasDrag.mode;
if(mode==='pull'){
if(dy>110){ closeViewer(); }
else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; }
} else if(mode==='nav'){
if(Math.abs(dx)>=50&&vCtx){
const nx=vCtx.idx+(dx>0?-1:1); // clamp at the ends
if(nx>=0&&nx<vCount()){ vCtx.idx=nx; vPaint(); }
}
vTx=0; vApply();
} else if(mode==='pan'){
// keep pan position
} else if(e.type==='pointerup'){ // tap -> double-tap zoom toggle
const now=Date.now();
if(now-vLastTap<300){ vLastTap=0; vScale= vScale>1?1:2.5; vApply(); }
else vLastTap=now;
}
vDrag=null;
}
vPtrs.delete(e.pointerId); vPinch=null;
}
vph.addEventListener('pointerup',vUp);
vph.addEventListener('pointercancel',vUp);
/* pull-down close from the grabber (the §3 idiom) */
let vgY=null;
function vgStart(e){ vgY=e.clientY; $('viewer').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
function vgMove(e){ if(vgY===null) return; $('viewer').style.transform=`translateY(${Math.max(0,e.clientY-vgY)}px)`; }
function vgEnd(e){ if(vgY===null) return; const dy=Math.max(0,e.clientY-vgY); vgY=null;
if(dy>110){ closeViewer(); }
else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; } }
$('vgrab').addEventListener('pointerdown',vgStart);
$('vgrab').addEventListener('pointermove',vgMove);
$('vgrab').addEventListener('pointerup',vgEnd);
$('vgrab').addEventListener('pointercancel',vgEnd);
/* ---------- list screen (real filters + search) ---------- */
let listItems=[];
const listCtl={decision:'all',sort:'score',q:'',priceDir:1};
async function fetchList(){
let items=[],total=0,offset=0;
do{
const q=listCtl.q?'&q='+encodeURIComponent(listCtl.q):'';
const res=await getJSON(`/api/list?decision=${listCtl.decision}&sort=${listCtl.sort}${q}&limit=200&offset=${offset}`);
if(!res) break;
total=res.total;
items.push(...res.items);
offset+=res.items.length;
if(!res.items.length||offset>=total||offset>=500) break;
}while(true);
listItems=items;
renderList();
}
function thumbFor(it){
if(it.img) return `<img class="thumb" src="/img/${it.id}" alt="" data-i="${it.id}">`;
return `<div class="thumb ph">◍</div>`;
}
function rowHTML(it){
const on=act=>it.decision===act;
return `
<div class="row" data-i="${it.id}">
${thumbFor(it)}
<div class="rmain"><div class="rtitle"></div><div class="rsub"></div></div>
<div class="rright"><div class="rpt">${it.tank?tankSVG(it.tank):''}<div class="rprice"></div></div>
<div class="rc" data-i="${it.id}">
<button class="rbtn like${on('like')?' on':''}" data-s="like">♥</button>
<button class="rbtn maybe${on('maybe')?' on':''}" data-s="maybe">★</button>
<button class="rbtn nope${on('nope')?' on':''}" data-s="nope">✕</button>
</div>
</div>
</div>`;
}
function attachThumbFallbacks(box){
box.querySelectorAll('img.thumb').forEach(im=>im.addEventListener('error',()=>{
const d=document.createElement('div'); d.className='thumb ph'; d.textContent='◍';
im.replaceWith(d);
}));
}
function renderList(){
$('lrows').innerHTML=listItems.map(rowHTML).join('');
attachThumbFallbacks($('lrows'));
listItems.forEach(it=>{
const el=$('lrows').querySelector(`.row[data-i="${it.id}"]`);
if(!el) return;
el.querySelector('.rtitle').textContent=it.title||'';
el.querySelector('.rsub').textContent=subFor(it);
el.querySelector('.rprice').textContent=priceFor(it);
});
if(!listItems.length) $('lrows').innerHTML='<div class="mempty">No bikes match.</div>';
}
/* optimistic decision updates for whichever screens show this listing */
function updateRowDecision(id, act){
const find=[listItems];
for(const arr of find){
const it=arr.find(x=>x.id===id);
if(it) it.decision=act;
}
const el=$('lrows').querySelector(`.rc[data-i="${id}"]`);
if(el) el.querySelectorAll('.rbtn').forEach(b=>b.classList.toggle('on',b.dataset.s===act));
['mlike','mmaybe'].forEach(sec=>{
const box=$(sec);
if(box.querySelector(`.rc[data-i="${id}"]`)) refreshRowStateInMatches(id,act);
});
}
function refreshRowStateInMatches(id,act){
// a changed decision may move the row between sections — refetch quietly
fetchMatches();
}
$('lrows').addEventListener('click',e=>{
const b=e.target.closest('.rbtn');
if(!b){
const row=e.target.closest('.row'); if(!row) return;
const id=+row.dataset.i;
const it=listItems.find(x=>x.id===id);
if(it) openDetail(it,{type:'row',it,screen:'list'});
return;
}
const id=+b.closest('.rc').dataset.i, s=b.dataset.s;
const it=listItems.find(x=>x.id===id);
if(!it) return;
const next= it.decision===s?null:s; // tapping the lit button clears
setDecision(id,next);
if(next===null && (listCtl.decision===s)) renderList(); // row leaves a filtered view
});
$('fdec').addEventListener('click',e=>{
const chip=e.target.closest('.fchip'); if(!chip) return;
$('fdec').querySelectorAll('.fchip').forEach(c=>c.classList.toggle('on',c===chip));
listCtl.decision=chip.dataset.d;
fetchList();
});
$('fsort').addEventListener('click',e=>{
const chip=e.target.closest('.fchip'); if(!chip) return;
const s=chip.dataset.s;
if(s==='price'){
if(listCtl.sort==='price_asc'||listCtl.sort==='price_desc'){
listCtl.priceDir= listCtl.sort==='price_asc'?1:-1; // toggle on re-tap
if(listCtl.priceDir===1) listCtl.sort='price_desc';
else listCtl.sort='price_asc';
} else { listCtl.sort='price_asc'; listCtl.priceDir=0; }
} else { listCtl.sort=s; }
$('fsort').querySelectorAll('.fchip').forEach(c=>{
const cs=c.dataset.s;
c.classList.toggle('on', cs==='score'?listCtl.sort==='score':cs==='newest'?listCtl.sort==='newest':cs==='km'?listCtl.sort==='km':(listCtl.sort==='price_asc'||listCtl.sort==='price_desc'));
});
$('fprice').textContent= listCtl.sort==='price_asc'?'Price ↑':listCtl.sort==='price_desc'?'Price ↓':'Price ↕';
fetchList();
});
let searchTimer=null;
$('lsearch').addEventListener('input',e=>{
clearTimeout(searchTimer);
searchTimer=setTimeout(()=>{ listCtl.q=e.target.value.trim(); fetchList(); },300);
});
/* ---------- matches screen (production: REAL) ---------- */
let mItems={like:[],maybe:[]};
async function fetchMatches(){
mItems.like=[];
mItems.maybe=[];
const [l,m]=await Promise.all([
getJSON('/api/list?decision=like&limit=500'),
getJSON('/api/list?decision=maybe&limit=500'),
]);
if(l) mItems.like=l.items;
if(m) mItems.maybe=m.items;
renderMatches();
}
function renderMatches(){
$('mlike').innerHTML=mItems.like.map(rowHTML).join('');
$('mmaybe').innerHTML=mItems.maybe.map(rowHTML).join('');
attachThumbFallbacks($('mlike'));
attachThumbFallbacks($('mmaybe'));
mItems.like.concat(mItems.maybe).forEach(it=>{
[['mlike',mItems.like],['mmaybe',mItems.maybe]].forEach(([boxId])=>{
const el=$(boxId).querySelector(`.row[data-i="${it.id}"]`);
if(!el) return;
el.querySelector('.rtitle').textContent=it.title||'';
el.querySelector('.rsub').textContent=subFor(it);
el.querySelector('.rprice').textContent=priceFor(it);
});
});
$('mlikeSec').textContent='♥ Liked ('+mItems.like.length+')';
$('mmaybeSec').textContent='★ Maybe ('+mItems.maybe.length+')';
$('mempty').style.display= (mItems.like.length||mItems.maybe.length)?'none':'';
}
['mlike','mmaybe'].forEach(sec=>{
$(sec).addEventListener('click',e=>{
const b=e.target.closest('.rbtn');
if(!b){
const row=e.target.closest('.row'); if(!row) return;
const id=+row.dataset.i;
const it=mItems.like.concat(mItems.maybe).find(x=>x.id===id);
if(it) openDetail(it,{type:'row',it,screen:'matches'});
return;
}
const id=+b.closest('.rc').dataset.i, s=b.dataset.s;
const it=mItems.like.concat(mItems.maybe).find(x=>x.id===id);
if(!it) return;
const next= it.decision===s?null:s;
setDecision(id,next);
fetchMatches();
});
});
/* ---------- header + badge ---------- */
let badgeFired=false;
async function loadStats(){
const res=await getJSON('/api/stats');
if(!res) return;
$('pill').innerHTML=`<b>${res.live}</b> live`+(res.new>0?` · <b>${res.new}</b> new`:'');
$('lsub').innerHTML=`${res.live} active · `+(res.new>0?`<b style="color:#fff">${res.new} new</b>`:res.new+' new')+' since your last visit';
if(!badgeFired){
badgeFired=true;
fetch('/api/badge/seen',{method:'POST'}).catch(()=>{});
}
}
/* ---------- P3 section UI: progress pill + report + forge + modes ---------- */
let sectDecided=0, sectTotal=0;
function paintSect(){
const t=$('sect');
if(legacyMode||!section){ t.textContent=''; return; }
t.textContent=`${sectDecided}/${sectTotal}`;
t.classList.toggle('hot',sectDecided>=sectTotal);
}
function paintMode(){
const m= section?section.mode:'ondemand';
$('modeOnd').classList.toggle('on',m==='ondemand');
$('modeStr').classList.toggle('on',m==='strict');
$('tOnd').classList.toggle('on',m==='ondemand');
$('tStr').classList.toggle('on',m==='strict');
$('strictCap').style.display= m==='strict'?'':'none';
const btn=$('btnForge');
if(m==='strict'){ btn.disabled=true; }
else if(!forgeBusy) btn.disabled=false;
}
function updateSectionUI(body){
sectTotal=body.progress.total;
let decided=0;
for(const it of (body.items||[])){
const local=localDecisions.get(it.id);
if(local===undefined? it.decision!=null : local!=null) decided++;
}
sectDecided=decided;
paintSect();
paintMode();
paintRubricMode();
const c=body.counts||{};
$('repCounts').innerHTML=`♥ ${c.likes||0} · ★ ${c.maybes||0} · ✕ ${c.nopes||0}`;
const hits=[];
if(c.restruck>0) hits.push('Second looks re-struck: '+c.restruck);
if(c.wildcard_hits>0) hits.push('Wildcards that hit: '+c.wildcard_hits);
$('repLine2').textContent=hits.join(' · ');
}
async function setMode(m){
const res=await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:'forge_mode',value:m})}).catch(()=>null);
if(res&&res.ok){ if(section) section.mode=m; paintMode(); }
}
$('modeOnd').onclick=()=>setMode('ondemand');
$('modeStr').onclick=()=>setMode('strict');
function paintRubricMode(){
const on=rubricOn();
$('tRubOff').classList.toggle('on',!on);
$('tRubOn').classList.toggle('on',on);
}
async function setRubricDebug(m){
const res=await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:'rubric_debug',value:m})}).catch(()=>null);
if(res&&res.ok){
if(section){ section.prefs=section.prefs||{}; section.prefs.rubric_debug=m; }
paintRubricMode();
if(!legacyMode) loadSection(); // repaint any dossier already open
}
}
$('tRubOff').onclick=()=>setRubricDebug('off');
$('tRubOn').onclick=()=>setRubricDebug('on');
$('btnForge').onclick=async()=>{
if(forgeBusy||!section) return;
forgeBusy=true;
const btn=$('btnForge'); btn.disabled=true;
$('forgeStatus').textContent='The agent is reading your strikes…';
try{
const res=await fetch('/api/section/forge',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const body=await res.json();
if(res.ok){
$('forgeStatus').textContent= body.note&&body.note.indexOf('fallback')>=0
?'Agent offline — forging on the last read.':'';
section=body;
buffer.length=0;
const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id)]);
const fresh=(body.items||[]).filter(it=>(it.decision===null||it.slot==='return')&&!live.has(it.id));
buffer.push(...fresh);
fillPile();
updateSectionUI(body);
if(pile.length){ $('report').classList.remove('on'); }
}else{
$('forgeStatus').textContent= body.detail||'Forge failed';
paintMode();
}
}catch(e){ $('forgeStatus').textContent='Offline — retrying'; }
forgeBusy=false;
paintMode();
};
/* ---------- P3 trends tab ---------- */
function fmtPct(f){ return f==null?'—':((Math.round(f*1000)/10)+'%'); }
async function fetchTrends(){
const t=await getJSON('/api/trends');
if(!t) return;
const p=t.profile;
$('tLead').textContent= p?p.lead:'—';
$('tReadAt').textContent= p?('updated '+relTime(p.created_at)):'';
$('tCold').style.display= p?'none':'';
$('tLeanSec').style.display= p&&p.leanings&&p.leanings.length?'':'none';
$('tLeanings').innerHTML= ((p&&p.leanings)||[]).map(x=>`<div>· ${x}</div>`).join('');
const s=t.stats||{};
$('tmHit').textContent= fmtPct(s.hit_rate);
$('tmRet').textContent= s.returns_re_struck??'0';
$('tmWild').textContent= s.wildcards_hit??'0';
const since= (p&&p.changes&&p.changes[0])||null;
$('tSinceSec').style.display= since?'':'none';
$('tSince').style.display= since?'':'none';
$('tSince').textContent= since||'';
const lr=t.last_run;
$('tLastRun').textContent= lr&&lr.ok
?`Last read ${relTime(lr.finished_at)} · ${lr.model||'—'}`
:(lr?`Last run failed (${lr.source||'?'}) — will retry`:'Last read —');
if(section) section.mode=t.forge_mode;
paintMode();
}
$('btnRunAgent').onclick=async()=>{
if(runBusy) return;
runBusy=true;
$('runStatus').textContent='Reading your strikes…';
const res=await fetch('/api/agent/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>null);
if(res&&res.ok){
const b=await res.json();
$('runStatus').textContent= b.source==='fallback'?'Agent offline — ran on the last read':'Done — the next read uses it.';
}else{
$('runStatus').textContent="Couldn't run — see the read above.";
}
runBusy=false;
fetchTrends();
};
$('tOnd').onclick=()=>setMode('ondemand');
$('tStr').onclick=()=>setMode('strict');
/* ---------- tabs ---------- */
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
const name=t.dataset.tab;
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x===t));
$('listScreen').classList.toggle('on',name==='list');
$('matchesScreen').classList.toggle('on',name==='matches');
$('trendsScreen').classList.toggle('on',name==='trends');
if(name==='list') fetchList(); // fresh decisions every visit
else if(name==='matches') fetchMatches();
else if(name==='trends') fetchTrends();
});
/* ---------- v1.1 keyboard map (BACKLOG "Shortcuts"): ← nope · → like ·
↑ maybe · ↓ undo · Backspace = undo alias (kept for muscle memory) ·
Esc closes the top open sheet (viewer → dossier → deck). Arrows act only
on the deck; text-input focus and open sheets swallow them; arrow-key
defaults are prevented so no page scroll can fire. -------------------- */
document.addEventListener('keydown',e=>{
const t=e.target;
if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable)) return;
const k=e.key;
if(k==='Escape'){
if($('viewer').classList.contains('on')) vWalkBack(); // viewer → dossier (when reached from it) → deck
else if($('detail').classList.contains('on')) closeDetail();
return; // Esc never forges or navigates tabs
}
if(k==='ArrowDown'){ e.preventDefault(); undo(); return; }
if(k==='Backspace'){ undo(); return; }
if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'){
e.preventDefault();
if($('detail').classList.contains('on')||$('viewer').classList.contains('on')) return;
const el=pile[0]&&pile[0].el;
if(el) commit(el, k==='ArrowLeft'?'nope':k==='ArrowRight'?'like':'maybe');
}
});
/* ---------- boot ---------- */
loadStats();
loadSection();
/* PWA */
if('serviceWorker' in navigator && (location.protocol==='https:'||location.hostname==='localhost'||location.hostname==='127.0.0.1')){
navigator.serviceWorker.register('/sw.js').catch(()=>{});
}
</script>
</body>
</html>