From 58e04ff4440336f9a475db99c9e154b069f4caa7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 23 Feb 2026 20:08:43 +0800 Subject: [PATCH 1/6] feat: ui-lab basics --- .env.local.example | 4 + app/ui-lab/[[...slug]]/page.tsx | 24 + docs/ui-lab.md | 52 +++ package.json | 1 + src/components/DataPrefetcher.tsx | 22 +- .../ui-lab/fixtures/component-fixtures.ts | 167 +++++++ .../ui-lab/fixtures/market-fixtures.ts | 122 +++++ .../ui-lab/harnesses/borrow-modal-harness.tsx | 55 +++ .../ui-lab/harnesses/market-harnesses.tsx | 183 ++++++++ .../ui-lab/harnesses/primitives-harnesses.tsx | 249 +++++++++++ .../ui-lab/harnesses/shared-harnesses.tsx | 287 ++++++++++++ .../ui-lab/harnesses/supply-modal-harness.tsx | 52 +++ src/features/ui-lab/registry.tsx | 284 ++++++++++++ src/features/ui-lab/types.ts | 20 + src/features/ui-lab/ui-lab-page-client.tsx | 418 ++++++++++++++++++ 15 files changed, 1935 insertions(+), 5 deletions(-) create mode 100644 app/ui-lab/[[...slug]]/page.tsx create mode 100644 docs/ui-lab.md create mode 100644 src/features/ui-lab/fixtures/component-fixtures.ts create mode 100644 src/features/ui-lab/fixtures/market-fixtures.ts create mode 100644 src/features/ui-lab/harnesses/borrow-modal-harness.tsx create mode 100644 src/features/ui-lab/harnesses/market-harnesses.tsx create mode 100644 src/features/ui-lab/harnesses/primitives-harnesses.tsx create mode 100644 src/features/ui-lab/harnesses/shared-harnesses.tsx create mode 100644 src/features/ui-lab/harnesses/supply-modal-harness.tsx create mode 100644 src/features/ui-lab/registry.tsx create mode 100644 src/features/ui-lab/types.ts create mode 100644 src/features/ui-lab/ui-lab-page-client.tsx diff --git a/.env.local.example b/.env.local.example index adff3a6a..45f3da81 100644 --- a/.env.local.example +++ b/.env.local.example @@ -63,3 +63,7 @@ MONARCH_API_KEY= # Base URL for oracle metadata Gist (without trailing slash) # Example: https://gist.githubusercontent.com/username/gist-id/raw NEXT_PUBLIC_ORACLE_GIST_BASE_URL= + +# ==================== UI Lab ==================== +# Enable dev-only component playground route at /ui-lab/* +NEXT_PUBLIC_ENABLE_UI_LAB= diff --git a/app/ui-lab/[[...slug]]/page.tsx b/app/ui-lab/[[...slug]]/page.tsx new file mode 100644 index 00000000..c25d9d5d --- /dev/null +++ b/app/ui-lab/[[...slug]]/page.tsx @@ -0,0 +1,24 @@ +import { notFound } from 'next/navigation'; +import { UiLabPageClient } from '@/features/ui-lab/ui-lab-page-client'; + +type UiLabPageProps = { + params: + | { + slug?: string[]; + } + | Promise<{ + slug?: string[]; + }>; +}; + +export default async function UiLabPage({ params }: UiLabPageProps) { + const isEnabled = process.env.NEXT_PUBLIC_ENABLE_UI_LAB === 'true'; + + if (!isEnabled) { + notFound(); + } + + const resolvedParams = await Promise.resolve(params); + + return ; +} diff --git a/docs/ui-lab.md b/docs/ui-lab.md new file mode 100644 index 00000000..81737d64 --- /dev/null +++ b/docs/ui-lab.md @@ -0,0 +1,52 @@ +# UI Lab + +A dev-only component playground for fast UI iteration with deterministic fixture data. + +## Run + +```bash +pnpm dev:ui-lab +``` + +UI Lab routes: + +- `http://localhost:3000/ui-lab/button` +- `http://localhost:3000/ui-lab/tooltip` +- `http://localhost:3000/ui-lab/tooltip-content` +- `http://localhost:3000/ui-lab/network-filter` +- `http://localhost:3000/ui-lab/asset-filter` +- `http://localhost:3000/ui-lab/account-identity` +- `http://localhost:3000/ui-lab/market-identity` +- `http://localhost:3000/ui-lab/market-details-block` +- `http://localhost:3000/ui-lab/dropdown-menu` +- `http://localhost:3000/ui-lab/table-pagination` +- `http://localhost:3000/ui-lab/borrow-modal` +- `http://localhost:3000/ui-lab/market-selection-modal` +- `http://localhost:3000/ui-lab/supply-modal` + +The route is gated by `NEXT_PUBLIC_ENABLE_UI_LAB=true`. + +## URL state + +- Component selection is stored in the path segment (`/ui-lab/`). +- Canvas controls are stored in query params: + - `pad` + - `maxW` + - `bg` + +Share the full URL to keep the same component and canvas setup. + +## Add a new component + +1. Add fixture data if needed in `src/features/ui-lab/fixtures`. +2. Add a harness in `src/features/ui-lab/harnesses`. +3. Register an entry in `src/features/ui-lab/registry.tsx`. +4. Open `/ui-lab/` and verify it renders. + +## Notes + +- The global `DataPrefetcher` is skipped on `/ui-lab` to avoid background market/oracle/reward fetches while iterating. +- Complex modals are rendered through harness wrappers with fixture props so layout work stays deterministic. +- Shared realistic fixtures live in: + - `src/features/ui-lab/fixtures/market-fixtures.ts` + - `src/features/ui-lab/fixtures/component-fixtures.ts` diff --git a/package.json b/package.json index ceadf718..31e81abd 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "rm -rf .next && next build", "check": "pnpm lint:check && pnpm typecheck", "dev": "next dev --turbo", + "dev:ui-lab": "NEXT_PUBLIC_ENABLE_UI_LAB=true next dev --turbo", "format": "biome format --write .", "lint": "biome check --write .", "lint:check": "biome check", diff --git a/src/components/DataPrefetcher.tsx b/src/components/DataPrefetcher.tsx index 2a8294ae..60ab6eca 100644 --- a/src/components/DataPrefetcher.tsx +++ b/src/components/DataPrefetcher.tsx @@ -1,19 +1,31 @@ 'use client'; +import { usePathname } from 'next/navigation'; import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useMerklCampaignsQuery } from '@/hooks/queries/useMerklCampaignsQuery'; import { useOracleDataQuery } from '@/hooks/queries/useOracleDataQuery'; +function DataPrefetcherContent() { + useMarketsQuery(); + useTokensQuery(); + useMerklCampaignsQuery(); + useOracleDataQuery(); + + return null; +} + /** * Triggeres data prefetching for markets, tokens, and Merkl campaigns. * These hooks use React Query under the hood, which will cache the data for future use. * @returns */ export function DataPrefetcher() { - useMarketsQuery(); - useTokensQuery(); - useMerklCampaignsQuery(); - useOracleDataQuery(); - return null; + const pathname = usePathname(); + + if (pathname?.startsWith('/ui-lab')) { + return null; + } + + return ; } diff --git a/src/features/ui-lab/fixtures/component-fixtures.ts b/src/features/ui-lab/fixtures/component-fixtures.ts new file mode 100644 index 00000000..d5bc883b --- /dev/null +++ b/src/features/ui-lab/fixtures/component-fixtures.ts @@ -0,0 +1,167 @@ +import { mainnet } from 'viem/chains'; +import type { Address } from 'viem'; +import { infoToKey, supportedTokens, type ERC20Token, type UnknownERC20Token } from '@/utils/tokens'; +import type { Market } from '@/utils/types'; +import { createUiLabMarketFixture } from '@/features/ui-lab/fixtures/market-fixtures'; + +type AssetFilterItem = ERC20Token | UnknownERC20Token; + +const uiLabPreferredAssetSymbols = ['USDC', 'USDT', 'WETH', 'WBTC', 'cbBTC', 'PYUSD'] as const; + +const hasMainnetAddress = (token: AssetFilterItem): boolean => { + return token.networks.some((network) => network.chain.id === mainnet.id); +}; + +const buildAssetSelectionKey = (token: AssetFilterItem): string => { + return token.networks.map((network) => infoToKey(network.address, network.chain.id)).join('|'); +}; + +export const createUiLabAssetFilterItems = (): AssetFilterItem[] => { + const symbolSet = new Set(uiLabPreferredAssetSymbols); + return supportedTokens.filter((token) => symbolSet.has(token.symbol) && hasMainnetAddress(token)).slice(0, uiLabPreferredAssetSymbols.length); +}; + +export const createUiLabDefaultAssetSelection = (items: AssetFilterItem[]): string[] => { + return items.slice(0, 2).map(buildAssetSelectionKey); +}; + +export const createUiLabMarketVariantsFixture = (): Market[] => { + const baseMarket = createUiLabMarketFixture(); + + const stablecoinBorrowMarket: Market = { + ...baseMarket, + id: '0x5f8a138ba332398a9116910f4d5e5dcd9b207024c5290ce5bc87bc2dbd8e4a86', + uniqueKey: '0x5f8a138ba332398a9116910f4d5e5dcd9b207024c5290ce5bc87bc2dbd8e4a86', + oracleAddress: '0x3333333333333333333333333333333333333333', + irmAddress: '0x4444444444444444444444444444444444444444', + loanAsset: { + ...baseMarket.loanAsset, + id: 'ethereum-usdt', + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + }, + collateralAsset: { + ...baseMarket.collateralAsset, + id: 'ethereum-wbtc', + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + symbol: 'WBTC', + name: 'Wrapped Bitcoin', + decimals: 8, + }, + state: { + ...baseMarket.state, + borrowAssets: '48100000000', + supplyAssets: '93200000000', + borrowAssetsUsd: 48100, + supplyAssetsUsd: 93200, + liquidityAssets: '45100000000', + liquidityAssetsUsd: 45100, + collateralAssets: '4850000000', + collateralAssetsUsd: 297000, + utilization: 0.516, + supplyApy: 0.041, + borrowApy: 0.066, + apyAtTarget: 0.053, + rateAtTarget: '53000000000000000', + dailySupplyApy: 0.04, + dailyBorrowApy: 0.065, + weeklySupplyApy: 0.041, + weeklyBorrowApy: 0.066, + monthlySupplyApy: 0.042, + monthlyBorrowApy: 0.067, + }, + }; + + const bitcoinBorrowMarket: Market = { + ...baseMarket, + id: '0x37e7484d642d90f14451f1910ba4b7b8e4c3ccdd0ec28f8b2bdb35479e472ba7', + uniqueKey: '0x37e7484d642d90f14451f1910ba4b7b8e4c3ccdd0ec28f8b2bdb35479e472ba7', + oracleAddress: '0x5555555555555555555555555555555555555555', + irmAddress: '0x6666666666666666666666666666666666666666', + loanAsset: { + ...baseMarket.loanAsset, + id: 'ethereum-wbtc', + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + symbol: 'WBTC', + name: 'Wrapped Bitcoin', + decimals: 8, + }, + collateralAsset: { + ...baseMarket.collateralAsset, + id: 'ethereum-weth', + address: '0xC02aaA39b223FE8D0A0E5C4F27EAD9083C756Cc2', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + }, + state: { + ...baseMarket.state, + borrowAssets: '129000000', + supplyAssets: '244000000', + borrowAssetsUsd: 112000000, + supplyAssetsUsd: 212000000, + liquidityAssets: '115000000', + liquidityAssetsUsd: 100000000, + collateralAssets: '90400000000000000000', + collateralAssetsUsd: 292000, + utilization: 0.528, + supplyApy: 0.019, + borrowApy: 0.033, + apyAtTarget: 0.027, + rateAtTarget: '27000000000000000', + dailySupplyApy: 0.019, + dailyBorrowApy: 0.032, + weeklySupplyApy: 0.019, + weeklyBorrowApy: 0.033, + monthlySupplyApy: 0.02, + monthlyBorrowApy: 0.034, + }, + }; + + return [baseMarket, stablecoinBorrowMarket, bitcoinBorrowMarket]; +}; + +export const uiLabAccountAddressFixtures: Address[] = [ + '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + '0x66f820a414680B5bcda5eECA5dea238543F42054', +]; + +export const uiLabTransactionHashFixtures = [ + '0xc18316f6405f6d22be8924bf2085b4b42f6df8947fb4e9f8c92312e5a85f8d48', + '0x8ce8f8fb0f83db4f1db1e5914f4f67f216178e8ce4fce1092f1b180dbc8933d1', +] as const; + +export const uiLabCollateralFixtures = [ + { + address: '0xC02aaA39b223FE8D0A0E5C4F27EAD9083C756Cc2', + symbol: 'WETH', + amount: 5.24, + }, + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + symbol: 'WBTC', + amount: 1.18, + }, + { + address: '0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + amount: 18540, + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + symbol: 'USDT', + amount: 9400, + }, + { + address: '0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32', + symbol: 'LDO', + amount: 620, + }, + { + address: '0x7f39c581f595b53c5cb5bbcf7f95e66b3d5f6d18', + symbol: 'wstETH', + amount: 3.72, + }, +]; diff --git a/src/features/ui-lab/fixtures/market-fixtures.ts b/src/features/ui-lab/fixtures/market-fixtures.ts new file mode 100644 index 00000000..7c3ca560 --- /dev/null +++ b/src/features/ui-lab/fixtures/market-fixtures.ts @@ -0,0 +1,122 @@ +import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; +import type { Market, MarketPosition, TokenInfo } from '@/utils/types'; + +const marketId = '0xb8fc70e82bc5bb53e773626fcc6a23f7eefa036918d7ef216ecfb1950a94a85e'; +const marketOracle = '0x1111111111111111111111111111111111111111'; +const marketIrm = '0x2222222222222222222222222222222222222222'; + +const chainId = 1; +const chainName = 'ethereum'; + +const makeToken = ({ + id, + address, + symbol, + name, + decimals, +}: { + id: string; + address: string; + symbol: string; + name: string; + decimals: number; +}): TokenInfo => ({ + id, + address, + symbol, + name, + decimals, +}); + +export const createUiLabMarketFixture = (): Market => ({ + id: marketId, + lltv: '860000000000000000', + uniqueKey: marketId, + irmAddress: marketIrm, + oracleAddress: marketOracle, + whitelisted: true, + morphoBlue: { + id: 'morpho-blue-mainnet', + address: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + chain: { + id: chainId, + }, + }, + loanAsset: makeToken({ + id: `${chainName}-usdc`, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }), + collateralAsset: makeToken({ + id: `${chainName}-weth`, + address: '0xC02aaA39b223FE8D0A0E5C4F27EAD9083C756Cc2', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + }), + state: { + borrowAssets: '74250000000', + supplyAssets: '125000000000', + borrowAssetsUsd: 74250, + supplyAssetsUsd: 125000, + borrowShares: '74220000000', + supplyShares: '124940000000', + liquidityAssets: '50750000000', + liquidityAssetsUsd: 50750, + collateralAssets: '160000000000000000000', + collateralAssetsUsd: 520000, + utilization: 0.594, + supplyApy: 0.048, + borrowApy: 0.076, + fee: 0, + timestamp: 1735689600, + apyAtTarget: 0.06, + rateAtTarget: '60000000000000000', + dailySupplyApy: 0.047, + dailyBorrowApy: 0.074, + weeklySupplyApy: 0.048, + weeklyBorrowApy: 0.076, + monthlySupplyApy: 0.05, + monthlyBorrowApy: 0.078, + }, + realizedBadDebt: { + underlying: '0', + }, + supplyingVaults: [], + hasUSDPrice: true, + warnings: [], +}); + +export const createUiLabBorrowPositionFixture = (market: Market): MarketPosition => ({ + market, + state: { + supplyShares: '0', + supplyAssets: '0', + borrowShares: '59940000000', + borrowAssets: '60000000000', + collateral: '82000000000000000000', + }, +}); + +export const createUiLabSupplyPositionFixture = (market: Market): MarketPosition => ({ + market, + state: { + supplyShares: '27480000000', + supplyAssets: '27500000000', + borrowShares: '0', + borrowAssets: '0', + collateral: '0', + }, +}); + +export const uiLabLiquiditySourcingFixture: LiquiditySourcingResult = { + totalAvailableExtraLiquidity: 0n, + canSourceLiquidity: false, + isLoading: false, + computeReallocation: () => null, + refetch: () => {}, +}; + +export const uiLabOraclePrice = 3250n * 10n ** 36n; diff --git a/src/features/ui-lab/harnesses/borrow-modal-harness.tsx b/src/features/ui-lab/harnesses/borrow-modal-harness.tsx new file mode 100644 index 00000000..c1010567 --- /dev/null +++ b/src/features/ui-lab/harnesses/borrow-modal-harness.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { BorrowModal } from '@/modals/borrow/borrow-modal'; +import { Button } from '@/components/ui/button'; +import { + createUiLabBorrowPositionFixture, + createUiLabMarketFixture, + uiLabLiquiditySourcingFixture, + uiLabOraclePrice, +} from '@/features/ui-lab/fixtures/market-fixtures'; + +export function BorrowModalHarness(): JSX.Element { + const [isOpen, setIsOpen] = useState(true); + const [defaultMode, setDefaultMode] = useState<'borrow' | 'repay'>('borrow'); + const [hasPosition, setHasPosition] = useState(true); + + const market = useMemo(() => createUiLabMarketFixture(), []); + const position = useMemo(() => (hasPosition ? createUiLabBorrowPositionFixture(market) : null), [hasPosition, market]); + + return ( +
+
+ + + + +
+ +

Use this harness to adjust real `BorrowModal` spacing/layout with deterministic fixture props.

+ + {isOpen ? ( + {}} + isRefreshing={false} + position={position} + defaultMode={defaultMode} + liquiditySourcing={uiLabLiquiditySourcingFixture} + /> + ) : null} +
+ ); +} diff --git a/src/features/ui-lab/harnesses/market-harnesses.tsx b/src/features/ui-lab/harnesses/market-harnesses.tsx new file mode 100644 index 00000000..8fef47d6 --- /dev/null +++ b/src/features/ui-lab/harnesses/market-harnesses.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { Address } from 'viem'; +import { Button } from '@/components/ui/button'; +import AssetFilter from '@/features/markets/components/filters/asset-filter'; +import NetworkFilter from '@/features/markets/components/filters/network-filter'; +import { MarketDetailsBlock } from '@/features/markets/components/market-details-block'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { MarketSelectionModal } from '@/features/markets/components/market-selection-modal'; +import { MarketSelector } from '@/features/markets/components/market-selector'; +import { createUiLabAssetFilterItems, createUiLabDefaultAssetSelection, createUiLabMarketVariantsFixture } from '@/features/ui-lab/fixtures/component-fixtures'; +import { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +const SUPPLY_PREVIEW_DELTA = 12_500_000n; +const BORROW_PREVIEW_DELTA = 8_000_000n; + +export function NetworkFilterHarness(): JSX.Element { + const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Mainnet); + + return ( +
+

Selected chain: {selectedNetwork ?? 'All networks'}

+
+ +
+ +
+
+
+ ); +} + +export function AssetFilterHarness(): JSX.Element { + const items = useMemo(() => createUiLabAssetFilterItems(), []); + const [selectedAssets, setSelectedAssets] = useState(() => createUiLabDefaultAssetSelection(items)); + + return ( +
+ +

Selected assets: {selectedAssets.length}

+
+ ); +} + +export function MarketIdentityHarness(): JSX.Element { + const markets = useMemo(() => createUiLabMarketVariantsFixture(), []); + const market = markets[0]; + + return ( +
+
+

Focused

+ +
+ +
+

Minimum

+ +
+ +
+

Badge

+ +
+
+ ); +} + +export function MarketDetailsBlockHarness(): JSX.Element { + const market = useMemo(() => createUiLabMarketVariantsFixture()[0], []); + const [mode, setMode] = useState<'supply' | 'borrow'>('supply'); + const [showPreview, setShowPreview] = useState(false); + + return ( +
+
+ + + +
+ + +
+ ); +} + +export function MarketSelectorHarness(): JSX.Element { + const markets = useMemo(() => createUiLabMarketVariantsFixture(), []); + const [addedMarketIds, setAddedMarketIds] = useState([]); + + const handleAdd = (marketId: string) => { + setAddedMarketIds((prev) => { + if (prev.includes(marketId)) { + return prev; + } + return [...prev, marketId]; + }); + }; + + return ( +
+
+ +

Added: {addedMarketIds.length}

+
+ + {markets.map((market) => { + const isAdded = addedMarketIds.includes(market.uniqueKey); + return ( + handleAdd(market.uniqueKey)} /> + ); + })} +
+ ); +} + +export function MarketSelectionModalHarness(): JSX.Element { + const [isOpen, setIsOpen] = useState(false); + const [isMultiSelect, setIsMultiSelect] = useState(true); + const [selectedMarkets, setSelectedMarkets] = useState([]); + const vaultAsset = useMemo
(() => createUiLabMarketVariantsFixture()[0].loanAsset.address as Address, []); + + const selectedLabels = selectedMarkets.map((market) => `${market.loanAsset.symbol}/${market.collateralAsset.symbol}`); + + return ( +
+
+ + +
+ +

Last selection: {selectedLabels.length > 0 ? selectedLabels.join(', ') : 'none'}

+

This one intentionally uses the live `MarketSelectionModal` data pipeline from the app.

+ + {isOpen ? ( + setSelectedMarkets(markets)} + /> + ) : null} +
+ ); +} diff --git a/src/features/ui-lab/harnesses/primitives-harnesses.tsx b/src/features/ui-lab/harnesses/primitives-harnesses.tsx new file mode 100644 index 00000000..05a6af3b --- /dev/null +++ b/src/features/ui-lab/harnesses/primitives-harnesses.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import { TooltipContent } from '@/components/shared/tooltip-content'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardBody, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Spinner } from '@/components/ui/spinner'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip } from '@/components/ui/tooltip'; + +export function ButtonHarness(): JSX.Element { + return ( +
+ + + + + + +
+ ); +} + +export function CardHarness(): JSX.Element { + return ( + + + Borrow Position + Simple card with static content for spacing checks. + + +
+
+

Collateral

+

2.60 WETH

+
+
+

Debt

+

1,200 USDC

+
+
+
+ + + + +
+ ); +} + +export function TooltipHarness(): JSX.Element { + return ( +
+ + + + + +

Wrap trigger in `span` to avoid ResizeObserver issues.

+
+ ); +} + +export function TooltipContentHarness(): JSX.Element { + return ( +
+
+ } + /> +
+ +
+ } + actionIcon={} + actionHref="https://docs.morpho.org/" + /> +
+
+ ); +} + +export function InputHarness(): JSX.Element { + const [amount, setAmount] = useState('1250'); + + return ( +
+ USDC} /> + +
+ ); +} + +export function SelectHarness(): JSX.Element { + const [network, setNetwork] = useState('mainnet'); + + return ( +
+

Selected network: {network}

+ +
+ ); +} + +export function BadgeHarness(): JSX.Element { + return ( +
+ Default + Primary + Success + Warning + Danger +
+ ); +} + +export function TableHarness(): JSX.Element { + const rows = useMemo( + () => [ + { token: 'USDC', supplied: '$12,000', apy: '4.7%' }, + { token: 'WETH', supplied: '$8,540', apy: '2.9%' }, + { token: 'WBTC', supplied: '$2,150', apy: '1.8%' }, + ], + [], + ); + + return ( +
+ + + + Asset + Supplied + APY + + + + {rows.map((row) => ( + + {row.token} + {row.supplied} + {row.apy} + + ))} + +
+
+ ); +} + +export function TabsHarness(): JSX.Element { + return ( + + + Borrow + Repay + + Borrow flow content placeholder. + Repay flow content placeholder. + + ); +} + +export function SliderHarness(): JSX.Element { + const [value, setValue] = useState([42]); + + return ( +
+

Utilization target: {value[0]}%

+ +
+ ); +} + +export function CheckboxHarness(): JSX.Element { + const [checked, setChecked] = useState(false); + + return ( +
+ setChecked(next === true)} label="Enable Permit2 for this flow" /> + setChecked(next === true)} label="Use highlighted style" /> +
+ ); +} + +export function PopoverHarness(): JSX.Element { + return ( +
+ + + + + +

This area is useful for quick spacing/layout checks inside overlays.

+
+
+
+ ); +} + +export function SpinnerHarness(): JSX.Element { + return ( +
+ + + +
+ ); +} diff --git a/src/features/ui-lab/harnesses/shared-harnesses.tsx b/src/features/ui-lab/harnesses/shared-harnesses.tsx new file mode 100644 index 00000000..61881abe --- /dev/null +++ b/src/features/ui-lab/harnesses/shared-harnesses.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { IoMdCheckmarkCircleOutline } from 'react-icons/io'; +import { LuArrowRightLeft, LuLayoutGrid, LuMoon, LuRefreshCw, LuSun } from 'react-icons/lu'; +import { SectionTag } from '@/components/landing/SectionTag'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; +import { AccountIdentity } from '@/components/shared/account-identity'; +import { TablePagination } from '@/components/shared/table-pagination'; +import { TransactionIdentity } from '@/components/shared/transaction-identity'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { IconSwitch } from '@/components/ui/icon-switch'; +import { RefetchIcon } from '@/components/ui/refetch-icon'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { CollateralIconsDisplay } from '@/features/positions/components/collateral-icons-display'; +import { + uiLabAccountAddressFixtures, + uiLabCollateralFixtures, + uiLabTransactionHashFixtures, +} from '@/features/ui-lab/fixtures/component-fixtures'; +import { useStyledToast } from '@/hooks/useStyledToast'; +import { SupportedNetworks } from '@/utils/networks'; + +export function AccountIdentityHarness(): JSX.Element { + const [primaryAddress, secondaryAddress] = uiLabAccountAddressFixtures; + + return ( +
+
+

Badge

+ +
+ +
+

Compact

+ +
+ +
+

Full

+ +
+
+ ); +} + +export function TransactionIdentityHarness(): JSX.Element { + return ( +
+ + +
+ ); +} + +export function CollateralIconsDisplayHarness(): JSX.Element { + return ( +
+
+

Compact

+ +
+ +
+

Overflow + Tooltip

+ +
+
+ ); +} + +export function SectionTagHarness(): JSX.Element { + return ( +
+ Market Snapshot + Risk Controls + Execution Layer +
+ ); +} + +export function IconSwitchHarness(): JSX.Element { + const [plainEnabled, setPlainEnabled] = useState(true); + const [modeEnabled, setModeEnabled] = useState(false); + + return ( +
+
+

Plain switch

+ +
+ +
+

Icon switch

+ +
+
+ ); +} + +export function RefetchIconHarness(): JSX.Element { + const [isLoading, setIsLoading] = useState(false); + + const triggerRefresh = () => { + if (isLoading) return; + setIsLoading(true); + window.setTimeout(() => { + setIsLoading(false); + }, 1200); + }; + + return ( +
+ +

The icon completes the active spin cycle before stopping.

+
+ ); +} + +export function DropdownMenuHarness(): JSX.Element { + const [showRiskSignals, setShowRiskSignals] = useState(true); + const [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); + + return ( +
+ + + + + + }>Refresh + }>Mark as reviewed + + setShowRiskSignals(checked === true)}> + Show risk signals + + + setViewMode(value as 'table' | 'cards')}> + }> + Table layout + + }> + Card layout + + + + + +

+ Current: {viewMode} / {showRiskSignals ? 'risk on' : 'risk off'} +

+
+ ); +} + +export function TableContainerWithHeaderHarness(): JSX.Element { + const rows = useMemo( + () => [ + { market: 'USDC / WETH', supplied: '$125,000', borrowed: '$74,250' }, + { market: 'USDT / WBTC', supplied: '$93,200', borrowed: '$48,100' }, + { market: 'WBTC / WETH', supplied: '$212,000', borrowed: '$112,000' }, + ], + [], + ); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = () => { + if (isRefreshing) return; + setIsRefreshing(true); + window.setTimeout(() => setIsRefreshing(false), 900); + }; + + return ( + + + + + + + + Top liquidity + Top borrow rate + + + + } + > + + + + Market + Supplied + Borrowed + + + + {rows.map((row) => ( + + {row.market} + {row.supplied} + {row.borrowed} + + ))} + +
+
+ ); +} + +export function TablePaginationHarness(): JSX.Element { + const totalEntries = 248; + const pageSize = 25; + const totalPages = Math.ceil(totalEntries / pageSize); + const [page, setPage] = useState(1); + + return ( + + ); +} + +export function ToastHarness(): JSX.Element { + const { success, error, info } = useStyledToast(); + + return ( +
+ + + +
+ ); +} diff --git a/src/features/ui-lab/harnesses/supply-modal-harness.tsx b/src/features/ui-lab/harnesses/supply-modal-harness.tsx new file mode 100644 index 00000000..0c748a92 --- /dev/null +++ b/src/features/ui-lab/harnesses/supply-modal-harness.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { SupplyModalV2 } from '@/modals/supply/supply-modal'; +import { + createUiLabMarketFixture, + createUiLabSupplyPositionFixture, + uiLabLiquiditySourcingFixture, +} from '@/features/ui-lab/fixtures/market-fixtures'; + +export function SupplyModalHarness(): JSX.Element { + const [isOpen, setIsOpen] = useState(true); + const [defaultMode, setDefaultMode] = useState<'supply' | 'withdraw'>('supply'); + const [hasPosition, setHasPosition] = useState(true); + + const market = useMemo(() => createUiLabMarketFixture(), []); + const position = useMemo(() => (hasPosition ? createUiLabSupplyPositionFixture(market) : null), [hasPosition, market]); + + return ( +
+
+ + + + +
+ +

Use this harness to tune `SupplyModalV2` layout while keeping market/position fixtures stable.

+ + {isOpen ? ( + {}} + defaultMode={defaultMode} + liquiditySourcing={uiLabLiquiditySourcingFixture} + /> + ) : null} +
+ ); +} diff --git a/src/features/ui-lab/registry.tsx b/src/features/ui-lab/registry.tsx new file mode 100644 index 00000000..5895068f --- /dev/null +++ b/src/features/ui-lab/registry.tsx @@ -0,0 +1,284 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { BorrowModalHarness } from '@/features/ui-lab/harnesses/borrow-modal-harness'; +import { + AssetFilterHarness, + MarketDetailsBlockHarness, + MarketIdentityHarness, + MarketSelectionModalHarness, + MarketSelectorHarness, + NetworkFilterHarness, +} from '@/features/ui-lab/harnesses/market-harnesses'; +import { + BadgeHarness, + ButtonHarness, + CardHarness, + CheckboxHarness, + InputHarness, + PopoverHarness, + SelectHarness, + SliderHarness, + SpinnerHarness, + TableHarness, + TabsHarness, + TooltipContentHarness, + TooltipHarness, +} from '@/features/ui-lab/harnesses/primitives-harnesses'; +import { + AccountIdentityHarness, + CollateralIconsDisplayHarness, + DropdownMenuHarness, + IconSwitchHarness, + RefetchIconHarness, + SectionTagHarness, + TableContainerWithHeaderHarness, + TablePaginationHarness, + ToastHarness, + TransactionIdentityHarness, +} from '@/features/ui-lab/harnesses/shared-harnesses'; +import { SupplyModalHarness } from '@/features/ui-lab/harnesses/supply-modal-harness'; + +export const uiLabRegistry: UiLabEntry[] = [ + { + id: 'button', + title: 'Button', + category: 'ui-primitives', + description: 'Primary, surface, default, and ghost variants.', + render: () => , + }, + { + id: 'card', + title: 'Card', + category: 'ui-primitives', + description: 'Card container and header/body/footer spacing.', + render: () => , + }, + { + id: 'tooltip', + title: 'Tooltip', + category: 'ui-primitives', + description: 'Tooltip content and trigger wrapping behavior.', + render: () => , + }, + { + id: 'tooltip-content', + title: 'Tooltip Content', + category: 'ui-primitives', + description: 'Shared tooltip content layouts from src/components/shared/tooltip-content.tsx.', + render: () => , + }, + { + id: 'input', + title: 'Input', + category: 'ui-primitives', + description: 'Input field states, labels, and error styling.', + render: () => , + }, + { + id: 'select', + title: 'Select', + category: 'ui-primitives', + description: 'Radix select trigger and dropdown content.', + render: () => , + }, + { + id: 'badge', + title: 'Badge', + category: 'ui-primitives', + description: 'Badge variants and compact spacing checks.', + render: () => , + }, + { + id: 'table', + title: 'Table', + category: 'ui-primitives', + description: 'Table alignment and compact body styles.', + render: () => , + }, + { + id: 'tabs', + title: 'Tabs', + category: 'ui-primitives', + description: 'Tab list and active indicator treatment.', + render: () => , + }, + { + id: 'slider', + title: 'Slider', + category: 'ui-primitives', + description: 'Slider track/thumb visuals and value binding.', + render: () => , + }, + { + id: 'checkbox', + title: 'Checkbox', + category: 'ui-primitives', + description: 'Default and highlighted checkbox variants.', + render: () => , + }, + { + id: 'popover', + title: 'Popover', + category: 'ui-primitives', + description: 'Popover overlay spacing and border treatment.', + render: () => , + }, + { + id: 'spinner', + title: 'Spinner', + category: 'ui-primitives', + description: 'Spinner sizes and animation behavior.', + render: () => , + }, + { + id: 'borrow-modal', + title: 'Borrow Modal', + category: 'modals', + description: 'Real BorrowModal with deterministic market/position fixtures.', + render: () => , + defaultCanvas: { + maxW: 1200, + pad: 24, + bg: 'surface', + }, + }, + { + id: 'network-filter', + title: 'Network Filter', + category: 'filters', + description: 'Real network selection dropdown component with compact/default variants.', + render: () => , + }, + { + id: 'asset-filter', + title: 'Asset Filter', + category: 'filters', + description: 'Token multi-select filter using realistic supported token fixtures.', + render: () => , + }, + { + id: 'account-identity', + title: 'Account Identity', + category: 'identity', + description: 'Badge, compact, and full account identity variants.', + render: () => , + }, + { + id: 'market-identity', + title: 'Market Identity', + category: 'identity', + description: 'Market identity modes: focused, minimum, and badge.', + render: () => , + }, + { + id: 'transaction-identity', + title: 'Transaction Identity', + category: 'identity', + description: 'Explorer-linked transaction hash badges.', + render: () => , + }, + { + id: 'collateral-icons-display', + title: 'Collateral Icons Display', + category: 'identity', + description: 'Overlapping collateral icons with overflow tooltip badge.', + render: () => , + }, + { + id: 'section-tag', + title: 'Section Tag', + category: 'data-display', + description: 'Bracketed section labels used in landing surfaces.', + render: () => , + }, + { + id: 'market-selector', + title: 'Market Selector', + category: 'data-display', + description: 'Single market row selector card used in selection flows.', + render: () => , + }, + { + id: 'market-details-block', + title: 'Market Details Block', + category: 'data-display', + description: 'Collapsible market details with supply/borrow preview states.', + render: () => , + }, + { + id: 'table-container-header', + title: 'Table Container Header', + category: 'data-display', + description: 'Table container with title/actions and compact rows.', + render: () => , + }, + { + id: 'table-pagination', + title: 'Table Pagination', + category: 'data-display', + description: 'Pagination control with page jump and entry count.', + render: () => , + }, + { + id: 'icon-switch', + title: 'Icon Switch', + category: 'controls', + description: 'Plain and icon-thumb switch states and sizes.', + render: () => , + }, + { + id: 'refetch-icon', + title: 'Refetch Icon', + category: 'controls', + description: 'Smooth spinning reload icon behavior during fetch states.', + render: () => , + }, + { + id: 'dropdown-menu', + title: 'Dropdown Menu', + category: 'controls', + description: 'Menu items, checkbox items, and radio groups.', + render: () => , + }, + { + id: 'toast', + title: 'Toast', + category: 'controls', + description: 'Styled success, error, and info toast triggers.', + render: () => , + }, + { + id: 'market-selection-modal', + title: 'Market Selection Modal', + category: 'modals', + description: 'Live market selection modal from the app workflow.', + render: () => , + defaultCanvas: { + maxW: 1280, + pad: 24, + bg: 'surface', + }, + }, + { + id: 'supply-modal', + title: 'Supply Modal', + category: 'modals', + description: 'Real SupplyModalV2 with deterministic market/position fixtures.', + render: () => , + defaultCanvas: { + maxW: 1200, + pad: 24, + bg: 'surface', + }, + }, +]; + +export const uiLabCategoryOrder = ['ui-primitives', 'filters', 'identity', 'data-display', 'controls', 'modals'] as const; + +export const uiLabCategoryLabel: Record<(typeof uiLabCategoryOrder)[number], string> = { + 'ui-primitives': 'UI Primitives', + filters: 'Filters & Selection', + identity: 'Identity', + 'data-display': 'Data Display', + controls: 'Controls', + modals: 'Modals', +}; diff --git a/src/features/ui-lab/types.ts b/src/features/ui-lab/types.ts new file mode 100644 index 00000000..b6285637 --- /dev/null +++ b/src/features/ui-lab/types.ts @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; + +export type UiLabCategory = 'ui-primitives' | 'filters' | 'identity' | 'data-display' | 'controls' | 'modals'; + +export type UiLabCanvasBackground = 'background' | 'surface' | 'hovered'; + +export type UiLabCanvasState = { + pad: number; + maxW: number; + bg: UiLabCanvasBackground; +}; + +export type UiLabEntry = { + id: string; + title: string; + category: UiLabCategory; + description: string; + render: () => ReactNode; + defaultCanvas?: Partial; +}; diff --git a/src/features/ui-lab/ui-lab-page-client.tsx b/src/features/ui-lab/ui-lab-page-client.tsx new file mode 100644 index 00000000..92134e23 --- /dev/null +++ b/src/features/ui-lab/ui-lab-page-client.tsx @@ -0,0 +1,418 @@ +'use client'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { useEffect, useMemo, useState, useTransition } from 'react'; +import { usePathname, useRouter, useSearchParams, type ReadonlyURLSearchParams } from 'next/navigation'; +import { useTheme } from 'next-themes'; +import { FaRegMoon } from 'react-icons/fa'; +import { LuSunMedium } from 'react-icons/lu'; +import { uiLabCategoryLabel, uiLabCategoryOrder, uiLabRegistry } from '@/features/ui-lab/registry'; +import type { UiLabCanvasBackground, UiLabCanvasState, UiLabEntry } from '@/features/ui-lab/types'; + +type UiLabPageClientProps = { + initialSlug: string[]; +}; + +type UiLabCategory = (typeof uiLabCategoryOrder)[number]; + +const DEFAULT_CANVAS: UiLabCanvasState = { + pad: 24, + maxW: 960, + bg: 'background', +}; + +const SIDEBAR_EXPANDED_WIDTH = 292; +const SIDEBAR_MINIMIZED_WIDTH = 80; + +const MIN_PAD = 0; +const MAX_PAD = 64; +const MIN_MAX_W = 360; +const MAX_MAX_W = 1440; + +const createCollapsedSections = (activeCategory?: UiLabCategory): Record => { + return Object.fromEntries(uiLabCategoryOrder.map((category) => [category, activeCategory ? category !== activeCategory : false])) as Record< + UiLabCategory, + boolean + >; +}; + +const canvasBackgroundClasses: Record = { + background: 'bg-background', + surface: 'bg-surface', + hovered: 'bg-hovered', +}; + +const categoryCompactLabel: Record = { + 'ui-primitives': 'UI', + filters: 'FS', + identity: 'ID', + 'data-display': 'DD', + controls: 'CT', + modals: 'MD', +}; + +const clamp = (value: number, min: number, max: number): number => { + return Math.min(max, Math.max(min, value)); +}; + +const parseNumericParam = (value: string | null, fallback: number, min: number, max: number): number => { + if (!value) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return clamp(parsed, min, max); +}; + +const resolveCanvasState = (entry: UiLabEntry, searchParams: ReadonlyURLSearchParams): UiLabCanvasState => { + const entryDefaults = { + ...DEFAULT_CANVAS, + ...entry.defaultCanvas, + }; + + const backgroundParam = searchParams.get('bg'); + const bg = + backgroundParam === 'background' || backgroundParam === 'surface' || backgroundParam === 'hovered' + ? backgroundParam + : entryDefaults.bg; + + return { + pad: parseNumericParam(searchParams.get('pad'), entryDefaults.pad, MIN_PAD, MAX_PAD), + maxW: parseNumericParam(searchParams.get('maxW'), entryDefaults.maxW, MIN_MAX_W, MAX_MAX_W), + bg, + }; +}; + +export function UiLabPageClient({ initialSlug }: UiLabPageClientProps): JSX.Element { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { theme, setTheme } = useTheme(); + const [isPending, startTransition] = useTransition(); + const [isSidebarMinimized, setIsSidebarMinimized] = useState(false); + const [isThemeMounted, setIsThemeMounted] = useState(false); + const [collapsedSections, setCollapsedSections] = useState>(createCollapsedSections()); + + const requestedId = initialSlug[0]; + + const requestedEntry = useMemo(() => { + if (!requestedId) return null; + return uiLabRegistry.find((entry) => entry.id === requestedId) ?? null; + }, [requestedId]); + + const selectedEntry = requestedEntry ?? uiLabRegistry[0]; + + const canvas = useMemo(() => { + if (!selectedEntry) { + return DEFAULT_CANVAS; + } + + return resolveCanvasState(selectedEntry, searchParams); + }, [selectedEntry, searchParams]); + + const groupedEntries = useMemo(() => { + const groupMap = new Map(); + + for (const category of uiLabCategoryOrder) { + groupMap.set(category, []); + } + + for (const entry of uiLabRegistry) { + const group = groupMap.get(entry.category); + if (group) { + group.push(entry); + } + } + + return groupMap; + }, []); + + const searchParamsString = searchParams.toString(); + + useEffect(() => { + if (!selectedEntry) return; + if (requestedId === selectedEntry.id) return; + + const nextPath = `/ui-lab/${selectedEntry.id}`; + const nextUrl = searchParamsString ? `${nextPath}?${searchParamsString}` : nextPath; + router.replace(nextUrl, { scroll: false }); + }, [requestedId, router, searchParamsString, selectedEntry]); + + useEffect(() => { + setCollapsedSections(createCollapsedSections(selectedEntry.category)); + }, [selectedEntry.category]); + + useEffect(() => { + setIsThemeMounted(true); + }, []); + + const navigateToEntry = (entryId: string) => { + const nextPath = `/ui-lab/${entryId}`; + const nextUrl = searchParamsString ? `${nextPath}?${searchParamsString}` : nextPath; + + startTransition(() => { + router.replace(nextUrl, { scroll: false }); + }); + }; + + const replaceSearchParams = (patch: Record) => { + const nextSearchParams = new URLSearchParams(searchParamsString); + + for (const [key, value] of Object.entries(patch)) { + nextSearchParams.set(key, value); + } + + const nextQuery = nextSearchParams.toString(); + const nextUrl = nextQuery ? `${pathname}?${nextQuery}` : pathname; + + startTransition(() => { + router.replace(nextUrl, { scroll: false }); + }); + }; + + const toggleSection = (category: UiLabCategory) => { + setCollapsedSections((prev) => ({ + ...prev, + [category]: !prev[category], + })); + }; + + const openCategory = (category: UiLabCategory) => { + const firstEntry = groupedEntries.get(category)?.[0]; + if (!firstEntry) return; + + setCollapsedSections(createCollapsedSections(category)); + setIsSidebarMinimized(false); + navigateToEntry(firstEntry.id); + }; + + if (!selectedEntry) { + return
UI Lab registry is empty.
; + } + + const canvasBgClass = canvasBackgroundClasses[canvas.bg]; + const isDarkTheme = theme === 'dark'; + + return ( +
+ +
+
+ {!isSidebarMinimized ? ( +
+

UI Lab

+

Component sections

+
+ ) : null} + + +
+ + {isSidebarMinimized ? ( +
+ {uiLabCategoryOrder.map((category) => ( + + ))} +
+ ) : ( + + )} +
+
+ +
+
+

{selectedEntry.title}

+

{selectedEntry.description}

+
+ +
+
+
+
+ {selectedEntry.render()} +
+
+
+ + +
+
+
+ ); +} From e88c68fa47c88546f33b2bb73761a788b1731850 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 23 Feb 2026 20:27:42 +0800 Subject: [PATCH 2/6] chore: cleanup --- docs/ui-lab.md | 6 +- .../components/borrowers-table.tsx | 8 +- .../ui-lab/fixtures/component-fixtures.ts | 4 +- .../ui-lab/harnesses/borrow-modal-harness.tsx | 28 +- .../ui-lab/harnesses/market-harnesses.tsx | 75 ++++- .../ui-lab/harnesses/primitives-harnesses.tsx | 73 ++++- .../ui-lab/harnesses/shared-harnesses.tsx | 112 +++++-- .../ui-lab/harnesses/supply-modal-harness.tsx | 28 +- src/features/ui-lab/registry.tsx | 284 ------------------ src/features/ui-lab/registry/controls.tsx | 37 +++ src/features/ui-lab/registry/data-display.tsx | 46 +++ src/features/ui-lab/registry/filters.tsx | 21 ++ src/features/ui-lab/registry/identity.tsx | 42 +++ src/features/ui-lab/registry/index.tsx | 52 ++++ src/features/ui-lab/registry/modals.tsx | 46 +++ src/features/ui-lab/registry/primitives.tsx | 123 ++++++++ src/features/ui-lab/types.ts | 2 + src/features/ui-lab/ui-lab-page-client.tsx | 52 +++- src/utils/tokens.ts | 16 +- 19 files changed, 682 insertions(+), 373 deletions(-) delete mode 100644 src/features/ui-lab/registry.tsx create mode 100644 src/features/ui-lab/registry/controls.tsx create mode 100644 src/features/ui-lab/registry/data-display.tsx create mode 100644 src/features/ui-lab/registry/filters.tsx create mode 100644 src/features/ui-lab/registry/identity.tsx create mode 100644 src/features/ui-lab/registry/index.tsx create mode 100644 src/features/ui-lab/registry/modals.tsx create mode 100644 src/features/ui-lab/registry/primitives.tsx diff --git a/docs/ui-lab.md b/docs/ui-lab.md index 81737d64..1f7bf259 100644 --- a/docs/ui-lab.md +++ b/docs/ui-lab.md @@ -40,7 +40,7 @@ Share the full URL to keep the same component and canvas setup. 1. Add fixture data if needed in `src/features/ui-lab/fixtures`. 2. Add a harness in `src/features/ui-lab/harnesses`. -3. Register an entry in `src/features/ui-lab/registry.tsx`. +3. Register an entry in the relevant section file under `src/features/ui-lab/registry/`. 4. Open `/ui-lab/` and verify it renders. ## Notes @@ -50,3 +50,7 @@ Share the full URL to keep the same component and canvas setup. - Shared realistic fixtures live in: - `src/features/ui-lab/fixtures/market-fixtures.ts` - `src/features/ui-lab/fixtures/component-fixtures.ts` +- Each entry has a `dataMode`: + - `fixture`: deterministic local fixtures only + - `hybrid`: mostly fixture-based, but may still call some shared hooks + - `live`: intentionally uses live app data/query pipeline diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 8feaa1d2..9d9bfc7a 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -145,9 +145,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen /> } > - - DAYS TO LIQ. - + DAYS TO LIQ. % OF BORROW @@ -172,9 +170,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen const percentDisplay = percentOfBorrow < 0.01 && percentOfBorrow > 0 ? '<0.01%' : `${percentOfBorrow.toFixed(2)}%`; // Days to liquidation display - const daysDisplay = borrower.daysToLiquidation !== null - ? `${borrower.daysToLiquidation}` - : '—'; + const daysDisplay = borrower.daysToLiquidation !== null ? `${borrower.daysToLiquidation}` : '—'; return ( diff --git a/src/features/ui-lab/fixtures/component-fixtures.ts b/src/features/ui-lab/fixtures/component-fixtures.ts index d5bc883b..381fbd27 100644 --- a/src/features/ui-lab/fixtures/component-fixtures.ts +++ b/src/features/ui-lab/fixtures/component-fixtures.ts @@ -18,7 +18,9 @@ const buildAssetSelectionKey = (token: AssetFilterItem): string => { export const createUiLabAssetFilterItems = (): AssetFilterItem[] => { const symbolSet = new Set(uiLabPreferredAssetSymbols); - return supportedTokens.filter((token) => symbolSet.has(token.symbol) && hasMainnetAddress(token)).slice(0, uiLabPreferredAssetSymbols.length); + return supportedTokens + .filter((token) => symbolSet.has(token.symbol) && hasMainnetAddress(token)) + .slice(0, uiLabPreferredAssetSymbols.length); }; export const createUiLabDefaultAssetSelection = (items: AssetFilterItem[]): string[] => { diff --git a/src/features/ui-lab/harnesses/borrow-modal-harness.tsx b/src/features/ui-lab/harnesses/borrow-modal-harness.tsx index c1010567..c7869f9a 100644 --- a/src/features/ui-lab/harnesses/borrow-modal-harness.tsx +++ b/src/features/ui-lab/harnesses/borrow-modal-harness.tsx @@ -21,21 +21,39 @@ export function BorrowModalHarness(): JSX.Element { return (
- - - -
-

Use this harness to adjust real `BorrowModal` spacing/layout with deterministic fixture props.

+

+ Use this harness to adjust real `BorrowModal` spacing/layout with deterministic fixture props. +

{isOpen ? (

Selected chain: {selectedNetwork ?? 'All networks'}

- +
- +
@@ -59,7 +71,13 @@ export function MarketIdentityHarness(): JSX.Element {

Focused

- +
@@ -76,7 +94,11 @@ export function MarketIdentityHarness(): JSX.Element {

Badge

- +
); @@ -90,13 +112,25 @@ export function MarketDetailsBlockHarness(): JSX.Element { return (
- - -
@@ -130,7 +164,11 @@ export function MarketSelectorHarness(): JSX.Element { return (
-

Added: {addedMarketIds.length}

@@ -139,7 +177,12 @@ export function MarketSelectorHarness(): JSX.Element { {markets.map((market) => { const isAdded = addedMarketIds.includes(market.uniqueKey); return ( - handleAdd(market.uniqueKey)} /> + handleAdd(market.uniqueKey)} + /> ); })}
@@ -157,10 +200,18 @@ export function MarketSelectionModalHarness(): JSX.Element { return (
- -
diff --git a/src/features/ui-lab/harnesses/primitives-harnesses.tsx b/src/features/ui-lab/harnesses/primitives-harnesses.tsx index 05a6af3b..d6098752 100644 --- a/src/features/ui-lab/harnesses/primitives-harnesses.tsx +++ b/src/features/ui-lab/harnesses/primitives-harnesses.tsx @@ -23,10 +23,16 @@ export function ButtonHarness(): JSX.Element { - -
@@ -53,10 +59,16 @@ export function CardHarness(): JSX.Element {
- - @@ -67,9 +79,15 @@ export function CardHarness(): JSX.Element { export function TooltipHarness(): JSX.Element { return (
- + - @@ -108,7 +126,12 @@ export function InputHarness(): JSX.Element { return (
- USDC} /> + USDC} + />

Selected network: {network}

- @@ -188,7 +214,10 @@ export function TableHarness(): JSX.Element { export function TabsHarness(): JSX.Element { return ( - + Borrow Repay @@ -205,7 +234,13 @@ export function SliderHarness(): JSX.Element { return (

Utilization target: {value[0]}%

- +
); } @@ -215,8 +250,17 @@ export function CheckboxHarness(): JSX.Element { return (
- setChecked(next === true)} label="Enable Permit2 for this flow" /> - setChecked(next === true)} label="Use highlighted style" /> + setChecked(next === true)} + label="Enable Permit2 for this flow" + /> + setChecked(next === true)} + label="Use highlighted style" + />
); } @@ -226,7 +270,10 @@ export function PopoverHarness(): JSX.Element {
- diff --git a/src/features/ui-lab/harnesses/shared-harnesses.tsx b/src/features/ui-lab/harnesses/shared-harnesses.tsx index 61881abe..8d3f0be2 100644 --- a/src/features/ui-lab/harnesses/shared-harnesses.tsx +++ b/src/features/ui-lab/harnesses/shared-harnesses.tsx @@ -78,8 +78,15 @@ export function AccountIdentityHarness(): JSX.Element { export function TransactionIdentityHarness(): JSX.Element { return (
- - + +
); } @@ -89,12 +96,22 @@ export function CollateralIconsDisplayHarness(): JSX.Element {

Compact

- +

Overflow + Tooltip

- +
); @@ -118,12 +135,23 @@ export function IconSwitchHarness(): JSX.Element {

Plain switch

- +

Icon switch

- +
); @@ -142,8 +170,16 @@ export function RefetchIconHarness(): JSX.Element { return (
-

The icon completes the active spin cycle before stopping.

@@ -159,7 +195,10 @@ export function DropdownMenuHarness(): JSX.Element {
- @@ -167,15 +206,27 @@ export function DropdownMenuHarness(): JSX.Element { }>Refresh }>Mark as reviewed - setShowRiskSignals(checked === true)}> + setShowRiskSignals(checked === true)} + > Show risk signals - setViewMode(value as 'table' | 'cards')}> - }> + setViewMode(value as 'table' | 'cards')} + > + } + > Table layout - }> + } + > Card layout @@ -211,12 +262,23 @@ export function TableContainerWithHeaderHarness(): JSX.Element { title="Market Activity" actions={ <> - - @@ -273,13 +335,25 @@ export function ToastHarness(): JSX.Element { return (
- - -
diff --git a/src/features/ui-lab/harnesses/supply-modal-harness.tsx b/src/features/ui-lab/harnesses/supply-modal-harness.tsx index 0c748a92..f89435d0 100644 --- a/src/features/ui-lab/harnesses/supply-modal-harness.tsx +++ b/src/features/ui-lab/harnesses/supply-modal-harness.tsx @@ -20,21 +20,39 @@ export function SupplyModalHarness(): JSX.Element { return (
- - - -
-

Use this harness to tune `SupplyModalV2` layout while keeping market/position fixtures stable.

+

+ Use this harness to tune `SupplyModalV2` layout while keeping market/position fixtures stable. +

{isOpen ? ( , - }, - { - id: 'card', - title: 'Card', - category: 'ui-primitives', - description: 'Card container and header/body/footer spacing.', - render: () => , - }, - { - id: 'tooltip', - title: 'Tooltip', - category: 'ui-primitives', - description: 'Tooltip content and trigger wrapping behavior.', - render: () => , - }, - { - id: 'tooltip-content', - title: 'Tooltip Content', - category: 'ui-primitives', - description: 'Shared tooltip content layouts from src/components/shared/tooltip-content.tsx.', - render: () => , - }, - { - id: 'input', - title: 'Input', - category: 'ui-primitives', - description: 'Input field states, labels, and error styling.', - render: () => , - }, - { - id: 'select', - title: 'Select', - category: 'ui-primitives', - description: 'Radix select trigger and dropdown content.', - render: () => , - }, - { - id: 'badge', - title: 'Badge', - category: 'ui-primitives', - description: 'Badge variants and compact spacing checks.', - render: () => , - }, - { - id: 'table', - title: 'Table', - category: 'ui-primitives', - description: 'Table alignment and compact body styles.', - render: () => , - }, - { - id: 'tabs', - title: 'Tabs', - category: 'ui-primitives', - description: 'Tab list and active indicator treatment.', - render: () => , - }, - { - id: 'slider', - title: 'Slider', - category: 'ui-primitives', - description: 'Slider track/thumb visuals and value binding.', - render: () => , - }, - { - id: 'checkbox', - title: 'Checkbox', - category: 'ui-primitives', - description: 'Default and highlighted checkbox variants.', - render: () => , - }, - { - id: 'popover', - title: 'Popover', - category: 'ui-primitives', - description: 'Popover overlay spacing and border treatment.', - render: () => , - }, - { - id: 'spinner', - title: 'Spinner', - category: 'ui-primitives', - description: 'Spinner sizes and animation behavior.', - render: () => , - }, - { - id: 'borrow-modal', - title: 'Borrow Modal', - category: 'modals', - description: 'Real BorrowModal with deterministic market/position fixtures.', - render: () => , - defaultCanvas: { - maxW: 1200, - pad: 24, - bg: 'surface', - }, - }, - { - id: 'network-filter', - title: 'Network Filter', - category: 'filters', - description: 'Real network selection dropdown component with compact/default variants.', - render: () => , - }, - { - id: 'asset-filter', - title: 'Asset Filter', - category: 'filters', - description: 'Token multi-select filter using realistic supported token fixtures.', - render: () => , - }, - { - id: 'account-identity', - title: 'Account Identity', - category: 'identity', - description: 'Badge, compact, and full account identity variants.', - render: () => , - }, - { - id: 'market-identity', - title: 'Market Identity', - category: 'identity', - description: 'Market identity modes: focused, minimum, and badge.', - render: () => , - }, - { - id: 'transaction-identity', - title: 'Transaction Identity', - category: 'identity', - description: 'Explorer-linked transaction hash badges.', - render: () => , - }, - { - id: 'collateral-icons-display', - title: 'Collateral Icons Display', - category: 'identity', - description: 'Overlapping collateral icons with overflow tooltip badge.', - render: () => , - }, - { - id: 'section-tag', - title: 'Section Tag', - category: 'data-display', - description: 'Bracketed section labels used in landing surfaces.', - render: () => , - }, - { - id: 'market-selector', - title: 'Market Selector', - category: 'data-display', - description: 'Single market row selector card used in selection flows.', - render: () => , - }, - { - id: 'market-details-block', - title: 'Market Details Block', - category: 'data-display', - description: 'Collapsible market details with supply/borrow preview states.', - render: () => , - }, - { - id: 'table-container-header', - title: 'Table Container Header', - category: 'data-display', - description: 'Table container with title/actions and compact rows.', - render: () => , - }, - { - id: 'table-pagination', - title: 'Table Pagination', - category: 'data-display', - description: 'Pagination control with page jump and entry count.', - render: () => , - }, - { - id: 'icon-switch', - title: 'Icon Switch', - category: 'controls', - description: 'Plain and icon-thumb switch states and sizes.', - render: () => , - }, - { - id: 'refetch-icon', - title: 'Refetch Icon', - category: 'controls', - description: 'Smooth spinning reload icon behavior during fetch states.', - render: () => , - }, - { - id: 'dropdown-menu', - title: 'Dropdown Menu', - category: 'controls', - description: 'Menu items, checkbox items, and radio groups.', - render: () => , - }, - { - id: 'toast', - title: 'Toast', - category: 'controls', - description: 'Styled success, error, and info toast triggers.', - render: () => , - }, - { - id: 'market-selection-modal', - title: 'Market Selection Modal', - category: 'modals', - description: 'Live market selection modal from the app workflow.', - render: () => , - defaultCanvas: { - maxW: 1280, - pad: 24, - bg: 'surface', - }, - }, - { - id: 'supply-modal', - title: 'Supply Modal', - category: 'modals', - description: 'Real SupplyModalV2 with deterministic market/position fixtures.', - render: () => , - defaultCanvas: { - maxW: 1200, - pad: 24, - bg: 'surface', - }, - }, -]; - -export const uiLabCategoryOrder = ['ui-primitives', 'filters', 'identity', 'data-display', 'controls', 'modals'] as const; - -export const uiLabCategoryLabel: Record<(typeof uiLabCategoryOrder)[number], string> = { - 'ui-primitives': 'UI Primitives', - filters: 'Filters & Selection', - identity: 'Identity', - 'data-display': 'Data Display', - controls: 'Controls', - modals: 'Modals', -}; diff --git a/src/features/ui-lab/registry/controls.tsx b/src/features/ui-lab/registry/controls.tsx new file mode 100644 index 00000000..ac90eeb9 --- /dev/null +++ b/src/features/ui-lab/registry/controls.tsx @@ -0,0 +1,37 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { DropdownMenuHarness, IconSwitchHarness, RefetchIconHarness, ToastHarness } from '@/features/ui-lab/harnesses/shared-harnesses'; + +export const controlEntries: UiLabEntry[] = [ + { + id: 'icon-switch', + title: 'Icon Switch', + category: 'controls', + dataMode: 'fixture', + description: 'Plain and icon-thumb switch states and sizes.', + render: () => , + }, + { + id: 'refetch-icon', + title: 'Refetch Icon', + category: 'controls', + dataMode: 'fixture', + description: 'Smooth spinning reload icon behavior during fetch states.', + render: () => , + }, + { + id: 'dropdown-menu', + title: 'Dropdown Menu', + category: 'controls', + dataMode: 'fixture', + description: 'Menu items, checkbox items, and radio groups.', + render: () => , + }, + { + id: 'toast', + title: 'Toast', + category: 'controls', + dataMode: 'fixture', + description: 'Styled success, error, and info toast triggers.', + render: () => , + }, +]; diff --git a/src/features/ui-lab/registry/data-display.tsx b/src/features/ui-lab/registry/data-display.tsx new file mode 100644 index 00000000..9bf11dca --- /dev/null +++ b/src/features/ui-lab/registry/data-display.tsx @@ -0,0 +1,46 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { MarketDetailsBlockHarness, MarketSelectorHarness } from '@/features/ui-lab/harnesses/market-harnesses'; +import { SectionTagHarness, TableContainerWithHeaderHarness, TablePaginationHarness } from '@/features/ui-lab/harnesses/shared-harnesses'; + +export const dataDisplayEntries: UiLabEntry[] = [ + { + id: 'section-tag', + title: 'Section Tag', + category: 'data-display', + dataMode: 'fixture', + description: 'Bracketed section labels used in landing surfaces.', + render: () => , + }, + { + id: 'market-selector', + title: 'Market Selector', + category: 'data-display', + dataMode: 'fixture', + description: 'Single market row selector card used in selection flows.', + render: () => , + }, + { + id: 'market-details-block', + title: 'Market Details Block', + category: 'data-display', + dataMode: 'hybrid', + description: 'Collapsible market details with supply/borrow preview states.', + render: () => , + }, + { + id: 'table-container-header', + title: 'Table Container Header', + category: 'data-display', + dataMode: 'fixture', + description: 'Table container with title/actions and compact rows.', + render: () => , + }, + { + id: 'table-pagination', + title: 'Table Pagination', + category: 'data-display', + dataMode: 'fixture', + description: 'Pagination control with page jump and entry count.', + render: () => , + }, +]; diff --git a/src/features/ui-lab/registry/filters.tsx b/src/features/ui-lab/registry/filters.tsx new file mode 100644 index 00000000..94f7abb3 --- /dev/null +++ b/src/features/ui-lab/registry/filters.tsx @@ -0,0 +1,21 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { AssetFilterHarness, NetworkFilterHarness } from '@/features/ui-lab/harnesses/market-harnesses'; + +export const filterEntries: UiLabEntry[] = [ + { + id: 'network-filter', + title: 'Network Filter', + category: 'filters', + dataMode: 'fixture', + description: 'Real network selection dropdown component with compact/default variants.', + render: () => , + }, + { + id: 'asset-filter', + title: 'Asset Filter', + category: 'filters', + dataMode: 'fixture', + description: 'Token multi-select filter using realistic supported token fixtures.', + render: () => , + }, +]; diff --git a/src/features/ui-lab/registry/identity.tsx b/src/features/ui-lab/registry/identity.tsx new file mode 100644 index 00000000..b00f3498 --- /dev/null +++ b/src/features/ui-lab/registry/identity.tsx @@ -0,0 +1,42 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { MarketIdentityHarness } from '@/features/ui-lab/harnesses/market-harnesses'; +import { + AccountIdentityHarness, + CollateralIconsDisplayHarness, + TransactionIdentityHarness, +} from '@/features/ui-lab/harnesses/shared-harnesses'; + +export const identityEntries: UiLabEntry[] = [ + { + id: 'account-identity', + title: 'Account Identity', + category: 'identity', + dataMode: 'hybrid', + description: 'Badge, compact, and full account identity variants.', + render: () => , + }, + { + id: 'market-identity', + title: 'Market Identity', + category: 'identity', + dataMode: 'hybrid', + description: 'Market identity modes: focused, minimum, and badge.', + render: () => , + }, + { + id: 'transaction-identity', + title: 'Transaction Identity', + category: 'identity', + dataMode: 'fixture', + description: 'Explorer-linked transaction hash badges.', + render: () => , + }, + { + id: 'collateral-icons-display', + title: 'Collateral Icons Display', + category: 'identity', + dataMode: 'fixture', + description: 'Overlapping collateral icons with overflow tooltip badge.', + render: () => , + }, +]; diff --git a/src/features/ui-lab/registry/index.tsx b/src/features/ui-lab/registry/index.tsx new file mode 100644 index 00000000..f0788959 --- /dev/null +++ b/src/features/ui-lab/registry/index.tsx @@ -0,0 +1,52 @@ +import type { UiLabCategory, UiLabDataMode, UiLabEntry } from '@/features/ui-lab/types'; +import { controlEntries } from '@/features/ui-lab/registry/controls'; +import { dataDisplayEntries } from '@/features/ui-lab/registry/data-display'; +import { filterEntries } from '@/features/ui-lab/registry/filters'; +import { identityEntries } from '@/features/ui-lab/registry/identity'; +import { modalEntries } from '@/features/ui-lab/registry/modals'; +import { primitiveEntries } from '@/features/ui-lab/registry/primitives'; + +const DEFAULT_DATA_MODE: UiLabDataMode = 'fixture'; + +const withDefaultDataMode = (entries: UiLabEntry[]): UiLabEntry[] => { + return entries.map((entry) => ({ + ...entry, + dataMode: entry.dataMode ?? DEFAULT_DATA_MODE, + })); +}; + +const assertUniqueEntryIds = (entries: UiLabEntry[]): void => { + const seen = new Set(); + + for (const entry of entries) { + if (seen.has(entry.id)) { + throw new Error(`Duplicate UI Lab entry id detected: "${entry.id}"`); + } + seen.add(entry.id); + } +}; + +const registrySections: UiLabEntry[][] = [ + primitiveEntries, + filterEntries, + identityEntries, + dataDisplayEntries, + controlEntries, + modalEntries, +]; + +const flatEntries = registrySections.flat(); +assertUniqueEntryIds(flatEntries); + +export const uiLabRegistry: UiLabEntry[] = withDefaultDataMode(flatEntries); + +export const uiLabCategoryOrder: UiLabCategory[] = ['ui-primitives', 'filters', 'identity', 'data-display', 'controls', 'modals']; + +export const uiLabCategoryLabel: Record = { + 'ui-primitives': 'UI Primitives', + filters: 'Filters & Selection', + identity: 'Identity', + 'data-display': 'Data Display', + controls: 'Controls', + modals: 'Modals', +}; diff --git a/src/features/ui-lab/registry/modals.tsx b/src/features/ui-lab/registry/modals.tsx new file mode 100644 index 00000000..d456aeb8 --- /dev/null +++ b/src/features/ui-lab/registry/modals.tsx @@ -0,0 +1,46 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { BorrowModalHarness } from '@/features/ui-lab/harnesses/borrow-modal-harness'; +import { MarketSelectionModalHarness } from '@/features/ui-lab/harnesses/market-harnesses'; +import { SupplyModalHarness } from '@/features/ui-lab/harnesses/supply-modal-harness'; + +export const modalEntries: UiLabEntry[] = [ + { + id: 'borrow-modal', + title: 'Borrow Modal', + category: 'modals', + dataMode: 'hybrid', + description: 'Real BorrowModal with deterministic market/position fixtures.', + render: () => , + defaultCanvas: { + maxW: 1200, + pad: 24, + bg: 'surface', + }, + }, + { + id: 'market-selection-modal', + title: 'Market Selection Modal', + category: 'modals', + dataMode: 'live', + description: 'Live market selection modal from the app workflow.', + render: () => , + defaultCanvas: { + maxW: 1280, + pad: 24, + bg: 'surface', + }, + }, + { + id: 'supply-modal', + title: 'Supply Modal', + category: 'modals', + dataMode: 'hybrid', + description: 'Real SupplyModalV2 with deterministic market/position fixtures.', + render: () => , + defaultCanvas: { + maxW: 1200, + pad: 24, + bg: 'surface', + }, + }, +]; diff --git a/src/features/ui-lab/registry/primitives.tsx b/src/features/ui-lab/registry/primitives.tsx new file mode 100644 index 00000000..76771717 --- /dev/null +++ b/src/features/ui-lab/registry/primitives.tsx @@ -0,0 +1,123 @@ +import type { UiLabEntry } from '@/features/ui-lab/types'; +import { + BadgeHarness, + ButtonHarness, + CardHarness, + CheckboxHarness, + InputHarness, + PopoverHarness, + SelectHarness, + SliderHarness, + SpinnerHarness, + TableHarness, + TabsHarness, + TooltipContentHarness, + TooltipHarness, +} from '@/features/ui-lab/harnesses/primitives-harnesses'; + +export const primitiveEntries: UiLabEntry[] = [ + { + id: 'button', + title: 'Button', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Primary, surface, default, and ghost variants.', + render: () => , + }, + { + id: 'card', + title: 'Card', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Card container and header/body/footer spacing.', + render: () => , + }, + { + id: 'tooltip', + title: 'Tooltip', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Tooltip content and trigger wrapping behavior.', + render: () => , + }, + { + id: 'tooltip-content', + title: 'Tooltip Content', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Shared tooltip content layouts from src/components/shared/tooltip-content.tsx.', + render: () => , + }, + { + id: 'input', + title: 'Input', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Input field states, labels, and error styling.', + render: () => , + }, + { + id: 'select', + title: 'Select', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Radix select trigger and dropdown content.', + render: () => , + }, + { + id: 'badge', + title: 'Badge', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Badge variants and compact spacing checks.', + render: () => , + }, + { + id: 'table', + title: 'Table', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Table alignment and compact body styles.', + render: () => , + }, + { + id: 'tabs', + title: 'Tabs', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Tab list and active indicator treatment.', + render: () => , + }, + { + id: 'slider', + title: 'Slider', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Slider track/thumb visuals and value binding.', + render: () => , + }, + { + id: 'checkbox', + title: 'Checkbox', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Default and highlighted checkbox variants.', + render: () => , + }, + { + id: 'popover', + title: 'Popover', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Popover overlay spacing and border treatment.', + render: () => , + }, + { + id: 'spinner', + title: 'Spinner', + category: 'ui-primitives', + dataMode: 'fixture', + description: 'Spinner sizes and animation behavior.', + render: () => , + }, +]; diff --git a/src/features/ui-lab/types.ts b/src/features/ui-lab/types.ts index b6285637..9baf1da9 100644 --- a/src/features/ui-lab/types.ts +++ b/src/features/ui-lab/types.ts @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; export type UiLabCategory = 'ui-primitives' | 'filters' | 'identity' | 'data-display' | 'controls' | 'modals'; export type UiLabCanvasBackground = 'background' | 'surface' | 'hovered'; +export type UiLabDataMode = 'fixture' | 'hybrid' | 'live'; export type UiLabCanvasState = { pad: number; @@ -14,6 +15,7 @@ export type UiLabEntry = { id: string; title: string; category: UiLabCategory; + dataMode?: UiLabDataMode; description: string; render: () => ReactNode; defaultCanvas?: Partial; diff --git a/src/features/ui-lab/ui-lab-page-client.tsx b/src/features/ui-lab/ui-lab-page-client.tsx index 92134e23..6819d800 100644 --- a/src/features/ui-lab/ui-lab-page-client.tsx +++ b/src/features/ui-lab/ui-lab-page-client.tsx @@ -7,7 +7,7 @@ import { useTheme } from 'next-themes'; import { FaRegMoon } from 'react-icons/fa'; import { LuSunMedium } from 'react-icons/lu'; import { uiLabCategoryLabel, uiLabCategoryOrder, uiLabRegistry } from '@/features/ui-lab/registry'; -import type { UiLabCanvasBackground, UiLabCanvasState, UiLabEntry } from '@/features/ui-lab/types'; +import type { UiLabCanvasBackground, UiLabCanvasState, UiLabDataMode, UiLabEntry } from '@/features/ui-lab/types'; type UiLabPageClientProps = { initialSlug: string[]; @@ -30,10 +30,9 @@ const MIN_MAX_W = 360; const MAX_MAX_W = 1440; const createCollapsedSections = (activeCategory?: UiLabCategory): Record => { - return Object.fromEntries(uiLabCategoryOrder.map((category) => [category, activeCategory ? category !== activeCategory : false])) as Record< - UiLabCategory, - boolean - >; + return Object.fromEntries( + uiLabCategoryOrder.map((category) => [category, activeCategory ? category !== activeCategory : false]), + ) as Record; }; const canvasBackgroundClasses: Record = { @@ -51,6 +50,18 @@ const categoryCompactLabel: Record = { modals: 'MD', }; +const dataModeLabel: Record = { + fixture: 'Fixture data', + hybrid: 'Hybrid data', + live: 'Live data', +}; + +const dataModeClasses: Record = { + fixture: 'border-green-500/30 bg-green-500/10 text-green-500', + hybrid: 'border-amber-500/30 bg-amber-500/10 text-amber-500', + live: 'border-red-500/30 bg-red-500/10 text-red-500', +}; + const clamp = (value: number, min: number, max: number): number => { return Math.min(max, Math.max(min, value)); }; @@ -70,9 +81,7 @@ const resolveCanvasState = (entry: UiLabEntry, searchParams: ReadonlyURLSearchPa const backgroundParam = searchParams.get('bg'); const bg = - backgroundParam === 'background' || backgroundParam === 'surface' || backgroundParam === 'hovered' - ? backgroundParam - : entryDefaults.bg; + backgroundParam === 'background' || backgroundParam === 'surface' || backgroundParam === 'hovered' ? backgroundParam : entryDefaults.bg; return { pad: parseNumericParam(searchParams.get('pad'), entryDefaults.pad, MIN_PAD, MAX_PAD), @@ -190,6 +199,7 @@ export function UiLabPageClient({ initialSlug }: UiLabPageClientProps): JSX.Elem const canvasBgClass = canvasBackgroundClasses[canvas.bg]; const isDarkTheme = theme === 'dark'; + const selectedDataMode = selectedEntry.dataMode ?? 'fixture'; return (
@@ -200,12 +210,12 @@ export function UiLabPageClient({ initialSlug }: UiLabPageClientProps): JSX.Elem >
- {!isSidebarMinimized ? ( + {isSidebarMinimized ? null : (

UI Lab

Component sections

- ) : null} + )} - {!isCollapsed ? ( + {isCollapsed ? null : ( - ) : null} + )} ); @@ -300,12 +313,21 @@ export function UiLabPageClient({ initialSlug }: UiLabPageClientProps): JSX.Elem

{selectedEntry.title}

{selectedEntry.description}

+ + {dataModeLabel[selectedDataMode]} +
-
-
+
+
{selectedEntry.render()}
diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index d8ecc717..d120f85f 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -793,33 +793,25 @@ const supportedTokens = [ symbol: 'cbDOGE', img: require('../imgs/tokens/cbdoge.png') as string, decimals: 8, - networks: [ - { chain: base, address: '0xcbD06E5A2B0C65597161de254AA074E489dEb510' }, - ] + networks: [{ chain: base, address: '0xcbD06E5A2B0C65597161de254AA074E489dEb510' }], }, { symbol: 'cbLTC', img: require('../imgs/tokens/cbltc.png') as string, decimals: 8, - networks: [ - { chain: base, address: '0xcb17C9Db87B595717C857a08468793f5bAb6445F' }, - ] + networks: [{ chain: base, address: '0xcb17C9Db87B595717C857a08468793f5bAb6445F' }], }, { symbol: 'cbXRP', img: require('../imgs/tokens/cbxrp.png') as string, decimals: 6, - networks: [ - { chain: base, address: '0xcb585250f852C6c6bf90434AB21A00f02833a4af' }, - ] + networks: [{ chain: base, address: '0xcb585250f852C6c6bf90434AB21A00f02833a4af' }], }, { symbol: 'cbADA', img: require('../imgs/tokens/cbada.png') as string, decimals: 6, - networks: [ - { chain: base, address: '0xcbADA732173e39521CDBE8bf59a6Dc85A9fc7b8c' }, - ] + networks: [{ chain: base, address: '0xcbADA732173e39521CDBE8bf59a6Dc85A9fc7b8c' }], }, { symbol: 'wstHYPE', From 01e367fc8ca7526acb7e3ea307400c9ca655e786 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 23 Feb 2026 20:31:40 +0800 Subject: [PATCH 3/6] fix: build --- app/ui-lab/[[...slug]]/page.tsx | 14 +++++--------- src/features/ui-lab/registry/controls.tsx | 4 ---- src/features/ui-lab/registry/data-display.tsx | 4 ---- src/features/ui-lab/registry/filters.tsx | 2 -- src/features/ui-lab/registry/identity.tsx | 2 -- src/features/ui-lab/registry/primitives.tsx | 13 ------------- 6 files changed, 5 insertions(+), 34 deletions(-) diff --git a/app/ui-lab/[[...slug]]/page.tsx b/app/ui-lab/[[...slug]]/page.tsx index c25d9d5d..c6469b6b 100644 --- a/app/ui-lab/[[...slug]]/page.tsx +++ b/app/ui-lab/[[...slug]]/page.tsx @@ -2,13 +2,9 @@ import { notFound } from 'next/navigation'; import { UiLabPageClient } from '@/features/ui-lab/ui-lab-page-client'; type UiLabPageProps = { - params: - | { - slug?: string[]; - } - | Promise<{ - slug?: string[]; - }>; + params?: Promise<{ + slug?: string[]; + }>; }; export default async function UiLabPage({ params }: UiLabPageProps) { @@ -18,7 +14,7 @@ export default async function UiLabPage({ params }: UiLabPageProps) { notFound(); } - const resolvedParams = await Promise.resolve(params); + const resolvedParams = params ? await params : undefined; - return ; + return ; } diff --git a/src/features/ui-lab/registry/controls.tsx b/src/features/ui-lab/registry/controls.tsx index ac90eeb9..3ee78b30 100644 --- a/src/features/ui-lab/registry/controls.tsx +++ b/src/features/ui-lab/registry/controls.tsx @@ -6,7 +6,6 @@ export const controlEntries: UiLabEntry[] = [ id: 'icon-switch', title: 'Icon Switch', category: 'controls', - dataMode: 'fixture', description: 'Plain and icon-thumb switch states and sizes.', render: () => , }, @@ -14,7 +13,6 @@ export const controlEntries: UiLabEntry[] = [ id: 'refetch-icon', title: 'Refetch Icon', category: 'controls', - dataMode: 'fixture', description: 'Smooth spinning reload icon behavior during fetch states.', render: () => , }, @@ -22,7 +20,6 @@ export const controlEntries: UiLabEntry[] = [ id: 'dropdown-menu', title: 'Dropdown Menu', category: 'controls', - dataMode: 'fixture', description: 'Menu items, checkbox items, and radio groups.', render: () => , }, @@ -30,7 +27,6 @@ export const controlEntries: UiLabEntry[] = [ id: 'toast', title: 'Toast', category: 'controls', - dataMode: 'fixture', description: 'Styled success, error, and info toast triggers.', render: () => , }, diff --git a/src/features/ui-lab/registry/data-display.tsx b/src/features/ui-lab/registry/data-display.tsx index 9bf11dca..926582e6 100644 --- a/src/features/ui-lab/registry/data-display.tsx +++ b/src/features/ui-lab/registry/data-display.tsx @@ -7,7 +7,6 @@ export const dataDisplayEntries: UiLabEntry[] = [ id: 'section-tag', title: 'Section Tag', category: 'data-display', - dataMode: 'fixture', description: 'Bracketed section labels used in landing surfaces.', render: () => , }, @@ -15,7 +14,6 @@ export const dataDisplayEntries: UiLabEntry[] = [ id: 'market-selector', title: 'Market Selector', category: 'data-display', - dataMode: 'fixture', description: 'Single market row selector card used in selection flows.', render: () => , }, @@ -31,7 +29,6 @@ export const dataDisplayEntries: UiLabEntry[] = [ id: 'table-container-header', title: 'Table Container Header', category: 'data-display', - dataMode: 'fixture', description: 'Table container with title/actions and compact rows.', render: () => , }, @@ -39,7 +36,6 @@ export const dataDisplayEntries: UiLabEntry[] = [ id: 'table-pagination', title: 'Table Pagination', category: 'data-display', - dataMode: 'fixture', description: 'Pagination control with page jump and entry count.', render: () => , }, diff --git a/src/features/ui-lab/registry/filters.tsx b/src/features/ui-lab/registry/filters.tsx index 94f7abb3..9c9176d9 100644 --- a/src/features/ui-lab/registry/filters.tsx +++ b/src/features/ui-lab/registry/filters.tsx @@ -6,7 +6,6 @@ export const filterEntries: UiLabEntry[] = [ id: 'network-filter', title: 'Network Filter', category: 'filters', - dataMode: 'fixture', description: 'Real network selection dropdown component with compact/default variants.', render: () => , }, @@ -14,7 +13,6 @@ export const filterEntries: UiLabEntry[] = [ id: 'asset-filter', title: 'Asset Filter', category: 'filters', - dataMode: 'fixture', description: 'Token multi-select filter using realistic supported token fixtures.', render: () => , }, diff --git a/src/features/ui-lab/registry/identity.tsx b/src/features/ui-lab/registry/identity.tsx index b00f3498..6e68c196 100644 --- a/src/features/ui-lab/registry/identity.tsx +++ b/src/features/ui-lab/registry/identity.tsx @@ -27,7 +27,6 @@ export const identityEntries: UiLabEntry[] = [ id: 'transaction-identity', title: 'Transaction Identity', category: 'identity', - dataMode: 'fixture', description: 'Explorer-linked transaction hash badges.', render: () => , }, @@ -35,7 +34,6 @@ export const identityEntries: UiLabEntry[] = [ id: 'collateral-icons-display', title: 'Collateral Icons Display', category: 'identity', - dataMode: 'fixture', description: 'Overlapping collateral icons with overflow tooltip badge.', render: () => , }, diff --git a/src/features/ui-lab/registry/primitives.tsx b/src/features/ui-lab/registry/primitives.tsx index 76771717..deebbd1d 100644 --- a/src/features/ui-lab/registry/primitives.tsx +++ b/src/features/ui-lab/registry/primitives.tsx @@ -20,7 +20,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'button', title: 'Button', category: 'ui-primitives', - dataMode: 'fixture', description: 'Primary, surface, default, and ghost variants.', render: () => , }, @@ -28,7 +27,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'card', title: 'Card', category: 'ui-primitives', - dataMode: 'fixture', description: 'Card container and header/body/footer spacing.', render: () => , }, @@ -36,7 +34,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'tooltip', title: 'Tooltip', category: 'ui-primitives', - dataMode: 'fixture', description: 'Tooltip content and trigger wrapping behavior.', render: () => , }, @@ -44,7 +41,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'tooltip-content', title: 'Tooltip Content', category: 'ui-primitives', - dataMode: 'fixture', description: 'Shared tooltip content layouts from src/components/shared/tooltip-content.tsx.', render: () => , }, @@ -52,7 +48,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'input', title: 'Input', category: 'ui-primitives', - dataMode: 'fixture', description: 'Input field states, labels, and error styling.', render: () => , }, @@ -60,7 +55,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'select', title: 'Select', category: 'ui-primitives', - dataMode: 'fixture', description: 'Radix select trigger and dropdown content.', render: () => , }, @@ -68,7 +62,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'badge', title: 'Badge', category: 'ui-primitives', - dataMode: 'fixture', description: 'Badge variants and compact spacing checks.', render: () => , }, @@ -76,7 +69,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'table', title: 'Table', category: 'ui-primitives', - dataMode: 'fixture', description: 'Table alignment and compact body styles.', render: () => , }, @@ -84,7 +76,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'tabs', title: 'Tabs', category: 'ui-primitives', - dataMode: 'fixture', description: 'Tab list and active indicator treatment.', render: () => , }, @@ -92,7 +83,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'slider', title: 'Slider', category: 'ui-primitives', - dataMode: 'fixture', description: 'Slider track/thumb visuals and value binding.', render: () => , }, @@ -100,7 +90,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'checkbox', title: 'Checkbox', category: 'ui-primitives', - dataMode: 'fixture', description: 'Default and highlighted checkbox variants.', render: () => , }, @@ -108,7 +97,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'popover', title: 'Popover', category: 'ui-primitives', - dataMode: 'fixture', description: 'Popover overlay spacing and border treatment.', render: () => , }, @@ -116,7 +104,6 @@ export const primitiveEntries: UiLabEntry[] = [ id: 'spinner', title: 'Spinner', category: 'ui-primitives', - dataMode: 'fixture', description: 'Spinner sizes and animation behavior.', render: () => , }, From abf8f49ed64869c9577a812e6e605967e42f98cb Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 23 Feb 2026 20:44:20 +0800 Subject: [PATCH 4/6] chore: fix --- src/features/ui-lab/fixtures/market-fixtures.ts | 3 ++- src/features/ui-lab/ui-lab-page-client.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/ui-lab/fixtures/market-fixtures.ts b/src/features/ui-lab/fixtures/market-fixtures.ts index 7c3ca560..0f966fb6 100644 --- a/src/features/ui-lab/fixtures/market-fixtures.ts +++ b/src/features/ui-lab/fixtures/market-fixtures.ts @@ -119,4 +119,5 @@ export const uiLabLiquiditySourcingFixture: LiquiditySourcingResult = { refetch: () => {}, }; -export const uiLabOraclePrice = 3250n * 10n ** 36n; +// Morpho oracle price normalized to 1e36 with 18(collateral)-vs-6(loan) decimals accounted for. +export const uiLabOraclePrice = 3250n * 10n ** 24n; diff --git a/src/features/ui-lab/ui-lab-page-client.tsx b/src/features/ui-lab/ui-lab-page-client.tsx index 6819d800..2d7518e4 100644 --- a/src/features/ui-lab/ui-lab-page-client.tsx +++ b/src/features/ui-lab/ui-lab-page-client.tsx @@ -146,8 +146,13 @@ export function UiLabPageClient({ initialSlug }: UiLabPageClientProps): JSX.Elem }, [requestedId, router, searchParamsString, selectedEntry]); useEffect(() => { + if (!selectedEntry) { + setCollapsedSections(createCollapsedSections()); + return; + } + setCollapsedSections(createCollapsedSections(selectedEntry.category)); - }, [selectedEntry.category]); + }, [selectedEntry?.category]); useEffect(() => { setIsThemeMounted(true); From 0f11c61e2053a67c75413ed2d3ccb120f907a152 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 23 Feb 2026 20:45:10 +0800 Subject: [PATCH 5/6] chore: fix data --- src/features/ui-lab/fixtures/component-fixtures.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/ui-lab/fixtures/component-fixtures.ts b/src/features/ui-lab/fixtures/component-fixtures.ts index 381fbd27..32c3b810 100644 --- a/src/features/ui-lab/fixtures/component-fixtures.ts +++ b/src/features/ui-lab/fixtures/component-fixtures.ts @@ -102,10 +102,10 @@ export const createUiLabMarketVariantsFixture = (): Market[] => { ...baseMarket.state, borrowAssets: '129000000', supplyAssets: '244000000', - borrowAssetsUsd: 112000000, - supplyAssetsUsd: 212000000, + borrowAssetsUsd: 112000, + supplyAssetsUsd: 212000, liquidityAssets: '115000000', - liquidityAssetsUsd: 100000000, + liquidityAssetsUsd: 100000, collateralAssets: '90400000000000000000', collateralAssetsUsd: 292000, utilization: 0.528, From bf8e8c33c951aeba05f87c57bd459f45186f3719 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 23 Feb 2026 20:55:47 +0800 Subject: [PATCH 6/6] chore: better production lock --- app/ui-lab/[[...slug]]/page.tsx | 6 ++++-- docs/ui-lab.md | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/ui-lab/[[...slug]]/page.tsx b/app/ui-lab/[[...slug]]/page.tsx index c6469b6b..7adabf12 100644 --- a/app/ui-lab/[[...slug]]/page.tsx +++ b/app/ui-lab/[[...slug]]/page.tsx @@ -1,5 +1,4 @@ import { notFound } from 'next/navigation'; -import { UiLabPageClient } from '@/features/ui-lab/ui-lab-page-client'; type UiLabPageProps = { params?: Promise<{ @@ -8,12 +7,15 @@ type UiLabPageProps = { }; export default async function UiLabPage({ params }: UiLabPageProps) { - const isEnabled = process.env.NEXT_PUBLIC_ENABLE_UI_LAB === 'true'; + const isEnabled = + process.env.NODE_ENV !== 'production' && + (process.env.ENABLE_UI_LAB === 'true' || process.env.NEXT_PUBLIC_ENABLE_UI_LAB === 'true'); if (!isEnabled) { notFound(); } + const { UiLabPageClient } = await import('@/features/ui-lab/ui-lab-page-client'); const resolvedParams = params ? await params : undefined; return ; diff --git a/docs/ui-lab.md b/docs/ui-lab.md index 1f7bf259..edc7c780 100644 --- a/docs/ui-lab.md +++ b/docs/ui-lab.md @@ -24,7 +24,12 @@ UI Lab routes: - `http://localhost:3000/ui-lab/market-selection-modal` - `http://localhost:3000/ui-lab/supply-modal` -The route is gated by `NEXT_PUBLIC_ENABLE_UI_LAB=true`. +The route is gated and disabled in production builds by default. + +- Enable locally with either: + - `ENABLE_UI_LAB=true` (server-only) + - `NEXT_PUBLIC_ENABLE_UI_LAB=true` +- In production (`NODE_ENV=production`), `/ui-lab` always returns `notFound()`. ## URL state