diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index bb70dd73..f2dd4d26 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,6 +11,7 @@
"@fontsource-variable/fraunces": "5.3.0",
"@fontsource/instrument-sans": "5.3.0",
"@fontsource/spline-sans-mono": "5.3.0",
+ "@phosphor-icons/react": "2.1.10",
"react": "19.2.0",
"react-dom": "19.2.0"
},
@@ -1067,6 +1068,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@phosphor-icons/react": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
+ "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8",
+ "react-dom": ">= 16.8"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 8365892f..dfd8b9d3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"@fontsource-variable/fraunces": "5.3.0",
"@fontsource/instrument-sans": "5.3.0",
"@fontsource/spline-sans-mono": "5.3.0",
+ "@phosphor-icons/react": "2.1.10",
"react": "19.2.0",
"react-dom": "19.2.0"
},
diff --git a/frontend/public/assets/USDtz.svg b/frontend/public/assets/USDtz.svg
new file mode 100644
index 00000000..359ecc7c
--- /dev/null
+++ b/frontend/public/assets/USDtz.svg
@@ -0,0 +1,22 @@
+
+
+ Artboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/assets/XTZ.svg b/frontend/public/assets/XTZ.svg
new file mode 100644
index 00000000..3af971a9
--- /dev/null
+++ b/frontend/public/assets/XTZ.svg
@@ -0,0 +1,10 @@
+
+
+ XTZ.e6d31ff6a59ad3bc98d68fe7c064a5eb
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/fXTZ.svg b/frontend/public/assets/fXTZ.svg
new file mode 100644
index 00000000..d306e580
--- /dev/null
+++ b/frontend/public/assets/fXTZ.svg
@@ -0,0 +1,27 @@
+
+
+ ꜰXTZ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/fstXTZ.svg b/frontend/public/assets/fstXTZ.svg
new file mode 100644
index 00000000..f5c67b0c
--- /dev/null
+++ b/frontend/public/assets/fstXTZ.svg
@@ -0,0 +1,39 @@
+
+
+ stXTZ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/ftzBTC.svg b/frontend/public/assets/ftzBTC.svg
new file mode 100644
index 00000000..9d37e781
--- /dev/null
+++ b/frontend/public/assets/ftzBTC.svg
@@ -0,0 +1,27 @@
+
+
+ ꜰtzBTC
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/fusdt.svg b/frontend/public/assets/fusdt.svg
new file mode 100644
index 00000000..fda925ba
--- /dev/null
+++ b/frontend/public/assets/fusdt.svg
@@ -0,0 +1,27 @@
+
+
+ ꜰUSDt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/fusdtz.svg b/frontend/public/assets/fusdtz.svg
new file mode 100644
index 00000000..60df3ceb
--- /dev/null
+++ b/frontend/public/assets/fusdtz.svg
@@ -0,0 +1,29 @@
+
+
+ ꜰUSDtz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/guilloche-source.svg b/frontend/public/assets/guilloche-source.svg
new file mode 100644
index 00000000..f1e747d0
--- /dev/null
+++ b/frontend/public/assets/guilloche-source.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/stacy_logo.png b/frontend/public/assets/stacy_logo.png
new file mode 100644
index 00000000..114e6602
Binary files /dev/null and b/frontend/public/assets/stacy_logo.png differ
diff --git a/frontend/public/assets/tezHeader.svg b/frontend/public/assets/tezHeader.svg
new file mode 100644
index 00000000..e860edf9
--- /dev/null
+++ b/frontend/public/assets/tezHeader.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/frontend/public/assets/tezfin-logo.svg b/frontend/public/assets/tezfin-logo.svg
new file mode 100644
index 00000000..ca2a242a
--- /dev/null
+++ b/frontend/public/assets/tezfin-logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/assets/tzBTC.svg b/frontend/public/assets/tzBTC.svg
new file mode 100644
index 00000000..484f240e
--- /dev/null
+++ b/frontend/public/assets/tzBTC.svg
@@ -0,0 +1,20 @@
+
+
+ Artboard Copy 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/assets/usdt.svg b/frontend/public/assets/usdt.svg
new file mode 100644
index 00000000..908769ba
--- /dev/null
+++ b/frontend/public/assets/usdt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx
index 41893d34..be248bcf 100644
--- a/frontend/src/app/App.test.tsx
+++ b/frontend/src/app/App.test.tsx
@@ -1,15 +1,135 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
describe("App", () => {
- it("renders the TezFin application frame", () => {
+ beforeEach(() => {
+ window.history.replaceState({}, "", "/?state=near-liquidation");
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders the complete approved dashboard state", () => {
render( );
- expect(screen.getByLabelText("TezFin")).toBeInTheDocument();
expect(
- screen.getByRole("navigation", { name: "Primary navigation" }),
+ screen.getByRole("link", { name: "TezFin dashboard" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Skip to content" })).toHaveAttribute(
+ "href",
+ "#dashboard",
+ );
+ expect(
+ screen.getByRole("alert"),
+ ).toHaveTextContent("Your position is close to liquidation");
+ expect(
+ screen.getByRole("region", { name: "Supplying" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("region", { name: "Borrowing" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("region", { name: "Assets to Supply" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("region", { name: "Assets to Borrow" }),
).toBeInTheDocument();
- expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
+ expect(screen.getByRole("main")).toHaveAttribute("id", "dashboard");
+ });
+
+ it("switches between the approved account fixtures", () => {
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Disconnected" }));
+
+ expect(
+ screen.getByRole("heading", { name: "Your TezFin passbook" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("button", { name: "Connect wallet" }),
+ ).toHaveLength(2);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(window.location.search).toContain("state=disconnected");
+ });
+
+ it("requires an explicit debt choice before opening a repayment review", () => {
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Choose debt to repay" }));
+
+ const chooser = screen.getByRole("dialog", { name: "Choose debt to repay" });
+ expect(chooser).toHaveTextContent(
+ "TezFin will not select or batch debts automatically",
+ );
+
+ const usdTzChoice = within(chooser).getByRole("button", {
+ name: /USDtz.*2,000 USDtz/s,
+ });
+ fireEvent.click(usdTzChoice);
+
+ expect(
+ screen.getByRole("dialog", { name: "Repay USDtz" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Offline fixture — wallet and RPC access are disabled"),
+ ).toBeInTheDocument();
+ });
+
+ it("rejects a malformed decimal amount instead of accepting its valid prefix", () => {
+ render( );
+
+ const supplyBoard = screen.getByRole("region", {
+ name: "Assets to Supply",
+ });
+ fireEvent.click(
+ within(supplyBoard).getAllByRole("button", { name: "Supply" })[0],
+ );
+
+ const dialog = screen.getByRole("dialog", { name: "Supply XTZ" });
+ const input = within(dialog).getByRole("textbox", {
+ name: "Amount in XTZ",
+ });
+
+ fireEvent.change(input, { target: { value: "1.2.3" } });
+
+ expect(input).toHaveValue("1.2.3");
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ expect(within(dialog).getByRole("alert")).toHaveTextContent(
+ "Enter a valid decimal amount",
+ );
+ expect(
+ within(dialog).getByRole("button", { name: "Check amount" }),
+ ).toBeDisabled();
+
+ fireEvent.change(input, { target: { value: "1.23" } });
+
+ expect(input).toHaveAttribute("aria-invalid", "false");
+ expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
+ expect(
+ within(dialog).getByRole("button", { name: "Review supply" }),
+ ).toBeEnabled();
+ });
+
+ it("keeps wallet connection local to the offline fixture", () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ window.history.replaceState({}, "", "/?state=disconnected");
+
+ render( );
+ fireEvent.click(screen.getAllByRole("button", { name: "Connect wallet" })[0]);
+
+ expect(
+ screen.getByRole("status"),
+ ).toHaveTextContent("Wallet access is disabled in this offline design preview.");
+ expect(fetchMock).not.toHaveBeenCalled();
});
});
diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx
index 117e35b8..9a49415f 100644
--- a/frontend/src/app/App.tsx
+++ b/frontend/src/app/App.tsx
@@ -1,18 +1,873 @@
+import {
+ ArrowSquareOut,
+ CaretDown,
+ CaretRight,
+ CheckCircle,
+ Copy,
+ List,
+ ShieldCheck,
+ WarningCircle,
+ Wallet,
+ X,
+} from "@phosphor-icons/react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { calculateTotals, scenarios } from "./fixtures";
+import type {
+ AssetId,
+ MarketFixture,
+ OperationStep,
+ ScenarioId,
+ TransactionAction,
+ TransactionDraft,
+} from "./types";
+
+const scenarioOrder: ScenarioId[] = [
+ "disconnected",
+ "fresh",
+ "supplying",
+ "active",
+ "near-liquidation",
+];
+
+const currency = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
+const compactCurrency = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ notation: "compact",
+ maximumFractionDigits: 2,
+});
+
+const decimalInputPattern = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
+
+function formatAmount(value: number): string {
+ return new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: value < 1 ? 6 : value < 100 ? 4 : 2,
+ }).format(value);
+}
+
+function parseFixtureAmount(value: string): number | null {
+ if (!decimalInputPattern.test(value)) return null;
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function riskBand(percent: number): "normal" | "attention" | "high" | "critical" {
+ if (percent >= 95) return "critical";
+ if (percent >= 85) return "high";
+ if (percent >= 70) return "attention";
+ return "normal";
+}
+
+function initialScenario(): ScenarioId {
+ const value = new URLSearchParams(window.location.search).get("state") as ScenarioId | null;
+ return value && scenarioOrder.includes(value) ? value : "near-liquidation";
+}
+
+function operationSteps(action: TransactionAction, market: MarketFixture): OperationStep[] {
+ const fToken = `f${market.name}`;
+ const accrue: OperationStep = {
+ label: "Accrue interest",
+ detail: "Refresh all 5 configured TezFin markets",
+ };
+ const liquidity: OperationStep = {
+ label: "Refresh account liquidity",
+ detail: "Comptroller · updateAccountLiquidityWithView",
+ };
+
+ if (action === "Supply" || action === "Repay") {
+ const steps: OperationStep[] = [accrue];
+
+ if (market.standard === "FA1.2") {
+ steps.push(
+ { label: "Reset allowance", detail: `${market.name} · approve 0` },
+ { label: "Grant exact allowance", detail: `${market.name} → ${fToken}` },
+ );
+ }
+
+ if (market.standard === "FA1.2 packed") {
+ steps.push({
+ label: "Permission path blocked",
+ detail: "Packed FA1.2 approval requires contract verification",
+ });
+ }
+
+ if (market.standard === "FA2") {
+ steps.push({
+ label: "Add temporary operator",
+ detail: `${market.name} → ${fToken}`,
+ });
+ }
+
+ steps.push({
+ label: action === "Supply" ? "Supply asset" : "Repay debt",
+ detail: `${fToken} · ${action === "Supply" ? "mint" : "repayBorrow"}`,
+ });
+
+ if (market.standard === "FA2") {
+ steps.push({
+ label: "Remove temporary operator",
+ detail: `${market.name} → ${fToken}`,
+ });
+ }
+
+ return steps;
+ }
+
+ if (action === "Withdraw") {
+ return [
+ liquidity,
+ { label: "Withdraw supplied asset", detail: `${fToken} · redeem` },
+ ];
+ }
+
+ if (action === "Borrow") {
+ return [
+ liquidity,
+ { label: "Borrow asset", detail: `${fToken} · borrow` },
+ ];
+ }
+
+ return [
+ liquidity,
+ {
+ label: action,
+ detail: `Comptroller · ${action === "Enable collateral" ? "enterMarkets" : "exitMarket"}`,
+ },
+ ];
+}
+
+function actionSide(action: TransactionAction): "supply" | "borrow" | "collateral" {
+ if (action === "Supply" || action === "Withdraw") return "supply";
+ if (action === "Borrow" || action === "Repay") return "borrow";
+ return "collateral";
+}
+
+function AssetIdentity({ market, fToken = false }: { market: MarketFixture; fToken?: boolean }) {
+ return (
+
+
+
+ {market.name}
+ {fToken ? `f${market.name} · ${currency.format(market.price)}` : `${market.subtitle} · ${currency.format(market.price)}`}
+
+
+ );
+}
+
+function RiskMeter({ percent, compact = false }: { percent: number; compact?: boolean }) {
+ const shown = Math.min(Math.max(percent, 0), 100);
+ const band = riskBand(percent);
+
+ return (
+
+ );
+}
+
+function PositionPassbook({
+ scenario,
+ markets,
+ onRepay,
+ onAddCollateral,
+}: {
+ scenario: ScenarioId;
+ markets: MarketFixture[];
+ onRepay: () => void;
+ onAddCollateral: () => void;
+}) {
+ const fixture = scenarios[scenario];
+ const totals = calculateTotals(markets);
+ const band = riskBand(totals.limitUsedPercent);
+ const suppliedMarkets = markets.filter((market) => market.supplied > 0).length;
+ const borrowedMarkets = markets.filter((market) => market.borrowed > 0).length;
+
+ if (!fixture.connected) {
+ return (
+
+
+
+
Your position
+
Your TezFin passbook
+
Connect a Tezos wallet to see supplied assets, open borrows, and liquidation headroom in one place.
+
window.dispatchEvent(new CustomEvent("tezfin-preview-connect"))}>
+ Connect wallet
+
+
Offline preview — wallet access is disabled
+
+
+ );
+ }
+
+ return (
+ <>
+ {band === "critical" && (
+
+
+
+ Your position is close to liquidation
+ Only {currency.format(totals.headroomUsd)} of borrowing headroom remains in this preview.
+
+
+ {borrowedMarkets > 1 ? "Choose debt to repay" : "Repay"}
+ Add collateral
+
+
+ )}
+
+
+
+
+
+
Supplied
+
{currency.format(totals.suppliedUsd)}
+
across {suppliedMarkets} market{suppliedMarkets === 1 ? "" : "s"}
+
+
+
Estimated net rate
+
{totals.estimatedNetRate >= 0 ? "+" : "−"}{Math.abs(totals.estimatedNetRate).toFixed(2)}%
+
variable · fixture calculation
+
+
+
Borrowed
+
{currency.format(totals.borrowedUsd)}
+
{borrowedMarkets === 0 ? "no open lines" : `${borrowedMarkets} open line${borrowedMarkets === 1 ? "" : "s"}`}
+
+
+
+
+
+ Borrow limit used {totals.limitUsedPercent.toFixed(1)}%
+ {totals.borrowLimitUsd > 0 ? `${currency.format(totals.headroomUsd)} headroom` : "Supply collateral to unlock borrowing"}
+
+
+
+ Collateral {currency.format(totals.collateralUsd)}
+ Borrow limit {currency.format(totals.borrowLimitUsd)}
+
+
+
+ >
+ );
+}
+
+type BoardKind = "supplying" | "borrowing" | "supply-market" | "borrow-market";
+
+function MarketBoard({
+ kind,
+ markets,
+ connected,
+ onOpen,
+ onCollateral,
+}: {
+ kind: BoardKind;
+ markets: MarketFixture[];
+ connected: boolean;
+ onOpen: (market: MarketFixture, action: TransactionAction) => void;
+ onCollateral: (market: MarketFixture) => void;
+}) {
+ const isSupply = kind === "supplying" || kind === "supply-market";
+ const isPosition = kind === "supplying" || kind === "borrowing";
+ const title = {
+ supplying: "Supplying",
+ borrowing: "Borrowing",
+ "supply-market": "Assets to Supply",
+ "borrow-market": "Assets to Borrow",
+ }[kind];
+ const action: TransactionAction = {
+ supplying: "Withdraw",
+ borrowing: "Repay",
+ "supply-market": "Supply",
+ "borrow-market": "Borrow",
+ }[kind] as TransactionAction;
+ const boardMarkets = kind === "supplying"
+ ? markets.filter((market) => market.supplied > 0)
+ : kind === "borrowing"
+ ? markets.filter((market) => market.borrowed > 0)
+ : markets;
+ const total = boardMarkets.reduce((sum, market) => {
+ const amount = kind === "borrowing" ? market.borrowed : kind === "supplying" ? market.supplied : 0;
+ return sum + amount * market.price;
+ }, 0);
+
+ return (
+
+
+ {title}
+ {isPosition && {boardMarkets.length} asset{boardMarkets.length === 1 ? "" : "s"} {currency.format(total)} }
+
+
+ {boardMarkets.length === 0 ? (
+
+ {kind === "supplying"
+ ? "Nothing supplied yet — choose an asset below to preview a deposit."
+ : "No open lines — borrowing stays empty until collateral is supplied."}
+
+ ) : (
+
+
+ Asset
+ {isPosition ? "APY" : kind === "supply-market" ? "Wallet" : "Available"}
+ {isPosition ? "Balance" : "APY"}
+ {kind === "supplying" && Collateral }
+
+
+ {boardMarkets.map((market) => {
+ const balance = kind === "supplying" ? market.supplied : market.borrowed;
+ const disabled = !connected && !isPosition;
+
+ return (
+
+
+ {isPosition ? (
+
+ {(isSupply ? market.supplyApy : market.borrowApy).toFixed(2)}%
+
+ ) : (
+
+ {kind === "supply-market" ? formatAmount(market.walletBalance) : compactCurrency.format(market.liquidityUsd)}
+ {kind === "supply-market" && {currency.format(market.walletBalance * market.price)} }
+
+ )}
+ {isPosition ? (
+
+ {formatAmount(balance)} {market.name}
+ {currency.format(balance * market.price)}
+
+ ) : (
+
+ {(isSupply ? market.supplyApy : market.borrowApy).toFixed(2)}%
+
+ )}
+ {kind === "supplying" && (
+
+ Collateral
+ onCollateral(market)}
+ aria-label={`${market.name} collateral ${market.collateral ? "enabled" : "disabled"}`}
+ />
+
+
+ )}
+
onOpen(market, action)}
+ >
+ {action}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function DebtChooserDialog({
+ markets,
+ onClose,
+ onSelect,
+}: {
+ markets: MarketFixture[];
+ onClose: () => void;
+ onSelect: (market: MarketFixture) => void;
+}) {
+ const closeButtonRef = useRef(null);
+ const dialogRef = useRef(null);
+ const totals = calculateTotals(markets);
+ const borrowedMarkets = markets
+ .filter((market) => market.borrowed > 0)
+ .sort((left, right) => (right.borrowed * right.price) - (left.borrowed * left.price));
+
+ useEffect(() => {
+ closeButtonRef.current?.focus();
+ const handleKeyboard = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ if (event.key === "Tab" && dialogRef.current) {
+ const focusable = Array.from(
+ dialogRef.current.querySelectorAll(
+ 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
+ ),
+ );
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (!first || !last) return;
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+ };
+ document.addEventListener("keydown", handleKeyboard);
+ return () => document.removeEventListener("keydown", handleKeyboard);
+ }, [onClose]);
+
+ return (
+ event.target === event.currentTarget && onClose()}>
+
+
+
+
+ Choose debt to repay
+ {borrowedMarkets.length} borrowed assets · {currency.format(totals.borrowedUsd)} total
+
+
+
+
+
+
+ Select one debt for a token-specific operation review
+ Liquidation risk is account-wide. Choose the asset you actually hold and want to repay; TezFin will not select or batch debts automatically.
+
+
+ {borrowedMarkets.map((market) => {
+ const borrowedUsd = market.borrowed * market.price;
+ const maxRepay = Math.min(market.walletBalance, market.borrowed);
+ const maxRepayUsd = maxRepay * market.price;
+ const headroomAfterMax = totals.headroomUsd + maxRepayUsd;
+
+ return (
+
onSelect(market)}>
+
+
+
{market.borrowApy.toFixed(2)}% variable borrow APY
+
+
+
+ Debt
+ {formatAmount(market.borrowed)} {market.name}
+ {currency.format(borrowedUsd)}
+
+
+ Wallet available
+ {formatAmount(market.walletBalance)} {market.name}
+ {currency.format(headroomAfterMax)} headroom after max
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+function TransactionDialog({
+ draft,
+ markets,
+ onClose,
+ onFixtureConfirm,
+}: {
+ draft: TransactionDraft;
+ markets: MarketFixture[];
+ onClose: () => void;
+ onFixtureConfirm: (draft: TransactionDraft, amount: number) => void;
+}) {
+ const market = markets.find((candidate) => candidate.id === draft.marketId);
+ const [amount, setAmount] = useState("");
+ const [activeAction, setActiveAction] = useState(draft.action);
+ const [reviewing, setReviewing] = useState(false);
+ const [confirmed, setConfirmed] = useState(false);
+ const closeButtonRef = useRef(null);
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ closeButtonRef.current?.focus();
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && !confirmed) onClose();
+ if (event.key === "Tab" && dialogRef.current) {
+ const focusable = Array.from(
+ dialogRef.current.querySelectorAll(
+ 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
+ ),
+ );
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (!first || !last) return;
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+ };
+ document.addEventListener("keydown", closeOnEscape);
+ return () => document.removeEventListener("keydown", closeOnEscape);
+ }, [confirmed, onClose]);
+
+ if (!market) return null;
+
+ const totals = calculateTotals(markets);
+ const parsedAmountResult = parseFixtureAmount(amount);
+ const parsedAmount = parsedAmountResult ?? 0;
+ const invalidAmountSyntax = amount !== "" && parsedAmountResult === null;
+ const valueUsd = parsedAmount * market.price;
+ const side = actionSide(activeAction);
+ const requiresAmount = side !== "collateral";
+ const maxAmount = activeAction === "Supply"
+ ? market.walletBalance
+ : activeAction === "Withdraw"
+ ? market.supplied
+ : activeAction === "Repay"
+ ? Math.min(market.walletBalance, market.borrowed)
+ : Math.max((totals.borrowLimitUsd * 0.8 - totals.borrowedUsd) / market.price, 0);
+ let nextBorrowed = totals.borrowedUsd;
+ let nextLimit = totals.borrowLimitUsd;
+
+ if (activeAction === "Borrow") nextBorrowed += valueUsd;
+ if (activeAction === "Repay") nextBorrowed = Math.max(nextBorrowed - valueUsd, 0);
+ if (activeAction === "Supply" && market.collateral) nextLimit += valueUsd * market.collateralFactor;
+ if (activeAction === "Withdraw" && market.collateral) nextLimit = Math.max(nextLimit - valueUsd * market.collateralFactor, 0);
+ if (activeAction === "Enable collateral") nextLimit += market.supplied * market.price * market.collateralFactor;
+ if (activeAction === "Disable collateral") nextLimit = Math.max(nextLimit - market.supplied * market.price * market.collateralFactor, 0);
+
+ const nextUsed = nextLimit > 0 ? (nextBorrowed / nextLimit) * 100 : 0;
+ const steps = operationSteps(activeAction, market);
+ const invalidPackedPermission = market.standard === "FA1.2 packed" && (activeAction === "Supply" || activeAction === "Repay");
+ const invalidAmount = requiresAmount && (invalidAmountSyntax || parsedAmount <= 0 || parsedAmount > maxAmount || nextUsed > 100);
+ const canReview = !invalidAmount && !invalidPackedPermission;
+
+ const context = activeAction === "Supply"
+ ? `Wallet ${formatAmount(market.walletBalance)}`
+ : activeAction === "Withdraw"
+ ? `Supplied ${formatAmount(market.supplied)}`
+ : activeAction === "Borrow"
+ ? `Liquidity ${compactCurrency.format(market.liquidityUsd)}`
+ : activeAction === "Repay"
+ ? `Borrowed ${formatAmount(market.borrowed)}`
+ : `${market.collateral ? "Currently used" : "Not currently used"} as collateral`;
+ const actionTabs: TransactionAction[] = side === "supply" ? ["Supply", "Withdraw"] : ["Borrow", "Repay"];
+
+ return (
+ event.target === event.currentTarget && onClose()}>
+
+
+
+
+ {activeAction} {market.name}
+ {context}
+
+
+
+
+
+
+ {confirmed ? (
+
+
+
Fixture confirmed
+
The local preview state changed. No wallet opened and no operation was created or submitted.
+
Return to dashboard
+
+ ) : (
+ <>
+ {side !== "collateral" && (
+
+ {actionTabs.map((action) => (
+ {
+ setActiveAction(action);
+ setAmount("");
+ setReviewing(false);
+ }}
+ >
+ {action}
+
+ ))}
+
+ )}
+ Offline fixture — wallet and RPC access are disabled
+
+ {requiresAmount && !reviewing && (
+ <>
+
+ Amount in {market.name}
+ setAmount(event.target.value)}
+ />
+ {market.name}
+ setAmount(String(maxAmount))}
+ >
+ {activeAction === "Borrow" ? "Safe max" : "Max"}
+
+
+ ≈ {currency.format(valueUsd)}
+ >
+ )}
+
+
+ {side !== "collateral" && (
+
{side === "supply" ? "Supply APY" : "Borrow APY"} {(side === "supply" ? market.supplyApy : market.borrowApy).toFixed(2)}% variable
+ )}
+
Borrow limit after {currency.format(nextLimit)}
+
Headroom after {currency.format(Math.max(nextLimit - nextBorrowed, 0))}
+
Limit used after = 95 ? "danger-text" : ""}>{nextUsed.toFixed(1)}%
+
+
+
+ {(reviewing || side === "collateral") && (
+
+
+
Operation preview
+ {steps.length} step{steps.length === 1 ? "" : "s"}
+
+
+ {steps.map((step, index) => (
+
+ {index + 1}
+ {step.label} {step.detail}
+
+ ))}
+
+
+ )}
+
+ {invalidPackedPermission && (
+ Packed FA1.2 permission handling is intentionally blocked until its contract path is verified.
+ )}
+ {requiresAmount && invalidAmountSyntax && (
+ Enter a valid decimal amount.
+ )}
+ {!invalidPackedPermission && !invalidAmountSyntax && requiresAmount && parsedAmount > maxAmount && (
+ Amount exceeds the fixture maximum of {formatAmount(maxAmount)} {market.name}.
+ )}
+
+ {!reviewing && side !== "collateral" ? (
+ setReviewing(true)}>
+ {invalidAmountSyntax ? "Check amount" : parsedAmount > 0 ? `Review ${activeAction.toLowerCase()}` : "Enter an amount"}
+
+ ) : (
+ {
+ onFixtureConfirm({ ...draft, action: activeAction }, parsedAmount);
+ setConfirmed(true);
+ }}
+ >
+ Simulate fixture confirmation
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
export function App() {
+ const [scenarioId, setScenarioId] = useState(initialScenario);
+ const [markets, setMarkets] = useState(() => scenarios[initialScenario()].markets.map((market) => ({ ...market })));
+ const [draft, setDraft] = useState(null);
+ const [debtChooserOpen, setDebtChooserOpen] = useState(false);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [walletMenuOpen, setWalletMenuOpen] = useState(false);
+ const [toast, setToast] = useState("");
+ const previewParams = new URLSearchParams(window.location.search);
+ const cleanPreview = previewParams.get("clean") === "1";
+ const forceMobilePreview = previewParams.get("mobile") === "1";
+ const scenario = scenarios[scenarioId];
+
+ useEffect(() => {
+ setMarkets(scenarios[scenarioId].markets.map((market) => ({ ...market })));
+ setDraft(null);
+ setDebtChooserOpen(false);
+ const url = new URL(window.location.href);
+ url.searchParams.set("state", scenarioId);
+ window.history.replaceState({}, "", url);
+ }, [scenarioId]);
+
+ useEffect(() => {
+ const previewConnect = () => setToast("Wallet access is disabled in this offline design preview.");
+ window.addEventListener("tezfin-preview-connect", previewConnect);
+ return () => window.removeEventListener("tezfin-preview-connect", previewConnect);
+ }, []);
+
+ useEffect(() => {
+ if (!toast) return;
+ const timer = window.setTimeout(() => setToast(""), 2800);
+ return () => window.clearTimeout(timer);
+ }, [toast]);
+
+ const borrowedMarkets = useMemo(
+ () => markets.filter((market) => market.borrowed > 0),
+ [markets],
+ );
+ const addCollateralMarket = useMemo(
+ () => markets.find((market) => market.walletBalance > 0 && market.collateral) ?? markets.find((market) => market.walletBalance > 0) ?? markets[0],
+ [markets],
+ );
+
+ const openTransaction = (market: MarketFixture, action: TransactionAction) => {
+ setDebtChooserOpen(false);
+ setDraft({ marketId: market.id, action });
+ };
+
+ const handleGlobalRepay = () => {
+ if (borrowedMarkets.length === 1) {
+ openTransaction(borrowedMarkets[0], "Repay");
+ return;
+ }
+ if (borrowedMarkets.length > 1) {
+ setDebtChooserOpen(true);
+ return;
+ }
+ setToast("No borrowed assets are open in this preview state.");
+ };
+
+ const handleCollateral = (market: MarketFixture) => {
+ openTransaction(market, market.collateral ? "Disable collateral" : "Enable collateral");
+ };
+
+ const applyFixtureTransaction = (currentDraft: TransactionDraft, amount: number) => {
+ setMarkets((current) => current.map((market) => {
+ if (market.id !== currentDraft.marketId) return market;
+ if (currentDraft.action === "Supply") return { ...market, walletBalance: Math.max(market.walletBalance - amount, 0), supplied: market.supplied + amount };
+ if (currentDraft.action === "Withdraw") return { ...market, walletBalance: market.walletBalance + amount, supplied: Math.max(market.supplied - amount, 0) };
+ if (currentDraft.action === "Borrow") return { ...market, walletBalance: market.walletBalance + amount, borrowed: market.borrowed + amount };
+ if (currentDraft.action === "Repay") return { ...market, walletBalance: Math.max(market.walletBalance - amount, 0), borrowed: Math.max(market.borrowed - amount, 0) };
+ return { ...market, collateral: currentDraft.action === "Enable collateral" };
+ }));
+ };
+
return (
-
-
+
+
Skip to content
-
+
+ {mobileMenuOpen && (
+
+ Dashboard
+ event.preventDefault()}>Markets
+ event.preventDefault()}>Docs
+
+ )}
+
+
+ openTransaction(addCollateralMarket, "Supply")}
+ />
+
+
+
+
+
+
+
+
+
+ TezFin fixture preview · block {scenario.block}
+ Design only · no wallet · no RPC · no transaction submission
+
+
+
+ {!cleanPreview && (
+
+ )}
+
+ {draft && (
+
setDraft(null)} onFixtureConfirm={applyFixtureTransaction} />
+ )}
+
+ {debtChooserOpen && (
+ setDebtChooserOpen(false)}
+ onSelect={(market) => openTransaction(market, "Repay")}
+ />
+ )}
+
+ {toast}
);
}
diff --git a/frontend/src/app/fixtures.ts b/frontend/src/app/fixtures.ts
new file mode 100644
index 00000000..c218a689
--- /dev/null
+++ b/frontend/src/app/fixtures.ts
@@ -0,0 +1,210 @@
+import type {
+ MarketFixture,
+ PositionTotals,
+ ScenarioFixture,
+ ScenarioId,
+} from "./types";
+
+const asset = (name: string) => `/assets/${name}`;
+
+const baseMarkets: MarketFixture[] = [
+ {
+ id: "XTZ",
+ name: "XTZ",
+ subtitle: "Tez",
+ price: 0.94,
+ supplyApy: 1.42,
+ borrowApy: 4.87,
+ collateralFactor: 0.65,
+ fTokenRate: 47.13,
+ liquidityUsd: 2_140_000,
+ walletBalance: 1240.5,
+ supplied: 0,
+ borrowed: 0,
+ collateral: false,
+ icon: asset("XTZ.svg"),
+ fTokenIcon: asset("fXTZ.svg"),
+ standard: "XTZ",
+ },
+ {
+ id: "tzBTC",
+ name: "tzBTC",
+ subtitle: "Wrapped Bitcoin",
+ price: 104_200,
+ supplyApy: 0.09,
+ borrowApy: 1.81,
+ collateralFactor: 0.75,
+ fTokenRate: 49.02,
+ liquidityUsd: 1_860_000,
+ walletBalance: 0.0061,
+ supplied: 0,
+ borrowed: 0,
+ collateral: false,
+ icon: asset("tzBTC.svg"),
+ fTokenIcon: asset("ftzBTC.svg"),
+ standard: "FA1.2 packed",
+ },
+ {
+ id: "USDt",
+ name: "USDt",
+ subtitle: "Tether USD",
+ price: 1,
+ supplyApy: 3.86,
+ borrowApy: 6.92,
+ collateralFactor: 0.78,
+ fTokenRate: 46.2,
+ liquidityUsd: 1_240_000,
+ walletBalance: 512.8,
+ supplied: 0,
+ borrowed: 0,
+ collateral: false,
+ icon: asset("usdt.svg"),
+ fTokenIcon: asset("fusdt.svg"),
+ standard: "FA2",
+ },
+ {
+ id: "USDtz",
+ name: "USDtz",
+ subtitle: "USD Tez",
+ price: 1,
+ supplyApy: 2.14,
+ borrowApy: 5.06,
+ collateralFactor: 0.7,
+ fTokenRate: 48.11,
+ liquidityUsd: 612_000,
+ walletBalance: 84.2,
+ supplied: 0,
+ borrowed: 0,
+ collateral: false,
+ icon: asset("USDtz.svg"),
+ fTokenIcon: asset("fusdtz.svg"),
+ standard: "FA1.2",
+ },
+ {
+ id: "stXTZ",
+ name: "stXTZ",
+ subtitle: "Staked Tez",
+ price: 1.09,
+ supplyApy: 5.62,
+ borrowApy: 7.4,
+ collateralFactor: 0.55,
+ fTokenRate: 45.87,
+ liquidityUsd: 310_000,
+ walletBalance: 0,
+ supplied: 0,
+ borrowed: 0,
+ collateral: false,
+ icon: asset("stacy_logo.png"),
+ fTokenIcon: asset("fstXTZ.svg"),
+ standard: "FA2",
+ },
+];
+
+function cloneMarkets(): MarketFixture[] {
+ return baseMarkets.map((market) => ({ ...market }));
+}
+
+function positionedMarkets(usdtBorrowedUsd = 0, usdtzBorrowedUsd = 0): MarketFixture[] {
+ return cloneMarkets().map((market) => {
+ if (market.id === "XTZ") {
+ return { ...market, supplied: 8400, collateral: true };
+ }
+ if (market.id === "tzBTC") {
+ return { ...market, supplied: 0.042, collateral: true };
+ }
+ if (market.id === "USDt") {
+ return { ...market, borrowed: usdtBorrowedUsd };
+ }
+ if (market.id === "USDtz") {
+ return { ...market, borrowed: usdtzBorrowedUsd };
+ }
+ return market;
+ });
+}
+
+export const scenarios: Record = {
+ disconnected: {
+ id: "disconnected",
+ label: "Disconnected",
+ connected: false,
+ block: "9,412,338",
+ markets: cloneMarkets(),
+ },
+ fresh: {
+ id: "fresh",
+ label: "Fresh wallet",
+ connected: true,
+ address: "tz1ZkH8f8M4V...7sQ2rV",
+ block: "9,412,338",
+ markets: cloneMarkets(),
+ },
+ supplying: {
+ id: "supplying",
+ label: "Supplying only",
+ connected: true,
+ address: "tz1ZkH8f8M4V...7sQ2rV",
+ block: "9,412,338",
+ markets: positionedMarkets(),
+ },
+ active: {
+ id: "active",
+ label: "Active position",
+ connected: true,
+ address: "tz1ZkH8f8M4V...7sQ2rV",
+ block: "9,412,338",
+ markets: positionedMarkets(6300),
+ },
+ "near-liquidation": {
+ id: "near-liquidation",
+ label: "Near liquidation",
+ connected: true,
+ address: "tz1ZkH8f8M4V...7sQ2rV",
+ block: "9,412,338",
+ markets: positionedMarkets(6000, 2000),
+ },
+};
+
+export function calculateTotals(markets: MarketFixture[]): PositionTotals {
+ const raw = markets.reduce(
+ (totals, market) => {
+ const suppliedUsd = market.supplied * market.price;
+ const borrowedUsd = market.borrowed * market.price;
+
+ totals.suppliedUsd += suppliedUsd;
+ totals.borrowedUsd += borrowedUsd;
+ totals.annualSupplyUsd += suppliedUsd * (market.supplyApy / 100);
+ totals.annualBorrowUsd += borrowedUsd * (market.borrowApy / 100);
+
+ if (market.collateral) {
+ totals.collateralUsd += suppliedUsd;
+ totals.borrowLimitUsd += suppliedUsd * market.collateralFactor;
+ }
+
+ return totals;
+ },
+ {
+ suppliedUsd: 0,
+ borrowedUsd: 0,
+ collateralUsd: 0,
+ borrowLimitUsd: 0,
+ annualSupplyUsd: 0,
+ annualBorrowUsd: 0,
+ },
+ );
+
+ const equity = raw.suppliedUsd - raw.borrowedUsd;
+ const estimatedNetRate =
+ equity > 0
+ ? ((raw.annualSupplyUsd - raw.annualBorrowUsd) / equity) * 100
+ : 0;
+
+ return {
+ ...raw,
+ headroomUsd: Math.max(raw.borrowLimitUsd - raw.borrowedUsd, 0),
+ limitUsedPercent:
+ raw.borrowLimitUsd > 0
+ ? (raw.borrowedUsd / raw.borrowLimitUsd) * 100
+ : 0,
+ estimatedNetRate,
+ };
+}
diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts
new file mode 100644
index 00000000..07108aec
--- /dev/null
+++ b/frontend/src/app/types.ts
@@ -0,0 +1,71 @@
+/**
+ * Presentation-only values used by the offline dashboard fixtures.
+ * Production protocol adapters must use the exact atomic-amount primitives in
+ * `src/domain/amount.ts` and must not pass financial values through `number`.
+ */
+export type ScenarioId =
+ | "disconnected"
+ | "fresh"
+ | "supplying"
+ | "active"
+ | "near-liquidation";
+
+export type AssetId = "XTZ" | "tzBTC" | "USDt" | "USDtz" | "stXTZ";
+export type TokenStandard = "XTZ" | "FA1.2" | "FA1.2 packed" | "FA2";
+export type TransactionAction =
+ | "Supply"
+ | "Withdraw"
+ | "Borrow"
+ | "Repay"
+ | "Enable collateral"
+ | "Disable collateral";
+
+export interface MarketFixture {
+ id: AssetId;
+ name: string;
+ subtitle: string;
+ price: number;
+ supplyApy: number;
+ borrowApy: number;
+ collateralFactor: number;
+ fTokenRate: number;
+ liquidityUsd: number;
+ walletBalance: number;
+ supplied: number;
+ borrowed: number;
+ collateral: boolean;
+ icon: string;
+ fTokenIcon: string;
+ standard: TokenStandard;
+}
+
+export interface ScenarioFixture {
+ id: ScenarioId;
+ label: string;
+ connected: boolean;
+ address?: string;
+ block: string;
+ markets: MarketFixture[];
+}
+
+export interface PositionTotals {
+ suppliedUsd: number;
+ borrowedUsd: number;
+ collateralUsd: number;
+ borrowLimitUsd: number;
+ headroomUsd: number;
+ limitUsedPercent: number;
+ annualSupplyUsd: number;
+ annualBorrowUsd: number;
+ estimatedNetRate: number;
+}
+
+export interface TransactionDraft {
+ marketId: AssetId;
+ action: TransactionAction;
+}
+
+export interface OperationStep {
+ label: string;
+ detail: string;
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 07cc0631..e435069b 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1,30 +1,31 @@
:root {
color-scheme: dark;
- font-family: "Instrument Sans", system-ui, -apple-system, BlinkMacSystemFont,
- "Segoe UI", sans-serif;
+ font-family: "Instrument Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #eceff7;
background: #0a0f1e;
font-synthesis: none;
text-rendering: optimizeLegibility;
- --surface-canvas: #0a0f1e;
- --surface-raised: #0e1526;
- --surface-panel: #131b30;
- --text-primary: #eceff7;
- --text-secondary: rgba(236, 239, 247, 0.7);
- --border-subtle: rgba(236, 239, 247, 0.1);
- --border-strong: rgba(236, 239, 247, 0.22);
- --action-primary: #618df8;
- --state-positive: #6fbf8e;
- --state-attention: #dcc95e;
- --state-warning: #e08a52;
- --state-critical: #e0645c;
- --font-editorial: "Fraunces Variable", Georgia, "Times New Roman", serif;
- --font-interface: "Instrument Sans", system-ui, -apple-system,
- BlinkMacSystemFont, "Segoe UI", sans-serif;
- --font-technical: "Spline Sans Mono", ui-monospace, "SFMono-Regular", Menlo,
- monospace;
- --radius-control: 999px;
- --radius-panel: 16px;
+ --ink: #0a0f1e;
+ --ink-2: #0e1526;
+ --ink-3: #131b30;
+ --cream: #eceff7;
+ --cream-dim: rgba(236, 239, 247, 0.7);
+ --cream-faint: rgba(236, 239, 247, 0.56);
+ --line: rgba(236, 239, 247, 0.1);
+ --line-strong: rgba(236, 239, 247, 0.22);
+ --blue: #618df8;
+ --blue-soft: rgba(97, 141, 248, 0.14);
+ --violet: #b278c6;
+ --violet-soft: rgba(178, 120, 198, 0.16);
+ --green: #6fbf8e;
+ --yellow: #dcc95e;
+ --orange: #e08a52;
+ --red: #e0645c;
+ --serif: "Fraunces Variable", Georgia, "Times New Roman", serif;
+ --sans: "Instrument Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --mono: "Spline Sans Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
+ --pill: 999px;
+ --card: 16px;
}
* {
@@ -33,16 +34,17 @@
html {
min-width: 320px;
- background: var(--surface-canvas);
+ background: var(--ink);
+ scroll-behavior: smooth;
}
body {
+ margin: 0;
min-width: 320px;
min-height: 100vh;
- margin: 0;
- color: var(--text-primary);
- background: var(--surface-canvas);
- font-family: var(--font-interface);
+ background: var(--ink);
+ color: var(--cream);
+ font-family: var(--sans);
font-size: 15px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
@@ -53,19 +55,29 @@ input {
font: inherit;
}
-a,
-button {
+button,
+a {
-webkit-tap-highlight-color: transparent;
}
-:focus-visible {
- outline: 2px solid var(--action-primary);
+button {
+ color: inherit;
+}
+
+button:focus-visible,
+a:focus-visible,
+input:focus-visible {
+ outline: 2px solid var(--blue);
outline-offset: 3px;
}
+button:disabled {
+ cursor: not-allowed;
+}
+
::selection {
- color: var(--surface-canvas);
- background: var(--action-primary);
+ color: var(--ink);
+ background: var(--blue);
}
.skip-link {
@@ -74,9 +86,9 @@ button {
left: 12px;
z-index: 100;
padding: 8px 12px;
- border-radius: var(--radius-control);
- color: var(--surface-canvas);
- background: var(--text-primary);
+ border-radius: var(--pill);
+ color: var(--ink);
+ background: var(--cream);
transform: translateY(-160%);
}
@@ -84,75 +96,2040 @@ button {
transform: translateY(0);
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
.app-shell {
min-height: 100vh;
- background: var(--surface-canvas);
+ background: var(--ink);
}
.topbar {
+ position: relative;
+ z-index: 20;
display: flex;
- min-height: 74px;
align-items: center;
+ min-height: 74px;
gap: 28px;
padding: 18px 32px;
- border-bottom: 1px solid var(--border-subtle);
+ border-bottom: 1px solid var(--line);
+ background: var(--ink);
}
.brand {
- color: var(--text-primary);
- font-family: var(--font-editorial);
- font-size: 26px;
- font-variation-settings: "opsz" 72, "wght" 600;
- letter-spacing: -0.04em;
+ display: inline-flex;
+ align-items: center;
+ min-width: 112px;
+}
+
+.brand img {
+ display: block;
+ width: 132px;
+ height: 27px;
+ filter: invert(1) grayscale(1) brightness(1.22);
+ object-fit: contain;
+ object-position: left center;
+}
+
+.desktop-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 8px;
+}
+
+.desktop-nav a,
+.mobile-nav a {
+ color: var(--cream-dim);
+ font-size: 14px;
+ font-weight: 500;
+ text-decoration: none;
+}
+
+.desktop-nav a {
+ padding: 8px 14px;
+ border-radius: var(--pill);
+}
+
+.desktop-nav a.active {
+ color: var(--cream);
+ background: rgba(236, 239, 247, 0.06);
+}
+
+.desktop-nav a:hover,
+.mobile-nav a:hover {
+ color: var(--cream);
}
-.primary-nav {
+.topbar__actions {
display: flex;
align-items: center;
+ gap: 10px;
+ margin-left: auto;
}
-.primary-nav span {
+.network-chip,
+.wallet-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 35px;
padding: 8px 14px;
- border-radius: var(--radius-control);
- color: var(--text-primary);
+ border: 1px solid var(--line);
+ border-radius: var(--pill);
+ color: var(--cream-dim);
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.network-chip i {
+ display: block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--yellow);
+ box-shadow: 0 0 0 4px rgba(220, 201, 94, 0.09);
+}
+
+.wallet-chip {
+ border-color: var(--line-strong);
+ color: var(--cream);
+ cursor: pointer;
+}
+
+.wallet-chip:hover,
+.menu-button:hover,
+.icon-button:hover {
background: rgba(236, 239, 247, 0.06);
+}
+
+.wallet-chip--connect {
+ font-family: var(--sans);
+ font-weight: 600;
+}
+
+.wallet-menu-wrap {
+ position: relative;
+}
+
+.wallet-menu {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ z-index: 30;
+ display: grid;
+ width: 290px;
+ padding: 16px;
+ border: 1px solid var(--line-strong);
+ border-radius: 14px;
+ background: var(--ink-3);
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.42);
+}
+
+.wallet-menu strong {
+ font-size: 13px;
+}
+
+.wallet-menu > span {
+ margin: 4px 0 12px;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 10.5px;
+ overflow-wrap: anywhere;
+}
+
+.wallet-menu button {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 10px 0;
+ border: 0;
+ border-top: 1px solid var(--line);
+ color: var(--cream-dim);
+ background: transparent;
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.menu-button,
+.icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ background: transparent;
+ cursor: pointer;
+}
+
+.menu-button {
+ display: none;
+}
+
+.mobile-nav {
+ position: relative;
+ z-index: 15;
+ display: none;
+ padding: 6px 18px 12px;
+ border-bottom: 1px solid var(--line);
+ background: var(--ink-2);
+}
+
+.mobile-nav a {
+ display: block;
+ padding: 11px 0;
+}
+
+.dashboard-shell {
+ width: min(100%, 1224px);
+ margin: 0 auto;
+ padding: 36px 32px 118px;
+}
+
+.risk-alert {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 16px;
+ padding: 14px 16px;
+ border: 1px solid rgba(224, 100, 92, 0.5);
+ border-radius: 14px;
+ background: rgba(224, 100, 92, 0.1);
+}
+
+.risk-alert__icon {
+ display: inline-flex;
+ color: var(--red);
+}
+
+.risk-alert__copy {
+ display: grid;
+ gap: 1px;
+}
+
+.risk-alert__copy strong {
font-size: 14px;
+}
+
+.risk-alert__copy small {
+ color: var(--cream-dim);
+ font-size: 12.5px;
+}
+
+.risk-alert__actions {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+}
+
+.risk-alert__actions button {
+ min-height: 36px;
+ padding: 7px 14px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--pill);
+ background: transparent;
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.risk-alert__actions button:first-child {
+ border-color: var(--red);
+ color: var(--ink);
+ background: var(--red);
+}
+
+.passbook {
+ position: relative;
+ min-height: 224px;
+ overflow: hidden;
+ padding: 30px 34px 26px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--card);
+ background: var(--ink-2);
+}
+
+.passbook::before {
+ position: absolute;
+ inset: 0 0 auto;
+ height: 45%;
+ border-radius: inherit;
+ background: rgba(236, 239, 247, 0.018);
+ content: "";
+ pointer-events: none;
+}
+
+.passbook__ornament {
+ position: absolute;
+ top: 50%;
+ right: -140px;
+ width: 560px;
+ height: 560px;
+ transform: translateY(-50%);
+ opacity: 0.95;
+ pointer-events: none;
+}
+
+.passbook__grid {
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: end;
+ gap: 28px;
+}
+
+.passbook-label,
+.eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 0 8px;
+ color: var(--cream-dim);
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+}
+
+.dot--supply {
+ background: var(--blue);
+}
+
+.dot--borrow {
+ background: var(--violet);
+}
+
+.passbook-value {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: clamp(23px, 2.45vw, 28px);
font-weight: 500;
+ letter-spacing: -0.02em;
}
-.workspace {
- width: min(1180px, calc(100% - 64px));
- min-height: calc(100vh - 75px);
- margin: 0 auto;
+.passbook-sub {
+ margin: 4px 0 0;
+ color: var(--cream-faint);
+ font-size: 12.5px;
}
-@media (max-width: 640px) {
- .topbar {
- min-height: 64px;
- padding: 14px 20px;
- }
+.passbook-stat--right {
+ text-align: right;
+}
- .brand {
- font-size: 24px;
- }
+.passbook-stat--right .passbook-label {
+ justify-content: flex-end;
+}
- .primary-nav {
- display: none;
- }
+.passbook-net {
+ text-align: center;
+}
- .workspace {
- width: calc(100% - 32px);
- min-height: calc(100vh - 65px);
- }
+.passbook-net .passbook-label {
+ justify-content: center;
+ margin-bottom: 1px;
}
-@media (prefers-reduced-motion: reduce) {
+.passbook-net h1 {
+ margin: 0;
+ font-family: var(--serif);
+ font-size: clamp(44px, 5vw, 56px);
+ font-variation-settings: "wght" 560;
+ font-weight: 560;
+ letter-spacing: -0.025em;
+ line-height: 1.05;
+}
+
+.passbook__risk {
+ position: relative;
+ margin-top: 30px;
+}
+
+.risk-head,
+.risk-foot {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ color: var(--cream-dim);
+}
+
+.risk-head {
+ align-items: baseline;
+ margin-bottom: 13px;
+ font-size: 12.5px;
+}
+
+.risk-head b {
+ color: var(--cream);
+ font-family: var(--mono);
+ font-weight: 500;
+}
+
+.risk-foot {
+ margin-top: 12px;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 11.5px;
+}
+
+.risk-meter__track {
+ position: relative;
+ display: flex;
+ height: 10px;
+ overflow: hidden;
+ border-radius: var(--pill);
+ background: rgba(236, 239, 247, 0.08);
+}
+
+.risk-meter__zone {
+ height: 100%;
+}
+
+.risk-meter__zone--safe {
+ width: 52%;
+ background: var(--green);
+}
+
+.risk-meter__zone--attention {
+ width: 22%;
+ background: var(--yellow);
+}
+
+.risk-meter__zone--high {
+ width: 16%;
+ background: var(--orange);
+}
+
+.risk-meter__zone--critical {
+ width: 10%;
+ background: var(--red);
+}
+
+.risk-meter__mask {
+ position: absolute;
+ inset: 0 0 0 auto;
+ background: rgba(236, 239, 247, 0.08);
+}
+
+.risk-meter__marker {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ width: 2px;
+ border-radius: 2px;
+ background: var(--cream);
+ box-shadow: 0 0 0 2px rgba(10, 15, 30, 0.62);
+ transform: translateX(-1px);
+}
+
+.risk-meter--compact {
+ margin: 15px 0 20px;
+}
+
+.risk-meter--compact .risk-meter__track {
+ height: 8px;
+}
+
+.danger-text {
+ color: var(--red) !important;
+}
+
+.passbook--disconnected {
+ display: flex;
+ align-items: center;
+ min-height: 260px;
+}
+
+.passbook__empty-copy {
+ position: relative;
+ z-index: 2;
+ max-width: 580px;
+}
+
+.passbook__empty-copy h1 {
+ margin: 0;
+ font-family: var(--serif);
+ font-size: clamp(36px, 5vw, 54px);
+ font-variation-settings: "wght" 560;
+ font-weight: 560;
+ letter-spacing: -0.025em;
+ line-height: 1.05;
+}
+
+.passbook__empty-copy > p:not(.eyebrow) {
+ max-width: 520px;
+ margin: 14px 0 22px;
+ color: var(--cream-dim);
+ font-size: 15px;
+}
+
+.primary-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 43px;
+ padding: 10px 18px;
+ border: 1px solid var(--blue);
+ border-radius: var(--pill);
+ color: var(--ink);
+ background: var(--blue);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.fixture-note {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: 13px;
+ color: var(--cream-faint);
+ font-size: 11.5px;
+}
+
+.market-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 22px;
+ margin-top: 26px;
+}
+
+.market-board {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: var(--card);
+ background: var(--ink-2);
+}
+
+.market-board__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 18px 18px 14px;
+}
+
+.market-board__header h2 {
+ margin: 0;
+ font-family: var(--serif);
+ font-size: 21px;
+ font-variation-settings: "wght" 560;
+ font-weight: 560;
+ letter-spacing: 0.005em;
+}
+
+.market-board__header > span {
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.market-board__header b {
+ margin-left: 4px;
+ color: var(--cream-dim);
+ font-weight: 500;
+}
+
+.market-board__empty {
+ min-height: 102px;
+ padding: 25px 22px;
+ border-top: 1px solid var(--line);
+ color: var(--cream-faint);
+ font-size: 13px;
+}
+
+.market-board__rows {
+ min-width: 0;
+}
+
+.market-row {
+ display: grid;
+ align-items: center;
+ gap: 10px;
+ min-height: 62px;
+ padding: 12px 18px;
+ border-top: 1px solid var(--line);
+ transition: background 150ms ease;
+}
+
+.market-row:not(.market-row--head):hover {
+ background: rgba(236, 239, 247, 0.032);
+}
+
+.market-row--supplying {
+ grid-template-columns: minmax(0, 1fr) 62px 124px 86px 92px;
+}
+
+.market-row--borrowing {
+ grid-template-columns: minmax(0, 1fr) 62px 124px 84px;
+}
+
+.market-row--supply-market,
+.market-row--borrow-market {
+ grid-template-columns: minmax(0, 1fr) 116px 66px 86px;
+}
+
+.market-row--head {
+ min-height: auto;
+ padding-top: 2px;
+ padding-bottom: 10px;
+ border-top: 0;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.market-row--head span:not(:first-child) {
+ text-align: right;
+}
+
+.asset-identity {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ min-width: 0;
+}
+
+.asset-identity img {
+ width: 35px;
+ height: 35px;
+ flex: none;
+ object-fit: contain;
+}
+
+.asset-identity > span {
+ display: grid;
+ min-width: 0;
+}
+
+.asset-identity strong {
+ overflow: hidden;
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.asset-identity small,
+.amount-cell small {
+ overflow: hidden;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rate-cell,
+.amount-cell {
+ min-width: 0;
+ text-align: right;
+}
+
+.rate-cell {
+ color: var(--blue);
+ font-family: var(--mono);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.market-board--borrow .rate-cell {
+ color: var(--violet);
+}
+
+.amount-cell {
+ display: grid;
+}
+
+.amount-cell strong {
+ overflow: hidden;
+ font-family: var(--mono);
+ font-size: 12px;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.amount-cell em {
+ color: var(--cream-faint);
+ font-style: normal;
+ font-weight: 400;
+}
+
+.collateral-control {
+ display: flex;
+ justify-content: flex-end;
+ cursor: pointer;
+}
+
+.collateral-control > span {
+ display: none;
+}
+
+.collateral-control input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.collateral-control i {
+ position: relative;
+ display: block;
+ width: 38px;
+ height: 22px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--pill);
+ background: rgba(236, 239, 247, 0.08);
+ transition: background 160ms ease;
+}
+
+.collateral-control i::after {
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--cream-dim);
+ content: "";
+ transition: transform 160ms ease, background 160ms ease;
+}
+
+.collateral-control input:checked + i {
+ border-color: var(--blue);
+ background: var(--blue);
+}
+
+.collateral-control input:checked + i::after {
+ background: var(--ink);
+ transform: translateX(16px);
+}
+
+.collateral-control input:focus-visible + i {
+ outline: 2px solid var(--blue);
+ outline-offset: 3px;
+}
+
+.row-action {
+ min-height: 33px;
+ padding: 7px 13px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--pill);
+ color: var(--cream);
+ background: transparent;
+ font-size: 11.5px;
+ font-weight: 600;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.row-action:hover:not(:disabled) {
+ background: rgba(236, 239, 247, 0.08);
+}
+
+.row-action:disabled {
+ opacity: 0.38;
+}
+
+.market-board--supply .row-action--fill {
+ border-color: var(--blue);
+ color: var(--ink);
+ background: var(--blue);
+}
+
+.market-board--borrow .row-action--fill {
+ border-color: var(--violet);
+ color: var(--ink);
+ background: var(--violet);
+}
+
+.site-footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ margin-top: 44px;
+ padding-top: 20px;
+ border-top: 1px solid var(--line);
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 10.5px;
+ letter-spacing: 0.02em;
+}
+
+.preview-switcher {
+ position: fixed;
+ right: 20px;
+ bottom: 20px;
+ z-index: 40;
+ width: min(440px, calc(100vw - 40px));
+ padding: 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: 15px;
+ background: rgba(19, 27, 48, 0.96);
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
+ backdrop-filter: blur(18px);
+}
+
+.preview-switcher > span {
+ display: block;
+ margin: 0 0 7px 2px;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+.preview-switcher > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.preview-switcher button {
+ flex: 1 1 auto;
+ padding: 7px 9px;
+ border: 1px solid var(--line);
+ border-radius: var(--pill);
+ color: var(--cream-dim);
+ background: transparent;
+ font-size: 10.5px;
+ cursor: pointer;
+}
+
+.preview-switcher button.active {
+ border-color: var(--cream);
+ color: var(--ink);
+ background: var(--cream);
+}
+
+.dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ overflow-y: auto;
+ background: rgba(5, 8, 15, 0.82);
+ backdrop-filter: blur(10px);
+}
+
+.transaction-dialog {
+ width: min(100%, 470px);
+ max-height: calc(100dvh - 40px);
+ overflow-y: auto;
+ padding: 23px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--card);
+ background: var(--ink-2);
+ box-shadow: 0 26px 80px rgba(0, 0, 0, 0.56);
+}
+
+.transaction-dialog__header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.transaction-dialog__header > img {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+}
+
+.transaction-dialog__header > span {
+ display: grid;
+}
+
+.transaction-dialog__header h2 {
+ margin: 0;
+ font-family: var(--serif);
+ font-size: 20px;
+ font-variation-settings: "wght" 560;
+ font-weight: 560;
+}
+
+.transaction-dialog__header small {
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 10.5px;
+}
+
+.transaction-dialog__header .icon-button {
+ margin-left: auto;
+ border-color: transparent;
+}
+
+.debt-chooser {
+ width: min(100%, 640px);
+}
+
+.debt-chooser__icon {
+ display: grid;
+ width: 40px;
+ height: 40px;
+ flex: none;
+ place-items: center;
+ border: 1px solid rgba(174, 112, 201, 0.42);
+ border-radius: 12px;
+ color: var(--violet);
+ background: rgba(174, 112, 201, 0.09);
+}
+
+.debt-chooser__intro {
+ max-width: 540px;
+ margin: 0 0 16px;
+ color: var(--cream-dim);
+ font-size: 12.5px;
+ line-height: 1.55;
+}
+
+.debt-choice-list {
+ display: grid;
+ gap: 10px;
+}
+
+.debt-choice {
+ display: grid;
+ grid-template-columns: minmax(150px, 0.85fr) minmax(280px, 1.35fr) 20px;
+ align-items: center;
+ gap: 16px;
+ width: 100%;
+ padding: 15px;
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ color: var(--cream);
+ background: rgba(236, 239, 247, 0.024);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
+}
+
+.debt-choice:hover,
+.debt-choice:focus-visible {
+ border-color: rgba(174, 112, 201, 0.54);
+ background: rgba(174, 112, 201, 0.07);
+}
+
+.debt-choice:hover {
+ transform: translateY(-1px);
+}
+
+.debt-choice:focus-visible {
+ outline: 2px solid var(--violet);
+ outline-offset: 2px;
+}
+
+.debt-choice__asset {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.debt-choice__asset > small {
+ color: var(--violet);
+ font-family: var(--mono);
+ font-size: 9.5px;
+}
+
+.debt-choice__metrics {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: 16px;
+ min-width: 0;
+}
+
+.debt-choice__metrics > span {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.debt-choice__metrics small {
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 8.5px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.debt-choice__metrics b,
+.debt-choice__metrics em {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.debt-choice__metrics b {
+ font-family: var(--mono);
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.debt-choice__metrics em {
+ color: var(--cream-faint);
+ font-size: 10px;
+ font-style: normal;
+}
+
+.debt-choice__caret {
+ color: var(--cream-faint);
+ transition: color 150ms ease, transform 150ms ease;
+}
+
+.debt-choice:hover .debt-choice__caret,
+.debt-choice:focus-visible .debt-choice__caret {
+ color: var(--violet);
+ transform: translateX(2px);
+}
+
+.offline-callout {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin: 18px 0 16px;
+ padding: 9px 11px;
+ border: 1px solid rgba(220, 201, 94, 0.28);
+ border-radius: 10px;
+ color: var(--cream-dim);
+ background: rgba(220, 201, 94, 0.07);
+ font-size: 11.5px;
+}
+
+.transaction-tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px;
+ margin-top: 18px;
+ padding: 4px;
+ border-radius: var(--pill);
+ background: rgba(236, 239, 247, 0.06);
+}
+
+.transaction-tabs button {
+ min-height: 36px;
+ padding: 7px 10px;
+ border: 0;
+ border-radius: var(--pill);
+ color: var(--cream-dim);
+ background: transparent;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.transaction-tabs button.active {
+ color: var(--ink);
+ background: var(--cream);
+}
+
+.offline-callout svg {
+ color: var(--yellow);
+}
+
+.amount-input {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 62px;
+ padding: 12px 14px;
+ border: 1px solid var(--line-strong);
+ border-radius: 12px;
+}
+
+.amount-input:focus-within {
+ border-color: rgba(97, 141, 248, 0.62);
+ box-shadow: 0 0 0 3px rgba(97, 141, 248, 0.1);
+}
+
+.amount-input input {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ color: var(--cream);
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 22px;
+}
+
+.amount-input input::placeholder {
+ color: var(--cream-faint);
+}
+
+.amount-input b {
+ color: var(--cream-dim);
+ font-family: var(--mono);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.amount-input button {
+ padding: 5px 9px;
+ border: 1px solid var(--line);
+ border-radius: var(--pill);
+ color: var(--cream-dim);
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 9.5px;
+ cursor: pointer;
+}
+
+.amount-usd {
+ margin: 6px 2px 0;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 10.5px;
+}
+
+.transaction-lines {
+ margin-top: 17px;
+ border-top: 1px dashed var(--line-strong);
+}
+
+.transaction-lines > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 9px 0;
+ border-bottom: 1px dashed var(--line);
+ color: var(--cream-dim);
+ font-size: 12px;
+}
+
+.transaction-lines b {
+ color: var(--cream);
+ font-family: var(--mono);
+ font-size: 11.5px;
+ font-weight: 500;
+ text-align: right;
+}
+
+.transaction-lines b small {
+ color: var(--cream-faint);
+ font-family: var(--sans);
+ font-size: 9px;
+ font-weight: 400;
+}
+
+.operation-review {
+ margin: 17px 0;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(236, 239, 247, 0.024);
+}
+
+.operation-review__heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 15px;
+ padding: 11px 12px;
+ border-bottom: 1px solid var(--line);
+ color: var(--cream-dim);
+ font-size: 11.5px;
+}
+
+.operation-review__heading > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.operation-review__heading b {
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ font-weight: 400;
+}
+
+.operation-review ol {
+ display: grid;
+ gap: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.operation-review li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 12px;
+ border-bottom: 1px solid var(--line);
+}
+
+.operation-review li:last-child {
+ border-bottom: 0;
+}
+
+.operation-review li > i {
+ display: grid;
+ width: 22px;
+ height: 22px;
+ flex: none;
+ place-items: center;
+ border: 1px solid var(--line-strong);
+ border-radius: 50%;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 8.5px;
+ font-style: normal;
+}
+
+.operation-review li > span {
+ display: grid;
+}
+
+.operation-review li strong {
+ font-size: 11.5px;
+}
+
+.operation-review li small {
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 9.5px;
+}
+
+.dialog-error {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ margin: 14px 0;
+ color: #ef8c85;
+ font-size: 11.5px;
+}
+
+.dialog-error svg {
+ flex: none;
+ margin-top: 1px;
+}
+
+.dialog-primary {
+ width: 100%;
+ min-height: 46px;
+ margin-top: 15px;
+ border: 0;
+ border-radius: var(--pill);
+ color: var(--ink);
+ background: var(--cream);
+ font-size: 13.5px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.dialog-primary:disabled {
+ color: var(--cream-faint);
+ background: rgba(236, 239, 247, 0.08);
+}
+
+.fixture-success {
+ display: grid;
+ justify-items: center;
+ padding: 34px 4px 5px;
+ text-align: center;
+}
+
+.fixture-success > svg {
+ color: var(--green);
+}
+
+.fixture-success h3 {
+ margin: 12px 0 4px;
+ font-family: var(--serif);
+ font-size: 24px;
+ font-variation-settings: "wght" 560;
+}
+
+.fixture-success p {
+ max-width: 340px;
+ margin: 0;
+ color: var(--cream-dim);
+ font-size: 12.5px;
+}
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: 28px;
+ z-index: 150;
+ max-width: calc(100vw - 32px);
+ padding: 11px 18px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--pill);
+ color: var(--cream);
+ background: var(--ink-3);
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5);
+ font-size: 12px;
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-50%, 14px);
+ transition: opacity 170ms ease, transform 170ms ease;
+}
+
+.toast--show {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+
+@media (max-width: 940px) {
+ .market-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .passbook__ornament {
+ opacity: 0.62;
+ }
+
+ .market-row--supplying {
+ grid-template-columns: minmax(0, 1fr) 80px 145px 90px 104px;
+ }
+
+ .market-row--borrowing {
+ grid-template-columns: minmax(0, 1fr) 80px 145px 104px;
+ }
+
+ .market-row--supply-market,
+ .market-row--borrow-market {
+ grid-template-columns: minmax(0, 1fr) 145px 80px 104px;
+ }
+}
+
+@media (min-width: 941px) and (max-width: 1140px) {
+ .market-row {
+ gap: 6px;
+ padding: 13px 10px;
+ }
+
+ .market-row--supplying {
+ grid-template-columns: minmax(88px, 1fr) 52px 92px 64px 80px;
+ }
+
+ .market-row--borrowing {
+ grid-template-columns: minmax(88px, 1fr) 52px 92px 80px;
+ }
+
+ .market-row--supply-market,
+ .market-row--borrow-market {
+ grid-template-columns: minmax(88px, 1fr) 92px 52px 80px;
+ }
+
+ .market-row--head {
+ font-size: 9px;
+ letter-spacing: 0.08em;
+ }
+
+ .asset-identity {
+ gap: 8px;
+ }
+
+ .asset-identity img {
+ width: 30px;
+ height: 30px;
+ }
+
+ .asset-identity strong {
+ font-size: 13px;
+ }
+
+ .asset-identity small,
+ .amount-cell small {
+ font-size: 9.5px;
+ }
+
+ .rate-cell {
+ font-size: 12.5px;
+ }
+
+ .amount-cell strong {
+ font-size: 11.5px;
+ }
+
+ .row-action {
+ min-height: 31px;
+ padding: 6px 10px;
+ font-size: 11px;
+ }
+
+ .market-board__header {
+ padding: 16px 12px 12px;
+ }
+}
+
+@media (max-width: 700px) {
+ .topbar {
+ min-height: 66px;
+ gap: 12px;
+ padding: 14px 18px;
+ }
+
+ .brand {
+ min-width: 112px;
+ }
+
+ .brand img {
+ width: 112px;
+ height: 25px;
+ }
+
+ .desktop-nav,
+ .network-chip {
+ display: none;
+ }
+
+ .wallet-chip {
+ max-width: 150px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .menu-button,
+ .mobile-nav {
+ display: flex;
+ }
+
+ .mobile-nav {
+ flex-direction: column;
+ }
+
+ .dashboard-shell {
+ padding: 24px 18px 126px;
+ }
+
+ .risk-alert {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .risk-alert__copy {
+ width: calc(100% - 42px);
+ }
+
+ .risk-alert__actions {
+ width: 100%;
+ margin-left: 36px;
+ }
+
+ .risk-alert__actions button {
+ flex: 1;
+ }
+
+ .risk-alert__actions button:first-child {
+ flex: 1.45;
+ }
+
+ .passbook {
+ padding: 22px 20px 20px;
+ }
+
+ .passbook__grid {
+ grid-template-columns: 1fr 1fr;
+ align-items: start;
+ gap: 19px 22px;
+ }
+
+ .passbook-net {
+ grid-column: 1 / -1;
+ grid-row: 1;
+ text-align: left;
+ }
+
+ .passbook-net .passbook-label {
+ justify-content: flex-start;
+ }
+
+ .passbook-net h1 {
+ font-size: 46px;
+ }
+
+ .passbook-stat {
+ grid-row: 2;
+ }
+
+ .passbook-value {
+ font-size: 21px;
+ }
+
+ .passbook__risk {
+ margin-top: 25px;
+ }
+
+ .risk-head {
+ align-items: flex-start;
+ }
+
+ .risk-head span:last-child {
+ max-width: 48%;
+ text-align: right;
+ }
+
+ .market-grid {
+ gap: 18px;
+ margin-top: 18px;
+ }
+
+ .market-board__header {
+ padding: 17px 17px 14px;
+ }
+
+ .market-row--head {
+ display: none;
+ }
+
+ .market-board__rows {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ gap: 12px;
+ padding: 4px 17px 17px;
+ }
+
+ .market-row {
+ min-height: 0;
+ padding: 15px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(236, 239, 247, 0.024);
+ }
+
+ .market-row--supplying {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "asset asset"
+ "apy balance"
+ "collateral collateral"
+ "action action";
+ }
+
+ .market-row--borrowing {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "asset asset"
+ "apy balance"
+ "action action";
+ }
+
+ .market-row--supply-market,
+ .market-row--borrow-market {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "asset asset"
+ "secondary rate"
+ "action action";
+ }
+
+ .market-row > .asset-identity {
+ grid-area: asset;
+ }
+
+ .market-row--supplying > .rate-cell,
+ .market-row--borrowing > .rate-cell {
+ grid-area: apy;
+ text-align: left;
+ }
+
+ .market-row--supplying > .amount-cell,
+ .market-row--borrowing > .amount-cell {
+ grid-area: balance;
+ text-align: left;
+ }
+
+ .market-row--supply-market > .amount-cell,
+ .market-row--borrow-market > .amount-cell {
+ grid-area: secondary;
+ text-align: left;
+ }
+
+ .market-row--supply-market > .rate-cell,
+ .market-row--borrow-market > .rate-cell {
+ grid-area: rate;
+ text-align: left;
+ }
+
+ .rate-cell::before,
+ .amount-cell::before {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 8.5px;
+ font-weight: 400;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+ content: attr(data-label);
+ }
+
+ .collateral-control {
+ grid-area: collateral;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 2px;
+ }
+
+ .collateral-control > span {
+ display: inline;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 8.5px;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+ }
+
+ .row-action {
+ grid-area: action;
+ min-height: 38px;
+ }
+
+ .site-footer {
+ flex-direction: column;
+ gap: 7px;
+ }
+
+ .preview-switcher {
+ right: 12px;
+ bottom: 12px;
+ width: calc(100vw - 24px);
+ }
+
+ .debt-chooser__intro {
+ font-size: 12px;
+ }
+
+ .debt-choice {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ padding: 14px;
+ }
+
+ .debt-choice__asset {
+ grid-row: 1;
+ grid-column: 1;
+ }
+
+ .debt-choice__metrics {
+ grid-row: 2;
+ grid-column: 1 / -1;
+ gap: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+ }
+
+ .debt-choice__caret {
+ grid-row: 1;
+ grid-column: 2;
+ }
+
+ .dialog-backdrop {
+ align-items: end;
+ padding: 0;
+ }
+
+ .transaction-dialog {
+ width: 100%;
+ max-height: 94dvh;
+ padding: 20px;
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+ border-radius: 18px 18px 0 0;
+ }
+}
+
+@media (max-width: 520px) {
+ .topbar {
+ padding: 12px 14px;
+ }
+
+ .wallet-chip {
+ max-width: 124px;
+ padding-right: 10px;
+ padding-left: 10px;
+ font-size: 10.5px;
+ }
+
+ .dashboard-shell {
+ padding-right: 14px;
+ padding-left: 14px;
+ }
+
+ .passbook__grid {
+ gap: 17px 12px;
+ }
+
+ .passbook-value {
+ font-size: 18px;
+ }
+
+ .passbook-sub,
+ .risk-head {
+ font-size: 10.5px;
+ }
+
+ .risk-foot {
+ font-size: 9.5px;
+ }
+
+ .passbook__empty-copy h1 {
+ font-size: 38px;
+ }
+
+ .fixture-note {
+ display: flex;
+ margin: 14px 0 0;
+ }
+
+ .market-board__header > span {
+ max-width: 46%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .preview-switcher button {
+ flex-basis: calc(33.333% - 5px);
+ }
+}
+
+/* Browser-QA harness: applies the same 390 px layout inside a desktop preview tab. */
+.app-shell--mobile {
+ width: 390px;
+ min-height: 844px;
+ margin: 0 auto;
+ overflow: hidden;
+ border-right: 1px solid var(--line);
+ border-left: 1px solid var(--line);
+}
+
+.app-shell--mobile .topbar {
+ min-height: 64px;
+ gap: 10px;
+ padding: 12px 14px;
+}
+
+.app-shell--mobile .brand {
+ min-width: 112px;
+}
+
+.app-shell--mobile .brand img {
+ width: 112px;
+ height: 25px;
+}
+
+.app-shell--mobile .desktop-nav,
+.app-shell--mobile .network-chip {
+ display: none;
+}
+
+.app-shell--mobile .wallet-chip {
+ max-width: 124px;
+ overflow: hidden;
+ padding-right: 10px;
+ padding-left: 10px;
+ font-size: 10.5px;
+ text-overflow: ellipsis;
+}
+
+.app-shell--mobile .menu-button,
+.app-shell--mobile .mobile-nav {
+ display: flex;
+}
+
+.app-shell--mobile .mobile-nav {
+ flex-direction: column;
+}
+
+.app-shell--mobile .dashboard-shell {
+ padding: 24px 14px 126px;
+}
+
+.app-shell--mobile .risk-alert {
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.app-shell--mobile .risk-alert__copy {
+ width: calc(100% - 42px);
+}
+
+.app-shell--mobile .risk-alert__actions {
+ width: 100%;
+ margin-left: 36px;
+}
+
+.app-shell--mobile .risk-alert__actions button {
+ flex: 1;
+}
+
+.app-shell--mobile .risk-alert__actions button:first-child {
+ flex: 1.45;
+}
+
+.app-shell--mobile .passbook {
+ padding: 22px 20px 20px;
+}
+
+.app-shell--mobile .passbook__ornament {
+ opacity: 0.58;
+}
+
+.app-shell--mobile .passbook__grid {
+ grid-template-columns: 1fr 1fr;
+ align-items: start;
+ gap: 17px 12px;
+}
+
+.app-shell--mobile .passbook-net {
+ grid-column: 1 / -1;
+ grid-row: 1;
+ text-align: left;
+}
+
+.app-shell--mobile .passbook-net .passbook-label {
+ justify-content: flex-start;
+}
+
+.app-shell--mobile .passbook-net h1 {
+ font-size: 46px;
+}
+
+.app-shell--mobile .passbook-stat {
+ grid-row: 2;
+}
+
+.app-shell--mobile .passbook-value {
+ font-size: 18px;
+}
+
+.app-shell--mobile .passbook-sub,
+.app-shell--mobile .risk-head {
+ font-size: 10.5px;
+}
+
+.app-shell--mobile .passbook__risk {
+ margin-top: 25px;
+}
+
+.app-shell--mobile .risk-head {
+ align-items: flex-start;
+}
+
+.app-shell--mobile .risk-head span:last-child {
+ max-width: 48%;
+ text-align: right;
+}
+
+.app-shell--mobile .risk-foot {
+ font-size: 9.5px;
+}
+
+.app-shell--mobile .market-grid {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 18px;
+ margin-top: 18px;
+}
+
+.app-shell--mobile .market-board__header {
+ padding: 17px 17px 14px;
+}
+
+.app-shell--mobile .market-board__header > span {
+ max-width: 46%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.app-shell--mobile .market-row--head {
+ display: none;
+}
+
+.app-shell--mobile .market-board__rows {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 12px;
+ padding: 4px 17px 17px;
+}
+
+.app-shell--mobile .market-row {
+ min-height: 0;
+ padding: 15px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(236, 239, 247, 0.024);
+}
+
+.app-shell--mobile .market-row--supplying {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "asset asset"
+ "apy balance"
+ "collateral collateral"
+ "action action";
+}
+
+.app-shell--mobile .market-row--borrowing {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "asset asset"
+ "apy balance"
+ "action action";
+}
+
+.app-shell--mobile .market-row--supply-market,
+.app-shell--mobile .market-row--borrow-market {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "asset asset"
+ "secondary rate"
+ "action action";
+}
+
+.app-shell--mobile .market-row > .asset-identity {
+ grid-area: asset;
+}
+
+.app-shell--mobile .market-row--supplying > .rate-cell,
+.app-shell--mobile .market-row--borrowing > .rate-cell {
+ grid-area: apy;
+ text-align: left;
+}
+
+.app-shell--mobile .market-row--supplying > .amount-cell,
+.app-shell--mobile .market-row--borrowing > .amount-cell {
+ grid-area: balance;
+ text-align: left;
+}
+
+.app-shell--mobile .market-row--supply-market > .amount-cell,
+.app-shell--mobile .market-row--borrow-market > .amount-cell {
+ grid-area: secondary;
+ text-align: left;
+}
+
+.app-shell--mobile .market-row--supply-market > .rate-cell,
+.app-shell--mobile .market-row--borrow-market > .rate-cell {
+ grid-area: rate;
+ text-align: left;
+}
+
+.app-shell--mobile .rate-cell::before,
+.app-shell--mobile .amount-cell::before {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 8.5px;
+ font-weight: 400;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+ content: attr(data-label);
+}
+
+.app-shell--mobile .collateral-control {
+ grid-area: collateral;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 2px;
+}
+
+.app-shell--mobile .collateral-control > span {
+ display: inline;
+ color: var(--cream-faint);
+ font-family: var(--mono);
+ font-size: 8.5px;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+.app-shell--mobile .row-action {
+ grid-area: action;
+ min-height: 38px;
+}
+
+.app-shell--mobile .site-footer {
+ flex-direction: column;
+ gap: 7px;
+}
+
+.app-shell--mobile .debt-chooser__intro {
+ font-size: 12px;
+}
+
+.app-shell--mobile .debt-choice {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ padding: 14px;
+}
+
+.app-shell--mobile .debt-choice__asset {
+ grid-row: 1;
+ grid-column: 1;
+}
+
+.app-shell--mobile .debt-choice__metrics {
+ grid-row: 2;
+ grid-column: 1 / -1;
+ gap: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+}
+
+.app-shell--mobile .debt-choice__caret {
+ grid-row: 1;
+ grid-column: 2;
+}
+
+.app-shell--mobile .dialog-backdrop {
+ left: 50%;
+ width: 390px;
+ align-items: end;
+ padding: 0;
+ transform: translateX(-50%);
+}
+
+.app-shell--mobile .transaction-dialog {
+ width: 100%;
+ max-height: 94dvh;
+ padding: 20px;
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+ border-radius: 18px 18px 0 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
}
}