Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .claude/rules/mobile-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
## Mobile UI Rules

Mobile views live in `apps/terminal/src/components/trade/mobile/`. They mirror the desktop in element sizing and design tokens — only layout/spacing adapts.

### Typography Scale (Mobile)

Follow the same scale as desktop — do NOT inflate font sizes for "mobile readability":

| Role | Token |
|------|-------|
| Metric labels (Size, Entry, Mark…) | `text-2xs text-text-weak` |
| Metric values | `text-xs tabular-nums font-medium` |
| Section labels / uppercase headers | `text-2xs font-medium uppercase` |
| Card header asset name | `text-sm font-semibold` |
| Mark price in header | `text-sm font-semibold tabular-nums` |
| 24h change | `text-xs tabular-nums` |
| Body / descriptions | `text-sm` |

### Header Price Display

- Header mark price: `text-sm font-semibold` (not `text-lg`)
- Stack price + change **vertically** (price above, change below, right-aligned) — do not place them side by side
- Do NOT show Long/Short position badges in chart/trade headers — the position is visible in the Positions tab

### Controls — Ghost vs Outline

Token/unit toggles vary by context:

- **Next to a large input** (e.g. size input in trade form): use `variant="outline"` so the control reads as a bordered selector matching the input's visual weight:
```tsx
<Button variant="outline" intent="neutral" size="sm" iconRight={<CaretDownIcon />}>
{token}
</Button>
```
- **Standalone / next to a Dropdown trigger**: use `variant="ghost"` to match the Dropdown's minimal style.

`variant="outline"` is also used for primary CTAs (Withdraw, Deposit, Close position).

### Metric Grid in Cards (Positions / Orders)

Do NOT use the `gap-px bg-stroke-weak/20` mosaic trick for metric grids. Instead use explicit row and column dividers:

```tsx
<div className="border-t border-stroke-weak/40">
<div className="grid grid-cols-3 divide-x divide-stroke-weak/40">
<MetricCell label="Size" value="..." />
<MetricCell label="Entry" value="..." />
<MetricCell label="Mark" value="..." />
</div>
<div className="grid grid-cols-3 divide-x divide-stroke-weak/40 border-t border-stroke-weak/40">
<MetricCell label="Margin" value="..." />
<MetricCell label="Liq" value="..." />
<MetricCell label="Funding" value="..." />
</div>
</div>
```

MetricCell standard:
```tsx
<div className="px-3 py-2">
<div className="text-2xs text-text-weak mb-0.5">{label}</div>
<div className="text-xs tabular-nums font-medium">{value}</div>
{sub && <div className="text-2xs text-text-weak tabular-nums mt-0.5">{sub}</div>}
</div>
```

### Card Action Buttons

Action buttons in position/order cards use `size="xs"` (not raw `<button>` elements):

```tsx
<Button variant="outline" intent="neutral" size="xs">Limit Close</Button>
<Button variant="outline" intent="error" size="xs">Close</Button>
```

### Reference — Account View

`mobile-account-view.tsx` is the visual reference for card style. Match its:
- Card: `rounded-xs border border-stroke-weak/40 bg-bg-raised`
- Stat label: `text-2xs text-text-weak`
- Stat value: `text-base font-semibold tabular-nums` (for primary metrics only; secondary metrics use `text-xs`)

### Button Size Standard (Mobile)

Use one consistent size per context — never add `min-h-[*]` or `h-[*]` overrides to Button components; they fight the design system.

| Context | Size |
|---------|------|
| Primary form action (Buy/Sell, trade submit) | `size="lg" className="w-full"` |
| Standalone section CTA (Connect Wallet, empty states) | `size="sm"` |
| Account actions (Withdraw, Deposit, Bridge) | `size="md"` in 3-col grid |
| Card action buttons (Cancel, Close, Transfer) | `size="sm"` |
| Card action buttons in position cards (TP/SL, Limit Close, Close) | `size="xs" className="flex-1 justify-center"` |
| Ghost market switcher in card headers | `size="sm"` |
| Inline toggle controls (unit switcher) | `size="sm" variant="ghost"` |

Never use `min-h-[36px]` or `min-h-[44px]` as class overrides on Button — the component's size prop already handles height correctly.
8 changes: 7 additions & 1 deletion apps/terminal/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const CACHE_VERSION = "v1";
const ASSET_CACHE = `assets-${CACHE_VERSION}`;
const CHARTING_CACHE = `charting-${CACHE_VERSION}`;
const FONT_CACHE = `fonts-${CACHE_VERSION}`;
const ICON_CACHE = `icons-${CACHE_VERSION}`;
Expand All @@ -16,7 +17,7 @@ self.addEventListener("activate", (event) => {
keys
.filter(
(k) =>
![CHARTING_CACHE, FONT_CACHE, ICON_CACHE, NAV_CACHE].includes(k),
![ASSET_CACHE, CHARTING_CACHE, FONT_CACHE, ICON_CACHE, NAV_CACHE].includes(k),
)
.map((k) => caches.delete(k)),
),
Expand All @@ -30,6 +31,11 @@ self.addEventListener("fetch", (event) => {

if (request.method !== "GET") return;

if (url.pathname.startsWith("/assets/")) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
}

if (url.pathname.startsWith("/charting_library/")) {
event.respondWith(cacheFirst(request, CHARTING_CACHE));
return;
Expand Down
240 changes: 240 additions & 0 deletions apps/terminal/src/components/account/account-balances.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { Checkbox, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hypeterminal/ui";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { WalletIcon } from "@phosphor-icons/react";
import { Skeleton } from "boneyard-js/react";
import { useId, useMemo } from "react";
import { useConnection } from "wagmi";
import { DEFAULT_QUOTE_TOKEN, HL_ALL_DEXS } from "@/config/constants";
import { useAccountBalances } from "@/hooks/trade/use-account-balances";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/cn";
import { formatToken, formatUSD } from "@/lib/format";
import { useSubAllMids } from "@/lib/hyperliquid/hooks/subscription";
import { useSpotTokens } from "@/lib/hyperliquid/markets/use-spot-tokens";
import { toNumberOrZero } from "@/lib/trade/numbers";
import { useGlobalSettingsActions, useHideSmallBalances } from "@/stores/use-global-settings-store";
import { AssetDisplay } from "../trade/components/asset-display";

const SMALL_BALANCE_THRESHOLD = 1;

interface SpotRow {
coin: string;
total: number;
available: number;
inOrders: number;
usdValue: number;
szDecimals: number;
}

export function AccountBalances() {
const { isConnected } = useConnection();
const { spotBalances, isLoading, hasError } = useAccountBalances();
const { getToken } = useSpotTokens();
const hideSmallBalances = useHideSmallBalances();
const { setHideSmallBalances } = useGlobalSettingsActions();
const isMobile = useIsMobile();

const { data: allMidsEvent } = useSubAllMids({ dex: HL_ALL_DEXS }, { enabled: isConnected });
const mids = allMidsEvent?.mids;

const rows = useMemo(() => {
const result: SpotRow[] = [];
for (const b of spotBalances) {
const total = toNumberOrZero(b.total);
if (total === 0) continue;

const hold = toNumberOrZero(b.hold);
const available = Math.max(0, total - hold);
const inOrders = hold;
const token = getToken(b.coin);
const szDecimals = token?.szDecimals ?? 2;

let usdValue: number;
if (b.coin === DEFAULT_QUOTE_TOKEN) {
usdValue = total;
} else {
const midPx = toNumberOrZero(mids?.[b.coin]);
usdValue = midPx > 0 ? total * midPx : toNumberOrZero(b.entryNtl);
}

result.push({ coin: b.coin, total, available, inOrders, usdValue, szDecimals });
}
result.sort((a, b) => b.usdValue - a.usdValue);
return result;
}, [spotBalances, mids, getToken]);

const filteredRows = useMemo(() => {
if (!hideSmallBalances) return rows;
return rows.filter((r) => r.usdValue >= SMALL_BALANCE_THRESHOLD);
}, [rows, hideSmallBalances]);

const totalValue = useMemo(() => rows.reduce((sum, r) => sum + r.usdValue, 0), [rows]);

if (!isConnected) return null;

if (isLoading) {
return (
<Section>
<SectionHeader totalValue={null} hideSmall={hideSmallBalances} onHideSmallChange={setHideSmallBalances} />
<div className="space-y-2 p-4">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
</div>
</Section>
);
}

if (hasError) {
return (
<Section>
<SectionHeader totalValue={null} hideSmall={hideSmallBalances} onHideSmallChange={setHideSmallBalances} />
<div className="p-6 text-center text-xs text-market-down">
<Trans>Failed to load balances.</Trans>
</div>
</Section>
);
}

if (rows.length === 0) {
return (
<Section>
<SectionHeader totalValue={0} hideSmall={hideSmallBalances} onHideSmallChange={setHideSmallBalances} />
<div className="p-6 text-center text-xs text-text-weak">
<Trans>No spot balances.</Trans>
</div>
</Section>
);
}

return (
<Section>
<SectionHeader totalValue={totalValue} hideSmall={hideSmallBalances} onHideSmallChange={setHideSmallBalances} />
{isMobile ? (
<div className="space-y-2 p-3">
{filteredRows.map((row) => (
<BalanceCard key={row.coin} row={row} />
))}
</div>
) : (
<BalancesTable rows={filteredRows} />
)}
</Section>
);
}

function Section({ children }: { children: React.ReactNode }) {
return <div className="rounded-xs border border-stroke-weak bg-bg-raised overflow-hidden">{children}</div>;
}

interface SectionHeaderProps {
totalValue: number | null;
hideSmall: boolean;
onHideSmallChange: (v: boolean) => void;
}

function SectionHeader({ totalValue, hideSmall, onHideSmallChange }: SectionHeaderProps) {
const checkboxId = useId();
return (
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-stroke-weak/40">
<WalletIcon className="size-3.5 text-text-weak" />
<span className="text-xs font-medium">
<Trans>Spot Balances</Trans>
</span>
<label htmlFor={checkboxId} className="ml-auto flex items-center gap-1.5 cursor-pointer text-3xs text-text-weak">
<Checkbox
id={checkboxId}
checked={hideSmall}
onCheckedChange={(checked) => onHideSmallChange(Boolean(checked))}
className="size-3"
/>
{t`Hide small`}
</label>
{totalValue !== null && (
<span className="text-xs font-semibold tabular-nums">{formatUSD(totalValue, { compact: true })}</span>
)}
</div>
);
}

function BalancesTable({ rows }: { rows: SpotRow[] }) {
return (
<Table>
<TableHeader>
<TableRow className="border-stroke-weak/40 bg-bg-alternate hover:bg-bg-alternate">
<TableHead className="text-3xs font-medium uppercase text-text-weak h-7">{t`Token`}</TableHead>
<TableHead className="text-3xs font-medium uppercase text-text-weak text-right h-7">{t`Total`}</TableHead>
<TableHead className="text-3xs font-medium uppercase text-text-weak text-right h-7">{t`Available`}</TableHead>
<TableHead className="text-3xs font-medium uppercase text-text-weak text-right h-7">{t`In Orders`}</TableHead>
<TableHead className="text-3xs font-medium uppercase text-text-weak text-right h-7">{t`USD Value`}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow
key={row.coin}
className={cn("border-stroke-weak/40 hover:bg-bg-alternate/30", i % 2 === 1 && "bg-bg-alternate")}
>
<TableCell className="text-xs font-medium py-1.5">
<AssetDisplay coin={row.coin} />
</TableCell>
<TableCell className="text-xs text-right tabular-nums py-1.5">
{formatToken(row.total, row.coin === DEFAULT_QUOTE_TOKEN ? 2 : row.szDecimals)}
</TableCell>
<TableCell className="text-xs text-right tabular-nums py-1.5">
{formatToken(row.available, row.coin === DEFAULT_QUOTE_TOKEN ? 2 : row.szDecimals)}
</TableCell>
<TableCell className="text-xs text-right tabular-nums text-text-warning py-1.5">
{row.inOrders > 0
? formatToken(row.inOrders, row.coin === DEFAULT_QUOTE_TOKEN ? 2 : row.szDecimals)
: "—"}
</TableCell>
<TableCell className="text-xs text-right tabular-nums py-1.5">
{formatUSD(row.usdValue, { compact: true })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

function BalanceCard({ row }: { row: SpotRow }) {
const decimals = row.coin === DEFAULT_QUOTE_TOKEN ? 2 : row.szDecimals;

return (
<div className="rounded-xs border border-stroke-weak/40 bg-bg-raised">
<div className="flex items-center justify-between px-3 py-2.5 border-b border-stroke-weak/40">
<AssetDisplay coin={row.coin} nameClassName="text-sm font-semibold" />
<span className="text-sm font-semibold tabular-nums">{formatUSD(row.usdValue, { compact: true })}</span>
</div>
<div className="border-t border-stroke-weak/40">
<div className="grid grid-cols-3 divide-x divide-stroke-weak/40">
<MetricCell label={t`Total`} value={formatToken(row.total, decimals)} />
<MetricCell label={t`Available`} value={formatToken(row.available, decimals)} />
<MetricCell
label={t`In Orders`}
value={row.inOrders > 0 ? formatToken(row.inOrders, decimals) : "—"}
valueClass={row.inOrders > 0 ? "text-text-warning" : undefined}
/>
</div>
</div>
</div>
);
}

interface MetricCellProps {
label: string;
value: string;
valueClass?: string;
}

function MetricCell({ label, value, valueClass }: MetricCellProps) {
return (
<div className="px-3 py-2">
<div className="text-3xs text-text-weak mb-0.5">{label}</div>
<div className={cn("text-xs tabular-nums font-medium", valueClass)}>{value}</div>
</div>
);
}
Loading
Loading