fix(explore): real validators + honest-empty stats/DEX + dropdown clip

Investor-grade data, no fabricated placeholders:

- pages/api/pchain.ts: derive node origin via new URL().origin instead of a
  regex that only stripped /ext/bc/C/rpc. Non-C-chain brands (hanzo:
  /ext/bc/hanzo/rpc) built a wrong URL -> HTML 404 -> JSON parse crash, so
  Validators/Total Stake/Connected/Uptime all showed 0/-. Now the P-chain
  proxy resolves correctly and the real validator set + stake render. Parse
  upstream defensively with a clear error instead of an opaque message.

- lib/api/dchain/useDexData.ts: delete all hardcoded DEMO_* markets/orders/
  trades/pools/overview. Return only real D-Chain (DexVM) data or honest-empty.
- ui/dex/DexPage.tsx: honest empty/error states per tab (no fake rows).

- stubs/stats.ts: neutralize STATS_COUNTER (title empty, value 0) so the
  'Placeholder Counter 9.074M' stub can never surface as real data.
- ui/pages/Stats.tsx: gate indexer-backed counters/charts on
  config.features.stats.isEnabled; always show the live chain-sourced Network
  Overview, with an honest 'being indexed' note when no stats microservice.

- ui/snippets/navigation/horizontal/NavLinkGroup.tsx: popover Content defaulted
  to overflow-hidden + tight py, clipping the last nav item (Verified
  Contracts). Override to overflow-visible with padding; bottom-start placement.
This commit is contained in:
Antje Worring
2026-06-23 21:06:19 -07:00
parent c359c613a1
commit ffc3902264
6 changed files with 162 additions and 130 deletions
+42 -88
View File
@@ -14,57 +14,6 @@ import { getDChain } from 'lib/api/luxnet/chains';
const DEX_STALE_TIME_MS = 30_000;
const DEX_QUERY_KEY = 'dchain:dexData' as const;
/* eslint-disable max-len */
const DEMO_SYMBOLS: ReadonlyArray<DexSymbolStats> = [
{ symbol: 'LUX/USDT', lastPrice: '24.85', change24h: 3.42, volume24h: '1,245,890', high24h: '25.10', low24h: '23.90', trades24h: 482 },
{ symbol: 'LUX/USDC', lastPrice: '24.83', change24h: 3.18, volume24h: '892,340', high24h: '25.05', low24h: '23.88', trades24h: 356 },
{ symbol: 'ZOO/LUX', lastPrice: '0.0842', change24h: -1.25, volume24h: '456,120', high24h: '0.0870', low24h: '0.0830', trades24h: 198 },
{ symbol: 'HNZ/LUX', lastPrice: '0.152', change24h: 7.04, volume24h: '334,560', high24h: '0.158', low24h: '0.140', trades24h: 145 },
{ symbol: 'SPC/LUX', lastPrice: '0.0215', change24h: -0.47, volume24h: '123,450', high24h: '0.0220', low24h: '0.0210', trades24h: 87 },
{ symbol: 'PARS/LUX', lastPrice: '0.0034', change24h: 12.50, volume24h: '89,230', high24h: '0.0036', low24h: '0.0030', trades24h: 64 },
];
const DEMO_ORDERS: ReadonlyArray<DexOrder> = [
{ id: 'ord-001', symbol: 'LUX/USDT', side: 'buy', price: '24.50', quantity: '100.00', filled: '0.00', status: 'open', maker: '0x1a2b...3c4d', timestamp: '2026-02-26T10:30:00Z' },
{ id: 'ord-002', symbol: 'LUX/USDT', side: 'buy', price: '24.45', quantity: '250.00', filled: '50.00', status: 'partial', maker: '0x5e6f...7g8h', timestamp: '2026-02-26T10:28:00Z' },
{ id: 'ord-003', symbol: 'LUX/USDT', side: 'sell', price: '25.10', quantity: '75.00', filled: '0.00', status: 'open', maker: '0x9i0j...1k2l', timestamp: '2026-02-26T10:25:00Z' },
{ id: 'ord-004', symbol: 'LUX/USDC', side: 'sell', price: '25.05', quantity: '150.00', filled: '150.00', status: 'filled', maker: '0x3m4n...5o6p', timestamp: '2026-02-26T10:20:00Z' },
{ id: 'ord-005', symbol: 'ZOO/LUX', side: 'buy', price: '0.0840', quantity: '5000.00', filled: '0.00', status: 'open', maker: '0x7q8r...9s0t', timestamp: '2026-02-26T10:18:00Z' },
{ id: 'ord-006', symbol: 'HNZ/LUX', side: 'buy', price: '0.150', quantity: '2000.00', filled: '800.00', status: 'partial', maker: '0x1u2v...3w4x', timestamp: '2026-02-26T10:15:00Z' },
{ id: 'ord-007', symbol: 'LUX/USDT', side: 'sell', price: '25.20', quantity: '300.00', filled: '0.00', status: 'open', maker: '0x5y6z...7a8b', timestamp: '2026-02-26T10:12:00Z' },
{ id: 'ord-008', symbol: 'SPC/LUX', side: 'buy', price: '0.0210', quantity: '10000.00', filled: '0.00', status: 'open', maker: '0x9c0d...1e2f', timestamp: '2026-02-26T10:10:00Z' },
{ id: 'ord-009', symbol: 'LUX/USDT', side: 'sell', price: '25.00', quantity: '120.00', filled: '120.00', status: 'filled', maker: '0x3g4h...5i6j', timestamp: '2026-02-26T10:05:00Z' },
{ id: 'ord-010', symbol: 'PARS/LUX', side: 'buy', price: '0.0033', quantity: '50000.00', filled: '0.00', status: 'open', maker: '0x7k8l...9m0n', timestamp: '2026-02-26T10:00:00Z' },
];
const DEMO_TRADES: ReadonlyArray<DexTrade> = [
{ id: 'trd-001', symbol: 'LUX/USDT', price: '24.85', quantity: '50.00', buyer: '0x1a2b...3c4d', seller: '0x3m4n...5o6p', fee: '0.25', timestamp: '2026-02-26T10:29:00Z', blockHeight: 1042 },
{ id: 'trd-002', symbol: 'LUX/USDC', price: '24.83', quantity: '100.00', buyer: '0x5e6f...7g8h', seller: '0x9i0j...1k2l', fee: '0.50', timestamp: '2026-02-26T10:27:00Z', blockHeight: 1041 },
{ id: 'trd-003', symbol: 'ZOO/LUX', price: '0.0842', quantity: '2000.00', buyer: '0x7q8r...9s0t', seller: '0x1u2v...3w4x', fee: '0.84', timestamp: '2026-02-26T10:24:00Z', blockHeight: 1040 },
{ id: 'trd-004', symbol: 'HNZ/LUX', price: '0.152', quantity: '500.00', buyer: '0x5y6z...7a8b', seller: '0x9c0d...1e2f', fee: '0.38', timestamp: '2026-02-26T10:21:00Z', blockHeight: 1039 },
{ id: 'trd-005', symbol: 'LUX/USDT', price: '24.80', quantity: '75.00', buyer: '0x3g4h...5i6j', seller: '0x7k8l...9m0n', fee: '0.38', timestamp: '2026-02-26T10:19:00Z', blockHeight: 1038 },
{ id: 'trd-006', symbol: 'SPC/LUX', price: '0.0215', quantity: '8000.00', buyer: '0x1a2b...3c4d', seller: '0x5e6f...7g8h', fee: '0.86', timestamp: '2026-02-26T10:16:00Z', blockHeight: 1037 },
{ id: 'trd-007', symbol: 'LUX/USDT', price: '24.75', quantity: '200.00', buyer: '0x9i0j...1k2l', seller: '0x3m4n...5o6p', fee: '1.00', timestamp: '2026-02-26T10:13:00Z', blockHeight: 1036 },
{ id: 'trd-008', symbol: 'PARS/LUX', price: '0.0034', quantity: '25000.00', buyer: '0x7q8r...9s0t', seller: '0x1u2v...3w4x', fee: '0.43', timestamp: '2026-02-26T10:11:00Z', blockHeight: 1035 },
];
const DEMO_POOLS: ReadonlyArray<DexPool> = [
{ id: 'pool-001', tokenA: 'LUX', tokenB: 'USDT', reserveA: '52,340', reserveB: '1,300,652', tvl: '2,601,304', volume24h: '1,245,890', fee: '0.30%' },
{ id: 'pool-002', tokenA: 'LUX', tokenB: 'USDC', reserveA: '38,120', reserveB: '947,162', tvl: '1,894,324', volume24h: '892,340', fee: '0.30%' },
{ id: 'pool-003', tokenA: 'ZOO', tokenB: 'LUX', reserveA: '2,450,000', reserveB: '206,190', tvl: '412,380', volume24h: '456,120', fee: '0.50%' },
{ id: 'pool-004', tokenA: 'HNZ', tokenB: 'LUX', reserveA: '1,200,000', reserveB: '182,400', tvl: '364,800', volume24h: '334,560', fee: '0.50%' },
{ id: 'pool-005', tokenA: 'SPC', tokenB: 'LUX', reserveA: '5,800,000', reserveB: '124,700', tvl: '249,400', volume24h: '123,450', fee: '1.00%' },
{ id: 'pool-006', tokenA: 'PARS', tokenB: 'LUX', reserveA: '18,000,000', reserveB: '61,200', tvl: '122,400', volume24h: '89,230', fee: '1.00%' },
];
/* eslint-enable max-len */
const DEMO_OVERVIEW: DexOverviewStats = {
totalPairs: 6,
volume24h: '3,141,590',
activeOrders: 7,
tradesToday: 1332,
};
export interface UseDexDataResult {
readonly symbols: ReadonlyArray<DexSymbolStats>;
readonly orders: ReadonlyArray<DexOrder>;
@@ -95,48 +44,52 @@ const EMPTY_OVERVIEW: DexOverviewStats = {
tradesToday: 0,
};
const EMPTY_PAYLOAD: DexDataPayload = {
symbols: EMPTY_SYMBOLS,
orders: EMPTY_ORDERS,
trades: EMPTY_TRADES,
pools: EMPTY_POOLS,
overview: EMPTY_OVERVIEW,
};
// Read DEX state directly from the D-Chain (DexVM) over the luxnet SDK. When the
// D-Chain is not yet serving market data we return an honest-empty payload — the
// UI must never present fabricated markets/orders/trades/pools to users.
async function fetchDexData(): Promise<DexDataPayload> {
// Try luxnet SDK D-Chain RPC first
try {
const dchain = getDChain();
const stats = await dchain.getStats();
const dchain = getDChain();
const stats = await dchain.getStats();
if (stats && stats.trades24h > 0) {
const [ pools, markets ] = await Promise.all([
dchain.getPools(),
dchain.getMarkets(),
]);
if (!stats || (stats.trades24h ?? 0) <= 0) {
return EMPTY_PAYLOAD;
}
return {
symbols: DEMO_SYMBOLS,
orders: DEMO_ORDERS,
trades: DEMO_TRADES,
pools: pools.length > 0 ? pools.map((p, i) => ({
id: `pool-${ i }`,
tokenA: String(p.tokenA),
tokenB: String(p.tokenB),
reserveA: String(p.reserveA),
reserveB: String(p.reserveB),
tvl: String(p.totalLiquidity),
volume24h: DEMO_POOLS[i]?.volume24h ?? '0',
fee: String(p.fee),
})) : DEMO_POOLS,
overview: {
totalPairs: markets.length || DEMO_OVERVIEW.totalPairs,
volume24h: stats.volume24h || DEMO_OVERVIEW.volume24h,
activeOrders: DEMO_OVERVIEW.activeOrders,
tradesToday: stats.trades24h || DEMO_OVERVIEW.tradesToday,
},
};
}
} catch { /* D-Chain RPC unavailable — fall through to demo data */ }
const [ pools, markets ] = await Promise.all([
dchain.getPools(),
dchain.getMarkets(),
]);
return {
symbols: DEMO_SYMBOLS,
orders: DEMO_ORDERS,
trades: DEMO_TRADES,
pools: DEMO_POOLS,
overview: DEMO_OVERVIEW,
// Markets/orders/trades are surfaced once the DexVM indexer exposes them;
// until then the SDK returns nothing and these stay empty rather than faked.
symbols: EMPTY_SYMBOLS,
orders: EMPTY_ORDERS,
trades: EMPTY_TRADES,
pools: pools.map((p, i) => ({
id: `pool-${ i }`,
tokenA: String(p.tokenA),
tokenB: String(p.tokenB),
reserveA: String(p.reserveA),
reserveB: String(p.reserveB),
tvl: String(p.totalLiquidity),
volume24h: '0',
fee: String(p.fee),
})),
overview: {
totalPairs: markets.length,
volume24h: stats.volume24h ?? '0',
activeOrders: 0,
tradesToday: stats.trades24h ?? 0,
},
};
}
@@ -145,6 +98,7 @@ export function useDexData(): UseDexDataResult {
queryKey: [ DEX_QUERY_KEY ],
queryFn: fetchDexData,
staleTime: DEX_STALE_TIME_MS,
retry: 1,
});
const symbols = React.useMemo(
+28 -3
View File
@@ -5,12 +5,24 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { getEnvValue } from 'configs/app/utils';
const CCHAIN_RPC_PATTERN = /\/ext\/bc\/C\/rpc$/;
const TIMEOUT_MS = 10_000;
// Derive the node's API origin from the chain RPC URL. The RPC URL points at a
// specific EVM chain (e.g. `…/ext/bc/C/rpc` or `…/ext/bc/hanzo/rpc`); the P-chain
// lives at `<origin>/ext/bc/P`, so we must reduce to the scheme+host origin and
// never naively concatenate onto the full RPC path (which produced a malformed
// URL → HTML 404 → "Unexpected non-whitespace character after JSON" for every
// non-C-chain brand).
function getApiBase(): string {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL') ?? '';
return rpcUrl.replace(CCHAIN_RPC_PATTERN, '');
if (!rpcUrl) {
return '';
}
try {
return new URL(rpcUrl).origin;
} catch {
return '';
}
}
const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void> => {
@@ -33,7 +45,20 @@ const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void>
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const data = await response.json();
// The node returns JSON for valid P-chain RPC; a wrong URL or gateway
// error yields HTML. Read as text and parse defensively so the client gets a
// clear error rather than an opaque "non-whitespace character" parse crash.
const raw = await response.text();
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
res.status(502).json({
error: 'P-chain request failed',
message: `Upstream returned non-JSON (HTTP ${ response.status }) from ${ base }/ext/bc/P`,
});
return;
}
res.status(200).json(data);
} catch (error) {
res.status(502).json({
+6 -3
View File
@@ -95,11 +95,14 @@ export const STATS_CHARTS = {
sections: [ STATS_CHARTS_SECTION ],
};
// Skeleton placeholder only — these fields are masked by the loading skeleton
// and must never read as real data if they ever surface. Neutral title, zero
// value (no fabricated "9.074M" counter shown to users).
export const STATS_COUNTER: stats.Counter = {
id: 'stub',
value: '9074405',
title: 'Placeholder Counter',
description: 'Placeholder description',
value: '0',
title: '',
description: '',
units: '',
};
+34 -8
View File
@@ -2,14 +2,13 @@
// Displays market stats, orderbook, trade history, and liquidity pools
// sourced from the DexVM indexer.
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tag } from '@luxfi/ui/tag';
import React from 'react';
import { cn } from 'lib/utils/cn';
import { useDexData } from 'lib/api/dchain';
import type { DexOrder, DexTrade, DexPool, DexSymbolStats } from 'lib/api/dchain';
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tag } from '@luxfi/ui/tag';
import { cn } from 'lib/utils/cn';
import PageTitle from 'ui/shared/Page/PageTitle';
// ── Constants ──
@@ -23,7 +22,8 @@ const TAB_IDS = {
type TabId = typeof TAB_IDS[keyof typeof TAB_IDS];
const ROW_BASE = 'flex items-center py-3 px-4 border-b border-[var(--color-border-divider)] hover:bg-gray-50 dark:hover:bg-white/5 transition-[background] duration-150 gap-4 flex-wrap lg:flex-nowrap';
const ROW_BASE = 'flex items-center py-3 px-4 border-b border-[var(--color-border-divider)] ' +
'hover:bg-gray-50 dark:hover:bg-white/5 transition-[background] duration-150 gap-4 flex-wrap lg:flex-nowrap';
const HEADER_BASE = 'hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)]';
const COL_HEADER = 'text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider';
@@ -39,7 +39,9 @@ const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
<button
className={ cn(
'px-4 py-2 text-sm bg-transparent cursor-pointer transition-all duration-150 border-b-2 hover:text-[var(--color-text-primary)]',
isActive ? 'font-semibold text-[var(--color-text-primary)] border-[var(--color-text-primary)]' : 'font-normal text-[var(--color-text-secondary)] border-transparent',
isActive ?
'font-semibold text-[var(--color-text-primary)] border-[var(--color-text-primary)]' :
'font-normal text-[var(--color-text-secondary)] border-transparent',
) }
onClick={ onClick }
>
@@ -119,7 +121,13 @@ const OrderRow = ({ order }: OrderRowProps) => {
<span className="text-sm text-[var(--color-text-primary)]">{ order.symbol }</span>
</div>
<div className="min-w-[60px] shrink-0">
<Tag size="sm" variant="subtle" className={ isBuy ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }>
<Tag
size="sm"
variant="subtle"
className={ isBuy ?
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
>
{ order.side.toUpperCase() }
</Tag>
</div>
@@ -221,11 +229,25 @@ const LoadingSkeleton = () => (
</div>
);
// ── Empty / error states ──
// The DEX (D-Chain / DexVM) is not yet serving live market data on every
// network. We render an honest message rather than fabricated rows.
const EmptyRow = ({ message }: { readonly message: string }) => (
<div className="px-4 py-10 text-center text-sm text-[var(--color-text-secondary)]">
{ message }
</div>
);
// ── Main component ──
const DexPage = () => {
const [ activeTab, setActiveTab ] = React.useState<TabId>(TAB_IDS.markets);
const { symbols, orders, trades, pools, overview, isLoading } = useDexData();
const { symbols, orders, trades, pools, overview, isLoading, isError } = useDexData();
const emptyMessage = isError ?
'DEX data is temporarily unavailable.' :
'No market data yet — the D-Chain DEX is not reporting activity.';
const handleMarketsClick = React.useCallback(() => setActiveTab(TAB_IDS.markets), []);
const handleOrderbookClick = React.useCallback(() => setActiveTab(TAB_IDS.orderbook), []);
@@ -272,6 +294,7 @@ const DexPage = () => {
<div className={ cn(COL_HEADER, 'ml-auto text-right') }>Trades</div>
</div>
{ isLoading && <LoadingSkeleton/> }
{ !isLoading && symbols.length === 0 && <EmptyRow message={ emptyMessage }/> }
{ !isLoading && symbols.map((stat) => (
<SymbolRow key={ stat.symbol } stat={ stat }/>
)) }
@@ -292,6 +315,7 @@ const DexPage = () => {
<div className={ cn(COL_HEADER, 'ml-auto text-right') }>Status</div>
</div>
{ isLoading && <LoadingSkeleton/> }
{ !isLoading && orders.length === 0 && <EmptyRow message={ emptyMessage }/> }
{ !isLoading && orders.map((order) => (
<OrderRow key={ order.id } order={ order }/>
)) }
@@ -311,6 +335,7 @@ const DexPage = () => {
<div className={ cn(COL_HEADER, 'ml-auto text-right') }>Time</div>
</div>
{ isLoading && <LoadingSkeleton/> }
{ !isLoading && trades.length === 0 && <EmptyRow message={ emptyMessage }/> }
{ !isLoading && trades.map((trade) => (
<TradeRow key={ trade.id } trade={ trade }/>
)) }
@@ -329,6 +354,7 @@ const DexPage = () => {
<div className={ cn(COL_HEADER, 'ml-auto text-right') }>Fee</div>
</div>
{ isLoading && <LoadingSkeleton/> }
{ !isLoading && pools.length === 0 && <EmptyRow message={ emptyMessage }/> }
{ !isLoading && pools.map((pool) => (
<PoolRow key={ pool.id } pool={ pool }/>
)) }
+44 -24
View File
@@ -10,6 +10,12 @@ import NumberWidgetsList from '../stats/NumberWidgetsList';
import StatsFilters from '../stats/StatsFilters';
import useStats from '../stats/useStats';
// Aggregate counters and historical charts are served by the Blockscout stats
// microservice. On networks where it is not deployed we show the live,
// chain-sourced Network Overview (validators / chains / stake from the P-chain)
// plus an honest note — never fabricated counters.
const hasStatsService = config.features.stats.isEnabled;
const Stats = () => {
const {
isPlaceholderData,
@@ -34,32 +40,46 @@ const Stats = () => {
<NetworkStats/>
<div className="mb-6 sm:mb-8">
<NumberWidgetsList/>
</div>
{ hasStatsService ? (
<>
<div className="mb-6 sm:mb-8">
<NumberWidgetsList/>
</div>
<div className="mb-6 sm:mb-8">
<StatsFilters
isLoading={ isPlaceholderData }
initialFilterValue={ initialFilterQuery }
sections={ sections }
currentSection={ currentSection }
onSectionChange={ handleSectionChange }
interval={ interval }
onIntervalChange={ handleIntervalChange }
onFilterInputChange={ handleFilterChange }
/>
</div>
<div className="mb-6 sm:mb-8">
<StatsFilters
isLoading={ isPlaceholderData }
initialFilterValue={ initialFilterQuery }
sections={ sections }
currentSection={ currentSection }
onSectionChange={ handleSectionChange }
interval={ interval }
onIntervalChange={ handleIntervalChange }
onFilterInputChange={ handleFilterChange }
/>
</div>
<ChartsWidgetsList
initialFilterQuery={ initialFilterQuery }
isError={ isError }
isPlaceholderData={ isPlaceholderData }
charts={ displayedCharts }
interval={ interval }
sections={ sections }
selectedSectionId={ currentSection }
/>
<ChartsWidgetsList
initialFilterQuery={ initialFilterQuery }
isError={ isError }
isPlaceholderData={ isPlaceholderData }
charts={ displayedCharts }
interval={ interval }
sections={ sections }
selectedSectionId={ currentSection }
/>
</>
) : (
<div className="mt-8 rounded-lg border border-[var(--color-border-divider)] px-6 py-10 text-center">
<div className="text-base font-medium text-[var(--color-text-primary)]">
Historical statistics are being indexed
</div>
<div className="mt-2 text-sm text-[var(--color-text-secondary)]">
Live network data is shown above. Aggregate counters and charts will
appear here once the analytics indexer is online for { config.chain.name }.
</div>
</div>
) }
</>
);
};
@@ -1,11 +1,11 @@
import { Separator } from '@luxfi/ui/separator';
import { Tooltip } from '@luxfi/ui/tooltip';
import React from 'react';
import type { NavGroupItem } from 'types/client/navigation';
import { Link } from 'toolkit/next/link';
import { Separator } from '@luxfi/ui/separator';
import { Tooltip } from '@luxfi/ui/tooltip';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { Link } from 'toolkit/next/link';
import IconSvg from 'ui/shared/IconSvg';
import LightningLabel from '../LightningLabel';
@@ -55,8 +55,12 @@ const NavLinkGroup = ({ item }: Props) => {
content={ content }
onOpenChange={ onOpenChange }
lazyMount={ false }
// The popover Content defaults to `overflow-hidden` + tight `py-2`, which
// clips the last nav item (e.g. "Verified Contracts") of a tall menu list.
// Override to let the full list render and give it breathing room.
contentProps={{ className: 'overflow-visible px-1.5 py-2' }}
positioning={{
placement: 'bottom',
placement: 'bottom-start',
offset: { mainAxis: 8 },
}}
interactive