diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
new file mode 100644
index 0000000..f5fad51
--- /dev/null
+++ b/frontend/.storybook/main.ts
@@ -0,0 +1,18 @@
+import type { StorybookConfig } from "@storybook/html-vite";
+
+const config: StorybookConfig = {
+ stories: ["../src/stories/**/*.stories.@(ts|js)"],
+ addons: [
+ "@storybook/addon-essentials",
+ "@storybook/addon-a11y",
+ ],
+ framework: {
+ name: "@storybook/html-vite",
+ options: {},
+ },
+ docs: {
+ autodocs: "tag",
+ },
+};
+
+export default config;
diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts
new file mode 100644
index 0000000..1b7b30c
--- /dev/null
+++ b/frontend/.storybook/preview.ts
@@ -0,0 +1,36 @@
+import type { Preview } from "@storybook/html";
+import "../src/style.css";
+
+const preview: Preview = {
+ parameters: {
+ backgrounds: {
+ default: "dark",
+ values: [
+ { name: "dark", value: "#0d1117" },
+ { name: "light", value: "#f6f8fa" },
+ ],
+ },
+ layout: "centered",
+ },
+ globalTypes: {
+ theme: {
+ description: "UI theme",
+ defaultValue: "dark",
+ toolbar: {
+ title: "Theme",
+ icon: "paintbrush",
+ items: ["dark", "light"],
+ dynamicTitle: true,
+ },
+ },
+ },
+ decorators: [
+ (story, context) => {
+ const theme = context.globals.theme ?? "dark";
+ document.documentElement.setAttribute("data-theme", theme);
+ return story();
+ },
+ ],
+};
+
+export default preview;
diff --git a/frontend/package.json b/frontend/package.json
index 433427d..255aee0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "test": "vitest run"
+ "test": "vitest run",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
},
"dependencies": {
"@creit-tech/stellar-wallets-kit": "npm:@jsr/creit-tech__stellar-wallets-kit@^2.0.1",
@@ -14,6 +16,11 @@
"@stellar/stellar-sdk": "^14.6.1"
},
"devDependencies": {
+ "@storybook/addon-a11y": "^8.4.0",
+ "@storybook/addon-essentials": "^8.4.0",
+ "@storybook/html": "^8.4.0",
+ "@storybook/html-vite": "^8.4.0",
+ "storybook": "^8.4.0",
"typescript": "^5.7.3",
"vite": "^6.2.0",
"vitest": "^3.0.0"
diff --git a/frontend/src/stories/AprCard.stories.ts b/frontend/src/stories/AprCard.stories.ts
new file mode 100644
index 0000000..c67fd5a
--- /dev/null
+++ b/frontend/src/stories/AprCard.stories.ts
@@ -0,0 +1,105 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+interface AprRow {
+ label: string;
+ value: string;
+ color?: string;
+}
+
+interface AprCardArgs {
+ title: "Supply" | "Borrow";
+ baseApr: number;
+ blndEmissions: number;
+ extraRows: AprRow[];
+}
+
+function renderAprCard({ title, baseApr, blndEmissions, extraRows }: AprCardArgs): string {
+ const totalApr = baseApr + blndEmissions;
+ const isSupply = title === "Supply";
+ const primaryColor = isSupply ? "#2ea043" : "#f85149";
+
+ const extraHtml = extraRows
+ .map(
+ (r) => `
+
+ ${r.label}
+ ${r.value}
+
`
+ )
+ .join("");
+
+ return `
+
+
${title}
+
+ Base APR
+ ${baseApr.toFixed(2)}%
+
+
+ BLND emissions
+ +${blndEmissions.toFixed(2)}%
+
+ ${extraHtml}
+
+ Total APY
+ ${totalApr.toFixed(2)}%
+
+
+ `;
+}
+
+const meta: Meta = {
+ title: "Components/APR Card",
+ tags: ["autodocs"],
+ render: (args) => renderAprCard(args),
+ argTypes: {
+ title: { control: { type: "select" }, options: ["Supply", "Borrow"] },
+ baseApr: { control: { type: "number", min: 0, max: 50, step: 0.01 } },
+ blndEmissions: { control: { type: "number", min: 0, max: 20, step: 0.01 } },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const SupplyCard: Story = {
+ args: {
+ title: "Supply",
+ baseApr: 9.84,
+ blndEmissions: 1.23,
+ extraRows: [],
+ },
+};
+
+export const BorrowCard: Story = {
+ args: {
+ title: "Borrow",
+ baseApr: 5.12,
+ blndEmissions: 0.45,
+ extraRows: [],
+ },
+};
+
+export const SupplyWithExtras: Story = {
+ args: {
+ title: "Supply",
+ baseApr: 7.5,
+ blndEmissions: 2.1,
+ extraRows: [
+ { label: "Protocol fee", value: "-0.50%", color: "#f85149" },
+ { label: "Compounding", value: "+0.12%", color: "#2ea043" },
+ ],
+ },
+};
diff --git a/frontend/src/stories/AssetPicker.stories.ts b/frontend/src/stories/AssetPicker.stories.ts
new file mode 100644
index 0000000..886d105
--- /dev/null
+++ b/frontend/src/stories/AssetPicker.stories.ts
@@ -0,0 +1,119 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+interface Asset {
+ symbol: string;
+ name: string;
+ balance?: string;
+}
+
+interface AssetPickerArgs {
+ assets: Asset[];
+ selectedIndex: number;
+ showBalances: boolean;
+}
+
+function renderAssetPicker(args: AssetPickerArgs): HTMLElement {
+ const { assets, selectedIndex, showBalances } = args;
+ const wrap = document.createElement("div");
+ wrap.style.cssText = "width:360px;";
+
+ const bar = document.createElement("div");
+ bar.className = "asset-tabs-bar";
+ bar.style.cssText = `
+ background:var(--color-surface,#161b22);
+ border:1px solid var(--color-border,#21262d);
+ border-radius:8px;
+ padding:4px;
+ display:flex;
+ gap:4px;
+ `;
+
+ assets.forEach((asset, i) => {
+ const btn = document.createElement("button");
+ btn.role = "tab";
+ btn.setAttribute("aria-selected", String(i === selectedIndex));
+ btn.style.cssText = `
+ flex:1;
+ padding:8px 4px;
+ border:none;
+ border-radius:6px;
+ cursor:pointer;
+ font-family:inherit;
+ font-size:13px;
+ background:${i === selectedIndex ? "var(--color-accent,#1f6feb)" : "transparent"};
+ color:${i === selectedIndex ? "#ffffff" : "var(--color-text-muted,#8b949e)"};
+ font-weight:${i === selectedIndex ? "600" : "400"};
+ transition:background 0.15s;
+ `;
+
+ const label = document.createElement("div");
+ label.textContent = asset.symbol;
+
+ if (showBalances && asset.balance) {
+ const bal = document.createElement("div");
+ bal.style.cssText = "font-size:10px;margin-top:2px;opacity:0.8;";
+ bal.textContent = asset.balance;
+ btn.appendChild(label);
+ btn.appendChild(bal);
+ } else {
+ btn.appendChild(label);
+ }
+
+ btn.addEventListener("click", () => {
+ bar.querySelectorAll("button").forEach((b, j) => {
+ const isSelected = j === i;
+ b.setAttribute("aria-selected", String(isSelected));
+ (b as HTMLElement).style.background = isSelected
+ ? "var(--color-accent,#1f6feb)"
+ : "transparent";
+ (b as HTMLElement).style.color = isSelected
+ ? "#ffffff"
+ : "var(--color-text-muted,#8b949e)";
+ (b as HTMLElement).style.fontWeight = isSelected ? "600" : "400";
+ });
+ });
+
+ bar.appendChild(btn);
+ });
+
+ wrap.appendChild(bar);
+ return wrap;
+}
+
+const meta: Meta = {
+ title: "Components/Asset Picker",
+ tags: ["autodocs"],
+ render: (args) => renderAssetPicker(args),
+ argTypes: {
+ selectedIndex: { control: { type: "number", min: 0 } },
+ showBalances: { control: "boolean" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Etherfuse: Story = {
+ args: {
+ assets: [
+ { symbol: "USDC", name: "USD Coin", balance: "1,240.00" },
+ { symbol: "XLM", name: "Lumen", balance: "5,000.00" },
+ { symbol: "CETES",name: "CETES Token", balance: "320.50" },
+ ],
+ selectedIndex: 2,
+ showBalances: true,
+ },
+};
+
+export const MultiAsset: Story = {
+ args: {
+ assets: [
+ { symbol: "USDC", name: "USD Coin" },
+ { symbol: "wETH", name: "Wrapped Ether" },
+ { symbol: "wBTC", name: "Wrapped Bitcoin" },
+ { symbol: "XLM", name: "Lumen" },
+ ],
+ selectedIndex: 0,
+ showBalances: false,
+ },
+};
diff --git a/frontend/src/stories/HfBadge.stories.ts b/frontend/src/stories/HfBadge.stories.ts
new file mode 100644
index 0000000..25f6fb0
--- /dev/null
+++ b/frontend/src/stories/HfBadge.stories.ts
@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+interface HfBadgeArgs {
+ value: number;
+ label?: string;
+}
+
+function renderHfBadge({ value, label = "Health Factor" }: HfBadgeArgs): string {
+ const color =
+ value >= 1.5 ? "var(--color-success, #2ea043)" :
+ value >= 1.2 ? "var(--color-warn, #d29922)" :
+ "var(--color-danger, #f85149)";
+
+ const displayVal = isFinite(value) ? value.toFixed(3) : "∞";
+
+ return `
+
+
+ ${label}
+
+
+ ${displayVal}
+
+
+ `;
+}
+
+const meta: Meta = {
+ title: "Components/HF Badge",
+ tags: ["autodocs"],
+ render: (args) => renderHfBadge(args),
+ argTypes: {
+ value: { control: { type: "number", min: 1, max: 5, step: 0.05 } },
+ label: { control: "text" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Safe: Story = {
+ args: { value: 1.85, label: "Health Factor" },
+};
+
+export const Warning: Story = {
+ args: { value: 1.25, label: "Health Factor" },
+};
+
+export const Critical: Story = {
+ args: { value: 1.05, label: "Health Factor" },
+};
+
+export const Infinite: Story = {
+ args: { value: Infinity, label: "Health Factor" },
+};
diff --git a/frontend/src/stories/LeverageSlider.stories.ts b/frontend/src/stories/LeverageSlider.stories.ts
new file mode 100644
index 0000000..badd57f
--- /dev/null
+++ b/frontend/src/stories/LeverageSlider.stories.ts
@@ -0,0 +1,104 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+interface LeverageSliderArgs {
+ value: number;
+ min: number;
+ max: number;
+ showZones: boolean;
+ expertMode: boolean;
+}
+
+function renderLeverageSlider({
+ value,
+ min,
+ max,
+ showZones,
+ expertMode,
+}: LeverageSliderArgs): HTMLElement {
+ const wrap = document.createElement("div");
+ wrap.style.cssText = "width:360px;padding:16px;background:var(--color-surface,#161b22);border-radius:8px;";
+
+ const zones = [
+ { key: "conservative", label: "Conservative", pct: 0 },
+ { key: "moderate", label: "Moderate", pct: 30 },
+ { key: "aggressive", label: "Aggressive", pct: 55 },
+ { key: "degen", label: "Degen", pct: 75 },
+ ...(expertMode ? [{ key: "maxi-degen", label: "Maxi Degen", pct: 90 }] : []),
+ ];
+
+ const pct = ((value - min) / (max - min)) * 100;
+ const hfEst = Math.max(1.01, 3.5 - (pct / 100) * 2.2);
+
+ wrap.innerHTML = `
+
+
+ ${showZones ? `
+
+ ${zones.map(z => `
+
+ ${z.label}
+
+ `).join("")}
+
+ ` : ""}
+
+
+ Health Factor
+ ${hfEst.toFixed(3)}
+
+
+ Leverage
+ ${value.toFixed(1)}×
+
+
+ `;
+
+ const slider = wrap.querySelector("input")!;
+ slider.addEventListener("input", () => {
+ const label = wrap.querySelector("label span:last-child")!;
+ label.textContent = `${parseFloat(slider.value).toFixed(1)}×`;
+ });
+
+ return wrap;
+}
+
+const meta: Meta = {
+ title: "Components/Leverage Slider",
+ tags: ["autodocs"],
+ render: (args) => renderLeverageSlider(args),
+ argTypes: {
+ value: { control: { type: "range", min: 1.1, max: 12.9, step: 0.1 } },
+ min: { control: { type: "number" } },
+ max: { control: { type: "number" } },
+ showZones: { control: "boolean" },
+ expertMode: { control: "boolean" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { value: 2.0, min: 1.1, max: 12.9, showZones: true, expertMode: false },
+};
+
+export const HighLeverage: Story = {
+ args: { value: 8.5, min: 1.1, max: 12.9, showZones: true, expertMode: false },
+};
+
+export const ExpertMode: Story = {
+ args: { value: 10.0, min: 1.1, max: 12.9, showZones: true, expertMode: true },
+};
+
+export const NoZones: Story = {
+ args: { value: 3.0, min: 1.1, max: 12.9, showZones: false, expertMode: false },
+};
diff --git a/frontend/src/stories/PoolCard.stories.ts b/frontend/src/stories/PoolCard.stories.ts
new file mode 100644
index 0000000..be27be4
--- /dev/null
+++ b/frontend/src/stories/PoolCard.stories.ts
@@ -0,0 +1,143 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+interface PoolCardArgs {
+ poolName: string;
+ assetSymbol: string;
+ supplyApr: number;
+ borrowApr: number;
+ maxLeverage: number;
+ available: string;
+ frozen: boolean;
+}
+
+function renderPoolCard(args: PoolCardArgs): string {
+ const {
+ poolName,
+ assetSymbol,
+ supplyApr,
+ borrowApr,
+ maxLeverage,
+ available,
+ frozen,
+ } = args;
+
+ const netApy = (supplyApr * maxLeverage - borrowApr * (maxLeverage - 1)).toFixed(2);
+
+ return `
+
+ ${frozen ? `
+
+ ⚠ This pool is Admin Frozen. No new positions can be opened.
+
+ ` : ""}
+
+
+ ${poolName}
+ ${assetSymbol}
+
+
+
+
Supply APR
+
${supplyApr.toFixed(2)}%
+
+
+
Borrow APR
+
${borrowApr.toFixed(2)}%
+
+
+
Max leverage
+
${maxLeverage.toFixed(1)}×
+
+
+
Available
+
${available}
+
+
+
+ Est. net APY at ${maxLeverage.toFixed(1)}×
+ ${netApy}%
+
+
+
+ `;
+}
+
+const meta: Meta = {
+ title: "Components/Pool Card",
+ tags: ["autodocs"],
+ render: (args) => renderPoolCard(args),
+ argTypes: {
+ poolName: { control: "text" },
+ assetSymbol: { control: "text" },
+ supplyApr: { control: { type: "number", min: 0, max: 50, step: 0.1 } },
+ borrowApr: { control: { type: "number", min: 0, max: 50, step: 0.1 } },
+ maxLeverage: { control: { type: "number", min: 1, max: 13, step: 0.1 } },
+ available: { control: "text" },
+ frozen: { control: "boolean" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Etherfuse: Story = {
+ args: {
+ poolName: "Etherfuse",
+ assetSymbol: "CETES",
+ supplyApr: 9.84,
+ borrowApr: 5.12,
+ maxLeverage: 6.5,
+ available: "482,310 USDC",
+ frozen: false,
+ },
+};
+
+export const Fixed: Story = {
+ args: {
+ poolName: "Fixed",
+ assetSymbol: "USDC",
+ supplyApr: 6.21,
+ borrowApr: 4.88,
+ maxLeverage: 8.2,
+ available: "1,200,000 USDC",
+ frozen: false,
+ },
+};
+
+export const FrozenPool: Story = {
+ args: {
+ poolName: "YieldBlox",
+ assetSymbol: "wETH",
+ supplyApr: 4.5,
+ borrowApr: 3.1,
+ maxLeverage: 5.0,
+ available: "0",
+ frozen: true,
+ },
+};
diff --git a/frontend/src/stories/StatCard.stories.ts b/frontend/src/stories/StatCard.stories.ts
new file mode 100644
index 0000000..4dc60d8
--- /dev/null
+++ b/frontend/src/stories/StatCard.stories.ts
@@ -0,0 +1,99 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+interface StatCardArgs {
+ label: string;
+ value: string;
+ sublabel?: string;
+ trend?: "up" | "down" | "neutral";
+ skeleton: boolean;
+}
+
+function renderStatCard({ label, value, sublabel, trend, skeleton }: StatCardArgs): string {
+ const trendColor =
+ trend === "up" ? "#2ea043" :
+ trend === "down" ? "#f85149" :
+ "var(--color-text-muted,#8b949e)";
+ const trendIcon =
+ trend === "up" ? "↑" :
+ trend === "down" ? "↓" :
+ "";
+
+ const valueHtml = skeleton
+ ? ` `
+ : `${value}`;
+
+ return `
+
+
+ ${label}
+
+
+ ${valueHtml}
+ ${trend && !skeleton ? `${trendIcon}` : ""}
+
+ ${sublabel && !skeleton ? `
+
${sublabel}
+ ` : ""}
+
+ `;
+}
+
+const meta: Meta = {
+ title: "Components/Stat Card",
+ tags: ["autodocs"],
+ render: (args) => renderStatCard(args),
+ argTypes: {
+ label: { control: "text" },
+ value: { control: "text" },
+ sublabel: { control: "text" },
+ trend: { control: { type: "select" }, options: ["up", "down", "neutral", undefined] },
+ skeleton: { control: "boolean" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const HealthFactor: Story = {
+ args: {
+ label: "Health Factor",
+ value: "1.847",
+ sublabel: "Safe",
+ trend: "up",
+ skeleton: false,
+ },
+};
+
+export const TotalSupply: Story = {
+ args: {
+ label: "Total Supply",
+ value: "$12,400.00",
+ sublabel: "1,240 CETES",
+ trend: "neutral",
+ skeleton: false,
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ label: "Max Leverage",
+ value: "—",
+ skeleton: true,
+ },
+};
+
+export const NegativeTrend: Story = {
+ args: {
+ label: "Net APY",
+ value: "-1.23%",
+ sublabel: "Reduce leverage",
+ trend: "down",
+ skeleton: false,
+ },
+};
diff --git a/frontend/src/stories/Toast.stories.ts b/frontend/src/stories/Toast.stories.ts
new file mode 100644
index 0000000..28fdb35
--- /dev/null
+++ b/frontend/src/stories/Toast.stories.ts
@@ -0,0 +1,102 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+type ToastType = "info" | "success" | "error";
+
+interface ToastArgs {
+ message: string;
+ type: ToastType;
+ txHash?: string;
+}
+
+function renderToast({ message, type, txHash }: ToastArgs): string {
+ const icon = type === "success" ? "✓" : type === "error" ? "✗" : "⟳";
+ const colors: Record = {
+ success: "#2ea043",
+ error: "#f85149",
+ info: "#388bfd",
+ };
+ const bg: Record = {
+ success: "rgba(46,160,67,0.12)",
+ error: "rgba(248,81,73,0.12)",
+ info: "rgba(56,139,253,0.12)",
+ };
+
+ const linkHtml = txHash
+ ? `View →`
+ : "";
+
+ return `
+
+ ${icon}
+ ${message}
+ ${linkHtml}
+
+ `;
+}
+
+function renderToastStack(count: number): string {
+ const toasts = [
+ { message: "Position opened successfully!", type: "success" as ToastType, txHash: "abc123" },
+ { message: "Fetching latest pool data…", type: "info" as ToastType },
+ { message: "Transaction failed: fee too low", type: "error" as ToastType },
+ ].slice(0, count);
+
+ return `
+
+ ${toasts.map(renderToast).join("")}
+
+ `;
+}
+
+const meta: Meta = {
+ title: "Components/Toast",
+ tags: ["autodocs"],
+ render: (args) => renderToast(args),
+ argTypes: {
+ type: { control: { type: "select" }, options: ["info", "success", "error"] },
+ message: { control: "text" },
+ txHash: { control: "text" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Success: Story = {
+ args: {
+ type: "success",
+ message: "Position opened successfully!",
+ txHash: "a1b2c3d4e5f6",
+ },
+};
+
+export const Info: Story = {
+ args: {
+ type: "info",
+ message: "Switched to Testnet. Please also switch your wallet.",
+ },
+};
+
+export const Error: Story = {
+ args: {
+ type: "error",
+ message: "Transaction failed: insufficient balance.",
+ },
+};
+
+export const Stack: StoryObj = {
+ render: () => renderToastStack(3),
+};