+
+
+
+
+
+
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 (
+ ([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 (
+
+ );
+}
+
+export function TransactionIdentityHarness(): JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+export function CollateralIconsDisplayHarness(): JSX.Element {
+ return (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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.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',