Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ <h2>Portfolio Overview</h2>
<!-- Stats row -->
<div class="stats-row">
<div class="stat-card">
<span class="stat-label">c_factor <span class="tooltip" data-tip="Collateral factor — the fraction of your collateral that counts toward borrowing power. 95% means $100 of collateral lets you borrow up to $95.">?</span></span>
<span class="stat-label">c_factor <span class="tooltip" data-term="c_factor">?</span></span>
<span class="stat-value" id="stat-cfactor">—</span>
</div>
<div class="stat-card">
Expand All @@ -296,7 +296,7 @@ <h2>Portfolio Overview</h2>
<span class="stat-value" id="stat-liquidity">—</span>
</div>
<div class="stat-card">
<span class="stat-label">Utilization <span class="tooltip" data-tip="Pool utilization = total borrowed / total supplied.">?</span></span>
<span class="stat-label">Utilization <span class="tooltip" data-term="util_target" data-tip="">?</span></span>
<span class="stat-value" id="stat-util">—</span>
<div class="util-bar-wrap"><div class="util-bar" id="util-bar"></div></div>
</div>
Expand All @@ -311,7 +311,7 @@ <h2>Portfolio Overview</h2>
<div class="apr-card">
<div class="apr-card-label">Supply</div>
<div class="apr-row">
<span class="apr-key">Interest APY</span>
<span class="apr-key">Interest <span class="tooltip" data-term="APY">APY</span></span>
<span class="apr-val" id="supply-interest-apr">—</span>
</div>
<div class="apr-row">
Expand All @@ -329,6 +329,14 @@ <h2>Portfolio Overview</h2>
<span class="apr-key">Interest cost</span>
<span class="apr-val" id="borrow-interest-apr">—</span>
</div>
<div class="apr-row apr-rate-params">
<span class="apr-key">Rate model:
<span class="tooltip" data-term="r_base">r_base</span>
<span class="tooltip" data-term="r_one">r_one</span>
<span class="tooltip" data-term="r_two">r_two</span>
<span class="tooltip" data-term="r_three">r_three</span>
</span>
</div>
<div class="apr-row">
<span class="apr-key">BLND emissions <span class="tooltip" data-tip="BLND token rewards that offset borrow cost. Shown as APR since emissions are linear and do not auto-compound.">?</span></span>
<span class="apr-val apr-blnd" id="borrow-blnd-apr">—</span>
Expand Down Expand Up @@ -397,6 +405,11 @@ <h2 id="action-card-title">Open Position</h2>
<input type="number" id="leverage-input" class="input mono leverage-num-input" min="1.1" max="12.9" step="0.1" value="2.0" />
<span class="slider-value-x mono">&times;</span>
</div>
<div class="target-hf-row">
<label class="target-hf-label" for="target-hf-input">or target HF <span class="tooltip" data-term="HF">?</span></label>
<input type="number" id="target-hf-input" class="input mono target-hf-input" placeholder="e.g. 1.8" min="1" step="0.0001" aria-label="Target Health Factor" />
</div>
<p class="hf-warning hidden" id="target-hf-error"></p>
<div class="slider-zones" id="slider-zones">
<span class="slider-zone zone-conservative" data-zone="conservative">Conservative</span>
<span class="slider-zone zone-moderate" data-zone="moderate">Moderate</span>
Expand Down Expand Up @@ -486,11 +499,11 @@ <h2>Position</h2>
<!-- Position data as table rows -->
<div class="position-grid">
<div class="position-metric">
<span class="metric-label">Collateral</span>
<span class="metric-label">Collateral <span class="tooltip" data-term="b_token">?</span></span>
<span class="metric-value mono" id="pos-collateral">—</span>
</div>
<div class="position-metric">
<span class="metric-label">Debt</span>
<span class="metric-label">Debt <span class="tooltip" data-term="d_token">?</span></span>
<span class="metric-value mono" id="pos-debt">—</span>
</div>
<div class="position-metric">
Expand All @@ -502,13 +515,13 @@ <h2>Position</h2>
<span class="metric-value mono" id="pos-leverage">—</span>
</div>
<div class="position-metric">
<span class="metric-label">Asset HF <span class="tooltip" data-tip="Health Factor for this asset only. HF = (collateral x c_factor) / (debt / l_factor). Below 1.0 = liquidatable.">?</span></span>
<span class="metric-label">Asset <span class="tooltip" data-term="HF">HF</span></span>
<span class="metric-value" id="pos-hf">—</span>
<div class="hf-bar-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100"><div class="hf-bar" id="hf-bar"></div></div>
<div class="hf-gauge-container"></div>
</div>
<div class="position-metric">
<span class="metric-label">Pool HF <span class="tooltip" data-tip="Health Factor across ALL your positions in this pool, weighted by oracle price.">?</span></span>
<span class="metric-label">Pool <span class="tooltip" data-term="HF">HF</span></span>
<span class="metric-value" id="pos-pool-hf">—</span>
</div>
<div class="position-metric">
Expand Down
178 changes: 166 additions & 12 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,53 @@ function minHF() { return expertMode ? MIN_HF_EXPERT : MIN_HF_NORMAL; }

let demoMode = false;

// ── Glossary — single source of truth for acronym tooltips (#8) ──────────────

const DOCS_URL = "https://docs.blend.capital/";

const GLOSSARY: Record<string, { text: string; html: string }> = {
HF: {
text: "Health Factor — ratio of weighted collateral to weighted debt. Below 1.0 means your position is liquidatable.",
html: `Health Factor — ratio of weighted collateral to weighted debt. Below 1.0 means your position is liquidatable. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
APY: {
text: "Annual Percentage Yield — estimated annual return assuming continuous compounding. Blend does not auto-compound; treat this as an approximation.",
html: `Annual Percentage Yield — estimated annual return assuming continuous compounding. Blend does not auto-compound; treat this as an approximation. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
c_factor: {
text: "Collateral Factor — fraction of your supplied amount that counts toward borrowing power. A 90% c_factor lets you borrow up to $90 for every $100 supplied.",
html: `Collateral Factor — fraction of your supplied amount that counts toward borrowing power. A 90% c_factor lets you borrow up to $90 for every $100 supplied. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
util_target: {
text: "Utilization Target — the pool's ideal ratio of total borrowed to total supplied. Above this threshold borrow rates rise sharply to attract new suppliers.",
html: `Utilization Target — the pool's ideal ratio of total borrowed to total supplied. Above this threshold borrow rates rise sharply to attract new suppliers. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
r_base: {
text: "Base Rate — the minimum borrow APR charged when pool utilization is near zero.",
html: `Base Rate — the minimum borrow APR charged when pool utilization is near zero. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
r_one: {
text: "Rate at Util Target — the borrow APR at the pool's utilization target; rates scale linearly from r_base to r_one below that threshold.",
html: `Rate at Util Target — the borrow APR at the pool's utilization target; rates scale linearly from r_base to r_one below that threshold. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
r_two: {
text: "Rate at 100% Utilization — borrow APR when the pool is fully utilized; rates scale steeply from r_one to r_two above the util_target.",
html: `Rate at 100% Utilization — borrow APR when the pool is fully utilized; rates scale steeply from r_one to r_two above the util_target. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
r_three: {
text: "Backstop Rate — extreme borrow APR activated when the backstop module is covering losses, incentivizing immediate repayment.",
html: `Backstop Rate — extreme borrow APR activated when the backstop module is covering losses, incentivizing immediate repayment. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
b_token: {
text: "Balance Token (b-token) — a receipt token Blend issues for every asset you supply; its redeemable value grows as supply interest accrues.",
html: `Balance Token (b-token) — a receipt token Blend issues for every asset you supply; its redeemable value grows as supply interest accrues. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
d_token: {
text: "Debt Token (d-token) — a token that tracks your outstanding borrow; its underlying value grows as borrow interest accrues.",
html: `Debt Token (d-token) — a token that tracks your outstanding borrow; its underlying value grows as borrow interest accrues. <a href="${DOCS_URL}" target="_blank" rel="noopener">Learn more →</a>`,
},
};

// ── DOM helpers ───────────────────────────────────────────────────────────────

const $ = (id: string) => document.getElementById(id)!;
Expand Down Expand Up @@ -1165,6 +1212,15 @@ function switchAdjustSubTab(sub: "leverage" | "add-funds") {
updatePreview();
}

// ── Target-HF solver (#5) ─────────────────────────────────────────────────────

/** Compute the leverage that produces a given HF: lev = HF / (HF - c·l). */
function leverageFromHf(hf: number, c: number, l: number): number {
const cl = c * l;
if (hf <= cl) return Infinity;
return hf / (hf - cl);
}

// ── Leverage preview ──────────────────────────────────────────────────────────

function updatePreview() {
Expand Down Expand Up @@ -1197,6 +1253,14 @@ function updatePreview() {
$("prev-hf").textContent = isFinite(hf) ? fmt(hf, expertMode ? 5 : 4) : "\u221E";
$("prev-hf").className = hf > 1.1 ? "hf-ok" : hf > 1.03 ? "hf-warn" : "hf-bad";

// Sync target-HF input when slider drives the change (#5)
const targetHfEl = document.getElementById("target-hf-input") as HTMLInputElement | null;
if (targetHfEl && document.activeElement !== targetHfEl) {
targetHfEl.value = isFinite(hf) ? hf.toFixed(4) : "";
const errEl = document.getElementById("target-hf-error");
if (errEl) errEl.classList.add("hidden");
}

// Borrow headroom: how much more could be borrowed before liquidation
if (rs && rs.priceUsd > 0) {
const effectiveCollateral = supply * rs.cFactor;
Expand Down Expand Up @@ -1955,40 +2019,85 @@ function debounceQuote() {
_quoteTimer = setTimeout(fetchSwapQuote, 500);
}

// ── Tooltip popovers (#1) ────────────────────────────────────────────────────
// ── Tooltip popovers (#1, #8) ─────────────────────────────────────────────────

function initTooltips() {
const popover = $("tooltip-popover");
let _hideTimer: ReturnType<typeof setTimeout> | null = null;

// Apply glossary entries to elements with data-term attribute
document.querySelectorAll<HTMLElement>("[data-term]").forEach(el => {
const term = el.dataset.term!;
const entry = GLOSSARY[term];
if (entry) {
el.dataset.tip = entry.text;
el.dataset.tipHtml = entry.html;
}
});

function showTip(el: HTMLElement) {
popover.textContent = el.dataset.tip || "";
if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; }
const html = el.dataset.tipHtml;
if (html) {
popover.innerHTML = html;
} else {
popover.textContent = el.dataset.tip || "";
}
const rect = el.getBoundingClientRect();
popover.style.left = `${rect.left + rect.width / 2}px`;
popover.style.top = `${rect.bottom + 8}px`;
popover.style.transform = "translateX(-50%)";
popover.classList.add("visible");
}

function hideTip() {
_hideTimer = setTimeout(() => popover.classList.remove("visible"), 150);
}

// Keep popover visible while hovering it (so "Learn more" link is clickable)
popover.addEventListener("mouseenter", () => { if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; } });
popover.addEventListener("mouseleave", hideTip);

document.querySelectorAll<HTMLElement>(".tooltip").forEach(el => {
if (el.hasAttribute("title")) {
el.dataset.tip = el.getAttribute("title") || "";
el.removeAttribute("title");
}
// Keyboard accessibility: make tooltip spans focusable
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
if (!el.hasAttribute("aria-label")) {
const term = el.dataset.term;
el.setAttribute("aria-label", term ? `${term} definition` : "tooltip");
}

el.addEventListener("mouseenter", () => { showTip(el); popover.classList.add("visible"); });
el.addEventListener("mouseleave", () => popover.classList.remove("visible"));
// Mobile: toggle on click
el.addEventListener("mouseenter", () => showTip(el));
el.addEventListener("mouseleave", hideTip);
el.addEventListener("focus", () => showTip(el));
el.addEventListener("blur", hideTip);
// Mobile / keyboard: toggle on click or Enter/Space
el.addEventListener("click", (e) => {
e.stopPropagation();
showTip(el);
popover.classList.toggle("visible");
if (popover.classList.contains("visible")) { popover.classList.remove("visible"); }
else { showTip(el); }
});
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (popover.classList.contains("visible")) { popover.classList.remove("visible"); }
else { showTip(el); }
}
});
});

// Also handle data-tip on non-.tooltip elements (buttons, etc.)
document.querySelectorAll<HTMLElement>("[data-tip]:not(.tooltip)").forEach(el => {
document.querySelectorAll<HTMLElement>("[data-tip]:not(.tooltip),[data-tip-html]:not(.tooltip)").forEach(el => {
el.removeAttribute("title");

el.addEventListener("mouseenter", () => { showTip(el); popover.classList.add("visible"); });
el.addEventListener("mouseleave", () => popover.classList.remove("visible"));
el.addEventListener("mouseenter", () => showTip(el));
el.addEventListener("mouseleave", hideTip);
});
document.addEventListener("click", () => popover.classList.remove("visible"));

document.addEventListener("click", () => { if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; } popover.classList.remove("visible"); });
}

// ── Event listeners ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -2225,6 +2334,51 @@ async function refreshAddFundsBalance() {
($("initial-input") as HTMLInputElement).addEventListener("input", () => { refreshTabData(); updatePreview(); });
($("initial-input") as HTMLInputElement).addEventListener("change", () => { refreshTabData(); updatePreview(); });

// Target-HF input: solve for leverage and drive the slider (#5)
($("target-hf-input") as HTMLInputElement).addEventListener("input", () => {
const targetHfEl = $("target-hf-input") as HTMLInputElement;
const errEl = $("target-hf-error");
const raw = targetHfEl.value;

if (!raw || raw === "") { errEl.classList.add("hidden"); return; }

const hfTarget = parseFloat(raw);
if (isNaN(hfTarget)) { errEl.classList.add("hidden"); return; }

const rs = reserves.find(r => r.asset.id === selectedAsset.id);
const c = rs ? rs.cFactor : selectedAsset.cFactor;
const l = rs?.lFactor ?? 1;
const cl = c * l;

if (hfTarget < 1) {
errEl.textContent = "HF must be at least 1.0 — values below 1.0 are liquidatable.";
errEl.classList.remove("hidden");
return;
}
if (hfTarget <= cl) {
errEl.textContent = `HF must exceed ${fmt(cl, 4)} — the floor for this asset at maximum leverage.`;
errEl.classList.remove("hidden");
return;
}

const lev = leverageFromHf(hfTarget, c, l);
const slider = $("leverage-slider") as HTMLInputElement;
const numIn = $("leverage-input") as HTMLInputElement;
const maxLev = parseFloat(slider.max);

if (lev > maxLev) {
errEl.textContent = `Target HF ${fmt(hfTarget, 4)} requires leverage above the pool maximum (${fmt(maxLev, 1)}×). Enter a lower HF.`;
errEl.classList.remove("hidden");
return;
}

errEl.classList.add("hidden");
const snapped = Math.round(lev * 10) / 10;
slider.value = String(snapped);
numIn.value = snapped.toFixed(1);
updatePreview();
});

// ── Demo mode (#17) ──────────────────────────────────────────────────────────

$("demo-btn").addEventListener("click", () => {
Expand Down
Loading