Supply
- Interest APY
+ Interest APY
—
@@ -329,6 +329,14 @@
Portfolio Overview
Interest cost
—
+
+ Rate model:
+ r_base
+ r_one
+ r_two
+ r_three
+
+
BLND emissions ?
—
@@ -397,6 +405,11 @@
Open Position
×
+
+
+
+
+
Conservative
Moderate
@@ -486,11 +499,11 @@
Position
- Collateral
+ Collateral ?
—
- Debt
+ Debt ?
—
@@ -502,13 +515,13 @@
Position
—
-
Asset HF ?
+
Asset HF
—
- Pool HF ?
+ Pool HF
—
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fcc1ceb..19af897 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -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
= {
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+ 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. Learn more →`,
+ },
+};
+
// ── DOM helpers ───────────────────────────────────────────────────────────────
const $ = (id: string) => document.getElementById(id)!;
@@ -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() {
@@ -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;
@@ -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 | null = null;
+
+ // Apply glossary entries to elements with data-term attribute
+ document.querySelectorAll("[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(".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("[data-tip]:not(.tooltip)").forEach(el => {
+ document.querySelectorAll("[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 ───────────────────────────────────────────────────────────
@@ -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", () => {
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0d4348f..a2dfbb2 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -562,8 +562,10 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px;
border-radius: 50%; background: var(--tooltip-bg); color: var(--text-3); font-size: 9px;
cursor: help; margin-left: 3px; transition: background .2s;
+ user-select: none; outline: none;
}
-.tooltip:hover { background: var(--tooltip-hover); color: var(--text-2); }
+.tooltip:hover, .tooltip:focus { background: var(--tooltip-hover); color: var(--text-2); }
+.tooltip:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.input-wrap { position: relative; }
.input {
width: 100%; padding: 10px 14px; padding-right: 100px;
@@ -607,6 +609,18 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
.leverage-num-input { width: 68px; padding: 5px 6px; font-size: 15px; text-align: right; padding-right: 4px; }
.slider-value-x { font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--text-3); margin-left: -4px; }
+/* Target-HF row (#5) */
+.target-hf-row {
+ display: flex; align-items: center; gap: 8px; margin-top: 6px; margin-bottom: 8px;
+}
+.target-hf-label { font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
+.target-hf-input { width: 110px !important; padding: 5px 8px !important; font-size: 13px !important; padding-right: 8px !important; }
+
+/* Rate model params row (#8) */
+.apr-rate-params { opacity: 0.75; margin-top: 2px; }
+.apr-rate-params .apr-key { font-size: 11px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
+.apr-rate-params .tooltip { font-size: 9px; padding: 0 4px; width: auto; border-radius: 3px; height: 14px; }
+
/* Slider risk zones */
.slider-zones {
display: flex; justify-content: space-between; font-size: 10px; font-weight: 600;
@@ -782,9 +796,11 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
background: var(--surface-solid); border: 1px solid var(--border);
border-radius: var(--r-xs); box-shadow: var(--shadow);
font-size: 12px; line-height: 1.5; color: var(--text-2);
- pointer-events: none; opacity: 0; transition: opacity .15s;
+ pointer-events: auto; opacity: 0; transition: opacity .15s;
}
.tooltip-popover.visible { opacity: 1; }
+.tooltip-popover a { color: var(--primary); text-decoration: underline; }
+.tooltip-popover a:hover { opacity: 0.8; }
/* ── Skeleton loading ────────────────────────────────────────────────────── */