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 ` + + `; +} + +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), +};