matchday / index.html
mzidan000's picture
Upload folder using huggingface_hub
02b8de5 verified
Raw
History Blame
46.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MatchDay — AI trip planner for the 2026 FIFA World Cup, Vancouver</title>
<!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--violet:#7c3aed; --violet-d:#6d28d9; --violet-l:#ede9fe;
--emerald:#10b981; --emerald-d:#047857;
--amber:#f59e0b;
--ink:#0f172a; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af;
--line:#e8eaee; --line2:#eef0f3; --panel:#f9fafb; --gray:#f3f4f6;
--bg:#f7f8fa; --surface:#ffffff;
--shadow-sm:0 1px 2px rgba(17,24,39,.04),0 2px 6px -2px rgba(17,24,39,.08);
--shadow-md:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
/* FIFA World Cup 2026 inspired palette (vibrant poster accents on dark) */
--wc-magenta:#db2777; --wc-violet:#7c3aed; --wc-blue:#2563eb;
--wc-teal:#0d9488; --wc-gold:#fbbf24; --wc-pink:#f472b6;
--display:'Space Grotesk',Inter,-apple-system,sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;}
body{
font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
color:var(--ink); background:var(--bg); -webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
::-webkit-scrollbar{width:10px;height:10px;}
::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:8px;border:2px solid var(--bg);}
::-webkit-scrollbar-thumb:hover{background:#b9c0c9;}
/* ── Top bar ─────────────────────────────────────────── */
.topbar{
display:flex;align-items:center;gap:14px;padding:13px 22px;
background:linear-gradient(100deg,#0f172a 0%,#1e293b 45%,#1e3a8a 100%);color:#fff;
position:relative;z-index:5;box-shadow:0 2px 14px rgba(15,23,42,.18);
}
.topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.025em;display:flex;align-items:center;gap:9px;}
.topbar .logo .ball{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));}
.topbar .tag{font-size:13px;opacity:.82;font-weight:500;}
.topbar .tag b{color:#c4b5fd;font-weight:600;}
.topbar .sp{flex:1;}
.topbar .pill{font-size:11.5px;font-weight:600;background:rgba(255,255,255,.10);
padding:6px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.16);
display:inline-flex;align-items:center;gap:8px;}
.topbar .pill b{color:#c4b5fd;}
.topbar .live-dot{width:7px;height:7px;border-radius:50%;background:#34d399;
box-shadow:0 0 0 0 rgba(52,211,153,.6);animation:pulse 2s infinite;}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(52,211,153,.5);}70%{box-shadow:0 0 0 7px rgba(52,211,153,0);}100%{box-shadow:0 0 0 0 rgba(52,211,153,0);}}
/* ── 3-column layout ─────────────────────────────────── */
.app{display:grid;grid-template-columns:minmax(330px,35%) minmax(0,45%) minmax(190px,21%);
height:calc(100vh - 57px);}
.col{display:flex;flex-direction:column;min-height:0;min-width:0;}
.chat{border-right:1px solid var(--line);background:var(--bg);}
.sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
/* ── Chat column ─────────────────────────────────────── */
#chat{flex:1;overflow-y:auto;padding:20px 18px 10px;display:flex;flex-direction:column;gap:15px;}
/* FIFA 2026 themed hero — real stadium imagery on a vibrant gradient fallback
(looks premium even before the photo finishes loading or if it's blocked). */
.hero{position:relative;overflow:hidden;border:0;border-radius:18px;min-height:220px;
background:
url('https://images.unsplash.com/photo-1522778526097-ce0a22ceb253?w=1600&q=80') center/cover no-repeat,
linear-gradient(135deg,#1e1b4b 0%,#6d28d9 45%,#db2777 100%);
box-shadow:var(--shadow-md);display:flex;align-items:flex-end;}
.hero-overlay{position:absolute;inset:0;
background:linear-gradient(180deg,rgba(15,23,42,.18) 0%,rgba(15,23,42,.55) 52%,rgba(15,23,42,.93) 100%);}
.hero-content{position:relative;z-index:2;padding:20px 22px 21px;color:#fff;width:100%;}
.hero-badge{display:inline-block;font-size:10.5px;font-weight:800;letter-spacing:.09em;text-transform:uppercase;
color:#fff;background:linear-gradient(135deg,var(--wc-magenta),var(--wc-violet));
padding:5px 11px;border-radius:999px;margin-bottom:11px;box-shadow:0 4px 12px -3px rgba(219,39,119,.5);}
.hero h2{margin:0 0 8px;font-family:var(--display);font-size:25px;font-weight:700;line-height:1.08;
letter-spacing:-.025em;color:#fff;}
.hero h2 .hero-accent{background:linear-gradient(135deg,var(--wc-gold),var(--wc-pink));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;}
.hero p{margin:0;font-size:13px;color:rgba(255,255,255,.92);line-height:1.55;max-width:48ch;}
.hero-cold{margin-top:11px;font-size:11.5px;font-weight:600;color:#fde68a;
display:inline-flex;align-items:center;gap:6px;background:rgba(0,0,0,.24);
padding:5px 11px;border-radius:999px;border:1px solid rgba(251,191,36,.28);}
.msg{display:flex;gap:10px;max-width:94%;animation:fade .3s ease;}
.msg.user{align-self:flex-end;flex-direction:row-reverse;}
.a-avatar{width:31px;height:31px;border-radius:50%;background:linear-gradient(135deg,#1e3a8a,#312e81);
color:#fff;display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 31px;
box-shadow:var(--shadow-sm);}
.a-body{background:var(--surface);border:1px solid var(--line2);border-radius:16px;
padding:11px 14px;min-width:0;box-shadow:var(--shadow-sm);}
.msg.user .a-body{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border-color:transparent;box-shadow:0 6px 16px -6px rgba(124,58,237,.5);}
.a-content{font-size:14px;line-height:1.5;}
.a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
font-weight:600;padding-top:3px;flex-wrap:wrap;}
.spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);font-size:14px;}
@keyframes spin{to{transform:rotate(360deg);}}
@keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
.elapsed{font-size:12px;color:var(--violet);font-weight:700;font-variant-numeric:tabular-nums;
background:var(--violet-l);padding:1px 8px;border-radius:999px;}
.green{background:linear-gradient(135deg,#ecfdf5,#d1fae5);border:1px solid #a7f3d0;color:#065f46;
border-radius:12px;padding:10px 13px;font-size:14px;font-weight:700;}
.clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;line-height:1.5;}
.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;line-height:1.5;}
.retry-btn{margin-top:9px;background:#fff;border:1px solid var(--violet);color:var(--violet);
border-radius:9px;padding:7px 14px;font:inherit;font-weight:700;font-size:13px;cursor:pointer;
display:inline-flex;align-items:center;gap:6px;transition:.15s;}
.retry-btn:hover{background:var(--violet-l);}
.nemotron-note{font-size:11px;color:var(--mut2);margin-top:8px;font-weight:600;letter-spacing:.02em;}
/* input row */
.composer{border-top:1px solid var(--line);padding:12px 16px 14px;background:var(--surface);}
.composer textarea{width:100%;border:1px solid #d6d9df;border-radius:13px;padding:12px 14px;
font:inherit;font-size:14px;resize:none;outline:none;min-height:50px;background:var(--bg);
transition:.15s;line-height:1.45;}
.composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 4px rgba(124,58,237,.13);background:#fff;}
.composer .row{display:flex;align-items:center;gap:9px;margin-top:9px;}
.examples{display:flex;flex-wrap:wrap;gap:7px;padding:0 18px 8px;}
.chip{font-size:12px;background:var(--surface);border:1px solid var(--line2);border-radius:999px;
padding:6px 12px;color:var(--ink2);cursor:pointer;transition:.15s;box-shadow:var(--shadow-sm);}
.chip:hover{border-color:var(--violet);color:var(--violet);transform:translateY(-1px);}
button.primary{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border:0;
border-radius:11px;padding:10px 17px;font:inherit;font-weight:700;cursor:pointer;
display:inline-flex;align-items:center;gap:7px;box-shadow:0 6px 16px -6px rgba(124,58,237,.55);
transition:.15s;}
button.primary:hover{transform:translateY(-1px);box-shadow:0 10px 22px -6px rgba(124,58,237,.6);}
button.primary:disabled{opacity:.55;cursor:not-allowed;transform:none;}
.hint{font-size:11.5px;color:var(--mut2);}
/* ── Result column ───────────────────────────────────── */
.result{overflow-y:auto;background:var(--bg);}
#result{padding:18px 20px 28px;}
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;text-align:center;padding:50px 30px;
background:radial-gradient(125% 90% at 50% 0%,#1e293b 0%,#0f172a 72%);color:#cbd5e1;}
.empty .big{font-size:66px;margin-bottom:16px;filter:drop-shadow(0 8px 26px rgba(56,189,248,.35));}
.empty h3{margin:0 0 9px;font-family:var(--display);font-size:19px;color:#fff;font-weight:700;letter-spacing:-.015em;}
.empty p{margin:0;font-size:13.5px;max-width:360px;line-height:1.6;color:#94a3b8;}
/* ── "Building your trip" skeleton state (premium wait UX) ── */
/* Layla-style visible progress steps (N10): real tool activity + provenance,
never raw chain-of-thought. Driven by backend `progress` events. */
.steps{display:flex;flex-direction:column;gap:5px;margin-bottom:18px;
background:#fff;border:1px solid var(--line2);border-radius:14px;padding:13px 15px;box-shadow:var(--shadow-sm);}
.steps-h{font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin-bottom:7px;}
.step{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--mut2);transition:color .2s;}
.step .si{width:18px;height:18px;border-radius:50%;flex:0 0 18px;display:inline-flex;
align-items:center;justify-content:center;font-size:11px;background:#eef0f3;color:transparent;}
.step.is-running{color:var(--ink2);font-weight:600;}
.step.is-running .si{background:var(--violet);color:#fff;}
.step.is-running .si::after{content:"";width:7px;height:7px;border-radius:50%;background:#fff;
animation:pulse 1.1s infinite;}
.step.is-done{color:var(--ink2);}
.step.is-done .si{background:var(--emerald);color:#fff;}
.step.is-done .si::after{content:"✓";}
.step.is-fallback{color:#b45309;}
.step.is-fallback .si{background:#fef3c7;color:#b45309;}
.step.is-fallback .si::after{content:"!";}
.step.is-unavailable{color:var(--mut2);}
.step.is-unavailable .si{background:#f3f4f6;color:var(--mut2);}
.step.is-unavailable .si::after{content:"–";}
.step .st{flex:1;min-width:0;}
.step .sd{font-size:11.5px;color:var(--mut2);font-weight:500;}
.step.is-running .sd,.step.is-done .sd{color:inherit;opacity:.75;}
.building{padding:2px 0;}
.build-head{display:flex;align-items:center;gap:11px;background:linear-gradient(135deg,#0f172a,#1e3a8a);
color:#fff;border-radius:16px;padding:14px 17px;margin-bottom:18px;box-shadow:var(--shadow-md);
flex-wrap:wrap;}
.build-head .spin{color:#a5b4fc;}
.build-text{font-size:14px;font-weight:700;flex:1;min-width:120px;}
.build-head .elapsed{background:rgba(255,255,255,.14);color:#fff;border:1px solid rgba(255,255,255,.18);}
.cold-hint{font-size:12.5px;color:#c7d2fe;background:rgba(255,255,255,.08);border-radius:10px;
padding:8px 12px;margin-top:8px;line-height:1.45;width:100%;border:1px solid rgba(255,255,255,.10);}
@keyframes shimmer{100%{transform:translateX(100%);}}
.sk{position:relative;overflow:hidden;background:#e9ecf2;border-radius:13px;}
.sk::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
background:linear-gradient(90deg,transparent,rgba(255,255,255,.65),transparent);animation:shimmer 1.5s infinite;}
.sk-h{height:13px;margin-bottom:10px;}
.sk-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:15px;margin-bottom:22px;}
.sk-card{background:var(--surface);border:1px solid var(--line2);border-radius:18px;padding:18px;box-shadow:var(--shadow-sm);overflow:hidden;}
.sk-card .sk{margin-bottom:9px;}
.sk-card .sk.big{height:30px;width:60%;margin-bottom:14px;}
.sk-card .sk.line{height:12px;}
.sk-card .sk.line.w80{width:80%;}
.sk-card .sk.line.w60{width:60%;}
.sk-map{height:300px;border-radius:18px;margin-bottom:22px;}
.sk-tlrow{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:15px;
display:flex;gap:14px;align-items:center;margin-bottom:11px;box-shadow:var(--shadow-sm);overflow:hidden;}
.sk-tlrow .sq{width:32px;height:32px;border-radius:10px;flex:0 0 32px;}
.sk-tlrow .ln{flex:1;}
.sk-tlrow .ln .sk{height:12px;margin-bottom:7px;}
.sk-tlrow .ln .sk.w50{width:50%;}
/* ── Sidebar ─────────────────────────────────────────── */
.sidebar .sec{padding:16px 16px 15px;border-bottom:1px solid var(--line);}
.sidebar h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);font-weight:800;}
.brain{display:flex;gap:10px;align-items:flex-start;}
.brain .b{font-size:20px;line-height:1.2;}
.brain .t{font-size:12.5px;line-height:1.5;color:var(--ink2);}
.brain .t b{color:var(--violet);}
.legend-row{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--ink2);margin:6px 0;}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:0 0 9px;}
.dot.live{background:var(--emerald);box-shadow:0 0 0 2px rgba(16,185,129,.2);}
.dot.fallback{background:var(--amber);}
.badges{display:flex;flex-wrap:wrap;gap:5px;}
.badge{font-size:10.5px;font-weight:700;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;
border-radius:7px;padding:3px 8px;}
.fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:5px 0;}
.fact b{color:var(--ink);}
/* full-screen map */
#matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
height:100vh!important;width:100vw!important;border-radius:0!important;}
#md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
font-weight:700;border:0;border-radius:11px;padding:9px 15px;cursor:pointer;font-size:14px;
box-shadow:0 6px 18px rgba(0,0,0,.25);display:none;}
#md-fs-close.show{display:block;}
/* ── Agent Trace / Evidence drawer (live, streamed from `trace` SSE) ───────
A persistent host above the package area. It survives skeleton/clarify/
error swaps (so a grounding refusal — a core Best-Agent proof — stays
visible) and is fed by every `trace` event, so a judge watches the agent
reason tool-by-tool in real time. Rules mirror render.py _CSS so the live
drawer is identical to the standalone render_trace output. */
#md-live-trace{padding:16px 20px 0;}
#md-live-trace:empty{display:none;}
.md-pv{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:800;padding:1px 7px 1px 5px;border-radius:999px;}
.md-pv::before{content:"";width:5px;height:5px;border-radius:50%;display:inline-block;}
.md-pv-live{background:#ecfdf5;color:#047857;} .md-pv-live::before{background:#10b981;}
.md-pv-ex{background:#fffbeb;color:#b45309;} .md-pv-ex::before{background:#f59e0b;}
.md-pv-other{background:#f3f4f6;color:#4b5563;} .md-pv-other::before{background:#9ca3af;}
.md-trace{margin:22px 2px 6px;background:#fff;border:1px solid #eef0f3;border-radius:18px;
box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -14px rgba(17,24,39,.16);overflow:hidden;}
.md-trace > summary{list-style:none;cursor:pointer;padding:15px 18px;display:flex;align-items:center;
gap:11px;font-size:13.5px;font-weight:700;color:#111827;user-select:none;}
.md-trace > summary::-webkit-details-marker{display:none;}
.md-trace > summary .md-trace-ico{font-size:17px;}
.md-trace > summary .md-trace-chev{margin-left:auto;color:#9ca3af;font-size:13px;transition:transform .2s;flex:0 0 auto;}
.md-trace[open] > summary .md-trace-chev{transform:rotate(90deg);}
.md-trace > summary .md-trace-sub{font-size:12px;font-weight:600;color:#6b7280;}
.md-trace-body{padding:2px 18px 18px;display:grid;gap:14px;border-top:1px solid #f1f3f6;}
.md-trace-meta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;color:#6b7280;font-weight:600;margin-top:13px;}
.md-tag{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:4px 10px;border-radius:999px;}
.md-tag-agent{background:#ede9fe;color:#6d28d9;}
.md-tag-det{background:#fef3c7;color:#b45309;}
.md-tag-clarify{background:#dbeafe;color:#1d4ed8;}
.md-tag-failed{background:#fee2e2;color:#b91c1c;}
.md-trace-model{color:#374151;font-weight:700;}
.md-trace-sec h5{margin:0 0 8px;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#6b7280;}
.md-slots{display:flex;flex-wrap:wrap;gap:7px;}
.md-slot{font-size:12px;background:#f3f4f6;border-radius:9px;padding:6px 11px;color:#374151;font-weight:600;}
.md-slot b{color:#111827;font-weight:800;}
.md-slot.miss{background:#fff7ed;color:#c2410c;}
.md-ground{font-size:13px;line-height:1.5;color:#374151;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:11px;padding:10px 13px;}
.md-ground.refused{background:#fffbeb;border-color:#fde68a;color:#92400e;}
.md-ground b{color:#065f46;} .md-ground.refused b{color:#92400e;}
.md-tcalls{list-style:none;margin:0;padding:0;display:grid;gap:8px;counter-reset:tc;}
.md-tcall{position:relative;background:#fafbfc;border:1px solid #eef0f3;border-radius:11px;padding:10px 12px 10px 38px;font-size:12.5px;}
.md-tcall::before{counter-increment:tc;content:counter(tc);position:absolute;left:11px;top:10px;width:20px;height:20px;
border-radius:50%;background:#7c3aed;color:#fff;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;}
.md-tcall.is-failed{border-color:#fecaca;background:#fef5f5;} .md-tcall.is-failed::before{background:#ef4444;}
.md-tcall.is-skipped{opacity:.7;} .md-tcall.is-skipped::before{background:#9ca3af;}
.md-tcall .tc-head{font-weight:800;color:#111827;font-family:'Space Grotesk',Inter,sans-serif;font-size:13px;}
.md-tcall .tc-meta{color:#6b7280;margin-top:2px;}
.md-tcall .tc-src{display:inline-flex;gap:5px;flex-wrap:wrap;margin-top:5px;}
.md-evi{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;}
.md-evi-cell{border:1px solid #eef0f3;border-radius:11px;padding:9px 11px;font-size:12px;}
.md-evi-cell .ec-name{font-weight:700;color:#111827;display:flex;align-items:center;gap:6px;}
.md-evi-cell .ec-stat{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;margin-top:3px;}
.md-evi-cell.live .ec-stat{color:#047857;} .md-evi-cell.example .ec-stat{color:#b45309;}
.md-evi-cell.failed .ec-stat{color:#b91c1c;} .md-evi-cell.skipped .ec-stat{color:#6b7280;}
.md-formula-w{font-size:12.5px;color:#374151;margin-bottom:10px;} .md-formula-w b{color:#111827;}
.md-rank-row{display:grid;grid-template-columns:22px 1fr auto;gap:10px;align-items:center;padding:7px 0;border-top:1px solid #f3f4f6;font-size:12.5px;}
.md-rank-row:first-of-type{border-top:0;}
.md-rank-row .rr-rk{font-weight:800;color:#9ca3af;font-family:'Space Grotesk',Inter,sans-serif;}
.md-rank-row .rr-lbl{font-weight:700;color:#111827;}
.md-rank-row .rr-cost{color:#6b7280;font-variant-numeric:tabular-nums;}
.md-dimbar{display:flex;gap:9px;margin-top:5px;align-items:center;font-size:10.5px;color:#6b7280;}
.md-dimbar .db{flex:1;height:6px;border-radius:999px;background:#eef0f3;overflow:hidden;position:relative;}
.md-dimbar .db > i{position:absolute;left:0;top:0;bottom:0;background:#7c3aed;border-radius:999px;}
.md-dimbar .db.b > i{background:#2563eb;} .md-dimbar .db.g > i{background:#16a34a;}
.md-trace-empty{padding:14px 18px;color:#9ca3af;font-size:13px;}
@media (max-width:980px){
.app{grid-template-columns:1fr;height:auto;}
.chat{border-right:0;border-bottom:1px solid var(--line);}
.sidebar{border-left:0;border-top:1px solid var(--line);}
.result{min-height:60vh;}
}
</style>
</head>
<body>
<header class="topbar">
<div class="logo"><span class="ball"></span> MatchDay</div>
<div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
<div class="sp"></div>
<span class="pill"><span class="live-dot"></span>Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
</header>
<div class="app">
<!-- LEFT: conversation -->
<section class="col chat">
<div id="chat">
<div class="hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<span class="hero-badge">⚽ FIFA World Cup 2026 · Vancouver</span>
<h2>Plan your <span class="hero-accent">World Cup</span> trip</h2>
<p>Tell me where you're flying from, the match, your dates &amp; budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices &amp; honest provenance.</p>
<span class="hero-cold">⏳ First request may take a few minutes while the model warms up.</span>
</div>
</div>
</div>
<div class="examples" id="examples">
<span class="chip">Flying from Montreal, Canada vs Qatar, mid-range, June 26-29, just me</span>
<span class="chip">Toronto to see Brazil vs Germany, premium, July 12, 2 adults</span>
<span class="chip">From Halifax, Canada vs Morocco June 18, couple, luxury</span>
</div>
<div class="composer">
<textarea id="prompt" rows="2" placeholder="e.g. Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"
aria-label="Describe your trip"></textarea>
<div class="row">
<button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
<span class="hint">Enter to send · Shift+Enter for a newline</span>
</div>
</div>
</section>
<!-- MIDDLE: result -->
<section class="col result">
<div id="md-live-trace"></div>
<div id="result">
<div class="empty">
<div class="big">🏟️</div>
<h3>Your Vancouver 2026 packages appear here</h3>
<p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map of BC Place.</p>
</div>
</div>
</section>
<!-- RIGHT: sidebar -->
<aside class="col sidebar">
<div class="sec">
<h4>How it works</h4>
<div class="brain">
<div class="b">🧠</div>
<div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
</div>
<div class="brain" style="margin-top:11px;">
<div class="b">🤲</div>
<div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
</div>
</div>
<div class="sec">
<h4>Provenance — no hallucinated prices</h4>
<div class="legend-row"><span class="dot live"></span> <b>● live</b> — real SerpApi / Open-Meteo / OSM reading</div>
<div class="legend-row"><span class="dot fallback"></span> <b>example</b> — fixture (demo fallback)</div>
</div>
<div class="sec">
<h4>Built for Build Small</h4>
<div class="badges">
<span class="badge">🎨 Off-Brand</span>
<span class="badge">🤖 Best Agent</span>
<span class="badge">🟩 Nemotron</span>
<span class="badge">🟢 Modal</span>
<span class="badge">📡 Trace</span>
</div>
</div>
<div class="sec">
<h4>The match</h4>
<div class="fact">🏟️ <b>BC Place Stadium</b>, Vancouver</div>
<div class="fact">⏰ ~7:00 PM PT kickoff</div>
<div class="fact">📍 Downtown — walkable from central hotels</div>
</div>
</aside>
</div>
<button id="md-fs-close" onclick="matchdayFullscreen(false)" aria-label="Exit full screen">✕ Close</button>
<script>
const EXAMPLES = [
"Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me",
"I want to fly from Toronto to see Brazil vs Germany, premium, July 12, 2 adults",
"Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
"From Halifax, Canada vs Morocco June 18, couple, luxury",
];
document.getElementById("prompt").value = EXAMPLES[0];
// NOTE: status text is driven ONLY by real stream events (the backend's _pulse
// sends an accurate commentary heartbeat every ~9s during any long phase). The
// timer below drives the elapsed counter + cold-start hint only — it does NOT
// invent phase messages, which would be inaccurate when round 1 runs long.
let running = false;
let resultHandled = false; // the result event repeats on `complete` — render once
let elapsedTimer = null;
let lastRealAt = 0; // timestamp of last real stream event (ms)
let lastRealMsg = "";
const chatEl = document.getElementById("chat");
const resultEl = document.getElementById("result");
const promptEl = document.getElementById("prompt");
const sendBtn = document.getElementById("send");
// ── DOM helpers ────────────────────────────────────────
function addUserBubble(text){
const m = document.createElement("div");
m.className = "msg user";
m.innerHTML = `<div class="a-avatar">🧑</div><div class="a-body"><div class="a-content"></div></div>`;
m.querySelector(".a-content").textContent = text;
chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
return m;
}
function addAssistantBubble(){
const m = document.createElement("div");
m.className = "msg assistant";
m.innerHTML =
`<div class="a-avatar">🤖</div>
<div class="a-body">
<div class="a-content"></div>
<div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span><span class="elapsed" style="display:none">· 0:00</span></div>
</div>`;
chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
return m;
}
function setStatus(bubble, text){
const beat = bubble.querySelector(".beat-text");
if (beat) beat.textContent = text;
const bh = document.querySelector(".build-text");
if (bh) bh.textContent = text;
}
function setElapsed(bubble, secs){
const fmt = Math.floor(secs/60) + ":" + String(secs%60).padStart(2,"0");
const e = bubble.querySelector(".elapsed");
if (e){ e.textContent = "· " + fmt; e.style.display = "inline-block"; }
const be = document.querySelector(".build-head .elapsed");
if (be) be.textContent = "· " + fmt;
}
function hideBeat(bubble){
const beat = bubble.querySelector(".a-beat");
if (beat) beat.style.display = "none";
}
function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
// ── Skeleton "building your trip" state ────────────────
function showSkeleton(bubble){
resultEl.innerHTML =
`<div class="building">
<div class="build-head">
<span class="spin">◐</span>
<span class="build-text">Reading your trip request…</span>
<span class="elapsed">· 0:00</span>
<div class="cold-hint" style="display:none"></div>
</div>
<div class="steps" id="md-steps">
<div class="steps-h">Planning your trip</div>
</div>
<div class="sk-cards">
${`<div class="sk-card"><div class="sk big"></div><div class="sk line w80"></div><div class="sk line w60"></div><div class="sk line"></div><div class="sk line w80"></div></div>`.repeat(3)}
</div>
<div class="sk sk-map"></div>
${`<div class="sk-tlrow"><div class="sk sq"></div><div class="ln"><div class="sk w50"></div><div class="sk w80"></div></div></div>`.repeat(4)}
</div>`;
}
// ── Elapsed timer + phased progress + cold-start honesty ─
function startTimer(bubble){
stopTimer();
const t0 = performance.now();
elapsedTimer = setInterval(()=>{
const secs = Math.floor((performance.now()-t0)/1000);
setElapsed(bubble, secs);
// cold-start honesty after ~20s (status text itself comes from real events)
const hint = document.querySelector(".cold-hint");
if (hint && secs >= 20 && hint.dataset.shown !== "1"){
hint.dataset.shown = "1";
hint.style.display = "block";
hint.textContent = "⏳ Warming up Nemotron-3-Nano-30B on Modal — the first query after idle can take ~1–2 min. It's working; the packages will stream in as soon as they're scored.";
}
}, 1000);
}
function stopTimer(){ if (elapsedTimer){ clearInterval(elapsedTimer); elapsedTimer = null; } }
// ── Visible agent progress steps (N10 / Layla tripProgress) ───────────
// Ordered checklist of SAFE tool activity + provenance. Driven by real backend
// `progress` events; never exposes raw chain-of-thought. Each step's status is
// pending → running → done | fallback | unavailable.
const STEPS = [
{key:"read", label:"Reading your request"},
{key:"extract", label:"Extracting trip details"},
{key:"flights", label:"Checking flights"},
{key:"hotels", label:"Checking hotels"},
{key:"weather", label:"Checking weather"},
{key:"nearby", label:"Checking nearby spots"},
{key:"score", label:"Scoring packages"},
{key:"itinerary", label:"Building itinerary"},
{key:"links", label:"Preparing booking & transit links"},
{key:"ready", label:"Packages ready"},
];
let stepState = {};
function renderSteps(){
const box = document.getElementById("md-steps");
if (!box) return;
let html = '<div class="steps-h">Planning your trip</div>';
for (const s of STEPS){
const st = stepState[s.key];
const status = st ? st.status : "pending";
const cls = status === "pending" ? "" : "is-" + status;
const detail = (st && st.text) ? `<span class="sd"> · ${esc(st.text)}</span>` : "";
html += `<div class="step ${cls}"><span class="si"></span><span class="st">${s.label}${detail}</span></div>`;
}
box.innerHTML = html;
}
// ── Live Agent Trace / Evidence drawer ──────────────────
// Mirrors render.py render_trace: extracted intent → verified-fixture grounding
// → numbered tool calls → per-category live/example/failed evidence → the
// deterministic ranking formula + why each package won. Fed by `trace` SSE
// events; each event replaces the drawer with the accumulated state so far.
const TRACE_DOTS = {live:"🟢", example:"🟠", failed:"🔴", skipped:"⚪", partial:"🟡", ok:"•"};
const TRACE_TAG = {agent:["md-tag-agent","Agent"], deterministic:["md-tag-det","Deterministic"],
clarify:["md-tag-clarify","Clarify"], error:["md-tag-failed","Error"]};
const TRACE_W = {cost:"Affordable", buffer:"Safe arrival", transit:"Close",
affordability:"Affordable", arrival_buffer:"Safe arrival", proximity:"Close"};
const TRACE_DIM = {affordability:["Affordable",""], arrival_buffer:["Safe arrival","b"], proximity:["Close","g"]};
const TRACE_TIER = {budget:"Budget", mid_range:"Balanced", premium:"Premium"};
const TRACE_CAT = {flights:"Flights", hotels:"Hotels", weather:"Weather", amenities:"Nearby"};
const LIVE_SRC = new Set(["serpapi","openmeteo","osm"]);
function _num(g){ const n = Number(g); return Number.isFinite(n) ? String(n) : String(g); }
function _range(a,b){ if(!a&&!b) return ""; if(a&&b&&a!==b) return a+" → "+b; return String(a||b||""); }
function _slotV(v){ return (v===null||v===undefined||v==="") ? "—" : esc(v); }
function renderTraceLive(d){
const host = document.getElementById("md-live-trace");
if (!host || !d) return;
const mode = d.mode || "";
const tag = TRACE_TAG[mode] || ["md-tag-det", mode || "—"];
let meta = `<span class="md-tag ${tag[0]}">${esc(tag[1])}</span>`;
if (d.model) meta += `<span class="md-trace-model">${esc(d.model)}</span>`;
if (d.rounds) meta += `<span>${d.rounds} loop round${d.rounds!==1?"s":""}</span>`;
meta += `<span>outcome: ${esc(d.outcome_status || "—")}</span>`;
let sections = "";
// ① Extracted Trip Intent
const intent = d.intent || {}, missing = d.missing_slots || [];
if (intent && Object.keys(intent).length || missing.length){
const defs = [["Origin",intent.origin],["Match",intent.match_name],["Match date",intent.match_date],
["Stay",_range(intent.check_in,intent.check_out)],["Travelers",intent.travelers],["Tier",intent.budget_tier]];
let slots = defs.map(([k,v])=>`<span class="md-slot"><b>${esc(k)}:</b> ${_slotV(v)}</span>`).join("");
slots += missing.map(m=>`<span class="md-slot miss">missing: ${esc(m)}</span>`).join("");
sections += `<div class="md-trace-sec"><h5>① Extracted Trip Intent</h5><div class="md-slots">${slots}</div></div>`;
}
// ② Match Grounding (verified 2026 fixtures, or honest refusal)
const g = d.grounding;
if (g){
if (g.recognized){
const bits = [g.corrected ? "<b>Date corrected</b> to the verified 2026 fixture"
: "<b>Match confirmed</b> against the verified 2026 schedule"];
if (g.kickoff) bits.push(`kickoff <b>${esc(g.kickoff)}</b>`);
if (g.venue) bits.push(`at <b>${esc(g.venue)}</b>`);
sections += `<div class="md-trace-sec"><h5>② Match Grounding</h5><div class="md-ground">${bits.join(" · ")}${g.note?". "+esc(g.note):""}</div></div>`;
} else {
const note = g.note || "Not a recognized 2026 fixture — the agent refused to invent a trip.";
sections += `<div class="md-trace-sec"><h5>② Match Grounding</h5><div class="md-ground refused"><b>Refused</b> — ${esc(note)}</div></div>`;
}
}
// ③ Tools Called (the multi-step proof)
const tcalls = d.tool_calls || [];
if (tcalls.length){
const rows = tcalls.map(t=>{
const st = t.status || "ok";
const cls = st==="failed"?" is-failed":(st==="skipped"?" is-skipped":"");
const src = (t.sources||[]).filter(s=>s).map(s=>`<span class="md-pv md-pv-${LIVE_SRC.has(s)?"live":"ex"}">${esc(s)}</span>`).join("");
const dur = t.duration_ms ? ` · ${t.duration_ms} ms` : "";
const args = t.args || {};
const argstr = args && Object.keys(args).length ? " · "+Object.entries(args).map(([k,v])=>`${esc(k)}=${esc(v)}`).join(", ") : "";
const detail = t.detail ? `<div class="tc-meta">${esc(t.detail)}</div>` : "";
return `<li class="md-tcall${cls}"><span class="tc-head">${esc(t.name||"tool")}</span>`+
`<div class="tc-meta">${esc(st)}${dur}${argstr}</div>${detail}`+
`${src?`<span class="tc-src">${src}</span>`:""}</li>`;
}).join("");
sections += `<div class="md-trace-sec"><h5>③ Tools Called</h5><ol class="md-tcalls">${rows}</ol></div>`;
}
// ④ Evidence per category (live vs example vs failed vs skipped)
const evi = d.evidence || {};
if (evi && Object.keys(evi).length){
const cells = ["flights","hotels","weather","amenities"].map(cat=>{
const st = evi[cat] || "skipped";
return `<div class="md-evi-cell ${st}"><div class="ec-name">${TRACE_DOTS[st]||"•"} ${esc(TRACE_CAT[cat]||cat)}</div><div class="ec-stat">${esc(st)}</div></div>`;
}).join("");
sections += `<div class="md-trace-sec"><h5>④ Evidence (live vs example)</h5><div class="md-evi">${cells}</div></div>`;
}
// ⑤ Ranking formula + why each package won
const rk = d.ranking || {};
if (rk.packages && rk.packages.length){
const w = rk.weights || {};
const wline = `<div class="md-formula-w"><b>${esc(TRACE_TIER[rk.tier]||rk.tier||"Balanced")}</b> tier — `+
`composite = ${Object.entries(w).map(([k,v])=>`${TRACE_W[k]||k}×${_num(v)}`).join(" + ")}</div>`;
const rows = rk.packages.map(p=>{
const sc = p.scores || {};
const bars = ["affordability","arrival_buffer","proximity"].map(k=>{
const val = Number(sc[k]||0), pct = Math.max(0,Math.min(100,Math.round(val*100)));
const dm = TRACE_DIM[k]||[k,""];
return `<span class="db ${dm[1]}" title="${dm[0]} ${_num(val)}"><i style="width:${pct}%;"></i></span>`;
}).join("");
const cost = Number(p.total_cost_cad||0);
return `<div class="md-rank-row"><span class="rr-rk">#${p.rank}</span>`+
`<span><span class="rr-lbl">${esc(p.label||"")}</span><span class="md-dimbar">${bars}</span>${p.why?" — "+esc(p.why):""}</span>`+
`<span class="rr-cost">$${Math.round(cost).toLocaleString()} CAD</span></div>`;
}).join("");
sections += `<div class="md-trace-sec"><h5>⑤ Ranking (deterministic)</h5>${wline}${rows}</div>`;
}
// Honesty / degradation notes
const notes = d.notes || [];
if (notes.length){
sections += `<div class="md-trace-sec"><h5>Notes</h5>`+
notes.map(n=>`<div class="md-rank-row" style="grid-template-columns:1fr;"><span>${esc(n)}</span></div>`).join("")+`</div>`;
}
const body = sections || `<div class="md-trace-empty">No trace recorded for this turn.</div>`;
host.innerHTML =
`<details class="md-trace" open><summary>`+
`<span class="md-trace-ico">🧭</span>`+
`<span>Agent Trace &amp; Evidence</span>`+
`<span class="md-trace-sub">how this trip was reasoned, tool-by-tool</span>`+
`<span class="md-trace-chev">▸</span></summary>`+
`<div class="md-trace-body"><div class="md-trace-meta">${meta}</div>${body}</div></details>`;
}
// ── Streaming via raw SSE ───────────────────────────────
function handleEvent(ev, bubble){
if (!ev) return;
// Live Agent Trace / Evidence drawer (Best-Agent proof): every `trace` event
// repaints the persistent drawer, so reasoning streams tool-by-tool in real
// time. Rendered into #md-live-trace (outside #result) so it survives the
// skeleton→result swap AND the clarify/error clear — a grounding refusal
// (agent won't invent a trip around a fake match) therefore stays visible.
if (ev.type === "trace"){
renderTraceLive(ev.data);
return;
}
if (ev.type === "progress"){
if (ev.step){
stepState[ev.step] = {status: ev.status || "running", text: ev.text || ""};
renderSteps();
if (ev.step === "ready" && ev.status === "done"){ setStatus(bubble, "Done — see your packages →"); }
else if (ev.text){ setStatus(bubble, ev.text); }
}
return;
}
if (ev.type === "commentary"){
lastRealAt = performance.now(); lastRealMsg = ev.text || "";
setStatus(bubble, lastRealMsg);
} else if (ev.type === "greenlight"){
lastRealAt = performance.now();
bubble.querySelector(".a-content").innerHTML =
`<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
setStatus(bubble, "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather…");
} else if (ev.type === "clarify"){
bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
hideBeat(bubble); stopTimer(); clearSkeleton();
} else if (ev.type === "error"){
bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
hideBeat(bubble); stopTimer(); clearSkeleton();
} else if (ev.type === "result"){
if (resultHandled) return; // `complete` re-emits the final yield — render once
resultHandled = true;
lastRealAt = performance.now();
hideBeat(bubble); stopTimer();
bubble.querySelector(".a-content").innerHTML =
`<div class="green">✅ Built 3 ranked packages — see them on the map →</div>`;
resultEl.innerHTML = ev.html;
activateScripts(resultEl); // re-init the injected Leaflet map
if (ev.explanation && ev.explanation.trim()){
const nb = document.createElement("div");
nb.className = "msg assistant";
const safe = esc(ev.explanation).replace(/\n/g,"<br>");
nb.innerHTML =
`<div class="a-avatar">🤖</div><div class="a-body">
<div class="a-content"><b style="color:#3730a3">🤖 Nemotron compares your options</b><br>${safe}
<div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
</div></div>`;
chatEl.appendChild(nb);
}
chatEl.scrollTop = chatEl.scrollHeight;
}
}
function clearSkeleton(){
const b = resultEl.querySelector(".building");
if (b && !resultHandled){
resultEl.innerHTML = `<div class="empty"><div class="big">🏟️</div><h3>Your trip packages will appear here</h3><p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots.</p></div>`;
}
}
function activateScripts(root){
// innerHTML-inserted <script> tags don't auto-run — re-create them so the
// Leaflet init (and any other inline script in the result fragment) executes.
root.querySelectorAll("script").forEach(old=>{
const s = document.createElement("script");
if (old.src){ s.src = old.src; } else { s.textContent = old.textContent; }
old.replaceWith(s);
});
}
function showStreamError(bubble, text, retryText){
stopTimer();
hideBeat(bubble);
clearSkeleton();
const c = bubble.querySelector(".a-content");
c.innerHTML = `<div class="err">⚠️ ${esc(text||"Connection to the agent was interrupted.")}</div>
<button class="retry-btn" type="button">↻ Try again</button>`;
c.querySelector(".retry-btn").addEventListener("click", ()=>{ runTrip(retryText); });
}
async function runTrip(forcedText){
if (running) return;
const text = (forcedText == null ? promptEl.value : forcedText).trim();
if (!text) return;
running = true; sendBtn.disabled = true; resultHandled = false;
lastRealAt = 0; lastRealMsg = "";
stepState = {}; // reset progress steps for the new run
const _traceHost = document.getElementById("md-live-trace");
if (_traceHost) _traceHost.innerHTML = ""; // reset the live trace drawer for the new run
addUserBubble(text);
const bubble = addAssistantBubble();
showSkeleton(bubble);
startTimer(bubble);
let completed = false;
try{
const resp = await fetch("/gradio_api/call/plan_trip", {
method: "POST", headers: {"Content-Type":"application/json"},
body: JSON.stringify({ data: [text] }),
});
if (!resp.ok){ throw new Error("Server returned HTTP " + resp.status); }
const j = await resp.json();
const eventId = j && j.event_id;
if (!eventId){ throw new Error("No event id returned from the queue."); }
const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
es.addEventListener("generating", (e)=>{
try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }
catch(err){ console.warn("MatchDay: skipped a malformed stream chunk", err); }
});
es.addEventListener("complete", (e)=>{
completed = true;
try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
es.close(); finish();
});
// gradio keepalive (ignored) — just mark the stream alive
es.addEventListener("heartbeat", ()=>{ lastRealAt = performance.now(); });
es.addEventListener("error", ()=>{
es.close();
if (completed) return; // clean end — EventSource fires error on close
if (resultHandled) return; // already rendered; nothing to do
showStreamError(bubble, "Connection to the agent dropped mid-stream.", text);
finish();
});
}catch(err){
showStreamError(bubble, err && err.message ? err.message : "Request failed.", text);
finish();
}
}
function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; stopTimer(); }
// ── Full-screen map (Layla frame 24) ───────────────────
function matchdayFullscreen(on){
const mapEl = document.getElementById("matchday-map");
const closeBtn = document.getElementById("md-fs-close");
if (!mapEl) return;
if (on === undefined) on = !mapEl.classList.contains("fs");
mapEl.classList.toggle("fs", on);
closeBtn.classList.toggle("show", on);
setTimeout(()=>{ if (window._matchdayMap){ window._matchdayMap.invalidateSize(); } }, 120);
}
window.matchdayFullscreen = matchdayFullscreen;
// ── Examples + keyboard ────────────────────────────────
document.getElementById("examples").addEventListener("click", (e)=>{
const chip = e.target.closest(".chip");
if (!chip) return;
promptEl.value = chip.textContent;
runTrip(chip.textContent);
});
promptEl.addEventListener("keydown", (e)=>{
if (e.key === "Enter" && !e.shiftKey){ e.preventDefault(); runTrip(); }
});
</script>
</body>
</html>