import { useState, useEffect } from "react";
// ══════════════════════════════════════════════════════════════════════════════
// STYLES — deep forest aesthetic
// ══════════════════════════════════════════════════════════════════════════════
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;700&display=swap');
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#070f04; --bg2:#0d160a; --bg3:#131e0e;
--panel:#192512; --panel2:#1e2d16;
--bdr:#233518; --bdr2:#334825; --bdr3:#4a6535;
--fern:#5a9635; --sage:#7ab840; --lime:#a0d855;
--moss:#3d6e22; --bark:#6a4218; --earth:#a06828;
--txt:#b8cca0; --txt2:#7aaa55; --txt3:#4a6835; --txt4:#2e4220;
--red:#b83828; --amb:#c07820;
--fD:'Bebas Neue',sans-serif;
--fM:'IBM Plex Mono',monospace;
}
body{background:var(--bg);color:var(--txt);font-family:var(--fM);min-height:100vh;overflow-x:hidden}
/* Grain */
body::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.04'/%3E%3C/svg%3E");pointer-events:none;z-index:9998;opacity:.6}
.app{max-width:1000px;margin:0 auto;padding:0 14px 80px}
/* Header */
.hdr{padding:22px 0 16px;border-bottom:1px solid var(--bdr);margin-bottom:18px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.logo{display:flex;align-items:center;gap:10px}
.logo-leaf{width:40px;height:40px;background:var(--fern);clip-path:polygon(50%0%,85%15%,100%50%,85%85%,50%100%,15%85%,0%50%,15%15%);display:flex;align-items:center;justify-content:center;font-family:var(--fD);font-size:22px;color:#050c02;line-height:1;flex-shrink:0}
.logo h1{font-family:var(--fD);font-size:clamp(28px,7vw,44px);letter-spacing:5px;line-height:1;color:var(--txt)}
.hdr-sub{font-size:9px;letter-spacing:3px;text-transform:uppercase;color:var(--fern);margin-left:auto;align-self:flex-end;padding-bottom:3px}
/* Tabs */
.tabs{display:flex;gap:2px;margin-bottom:20px;background:var(--bg2);border:1px solid var(--bdr);padding:3px;overflow-x:auto}
.tab{flex:1;min-width:70px;padding:8px 4px;background:transparent;border:none;cursor:pointer;font-family:var(--fD);font-size:clamp(12px,2vw,15px);letter-spacing:1.5px;text-transform:uppercase;color:var(--txt4);transition:all .15s;white-space:nowrap;text-align:center}
.tab:hover{color:var(--txt3);background:var(--bg3)}
.tab.on{background:var(--fern);color:#050c02}
/* Buttons */
.btn{padding:8px 14px;border:1px solid var(--bdr2);background:var(--bg3);color:var(--txt3);font-family:var(--fM);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;cursor:pointer;transition:all .15s}
.btn:hover{border-color:var(--fern);color:var(--sage)}
.btn:disabled{opacity:.35;cursor:not-allowed}
.btn-p{background:var(--fern);color:#050c02;border-color:var(--fern);font-weight:700}
.btn-p:hover{background:var(--sage);border-color:var(--sage);color:#050c02}
.btn-sm{padding:5px 9px;font-size:9px}
.btn-d:hover{border-color:var(--red);color:var(--red)}
/* Forms */
.field{display:flex;flex-direction:column;gap:4px}
.field label{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--txt3);font-weight:700}
.field input,.field select,.field textarea{background:var(--bg2);border:1px solid var(--bdr);color:var(--txt);font-family:var(--fM);font-size:12px;padding:8px 10px;outline:none;transition:border-color .15s;width:100%}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--fern)}
.field select{cursor:pointer}
.field select option{background:var(--bg2)}
.field textarea{resize:vertical;min-height:70px;line-height:1.5}
.field .hint{font-size:9px;color:var(--txt4);letter-spacing:.5px;margin-top:2px}
/* Grid */
.g2{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.g3{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
.g4{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px}
.full{grid-column:1/-1}
/* Section header */
.sH{font-family:var(--fD);font-size:12px;letter-spacing:2.5px;color:var(--txt3);padding:16px 0 8px;border-bottom:1px solid var(--bdr);margin-bottom:10px;display:flex;align-items:center;gap:8px}
.sH::before{content:'';display:block;width:3px;height:12px;background:var(--fern);flex-shrink:0}
/* Component card */
.cCard{background:var(--bg3);border:1px solid var(--bdr);padding:12px;cursor:pointer;transition:all .2s;position:relative;user-select:none}
.cCard:hover{border-color:var(--moss);background:var(--panel)}
.cCard.sel{border-color:var(--fern);background:var(--panel)}
.cCard .cBrand{font-size:8px;letter-spacing:2px;text-transform:uppercase;color:var(--txt3)}
.cCard .cModel{font-family:var(--fD);font-size:19px;letter-spacing:1px;color:var(--txt);line-height:1.1;margin:2px 0 6px}
.cCard .cTags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
.cCard .cTag{font-size:8px;letter-spacing:1px;text-transform:uppercase;padding:2px 5px;background:var(--bg2);border:1px solid var(--bdr2);color:var(--txt4)}
.cCard .cRec{border-color:var(--fern);color:var(--fern)}
.cCard .cCheck{position:absolute;top:8px;right:8px;width:14px;height:14px;background:var(--fern);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:#050c02;font-weight:900;line-height:1}
.cCard .cNote{font-size:9px;color:var(--txt3);line-height:1.4;margin-top:6px;border-top:1px solid var(--bdr);padding-top:6px}
/* Rider profile */
.riderBar{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;background:var(--panel);border:1px solid var(--bdr);padding:14px;margin-bottom:18px}
/* Slider */
.slW{display:flex;flex-direction:column;gap:4px}
.slH{display:flex;justify-content:space-between;align-items:baseline}
.slH span:first-child{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--txt3);font-weight:700}
.slV{font-family:var(--fD);font-size:18px;letter-spacing:1px;color:var(--sage)}
.slU{font-size:9px;color:var(--txt3);margin-left:2px;font-family:var(--fM)}
input[type=range]{width:100%;-webkit-appearance:none;appearance:none;height:2px;background:var(--bdr2);outline:none;cursor:pointer;margin:4px 0}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;background:var(--fern);cursor:pointer;clip-path:polygon(50%0%,100%50%,50%100%,0%50%)}
/* Spec blocks */
.specs{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px;margin:10px 0}
.spB{background:var(--bg2);border:1px solid var(--bdr);padding:10px 12px}
.spL{font-size:8px;letter-spacing:1.5px;text-transform:uppercase;color:var(--txt3);margin-bottom:3px}
.spV{font-family:var(--fD);font-size:22px;letter-spacing:1px;color:var(--sage);line-height:1}
.spU{font-size:9px;color:var(--txt3);margin-left:2px;font-family:var(--fM)}
/* Meter bars (grip, feel etc) */
.mBar{display:flex;align-items:center;gap:8px;margin:3px 0}
.mLabel{font-size:9px;color:var(--txt3);width:90px;letter-spacing:.5px;text-transform:uppercase;flex-shrink:0}
.mTrack{flex:1;height:4px;background:var(--bdr2);border-radius:0;overflow:hidden}
.mFill{height:100%;transition:width .3s}
/* AI output */
.aiOut{background:var(--bg2);border:1px solid var(--bdr);border-left:3px solid var(--fern);padding:14px 16px;font-size:12px;line-height:1.9;color:var(--txt);white-space:pre-wrap;max-height:420px;overflow-y:auto;margin-top:12px}
.thinking{display:flex;align-items:center;gap:6px;color:var(--fern);font-size:11px;letter-spacing:1px;padding:12px 0}
.dot{width:6px;height:6px;background:var(--fern);border-radius:50%;animation:bl 1.2s infinite}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
@keyframes bl{0%,100%{opacity:1}50%{opacity:.15}}
/* Garage */
.gGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
.gCard{background:var(--panel);border:1px solid var(--bdr);border-left:3px solid var(--moss);padding:14px;cursor:pointer;transition:all .15s}
.gCard:hover{border-left-color:var(--fern);background:var(--panel2)}
.gBike{font-family:var(--fD);font-size:20px;letter-spacing:1px;text-transform:uppercase;color:var(--txt)}
.gSub{font-size:10px;color:var(--txt3);letter-spacing:1px;margin:2px 0 8px}
.gPills{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
.pill{display:inline-block;font-size:8px;letter-spacing:1px;text-transform:uppercase;padding:2px 6px;background:var(--bg3);border:1px solid var(--bdr2);color:var(--txt4)}
.pill-g{border-color:var(--moss);color:var(--fern)}
.pill-e{border-color:var(--earth);color:var(--amb)}
/* Toast */
.toast{position:fixed;bottom:20px;right:18px;background:var(--fern);color:#050c02;font-family:var(--fM);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:9px 16px;z-index:10000;animation:ti .2s ease}
@keyframes ti{from{transform:translateY(14px);opacity:0}to{transform:translateY(0);opacity:1}}
/* Notice */
.notice{font-size:10px;color:var(--txt3);line-height:1.6;padding:10px 12px;background:var(--bg3);border-left:2px solid var(--bdr3)}
/* Feel slider */
.feelRow{display:grid;grid-template-columns:120px 1fr 32px;gap:8px;align-items:center;margin:6px 0}
.feelLabel{font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--txt3)}
.feelScore{font-size:12px;font-weight:700;color:var(--sage);text-align:right;font-family:var(--fD)}
/* Misc */
.flex{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.flexE{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap}
.mt{margin-top:12px} .mt2{margin-top:20px} .mb{margin-bottom:10px}
.divider{height:1px;background:var(--bdr);margin:18px 0}
.empty{text-align:center;padding:50px 20px;color:var(--txt3)}
.empty-i{font-size:42px;margin-bottom:12px;opacity:.3}
.empty h3{font-family:var(--fD);font-size:22px;letter-spacing:2px;text-transform:uppercase;color:var(--txt2);margin-bottom:6px}
.empty p{font-size:11px;line-height:1.6}
@media(max-width:540px){
.tabs{flex-wrap:wrap}
.tab{flex:none}
.g2,.g3,.g4{grid-template-columns:1fr}
.riderBar{grid-template-columns:1fr 1fr}
.feelRow{grid-template-columns:80px 1fr 28px}
}
`;
// ══════════════════════════════════════════════════════════════════════════════
// COMPONENT DATABASE (curated from manufacturer specs + community data)
// ══════════════════════════════════════════════════════════════════════════════
const FRAMES = [
{ id:'tr-sentinel', brand:'Transition', model:'Sentinel', travel:140, ha:64.0, color:'#e07020', geo:'enduro',
bestFor:['enduro','trail'], desc:'Aggressive trail / light enduro. Progressive geometry, great for technical terrain.' },
{ id:'tr-scout', brand:'Transition', model:'Scout', travel:130, ha:65.5, color:'#2060c8', geo:'trail',
bestFor:['trail','xc'], desc:'Playful short-travel trail weapon. Efficient climber, fun descender.' },
{ id:'no-sight', brand:'Norco', model:'Sight VLT', travel:140, ha:64.0, color:'#c02020', geo:'enduro',
bestFor:['enduro','trail'], desc:'Full-power e-MTB enduro. Long, low, slack geometry. (Electric)' },
{ id:'ca-spectral', brand:'Canyon', model:'Spectral', travel:140, ha:64.5, color:'#1a1a1a', geo:'trail',
bestFor:['trail','enduro'], desc:'Versatile all-mountain. Balanced pedaling efficiency and descending capability.' },
{ id:'la-coal', brand:'Last', model:'Coal', travel:150, ha:63.5, color:'#384040', geo:'enduro',
bestFor:['enduro'], desc:'Austrian enduro machine. Very long and slack, huge travel, shreds everything.' },
{ id:'ev-calling', brand:'Evil', model:'The Calling', travel:140, ha:65.0, color:'#c02838', geo:'trail',
bestFor:['trail','enduro'], desc:'High-performance trail bike. Sharp, responsive handling with huge capability.' },
];
const FORKS = [
{ id:'mn-mezzer', brand:'Manitou', model:'Mezzer', travels:[140,150,160], damper:'IsoSpeed', fcolor:'#9090a0',
psi:{ lt:[60,72], md:[70,84], hv:[82,100] }, sag:{ xc:20, trail:25, enduro:27 },
reb:{ lt:14, md:11, hv:9 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Superb small-bump. IsoSpeed damper is very tunable. Lighter than Fox/RS alternatives.' },
{ id:'fx-38', brand:'Fox', model:'38 Factory', travels:[160,170,180], damper:'GRIP2', fcolor:'#f06010',
psi:{ lt:[65,78], md:[76,92], hv:[88,110] }, sag:{ xc:20, trail:25, enduro:28 },
reb:{ lt:13, md:11, hv:8 }, lsc:{ lt:6, md:8, hv:10 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Enduro/DH dedicated. Very stiff chassis. GRIP2 is industry-leading damper. Run 25% sag, low rebound.' },
{ id:'fx-36', brand:'Fox', model:'36 Factory', travels:[140,150,160], damper:'GRIP2', fcolor:'#f06010',
psi:{ lt:[60,74], md:[70,86], hv:[82,102] }, sag:{ xc:20, trail:25, enduro:27 },
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:1, hv:2 },
notes:'All-mountain workhorse. Best balance of weight, stiffness and feel. Most popular enduro fork.' },
{ id:'fx-34', brand:'Fox', model:'34 Factory', travels:[120,130,140], damper:'GRIP2', fcolor:'#f06010',
psi:{ lt:[55,68], md:[64,80], hv:[76,94] }, sag:{ xc:20, trail:22, enduro:25 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:0, hv:1 },
notes:'Lightweight XC/trail fork. Best paired with lighter frames and short-travel builds.' },
{ id:'rs-zeb', brand:'RockShox', model:'ZEB Ultimate', travels:[150,160,170,180], damper:'DebonAir+', fcolor:'#c8a010',
psi:{ lt:[62,76], md:[72,88], hv:[84,106] }, sag:{ xc:20, trail:25, enduro:28 },
reb:{ lt:14, md:12, hv:10 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'DebonAir+ gives plush small-bump feel. Slightly more supple than Fox 38 for trail. Great enduro choice.' },
{ id:'rs-lyrik', brand:'RockShox', model:'Lyrik Ultimate', travels:[140,150,160], damper:'DebonAir+', fcolor:'#c8a010',
psi:{ lt:[58,72], md:[68,83], hv:[80,100] }, sag:{ xc:20, trail:25, enduro:28 },
reb:{ lt:14, md:12, hv:10 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:1, hv:2 },
notes:'Best all-rounder in its travel range. Plush + supportive. Pairs perfectly with Super Deluxe shock.' },
{ id:'rs-pike', brand:'RockShox', model:'Pike Ultimate', travels:[120,130,140], damper:'DebonAir+', fcolor:'#c8a010',
psi:{ lt:[55,68], md:[64,78], hv:[76,93] }, sag:{ xc:20, trail:22, enduro:25 },
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:3, md:5, hv:7 }, hsc:{ lt:2, md:3, hv:3 }, tok:{ lt:0, md:0, hv:1 },
notes:'Lightweight, responsive. Great XC/trail. Zero tokens to start — very progressive air spring.' },
];
const SHOCKS = [
{ id:'fx-x2', brand:'Fox', model:'X2 Factory', type:'air', hsr:true, hsc:true, scolor:'#f06010',
psi:{ lt:[140,158], md:[158,185], hv:[185,212] }, sag:{ xc:25, trail:28, enduro:31 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Top Fox air shock. 4-way adjust (HSC/LSC/HSR/LSR). Best for technical high-speed terrain.' },
{ id:'fx-fx', brand:'Fox', model:'Float X Factory', type:'air', hsr:false, hsc:false, scolor:'#f06010',
psi:{ lt:[132,152], md:[152,178], hv:[178,205] }, sag:{ xc:25, trail:28, enduro:30 },
reb:{ lt:11, md:9, hv:7 }, lsc:{ lt:5, md:7, hv:9 }, hsc:null, tok:{ lt:0, md:1, hv:2 },
notes:'Simplified 2-way. Easier to set up than X2. Still very capable for trail/enduro use.' },
{ id:'rs-vivid', brand:'RockShox', model:'Vivid Air Ultimate', type:'air', hsr:true, hsc:true, scolor:'#c8a010',
psi:{ lt:[128,150], md:[150,178], hv:[178,208] }, sag:{ xc:25, trail:28, enduro:32 },
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'More supple off the top than Fox. DebonAir air spring pairs perfectly with Lyrik/ZEB fork.' },
{ id:'rs-sdu-air', brand:'RockShox', model:'Super Deluxe Ultimate', type:'air', hsr:false, hsc:true, scolor:'#c8a010',
psi:{ lt:[135,158], md:[158,184], hv:[184,212] }, sag:{ xc:25, trail:28, enduro:31 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:3, md:5, hv:7 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Proven OEM workhorse. Consistent, reliable. HSC/LSC on Factory version. Great trail shock.' },
{ id:'rs-sdu-coil', brand:'RockShox', model:'Super Deluxe Coil Ultimate', type:'coil', hsr:false, hsc:true, scolor:'#c8a010',
sag:{ xc:30, trail:33, enduro:36 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:3, md:4, hv:5 },
spring:{ lt:'350–400 lb/in', md:'400–500 lb/in', hv:'500–600 lb/in' },
notes:'Coil = bottomless feel, no harshness on big hits. Select spring: lighter = softer. Requires respring to tune.' },
];
const TIRES = {
front: [
{ id:'mx-assegai-f', brand:'Maxxis', model:'Assegai', w:2.5, grip:10, roll:3, best:['enduro','dh','wet'],
psi:{ lt:[17,22], md:[21,26], hv:[25,30] }, notes:'Max grip, minimum rolling speed. Dominant enduro front tire worldwide.' },
{ id:'mx-dissector-f', brand:'Maxxis', model:'Dissector', w:2.4, grip:7, roll:7, best:['trail','xc','dry'],
psi:{ lt:[20,24], md:[23,28], hv:[27,32] }, notes:'Fast rolling, good dry grip. Best paired with Assegai/DHF.' },
{ id:'mx-minion-f', brand:'Maxxis', model:'Minion DHF', w:2.5, grip:9, roll:5, best:['enduro','trail','mixed'],
psi:{ lt:[18,23], md:[22,27], hv:[25,30] }, notes:'The benchmark. Predictable and capable in all conditions.' },
{ id:'sw-mary-f', brand:'Schwalbe', model:'Magic Mary', w:2.4, grip:10, roll:3, best:['enduro','dh','wet'],
psi:{ lt:[16,21], md:[20,25], hv:[24,29] }, notes:'Extraordinary wet traction. Soft compound, slow but grippy.' },
{ id:'sw-albert-f', brand:'Schwalbe', model:'Albert Radial', w:2.35, grip:8, roll:6, best:['trail','enduro'],
psi:{ lt:[19,24], md:[22,27], hv:[26,30] }, notes:'Radial casing = excellent suppleness. Versatile all-rounder.' },
{ id:'sp-elim-f', brand:'Specialized', model:'Eliminator', w:2.3, grip:7, roll:7, best:['trail','xc'],
psi:{ lt:[21,26], md:[24,29], hv:[28,33] }, notes:'Efficient XC/trail option. Good cornering, lower grip in wet.' },
{ id:'sp-butch-f', brand:'Specialized', model:'Butcher', w:2.3, grip:8, roll:6, best:['enduro','trail'],
psi:{ lt:[20,25], md:[23,28], hv:[26,31] }, notes:'Aggressive but not max grip. Solid daily enduro tire.' },
],
rear: [
{ id:'mx-assegai-r', brand:'Maxxis', model:'Assegai', w:2.5, grip:9, roll:4, best:['enduro','dh'],
psi:{ lt:[22,27], md:[25,30], hv:[29,34] }, notes:'Heavy but grippy rear. For very tech/loose terrain.' },
{ id:'mx-dissector-r', brand:'Maxxis', model:'Dissector', w:2.4, grip:7, roll:8, best:['trail','xc','dry'],
psi:{ lt:[22,27], md:[26,31], hv:[30,35] }, notes:'Recommended combo: Dissector rear + Assegai front.' },
{ id:'mx-minion-r', brand:'Maxxis', model:'Minion DHR II', w:2.4, grip:8, roll:6, best:['enduro','trail','mixed'],
psi:{ lt:[21,26], md:[24,29], hv:[28,33] }, notes:'Classic workhorse rear. Balanced traction and rolling speed.' },
{ id:'sw-mary-r', brand:'Schwalbe', model:'Magic Mary', w:2.4, grip:9, roll:3, best:['enduro','dh','wet'],
psi:{ lt:[20,25], md:[23,28], hv:[27,32] }, notes:'Wet conditions specialist rear. Very slow rolling.' },
{ id:'sw-albert-r', brand:'Schwalbe', model:'Albert Radial', w:2.35, grip:7, roll:7, best:['trail','enduro'],
psi:{ lt:[21,26], md:[24,29], hv:[27,32] }, notes:'Supple and forgiving. Good all-conditions rear option.' },
{ id:'sp-elim-r', brand:'Specialized', model:'Eliminator', w:2.3, grip:7, roll:8, best:['trail','xc'],
psi:{ lt:[23,28], md:[26,31], hv:[30,35] }, notes:'Fast XC rear. Good on hard-pack and gravel mix.' },
{ id:'sp-butch-r', brand:'Specialized', model:'Butcher', w:2.3, grip:8, roll:7, best:['enduro','trail'],
psi:{ lt:[21,26], md:[24,29], hv:[27,33] }, notes:'Balanced grip+roll. Reliable enduro/trail rear choice.' },
],
};
const DISC = ['XC / Marathon', 'Trail', 'Enduro', 'DH / Park'];
const LEVELS = ['Beginner', 'Intermediate', 'Advanced', 'Expert / Racer'];
const STYLES_R = ['Smooth & Pedally', 'Aggressive / Attack', 'Tech-focused', 'Flow & Jump', 'Mixed'];
// ══════════════════════════════════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════════════════════════════════
const wCat = (kg) => kg < 65 ? 'lt' : kg < 85 ? 'md' : 'hv';
const dCat = (d) => d.toLowerCase().includes('xc') ? 'xc' : d.toLowerCase().includes('enduro') || d.toLowerCase().includes('dh') ? 'enduro' : 'trail';
function isRecFork(fork, rider) {
const dc = dCat(rider.disc);
const wc = wCat(rider.weight);
const minT = dc === 'enduro' ? 150 : dc === 'trail' ? 130 : 110;
const maxT = dc === 'xc' ? 140 : 200;
return fork.travels.some(t => t >= minT && t <= maxT);
}
function isRecShock(shock, rider) {
const dc = dCat(rider.disc);
if (dc === 'xc' && shock.type === 'coil') return false;
if (dc === 'dh' && shock.id === 'fx-fx') return false;
return true;
}
function isRecFrame(frame, rider) {
const dc = dCat(rider.disc);
return frame.bestFor.some(b => b === dc || b === 'trail');
}
function isRecTire(tire, rider, pos) {
const dc = dCat(rider.disc);
return tire.best.some(b => b === dc || (pos === 'front' && b === 'mixed'));
}
function getSetupDefaults(build, rider) {
const wc = wCat(rider.weight);
const dc = dCat(rider.disc);
const fork = build.fork;
const shock = build.shock;
const ft = build.frontTire;
const rt = build.rearTire;
const lvlMod = { 'Beginner':0, 'Intermediate':0, 'Advanced':1, 'Expert / Racer':2 }[rider.level] || 0;
// Fork PSI midpoint + level adjustment
const fPsiRange = fork?.psi?.[wc];
const fPsi = fPsiRange ? Math.round((fPsiRange[0] + fPsiRange[1]) / 2 + lvlMod * 2) : null;
const fSag = fork?.sag?.[dc] ?? 25;
const fReb = fork ? Math.max(0, (fork.reb[wc] || 10) - lvlMod) : null;
const fLsc = fork ? Math.min(20, (fork.lsc[wc] || 6) + lvlMod) : null;
const fHsc = fork?.hsc ? Math.min(10, (fork.hsc[wc] || 3) + lvlMod) : null;
const fTok = fork?.tok?.[wc] ?? 0;
const sPsiRange = shock?.type === 'air' ? shock.psi?.[wc] : null;
const sPsi = sPsiRange ? Math.round((sPsiRange[0] + sPsiRange[1]) / 2 + lvlMod * 3) : null;
const sSag = shock?.sag?.[dc] ?? 28;
const sReb = shock ? Math.max(0, (shock.reb[wc] || 10) - lvlMod) : null;
const sLsc = shock ? Math.min(20, (shock.lsc[wc] || 5) + lvlMod) : null;
const sHsc = shock?.hsc ? Math.min(10, (shock.hsc[wc] || 3) + lvlMod) : null;
const sTok = shock?.tok?.[wc] ?? 0;
const ftPsiRange = ft?.psi?.[wc];
const fTirePsi = ftPsiRange ? Math.round((ftPsiRange[0] + ftPsiRange[1]) / 2) : null;
const rtPsiRange = rt?.psi?.[wc];
const rTirePsi = rtPsiRange ? Math.round((rtPsiRange[0] + rtPsiRange[1]) / 2) : null;
return { fPsi, fSag, fReb, fLsc, fHsc, fTok, sPsi, sSag, sReb, sLsc, sHsc, sTok, fTirePsi, rTirePsi };
}
// Storage
const SK = 'loam-v2';
async function loadData() {
try { const r = await window.storage.get(SK); return r ? JSON.parse(r.value) : {}; } catch { return {}; }
}
async function saveData(data) {
try { await window.storage.set(SK, JSON.stringify(data)); } catch {}
}
// AI call — Anthropic or Ollama
async function callAI(userMsg, systemMsg, config) {
if (config.backend === 'ollama') {
const res = await fetch(`${config.ollamaUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: config.model, stream: false, messages: [
{ role: 'system', content: systemMsg },
{ role: 'user', content: userMsg }
]})
});
if (!res.ok) throw new Error(`Ollama error ${res.status} — is the server running?`);
const d = await res.json();
return d.message?.content || d.response || '';
} else {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model:'claude-sonnet-4-20250514', max_tokens:1000,
system: systemMsg, messages:[{ role:'user', content:userMsg }] })
});
if (!res.ok) throw new Error(`API error ${res.status}`);
const d = await res.json();
return d.content?.[0]?.text || '';
}
}
const AI_SYS = `You are a professional mountain bike suspension tuner with deep hands-on expertise in Fox, RockShox, Manitou, and Öhlins products.
You combine manufacturer specs with real-world community setups from forums like Pinkbike, Vital MTB, and MTBR.
Be specific, practical, and technical. Use metric where possible but mention PSI/clicks.
Format responses with clear section headers using ▸ symbol and short numbered lists.
Keep recommendations actionable — give exact values, not ranges when possible.`;
// ══════════════════════════════════════════════════════════════════════════════
// BIKE VISUALIZER SVG
// ══════════════════════════════════════════════════════════════════════════════
function BikeVisualizer({ build }) {
const { frame, fork, shock, frontTire, rearTire } = build;
const W = 560, H = 310;
const ha = frame?.ha || 65;
const slack = Math.max(0, Math.min(1, (65 - ha) / 2.0));
const ft = fork ? fork.travels[Math.floor(fork.travels.length / 2)] : 150;
const forkScale = 0.86 + (ft - 130) / 70 * 0.18;
const isCoil = shock?.type === 'coil';
const fc = frame?.color || '#3a5828';
const fkc = fork?.fcolor || '#4a6035';
const shc = shock?.scolor || '#3a4828';
const rtw = Math.round(((rearTire?.w || 2.4) - 2.15) * 32 + 10);
const ftw = Math.round(((frontTire?.w || 2.4) - 2.15) * 32 + 10);
// Key points
const RW = [116, 224];
const FWx = 388 + slack * 10;
const FW = [FWx, 224];
const WR = 72;
const BB = [204, 228];
const HTT = [342 - slack * 8, 114 + slack * 5];
const HTB = [356 - slack * 6, 148 + slack * 4];
const STT = [200, 100];
const SSJ = [202, 140]; // seat stay junction
// Fork end scaling
const fDx = (FW[0] - HTB[0]) * forkScale;
const fDy = (FW[1] - HTB[1]) * forkScale;
const fEnd = [HTB[0] + fDx, HTB[1] + fDy];
// Fork arch (junction upper/lower legs)
const archF = 0.38;
const arch = [HTB[0] + fDx * archF, HTB[1] + fDy * archF];
// Fork perpendicular offset (for two-leg visual)
const fAngle = Math.atan2(fDy, fDx);
const pOff = [Math.sin(fAngle) * 9, -Math.cos(fAngle) * 9];
// Shock mounts
const ShT = [252, 150];
const ShB = [226, 195];
const shMid = [(ShT[0]+ShB[0])/2, (ShT[1]+ShB[1])/2];
const spokes = (cx, cy, r1, r2, n=8) =>
Array.from({length:n}, (_,i) => {
const a = i * Math.PI * 2 / n + 0.2;
return
Select a fork in the Build tab
Select a shock in the Build tab
Build and save your first setup from the Build tab.
ollama pull llama3.1:8b