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 ; }); const hasAny = frame || fork || shock || frontTire || rearTire; return ( {/* Shadow */} {/* Ground */} {/* ── REAR WHEEL ── */} {spokes(RW[0],RW[1],15,WR-rtw/2-9)} {/* ── FRONT WHEEL ── */} {spokes(FW[0],FW[1],13,WR-ftw/2-9)} {/* ── CHAIN STAYS ── */} {/* ── SEAT STAYS ── */} {/* ── REAR SHOCK ── */} {isCoil ? ( <> {Array.from({length:6},(_,i)=>{ const f=(i+0.5)/6; const mx=ShT[0]+(ShB[0]-ShT[0])*f, my=ShT[1]+(ShB[1]-ShT[1])*f; const dx=ShB[0]-ShT[0], dy=ShB[1]-ShT[1]; const len=Math.sqrt(dx*dx+dy*dy); const nx=-dy/len*7, ny=dx/len*7; return ; })} ) : ( <> )} {/* ── DOWN TUBE ── */} {/* ── TOP TUBE ── */} {/* ── SEAT TUBE ── */} {/* ── HEAD TUBE ── */} {/* ── BB ── */} {/* ── FORK upper stanchions ── */} {/* Fork crown */} {/* Fork lower legs */} {/* Axle */} {/* ── SEAT POST ── */} {/* SADDLE */} {/* ── STEM + BAR ── */} {/* ── LABELS ── */} {fork && {fork.brand} {fork.model}} {shock && {shock.brand} {shock.model}} {frame && {frame.brand} {frame.model}} {frontTire && {frontTire.model}} {rearTire && {rearTire.model}} {!hasAny && ( SELECT COMPONENTS TO BUILD YOUR BIKE )} ); } // ══════════════════════════════════════════════════════════════════════════════ // SUB-COMPONENTS // ══════════════════════════════════════════════════════════════════════════════ function Sl({ label, value, onChange, min, max, step=1, unit='' }) { return (
{label} {value}{unit}
onChange(Number(e.target.value))} />
); } function SpB({ label, value, unit }) { return (
{label}
{value ?? '—'}{unit && {unit}}
); } function CompCard({ item, selected, recommended, onClick, extraTags=[] }) { return (
{selected &&
}
{item.brand}
{item.model}
{item.desc &&
{item.desc}
} {item.notes &&
{item.notes}
}
{recommended && ★ Recommended} {extraTags.map((t,i) => {t})}
); } function MeterBar({ label, value, max=10, color }) { const pct = Math.round((value / max) * 100); const bg = color || (pct > 70 ? 'var(--fern)' : pct > 40 ? 'var(--amb)' : 'var(--red)'); return (
{label}
{value}/{max}
); } // ══════════════════════════════════════════════════════════════════════════════ // MAIN APP // ══════════════════════════════════════════════════════════════════════════════ const emptyBuild = () => ({ frame:null, fork:null, shock:null, frontTire:null, rearTire:null, name:'' }); const emptySetup = () => ({ fPsi:'', fSag:25, fReb:'', fLsc:'', fHsc:'', fTok:0, sPsi:'', sSag:28, sReb:'', sLsc:'', sHsc:'', sTok:0, fTirePsi:'', rTirePsi:'', notes:'' }); const emptyReview = () => ({ buildId:'', suspFeel:5, fGrip:7, rGrip:7, rollRes:5, confidence:7, bottomOut:false, harshHit:false, wallow:false, smallBump:false, notes:'' }); const emptyRider = () => ({ weight:78, disc:'Trail', level:'Intermediate', style:'Mixed' }); export default function App() { const [tab, setTab] = useState('build'); const [rider, setRider] = useState(emptyRider()); const [build, setBuild] = useState(emptyBuild()); const [setup, setSetup] = useState(emptySetup()); const [garage, setGarage] = useState([]); const [reviews, setReviews] = useState([]); const [prefs, setPrefs] = useState({ harshTendency:0, softTendency:0, gripPref:0 }); const [aiConfig, setAiConfig] = useState({ backend:'anthropic', ollamaUrl:'http://localhost:11434', model:'llama3.1:8b' }); const [aiOut, setAiOut] = useState(''); const [aiLoad, setAiLoad] = useState(false); const [review, setReview] = useState(emptyReview()); const [revOut, setRevOut] = useState(''); const [revLoad, setRevLoad] = useState(false); const [toast, setToast] = useState(null); const [loading, setLoading] = useState(true); const [showAiSettings, setShowAiSettings] = useState(false); const [detailBuild, setDetailBuild] = useState(null); useEffect(() => { loadData().then(d => { if (d.garage) setGarage(d.garage); if (d.reviews) setReviews(d.reviews); if (d.prefs) setPrefs(d.prefs); if (d.aiConfig) setAiConfig(p => ({...p,...d.aiConfig})); setLoading(false); }); }, []); const persist = async (updates) => { const merged = { garage, reviews, prefs, aiConfig: {backend:aiConfig.backend,ollamaUrl:aiConfig.ollamaUrl,model:aiConfig.model}, ...updates }; await saveData(merged); }; const toast_ = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2400); }; const rSet = (k) => (e) => setRider(r => ({...r,[k]: e?.target ? e.target.value : e})); const bSet = (k, v) => setBuild(b => ({...b,[k]:b[k]?.id===v?.id ? null : v})); const wc = wCat(rider.weight); const dc = dCat(rider.disc); const defaults = getSetupDefaults(build, rider); // Adapt setup defaults whenever build/rider changes useEffect(() => { const d = getSetupDefaults(build, rider); setSetup(s => ({ ...s, fPsi: s.fPsi || '', fSag: d.fSag||s.fSag, fReb: s.fReb || '', fLsc: s.fLsc || '', fHsc: s.fHsc || '', fTok: s.fTok!=='' ? s.fTok : d.fTok||0, sPsi: s.sPsi || '', sSag: d.sSag||s.sSag, sReb: s.sReb || '', sLsc: s.sLsc || '', sHsc: s.sHsc || '', sTok: s.sTok!=='' ? s.sTok : d.sTok||0, fTirePsi: s.fTirePsi || '', rTirePsi: s.rTirePsi || '', })); }, [build.fork?.id, build.shock?.id, build.frontTire?.id, build.rearTire?.id, rider.weight, rider.disc, rider.level]); const saveBuild = async () => { if (!build.name.trim()) { toast_('Add a build name first'); return; } const saved = { id: Date.now().toString(), ...build, rider: {...rider}, setup: {...setup}, date: new Date().toISOString() }; const updated = [saved, ...garage]; setGarage(updated); await persist({ garage: updated }); toast_('Build saved to garage'); }; const loadGarageBuild = (gb) => { setBuild({ frame:gb.frame, fork:gb.fork, shock:gb.shock, frontTire:gb.frontTire, rearTire:gb.rearTire, name:gb.name }); if (gb.setup) setSetup(gb.setup); if (gb.rider) setRider(gb.rider); setDetailBuild(null); setTab('build'); toast_('Build loaded'); }; const deleteGarageBuild = async (id) => { const updated = garage.filter(g => g.id !== id); setGarage(updated); await persist({ garage: updated }); toast_('Build deleted'); }; const buildSummary = () => { const parts = [build.frame, build.fork, build.shock, build.frontTire, build.rearTire].filter(Boolean); return parts.map(p => `${p.brand} ${p.model}`).join(', ') || 'No components selected'; }; const learnedContext = () => { if (!reviews.length) return ''; const harsh = reviews.filter(r => r.suspFeel >= 7).length; const soft = reviews.filter(r => r.suspFeel <= 3).length; if (harsh > soft && harsh > 1) return '\n[RIDER PATTERN] Previous reviews indicate this rider consistently finds suspension too harsh. Recommend starting with softer rebound (more clicks open) and lower compression damping.'; if (soft > harsh && soft > 1) return '\n[RIDER PATTERN] Previous reviews indicate this rider often finds suspension too soft/wallowy. Recommend firmer base settings and consider extra volume spacers.'; return ''; }; const handleAiRec = async () => { setAiLoad(true); setAiOut(''); const ctx = learnedContext(); const prompt = `Give me a complete suspension setup recommendation for this build: RIDER: ${rider.weight}kg, ${rider.level}, ${rider.style}, rides ${rider.disc} FRAME: ${build.frame ? `${build.frame.brand} ${build.frame.model} (${build.frame.travel}mm, ${build.frame.ha}° HA)` : 'Not selected'} FORK: ${build.fork ? `${build.fork.brand} ${build.fork.model} (${build.fork.travels[1]}mm travel, ${build.fork.damper} damper)` : 'Not selected'} SHOCK: ${build.shock ? `${build.shock.brand} ${build.shock.model} (${build.shock.type})` : 'Not selected'} FRONT TIRE: ${build.frontTire ? `${build.frontTire.brand} ${build.frontTire.model} ${build.frontTire.w}"` : 'Not selected'} REAR TIRE: ${build.rearTire ? `${build.rearTire.brand} ${build.rearTire.model} ${build.rearTire.w}"` : 'Not selected'} ${ctx} Please give: ▸ FORK: Starting PSI, sag %, rebound clicks from closed, LSC, HSC (if applicable), volume tokens ▸ SHOCK: Starting PSI (or spring rate for coil), sag %, rebound, LSC, HSC, volume tokens ▸ TIRES: Front and rear starting PSI ▸ RATIONALE: Why these settings for this rider/build/discipline ▸ FIRST RIDE TIPS: What to feel for and how to adjust ▸ KNOWN ISSUES: Any common pitfalls for this specific component combo`; try { const resp = await callAI(prompt, AI_SYS, aiConfig); setAiOut(resp); } catch (e) { setAiOut('Error: ' + e.message); } setAiLoad(false); }; const handlePostRide = async () => { setRevLoad(true); setRevOut(''); const selectedBuild = garage.find(g => g.id === review.buildId); const bp = selectedBuild ? `\nBUILD: ${selectedBuild.name}\nFORK: ${selectedBuild.fork?.brand} ${selectedBuild.fork?.model}\nSHOCK: ${selectedBuild.shock?.brand} ${selectedBuild.shock?.model} SETUP: Fork ${selectedBuild.setup?.fPsi||'?'}psi / ${selectedBuild.setup?.fSag||'?'}% sag / ${selectedBuild.setup?.fReb||'?'} reb clicks, Shock ${selectedBuild.setup?.sPsi||'?'}psi` : ''; const prompt = `POST-RIDE REVIEW ANALYSIS: ${bp} RIDER FEEDBACK (scale 1-10): - Suspension feel: ${review.suspFeel}/10 (1=too soft/wallowing, 5=perfect, 10=too harsh/rigid) - Front grip: ${review.fGrip}/10 - Rear grip: ${review.rGrip}/10 - Rolling resistance: ${review.rollRes}/10 (10=fast rolling, 1=sluggish) - Confidence level: ${review.confidence}/10 ISSUES REPORTED: - Bottoming out: ${review.bottomOut ? 'YES' : 'No'} - Harsh initial hit: ${review.harshHit ? 'YES' : 'No'} - Wallowing/lack of support: ${review.wallow ? 'YES' : 'No'} - Poor small-bump sensitivity: ${review.smallBump ? 'YES' : 'No'} RIDER NOTES: ${review.notes || 'None'} Based on this feedback, provide specific actionable adjustments: ▸ FORK ADJUSTMENTS: Exact changes to PSI, rebound clicks, compression clicks ▸ SHOCK ADJUSTMENTS: Exact changes to PSI, rebound, compression, tokens ▸ TIRE ADJUSTMENTS: Any PSI changes recommended ▸ PRIORITY: Which adjustment to make first ▸ NEXT RIDE: What to feel for after changes`; try { const resp = await callAI(prompt, AI_SYS, aiConfig); setRevOut(resp); // Learn from this review const newReviews = [{ ...review, date: new Date().toISOString() }, ...reviews.slice(0,19)]; const newPrefs = { ...prefs, harshTendency: newReviews.filter(r=>r.suspFeel>=7).length, softTendency: newReviews.filter(r=>r.suspFeel<=3).length, }; setReviews(newReviews); setPrefs(newPrefs); await persist({ reviews: newReviews, prefs: newPrefs }); } catch (e) { setRevOut('Error: ' + e.message); } setRevLoad(false); }; // ── RENDER: Build tab ───────────────────────────────────────────────────── function renderBuild() { const tires = TIRES; return (
{/* Rider profile bar */}
setRider(r=>({...r,weight:Number(e.target.value)}))} />
{/* Bike viz */}
{/* Build name + save */}
setBuild(b=>({...b,name:e.target.value}))} />
{/* Frame */}
Frame
{FRAMES.map(f => ( bSet('frame',f)} extraTags={[`${f.travel}mm`, f.ha+'°', f.geo]} /> ))}
{/* Fork */}
Fork
{FORKS.map(f => ( bSet('fork',f)} extraTags={[f.travels.join('/')+'mm', f.damper]} /> ))}
{/* Shock */}
Rear Shock
{SHOCKS.map(s => ( bSet('shock',s)} extraTags={[s.type, s.hsc?'HSC':'', s.hsr?'HSR':''].filter(Boolean)} /> ))}
{/* Tires */}
Front Tire
{tires.front.map(t => ( bSet('frontTire',t)} extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} /> ))}
Rear Tire
{tires.rear.map(t => ( bSet('rearTire',t)} extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} /> ))}
); } // ── RENDER: Setup tab ───────────────────────────────────────────────────── function renderSetup() { const d = defaults; const sp = (v, d) => v !== '' ? v : (d !== null && d !== undefined ? `≈ ${d}` : ''); return (
Settings below auto-populate from your build + rider profile. Edit any value. Click AI Tune to get full AI recommendation.
{/* Fork */} {build.fork ? ( <>
Fork — {build.fork.brand} {build.fork.model}
setSetup(s=>({...s,fPsi:e.target.value}))} /> {d.fPsi && Manufacturer range for your weight: {build.fork.psi[wc][0]}–{build.fork.psi[wc][1]} psi}
setSetup(s=>({...s,fTok:e.target.value}))} /> More tokens = more progressive
setSetup(s=>({...s,fSag:v}))} min={15} max={40} unit="%" />
setSetup(s=>({...s,fReb:v}))} min={0} max={20} /> setSetup(s=>({...s,fLsc:v}))} min={0} max={20} /> {build.fork.hsc && setSetup(s=>({...s,fHsc:v}))} min={0} max={10} />}
) : (

Select a fork in the Build tab

)} {/* Shock */} {build.shock ? ( <>
Rear Shock — {build.shock.brand} {build.shock.model}
{build.shock.type === 'air' ? (
setSetup(s=>({...s,sPsi:e.target.value}))} /> {d.sPsi && Range for your weight: {build.shock.psi[wc][0]}–{build.shock.psi[wc][1]} psi}
) : (
Consult spring chart for fine-tuning
)}
setSetup(s=>({...s,sTok:e.target.value}))} />
setSetup(s=>({...s,sSag:v}))} min={20} max={42} unit="%" />
setSetup(s=>({...s,sReb:v}))} min={0} max={20} /> setSetup(s=>({...s,sLsc:v}))} min={0} max={20} /> {build.shock.hsc && setSetup(s=>({...s,sHsc:v}))} min={0} max={10} />}
) : (

Select a shock in the Build tab

)} {/* Tires */} {(build.frontTire || build.rearTire) && ( <>
Tire Pressures
{build.frontTire && (
setSetup(s=>({...s,fTirePsi:e.target.value}))} /> {d.fTirePsi && Range: {build.frontTire.psi[wc][0]}–{build.frontTire.psi[wc][1]} psi}
)} {build.rearTire && (
setSetup(s=>({...s,rTirePsi:e.target.value}))} /> {d.rTirePsi && Range: {build.rearTire.psi[wc][0]}–{build.rearTire.psi[wc][1]} psi}
)}
)}
Notes