0
TOOLS#6a8f60b8
SBA DealLens v2
@Owner·deposited 2w ago·updated 2w ago·45 views
TOOLS#6a8f60b8
SBA DealLens v2
OW
@Owner
45Views
0Comments
0Forks
0Saves
SHARE · REMIX
SBA DealLens v2 — a HTML tools widget by @Owner.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widget
vibes.onReadyvibes.savevibes.loadvibes.fetch
- raw fetch(): Raw fetch() detected. Prefer vibes.fetch for proxied requests.
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "SBA DealLens v2" by @Owner]
Source: https://itjustvibes.com/Owner/sba-deallens-v2
Type: HTML
--- SOURCE CODE ---
```html
<div id="sba-deallens-root" class="sba-deallens-shell" aria-live="polite">
<div class="dl-loading">Loading SBA DealLens...</div>
</div>
<style>
:root{--dl-bg:#161816;--dl-bg2:#20231f;--dl-panel:#f7f2e8;--dl-panel2:#fffaf0;--dl-ink:#18201c;--dl-muted:#65706a;--dl-border:rgba(24,32,28,.16);--dl-dark:#0d1110;--dl-blue:#4f7d9a;--dl-green:#5f8f6c;--dl-amber:#b88a36;--dl-red:#aa4f4f;--dl-shadow:0 22px 60px rgba(0,0,0,.28);--dl-radius:22px;--dl-radius-sm:14px;--dl-focus:0 0 0 3px rgba(79,125,154,.35)}
.sba-deallens-shell{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at 20% 0%,rgba(79,125,154,.22),transparent 30%),radial-gradient(circle at 88% 6%,rgba(184,138,54,.16),transparent 24%),linear-gradient(145deg,#111413,#20231f 42%,#161816);color:var(--dl-ink);min-height:100vh;padding:18px;box-sizing:border-box;overflow-x:hidden}.dl-loading{color:#f8f1e4;padding:34px;border:1px solid rgba(255,255,255,.12);border-radius:20px;background:rgba(255,255,255,.06)}.dl-wrap{max-width:1540px;margin:0 auto}.dl-top{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,.75fr);gap:18px;align-items:stretch;margin-bottom:16px}.dl-hero{position:relative;overflow:hidden;background:linear-gradient(140deg,rgba(247,242,232,.98),rgba(239,232,218,.92));border:1px solid rgba(255,255,255,.28);border-radius:var(--dl-radius);box-shadow:var(--dl-shadow);padding:26px}.dl-hero:before{content:"";position:absolute;inset:-80px -80px auto auto;width:260px;height:260px;background:radial-gradient(circle,rgba(95,143,108,.25),transparent 60%);pointer-events:none}.dl-eyebrow{display:flex;gap:10px;align-items:center;font-size:12px;letter-spacing:.13em;text-transform:uppercase;color:#53615a;font-weight:800}.dl-dot{width:8px;height:8px;border-radius:999px;background:var(--dl-green);box-shadow:0 0 0 5px rgba(95,143,108,.16)}h1.dl-title{font-size:clamp(32px,5vw,58px);line-height:.94;margin:12px 0 12px;letter-spacing:-.06em;color:#121817}.dl-sub{max-width:800px;color:#455049;font-size:clamp(15px,1.5vw,18px);line-height:1.55;margin:0 0 18px}.dl-presets,.dl-actions{display:flex;flex-wrap:wrap;gap:10px}.dl-btn,.dl-chip,button.dl-btn{appearance:none;border:1px solid rgba(24,32,28,.16);background:#fffaf0;color:#1d2823;border-radius:999px;min-height:42px;padding:10px 14px;font-weight:800;font-size:13px;cursor:pointer;transition:transform .18s ease,box-shadow .18s ease,background .18s ease}.dl-btn:hover,.dl-chip:hover{transform:translateY(-1px);box-shadow:0 8px 18px rgba(0,0,0,.12)}.dl-btn:focus-visible,.dl-chip:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:0;box-shadow:var(--dl-focus)}.dl-btn.primary{background:#18201c;color:#fff7e8;border-color:#18201c}.dl-btn.ghost{background:rgba(255,250,240,.62)}.dl-btn.warn{background:#fff4d8;color:#65440c;border-color:rgba(184,138,54,.35)}.dl-summary-card{background:rgba(13,17,16,.78);color:#f7f2e8;border:1px solid rgba(255,255,255,.1);border-radius:var(--dl-radius);padding:18px;box-shadow:var(--dl-shadow);display:flex;flex-direction:column;justify-content:space-between;gap:14px}.dl-summary-card h2{margin:0;font-size:18px;color:#fffaf0;letter-spacing:-.02em}.dl-summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.dl-mini{border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.06);border-radius:16px;padding:12px}.dl-mini span{display:block;color:#bec7c0;font-size:12px}.dl-mini strong{display:block;font-size:21px;letter-spacing:-.04em;margin-top:4px}.dl-mini .good{color:#bfe2c6}.dl-mini .amber{color:#f4ce84}.dl-section{background:rgba(247,242,232,.97);border:1px solid rgba(255,255,255,.28);border-radius:var(--dl-radius);box-shadow:0 12px 34px rgba(0,0,0,.18);margin-bottom:16px;overflow:hidden}.dl-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 20px;border-bottom:1px solid var(--dl-border);background:linear-gradient(180deg,rgba(255,250,240,.86),rgba(247,242,232,.8))}.dl-section-head h2{font-size:18px;margin:0;letter-spacing:-.03em}.dl-section-head p{font-size:12px;color:var(--dl-muted);margin:3px 0 0}.dl-rate-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:12px;padding:14px}.dl-rate-card{background:var(--dl-panel2);border:1px solid var(--dl-border);border-radius:18px;padding:13px;position:relative;overflow:hidden}.dl-rate-card.fetching:after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(79,125,154,.18),transparent);animation:dlSweep 1.2s linear infinite}.dl-rate-card h3{margin:0 0 8px;font-size:13px;color:#46524c;display:flex;justify-content:space-between;gap:8px}.dl-status{font-size:10px;text-transform:uppercase;letter-spacing:.08em;border-radius:999px;padding:4px 7px;background:#e8eceb;color:#4b5960}.dl-status.live{background:#e6f2e8;color:#32653d}.dl-status.cached{background:#e8eff5;color:#31546b}.dl-status.manual{background:#f5ead2;color:#715017}.dl-status.error{background:#f4dedc;color:#8b3434}.dl-rate-value{display:flex;gap:8px;align-items:end}.dl-rate-value input{font-size:22px;font-weight:900;letter-spacing:-.04em;max-width:94px}.dl-rate-card small{display:block;color:var(--dl-muted);font-size:11px;margin-top:7px;line-height:1.35}.dl-input,select.dl-input,textarea.dl-input{width:100%;border:1px solid rgba(24,32,28,.18);background:#fffdf8;color:#18201c;border-radius:12px;padding:10px 11px;min-height:42px;font-size:14px;box-sizing:border-box}.dl-field{display:flex;flex-direction:column;gap:6px}.dl-field label{font-size:12px;color:#4f5c56;font-weight:800}.dl-offer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:14px}.dl-offer{background:linear-gradient(180deg,#fffaf0,#f4eee1);border:1px solid var(--dl-border);border-radius:20px;overflow:hidden;min-width:0}.dl-offer-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:16px;border-bottom:1px solid var(--dl-border);background:rgba(255,255,255,.34)}.dl-offer-title{display:flex;gap:12px;align-items:center}.dl-badge-letter{width:38px;height:38px;border-radius:14px;display:grid;place-items:center;background:#17201c;color:#fff4df;font-weight:900}.dl-offer h3{margin:0;font-size:18px}.dl-offer small{color:var(--dl-muted)}.dl-form{padding:14px;display:grid;gap:12px}.dl-form-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.dl-form-row.three{grid-template-columns:repeat(3,minmax(0,1fr))}.dl-form-row.four{grid-template-columns:repeat(4,minmax(0,1fr))}.dl-subpanel{border:1px solid rgba(24,32,28,.12);background:rgba(255,255,255,.44);border-radius:16px;padding:12px;display:grid;gap:10px}.dl-subpanel h4{font-size:13px;margin:0;color:#334039;display:flex;justify-content:space-between}.dl-capstack{height:18px;border-radius:999px;display:flex;overflow:hidden;background:#ded8cc;border:1px solid rgba(24,32,28,.12)}.dl-capstack span:nth-child(1){background:#4f7d9a}.dl-capstack span:nth-child(2){background:#5f8f6c}.dl-capstack span:nth-child(3){background:#b88a36}.dl-tabs{display:none;padding:0 14px 14px;gap:10px}.dl-tabs button{flex:1}.dl-tabs button.active{background:#18201c;color:#fff7e8}.dl-results-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:14px}.dl-metric{background:#fffaf0;border:1px solid var(--dl-border);border-radius:18px;padding:14px;min-height:96px;position:relative;overflow:hidden}.dl-metric:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:#ccd3cf}.dl-metric.good:before{background:var(--dl-green)}.dl-metric.amber:before{background:var(--dl-amber)}.dl-metric.blue:before{background:var(--dl-blue)}.dl-metric.red:before{background:var(--dl-red)}.dl-metric span{display:block;color:#65706a;font-size:12px;font-weight:800}.dl-metric strong{display:block;font-size:clamp(21px,2vw,30px);letter-spacing:-.05em;margin-top:8px}.dl-metric small{display:block;color:#65706a;margin-top:4px;font-size:11px;line-height:1.35}.dl-winner-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;padding:0 14px 14px}.dl-win{border:1px solid var(--dl-border);border-radius:14px;background:#fffdf8;padding:10px;font-size:12px;display:flex;justify-content:space-between;gap:8px}.dl-win b{font-size:13px}.dl-win .tag{border-radius:999px;padding:3px 7px;background:#e8eceb;font-weight:900;white-space:nowrap}.dl-win .tag.a{background:#e6eef4;color:#284e66}.dl-win .tag.b{background:#e8f2e8;color:#315d3a}.dl-win .tag.tie,.dl-win .tag.depends{background:#f3e9d3;color:#67460f}.dl-charts{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:14px}.dl-chart{background:#fffaf0;border:1px solid var(--dl-border);border-radius:18px;padding:14px;min-height:250px}.dl-chart h3{margin:0 0 10px;font-size:15px}.dl-chart svg{width:100%;height:210px;display:block}.dl-chart .axis{stroke:#c6c0b4;stroke-width:1}.dl-chart .line-a{fill:none;stroke:#4f7d9a;stroke-width:3;stroke-linecap:round}.dl-chart .line-b{fill:none;stroke:#5f8f6c;stroke-width:3;stroke-linecap:round}.dl-chart .fill-a{fill:rgba(79,125,154,.1)}.dl-chart .fill-b{fill:rgba(95,143,108,.1)}.dl-chart text{font-size:10px;fill:#65706a}.dl-timeline{padding:14px}.dl-timeline-box{background:#fffaf0;border:1px solid var(--dl-border);border-radius:18px;padding:14px}.dl-track{height:12px;border-radius:999px;background:linear-gradient(90deg,#4f7d9a,#e5dccb,#5f8f6c);position:relative;margin:22px 8px}.dl-marker{position:absolute;top:50%;transform:translate(-50%,-50%);width:22px;height:22px;border-radius:999px;background:#18201c;border:4px solid #fffaf0;box-shadow:0 5px 18px rgba(0,0,0,.25);transition:left .4s ease}.dl-notes{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:14px}.dl-note-list{background:#fffaf0;border:1px solid var(--dl-border);border-radius:18px;padding:14px}.dl-note-list h3{margin:0 0 10px;font-size:15px}.dl-note-list ul{margin:0;padding-left:18px;color:#3f4c45;line-height:1.5;font-size:13px}.dl-note-list li{margin-bottom:6px}.dl-note-list .warning{color:#8a4b18}.dl-note-list .serious{color:#8b3434}.dl-table-tools{display:flex;gap:10px;flex-wrap:wrap;padding:14px;border-bottom:1px solid var(--dl-border)}.dl-table-wrap{padding:0 14px 14px;overflow:auto;max-height:520px}.dl-table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;min-width:980px}.dl-table th{position:sticky;top:0;background:#efe8da;color:#4e5b54;text-align:left;padding:10px;border-bottom:1px solid var(--dl-border);z-index:1}.dl-table td{padding:10px;border-bottom:1px solid rgba(24,32,28,.08);background:#fffdf8}.dl-table tr:nth-child(even) td{background:#fbf5ea}.dl-disclaimer{color:#d9d0bf;background:rgba(13,17,16,.72);border:1px solid rgba(255,255,255,.1);border-radius:18px;padding:14px;font-size:12px;line-height:1.5;margin-bottom:18px}.dl-save-state{font-size:12px;color:#c9d1ca;margin-left:auto}.dl-mobile-sticky{display:none;position:sticky;top:0;z-index:10;background:rgba(13,17,16,.92);backdrop-filter:blur(14px);color:#fff7e8;border:1px solid rgba(255,255,255,.1);border-radius:0 0 18px 18px;margin:-18px -18px 14px;padding:10px 14px;justify-content:space-between;align-items:center}.dl-mobile-sticky strong{font-size:16px}.dl-mobile-sticky span{font-size:11px;color:#cbd4cd}.dl-hidden{display:none!important}.dl-sr{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@keyframes dlSweep{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}@keyframes dlRise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.dl-section,.dl-hero,.dl-summary-card{animation:dlRise .45s ease both}@media (max-width:1180px){.dl-rate-grid,.dl-results-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dl-winner-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dl-charts{grid-template-columns:1fr}.dl-top{grid-template-columns:1fr}.dl-summary-card{min-height:unset}}@media (max-width:820px){.sba-deallens-shell{padding:18px 12px}.dl-mobile-sticky{display:flex}.dl-offer-grid{grid-template-columns:1fr}.dl-tabs{display:flex}.dl-offer[data-hidden-mobile="true"]{display:none}.dl-form-row,.dl-form-row.three,.dl-form-row.four{grid-template-columns:1fr}.dl-rate-grid,.dl-results-grid,.dl-winner-grid,.dl-notes{grid-template-columns:1fr}.dl-section-head{align-items:flex-start;flex-direction:column}.dl-actions{width:100%}.dl-actions .dl-btn{flex:1}.dl-hero{padding:20px}.dl-summary-grid{grid-template-columns:1fr 1fr}.dl-btn,.dl-chip,button.dl-btn{min-height:44px}.dl-table{min-width:820px}}@media (max-width:430px){.sba-deallens-shell{padding:18px 10px}.dl-summary-grid{grid-template-columns:1fr}.dl-presets .dl-chip{width:100%}.dl-hero{border-radius:18px}.dl-section{border-radius:18px}.dl-results-grid{gap:10px}.dl-chart{padding:10px}.dl-chart svg{height:190px}.dl-rate-value input{max-width:84px}}@media (prefers-reduced-motion:reduce){*{animation-duration:.001ms!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}
</style>
<script>
(function(){
"use strict";
const fallbackVibes={onReady:function(cb){if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",cb,{once:true});}else{cb();}},load:async()=>null,save:async()=>true,delete:async()=>true,listKeys:async()=>[],fetch:async(url,opts)=>fetch(url,opts)};
const vibes=window.vibes||fallbackVibes;
const root=document.getElementById("sba-deallens-root");
const HORIZONS=[5,10,15,20,25];
const MAX_MONTHS=25*12;
const rateDefs={prime:{label:"Prime",range:[0,25],staleDays:10},treasury5:{label:"5Y Treasury",range:[0,20],staleDays:5},treasury10:{label:"10Y Treasury",range:[0,20],staleDays:5},sba504:{label:"504 Effective",range:[0,20],staleDays:45}};
const fmtMoney=(n)=>{n=Number(n)||0;const abs=Math.abs(n);const opt=abs>=1000000?{notation:"compact",maximumFractionDigits:2}:{maximumFractionDigits:0};return (n<0?"-":"")+"$"+abs.toLocaleString(undefined,opt)};
const fmtFullMoney=(n)=>{n=Number(n)||0;return n.toLocaleString(undefined,{style:"currency",currency:"USD",maximumFractionDigits:0})};
const fmtPct=(n)=>{n=Number(n);return isFinite(n)?n.toFixed(2)+"%":"--"};
const fmtNum=(n,d=2)=>{n=Number(n);return isFinite(n)?n.toFixed(d):""};
const pct=(n)=>Number(n||0)/100;
const clamp=(n,min,max)=>Math.min(max,Math.max(min,Number(n)||0));
const uid=()=>Math.random().toString(36).slice(2,9);
function todayISO(){return new Date().toISOString();}
function defaultRate(label,rate,status,source){return{label,rate,status,source:source||"Manual assumption",sourceDate:null,retrievedAt:null,notes:""};}
function defaults(){return{
activeOffer:"A",saveState:"Ready",tableMode:"annual",componentFilter:"all",isFetching:false,
rates:{prime:defaultRate("Prime",6.75,"manual","Manual prime override"),treasury5:defaultRate("5Y Treasury",4.25,"manual","Manual Treasury override"),treasury10:defaultRate("10Y Treasury",4.5,"manual","Manual Treasury override"),sba504:defaultRate("504 Effective",6.1,"manual","Manual CDC/504 assumption")},
scenario:{horizon:10,shock:0,extraPrincipal:0,feeTreatment:"as_entered",payoffYear:10,prepay:true},
offers:[defaultOffer("A","Offer A","SBA_7A"),defaultOffer("B","Offer B","SBA_504")]
};}
function defaultOffer(id,name,type){return type==="SBA_504"?{id,name,type,projectCost:2500000,equityPct:10,cashFees:35000,financedFees:75000,recurringFees:0,notes:"",parts:{bank:{pct:50,rateMode:"manual",manualRate:7.25,base:"prime",spread:1.25,termYears:10,amortYears:25,ioMonths:0,extraPrincipal:0,financedFees:30000,cashFees:15000,recurringFees:0,prepay:"manual"},cdc:{pct:40,rateMode:"manual",manualRate:6.1,base:"treasury10",spread:1.75,termYears:25,amortYears:25,ioMonths:0,extraPrincipal:0,financedFees:45000,cashFees:20000,recurringFees:0,prepay:"sba_504_declining"}}}:{id,name,type,projectCost:2500000,equityPct:10,cashFees:45000,financedFees:87500,recurringFees:0,notes:"",parts:{main:{rateMode:"prime_spread",manualRate:9.75,base:"prime",spread:2.75,termYears:25,amortYears:25,ioMonths:0,extraPrincipal:0,prepay:"sba_7a_5_3_1"}}};}
let state=defaults();
let calc=null;
let saveTimer=null;
async function safeLoad(key){try{return await vibes.load(key);}catch(e){noteSave("Saved data unavailable");return null;}}
async function safeSave(key,val){try{await vibes.save(key,val);noteSave("Saved");}catch(e){noteSave("Active changes not saved");}}
function noteSave(msg){state.saveState=msg;const el=root.querySelector("#dlSaveState");if(el)el.textContent=msg;}
function debouncedSave(){clearTimeout(saveTimer);noteSave("Saving...");saveTimer=setTimeout(async()=>{await safeSave("loan_offers",state.offers);await safeSave("scenario",state.scenario);await safeSave("rate_cache",state.rates);await safeSave("ui_prefs",{tableMode:state.tableMode,componentFilter:state.componentFilter,activeOffer:state.activeOffer});},650);}
function mergeSaved(saved){if(saved.offers&&Array.isArray(saved.offers)&&saved.offers.length===2)state.offers=saved.offers;if(saved.scenario)state.scenario=Object.assign(state.scenario,saved.scenario);if(saved.rates)state.rates=Object.assign(state.rates,saved.rates);if(saved.prefs)Object.assign(state,saved.prefs);}
function inputAttrs(id,path,label,value,type="number",step="1",min="0"){const val=value==null?"":String(value);return `<div class="dl-field"><label for="${id}">${label}</label><input class="dl-input" id="${id}" data-path="${path}" type="${type}" step="${step}" min="${min}" value="${escapeHtml(val)}"></div>`;}
function selectAttrs(id,path,label,value,opts){return `<div class="dl-field"><label for="${id}">${label}</label><select class="dl-input" id="${id}" data-path="${path}">${opts.map(o=>`<option value="${o[0]}" ${String(value)===String(o[0])?"selected":""}>${o[1]}</option>`).join("")}</select></div>`;}
function escapeHtml(s){return String(s).replace(/[&<>"]/g,m=>({"&":"&","<":"<",">":">","\"":"""}[m]));}
function render(){calc=calculateAll();root.innerHTML=`<div class="dl-wrap"><div class="dl-mobile-sticky"><div><strong>${fmtMoney(calc.selected.monthlyWinnerValue)}</strong><br><span>${calc.selected.summary}</span></div><button class="dl-btn ghost" data-scroll="results">Results</button></div>${renderHero()}${renderRates()}${renderOffers()}${renderResults()}${renderCharts()}${renderAmortization()}${renderDisclaimer()}</div>`;bind();}
function renderHero(){return `<section class="dl-top"><div class="dl-hero"><div class="dl-eyebrow"><span class="dl-dot"></span> SBA financing analysis dashboard</div><h1 class="dl-title">SBA DealLens</h1><p class="dl-sub">The cheaper payment is not always the cheaper deal. Compare SBA financing structures by cash outlay, payment, payoff year, and long-term cost.</p><div class="dl-presets"><button class="dl-chip" data-preset="7a504">7(a) vs 504</button><button class="dl-chip" data-preset="7a7a">Two 7(a) offers</button><button class="dl-chip" data-preset="504504">Two 504 structures</button><button class="dl-chip" data-preset="custom">Reset custom/manual</button></div></div><aside class="dl-summary-card"><div><h2>Selected scenario</h2><p style="color:#cbd4cd;margin:6px 0 0;font-size:13px;line-height:1.45">${calc.selected.summary}. Winner depends on holding period and assumptions.</p></div><div class="dl-summary-grid"><div class="dl-mini"><span>Offer A payment</span><strong>${fmtMoney(calc.A.monthlyPayment)}</strong></div><div class="dl-mini"><span>Offer B payment</span><strong>${fmtMoney(calc.B.monthlyPayment)}</strong></div><div class="dl-mini"><span>${state.scenario.horizon}-year cost delta</span><strong class="${calc.selected.delta<=0?"good":"amber"}">${fmtMoney(Math.abs(calc.selected.delta))}</strong></div><div class="dl-mini"><span>Break-even</span><strong>${calc.breakEven.label}</strong></div></div><div class="dl-actions"><button class="dl-btn primary" data-scroll="offers">Edit assumptions</button><button class="dl-btn ghost" data-export="summary">Export summary</button><span id="dlSaveState" class="dl-save-state">${state.saveState}</span></div></aside></section>`;}
function renderRates(){const cards=Object.keys(state.rates).map(k=>{const r=state.rates[k]||{};const status=(r.status||"manual").toLowerCase();return `<article class="dl-rate-card ${state.isFetching?"fetching":""}"><h3>${rateDefs[k].label}<span class="dl-status ${status}">${status}</span></h3><div class="dl-rate-value"><input class="dl-input" data-rate="${k}" type="number" step="0.01" min="0" value="${fmtNum(r.rate)}" aria-label="${rateDefs[k].label} rate"><b>%</b></div><small>${escapeHtml(r.source||"Manual")}${r.sourceDate?" · "+escapeHtml(r.sourceDate):""}${staleText(k,r)}</small></article>`}).join("");return `<section class="dl-section"><div class="dl-section-head"><div><h2>Reference rate strip</h2><p>Live/cached/manual rates remain editable. Offer rates recalculate from the assumptions below.</p></div><div class="dl-actions"><button class="dl-btn primary" data-action="refreshRates">Refresh public rates</button><button class="dl-btn ghost" data-action="markManual">Mark overrides manual</button></div></div><div class="dl-rate-grid">${cards}</div></section>`;}
function staleText(k,r){if(!r||!r.sourceDate)return " · manual or source date unavailable";const age=ageDays(r.sourceDate);if(age>rateDefs[k].staleDays)return ` · stale: ${age} days old`;return ` · fresh: ${age} days old`;}
function ageDays(d){const t=Date.parse(d);if(!isFinite(t))return 999;return Math.floor((Date.now()-t)/86400000);}
function renderOffers(){return `<section id="offers" class="dl-section"><div class="dl-section-head"><div><h2>Offer input workspace</h2><p>Desktop compares side by side. Mobile uses the Offer A / Offer B toggle.</p></div><div class="dl-tabs"><button class="dl-btn ${state.activeOffer==="A"?"active":""}" data-mobile-offer="A">Offer A</button><button class="dl-btn ${state.activeOffer==="B"?"active":""}" data-mobile-offer="B">Offer B</button></div></div><div class="dl-offer-grid">${state.offers.map(renderOffer).join("")}</div></section>`;}
function renderOffer(o,idx){const hide=state.activeOffer!==o.id?"true":"false";return `<article class="dl-offer" data-hidden-mobile="${hide}"><div class="dl-offer-head"><div class="dl-offer-title"><div class="dl-badge-letter">${o.id}</div><div><h3>${escapeHtml(o.name)}</h3><small>${o.type==="SBA_504"?"Two-note SBA 504 structure":"Single-note SBA 7(a) structure"}</small></div></div></div><div class="dl-form"><div class="dl-form-row">${inputAttrs(`name-${o.id}`,`offers.${idx}.name`,"Offer name",o.name,"text","1","")}${selectAttrs(`type-${o.id}`,`offers.${idx}.type`,"Loan type",o.type,[["SBA_7A","SBA 7(a)"],["SBA_504","SBA 504"]])}</div><div class="dl-form-row three">${inputAttrs(`cost-${o.id}`,`offers.${idx}.projectCost`,"Project / acquisition cost",o.projectCost,"number","1000")}${inputAttrs(`eq-${o.id}`,`offers.${idx}.equityPct`,"Borrower equity %",o.equityPct,"number","0.1")}${inputAttrs(`cashfees-${o.id}`,`offers.${idx}.cashFees`,"Cash-paid fees / closing",o.cashFees,"number","100")}</div><div class="dl-form-row three">${inputAttrs(`finfees-${o.id}`,`offers.${idx}.financedFees`,"General financed fees",o.financedFees,"number","100")}${inputAttrs(`recfees-${o.id}`,`offers.${idx}.recurringFees`,"Recurring monthly fees",o.recurringFees,"number","1")}${inputAttrs(`notes-${o.id}`,`offers.${idx}.notes`,"Advisor notes",o.notes||"","text","1","")}</div>${o.type==="SBA_504"?render504(o,idx):render7a(o,idx)}</div></article>`;}
function render7a(o,idx){const p=o.parts.main||defaultOffer("x","x","SBA_7A").parts.main;return `<div class="dl-subpanel"><h4>7(a) note assumptions <span>${fmtMoney(componentPrincipal(o,"main"))} principal</span></h4><div class="dl-form-row three">${selectAttrs(`rm-${o.id}`,`offers.${idx}.parts.main.rateMode`,"Rate mode",p.rateMode,rateModeOpts())}${selectAttrs(`base-${o.id}`,`offers.${idx}.parts.main.base`,"Base rate",p.base,baseOpts())}${inputAttrs(`spread-${o.id}`,`offers.${idx}.parts.main.spread`,"Spread over base %",p.spread,"number","0.01")}</div><div class="dl-form-row four">${inputAttrs(`rate-${o.id}`,`offers.${idx}.parts.main.manualRate`,"Manual / fixed rate %",p.manualRate,"number","0.01")}${inputAttrs(`term-${o.id}`,`offers.${idx}.parts.main.termYears`,"Term years",p.termYears,"number","1")}${inputAttrs(`amort-${o.id}`,`offers.${idx}.parts.main.amortYears`,"Amortization years",p.amortYears,"number","1")}${inputAttrs(`io-${o.id}`,`offers.${idx}.parts.main.ioMonths`,"Interest-only months",p.ioMonths,"number","1")}</div><div class="dl-form-row three">${inputAttrs(`extra-${o.id}`,`offers.${idx}.parts.main.extraPrincipal`,"Extra principal / month",p.extraPrincipal,"number","1")}${selectAttrs(`prepay-${o.id}`,`offers.${idx}.parts.main.prepay`,"Prepayment rule",p.prepay,prepayOpts("7a"))}<div class="dl-field"><label>7(a) cap warning</label><div class="dl-input" style="display:flex;align-items:center">${sevenACapText(o,p)}</div></div></div></div>`;}
function render504(o,idx){const b=o.parts.bank,c=o.parts.cdc;const total=(Number(b.pct)||0)+(Number(c.pct)||0)+(Number(o.equityPct)||0);return `<div class="dl-subpanel"><h4>504 capital stack <span>${total.toFixed(1)}% total</span></h4><div class="dl-capstack" aria-label="504 capital stack"><span style="width:${clamp(b.pct,0,100)}%"></span><span style="width:${clamp(c.pct,0,100)}%"></span><span style="width:${clamp(o.equityPct,0,100)}%"></span></div><div class="dl-form-row three">${inputAttrs(`bp-${o.id}`,`offers.${idx}.parts.bank.pct`,"Bank %",b.pct,"number","0.1")}${inputAttrs(`cp-${o.id}`,`offers.${idx}.parts.cdc.pct`,"CDC/SBA %",c.pct,"number","0.1")}${inputAttrs(`ep-${o.id}`,`offers.${idx}.equityPct`,"Equity %",o.equityPct,"number","0.1")}</div></div>${render504Part(o,idx,"bank","Bank / first-lien note")}${render504Part(o,idx,"cdc","CDC / SBA debenture")}`;}
function render504Part(o,idx,key,title){const p=o.parts[key];return `<div class="dl-subpanel"><h4>${title} <span>${fmtMoney(componentPrincipal(o,key))}</span></h4><div class="dl-form-row three">${selectAttrs(`${key}-rm-${o.id}`,`offers.${idx}.parts.${key}.rateMode`,"Rate mode",p.rateMode,rateModeOpts())}${selectAttrs(`${key}-base-${o.id}`,`offers.${idx}.parts.${key}.base`,"Base rate",p.base,baseOpts())}${inputAttrs(`${key}-spread-${o.id}`,`offers.${idx}.parts.${key}.spread`,"Spread %",p.spread,"number","0.01")}</div><div class="dl-form-row four">${inputAttrs(`${key}-rate-${o.id}`,`offers.${idx}.parts.${key}.manualRate`,"Manual/fixed rate %",p.manualRate,"number","0.01")}${inputAttrs(`${key}-term-${o.id}`,`offers.${idx}.parts.${key}.termYears`,"Term years",p.termYears,"number","1")}${inputAttrs(`${key}-amort-${o.id}`,`offers.${idx}.parts.${key}.amortYears`,"Amort. years",p.amortYears,"number","1")}${inputAttrs(`${key}-io-${o.id}`,`offers.${idx}.parts.${key}.ioMonths`,"I/O months",p.ioMonths,"number","1")}</div><div class="dl-form-row four">${inputAttrs(`${key}-ffin-${o.id}`,`offers.${idx}.parts.${key}.financedFees`,"Component financed fees",p.financedFees,"number","100")}${inputAttrs(`${key}-fcash-${o.id}`,`offers.${idx}.parts.${key}.cashFees`,"Component cash fees",p.cashFees,"number","100")}${inputAttrs(`${key}-frec-${o.id}`,`offers.${idx}.parts.${key}.recurringFees`,"Monthly fees",p.recurringFees,"number","1")}${selectAttrs(`${key}-prepay-${o.id}`,`offers.${idx}.parts.${key}.prepay`,"Prepayment rule",p.prepay,prepayOpts("504"))}</div></div>`;}
function rateModeOpts(){return [["fixed","Fixed / manual"],["manual","Manual variable"],["prime_spread","Prime + spread"],["treasury5_spread","5Y Treasury + spread"],["treasury10_spread","10Y Treasury + spread"]];}
function baseOpts(){return [["prime","Prime"],["treasury5","5Y Treasury"],["treasury10","10Y Treasury"],["manual","Manual"]];}
function prepayOpts(type){return type==="7a"?[["none","None"],["sba_7a_5_3_1","SBA 7(a) 5/3/1 assumption"],["manual","Manual / lender-specific"]]:[["none","None"],["sba_504_declining","504 declining premium assumption"],["manual","Manual / CDC-specific"]];}
function sevenACapText(o,p){const loan=componentPrincipal(o,"main");const r=componentRate(p);let cap=loan>350000?((state.rates.prime.rate||0)+3):(loan>250000?((state.rates.prime.rate||0)+4.5):(loan>50000?((state.rates.prime.rate||0)+6):((state.rates.prime.rate||0)+6.5)));return r>cap?`Above modeled variable cap (${fmtPct(cap)})`:`OK under modeled cap (${fmtPct(cap)})`;}
function renderResults(){const h=state.scenario.horizon;const rows=winnerRows();return `<section id="results" class="dl-section"><div class="dl-section-head"><div><h2>Comparison dashboard</h2><p>Costs include upfront cash, payments through horizon, remaining balance, and modeled payoff penalty.</p></div><div class="dl-actions"><label class="dl-field" style="min-width:135px"><span>Horizon</span><select class="dl-input" data-scenario="horizon">${HORIZONS.map(x=>`<option value="${x}" ${x==h?"selected":""}>${x} years</option>`).join("")}</select></label><label class="dl-field" style="min-width:135px"><span>Rate shock</span><select class="dl-input" data-scenario="shock">${[0,1,2,3].map(x=>`<option value="${x}" ${x==state.scenario.shock?"selected":""}>+${x}%</option>`).join("")}</select></label><label class="dl-field" style="min-width:150px"><span>Fee treatment</span><select class="dl-input" data-scenario="feeTreatment"><option value="as_entered" ${state.scenario.feeTreatment==="as_entered"?"selected":""}>As entered</option><option value="all_cash" ${state.scenario.feeTreatment==="all_cash"?"selected":""}>All cash</option><option value="all_financed" ${state.scenario.feeTreatment==="all_financed"?"selected":""}>All financed</option></select></label></div></div><div class="dl-results-grid">${metric("Upfront cash",calc.A.upfrontCash,calc.B.upfrontCash,"Lower cash at close",true)}${metric("Monthly payment",calc.A.monthlyPayment,calc.B.monthlyPayment,"Combined scheduled debt service",true)}${metric(`${h}-year cost`,calc.A.horizons[h].totalCost,calc.B.horizons[h].totalCost,"Payments + payoff balance + upfront cash",true)}${metric("Break-even",calc.breakEven.month?calc.breakEven.month:0,0,calc.breakEven.detail,false,"blue",calc.breakEven.label)}</div><div class="dl-winner-grid">${rows}</div>${renderTimeline()}</section>`;}
function metric(label,a,b,small,compare,cls,override){let val=override||"--", note=small, c=cls||"";if(compare){const win=a<b?"A":b<a?"B":"Tie";val=win==="Tie"?"Tie":`Offer ${win}`;note+=` · A ${fmtMoney(a)} / B ${fmtMoney(b)}`;c=win==="A"?"blue":win==="B"?"good":"amber";}return `<div class="dl-metric ${c}"><span>${label}</span><strong>${val}</strong><small>${note}</small></div>`;}
function winnerRows(){const metrics=[ ["Lowest upfront cash",calc.A.upfrontCash,calc.B.upfrontCash],["Lowest monthly payment",calc.A.monthlyPayment,calc.B.monthlyPayment],["Lowest 5-year cost",calc.A.horizons[5].totalCost,calc.B.horizons[5].totalCost],["Lowest 10-year cost",calc.A.horizons[10].totalCost,calc.B.horizons[10].totalCost],["Lowest 15-year cost",calc.A.horizons[15].totalCost,calc.B.horizons[15].totalCost],["Lowest 20-year cost",calc.A.horizons[20].totalCost,calc.B.horizons[20].totalCost],["Lowest total interest",calc.A.horizons[25].interest,calc.B.horizons[25].interest],["Lowest remaining balance",calc.A.horizons[state.scenario.horizon].balance,calc.B.horizons[state.scenario.horizon].balance] ];return metrics.map(m=>{const w=m[1]<m[2]?"A":m[2]<m[1]?"B":"Tie";const klass=w==="A"?"a":w==="B"?"b":"tie";return `<div class="dl-win"><span>${m[0]}</span><b class="tag ${klass}">${w==="Tie"?"Tie":"Offer "+w}</b></div>`;}).join("")+`<div class="dl-win"><span>Best cash preservation</span><b class="tag ${calc.A.upfrontCash<calc.B.upfrontCash?"a":calc.B.upfrontCash<calc.A.upfrontCash?"b":"tie"}">${calc.A.upfrontCash<calc.B.upfrontCash?"Offer A":calc.B.upfrontCash<calc.A.upfrontCash?"Offer B":"Tie"}</b></div><div class="dl-win"><span>Best if refinancing early</span><b class="tag ${calc.A.horizons[5].totalCost<calc.B.horizons[5].totalCost?"a":"b"}">${calc.A.horizons[5].totalCost<calc.B.horizons[5].totalCost?"Offer A":"Offer B"}</b></div><div class="dl-win"><span>Best full-term hold</span><b class="tag ${calc.A.horizons[25].totalCost<calc.B.horizons[25].totalCost?"a":"b"}">${calc.A.horizons[25].totalCost<calc.B.horizons[25].totalCost?"Offer A":"Offer B"}</b></div><div class="dl-win"><span>Overall answer</span><b class="tag depends">Depends</b></div>`;}
function renderTimeline(){const pos=calc.breakEven.month?clamp(calc.breakEven.month/MAX_MONTHS*100,0,100):50;return `<div class="dl-timeline"><div class="dl-timeline-box"><strong>Breakeven timeline</strong><p style="margin:6px 0 0;color:#65706a;font-size:13px">${calc.breakEven.detail}</p><div class="dl-track"><span class="dl-marker" style="left:${pos}%"></span></div><div style="display:flex;justify-content:space-between;color:#65706a;font-size:11px"><span>Closing</span><span>Year 10</span><span>Year 25</span></div></div></div>`;}
function renderCharts(){return `<section class="dl-section"><div class="dl-section-head"><div><h2>Deal visuals</h2><p>Inline SVG charts avoid external libraries and stay responsive inside the widget sandbox.</p></div></div><div class="dl-charts"><div class="dl-chart"><h3>Cumulative payoff cost over time</h3>${lineChart("cost")}</div><div class="dl-chart"><h3>Remaining balance over time</h3>${lineChart("balance")}</div><div class="dl-chart"><h3>Monthly payment composition</h3>${barChart()}</div><div class="dl-chart"><h3>Rate shock sensitivity</h3>${sensitivityChart()}</div></div>${renderNotes()}</section>`;}
function chartPoints(kind,offer){return HORIZONS.map(y=>({x:y,y:offer.horizons[y][kind==="cost"?"totalCost":"balance"]}));}
function lineChart(kind){const a=chartPoints(kind,calc.A),b=chartPoints(kind,calc.B);const all=a.concat(b).map(p=>p.y);const max=Math.max(...all,1),min=Math.min(...all,0);const sx=x=>40+(x/25)*310;const sy=y=>178-((y-min)/(max-min||1))*145;const path=pts=>pts.map((p,i)=>`${i?"L":"M"}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(" ");return `<svg viewBox="0 0 380 210" role="img" aria-label="${kind} chart"><line class="axis" x1="40" y1="180" x2="354" y2="180"/><line class="axis" x1="40" y1="30" x2="40" y2="180"/><path class="line-a" d="${path(a)}"/><path class="line-b" d="${path(b)}"/>${HORIZONS.map(y=>`<text x="${sx(y)-8}" y="198">${y}</text>`).join("")}<text x="42" y="24">${fmtMoney(max)}</text><text x="42" y="192">${fmtMoney(min)}</text><circle cx="285" cy="18" r="5" fill="#4f7d9a"/><text x="295" y="22">A</text><circle cx="325" cy="18" r="5" fill="#5f8f6c"/><text x="335" y="22">B</text></svg>`;}
function barChart(){const a=calc.A.components.reduce((s,c)=>s+c.firstInterest,0),ap=calc.A.monthlyPayment-a;const b=calc.B.components.reduce((s,c)=>s+c.firstInterest,0),bp=calc.B.monthlyPayment-b;const max=Math.max(a+ap,b+bp,1);const h=v=>Math.max(1,(v/max)*140);return `<svg viewBox="0 0 380 210" role="img" aria-label="Payment composition"><line class="axis" x1="40" y1="180" x2="354" y2="180"/><g><rect x="90" y="${180-h(ap)}" width="44" height="${h(ap)}" fill="#4f7d9a"/><rect x="90" y="${180-h(ap)-h(a)}" width="44" height="${h(a)}" fill="#b88a36"/><text x="92" y="198">Offer A</text><text x="78" y="${166-h(ap)-h(a)}">${fmtMoney(a+ap)}</text></g><g><rect x="230" y="${180-h(bp)}" width="44" height="${h(bp)}" fill="#5f8f6c"/><rect x="230" y="${180-h(bp)-h(b)}" width="44" height="${h(b)}" fill="#b88a36"/><text x="232" y="198">Offer B</text><text x="218" y="${166-h(bp)-h(b)}">${fmtMoney(b+bp)}</text></g><text x="40" y="24">Principal / interest split, first modeled month</text></svg>`;}
function sensitivityChart(){const old=state.scenario.shock;const rows=[0,1,2,3].map(s=>{state.scenario.shock=s;const c=calculateAll(false);return{s,a:c.A.horizons[state.scenario.horizon].totalCost,b:c.B.horizons[state.scenario.horizon].totalCost};});state.scenario.shock=old;const max=Math.max(...rows.flatMap(r=>[r.a,r.b]),1);const sx=i=>58+i*84;const h=v=>(v/max)*130;return `<svg viewBox="0 0 380 210" role="img" aria-label="Rate shock sensitivity"><line class="axis" x1="40" y1="180" x2="354" y2="180"/>${rows.map((r,i)=>`<g><rect x="${sx(i)}" y="${180-h(r.a)}" width="26" height="${h(r.a)}" fill="#4f7d9a"/><rect x="${sx(i)+30}" y="${180-h(r.b)}" width="26" height="${h(r.b)}" fill="#5f8f6c"/><text x="${sx(i)+3}" y="198">+${r.s}%</text></g>`).join("")}<text x="40" y="24">${state.scenario.horizon}-year total cost under rate shock</text></svg>`;}
function renderNotes(){const notes=calc.notes.map(n=>`<li>${escapeHtml(n)}</li>`).join("");const warns=calc.warnings.length?calc.warnings.map(w=>`<li class="${w.severity==="warning"?"serious":"warning"}">${escapeHtml(w.message)}</li>`).join(""):`<li>No serious validation warnings under current assumptions.</li>`;return `<div class="dl-notes"><div class="dl-note-list"><h3>Advisor notes</h3><ul>${notes}</ul></div><div class="dl-note-list"><h3>Warnings and assumptions</h3><ul>${warns}</ul></div></div>`;}
function renderAmortization(){const rows=state.tableMode==="monthly"?calc.tableMonthly:calc.tableAnnual;return `<section class="dl-section"><div class="dl-section-head"><div><h2>Amortization and annual summary</h2><p>504 loans preserve bank and CDC/SBA component calculations before combined totals.</p></div></div><div class="dl-table-tools"><button class="dl-btn ${state.tableMode==="annual"?"primary":"ghost"}" data-table-mode="annual">Annual summary</button><button class="dl-btn ${state.tableMode==="monthly"?"primary":"ghost"}" data-table-mode="monthly">Monthly schedule</button><select class="dl-input" style="max-width:210px" data-component-filter><option value="all" ${state.componentFilter==="all"?"selected":""}>All components</option><option value="A" ${state.componentFilter==="A"?"selected":""}>Offer A only</option><option value="B" ${state.componentFilter==="B"?"selected":""}>Offer B only</option><option value="Bank" ${state.componentFilter==="Bank"?"selected":""}>504 bank notes</option><option value="CDC" ${state.componentFilter==="CDC"?"selected":""}>504 CDC/SBA notes</option></select><button class="dl-btn ghost" data-export="amortization">Export CSV</button></div><div class="dl-table-wrap"><table class="dl-table"><thead><tr>${(state.tableMode==="monthly"?["Month","Offer","Component","Beg. balance","Payment","Principal","Interest","Fees","End balance"]:["Year","Offer","Beg. balance","Payments","Principal","Interest","Fees","End balance","Payoff cost"]).map(h=>`<th>${h}</th>`).join("")}</tr></thead><tbody>${rows.slice(0, state.tableMode==="monthly"?650:80).map(r=>state.tableMode==="monthly"?`<tr><td>${r.month}</td><td>${r.offer}</td><td>${r.component}</td><td>${fmtFullMoney(r.beg)}</td><td>${fmtFullMoney(r.payment)}</td><td>${fmtFullMoney(r.principal)}</td><td>${fmtFullMoney(r.interest)}</td><td>${fmtFullMoney(r.fees)}</td><td>${fmtFullMoney(r.end)}</td></tr>`:`<tr><td>${r.year}</td><td>${r.offer}</td><td>${fmtFullMoney(r.beg)}</td><td>${fmtFullMoney(r.payment)}</td><td>${fmtFullMoney(r.principal)}</td><td>${fmtFullMoney(r.interest)}</td><td>${fmtFullMoney(r.fees)}</td><td>${fmtFullMoney(r.end)}</td><td>${fmtFullMoney(r.payoffCost)}</td></tr>`).join("")}</tbody></table></div></section>`;}
function renderDisclaimer(){return `<div class="dl-disclaimer"><strong>Educational-use disclaimer.</strong> This tool is for educational comparison only. It is not legal, tax, accounting, lending, or financial advice. Actual lender terms, SBA rules, fees, eligibility, and rates vary. Users should verify all assumptions with their lender, CDC, accountant, attorney, and other advisors. Live rates are reference rates and are not guaranteed loan quotes.</div><div class="dl-sr" aria-live="polite">${escapeHtml(calc.selected.summary)}</div>`;}
function bind(){root.querySelectorAll("[data-path]").forEach(el=>{el.addEventListener("input",onPathChange);el.addEventListener("change",onPathChange);});root.querySelectorAll("[data-rate]").forEach(el=>el.addEventListener("input",onRateChange));root.querySelectorAll("[data-scenario]").forEach(el=>el.addEventListener("change",onScenario));root.querySelectorAll("[data-preset]").forEach(el=>el.addEventListener("click",()=>applyPreset(el.dataset.preset)));root.querySelectorAll("[data-action='refreshRates']").forEach(el=>el.addEventListener("click",refreshRates));root.querySelectorAll("[data-action='markManual']").forEach(el=>el.addEventListener("click",markRatesManual));root.querySelectorAll("[data-mobile-offer]").forEach(el=>el.addEventListener("click",()=>{state.activeOffer=el.dataset.mobileOffer;render();debouncedSave();}));root.querySelectorAll("[data-table-mode]").forEach(el=>el.addEventListener("click",()=>{state.tableMode=el.dataset.tableMode;render();debouncedSave();}));root.querySelectorAll("[data-component-filter]").forEach(el=>el.addEventListener("change",()=>{state.componentFilter=el.value;render();debouncedSave();}));root.querySelectorAll("[data-export]").forEach(el=>el.addEventListener("click",async()=>{await exportCSV(el.dataset.export);}));root.querySelectorAll("[data-scroll]").forEach(el=>el.addEventListener("click",()=>document.getElementById(el.dataset.scroll)?.scrollIntoView({behavior:"smooth",block:"start"})));}
function onPathChange(e){let val=e.target.value;if(e.target.type==="number")val=Number(val);setByPath(state,e.target.dataset.path,val);normalizeOfferTypes();render();debouncedSave();}
function onRateChange(e){const k=e.target.dataset.rate;state.rates[k].rate=Number(e.target.value);state.rates[k].status="manual";state.rates[k].source="Manual override";state.rates[k].retrievedAt=todayISO();render();debouncedSave();}
function onScenario(e){const key=e.target.dataset.scenario;state.scenario[key]=key==="feeTreatment"?e.target.value:Number(e.target.value);render();debouncedSave();}
function setByPath(obj,path,val){const parts=path.split(".");let cur=obj;for(let i=0;i<parts.length-1;i++){cur=cur[parts[i]];}cur[parts[parts.length-1]]=val;}
function normalizeOfferTypes(){state.offers=state.offers.map(o=>{if(o.type==="SBA_504"&&!o.parts.bank)return Object.assign(defaultOffer(o.id,o.name,"SBA_504"),{projectCost:o.projectCost,equityPct:o.equityPct,cashFees:o.cashFees,financedFees:o.financedFees,recurringFees:o.recurringFees,notes:o.notes});if(o.type==="SBA_7A"&&!o.parts.main)return Object.assign(defaultOffer(o.id,o.name,"SBA_7A"),{projectCost:o.projectCost,equityPct:o.equityPct,cashFees:o.cashFees,financedFees:o.financedFees,recurringFees:o.recurringFees,notes:o.notes});return o;});}
function applyPreset(p){if(p==="7a504"){state.offers=[defaultOffer("A","Offer A","SBA_7A"),defaultOffer("B","Offer B","SBA_504")];}if(p==="7a7a"){state.offers=[defaultOffer("A","Offer A","SBA_7A"),defaultOffer("B","Offer B","SBA_7A")];state.offers[1].parts.main.manualRate=9.25;state.offers[1].parts.main.spread=2.25;state.offers[1].financedFees=65000;}if(p==="504504"){state.offers=[defaultOffer("A","Offer A","SBA_504"),defaultOffer("B","Offer B","SBA_504")];state.offers[1].parts.bank.pct=55;state.offers[1].parts.cdc.pct=35;state.offers[1].equityPct=10;state.offers[1].parts.bank.manualRate=7.0;state.offers[1].parts.cdc.manualRate=6.35;}if(p==="custom"){state=defaults();}render();debouncedSave();}
function markRatesManual(){Object.keys(state.rates).forEach(k=>{state.rates[k].status="manual";state.rates[k].source="Manual override";state.rates[k].retrievedAt=todayISO();});render();debouncedSave();}
function componentPrincipal(o,key){let base=Number(o.projectCost)||0;if(o.type==="SBA_7A")return Math.max(0,base*(1-pct(o.equityPct))+feeValue(o,"main","financed"));const part=o.parts[key];return Math.max(0,base*pct(part.pct)+feeValue(o,key,"financed"));}
function feeValue(o,key,treatment){let general=Number(o.financedFees)||0;if(state.scenario.feeTreatment==="all_cash")general=0;if(state.scenario.feeTreatment==="all_financed")general=(Number(o.financedFees)||0)+(Number(o.cashFees)||0);if(o.type==="SBA_7A")return treatment==="financed"?general:0;const part=o.parts[key]||{};let pf=Number(part.financedFees)||0;if(state.scenario.feeTreatment==="all_cash")pf=0;if(state.scenario.feeTreatment==="all_financed")pf=(Number(part.financedFees)||0)+(Number(part.cashFees)||0);return treatment==="financed"?pf:0;}
function upfrontCash(o){let cash=Number(o.projectCost||0)*pct(o.equityPct)+(Number(o.cashFees)||0);if(state.scenario.feeTreatment==="all_cash")cash+=(Number(o.financedFees)||0);if(state.scenario.feeTreatment==="all_financed")cash=Number(o.projectCost||0)*pct(o.equityPct);if(o.type==="SBA_504"){["bank","cdc"].forEach(k=>{const p=o.parts[k];cash+=Number(p.cashFees)||0;if(state.scenario.feeTreatment==="all_cash")cash+=Number(p.financedFees)||0;if(state.scenario.feeTreatment==="all_financed")cash-=Number(p.cashFees)||0;});}return Math.max(0,cash);}
function componentRate(p){let r=Number(p.manualRate)||0;if(p.rateMode==="prime_spread")r=(Number(state.rates.prime.rate)||0)+Number(p.spread||0)+Number(state.scenario.shock||0);if(p.rateMode==="treasury5_spread")r=(Number(state.rates.treasury5.rate)||0)+Number(p.spread||0)+Number(state.scenario.shock||0);if(p.rateMode==="treasury10_spread")r=(Number(state.rates.treasury10.rate)||0)+Number(p.spread||0)+Number(state.scenario.shock||0);return clamp(r,0,40);}
function amortizeComponent(o,key,label){const p=o.parts[key];const principal=componentPrincipal(o,key);const rate=componentRate(p);const termMonths=Math.max(1,Math.round((Number(p.termYears)||0)*12));const amortMonths=Math.max(1,Math.round((Number(p.amortYears)||0)*12));const ioMonths=clamp(p.ioMonths,0,termMonths);const mRate=pct(rate)/12;const rec=(Number(o.recurringFees)||0)+(Number(p.recurringFees)||0);let bal=principal,cumI=0,cumP=0,cumPaid=0;const rows=[];const payCalc=(balance,months)=>mRate>0?balance*mRate/(1-Math.pow(1+mRate,-months)):balance/months;let scheduled=payCalc(bal,Math.max(1,amortMonths));let firstInterest=bal*mRate;for(let m=1;m<=MAX_MONTHS;m++){if(bal<=.01){rows.push({month:m,offer:o.id,component:label,beg:0,payment:0,principal:0,interest:0,fees:0,end:0,cumI,cumP,cumPaid,payoff:0});continue;}let beg=bal,interest=beg*mRate,principalPaid=0,payment=0;if(m<=ioMonths){principalPaid=Math.min(beg,Number(p.extraPrincipal||0)+Number(state.scenario.extraPrincipal||0));payment=interest+principalPaid+rec;}else{if(m===ioMonths+1){const remAmort=Math.max(1,amortMonths-ioMonths);scheduled=payCalc(beg,remAmort);}let schedPrincipal=Math.max(0,scheduled-interest);principalPaid=Math.min(beg,schedPrincipal+Number(p.extraPrincipal||0)+Number(state.scenario.extraPrincipal||0));payment=interest+principalPaid+rec;}if(m>termMonths){payment=0;principalPaid=0;interest=0;}bal=Math.max(0,beg-principalPaid);cumI+=interest;cumP+=principalPaid;cumPaid+=payment;rows.push({month:m,offer:o.id,component:label,beg,payment,principal:principalPaid,interest,fees:rec,end:bal,cumI,cumP,cumPaid,payoff:bal});}
return{key,label,principal,rate,rows,monthlyPayment:rows[0]?.payment||0,firstInterest,termMonths,amortMonths,prepay:p.prepay};}
function calculateOffer(o){const comps=o.type==="SBA_504"?[amortizeComponent(o,"bank","Bank"),amortizeComponent(o,"cdc","CDC/SBA")]:[amortizeComponent(o,"main","7(a)")];const monthlyPayment=comps.reduce((s,c)=>s+c.monthlyPayment,0);const up=upfrontCash(o);const principal=comps.reduce((s,c)=>s+c.principal,0);const blended=principal?comps.reduce((s,c)=>s+c.principal*c.rate,0)/principal:0;const horizons={};HORIZONS.forEach(y=>{const m=y*12;let payments=0,interest=0,principalPaid=0,fees=0,balance=0,penalty=0;comps.forEach(c=>{const rows=c.rows.slice(0,m);const last=c.rows[Math.min(m,MAX_MONTHS)-1]||c.rows[c.rows.length-1];payments+=rows.reduce((s,r)=>s+r.payment,0);interest+=rows.reduce((s,r)=>s+r.interest,0);principalPaid+=rows.reduce((s,r)=>s+r.principal,0);fees+=rows.reduce((s,r)=>s+r.fees,0);balance+=last?last.end:0;penalty+=prepayPenalty(c,last?last.end:0,m);});horizons[y]={payments,interest,principal:principalPaid,fees,balance,penalty,totalCost:up+payments+balance+penalty};});return{offer:o,components:comps,monthlyPayment,upfrontCash:up,principal,blended,horizons};}
function prepayPenalty(c,balance,month){if(!state.scenario.prepay||!balance)return 0;if(c.prepay==="sba_7a_5_3_1"&&c.termMonths>=180){if(month<=12)return balance*.05;if(month<=24)return balance*.03;if(month<=36)return balance*.01;}if(c.prepay==="sba_504_declining"){const y=Math.ceil(month/12);if(y<=10)return balance*(.03*(11-y)/10);}return 0;}
function calculateAll(makeTables=true){const A=calculateOffer(state.offers[0]),B=calculateOffer(state.offers[1]);const selectedH=state.scenario.horizon;const delta=A.horizons[selectedH].totalCost-B.horizons[selectedH].totalCost;let selected={delta,summary:Math.abs(delta)<1?`Offers are roughly tied at ${selectedH} years`:`Offer ${delta<0?"A":"B"} is lower cost at ${selectedH} years`,monthlyWinnerValue:Math.min(A.monthlyPayment,B.monthlyPayment)};const breakEven=findBreakEven(A,B);const warnings=collectWarnings(A,B);const notes=collectNotes(A,B,breakEven);const tables=makeTables?buildTables(A,B):{monthly:[],annual:[]};return{A,B,selected,breakEven,warnings,notes,tableMonthly:tables.monthly,tableAnnual:tables.annual};}
function findBreakEven(A,B){let prev=null,changes=[];for(let m=1;m<=MAX_MONTHS;m++){const ca=monthCost(A,m),cb=monthCost(B,m),d=ca-cb;if(prev!==null&&((prev<0&&d>0)||(prev>0&&d<0)))changes.push(m);prev=d;}if(!changes.length){const d25=monthCost(A,MAX_MONTHS)-monthCost(B,MAX_MONTHS);const lower=d25<0?"Offer A remains lower-cost through 25 years under current assumptions.":d25>0?"Offer B remains lower-cost through 25 years under current assumptions.":"Offers end roughly tied through 25 years.";return{month:null,label:"None",detail:lower};}const m=changes[0],y=(m/12).toFixed(1);return{month:m,label:`Month ${m}`,detail:`First modeled break-even occurs around month ${m} (year ${y}).${changes.length>1?" Winner changes more than once under these assumptions.":""}`};}
function monthCost(of,m){let payments=0,balance=0,penalty=0;of.components.forEach(c=>{const rows=c.rows.slice(0,m);const last=c.rows[Math.min(m,MAX_MONTHS)-1];payments+=rows.reduce((s,r)=>s+r.payment,0);balance+=last?last.end:0;penalty+=prepayPenalty(c,last?last.end:0,m);});return of.upfrontCash+payments+balance+penalty;}
function collectWarnings(A,B){const w=[];state.offers.forEach(o=>{if(o.type==="SBA_7A"&&componentPrincipal(o,"main")>5000000)w.push({severity:"warning",message:`${o.name}: modeled 7(a) principal exceeds $5 million.`});if(o.type==="SBA_7A"&&Number(o.parts.main.termYears)>25)w.push({severity:"warning",message:`${o.name}: 7(a) term exceeds 25 years.`});if(o.type==="SBA_504"){const total=(Number(o.parts.bank.pct)||0)+(Number(o.parts.cdc.pct)||0)+(Number(o.equityPct)||0);if(Math.abs(total-100)>.05)w.push({severity:"warning",message:`${o.name}: 504 bank + CDC + equity percentages total ${total.toFixed(1)}%, not 100%.`});if(!componentRate(o.parts.cdc))w.push({severity:"warning",message:`${o.name}: CDC/SBA debenture rate is missing.`});}Object.keys(state.rates).forEach(k=>{const r=state.rates[k];if(r.sourceDate&&ageDays(r.sourceDate)>rateDefs[k].staleDays)w.push({severity:"caution",message:`${rateDefs[k].label} source appears stale (${ageDays(r.sourceDate)} days old).`});});});if(state.scenario.shock>0)w.push({severity:"caution",message:`A +${state.scenario.shock}% rate shock is active for base-rate formulas.`});if(state.scenario.feeTreatment!=="as_entered")w.push({severity:"caution",message:`Fee treatment override is active: ${state.scenario.feeTreatment.replace("_"," ")}.`});return w;}
function collectNotes(A,B,be){const h=state.scenario.horizon;const notes=[];notes.push(`${A.monthlyPayment<B.monthlyPayment?"Offer A":"Offer B"} has the lower modeled monthly payment.`);notes.push(`${A.upfrontCash<B.upfrontCash?"Offer A":"Offer B"} preserves more cash at closing by ${fmtFullMoney(Math.abs(A.upfrontCash-B.upfrontCash))}.`);notes.push(`${A.horizons[h].totalCost<B.horizons[h].totalCost?"Offer A":"Offer B"} has the lower ${h}-year payoff cost under current assumptions.`);notes.push(be.month?`Break-even occurs near month ${be.month}; do not pick a winner from payment alone.`:`No break-even was found through 25 years under the current scenario.`);if(state.offers.some(o=>o.type==="SBA_504"))notes.push("504 structures are calculated as separate bank and CDC/SBA schedules before combining totals.");if(Object.values(state.rates).some(r=>r.status==="manual"))notes.push("At least one reference rate is manually entered; verify lender and CDC assumptions before relying on results.");return notes;}
function buildTables(A,B){let monthly=[];[A,B].forEach(of=>of.components.forEach(c=>c.rows.forEach(r=>{if(filterRow(r))monthly.push(r);})));let annual=[];[A,B].forEach(of=>{for(let y=1;y<=25;y++){const rows=[];of.components.forEach(c=>{rows.push(...c.rows.slice((y-1)*12,y*12).filter(filterRow));});if(!rows.length)continue;annual.push({year:y,offer:of.offer.id,beg:rows.reduce((s,r)=>s+(r.month%12===1?r.beg:0),0),payment:rows.reduce((s,r)=>s+r.payment,0),principal:rows.reduce((s,r)=>s+r.principal,0),interest:rows.reduce((s,r)=>s+r.interest,0),fees:rows.reduce((s,r)=>s+r.fees,0),end:rows.filter(r=>r.month===y*12).reduce((s,r)=>s+r.end,0),payoffCost:monthCost(of,y*12)});}});return{monthly,annual};}
function filterRow(r){const f=state.componentFilter;if(f==="all")return true;if(f==="A"||f==="B")return r.offer===f;if(f==="Bank")return r.component==="Bank";if(f==="CDC")return r.component==="CDC/SBA";return true;}
async function fetchWithFallback(url,options){try{const res=await fetch(url,options||{});if(!res.ok)throw new Error("HTTP "+res.status);return res;}catch(direct){if(!window.vibes||!vibes.fetch)throw direct;const res=await vibes.fetch(url,Object.assign({method:"GET",timeout:12000},options||{}));if(res&&typeof res.text==="function")return res;if(typeof res==="string")return{ok:true,text:async()=>res};if(res&&res.body)return{ok:true,text:async()=>res.body};throw new Error("Rate source unavailable");}}
async function refreshRates(){state.isFetching=true;render();try{const res=await fetchWithFallback("https://www.federalreserve.gov/releases/h15/");const text=await res.text();const parsed=parseH15(text);applyRate("prime",parsed.prime,parsed.date,"Federal Reserve H.15");applyRate("treasury5",parsed.treasury5,parsed.date,"Federal Reserve H.15");applyRate("treasury10",parsed.treasury10,parsed.date,"Federal Reserve H.15");state.rates.sba504.status=state.rates.sba504.status||"manual";state.rates.sba504.source=state.rates.sba504.source||"Manual CDC/504 effective-rate assumption";noteSave("Live rates refreshed");}catch(e){Object.keys(state.rates).forEach(k=>{if(state.rates[k].status!=="manual")state.rates[k].status="cached";});noteSave("Rate refresh failed; using manual/cached values");}finally{state.isFetching=false;render();debouncedSave();}}
function applyRate(k,val,date,source){if(isFinite(val)&&val>=rateDefs[k].range[0]&&val<=rateDefs[k].range[1])state.rates[k]={label:rateDefs[k].label,rate:val,status:"live",source,sourceDate:date||new Date().toISOString().slice(0,10),retrievedAt:todayISO(),notes:"Pulled during this session"};}
function parseH15(html){const doc=new DOMParser().parseFromString(html,"text/html");const text=(doc.body&&doc.body.innerText?doc.body.innerText:html.replace(/<[^>]+>/g," ")).replace(/\u00a0/g," ");const dateMatch=text.match(/Release date:\s*([^\n]+)/i);const date=dateMatch?dateMatch[1].trim():new Date().toISOString().slice(0,10);const lines=text.split(/\n+/).map(x=>x.trim()).filter(Boolean);function nums(line){return (line.match(/\d+\.\d+/g)||[]).map(Number);}function lastFor(label,start){for(let i=start||0;i<lines.length;i++){if(lines[i].toLowerCase().startsWith(label.toLowerCase())){const n=nums(lines[i]);if(n.length)return n[n.length-1];let joined=lines.slice(i,i+8).join(" ");n=nums(joined);if(n.length)return n[n.length-1];}}return NaN;}const prime=lastFor("Bank prime loan",0);let tStart=lines.findIndex(l=>/Treasury constant maturities/i.test(l));if(tStart<0)tStart=0;return{date,prime,treasury5:lastFor("5-year",tStart),treasury10:lastFor("10-year",tStart)};}
async function exportCSV(type){const rows=type==="summary"?summaryRows():calc.tableMonthly.map(r=>({month:r.month,offer:r.offer,component:r.component,beginning_balance:r.beg,payment:r.payment,principal:r.principal,interest:r.interest,fees:r.fees,ending_balance:r.end}));const csv=toCSV(rows);const filename=type==="summary"?"sba-deallens-summary.csv":"sba-amortization-schedule.csv";if(vibes.exportData){try{await vibes.exportData(rows,{filename});return;}catch(e){}}const blob=new Blob([csv],{type:"text/csv;charset=utf-8"});const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},0);}
function summaryRows(){return HORIZONS.map(y=>({horizon_years:y,offer_a_cost:calc.A.horizons[y].totalCost,offer_b_cost:calc.B.horizons[y].totalCost,winner:calc.A.horizons[y].totalCost<calc.B.horizons[y].totalCost?"Offer A":calc.B.horizons[y].totalCost<calc.A.horizons[y].totalCost?"Offer B":"Tie",offer_a_payment:calc.A.monthlyPayment,offer_b_payment:calc.B.monthlyPayment,offer_a_upfront_cash:calc.A.upfrontCash,offer_b_upfront_cash:calc.B.upfrontCash}));}
function toCSV(rows){if(!rows.length)return"";const keys=Object.keys(rows[0]);return keys.join(",")+"\n"+rows.map(r=>keys.map(k=>`"${String(r[k]??"").replace(/"/g,'""')}"`).join(",")).join("\n");}
vibes.onReady(async()=>{const saved={offers:await safeLoad("loan_offers"),rates:await safeLoad("rate_cache"),scenario:await safeLoad("scenario"),prefs:await safeLoad("ui_prefs")};mergeSaved(saved);normalizeOfferTypes();render();});
})();
</script>
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Agent-Accessible State (vibes.ai.*)
State written with the standard `vibes.save` / `vibes.load` methods is private to each user and is **NOT readable by AI agents**. To share state with an AI agent (via the MCP connector), use the `vibes.ai` sub-namespace:
| Method | Description |
|--------|-------------|
| `await vibes.ai.setState(key, value)` | Write agent-readable state. Key is stored internally as `ai/<key>`. |
| `await vibes.ai.getState(key)` | Read agent-readable state. Returns `null` if key absent. |
| `await vibes.ai.listKeys()` | List all agent-readable keys (without the `ai/` prefix). |
**Important rules:**
- Regular `vibes.save()` / `vibes.load()` is **NOT agent-accessible** — use `vibes.ai.*` for state you want agents to read.
- The widget **owner** must enable agent access in **Manage → Agent tab** before any agent can read or write `vibes.ai.*` state.
- Keys are auto-prefixed to `ai/` internally; you supply just the short key (e.g. `'context'`).
```js
vibes.onReady(async () => {
// Write state an AI agent can later read
await vibes.ai.setState('context', { currentLevel: 3, score: 1500 });
// Read it back (same auto-prefix applies)
const ctx = await vibes.ai.getState('context');
// List all agent-accessible keys for this widget
const keys = await vibes.ai.listKeys(); // e.g. ['context']
});
```
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **External JS libraries are allowed and encouraged** — load them via a `<script src>` tag from a reputable CDN (jsDelivr, unpkg, or cdnjs) with a pinned version, or inline the library source. The only exception: do NOT add a script tag for the Vibes SDK itself — it is auto-injected (see Rule 2).
6. **Keep total code under 100KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained HTML file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/sba-deallens-v2 */