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..7adabf12 --- /dev/null +++ b/app/ui-lab/[[...slug]]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from 'next/navigation'; + +type UiLabPageProps = { + params?: Promise<{ + slug?: string[]; + }>; +}; + +export default async function UiLabPage({ params }: UiLabPageProps) { + 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 new file mode 100644 index 00000000..edc7c780 --- /dev/null +++ b/docs/ui-lab.md @@ -0,0 +1,61 @@ +# 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 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 + +- 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 the relevant section file under `src/features/ui-lab/registry/`. +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` +- 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/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/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 new file mode 100644 index 00000000..32c3b810 --- /dev/null +++ b/src/features/ui-lab/fixtures/component-fixtures.ts @@ -0,0 +1,169 @@ +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: 112000, + supplyAssetsUsd: 212000, + liquidityAssets: '115000000', + liquidityAssetsUsd: 100000, + 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..0f966fb6 --- /dev/null +++ b/src/features/ui-lab/fixtures/market-fixtures.ts @@ -0,0 +1,123 @@ +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: () => {}, +}; + +// 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/harnesses/borrow-modal-harness.tsx b/src/features/ui-lab/harnesses/borrow-modal-harness.tsx new file mode 100644 index 00000000..c7869f9a --- /dev/null +++ b/src/features/ui-lab/harnesses/borrow-modal-harness.tsx @@ -0,0 +1,73 @@ +'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..4e635695 --- /dev/null +++ b/src/features/ui-lab/harnesses/market-harnesses.tsx @@ -0,0 +1,234 @@ +'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..d6098752 --- /dev/null +++ b/src/features/ui-lab/harnesses/primitives-harnesses.tsx @@ -0,0 +1,296 @@ +'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..8d3f0be2 --- /dev/null +++ b/src/features/ui-lab/harnesses/shared-harnesses.tsx @@ -0,0 +1,361 @@ +'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..f89435d0 --- /dev/null +++ b/src/features/ui-lab/harnesses/supply-modal-harness.tsx @@ -0,0 +1,70 @@ +'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/controls.tsx b/src/features/ui-lab/registry/controls.tsx new file mode 100644 index 00000000..3ee78b30 --- /dev/null +++ b/src/features/ui-lab/registry/controls.tsx @@ -0,0 +1,33 @@ +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', + 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: () => , + }, +]; 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..926582e6 --- /dev/null +++ b/src/features/ui-lab/registry/data-display.tsx @@ -0,0 +1,42 @@ +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', + 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', + dataMode: 'hybrid', + 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: () => , + }, +]; diff --git a/src/features/ui-lab/registry/filters.tsx b/src/features/ui-lab/registry/filters.tsx new file mode 100644 index 00000000..9c9176d9 --- /dev/null +++ b/src/features/ui-lab/registry/filters.tsx @@ -0,0 +1,19 @@ +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', + 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: () => , + }, +]; diff --git a/src/features/ui-lab/registry/identity.tsx b/src/features/ui-lab/registry/identity.tsx new file mode 100644 index 00000000..6e68c196 --- /dev/null +++ b/src/features/ui-lab/registry/identity.tsx @@ -0,0 +1,40 @@ +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', + 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: () => , + }, +]; 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..deebbd1d --- /dev/null +++ b/src/features/ui-lab/registry/primitives.tsx @@ -0,0 +1,110 @@ +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', + 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: () => , + }, +]; diff --git a/src/features/ui-lab/types.ts b/src/features/ui-lab/types.ts new file mode 100644 index 00000000..9baf1da9 --- /dev/null +++ b/src/features/ui-lab/types.ts @@ -0,0 +1,22 @@ +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; + maxW: number; + bg: UiLabCanvasBackground; +}; + +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 new file mode 100644 index 00000000..2d7518e4 --- /dev/null +++ b/src/features/ui-lab/ui-lab-page-client.tsx @@ -0,0 +1,445 @@ +'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, UiLabDataMode, 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; +}; + +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 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)); +}; + +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(() => { + if (!selectedEntry) { + setCollapsedSections(createCollapsedSections()); + return; + } + + 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'; + const selectedDataMode = selectedEntry.dataMode ?? 'fixture'; + + return ( +
+ +
+
+ {isSidebarMinimized ? null : ( +
+

UI Lab

+

Component sections

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

{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',