# Loans dashboard

> A lending app’s Loans screen: outstanding debt, a liquidation-risk map of every position’s health factor, and a table to repay from.

A whole Loans screen for a borrower with positions on several lending venues. The debt counts in over a repaid bar; a map lays every open position on one bar of health factors, coloured from liquidation to safe, with a card on hover and a pill that says what lifting the weakest one would take. The table filters by status, searches, filters by venue and rate, sorts and pages, and every row repays or adds collateral through a dialog that previews the health factor, plays the wallet’s steps and updates the screen.

A React component by Bruno Delorence: https://brunodelorence.com/components/loans-dashboard. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Dashboards.

## Anatomy

1. **Sidebar**: Navigation with the current page marked, and the liquidation alerts switch. An icon rail under 1100 px, a drawer under 760 px.
2. **Top bar**: The breadcrumb, a bell with a dot while any position sits under 1.50, and the account menu, which also switches the colours.
3. **Outstanding debt**: The open debt, counted in over 900 ms, a bar of the share repaid, and the collateral, loan-to-value and position count.
4. **Risk map**: Every open position on one logarithmic bar of health factors, 0.9 to 5, in four zones. Labels closer than 44 px drop a line.
5. **Lift pill**: Rides over the weakest position under 1.50 and says what repaying would lift it there; pressing it opens the dialog with that amount in.
6. **Toolbar**: Status tabs with live counts, a search on token, venue or chain, a venue and rate filter, and the sort.
7. **Positions table**: Six rows a page: collateral, borrowed, a repaid bar, APR, health and status, then Repay and a menu. Hovering a row lights its marker.
8. **Pagination**: How many rows show of how many match, and the page, with previous and next.

## When to use

- A borrower’s home for loans spread over several venues: how much is owed, how much is repaid, and which position is closest to liquidation.
- Risk that is one number per item (a health factor, a score, a margin) and reads best laid on one shared scale, coloured by zone.
- A list that people act on row by row: repay, add collateral, copy an ID, with a preview of the result before they confirm.
- Screens where one action has to move every figure at once: the total, the bar, the map and the table all come from one list.

## When not to use

- A single loan. Show its own page: the health, the debt and one Repay button, with no map or table.
- Read-only reporting over time (interest paid, rates by month). Use a chart dashboard with a period picker.
- Hundreds of positions. The map’s labels drop to new lines and stop reading; page the table on the server and replace the map with a histogram.
- Trading or swapping. The dialog only repays or adds collateral; a trade needs quotes, slippage and routes.

## Installation

Install it with the shadcn CLI, into components/loans-dashboard of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/loans-dashboard.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { LoansDashboard } from './loans-dashboard';

export function LoansPage() {
  return (
    <LoansDashboard
      autoplay={false}
      defaultSort="health"
      onRepay={(position, kind, amount) => console.log(kind, position.id, amount)}
    />
  );
}
```

## States

- **Loading**: The first 700 ms: skeletons for the debt and six rows, dashes for the figures. Skipped under reduced motion.
- **Loaded**: Six open positions on the map, the lift pill over the weakest, the newest rows first.
- **Position card**: Hovering a marker, or View details in a row’s menu: the card shows health, debt, collateral and the liquidation price; its row lights.
- **At risk**: The At risk tab: only the open positions under 1.20, each with a red At risk status.
- **No matches**: A search with no result: the table says so and offers to clear the search and filters.
- **Filter menu**: Venues and rates as checkboxes; the Filter button counts what is ticked and the tab counts follow.
- **Row menu**: View details, Repay, Add collateral and Copy position ID, drawn at the root so the table never clips it.
- **Repay dialog**: Opened from the lift pill: the lifting amount is in, and the small bar shows the health moving from 1.08 to 1.50.
- **Add collateral**: The same dialog in units of the collateral, with Max instead of Full.
- **Dark**: colorScheme="dark", or Dark in the account menu: every colour is a light-dark() token.
- **Icon rail**: Under 1100 px of its own width the sidebar keeps its icons only; under 980 px the overview stacks.
- **Drawer**: Under 760 px a menu button slides the sidebar in over a scrim; Escape or the scrim closes it.

## How it works

- One list of positions feeds the whole screen. The debt, the share repaid, the collateral, the loan-to-value, the map, the tab counts, the rows and the pages are all computed from it on each render, so one repayment moves every figure at once.
- A health factor is the collateral, valued at its liquidation threshold, over the debt. So health × debt stays constant while prices hold: repaying $3,840 of $13,700 at 1.08 lifts it to 14,796 / 9,860 = 1.50. The amount that lifts a position to 1.50 is solved from the same constant and rounded up to $10, and the price that would liquidate it is the collateral’s price over its health.
- The map is logarithmic, from 0.9 to 5, so the crowded zone under 2 gets the room. Each marker is a button: the asset’s mark, a stem, a dot on the bar and its health and venue under it. Labels that would come closer than 44 px drop to the next line, measured from the map’s own width (a ResizeObserver), and the map grows a line rather than overlap.
- The map and the table are linked through one hovered id: a marker lights its row (and shows its card), a row lights its marker and dims the rest. A dark pill rides over the weakest position under 1.50 and opens the dialog with the lifting amount already in.
- The repay dialog also adds collateral. Presets fill the amount (lift to 1.50, a quarter, half, all of it); a small copy of the risk bar shows the old and new health; the wallet’s two steps, "Confirm in wallet" and "Repaying", run on timers before the receipt. The screen updates as the receipt shows: the debt counts down, the bars grow, the marker slides and a toast offers to show the row.
- Menus (a row’s, sort, filter, account, notifications) are drawn at the dashboard’s root and placed under their button, so the table’s horizontal scroll never clips them.
- The debt counts with requestAnimationFrame on an ease-out; the repaid bars scale by transform; markers slide by their left offset on a transition. Under reduced motion, there is no skeleton and every figure lands at once.
- Layout is by container queries on the root, so it answers to its own width, not the window’s: a full sidebar from 1100 px, an icon rail from 760 px, a drawer behind a menu button below that. The overview stacks under 980 px and the table scrolls inside its box.
- While on screen and untouched, a demo pointer hovers two markers, repays the weakest position from the pill, lights a row, sorts by lowest health, searches and opens a row’s menu, then puts the positions back. Any real input hands control to the reader; once the reader has changed a position, the demo leaves the positions alone.
- Every colour is a light-dark() token on the root, so the dashboard follows the page’s color-scheme or the colorScheme prop, and its account menu can switch it to light, dark or the system’s.

## API reference

- **positions** (`Position[]`, default `POSITIONS`): The borrow positions shown (data.ts has the type and twelve invented ones). The dashboard keeps its own copy as they are repaid.
- **defaultSort** (`'newest' | 'oldest' | 'health' | 'largest' | 'smallest' | 'apr' | 'repaid'`, default `'newest'`): The table’s first sort.
- **alerts** (`boolean`, default `true`): Whether the liquidation alerts switch in the sidebar starts on.
- **onRepay** (`(position: Position, kind: 'repay' | 'collateral', amount: number) => void`): Called when a repayment (in dollars) or added collateral (in units) lands, with the position as it now is.
- **defaultState** (`LoansDashboardState`): Opens in this state, fully loaded unless loading (the first load’s skeleton, held): tab ('all' | 'active' | 'risk' | 'closed'), query, venues and rates (the search and its filters), card (a position’s id, its card pinned on the map and its row lit), menu ('row' | 'sort' | 'filter' | 'account' | 'bell'; 'row' is the first row’s), dialog ({ id, mode?: 'repay' | 'collateral' }, the dialog open on an open position) and drawer (the sidebar drawer, under 760 px). Turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer shows the map, a repayment, the sort, the search and a row’s menu. Real input hands control to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s system setting; without it, the page’s color-scheme. The account menu can change it.
- **className** (`string`): Extra class for the root.

## Accessibility

- Landmarks and headings: the sidebar is an aside with a nav, the screen a main with an h1, the overview, map and positions are labelled sections, and the breadcrumb marks the current page.
- Each marker on the map is a button named for its position, health and zone; focusing it shows the same card as hovering, and Enter opens the repay dialog. The card is a role="tooltip".
- The status filters are a tablist with arrow keys; the table has real headers, and aria-sort on the column the current sort applies to. Every row’s Repay and menu buttons are named for the position.
- Menus are role="menu" with menuitem, menuitemradio and menuitemcheckbox items: arrow keys, Home and End move through them, Escape closes them and returns to their button, Tab or a press outside closes them.
- The repay dialog is modal: focus moves to the amount, Tab is kept inside, Escape closes it while nothing is pending, and focus returns to the control that opened it. Its errors and the wallet’s steps are announced through aria-live.
- The debt, the toast and the table’s count are live regions; the debt announces its final value, not every step of the count. The alerts switch is a role="switch".
- Every control shows a :focus-visible ring, the drawer closes with Escape, and reduced motion removes the skeleton, the counting and the demo. The demo pointer and the asset marks are aria-hidden.

## Source

### loans-dashboard/loans-dashboard.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from 'react';
import {
  ASSETS,
  POSITIONS,
  VENUES,
  collateralValue,
  dollars,
  healthAfterCollateral,
  isAtRisk,
  isOpen,
  liftRepay,
  units,
  zoneOf,
  type Position,
  type Venue,
} from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { AssetMark, Icon, Logo, Shield, type IconName } from './icons';
import { RepayDialog, type DialogMode } from './repay-dialog';
import { RiskMap } from './risk-map';
import { useAutoplay } from './use-autoplay';
import styles from './loans-dashboard.module.css';

/*
 * Loans dashboard: a lending app's "Loans" screen. A sidebar, the outstanding debt with how much
 * of it is repaid, a liquidation-risk map of every open position's health factor, and a table of
 * the positions with filter tabs, search, a venue and rate filter, sorts and pages. A position can
 * be repaid, or given more collateral, from the map, its row or its row's menu; the dialog shows
 * the health factor it will reach, plays the wallet's steps and updates the whole screen.
 *
 * - One list of positions feeds everything: the debt, the map, the tab counts and the table are
 *   all computed from it on each render, so a repayment moves them all at once. The debt counts
 *   to its new value; the repaid bars grow by a transform.
 * - The map and the table are linked: hovering a marker lights its row and shows its card,
 *   hovering a row lights its marker. "View details" in a row's menu pins the card.
 * - Menus (row, sort, filter, account, notifications) are role="menu" lists drawn at the
 *   dashboard's root, placed under the button that opened them, so the table's scrolling never
 *   clips them. Arrow keys move through them; Escape returns to their button.
 * - The account menu sets the colours: light, dark, or the page's (data-color-scheme).
 * - Layout is by container queries on the root: a full sidebar from 1100 px, an icon rail from
 *   760 px, and under that a menu button that slides the sidebar in. The table scrolls inside its
 *   card when the dashboard is narrower than it.
 * - While on screen and untouched, a demo pointer hovers the map, repays the weakest position,
 *   sorts, searches and opens a row's menu. Any real input hands control to the reader; the demo
 *   only undoes its own repayment.
 */

type Tab = 'all' | 'active' | 'risk' | 'closed';
type SortId = 'newest' | 'oldest' | 'health' | 'largest' | 'smallest' | 'apr' | 'repaid';
type Scheme = 'light' | 'dark' | 'system';
type MenuKind = 'row' | 'sort' | 'filter' | 'account' | 'bell';
type Place = { top: number; right: number };
type Toast = { key: number; title: string; detail?: string; view?: string };

/**
 * A state to open in, for docs and tests: the screen starts there, fully loaded unless `loading`,
 * and the demo stays off.
 */
export type LoansDashboardState = {
  /** Holds the first load's skeleton. */
  loading?: boolean;
  /** The status tab. */
  tab?: Tab;
  /** The search, and the venue and rate filters. */
  query?: string;
  venues?: Venue[];
  rates?: Position['rate'][];
  /** A position whose card is pinned on the map (its row lit), as "View details" does. */
  card?: string;
  /** A menu open under its button; 'row' is the first row's. */
  menu?: MenuKind;
  /** The repay (or add-collateral) dialog, open on an open position. */
  dialog?: { id: string; mode?: DialogMode };
  /** The sidebar drawer open, under 760 px. */
  drawer?: boolean;
};

const SORTS: { id: SortId; label: string; menu: boolean }[] = [
  { id: 'newest', label: 'Newest', menu: true },
  { id: 'oldest', label: 'Oldest', menu: true },
  { id: 'health', label: 'Lowest health', menu: true },
  { id: 'largest', label: 'Largest loan', menu: true },
  { id: 'smallest', label: 'Smallest loan', menu: false },
  { id: 'apr', label: 'Highest APR', menu: true },
  { id: 'repaid', label: 'Most repaid', menu: true },
];

const TABS: { id: Tab; label: string }[] = [
  { id: 'all', label: 'All' },
  { id: 'active', label: 'Active' },
  { id: 'risk', label: 'At risk' },
  { id: 'closed', label: 'Closed' },
];

const NAV: { id: string; label: string; icon: IconName }[] = [
  { id: 'overview', label: 'Overview', icon: 'overview' },
  { id: 'portfolio', label: 'Portfolio', icon: 'portfolio' },
  { id: 'loans', label: 'Loans', icon: 'loans' },
  { id: 'swap', label: 'Swap', icon: 'swap' },
  { id: 'activity', label: 'Activity', icon: 'activity' },
  { id: 'explore', label: 'Explore', icon: 'explore' },
];
const ACCOUNT_NAV: { id: string; label: string; icon: IconName }[] = [
  { id: 'settings', label: 'Settings', icon: 'settings' },
  { id: 'help', label: 'Help center', icon: 'help' },
];

/** Rows per page. */
const PAGE = 6;
/** How long the first load's skeleton shows, and how long the debt takes to count. */
const LOADING = 700;
const COUNT = 900;
const ADDRESS = '0xa73c…f19c';
const FULL_ADDRESS = '0xa73c52e04b19d7c3f8a6e2b1094d5c7e8a21f19c';

const repaidShare = (position: Position) =>
  position.borrowed ? (position.borrowed - position.left) / position.borrowed : 1;

function sortBy(sort: SortId) {
  return (a: Position, b: Position) => {
    switch (sort) {
      case 'newest':
        return a.age - b.age;
      case 'oldest':
        return b.age - a.age;
      case 'health':
        return (a.health ?? Infinity) - (b.health ?? Infinity) || a.age - b.age;
      case 'largest':
        return b.borrowed - a.borrowed;
      case 'smallest':
        return a.borrowed - b.borrowed;
      case 'apr':
        return b.apr - a.apr;
      case 'repaid':
        return repaidShare(b) - repaidShare(a);
    }
  };
}

const matches = (position: Position, query: string) => {
  const words = query.trim().toLowerCase();
  if (!words) return true;
  const text = [
    ASSETS[position.asset].symbol,
    ASSETS[position.collateral].symbol,
    position.market,
    position.chain,
  ]
    .join(' ')
    .toLowerCase();
  return words.split(/\s+/).every((word) => text.includes(word));
};

const inTab = (position: Position, tab: Tab) =>
  tab === 'all' ||
  (tab === 'closed' && !isOpen(position)) ||
  (tab === 'risk' && isAtRisk(position)) ||
  (tab === 'active' && isOpen(position) && !isAtRisk(position));

/** Whether the reader asks for less motion. */
function useReducedMotion() {
  return useSyncExternalStore(
    (change) => {
      const query = window.matchMedia('(prefers-reduced-motion: reduce)');
      query.addEventListener('change', change);
      return () => query.removeEventListener('change', change);
    },
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
    () => false,
  );
}

/** A number that eases to each new value over `ms` (at once under reduced motion). */
function useTween(value: number, ms: number, enabled: boolean) {
  const [shown, setShown] = useState(value);
  const from = useRef(value);
  useEffect(() => {
    if (!enabled) {
      from.current = value;
      return;
    }
    const start = performance.now();
    const origin = from.current;
    let frame = requestAnimationFrame(function tick(now) {
      const t = Math.min(1, (now - start) / ms);
      const next = origin + (value - origin) * (1 - (1 - t) ** 3);
      from.current = next;
      setShown(next);
      if (t < 1) frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [value, ms, enabled]);
  return enabled ? shown : value;
}

export function LoansDashboard({
  positions: initial = POSITIONS,
  autoplay = true,
  colorScheme,
  defaultSort = 'newest',
  alerts: defaultAlerts = true,
  onRepay,
  defaultState,
  className = '',
}: {
  /** The positions shown; the dashboard keeps its own copy as they are repaid. */
  positions?: Position[];
  /** While on screen and untouched, a demo pointer shows the map, a repayment and the table. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: Scheme;
  /** The table's first sort. */
  defaultSort?: SortId;
  /** Whether the liquidation alerts start switched on. */
  alerts?: boolean;
  /** Called after a repayment or added collateral lands, with the position as it now is. */
  onRepay?: (position: Position, kind: DialogMode, amount: number) => void;
  /** Opens in this state, loaded unless it says `loading`; turns the demo off. */
  defaultState?: LoansDashboardState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const reduced = useReducedMotion();

  const [positions, setPositions] = useState(initial);
  const [loading, setLoading] = useState(defaultState ? !!defaultState.loading : true);
  const [tab, setTab] = useState<Tab>(defaultState?.tab ?? 'all');
  const [query, setQuery] = useState(defaultState?.query ?? '');
  const [sort, setSort] = useState<SortId>(defaultSort);
  const [venues, setVenues] = useState<Venue[]>(defaultState?.venues ?? []);
  const [rates, setRates] = useState<Position['rate'][]>(defaultState?.rates ?? []);
  const [page, setPage] = useState(0);
  const [hover, setHover] = useState<{ id: string; from: 'map' | 'row' } | null>(null);
  const [pinned, setPinned] = useState<string | null>(defaultState?.card ?? null);
  const [menu, setMenu] = useState<{
    kind: MenuKind;
    id?: string;
    anchor: HTMLElement;
    place: Place;
    focus: boolean;
  } | null>(null);
  const [dialog, setDialog] = useState<{
    id: string;
    mode: DialogMode;
    lift: boolean;
    top: number;
    focus: boolean;
    opener: HTMLElement | null;
    /** The position as it was when the dialog opened: its receipt outlives a paid-off loan. */
    position: Position;
  } | null>(null);
  const [toast, setToast] = useState<(Toast & { top: number }) | null>(null);
  const [flash, setFlash] = useState<string | null>(null);
  const [alerts, setAlerts] = useState(defaultAlerts);
  const [drawer, setDrawer] = useState(defaultState?.drawer ?? false);
  const [scheme, setScheme] = useState<Scheme | undefined>(colorScheme);
  const [schemeSeen, setSchemeSeen] = useState(colorScheme);
  if (schemeSeen !== colorScheme) {
    setSchemeSeen(colorScheme);
    setScheme(colorScheme);
  }
  const pointer = useRef<HTMLSpanElement>(null);
  const tableScroll = useRef<HTMLDivElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  /** The demo is driving: its dialog and menus leave the focus where it is. */
  const demoOn = useRef(false);
  /** The reader has changed the positions: the demo leaves them as they are. */
  const touched = useRef(false);

  // The first load: a moment of skeleton, then the figures count in.
  const holdLoading = !!defaultState?.loading;
  useEffect(() => {
    if (holdLoading) return;
    const timer = window.setTimeout(() => setLoading(false), reduced ? 0 : LOADING);
    return () => window.clearTimeout(timer);
  }, [reduced, holdLoading]);

  useEffect(() => {
    if (!flash) return;
    const timer = window.setTimeout(() => setFlash(null), 3600);
    return () => window.clearTimeout(timer);
  }, [flash]);

  useEffect(() => {
    if (!toast) return;
    const timer = window.setTimeout(() => setToast(null), 6000);
    return () => window.clearTimeout(timer);
  }, [toast]);

  // ——— Figures ———
  const open = positions.filter(isOpen);
  const debt = open.reduce((sum, position) => sum + position.left, 0);
  const borrowed = positions.reduce((sum, position) => sum + position.borrowed, 0);
  const collateral = open.reduce((sum, position) => sum + collateralValue(position), 0);
  const shownDebt = useTween(loading ? 0 : debt, COUNT, !reduced);
  const repaid = borrowed ? 1 - debt / borrowed : 0;

  // ——— The table ———
  const filtered = positions.filter(
    (position) =>
      matches(position, query) &&
      (!venues.length || venues.includes(position.venue)) &&
      (!rates.length || rates.includes(position.rate)),
  );
  const counts = Object.fromEntries(
    TABS.map(({ id }) => [id, filtered.filter((position) => inTab(position, id)).length]),
  ) as Record<Tab, number>;
  const rows = filtered.filter((position) => inTab(position, tab)).sort(sortBy(sort));
  const pages = Math.max(1, Math.ceil(rows.length / PAGE));
  const current = Math.min(page, pages - 1);
  const shownRows = rows.slice(current * PAGE, current * PAGE + PAGE);
  const narrowed = Boolean(query.trim() || venues.length || rates.length);
  const sortLabel = SORTS.find((option) => option.id === sort)?.label ?? 'Newest';
  const filterCount = venues.length + rates.length;

  const lit = hover?.id ?? pinned;
  const card = hover?.from === 'map' ? hover.id : pinned;

  // ——— Placing things in the dashboard's own px ———
  const frame = () => {
    const element = root.current!;
    const rect = element.getBoundingClientRect();
    return { rect, scale: rect.width / element.offsetWidth || 1 };
  };
  /** The part of the dashboard on screen, from its top. */
  const visible = () => {
    const { rect, scale } = frame();
    const top = (Math.max(rect.top, 0) - rect.top) / scale;
    const bottom = (Math.min(rect.bottom, window.innerHeight) - rect.top) / scale;
    return { top, bottom: Math.max(bottom, top + 200), height: rect.height / scale };
  };
  const placeUnder = (anchor: HTMLElement): Place => {
    const { rect, scale } = frame();
    const box = anchor.getBoundingClientRect();
    return {
      top: (box.bottom - rect.top) / scale + 6,
      right: Math.max(8, (rect.right - box.right) / scale),
    };
  };

  const say = (next: Toast | Omit<Toast, 'key'>) => {
    const view = visible();
    const top = Math.min(view.bottom, view.height) - 80;
    setToast({ ...next, key: Date.now(), top: Math.max(16, top) });
  };

  // ——— Actions ———
  const openMenu = (kind: MenuKind, anchor: HTMLElement, id?: string, focus = !demoOn.current) => {
    if (menu && menu.kind === kind && menu.id === id) return setMenu(null);
    setMenu({ kind, id, anchor, place: placeUnder(anchor), focus });
  };
  const closeMenu = (refocus: boolean) => {
    if (refocus) menu?.anchor.focus({ preventScroll: true });
    setMenu(null);
  };

  const openDialog = (id: string, mode: DialogMode, lift: boolean, opener: HTMLElement | null) => {
    const position = positions.find((item) => item.id === id);
    if (!position || !isOpen(position)) return;
    const view = visible();
    const middle = (view.top + Math.min(view.bottom, view.height)) / 2;
    const top = Math.max(16, Math.min(middle - 240, view.height - 500));
    setMenu(null);
    setHover(null);
    setDialog({ id, mode, lift, top, focus: !demoOn.current, opener, position });
  };
  const closeDialog = () => {
    const opener = dialog?.opener;
    setDialog(null);
    if (opener?.isConnected && !demoOn.current) opener.focus({ preventScroll: true });
  };

  const apply = (id: string, mode: DialogMode, amount: number) => {
    if (!demoOn.current) touched.current = true;
    const before = positions.find((position) => position.id === id);
    if (!before || before.health === null) return;
    let after: Position;
    if (mode === 'repay') {
      const left = Math.max(0, before.left - amount);
      const closed = left < 0.005;
      after = {
        ...before,
        left: closed ? 0 : left,
        health: closed ? null : (before.health * before.left) / left,
        amount: closed ? 0 : before.amount,
      };
    } else {
      after = {
        ...before,
        amount: before.amount + amount,
        health: healthAfterCollateral(before, amount),
      };
    }
    setPositions((list) => list.map((position) => (position.id === id ? after : position)));
    setFlash(id);
    say({
      title:
        mode === 'repay'
          ? `Repaid ${dollars(amount)} on ${before.venue}`
          : `Added ${units(amount, before.collateral)} ${ASSETS[before.collateral].symbol} on ${before.venue}`,
      detail:
        after.health === null
          ? 'Loan closed, collateral released'
          : `Health ${before.health.toFixed(2)} → ${after.health.toFixed(2)}`,
      view: id,
    });
    onRepay?.(after, mode, amount);
  };

  /** Brings a position's row into view and lights it. */
  const showRow = (id: string) => {
    const position = positions.find((item) => item.id === id);
    if (!position) return;
    setTab('all');
    setQuery('');
    setVenues([]);
    setRates([]);
    const all = [...positions].sort(sortBy(sort));
    setPage(Math.floor(all.findIndex((item) => item.id === id) / PAGE));
    setFlash(id);
    requestAnimationFrame(() =>
      root.current
        ?.querySelector(`tr[data-id="${id}"]`)
        ?.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' }),
    );
  };

  const copyId = async (id: string) => {
    try {
      await navigator.clipboard.writeText(id);
      say({ title: 'Position ID copied', detail: id });
    } catch {
      say({ title: 'Could not copy', detail: id });
    }
  };

  const changeView = (change: () => void) => {
    change();
    setPage(0);
  };

  // ——— The demo ———
  const demo = autoplay && !defaultState && playing && !loading && !reduced;
  const demoHover = useEffectEvent((id: string | null, from: 'map' | 'row') =>
    setHover(id ? { id, from } : null),
  );
  const demoState = useEffectEvent(() => ({
    pristine: !touched.current,
    weakest: positions.find((position) => liftRepay(position) > 0 && position.id === 'pos-5e18c2'),
    sort,
  }));
  const demoSort = useEffectEvent((next: SortId) => changeView(() => setSort(next)));
  const demoQuery = useEffectEvent((next: string) => changeView(() => setQuery(next)));
  const demoReset = useEffectEvent(() => {
    if (touched.current) return;
    setPositions(initial);
    setFlash(null);
  });
  const demoStop = useEffectEvent(() => {
    setHover(null);
    setMenu(null);
  });

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    demoOn.current = true;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    /** An element's middle, in the stage's own px, nudged by a share of its size. */
    const middle = (element: HTMLElement, dx = 0, dy = 0): Point => {
      const rect = stage.getBoundingClientRect();
      const scale = rect.width / stage.offsetWidth || 1;
      const box = element.getBoundingClientRect();
      return {
        x: (box.left + box.width * (0.5 + dx) - rect.left) / scale,
        y: (box.top + box.height * (0.5 + dy) - rect.top) / scale,
      };
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** Flies to an element; false when it is not there (a narrow layout, a filter). */
      const to = async (selector: string, ms = 650, dx = 0, dy = 0) => {
        const element = find(selector);
        if (!element) return null;
        glyph('arrow');
        await glide(middle(element, dx, dy), ms);
        return element;
      };
      const press = async (selector: string, ms = 650) => {
        const element = await to(selector, ms);
        if (!element) return false;
        glyph('hand');
        await wait(220);
        element.click();
        return true;
      };
      for (;;) {
        await wait(700);
        // The map: a safe position's card, then the weakest one's.
        if (await to('[data-part="marker"][data-id="pos-7f3a21"] [class*="markerIcon"]', 800)) {
          glyph('hand');
          demoHover('pos-7f3a21', 'map');
          await wait(1700);
        }
        if (await to('[data-part="marker"][data-id="pos-5e18c2"] [class*="markerIcon"]', 700)) {
          glyph('hand');
          demoHover('pos-5e18c2', 'map');
          await wait(1700);
        }
        demoHover(null, 'map');
        // Repay it, from the pill: the lift amount is preset.
        const state = demoState();
        if (state.pristine && state.weakest && (await press('[data-part="lift-tip"]', 600))) {
          await wait(900);
          if (await to('[data-part="preset-quarter"]', 600)) await wait(500);
          if (await press('[data-part="preset-lift"]', 400)) await wait(700);
          if (await press('[data-part="dialog-confirm"]', 600)) {
            await wait(2900);
            await press('[data-part="dialog-done"]', 600);
            await wait(900);
          }
        }
        // A row lights its marker.
        const row = await to('tbody tr:nth-child(2) [data-part="borrowed"]', 800);
        if (row) {
          demoHover(row.closest('tr')?.dataset.id ?? null, 'row');
          await wait(1500);
          demoHover(null, 'row');
        }
        // Sort by lowest health.
        const before = demoState().sort;
        if (await press('[data-part="sort-button"]', 800)) {
          await wait(600);
          await to('[data-part="sort-menu"] [data-sort="oldest"]', 400);
          await wait(250);
          if (await press('[data-part="sort-menu"] [data-sort="health"]', 400)) await wait(1600);
        }
        // Search.
        if (await to('[data-part="search"]', 700)) {
          glyph('arrow');
          for (const [index] of [...'halden'].entries()) {
            demoQuery('halden'.slice(0, index + 1));
            await wait(110);
          }
          await wait(1800);
        }
        // A row's menu.
        if (await press('tbody tr:first-child [data-part="row-more"]', 900)) {
          await wait(500);
          for (const item of ['view', 'repay', 'collateral', 'copy']) {
            await to(`[data-part="row-menu"] [data-item="${item}"]`, 300);
            await wait(320);
          }
          await wait(500);
          demoStop();
        }
        await wait(500);
        demoQuery('');
        demoSort(before);
        await to('[data-part="plot"]', 1000, 0.1, 0.9);
        await wait(1200);
        demoReset();
        await wait(1600);
      }
    });
    return () => {
      stop();
      demoOn.current = false;
      demoStop();
    };
  }, [demo, root]);

  // ——— A state to open in: its menu or dialog, once the screen is laid out ———
  const opening = useEffectEvent(() => {
    const element = root.current;
    if (!element || !defaultState) return;
    const { dialog: open, menu: kind } = defaultState;
    if (open) {
      const position = positions.find((item) => item.id === open.id);
      if (!position || !isOpen(position)) return;
      const mode = open.mode ?? 'repay';
      setDialog({
        id: open.id,
        mode,
        lift: mode === 'collateral' || liftRepay(position) > 0,
        top: Math.max(16, element.offsetHeight / 2 - 260),
        focus: false,
        opener: null,
        position,
      });
    } else if (kind) {
      const anchor = element.querySelector<HTMLElement>(
        kind === 'row'
          ? 'tbody tr:first-child [data-part="row-more"]'
          : `[data-part="${kind === 'sort' || kind === 'filter' ? `${kind}-button` : kind}"]`,
      );
      if (!anchor) return;
      const id = kind === 'row' ? anchor.closest('tr')?.dataset.id : undefined;
      setMenu({ kind, id, anchor, place: placeUnder(anchor), focus: false });
    }
  });
  const opensOn = loading ? null : (defaultState?.dialog ?? defaultState?.menu ?? null);
  const hasOpening = Boolean(opensOn);
  useEffect(() => {
    if (!hasOpening) return;
    let frame = requestAnimationFrame(() => (frame = requestAnimationFrame(opening)));
    return () => cancelAnimationFrame(frame);
  }, [hasOpening]);

  // ——— Pieces ———
  const menuPosition = menu?.id ? positions.find((item) => item.id === menu.id) : undefined;
  const watchList = open.filter((position) => (position.health ?? 0) < 1.5);

  const navButton = (item: { id: string; label: string; icon: IconName }) => (
    <li key={item.id}>
      <button
        type="button"
        className={styles.navItem}
        aria-current={item.id === 'loans' ? 'page' : undefined}
        title={item.label}
        onClick={() => {
          setDrawer(false);
          if (item.id !== 'loans') say({ title: `${item.label} is not part of this demo` });
        }}
      >
        <Icon name={item.icon} />
        <span className={styles.navLabel}>{item.label}</span>
      </button>
    </li>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="dashboard"
      data-color-scheme={scheme}
      data-drawer={drawer || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onWheelCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
      onPointerDown={(event) => {
        // A press anywhere else lets go of a pinned card.
        if (!(event.target as HTMLElement).closest('[data-part="row-menu"]')) setPinned(null);
      }}
    >
      <div className={styles.shell}>
        <aside
          className={styles.sidebar}
          data-part="sidebar"
          aria-label="Main"
          onKeyDown={(event) => {
            if (event.key === 'Escape' && drawer) setDrawer(false);
          }}
        >
          <div className={styles.brand}>
            <Logo className={styles.logo} />
            <button
              type="button"
              className={`${styles.iconButton} ${styles.drawerClose}`}
              aria-label="Close menu"
              onClick={() => setDrawer(false)}
            >
              <Icon name="close" />
            </button>
          </div>
          <nav className={styles.nav}>
            <ul>{NAV.map(navButton)}</ul>
            <p className={styles.navHeading}>Account</p>
            <ul>{ACCOUNT_NAV.map(navButton)}</ul>
          </nav>
          <div className={styles.alerts} data-part="alerts">
            <div className={styles.alertsHead}>
              <Shield className={styles.shield} />
              <span className={styles.alertsTitle} id="loans-alerts-label">
                Liquidation alerts
              </span>
              <button
                type="button"
                role="switch"
                className={styles.switch}
                data-part="alerts-switch"
                aria-checked={alerts}
                aria-labelledby="loans-alerts-label"
                onClick={() => {
                  setAlerts(!alerts);
                  say({ title: alerts ? 'Liquidation alerts off' : 'Liquidation alerts on' });
                }}
              >
                <span />
              </button>
            </div>
            <p className={styles.alertsNote}>Notify me when any health factor drops below 1.20.</p>
          </div>
        </aside>
        <div className={styles.drawerScrim} aria-hidden="true" onClick={() => setDrawer(false)} />

        <main className={styles.main} data-part="main">
          <header className={styles.topbar} data-part="topbar">
            <button
              type="button"
              className={`${styles.iconButton} ${styles.menuButton}`}
              aria-label="Open menu"
              aria-expanded={drawer}
              onClick={() => setDrawer(true)}
            >
              <Icon name="menu" />
            </button>
            <nav aria-label="Breadcrumb" className={styles.breadcrumb}>
              <span>Portfolio</span>
              <span aria-hidden="true">/</span>
              <span aria-current="page">Loans</span>
            </nav>
            <div className={styles.topActions}>
              <button
                type="button"
                className={styles.bell}
                data-part="bell"
                aria-label={`Notifications, ${watchList.length} to watch`}
                aria-haspopup="menu"
                aria-expanded={menu?.kind === 'bell'}
                onClick={(event) => openMenu('bell', event.currentTarget)}
              >
                <Icon name="bell" />
                {watchList.length > 0 && <i aria-hidden="true" />}
              </button>
              <button
                type="button"
                className={styles.account}
                data-part="account"
                aria-label={`Account ${ADDRESS}`}
                aria-haspopup="menu"
                aria-expanded={menu?.kind === 'account'}
                onClick={(event) => openMenu('account', event.currentTarget)}
              >
                <span className={styles.avatar} aria-hidden="true" />
                <span className={styles.address}>{ADDRESS}</span>
                <Icon name="chevronDown" />
              </button>
            </div>
          </header>

          <div className={styles.titleRow}>
            <div>
              <h1 className={styles.title}>Loans</h1>
              <p className={styles.subtitle}>
                Every borrow position across Halden, Corvo, Merrow, Sable and Vela.
              </p>
            </div>
            <div className={styles.titleActions}>
              <button
                type="button"
                className={styles.secondary}
                data-part="export"
                onClick={() =>
                  say({ title: `Exported ${positions.length} positions`, detail: 'loans.csv' })
                }
              >
                <Icon name="download" />
                Export
              </button>
              <button
                type="button"
                className={styles.primary}
                data-part="new-loan"
                onClick={() => say({ title: 'New loans are not part of this demo' })}
              >
                <Icon name="plus" />
                New loan
              </button>
            </div>
          </div>

          <section className={styles.overview} data-part="overview" aria-label="Overview">
            <div className={styles.debt} data-part="debt" data-loading={loading || undefined}>
              <h2 className={styles.label}>Outstanding debt</h2>
              {loading ? (
                <>
                  <span className={styles.skeleton} data-size="figure" />
                  <span className={styles.skeleton} data-size="bar" />
                  <span className={styles.skeleton} data-size="line" />
                </>
              ) : (
                <>
                  <p className={styles.figure} data-part="figure" aria-live="polite">
                    <span aria-hidden="true">{dollars(shownDebt)}</span>
                    <span className={styles.hidden}>{dollars(debt)}</span>
                  </p>
                  <div
                    className={styles.bar}
                    role="meter"
                    aria-label="Repaid"
                    aria-valuemin={0}
                    aria-valuemax={100}
                    aria-valuenow={Math.round(repaid * 100)}
                  >
                    <span style={{ transform: `scaleX(${repaid})` }} />
                  </div>
                  <p className={styles.repaidLine}>
                    <b>{Math.round(repaid * 100)}% repaid</b> of {dollars(borrowed)} borrowed
                  </p>
                </>
              )}
              <dl className={styles.stats}>
                <div>
                  <dt>Collateral</dt>
                  <dd>{loading ? '—' : dollars(collateral)}</dd>
                </div>
                <div>
                  <dt>Loan-to-value</dt>
                  <dd>{loading ? '—' : `${((debt / collateral) * 100 || 0).toFixed(1)}%`}</dd>
                </div>
                <div>
                  <dt>Positions</dt>
                  <dd>{loading ? '—' : positions.length}</dd>
                </div>
              </dl>
            </div>
            <RiskMap
              positions={open}
              lit={lit}
              card={card}
              loading={loading}
              onHover={(id) => {
                setHover(id ? { id, from: 'map' } : null);
                if (id) setPinned(null);
              }}
              onOpen={(id, opener, lift) => openDialog(id, 'repay', lift, opener)}
            />
          </section>

          <section className={styles.positions} data-part="positions" aria-label="Positions">
            <div className={styles.toolbar} data-part="toolbar">
              <div
                className={styles.tabs}
                role="tablist"
                aria-label="Status"
                data-part="tabs"
                onKeyDown={(event) => {
                  const at = TABS.findIndex((item) => item.id === tab);
                  const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
                  if (!step) return;
                  event.preventDefault();
                  const next = (at + step + TABS.length) % TABS.length;
                  changeView(() => setTab(TABS[next].id));
                  tabRefs.current[next]?.focus();
                }}
              >
                {TABS.map((item, index) => (
                  <button
                    key={item.id}
                    ref={(element) => {
                      tabRefs.current[index] = element;
                    }}
                    type="button"
                    role="tab"
                    data-part={`tab-${item.id}`}
                    aria-selected={tab === item.id}
                    aria-controls="loans-table"
                    tabIndex={tab === item.id ? 0 : -1}
                    onClick={() => changeView(() => setTab(item.id))}
                  >
                    {item.label} {loading ? '·' : counts[item.id]}
                  </button>
                ))}
              </div>
              <div className={styles.tools}>
                <label className={styles.search} data-part="search">
                  <Icon name="search" />
                  <span className={styles.hidden}>Search token or venue</span>
                  <input
                    type="search"
                    placeholder="Search token or venue"
                    value={query}
                    onChange={(event) => changeView(() => setQuery(event.target.value))}
                  />
                  {query && (
                    <button
                      type="button"
                      className={styles.clear}
                      aria-label="Clear search"
                      onClick={() => changeView(() => setQuery(''))}
                    >
                      <Icon name="close" />
                    </button>
                  )}
                </label>
                <button
                  type="button"
                  className={styles.tool}
                  data-part="filter-button"
                  aria-haspopup="menu"
                  aria-expanded={menu?.kind === 'filter'}
                  onClick={(event) => openMenu('filter', event.currentTarget)}
                >
                  <Icon name="filter" />
                  Filter
                  {filterCount > 0 && <span className={styles.count}>{filterCount}</span>}
                </button>
                <button
                  type="button"
                  className={styles.tool}
                  data-part="sort-button"
                  aria-haspopup="menu"
                  aria-expanded={menu?.kind === 'sort'}
                  aria-label={`Sort: ${sortLabel}`}
                  onClick={(event) => openMenu('sort', event.currentTarget)}
                >
                  <Icon name="sort" />
                  {sortLabel}
                </button>
              </div>
            </div>

            <div
              ref={tableScroll}
              className={styles.tableScroll}
              id="loans-table"
              role="tabpanel"
              tabIndex={-1}
              aria-label={`${TABS.find((item) => item.id === tab)?.label} positions`}
              onScroll={() => menu?.kind === 'row' && setMenu(null)}
            >
              <table className={styles.table} data-part="table">
                <thead>
                  <tr>
                    <th scope="col">Position</th>
                    <th scope="col">Collateral</th>
                    <th
                      scope="col"
                      aria-sort={
                        sort === 'largest'
                          ? 'descending'
                          : sort === 'smallest'
                            ? 'ascending'
                            : undefined
                      }
                      data-sorted={sort === 'largest' || sort === 'smallest' || undefined}
                    >
                      <button
                        type="button"
                        className={styles.sortHead}
                        data-part="borrowed-sort"
                        onClick={() =>
                          changeView(() => setSort(sort === 'largest' ? 'smallest' : 'largest'))
                        }
                      >
                        Borrowed
                        <Icon name="sort" />
                      </button>
                    </th>
                    <th scope="col" data-sorted={sort === 'repaid' || undefined}>
                      Repaid
                    </th>
                    <th
                      scope="col"
                      aria-sort={sort === 'apr' ? 'descending' : undefined}
                      data-sorted={sort === 'apr' || undefined}
                    >
                      APR
                    </th>
                    <th
                      scope="col"
                      aria-sort={sort === 'health' ? 'ascending' : undefined}
                      data-sorted={sort === 'health' || undefined}
                    >
                      Health
                    </th>
                    <th scope="col">Status</th>
                    <th scope="col">
                      <span className={styles.hidden}>Actions</span>
                    </th>
                  </tr>
                </thead>
                <tbody>
                  {loading &&
                    Array.from({ length: PAGE }, (_, index) => (
                      <tr key={index} className={styles.skeletonRow} aria-hidden="true">
                        <td>
                          <span className={styles.skeleton} data-size="coin" />
                          <span className={styles.skeletonStack}>
                            <span className={styles.skeleton} data-size="short" />
                            <span className={styles.skeleton} data-size="medium" />
                          </span>
                        </td>
                        <td>
                          <span className={styles.skeleton} data-size="chip" />
                        </td>
                        <td>
                          <span className={styles.skeleton} data-size="short" />
                        </td>
                        <td>
                          <span className={styles.skeleton} data-size="long" />
                        </td>
                        <td>
                          <span className={styles.skeleton} data-size="tiny" />
                        </td>
                        <td>
                          <span className={styles.skeleton} data-size="tiny" />
                        </td>
                        <td>
                          <span className={styles.skeleton} data-size="short" />
                        </td>
                        <td />
                      </tr>
                    ))}
                  {!loading &&
                    shownRows.map((position) => (
                      <Row
                        key={position.id}
                        position={position}
                        lit={lit === position.id}
                        flash={flash === position.id}
                        menuOpen={menu?.kind === 'row' && menu.id === position.id}
                        onHover={(on) => {
                          setHover(on ? { id: position.id, from: 'row' } : null);
                          if (on) setPinned(null);
                        }}
                        onRepay={(opener) =>
                          openDialog(position.id, 'repay', liftRepay(position) > 0, opener)
                        }
                        onMore={(anchor) => openMenu('row', anchor, position.id)}
                      />
                    ))}
                  {!loading && !shownRows.length && (
                    <tr className={styles.empty}>
                      <td colSpan={8}>
                        No positions match.{' '}
                        {narrowed && (
                          <button
                            type="button"
                            onClick={() =>
                              changeView(() => {
                                setQuery('');
                                setVenues([]);
                                setRates([]);
                              })
                            }
                          >
                            Clear search and filters
                          </button>
                        )}
                      </td>
                    </tr>
                  )}
                </tbody>
              </table>
            </div>

            <footer className={styles.tableFoot} data-part="pagination">
              <p aria-live="polite">
                {loading
                  ? ''
                  : `Showing ${shownRows.length} of ${rows.length} ${narrowed ? 'matching ' : ''}positions`}
              </p>
              <div className={styles.pager}>
                {!loading && (
                  <span>
                    Page {current + 1} of {pages}
                  </span>
                )}
                <button
                  type="button"
                  className={styles.pageButton}
                  aria-label="Previous page"
                  disabled={loading || current === 0}
                  onClick={() => setPage(current - 1)}
                >
                  <Icon name="chevronLeft" />
                </button>
                <button
                  type="button"
                  className={styles.pageButton}
                  aria-label="Next page"
                  disabled={loading || current >= pages - 1}
                  onClick={() => setPage(current + 1)}
                >
                  <Icon name="chevronRight" />
                </button>
              </div>
            </footer>
          </section>
        </main>
      </div>

      {menu?.kind === 'row' && menuPosition && (
        <Menu
          place={menu.place}
          part="row-menu"
          label="Position actions"
          focus={menu.focus}
          anchor={menu.anchor}
          onClose={closeMenu}
        >
          {isOpen(menuPosition) && (
            <>
              <MenuItem
                icon="eye"
                item="view"
                onSelect={() => {
                  setMenu(null);
                  setPinned(menuPosition.id);
                  root.current
                    ?.querySelector('[data-part="risk-map"]')
                    ?.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' });
                }}
              >
                View details
              </MenuItem>
              <MenuItem
                icon="arrowUpRight"
                item="repay"
                onSelect={() =>
                  openDialog(menuPosition.id, 'repay', liftRepay(menuPosition) > 0, menu.anchor)
                }
              >
                Repay
              </MenuItem>
              <MenuItem
                icon="plus"
                item="collateral"
                onSelect={() => openDialog(menuPosition.id, 'collateral', true, menu.anchor)}
              >
                Add collateral
              </MenuItem>
            </>
          )}
          <MenuItem
            icon="copy"
            item="copy"
            onSelect={() => {
              closeMenu(true);
              void copyId(menuPosition.id);
            }}
          >
            Copy position ID
          </MenuItem>
        </Menu>
      )}

      {menu?.kind === 'sort' && (
        <Menu
          place={menu.place}
          part="sort-menu"
          label="Sort by"
          focus={menu.focus}
          anchor={menu.anchor}
          onClose={closeMenu}
        >
          <p className={styles.menuHeading} data-strong="" aria-hidden="true">
            Sort by
          </p>
          {SORTS.filter((option) => option.menu).map((option) => (
            <button
              key={option.id}
              type="button"
              role="menuitemradio"
              aria-checked={sort === option.id}
              className={styles.menuItem}
              data-sort={option.id}
              tabIndex={-1}
              onClick={() => {
                changeView(() => setSort(option.id));
                closeMenu(!demoOn.current);
              }}
            >
              {option.label}
              {sort === option.id && <Icon name="check" className={styles.menuCheck} />}
            </button>
          ))}
        </Menu>
      )}

      {menu?.kind === 'filter' && (
        <Menu
          place={menu.place}
          part="filter-menu"
          label="Filter"
          focus={menu.focus}
          anchor={menu.anchor}
          onClose={closeMenu}
        >
          <p className={styles.menuHeading} aria-hidden="true">
            Venue
          </p>
          {VENUES.map((venue) => (
            <CheckItem
              key={venue}
              checked={venues.includes(venue)}
              onToggle={() =>
                changeView(() =>
                  setVenues((list) =>
                    list.includes(venue) ? list.filter((item) => item !== venue) : [...list, venue],
                  ),
                )
              }
            >
              {venue}
            </CheckItem>
          ))}
          <hr className={styles.menuRule} />
          <p className={styles.menuHeading} aria-hidden="true">
            Rate
          </p>
          {(['Variable', 'Fixed'] as const).map((rate) => (
            <CheckItem
              key={rate}
              checked={rates.includes(rate)}
              onToggle={() =>
                changeView(() =>
                  setRates((list) =>
                    list.includes(rate) ? list.filter((item) => item !== rate) : [...list, rate],
                  ),
                )
              }
            >
              {rate}
            </CheckItem>
          ))}
        </Menu>
      )}

      {menu?.kind === 'bell' && (
        <Menu
          place={menu.place}
          part="bell-menu"
          label="Notifications"
          focus={menu.focus}
          anchor={menu.anchor}
          onClose={closeMenu}
        >
          <p className={styles.menuHeading} aria-hidden="true">
            {watchList.length ? 'Below 1.50' : 'All positions are safe'}
          </p>
          {watchList.map((position) => (
            <button
              key={position.id}
              type="button"
              role="menuitem"
              tabIndex={-1}
              className={styles.menuItem}
              onClick={() => {
                setMenu(null);
                setPinned(position.id);
              }}
            >
              <i
                className={styles.dot}
                data-zone={zoneOf(position.health ?? 0)}
                aria-hidden="true"
              />
              {position.venue} · {ASSETS[position.asset].symbol} at{' '}
              {(position.health ?? 0).toFixed(2)}
            </button>
          ))}
        </Menu>
      )}

      {menu?.kind === 'account' && (
        <Menu
          place={menu.place}
          part="account-menu"
          label="Account"
          focus={menu.focus}
          anchor={menu.anchor}
          onClose={closeMenu}
          wide
        >
          <div className={styles.accountHead}>
            <span className={styles.avatar} data-size="large" aria-hidden="true" />
            <span>
              <b>{ADDRESS}</b>
              <span>
                <i className={styles.dot} data-zone="safe" aria-hidden="true" /> Connected ·
                Mainline
              </span>
            </span>
            <button
              type="button"
              role="menuitem"
              tabIndex={-1}
              className={styles.iconButton}
              aria-label="Copy address"
              onClick={() => {
                closeMenu(true);
                void copyId(FULL_ADDRESS);
              }}
            >
              <Icon name="copy" />
            </button>
          </div>
          <hr className={styles.menuRule} />
          <p className={styles.menuHeading} aria-hidden="true">
            Appearance
          </p>
          <div className={styles.appearance} role="group" aria-label="Appearance">
            {(
              [
                ['light', 'Light', 'sun'],
                ['dark', 'Dark', 'moon'],
                ['system', 'System', 'monitor'],
              ] as const
            ).map(([id, label, icon]) => (
              <button
                key={id}
                type="button"
                role="menuitemradio"
                tabIndex={-1}
                aria-checked={(scheme ?? 'system') === id}
                data-scheme={id}
                onClick={() => setScheme(id === 'system' && !colorScheme ? undefined : id)}
              >
                <Icon name={icon} />
                {label}
              </button>
            ))}
          </div>
          <hr className={styles.menuRule} />
          <button
            type="button"
            role="menuitem"
            tabIndex={-1}
            className={`${styles.menuItem} ${styles.danger}`}
            onClick={() => {
              closeMenu(true);
              say({ title: 'The demo stays connected' });
            }}
          >
            <Icon name="logout" />
            Disconnect
          </button>
        </Menu>
      )}

      {dialog && (
        <RepayDialog
          key={`${dialog.id}-${dialog.mode}`}
          position={dialog.position}
          mode={dialog.mode}
          lift={dialog.lift}
          top={dialog.top}
          focusOnOpen={dialog.focus}
          onApply={(amount) => apply(dialog.id, dialog.mode, amount)}
          onClose={closeDialog}
        />
      )}
      <div className={styles.toastLayer} role="status" aria-live="polite">
        {toast && (
          <div
            key={toast.key}
            className={styles.toast}
            data-part="toast"
            style={{ top: `${toast.top}px` }}
          >
            <Icon name="checkCircle" />
            <span>
              <b>{toast.title}</b>
              {toast.detail && <span>{toast.detail}</span>}
            </span>
            {toast.view && (
              <button
                type="button"
                onClick={() => {
                  showRow(toast.view!);
                  setToast(null);
                }}
              >
                View
              </button>
            )}
          </div>
        )}
      </div>

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}

/** One position's row. */
function Row({
  position,
  lit,
  flash,
  menuOpen,
  onHover,
  onRepay,
  onMore,
}: {
  position: Position;
  lit: boolean;
  flash: boolean;
  menuOpen: boolean;
  onHover: (on: boolean) => void;
  onRepay: (opener: HTMLElement) => void;
  onMore: (anchor: HTMLElement) => void;
}) {
  const open = isOpen(position);
  const health = position.health ?? 0;
  const zone = zoneOf(health);
  const share = repaidShare(position);
  const atRisk = isAtRisk(position);
  const name = `${ASSETS[position.asset].symbol} loan on ${position.market}`;
  return (
    <tr
      data-id={position.id}
      data-lit={lit || menuOpen || undefined}
      data-flash={flash || undefined}
      data-risk={atRisk || undefined}
      onPointerEnter={(event) => {
        if (event.pointerType !== 'touch') onHover(true);
      }}
      onPointerLeave={() => onHover(false)}
    >
      <td>
        <span className={styles.position}>
          <span className={styles.coin}>
            <AssetMark asset={position.asset} />
          </span>
          <span>
            <b>{ASSETS[position.asset].symbol} loan</b>
            <span>
              {position.market} · {position.chain}
            </span>
          </span>
        </span>
      </td>
      <td>
        <span className={styles.collateral}>
          <span className={styles.chip} data-asset={position.collateral}>
            <AssetMark asset={position.collateral} />
            {ASSETS[position.collateral].symbol}
          </span>
          {open ? units(position.amount, position.collateral) : 'Released'}
        </span>
      </td>
      <td data-part="borrowed">{dollars(position.borrowed)}</td>
      <td>
        <span className={styles.repaid}>
          <span className={styles.repaidBar} aria-hidden="true">
            <span style={{ transform: `scaleX(${share})` }} />
          </span>
          <span className={styles.repaidShare}>{Math.round(share * 100)}%</span>
          <span className={styles.muted}>
            {open ? `${dollars(position.left)} left` : 'Fully repaid'}
          </span>
        </span>
      </td>
      <td>
        <span className={styles.stack}>
          {position.apr.toFixed(2)}%<span className={styles.muted}>{position.rate}</span>
        </span>
      </td>
      <td>
        {open ? (
          <span className={styles.health}>
            <i className={styles.dot} data-zone={zone} aria-hidden="true" />
            {health.toFixed(2)}
          </span>
        ) : (
          <span aria-label="None">—</span>
        )}
      </td>
      <td>
        <span
          className={styles.status}
          data-status={open ? (atRisk ? 'risk' : 'active') : 'closed'}
        >
          <i aria-hidden="true" />
          {open ? (atRisk ? 'At risk' : 'Active') : 'Closed'}
        </span>
      </td>
      <td>
        <span className={styles.actions}>
          {open && (
            <button
              type="button"
              className={styles.repay}
              data-part="row-repay"
              aria-label={`Repay ${name}`}
              onClick={(event) => onRepay(event.currentTarget)}
            >
              Repay
            </button>
          )}
          <button
            type="button"
            className={styles.more}
            data-part="row-more"
            aria-label={`More for ${name}`}
            aria-haspopup="menu"
            aria-expanded={menuOpen}
            onClick={(event) => onMore(event.currentTarget)}
          >
            <Icon name="more" />
          </button>
        </span>
      </td>
    </tr>
  );
}

/**
 * A menu under its button, at the dashboard's root. Arrow keys, Home and End move through its
 * items; Escape closes it and returns to the button; Tab or a press outside closes it.
 */
function Menu({
  place,
  part,
  label,
  focus,
  anchor,
  wide = false,
  onClose,
  children,
}: {
  place: Place;
  part: string;
  label: string;
  focus: boolean;
  anchor: HTMLElement;
  wide?: boolean;
  onClose: (refocus: boolean) => void;
  children: ReactNode;
}) {
  const box = useRef<HTMLDivElement>(null);
  const close = useEffectEvent(onClose);

  useEffect(() => {
    const element = box.current;
    if (!element) return;
    if (focus)
      element
        .querySelector<HTMLElement>('[role^="menuitem"]:not([aria-disabled="true"])')
        ?.focus({ preventScroll: true });
    const outside = (event: PointerEvent) => {
      const target = event.target as Node;
      if (!element.contains(target) && !anchor.contains(target)) close(false);
    };
    document.addEventListener('pointerdown', outside, true);
    return () => document.removeEventListener('pointerdown', outside, true);
  }, [focus, anchor]);

  const onKeyDown = (event: KeyboardEvent) => {
    const items = [...(box.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
    const at = items.indexOf(document.activeElement as HTMLElement);
    const move = (index: number) => {
      event.preventDefault();
      items[(index + items.length) % items.length]?.focus();
    };
    if (event.key === 'ArrowDown') move(at + 1);
    else if (event.key === 'ArrowUp') move(at - 1);
    else if (event.key === 'Home') move(0);
    else if (event.key === 'End') move(items.length - 1);
    else if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      onClose(true);
    } else if (event.key === 'Tab') onClose(false);
  };

  return (
    <div
      ref={box}
      className={styles.menu}
      data-part={part}
      data-wide={wide || undefined}
      role="menu"
      aria-label={label}
      style={{ top: `${place.top}px`, right: `${place.right}px` } as CSSProperties}
      onKeyDown={onKeyDown}
    >
      {children}
    </div>
  );
}

function MenuItem({
  icon,
  item,
  onSelect,
  children,
}: {
  icon: IconName;
  item: string;
  onSelect: () => void;
  children: ReactNode;
}) {
  return (
    <button
      type="button"
      role="menuitem"
      tabIndex={-1}
      className={styles.menuItem}
      data-item={item}
      onClick={onSelect}
    >
      <Icon name={icon} />
      {children}
    </button>
  );
}

function CheckItem({
  checked,
  onToggle,
  children,
}: {
  checked: boolean;
  onToggle: () => void;
  children: ReactNode;
}) {
  return (
    <button
      type="button"
      role="menuitemcheckbox"
      aria-checked={checked}
      tabIndex={-1}
      className={styles.menuItem}
      onClick={onToggle}
    >
      <span className={styles.checkbox} aria-hidden="true">
        {checked && <Icon name="check" />}
      </span>
      {children}
    </button>
  );
}
```

### loans-dashboard/risk-map.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useRef, useState, type CSSProperties } from 'react';
import {
  ASSETS,
  ZONES,
  ZONE_NAME,
  collateralValue,
  compact,
  cents,
  dollars,
  liftRepay,
  liquidationPrice,
  scale,
  units,
  zoneOf,
  type Position,
} from './data';
import { AssetMark } from './icons';
import styles from './loans-dashboard.module.css';

/*
 * The liquidation-risk map: every open position as a marker on one bar of health factors, on a
 * logarithmic scale from 0.9 to 5 whose four zones are coloured. Each marker is a button: its
 * asset's mark, a stem, a dot on the bar and, under it, its health and venue. Labels that would
 * touch drop to a lower line, so the map grows a line rather than overlap.
 *
 * - Hovering or focusing a marker brings up a card on that position (health, debt, collateral and
 *   the price that would liquidate it) and dims the others; pressing it opens the repay dialog.
 * - A position hovered in the table is lit here the same way, without the card.
 * - A dark pill rides above the weakest position under 1.50 and says what repaying would take;
 *   pressing it opens the repay dialog with that amount.
 */

/** The narrowest gap, in px, between two labels on one line. */
const LABEL = 44;

export function RiskMap({
  positions,
  lit,
  card,
  loading,
  onHover,
  onOpen,
}: {
  /** The open positions. */
  positions: Position[];
  /** The position lit, from here or from the table. */
  lit: string | null;
  /** The position whose card shows (hovered here, or pinned from the table's menu). */
  card: string | null;
  loading: boolean;
  onHover: (id: string | null) => void;
  /** Opens the repay dialog; `lift` presets the amount that lifts it to 1.50. */
  onOpen: (id: string, opener: HTMLElement, lift: boolean) => void;
}) {
  const plot = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(720);

  useEffect(() => {
    const element = plot.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  // Lines for the labels: each takes the first line where it clears the one before.
  const sorted = [...positions].sort((a, b) => (a.health ?? 0) - (b.health ?? 0));
  const ends: number[] = [];
  const line = new Map<string, number>();
  for (const position of sorted) {
    const x = (scale(position.health ?? 0) / 100) * width;
    let row = ends.findIndex((end) => x - end >= LABEL);
    if (row === -1) row = ends.length;
    ends[row] = x;
    line.set(position.id, row);
  }
  const lines = Math.max(1, ends.length);

  // The weakest position under the target, and what lifting it takes.
  const weakest = sorted.find((position) => liftRepay(position) > 0);
  const shown = positions.find((position) => position.id === card) ?? null;

  /** Keeps a card inside the map: its middle at least `half` px from either end. */
  const clampX = (percent: number, half: number) =>
    Math.min(Math.max((percent / 100) * width, half), Math.max(half, width - half));

  return (
    <section className={styles.map} data-part="risk-map" aria-labelledby="loans-risk-title">
      <header className={styles.mapHead}>
        <h2 className={styles.mapTitle} id="loans-risk-title">
          Liquidation risk <span>Health factor of each open position</span>
        </h2>
        <ul className={styles.legend} data-part="legend" aria-label="Zones">
          {ZONES.map((zone) => (
            <li key={zone.id} data-zone={zone.id}>
              <i aria-hidden="true" />
              {zone.label} {zone.range}
            </li>
          ))}
        </ul>
      </header>
      <div
        ref={plot}
        className={styles.plot}
        data-part="plot"
        data-loading={loading || undefined}
        style={{ '--lines': lines } as CSSProperties}
      >
        <div className={styles.track} data-part="track" aria-hidden="true">
          {ZONES.map((zone, index) => {
            const from = index === 0 ? 0 : scale(zone.from);
            const to = index === ZONES.length - 1 ? 100 : scale(ZONES[index + 1].from);
            return (
              <span
                key={zone.id}
                data-zone={zone.id}
                style={{ left: `${from}%`, width: `calc(${to - from}% - 2px)` }}
              />
            );
          })}
        </div>
        {/* The pill hangs to the right of its arrow, held inside the map on narrow screens. */}
        {!loading && weakest && (
          <>
            <button
              type="button"
              className={styles.lift}
              data-part="lift-tip"
              style={{
                left: `${Math.max(0, Math.min((scale(weakest.health ?? 0) / 100) * width - 13, width - 232))}px`,
              }}
              onClick={(event) => onOpen(weakest.id, event.currentTarget, true)}
            >
              Repay {dollars(liftRepay(weakest))} to lift {weakest.venue} to 1.50
            </button>
            <span
              className={styles.liftArrow}
              style={{ left: `${scale(weakest.health ?? 0)}%` }}
              aria-hidden="true"
            />
          </>
        )}
        {!loading &&
          positions.map((position) => {
            const health = position.health ?? 0;
            const zone = zoneOf(health);
            const state = lit === null ? undefined : lit === position.id ? 'lit' : 'dim';
            return (
              <button
                key={position.id}
                type="button"
                className={styles.marker}
                data-part="marker"
                data-id={position.id}
                data-zone={zone}
                data-state={state}
                data-weakest={weakest?.id === position.id || undefined}
                style={
                  {
                    left: `${scale(health)}%`,
                    '--line': line.get(position.id) ?? 0,
                  } as CSSProperties
                }
                aria-label={`${ASSETS[position.asset].symbol} loan on ${position.market}, ${position.chain}: health ${health.toFixed(2)}, ${ZONE_NAME[zone]}`}
                onPointerEnter={(event) => {
                  if (event.pointerType !== 'touch') onHover(position.id);
                }}
                onPointerLeave={() => onHover(null)}
                onFocus={() => onHover(position.id)}
                onBlur={() => onHover(null)}
                onClick={(event) => onOpen(position.id, event.currentTarget, false)}
              >
                <span className={styles.markerIcon}>
                  <AssetMark asset={position.asset} />
                </span>
                <span className={styles.markerStem} />
                <span className={styles.markerDot} />
                <span className={styles.markerLabel}>
                  <b>{health.toFixed(2)}</b>
                  <span>{position.venue}</span>
                </span>
              </button>
            );
          })}
        {shown && shown.health !== null && (
          <PositionCard position={shown} x={clampX(scale(shown.health), 128)} />
        )}
      </div>
    </section>
  );
}

/** The card over a hovered marker. */
function PositionCard({ position, x }: { position: Position; x: number }) {
  const health = position.health ?? 0;
  const { price, drop } = liquidationPrice(position);
  const symbol = ASSETS[position.collateral].symbol;
  return (
    <div
      className={styles.card}
      data-part="position-card"
      style={{ left: `${x}px` }}
      role="tooltip"
    >
      <div className={styles.cardHead}>
        <span className={styles.coin}>
          <AssetMark asset={position.asset} />
        </span>
        <span>
          <b>
            {ASSETS[position.asset].symbol} loan · {position.market}
          </b>
          <span>{position.chain}</span>
        </span>
      </div>
      <dl className={styles.cardRows}>
        <div>
          <dt>Health factor</dt>
          <dd>
            {health.toFixed(2)} · {ZONE_NAME[zoneOf(health)]}
          </dd>
        </div>
        <div>
          <dt>Debt</dt>
          <dd>{dollars(position.left)}</dd>
        </div>
        <div>
          <dt>Collateral</dt>
          <dd>
            {units(position.amount, position.collateral)} {symbol} ·{' '}
            {compact(collateralValue(position))}
          </dd>
        </div>
      </dl>
      <p className={styles.cardNote}>
        Liquidates if {symbol} falls to <b>{cents(price)}</b> (−{(drop * 100).toFixed(1)}%). Click
        for details.
      </p>
    </div>
  );
}
```

### loans-dashboard/repay-dialog.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useEffectEvent, useRef, useState, type KeyboardEvent } from 'react';
import {
  ASSETS,
  WALLET,
  ZONES,
  ZONE_NAME,
  cents,
  dollars,
  healthAfterCollateral,
  healthAfterRepay,
  healthText,
  liftCollateral,
  liftRepay,
  scale,
  units,
  zoneOf,
  type Position,
} from './data';
import { AssetMark, Icon } from './icons';
import styles from './loans-dashboard.module.css';

/*
 * The repay dialog, which also adds collateral. The reader types an amount or picks a preset (the
 * amount that lifts the position to 1.50, a quarter, a half, all of it), and sees the health factor
 * move on a small copy of the risk bar before confirming. Confirming plays the wallet's steps,
 * "Confirm in wallet" then "Repaying", and lands on a receipt; the dashboard is updated as the
 * receipt shows (onApply).
 *
 * It is a modal dialog drawn inside the dashboard (not the page's top layer, so the demo's pointer
 * can sit above it): focus moves in and is kept in, Escape closes it, and focus goes back to the
 * control that opened it.
 */

export type DialogMode = 'repay' | 'collateral';
type Phase = 'edit' | 'confirm' | 'pending' | 'done';

/** How long the wallet's two steps take, in ms. */
const CONFIRM = 1300;
const PENDING = 1100;

const parse = (text: string) => {
  const value = Number(text.replace(/[^0-9.]/g, ''));
  return Number.isFinite(value) ? value : 0;
};

export function RepayDialog({
  position,
  mode,
  lift,
  top,
  focusOnOpen,
  onApply,
  onClose,
}: {
  position: Position;
  mode: DialogMode;
  /** Starts on the amount that lifts the position to 1.50. */
  lift: boolean;
  /** Where the dialog sits, in px from the top of the dashboard. */
  top: number;
  /** Moves focus into the dialog (not when the demo opens it). */
  focusOnOpen: boolean;
  /** Applies the repayment (dollars) or the collateral (units). */
  onApply: (amount: number) => void;
  onClose: () => void;
}) {
  const repay = mode === 'repay';
  const asset = repay ? position.asset : position.collateral;
  const symbol = repay && ASSETS[asset].price !== 1 ? 'USD' : ASSETS[asset].symbol;
  const wallet = repay ? WALLET[position.asset] * ASSETS[position.asset].price : WALLET[asset];
  const lifting = repay ? liftRepay(position) : liftCollateral(position);
  const full = repay ? position.left : wallet;
  const format = (value: number) =>
    repay
      ? value.toLocaleString('en-US', { maximumFractionDigits: 2 })
      : units(value, position.collateral);

  const [text, setText] = useState(() =>
    format(lift && lifting > 0 ? lifting : repay ? Math.min(full, wallet) / 4 : wallet / 4),
  );
  const [phase, setPhase] = useState<Phase>('edit');
  const [result, setResult] = useState({ amount: 0, from: 0, to: 0 });
  const box = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const done = useRef<HTMLButtonElement>(null);

  const amount = parse(text);
  const before = position.health ?? 0;
  const after = repay
    ? healthAfterRepay(position, Math.min(amount, position.left))
    : healthAfterCollateral(position, amount);
  const tooMuch = amount > wallet + 1e-9;
  const overDebt = repay && amount > position.left + 1e-9;
  const valid = amount > 0 && !tooMuch && !overDebt;
  const busy = phase === 'confirm' || phase === 'pending';

  const presets = [
    ...(lifting > 0 ? [{ id: 'lift', label: 'Lift to 1.50', value: lifting, show: true }] : []),
    { id: 'quarter', label: '25%', value: (repay ? position.left : wallet) / 4, show: false },
    { id: 'half', label: '50%', value: (repay ? position.left : wallet) / 2, show: false },
    { id: 'full', label: repay ? 'Full' : 'Max', value: full, show: true },
  ];
  const presetText = (value: number) => (repay ? dollars(value) : `${format(value)} ${symbol}`);

  useEffect(() => {
    if (focusOnOpen) input.current?.focus({ preventScroll: true });
  }, [focusOnOpen]);

  const apply = useEffectEvent((value: number) => onApply(value));
  useEffect(() => {
    if (phase === 'confirm') {
      const timer = window.setTimeout(() => setPhase('pending'), CONFIRM);
      return () => window.clearTimeout(timer);
    }
    if (phase === 'pending') {
      const timer = window.setTimeout(() => {
        setPhase('done');
        apply(result.amount);
      }, PENDING);
      return () => window.clearTimeout(timer);
    }
    if (phase === 'done' && box.current?.contains(document.activeElement))
      done.current?.focus({ preventScroll: true });
  }, [phase, result.amount]);

  const confirm = () => {
    if (!valid || busy) return;
    const value = repay ? Math.min(amount, position.left) : amount;
    setResult({ amount: value, from: before, to: after });
    setPhase('confirm');
  };

  const onKeyDown = (event: KeyboardEvent) => {
    if (event.key === 'Escape' && !busy) {
      event.stopPropagation();
      onClose();
    }
    if (event.key !== 'Tab' || !box.current) return;
    const focusable = [
      ...box.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)'),
    ];
    if (!focusable.length) return;
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  };

  const title = repay
    ? `Repay ${ASSETS[position.asset].symbol} loan`
    : `Add ${ASSETS[position.collateral].symbol} collateral`;
  const verb = repay ? 'Repay' : 'Add';
  const amountText = repay ? dollars(result.amount) : `${format(result.amount)} ${symbol}`;

  return (
    <div
      className={styles.scrim}
      data-part="dialog-scrim"
      onPointerDown={(event) => {
        if (event.target === event.currentTarget && !busy) onClose();
      }}
    >
      <div
        ref={box}
        className={styles.dialog}
        data-part="dialog"
        data-phase={phase}
        role="dialog"
        aria-modal="true"
        aria-labelledby="loans-dialog-title"
        style={{ top: `${top}px` }}
        onKeyDown={onKeyDown}
      >
        <header className={styles.dialogHead}>
          <span className={styles.coin}>
            <AssetMark asset={position.asset} />
          </span>
          <div>
            <h2 id="loans-dialog-title">{title}</h2>
            <p>
              {position.market} · {position.chain} · {dollars(position.left)} owed
            </p>
          </div>
          <button
            type="button"
            className={styles.iconButton}
            data-part="dialog-close"
            aria-label="Close"
            disabled={busy}
            onClick={onClose}
          >
            <Icon name="close" />
          </button>
        </header>

        {phase === 'done' ? (
          <div className={styles.receipt} data-part="receipt" role="status">
            <span className={styles.receiptCheck}>
              <Icon name="check" />
            </span>
            <b>
              {repay ? 'Repaid' : 'Added'} {amountText}
            </b>
            <span>
              {result.to === Infinity
                ? 'The loan is paid off and its collateral released.'
                : `Health factor moved from ${healthText(result.from)} to ${healthText(result.to)}.`}
            </span>
          </div>
        ) : (
          <div className={styles.dialogBody}>
            <label className={styles.amount} data-invalid={tooMuch || overDebt || undefined}>
              <span className={styles.hidden}>Amount, in {symbol}</span>
              <input
                ref={input}
                data-part="amount"
                inputMode="decimal"
                autoComplete="off"
                value={text}
                disabled={busy}
                onChange={(event) => setText(event.target.value.replace(/[^0-9.,]/g, ''))}
                onBlur={() => amount > 0 && setText(format(amount))}
                onKeyDown={(event) => {
                  if (event.key === 'Enter') confirm();
                }}
              />
              <span className={styles.amountUnit}>
                <AssetMark asset={asset} />
                {symbol}
              </span>
            </label>
            <p className={styles.wallet} aria-live="polite">
              {tooMuch
                ? 'More than your wallet holds.'
                : overDebt
                  ? `More than the ${dollars(position.left)} owed.`
                  : `Wallet balance ${repay ? cents(wallet) : `${format(wallet)} ${symbol}`}`}
            </p>
            <div className={styles.presets} data-part="presets">
              {presets.map((preset) => (
                <button
                  key={preset.id}
                  type="button"
                  data-part={`preset-${preset.id}`}
                  aria-pressed={Math.abs(amount - preset.value) < 0.005}
                  disabled={busy}
                  onClick={() => setText(format(preset.value))}
                >
                  {preset.label}
                  {preset.show && <span> · {presetText(preset.value)}</span>}
                </button>
              ))}
            </div>
            <div className={styles.preview} data-part="health-preview">
              <div className={styles.previewRow}>
                <span>Health factor</span>
                <b>
                  {before.toFixed(2)} <Icon name="arrowRight" /> {healthText(after)}
                </b>
              </div>
              <div className={styles.previewTrack} aria-hidden="true">
                {ZONES.map((zone, index) => {
                  const from = index === 0 ? 0 : scale(zone.from);
                  const to = index === ZONES.length - 1 ? 100 : scale(ZONES[index + 1].from);
                  return (
                    <span
                      key={zone.id}
                      data-zone={zone.id}
                      style={{ left: `${from}%`, width: `calc(${to - from}% - 2px)` }}
                    />
                  );
                })}
                <i
                  className={styles.previewFrom}
                  data-zone={zoneOf(before)}
                  style={{ left: `${scale(before)}%` }}
                />
                <i
                  className={styles.previewTo}
                  data-zone={zoneOf(after)}
                  style={{ left: `${scale(after)}%` }}
                />
              </div>
              <div className={styles.previewRow}>
                <span>{repay ? 'Debt after' : 'Collateral after'}</span>
                <b>
                  {repay
                    ? dollars(Math.max(0, position.left - amount))
                    : `${format(position.amount + amount)} ${symbol}`}
                </b>
              </div>
              <div className={styles.previewRow}>
                <span>Zone</span>
                <b className={styles.zoneTag} data-zone={zoneOf(after)}>
                  {after === Infinity ? 'Closed' : ZONE_NAME[zoneOf(after)]}
                </b>
              </div>
            </div>
          </div>
        )}

        <footer className={styles.dialogFoot}>
          {phase === 'done' ? (
            <button
              ref={done}
              type="button"
              className={styles.primary}
              data-part="dialog-done"
              onClick={onClose}
            >
              Done
            </button>
          ) : (
            <>
              <button
                type="button"
                className={styles.secondary}
                data-part="dialog-cancel"
                disabled={busy}
                onClick={onClose}
              >
                Cancel
              </button>
              <button
                type="button"
                className={styles.primary}
                data-part="dialog-confirm"
                disabled={!valid || busy}
                data-busy={busy || undefined}
                onClick={confirm}
              >
                {busy && <span className={styles.spinner} aria-hidden="true" />}
                {phase === 'confirm'
                  ? 'Confirm in wallet'
                  : phase === 'pending'
                    ? repay
                      ? 'Repaying'
                      : 'Adding'
                    : `${verb} ${repay ? dollars(Math.min(amount, position.left) || 0) : `${format(amount)} ${symbol}`}`}
              </button>
            </>
          )}
        </footer>
        <p className={styles.hidden} aria-live="polite">
          {phase === 'confirm'
            ? 'Confirm in your wallet.'
            : phase === 'pending'
              ? `${verb}ing…`
              : ''}
        </p>
      </div>
    </div>
  );
}
```

### loans-dashboard/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The loans dashboard's data: invented venues, chains, assets and twelve borrow positions, and the
 * arithmetic of a health factor.
 *
 * A position's health factor is its collateral, valued at the venue's liquidation threshold,
 * over its debt. So `health × debt` is a constant of the collateral (its "cover"): repaying lowers
 * the debt and lifts the health in proportion, adding collateral lifts the cover. The collateral
 * is liquidated once the health falls under 1.0, that is once its price falls by 1 − 1/health.
 */

export type AssetId = 'dolr' | 'talr' | 'orb' | 'flint' | 'lume' | 'peak' | 'comb';

export type Asset = {
  symbol: string;
  /** The price in dollars. Stable assets are worth 1. */
  price: number;
  /** Decimals shown for an amount of it. */
  decimals: number;
};

export const ASSETS: Record<AssetId, Asset> = {
  dolr: { symbol: 'DOLR', price: 1, decimals: 2 },
  talr: { symbol: 'TALR', price: 1, decimals: 2 },
  orb: { symbol: 'ORB', price: 3454, decimals: 2 },
  flint: { symbol: 'FLINT', price: 101_400, decimals: 2 },
  lume: { symbol: 'LUME', price: 145.2, decimals: 1 },
  peak: { symbol: 'PEAK', price: 28.4, decimals: 0 },
  comb: { symbol: 'COMB', price: 436, decimals: 2 },
};

/** What the connected wallet holds, in units of each asset. */
export const WALLET: Record<AssetId, number> = {
  dolr: 12_480,
  talr: 3_150,
  orb: 4.2,
  flint: 0.08,
  lume: 60,
  peak: 900,
  comb: 14,
};

export const VENUES = ['Halden', 'Corvo', 'Merrow', 'Sable', 'Vela'] as const;
export type Venue = (typeof VENUES)[number];

export type Position = {
  id: string;
  /** The asset borrowed. */
  asset: AssetId;
  venue: Venue;
  /** The venue's market, as shown: "Halden v3". */
  market: string;
  chain: string;
  collateral: AssetId;
  /** Units of collateral pledged. */
  amount: number;
  /** Dollars borrowed in all, and still owed. */
  borrowed: number;
  left: number;
  apr: number;
  rate: 'Variable' | 'Fixed';
  /** The health factor; null once closed. */
  health: number | null;
  /** When it was opened, for the Newest and Oldest sorts (days ago). */
  age: number;
};

export const POSITIONS: Position[] = [
  {
    id: 'pos-7f3a21',
    asset: 'dolr',
    venue: 'Halden',
    market: 'Halden v3',
    chain: 'Mainline',
    collateral: 'orb',
    amount: 24,
    borrowed: 45_000,
    left: 37_000,
    apr: 5.2,
    rate: 'Variable',
    health: 1.86,
    age: 4,
  },
  {
    id: 'pos-2c90e4',
    asset: 'talr',
    venue: 'Corvo',
    market: 'Corvo v3',
    chain: 'Mainline',
    collateral: 'flint',
    amount: 0.05,
    borrowed: 39_000,
    left: 1_300,
    apr: 4.8,
    rate: 'Variable',
    health: 3.12,
    age: 21,
  },
  {
    id: 'pos-91bd07',
    asset: 'orb',
    venue: 'Merrow',
    market: 'Merrow',
    chain: 'Basalt',
    collateral: 'flint',
    amount: 0.6,
    borrowed: 25_000,
    left: 10_500,
    apr: 3.1,
    rate: 'Fixed',
    health: 4.29,
    age: 33,
  },
  {
    id: 'pos-5e18c2',
    asset: 'dolr',
    venue: 'Sable',
    market: 'Sable',
    chain: 'Mainline',
    collateral: 'lume',
    amount: 135,
    borrowed: 15_000,
    left: 13_700,
    apr: 6.2,
    rate: 'Variable',
    health: 1.08,
    age: 40,
  },
  {
    id: 'pos-d40a6f',
    asset: 'dolr',
    venue: 'Halden',
    market: 'Halden v3',
    chain: 'Arcline',
    collateral: 'peak',
    amount: 1_600,
    borrowed: 40_000,
    left: 21_000,
    apr: 5.4,
    rate: 'Variable',
    health: 1.61,
    age: 58,
  },
  {
    id: 'pos-a6721b',
    asset: 'talr',
    venue: 'Vela',
    market: 'Vela',
    chain: 'Hive Chain',
    collateral: 'comb',
    amount: 9.5,
    borrowed: 8_200,
    left: 2_300,
    apr: 7.1,
    rate: 'Variable',
    health: 1.34,
    age: 74,
  },
  {
    id: 'pos-0be35d',
    asset: 'talr',
    venue: 'Halden',
    market: 'Halden v3',
    chain: 'Polar',
    collateral: 'lume',
    amount: 0,
    borrowed: 7_500,
    left: 0,
    apr: 4.2,
    rate: 'Variable',
    health: null,
    age: 96,
  },
  {
    id: 'pos-c3f912',
    asset: 'dolr',
    venue: 'Corvo',
    market: 'Corvo v3',
    chain: 'Arcline',
    collateral: 'orb',
    amount: 0,
    borrowed: 12_000,
    left: 0,
    apr: 3.9,
    rate: 'Variable',
    health: null,
    age: 120,
  },
  {
    id: 'pos-48e6aa',
    asset: 'orb',
    venue: 'Merrow',
    market: 'Merrow',
    chain: 'Mainline',
    collateral: 'flint',
    amount: 0,
    borrowed: 9_800,
    left: 0,
    apr: 2.8,
    rate: 'Fixed',
    health: null,
    age: 151,
  },
  {
    id: 'pos-e7d254',
    asset: 'dolr',
    venue: 'Sable',
    market: 'Sable',
    chain: 'Basalt',
    collateral: 'orb',
    amount: 0,
    borrowed: 6_000,
    left: 0,
    apr: 5.7,
    rate: 'Variable',
    health: null,
    age: 188,
  },
  {
    id: 'pos-19ac80',
    asset: 'talr',
    venue: 'Vela',
    market: 'Vela',
    chain: 'Hive Chain',
    collateral: 'comb',
    amount: 0,
    borrowed: 4_000,
    left: 0,
    apr: 6.9,
    rate: 'Variable',
    health: null,
    age: 230,
  },
  {
    id: 'pos-b85f3e',
    asset: 'dolr',
    venue: 'Halden',
    market: 'Halden v3',
    chain: 'Mainline',
    collateral: 'peak',
    amount: 0,
    borrowed: 15_000,
    left: 0,
    apr: 5,
    rate: 'Variable',
    health: null,
    age: 274,
  },
];

/** The health every "lift" aims for: the bottom of the safe zone. */
export const TARGET = 1.5;

export type Zone = 'liquidation' | 'risk' | 'watch' | 'safe';

export const ZONES: { id: Zone; label: string; range: string; from: number }[] = [
  { id: 'liquidation', label: 'Liquidation', range: '< 1.0', from: 0 },
  { id: 'risk', label: 'At risk', range: '1.0–1.2', from: 1 },
  { id: 'watch', label: 'Watch', range: '1.2–1.5', from: 1.2 },
  { id: 'safe', label: 'Safe', range: '1.5+', from: 1.5 },
];

export function zoneOf(health: number): Zone {
  if (health < 1) return 'liquidation';
  if (health < 1.2) return 'risk';
  if (health < TARGET) return 'watch';
  return 'safe';
}

export const ZONE_NAME: Record<Zone, string> = {
  liquidation: 'Liquidation',
  risk: 'At risk',
  watch: 'Watch',
  safe: 'Safe',
};

/** The health factor's scale on the risk map: logarithmic, from 0.9 to 5. */
const LOW = Math.log(0.9);
const HIGH = Math.log(5);
export function scale(health: number) {
  const at = (Math.log(Math.max(0.9, Math.min(5, health))) - LOW) / (HIGH - LOW);
  return at * 100;
}

/** Health × debt: what the collateral covers, constant while its price holds. */
export const cover = (position: Position) => (position.health ?? 0) * position.left;

/** The collateral's value in dollars. */
export const collateralValue = (position: Position) =>
  position.amount * ASSETS[position.collateral].price;

/** The repayment, rounded up to $10, that lifts a position to the target health. */
export function liftRepay(position: Position) {
  if (position.health === null || position.health >= TARGET) return 0;
  return Math.ceil((position.left - cover(position) / TARGET) / 10) * 10;
}

/** The collateral, in units rounded up, that lifts a position to the target health. */
export function liftCollateral(position: Position) {
  if (position.health === null || position.health >= TARGET) return 0;
  const threshold = cover(position) / collateralValue(position);
  const dollars = (TARGET * position.left - cover(position)) / threshold;
  const step = 10 ** -ASSETS[position.collateral].decimals;
  return Math.ceil(dollars / ASSETS[position.collateral].price / step) * step;
}

/** The health after repaying `dollars`; Infinity once nothing is owed. */
export function healthAfterRepay(position: Position, dollars: number) {
  const left = position.left - dollars;
  return left <= 0.005 ? Infinity : cover(position) / left;
}

/** The health after adding `units` of collateral. */
export function healthAfterCollateral(position: Position, units: number) {
  const value = collateralValue(position);
  const threshold = value ? cover(position) / value : 0;
  return (cover(position) + units * ASSETS[position.collateral].price * threshold) / position.left;
}

/** The collateral price at which the position is liquidated, and how far that is. */
export function liquidationPrice(position: Position) {
  const price = ASSETS[position.collateral].price;
  const health = position.health ?? 1;
  return { price: price / health, drop: 1 - 1 / health };
}

export const isOpen = (position: Position) => position.health !== null;
export const isAtRisk = (position: Position) => position.health !== null && position.health < 1.2;

const usd0 = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});
const usd2 = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

/** $87,800 */
export const dollars = (value: number) => usd0.format(Math.round(value));
/** $12,480.00 */
export const cents = (value: number) => usd2.format(value);
/** $82.9k */
export function compact(value: number) {
  if (value >= 1000) return `$${(value / 1000).toFixed(1)}k`;
  return usd0.format(value);
}
/** An amount of an asset, with its own decimals: 24.00, 1,600. */
export function units(value: number, asset: AssetId) {
  const digits = ASSETS[asset].decimals;
  return value.toLocaleString('en-US', {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
}
export const healthText = (health: number) => (health === Infinity ? '∞' : health.toFixed(2));
```

### loans-dashboard/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The dashboard's drawings, all decorative (aria-hidden): line icons on a 24-unit grid, stroked
 * in the current colour, and the invented assets' marks on a 20-unit grid, in their own colours.
 * The stylesheet sizes every SVG; nothing here sets a width or height.
 */

import type { ReactNode } from 'react';
import type { AssetId } from './data';

const LINE: Record<string, string> = {
  overview: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z',
  portfolio:
    'M4 7.5A2.5 2.5 0 0 1 6.5 5h11A2.5 2.5 0 0 1 20 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16.5zM4 9.5h16',
  loans: 'M3.5 9.5 12 4.5l8.5 5M5 20h14M6.5 11v6.5M10 11v6.5M14 11v6.5M17.5 11v6.5M4 9.5h16',
  swap: 'M8 20V5M4.5 8.5 8 5l3.5 3.5M16 4v15M12.5 15.5 16 19l3.5-3.5',
  activity: 'M3 12h4l2.5-6 5 12 2.5-6h4',
  explore: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM15.5 8.5l-2 5-5 2 2-5z',
  settings: 'M4 8h9M17 8h3M4 16h3M11 16h9M15 6v4M9 14v4',
  help: 'M7 17 17 7M8.5 7H17v8.5',
  bell: 'M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5H5zM10 20.5a2.2 2.2 0 0 0 4 0',
  chevronDown: 'm7 10 5 5 5-5',
  chevronLeft: 'm14.5 6.5-5.5 5.5 5.5 5.5',
  chevronRight: 'm9.5 6.5 5.5 5.5-5.5 5.5',
  download: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14',
  plus: 'M12 5v14M5 12h14',
  search: 'M10.5 4.5a6 6 0 1 0 0 12 6 6 0 0 0 0-12zM15 15l4.5 4.5',
  filter: 'M4.5 7h15M7.5 12h9M10.5 17h3',
  sort: 'm8 9.5 4-4 4 4M8 14.5l4 4 4-4',
  more: 'M6 12h.01M12 12h.01M18 12h.01',
  eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12zM12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z',
  arrowUpRight: 'M7.5 16.5 16.5 7.5M9 7.5h7.5V15',
  copy: 'M9 9h10v10H9zM15 9V5H5v10h4',
  close: 'M6.5 6.5l11 11M17.5 6.5l-11 11',
  check: 'm5 12.5 4.5 4.5L19 7.5',
  arrowRight: 'M5 12h14M13.5 6.5 19 12l-5.5 5.5',
  sun: 'M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zM12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
  moon: 'M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10z',
  monitor: 'M3.5 5h17v11h-17zM9 20h6M12 16v4',
  logout: 'M14 4H6v16h8M10.5 12H20M16.5 8.5 20 12l-3.5 3.5',
  menu: 'M4 7h16M4 12h16M4 17h16',
  checkCircle: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM8 12.2l2.7 2.7L16 9.6',
};

export type IconName = keyof typeof LINE;

/** A line icon in the current colour. */
export function Icon({ name, className }: { name: IconName; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={name === 'more' ? 3.4 : 1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d={LINE[name]} />
    </svg>
  );
}

/** The shield of the liquidation alerts, filled. */
export function Shield({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      <path
        fill="currentColor"
        d="M12 2.8 19.5 5.6v5.6c0 4.7-3.1 8.6-7.5 10-4.4-1.4-7.5-5.3-7.5-10V5.6z"
      />
    </svg>
  );
}

/** The app's own mark: a ring and a dot on a violet tile. */
export function Logo({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 28 28" aria-hidden="true" focusable="false">
      <rect width="28" height="28" rx="8" fill="#4b3bc9" />
      <path
        d="M9 14a5 5 0 0 1 8.6-3.5M19 14a5 5 0 0 1-8.6 3.5"
        fill="none"
        stroke="#fff"
        strokeWidth="2.6"
        strokeLinecap="round"
      />
      <circle cx="18.4" cy="9.6" r="1.7" fill="#fff" />
      <circle cx="9.6" cy="18.4" r="1.7" fill="#fff" />
    </svg>
  );
}

/** An asset's mark, in its own colours. */
export function AssetMark({ asset, className }: { asset: AssetId; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" aria-hidden="true" focusable="false">
      {MARKS[asset]}
    </svg>
  );
}

const MARKS: Record<AssetId, ReactNode> = {
  // A dollar coin: a blue disc and its sign.
  dolr: (
    <>
      <circle cx="10" cy="10" r="9" fill="var(--mark-dolr)" />
      <path
        d="M12.6 7.4c-.4-.9-1.4-1.4-2.6-1.4-1.5 0-2.6.8-2.6 2 0 2.6 5.3 1.4 5.3 4 0 1.2-1.1 2-2.7 2-1.3 0-2.3-.6-2.7-1.5M10 4.6v1.4M10 14v1.4"
        fill="none"
        stroke="var(--mark-on)"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </>
  ),
  // A green gem with two bars.
  talr: (
    <>
      <path d="M5.3 3.5h9.4l3.8 5L10 17.5 1.5 8.5z" fill="var(--mark-talr)" />
      <path
        d="M6.6 7.4h6.8M10 7.4v5.4"
        stroke="var(--mark-on)"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </>
  ),
  // A violet orb in its orbit.
  orb: (
    <>
      <circle cx="10" cy="10" r="5.6" fill="var(--mark-orb)" />
      <ellipse
        cx="10"
        cy="10"
        rx="8.6"
        ry="3.2"
        fill="none"
        stroke="var(--mark-orb)"
        strokeWidth="1.3"
        transform="rotate(-28 10 10)"
      />
      <circle cx="8.2" cy="8.2" r="1.5" fill="var(--mark-on)" opacity="0.55" />
    </>
  ),
  // An orange spark.
  flint: <path d="M11.6 1.8 4.4 11.2h4.8l-1.1 7 7.4-9.6h-4.9z" fill="var(--mark-flint)" />,
  // A four-pointed light, violet to teal.
  lume: (
    <>
      <defs>
        <linearGradient id="loans-lume" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#9a5cf5" />
          <stop offset="1" stopColor="#1fc2b4" />
        </linearGradient>
      </defs>
      <path
        d="M10 1.5c.6 4.6 3.9 7.9 8.5 8.5-4.6.6-7.9 3.9-8.5 8.5-.6-4.6-3.9-7.9-8.5-8.5 4.6-.6 7.9-3.9 8.5-8.5z"
        fill="url(#loans-lume)"
      />
    </>
  ),
  // Two red peaks.
  peak: (
    <path
      d="M1.5 17 7.6 5.5l3.2 6 1.9-3.3L18.5 17zM7.6 9.4 5 14.4h5.3z"
      fill="var(--mark-peak)"
      fillRule="evenodd"
    />
  ),
  // A honeycomb cell.
  comb: (
    <>
      <path
        d="M10 1.8 17.2 6v8L10 18.2 2.8 14V6z"
        fill="none"
        stroke="var(--mark-comb)"
        strokeWidth="1.8"
        strokeLinejoin="round"
      />
      <path d="M10 6.6 13 8.3v3.4L10 13.4 7 11.7V8.3z" fill="var(--mark-comb)" />
    </>
  ),
};
```

### loans-dashboard/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the eye of the
 * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (nda-badge.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the badge. It is
 * decorative: what it does shows on the badge itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### loans-dashboard/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### loans-dashboard/loans-dashboard.module.css

```css
/* Loans dashboard (loans-dashboard.tsx): a white app screen with a pale sidebar and a pale card of
   figures, lavender for the actions and the repaid bars, red to green for the four zones of the
   health factor; at night near-black, with the zones' tracks dimmed and their dots lit.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the dashboard takes
   the color-scheme it inherits from the page, or the one its colorScheme prop or its account
   menu sets (data-color-scheme).

   Layout is by container queries on the root: a full sidebar from 1100 px, an icon rail from
   760 px, and below that a drawer behind a menu button; the overview stacks under 980 px, and the
   table scrolls inside its own box when it does not fit.

   Memory. Only transforms and opacity animate (the repaid bars scale, the markers slide, menus
   and the dialog fade in); the debt counts in text. The skeleton does not shimmer. */

.root {
  --bg: #fff;
  --side: #fafafa;
  --panel: #f8f8f9;
  --ink: #1b1b1f;
  --ink-2: #36363c;
  --muted: #7c7c84;
  --muted-2: #96969d;
  --nav-icon: #5f5f66;
  --alerts-edge: #f1f1f3;
  --alerts-bg: #fff;
  --line: #ececee;
  --line-2: #e2e2e6;
  --fill: #f2f2f4;
  --fill-hover: #e8e8ec;
  --nav-on: #ececee;
  --row: #f8f8f9;
  --card: #fff;
  --accent: #c9c0fc;
  --accent-hover: #bdb2fb;
  --accent-ink: #34258f;
  --accent-bar: #a99cf6;
  --accent-soft: #efecfe;
  --accent-line: #b5a8f8;
  --kebab: #5a49c9;
  --bar-track: #ececef;
  --tabs: #f2f2f4;
  --tab-on: #fff;
  --track-liquidation: #e48384;
  --track-risk: #fbcfcf;
  --track-watch: #fde2bf;
  --track-safe: #cdeed6;
  --dot-liquidation: #dc3d45;
  --dot-risk: #e5545a;
  --dot-watch: #e3a248;
  --dot-safe: #47ad6c;
  --halo: rgb(229 84 90 / 0.2);
  --text-risk: #b8323a;
  --text-safe: #2f9256;
  --tip: #1e1e21;
  --tip-ink: #fff;
  --toast: #1d1d20;
  --toast-ink: #fff;
  --toast-muted: #a1a1aa;
  --toast-link: #c3b9ff;
  --switch: #34c759;
  --switch-off: #d9d9de;
  --skeleton: #f0f0f2;
  --scrim: rgb(28 28 36 / 0.2);
  --shadow: rgb(16 16 24 / 0.08);
  --shadow-2: rgb(16 16 24 / 0.05);
  --danger: #a3304c;
  --success-soft: #e3f6e9;
  --mark-on: #fff;
  --mark-dolr: #2a62d6;
  --mark-talr: #13906f;
  --mark-orb: #5145d6;
  --mark-flint: #f08c1f;
  --mark-peak: #e23d44;
  --mark-comb: #e0b000;
  --chip-orb: #ecebfd;
  --chip-orb-ink: #4a3fc8;
  --chip-flint: #fff0de;
  --chip-flint-ink: #b4610c;
  --chip-lume: #f3ecfd;
  --chip-lume-ink: #7a3fd6;
  --chip-peak: #fde9ea;
  --chip-peak-ink: #c8303a;
  --chip-comb: #fff5d1;
  --chip-comb-ink: #8c6c00;
  --chip-dolr: #e8efff;
  --chip-dolr-ink: #2a5cc6;
  --chip-talr: #e2f6ef;
  --chip-talr-ink: #12805f;
  --focus: #6a5af0;
  /* The demo's pointer looks like the system's, on both schemes. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);

  position: relative;
  container: loans / inline-size;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 16px;
  background: var(--bg);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  font-size: 14px;
  line-height: 20px;
  font-feature-settings: 'cv11' 1;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  text-align: left;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --bg: light-dark(#fff, #0e0e10);
    --side: light-dark(#fafafa, #131315);
    --panel: light-dark(#f8f8f9, #18181b);
    --ink: light-dark(#1b1b1f, #f2f2f3);
    --ink-2: light-dark(#36363c, #d6d6da);
    --muted: light-dark(#7c7c84, #98989e);
    --muted-2: light-dark(#96969d, #74747c);
    --nav-icon: light-dark(#5f5f66, #9a9aa2);
    --alerts-edge: light-dark(#f1f1f3, transparent);
    --alerts-bg: light-dark(#fff, #1c1b1e);
    --line: light-dark(#ececee, #232327);
    --line-2: light-dark(#e2e2e6, #2e2e33);
    --fill: light-dark(#f2f2f4, #2a2a2d);
    --fill-hover: light-dark(#e8e8ec, #343438);
    --nav-on: light-dark(#ececee, #222225);
    --row: light-dark(#f8f8f9, #18181b);
    --card: light-dark(#fff, #1b1b1e);
    --accent: light-dark(#c9c0fc, #c9c0fc);
    --accent-hover: light-dark(#bdb2fb, #d6cffd);
    --accent-ink: light-dark(#34258f, #251a6e);
    --accent-bar: light-dark(#a99cf6, #b3a8fa);
    --accent-soft: light-dark(#efecfe, #26223e);
    --accent-line: light-dark(#b5a8f8, #6f62c8);
    --kebab: light-dark(#5a49c9, #b8adfb);
    --bar-track: light-dark(#ececef, #2a2a2f);
    --tabs: light-dark(#f2f2f4, #1b1b1e);
    --tab-on: light-dark(#fff, #3a393e);
    --track-liquidation: light-dark(#e48384, #c96767);
    --track-risk: light-dark(#fbcfcf, #462427);
    --track-watch: light-dark(#fde2bf, #43321b);
    --track-safe: light-dark(#cdeed6, #1d3a27);
    --dot-liquidation: light-dark(#dc3d45, #ff6b70);
    --dot-risk: light-dark(#e5545a, #f28787);
    --dot-watch: light-dark(#e3a248, #f4bb6a);
    --dot-safe: light-dark(#47ad6c, #7fd99b);
    --halo: light-dark(rgb(229 84 90 / 0.2), rgb(242 135 135 / 0.22));
    --text-risk: light-dark(#b8323a, #f28787);
    --text-safe: light-dark(#2f9256, #7fd99b);
    --tip: light-dark(#1e1e21, #f4f4f5);
    --tip-ink: light-dark(#fff, #17171a);
    --toast: light-dark(#1d1d20, #26262b);
    --switch-off: light-dark(#d9d9de, #3a3a40);
    --skeleton: light-dark(#f0f0f2, #1d1d21);
    --scrim: light-dark(rgb(28 28 36 / 0.2), rgb(0 0 0 / 0.55));
    --shadow: light-dark(rgb(16 16 24 / 0.08), rgb(0 0 0 / 0.5));
    --shadow-2: light-dark(rgb(16 16 24 / 0.05), rgb(0 0 0 / 0.3));
    --danger: light-dark(#a3304c, #ff7b80);
    --success-soft: light-dark(#e3f6e9, #1b3424);
    --mark-on: light-dark(#fff, #111114);
    --mark-dolr: light-dark(#2a62d6, #8fb5ff);
    --mark-talr: light-dark(#13906f, #4fe0b5);
    --mark-orb: light-dark(#5145d6, #a39cff);
    --mark-flint: light-dark(#f08c1f, #ffad4d);
    --mark-peak: light-dark(#e23d44, #ff6b70);
    --mark-comb: light-dark(#e0b000, #f5cc35);
    --chip-orb: light-dark(#ecebfd, #22204a);
    --chip-orb-ink: light-dark(#4a3fc8, #b3acff);
    --chip-flint: light-dark(#fff0de, #362510);
    --chip-flint-ink: light-dark(#b4610c, #ffb35c);
    --chip-lume: light-dark(#f3ecfd, #2a1f3e);
    --chip-lume-ink: light-dark(#7a3fd6, #c7a3ff);
    --chip-peak: light-dark(#fde9ea, #3b191c);
    --chip-peak-ink: light-dark(#c8303a, #ff7b80);
    --chip-comb: light-dark(#fff5d1, #352c0d);
    --chip-comb-ink: light-dark(#8c6c00, #f5cc35);
    --chip-dolr: light-dark(#e8efff, #1b2747);
    --chip-dolr-ink: light-dark(#2a5cc6, #8fb5ff);
    --chip-talr: light-dark(#e2f6ef, #123328);
    --chip-talr-ink: light-dark(#12805f, #4fe0b5);
    --focus: light-dark(#6a5af0, #a99cf6);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root :where(button, input) {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}
.root :where(button) {
  margin: 0;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
}
.root :where(button:disabled) {
  cursor: default;
}
.root :where(h1, h2, p, ul, dl, dd, figure) {
  margin: 0;
  padding: 0;
}
.root :where(ul) {
  list-style: none;
}
.root :where(svg) {
  display: block;
  flex: none;
}
.root :where(button, input, [tabindex]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ——— Frame ——— */

.shell {
  display: grid;
  grid-template-columns: 250px minmax(0, 1fr);
  min-height: 908px;
}

/* ——— Sidebar ——— */

.sidebar {
  display: flex;
  flex-direction: column;
  padding: 20px 16px 16px;
  background: var(--side);
  min-width: 0;
}
.brand {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-left: 8px;
}
.logo {
  width: 28px;
  height: 28px;
}
.brand .drawerClose {
  display: none;
}
.nav {
  margin-top: 28px;
}
.nav ul {
  display: grid;
  gap: 1.5px;
}
.navItem {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  height: 37px;
  padding: 0 10px;
  border-radius: 10px;
  color: var(--muted);
  font-size: 12px;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.navItem svg {
  width: 17px;
  height: 17px;
  color: var(--nav-icon);
}
.navItem:hover {
  color: var(--ink);
}
.navItem[aria-current='page'] {
  background: var(--nav-on);
  color: var(--ink);
}
.navHeading {
  margin: 22px 0 8px !important;
  padding-left: 10px;
  color: var(--muted-2);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.alerts {
  margin-top: auto;
  padding: 16px 16px 17px;
  border-radius: 12px;
  background: var(--alerts-bg);
  box-shadow: 0 0 0 1px var(--alerts-edge);
}
.alertsHead {
  display: flex;
  align-items: center;
  gap: 8px;
}
.shield {
  width: 14px;
  height: 14px;
  color: var(--ink);
}
.alertsTitle {
  flex: 1;
  font-size: 12px;
  font-weight: 500;
}
.alertsNote {
  margin-top: 8px !important;
  color: var(--muted);
  font-size: 12px;
  line-height: 16px;
}
.switch {
  position: relative;
  width: 36px;
  height: 21px;
  flex: none;
  border-radius: 11px;
  background: var(--switch-off);
  transition: background-color 0.2s ease;
}
.switch span {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
  transition: transform 0.2s cubic-bezier(0.3, 0.7, 0.4, 1);
}
.switch[aria-checked='true'] {
  background: var(--switch);
}
.switch[aria-checked='true'] span {
  transform: translateX(15px);
}
.drawerScrim {
  display: none;
}

/* ——— Main ——— */

.main {
  min-width: 0;
  padding: 14px 32px 28px;
}
.topbar {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 36px;
}
.menuButton {
  display: none !important;
}
.breadcrumb {
  display: flex;
  gap: 8px;
  color: var(--muted-2);
  font-size: 13px;
}
.breadcrumb [aria-current] {
  color: var(--ink);
  font-weight: 500;
}
.topActions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
}
.iconButton {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  color: var(--muted);
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.iconButton svg {
  width: 16px;
  height: 16px;
}
.iconButton:hover:not(:disabled) {
  background: var(--fill);
  color: var(--ink);
}
.iconButton:disabled {
  opacity: 0.4;
}
.bell {
  position: relative;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--panel);
  color: var(--ink);
  transition: background-color 0.15s ease;
}
.bell svg {
  width: 16px;
  height: 16px;
}
.bell i {
  position: absolute;
  top: 5px;
  right: 6px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--dot-watch);
  box-shadow: 0 0 0 1.5px var(--panel);
}
.bell:hover,
.bell[aria-expanded='true'] {
  background: var(--fill);
}
.account {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 36px;
  padding: 0 10px 0 6px;
  border-radius: 18px;
  background: var(--panel);
  transition: background-color 0.15s ease;
}
.account:hover,
.account[aria-expanded='true'] {
  background: var(--fill);
}
.account > svg {
  width: 14px;
  height: 14px;
  color: var(--muted);
}
.address {
  font-size: 14px;
  font-weight: 500;
}
.avatar {
  position: relative;
  display: block;
  width: 24px;
  height: 24px;
  flex: none;
  border-radius: 50%;
  background:
    radial-gradient(circle at 70% 25%, #c8b8ff 0 34%, transparent 35%),
    radial-gradient(circle at 25% 75%, #bfe9d6 0 40%, transparent 41%),
    conic-gradient(from 200deg, #ffd8b8, #f6c8de, #c8b8ff, #ffd8b8);
}
.avatar::after {
  content: '';
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--dot-safe);
  box-shadow: 0 0 0 2px var(--panel);
}
.avatar[data-size='large'] {
  width: 32px;
  height: 32px;
}
.avatar[data-size='large']::after {
  width: 10px;
  height: 10px;
  box-shadow: 0 0 0 2px var(--card);
}

.titleRow {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  margin-top: 19px;
}
.title {
  font-size: 24px;
  font-weight: 500;
  line-height: 30px;
  letter-spacing: -0.01em;
}
.subtitle {
  margin-top: 5px !important;
  color: var(--muted);
  font-size: 14px;
}
.titleActions {
  display: flex;
  gap: 8px;
  flex: none;
  margin-bottom: -3px;
}
.primary,
.secondary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  height: 34px;
  padding: 0 14px;
  border-radius: 17px;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  transition:
    background-color 0.15s ease,
    opacity 0.15s ease;
}
.primary svg,
.secondary svg {
  width: 15px;
  height: 15px;
}
.primary {
  background: var(--accent);
  color: var(--accent-ink);
}
.primary:hover:not(:disabled) {
  background: var(--accent-hover);
}
.secondary {
  background: var(--fill);
  color: var(--ink);
}
.secondary:hover:not(:disabled) {
  background: var(--fill-hover);
}
.primary:disabled,
.secondary:disabled {
  opacity: 0.5;
}
.primary[data-busy] {
  opacity: 1;
  background: var(--fill);
  color: var(--muted);
}

/* ——— Overview ——— */

.overview {
  display: grid;
  grid-template-columns: 296px minmax(0, 1fr);
  margin-top: 25px;
  padding: 24px 24px 24px;
  border-radius: 20px;
  background: var(--panel);
}
.label {
  color: var(--muted);
  font-size: 12px;
  font-weight: 400;
}
.figure {
  margin-top: 1px !important;
  font-variant-numeric: tabular-nums;
  font-size: 32px;
  font-weight: 500;
  line-height: 40px;
  letter-spacing: -0.02em;
}
.bar {
  position: relative;
  height: 5px;
  margin-top: 13px;
  overflow: hidden;
  border-radius: 3px;
  background: var(--bar-track);
}
.bar span {
  position: absolute;
  inset: 0;
  border-radius: 3px;
  background: var(--accent-bar);
  transform-origin: left;
  transition: transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.repaidLine {
  margin-top: 8px !important;
  color: var(--muted);
  font-size: 12.5px;
}
.repaidLine b {
  color: var(--ink);
  font-weight: 400;
}
.stats {
  display: flex;
  gap: 28px;
  margin-top: 20px !important;
  font-size: 12.5px;
  line-height: 18px;
}
.stats dt {
  color: var(--muted);
  font-size: 12px;
}
.stats dd {
  font-weight: 500;
}
.skeleton {
  display: block;
  border-radius: 6px;
  background: var(--skeleton);
}
.skeleton[data-size='figure'] {
  width: 160px;
  height: 32px;
  margin: 10px 0 13px;
}
.skeleton[data-size='bar'] {
  height: 5px;
}
.skeleton[data-size='line'] {
  width: 190px;
  height: 10px;
  margin: 13px 0 4px;
}
.skeleton[data-size='coin'] {
  width: 32px;
  height: 32px;
  border-radius: 50%;
}
.skeleton[data-size='short'] {
  width: 64px;
  height: 10px;
}
.skeleton[data-size='medium'] {
  width: 128px;
  height: 8px;
}
.skeleton[data-size='chip'] {
  width: 102px;
  height: 20px;
}
.skeleton[data-size='long'] {
  width: 118px;
  height: 8px;
}
.skeleton[data-size='tiny'] {
  width: 48px;
  height: 10px;
}

/* ——— Risk map ——— */

.map {
  position: relative;
  min-width: 0;
  margin-left: 32px;
  padding-left: 32px;
  border-left: 1px solid var(--line-2);
}
.mapHead {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 8px;
}
.mapTitle {
  font-size: 13px;
  font-weight: 500;
}
.mapTitle span {
  margin-left: 5px;
  color: var(--muted-2);
  font-size: 12.5px;
  font-weight: 400;
}
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 11px;
  color: var(--muted);
  font-size: 12px;
}
.legend li {
  display: flex;
  align-items: center;
  gap: 6px;
}
.legend i {
  width: 8px;
  height: 8px;
  border-radius: 2px;
  background: var(--track-safe);
}
.legend [data-zone='liquidation'] i {
  background: var(--track-liquidation);
}
.legend [data-zone='risk'] i {
  background: var(--track-risk);
}
.legend [data-zone='watch'] i {
  background: var(--track-watch);
}
.plot {
  position: relative;
  height: calc(116px + (var(--lines, 1) - 1) * 34px);
  margin-top: 9px;
  transition: height 0.4s ease;
}
.plot[data-loading] {
  height: 116px;
}
.track span {
  position: absolute;
  top: 67px;
  height: 8px;
  border-radius: 4px;
  background: var(--track-safe);
}
.track [data-zone='liquidation'] {
  background: var(--track-liquidation);
}
.track [data-zone='risk'] {
  background: var(--track-risk);
}
.track [data-zone='watch'] {
  background: var(--track-watch);
}
.lift {
  position: absolute;
  top: 2px;
  z-index: 2;
  max-width: 100%;
  padding: 2.5px 10px;
  border-radius: 8px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 12.5px;
  line-height: 18px;
  white-space: nowrap;
  transition: left 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
  animation: loans-fade 0.3s ease both;
}
.liftArrow {
  position: absolute;
  top: 19px;
  z-index: 2;
  width: 8px;
  height: 8px;
  border-radius: 1px;
  background: var(--tip);
  transform: translateX(-50%) rotate(45deg);
  transition: left 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
  pointer-events: none;
  animation: loans-fade 0.3s ease both;
}
.marker {
  position: absolute;
  top: 33px;
  z-index: 1;
  width: 30px;
  height: calc(76px + var(--line) * 34px);
  margin-left: -15px;
  border-radius: 8px;
  transition: left 0.7s cubic-bezier(0.3, 0.7, 0.3, 1);
  animation: loans-fade 0.4s ease both;
}
.marker:focus-visible {
  outline: none;
}
.marker:focus-visible .markerIcon {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.markerIcon {
  position: absolute;
  top: 0;
  left: 50%;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  margin-left: -11px;
  border-radius: 50%;
  background: var(--panel);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
}
.markerIcon svg {
  width: 18px;
  height: 18px;
}
.markerStem {
  position: absolute;
  top: 22px;
  left: 50%;
  width: 1px;
  height: 11px;
  background: var(--line-2);
  transition: opacity 0.2s ease;
}
.markerDot {
  position: absolute;
  top: 33px;
  left: 50%;
  width: 10px;
  height: 10px;
  margin-left: -5px;
  border-radius: 50%;
  background: var(--dot-safe);
  box-shadow: 0 0 0 2px var(--panel);
  transition:
    transform 0.2s ease,
    opacity 0.2s ease,
    background-color 0.3s ease;
}
.marker[data-zone='liquidation'] .markerDot {
  background: var(--dot-liquidation);
}
.marker[data-zone='risk'] .markerDot {
  background: var(--dot-risk);
}
.marker[data-zone='watch'] .markerDot {
  background: var(--dot-watch);
}
.marker[data-weakest][data-zone='risk'] .markerDot,
.marker[data-weakest][data-zone='liquidation'] .markerDot {
  box-shadow:
    0 0 0 2px var(--panel),
    0 0 0 7px var(--halo);
}
.markerLabel {
  position: absolute;
  top: calc(49px + var(--line) * 34px);
  left: 50%;
  display: grid;
  width: 90px;
  margin-left: -45px;
  text-align: center;
  font-size: 13px;
  line-height: 16px;
  white-space: nowrap;
  transition:
    opacity 0.2s ease,
    top 0.4s ease;
}
.markerLabel b {
  color: var(--ink-2);
  font-weight: 400;
}
.markerLabel span {
  color: var(--muted);
  font-size: 12.5px;
}
.marker[data-state='dim'] .markerIcon,
.marker[data-state='dim'] .markerLabel,
.marker[data-state='dim'] .markerStem {
  opacity: 0.35;
}
.marker[data-state='dim'] .markerDot {
  opacity: 0.5;
}
.marker[data-state='lit'] .markerDot {
  transform: scale(1.3);
}
.marker[data-state='lit'] .markerIcon {
  transform: scale(1.08);
}
.marker[data-state='lit'] .markerLabel b {
  color: var(--ink);
  font-weight: 500;
}
.marker[data-state='lit'] .markerLabel span {
  color: var(--ink-2);
}
.card {
  position: absolute;
  top: 28px;
  z-index: 4;
  width: 256px;
  padding: 13px 12px 13px;
  border-radius: 14px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--line),
    0 12px 32px var(--shadow),
    0 2px 6px var(--shadow-2);
  font-size: 12px;
  line-height: 16px;
  transform: translate(-50%, -100%);
  pointer-events: none;
  animation: loans-pop 0.18s ease both;
}
.cardHead {
  display: flex;
  gap: 13px;
}
.cardHead .coin {
  width: 25px;
  height: 25px;
  margin: 1px 0 0 -1px;
  background: var(--fill);
}
.cardHead .coin svg {
  width: 16px;
  height: 16px;
}
.cardHead b {
  display: block;
  font-size: 13px;
  font-weight: 500;
  line-height: 18px;
}
.cardHead > span:last-child > span {
  color: var(--muted);
  font-size: 12px;
}
.cardRows {
  display: grid;
  gap: 4px;
  margin-top: 10px !important;
  padding-bottom: 11px;
  border-bottom: 1px solid var(--line);
}
.cardRows div {
  display: flex;
  justify-content: space-between;
  gap: 12px;
}
.cardRows dt {
  color: var(--muted);
}
.cardRows dd {
  font-weight: 500;
}
.cardNote {
  margin-top: 8px !important;
  color: var(--muted);
  font-size: 12px;
}
.cardNote b {
  color: var(--ink);
  font-weight: 500;
}

/* ——— Positions ——— */

.positions {
  margin-top: 28px;
}
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.tabs {
  display: flex;
  padding: 2px;
  border-radius: 17px;
  background: var(--tabs);
}
.tabs button {
  min-width: 95.5px;
  height: 28px;
  padding: 0 14px;
  border-radius: 14px;
  color: var(--muted);
  font-size: 13.5px;
  white-space: nowrap;
  transition:
    background-color 0.2s ease,
    color 0.2s ease,
    box-shadow 0.2s ease;
}
.tabs button:hover {
  color: var(--ink);
}
.tabs button[aria-selected='true'] {
  background: var(--tab-on);
  color: var(--ink);
  box-shadow:
    0 1px 2px var(--shadow-2),
    0 0 0 1px var(--line);
}
.tools {
  display: flex;
  align-items: center;
  gap: 8px;
}
.search {
  position: relative;
  display: flex;
  align-items: center;
  width: 242px;
  height: 34px;
  padding: 0 10px 0 13px;
  border-radius: 17px;
  box-shadow: inset 0 0 0 1px var(--line-2);
  color: var(--muted);
  transition: box-shadow 0.15s ease;
}
.search:focus-within {
  box-shadow: inset 0 0 0 1px var(--muted);
}
.search > svg {
  width: 15px;
  height: 15px;
}
.search input {
  flex: 1;
  min-width: 0;
  height: 100%;
  margin-left: 9px;
  border: 0;
  outline: none !important;
  background: none;
  color: var(--ink);
  font-size: 13.5px;
  appearance: none;
}
.search input::placeholder {
  color: var(--muted);
}
.search input::-webkit-search-cancel-button {
  display: none;
}
.clear {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--fill-hover);
  color: var(--ink-2);
}
.clear svg {
  width: 10px;
  height: 10px;
}
.tool {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 34px;
  padding: 0 13px;
  border-radius: 17px;
  background: var(--fill);
  font-size: 14px;
  white-space: nowrap;
  transition: background-color 0.15s ease;
}
.tool svg {
  width: 15px;
  height: 15px;
}
.tool:hover,
.tool[aria-expanded='true'] {
  background: var(--fill-hover);
}
.count {
  display: grid;
  place-items: center;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 9px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 11px;
  font-weight: 600;
}

.tableScroll {
  margin-top: 13px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}
.tableScroll:focus-visible {
  outline: 2px solid var(--focus);
}
.table {
  width: 100%;
  min-width: 860px;
  border-collapse: separate;
  border-spacing: 0;
  table-layout: fixed;
}
.table th {
  height: 38px;
  padding: 0 12px;
  border-bottom: 1px solid var(--line-2);
  color: var(--muted-2);
  font-size: 13px;
  font-weight: 400;
  text-align: left;
  white-space: nowrap;
}
.table th:nth-child(1) {
  width: 22.6%;
}
.table th:nth-child(2) {
  width: 13.3%;
}
.table th:nth-child(3) {
  width: 10%;
}
.table th:nth-child(4) {
  width: 16%;
}
.table th:nth-child(5) {
  width: 8.5%;
}
.table th:nth-child(6) {
  width: 7.9%;
}
.table th:nth-child(7) {
  width: 10%;
}
.table th[data-sorted] {
  color: var(--ink);
}
.sortHead {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  border-radius: 4px;
}
.sortHead svg {
  width: 12px;
  height: 12px;
}
.sortHead:hover {
  color: var(--ink);
}
.table td {
  height: 56.4px;
  padding: 0 12px;
  font-size: 14px;
  white-space: nowrap;
  transition: background-color 0.2s ease;
}
.table tbody tr:first-child td {
  height: 57px;
}
.table td:first-child {
  border-radius: 12px 0 0 12px;
}
.table td:last-child {
  border-radius: 0 12px 12px 0;
}
.table tbody tr:hover td,
.table tbody tr[data-lit] td,
.table tbody tr:focus-within td {
  background: var(--row);
}
.table tbody tr[data-flash] td {
  background: var(--accent-soft);
  transition: background-color 0.3s ease;
}
.table tbody tr[data-flash]:not(:hover) td {
  transition: background-color 1.2s ease;
}
.skeletonRow td > span,
.skeletonRow td > .skeleton {
  display: inline-block;
  vertical-align: middle;
}
.skeletonRow td:first-child {
  display: table-cell;
}
.skeletonStack {
  display: inline-grid !important;
  gap: 8px;
  margin-left: 13px;
}
.position {
  display: flex;
  align-items: center;
  gap: 13px;
  line-height: 18px;
}
.position b {
  display: block;
  font-weight: 400;
}
.position span span {
  color: var(--muted);
  font-size: 12.5px;
}
.coin {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 50%;
  background: var(--fill);
}
.coin svg {
  width: 20px;
  height: 20px;
}
.collateral {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 13px;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 22px;
  padding: 0 7px;
  border-radius: 6px;
  font-size: 12.5px;
  font-weight: 500;
}
.chip svg {
  width: 13px;
  height: 13px;
}
.chip[data-asset='orb'] {
  background: var(--chip-orb);
  color: var(--chip-orb-ink);
}
.chip[data-asset='flint'] {
  background: var(--chip-flint);
  color: var(--chip-flint-ink);
}
.chip[data-asset='lume'] {
  background: var(--chip-lume);
  color: var(--chip-lume-ink);
}
.chip[data-asset='peak'] {
  background: var(--chip-peak);
  color: var(--chip-peak-ink);
}
.chip[data-asset='comb'] {
  background: var(--chip-comb);
  color: var(--chip-comb-ink);
}
.chip[data-asset='dolr'] {
  background: var(--chip-dolr);
  color: var(--chip-dolr-ink);
}
.chip[data-asset='talr'] {
  background: var(--chip-talr);
  color: var(--chip-talr-ink);
}
.repaid {
  display: grid;
  grid-template-columns: 81px auto;
  align-items: center;
  column-gap: 8px;
  line-height: 18px;
}
.repaidBar {
  position: relative;
  height: 5px;
  overflow: hidden;
  border-radius: 3px;
  background: var(--bar-track);
}
.repaidBar span {
  position: absolute;
  inset: 0;
  border-radius: 3px;
  background: var(--accent-bar);
  transform-origin: left;
  transition: transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.repaidShare {
  margin-top: -4px;
  font-size: 12.5px;
}
.repaid .muted {
  grid-column: 1 / -1;
  margin-top: 1px;
}
.muted {
  color: var(--muted);
  font-size: 12.5px;
}
.stack {
  display: grid;
  line-height: 18px;
}
.health {
  display: inline-flex;
  align-items: center;
  gap: 7px;
}
.dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  flex: none;
  border-radius: 50%;
  background: var(--dot-safe);
}
.dot[data-zone='liquidation'] {
  background: var(--dot-liquidation);
}
.dot[data-zone='risk'] {
  background: var(--dot-risk);
}
.dot[data-zone='watch'] {
  background: var(--dot-watch);
}
.status {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  color: var(--text-safe);
  font-size: 13px;
}
.status i {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentColor;
}
.status[data-status='risk'] {
  color: var(--text-risk);
}
.status[data-status='closed'] {
  color: var(--muted);
}
.actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
}
.repay {
  height: 30px;
  padding: 0 10px;
  border-radius: 15px;
  font-size: 14px;
  opacity: 0;
  transition:
    opacity 0.15s ease,
    background-color 0.15s ease;
}
.repay:hover {
  background: var(--fill-hover);
}
.table tr:hover .repay,
.table tr[data-lit] .repay,
.table tr[data-risk] .repay,
.table tr:focus-within .repay {
  opacity: 1;
}
.table tr[data-risk]:not(:hover):not([data-lit]) .repay {
  opacity: 0.35;
}
.more {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  color: var(--kebab);
  transition: background-color 0.15s ease;
}
.more svg {
  width: 18px;
  height: 18px;
}
.more:hover,
.more[aria-expanded='true'] {
  background: var(--accent-soft);
}
.empty td {
  height: 120px;
  color: var(--muted);
  text-align: center;
}
.empty button {
  color: var(--kebab);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.tableFoot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-height: 26px;
  margin-top: 13px;
  color: var(--muted);
  font-size: 12px;
}
.pager {
  display: flex;
  align-items: center;
  gap: 6px;
}
.pager span {
  margin-right: 8px;
}
.pageButton {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  color: var(--ink);
  transition: background-color 0.15s ease;
}
.pageButton svg {
  width: 14px;
  height: 14px;
}
.pageButton:hover:not(:disabled) {
  background: var(--fill);
}
.pageButton:disabled {
  color: var(--muted);
  opacity: 0.5;
  background: var(--panel);
}

/* ——— Menus ——— */

.menu {
  position: absolute;
  z-index: 20;
  display: grid;
  min-width: 220px;
  padding: 6px;
  border-radius: 14px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--line-2),
    0 12px 32px var(--shadow),
    0 2px 6px var(--shadow-2);
  animation: loans-drop 0.16s ease both;
}
.menu[data-wide] {
  width: 262px;
}
.menuHeading {
  padding: 6px 10px 4px;
  color: var(--muted);
  font-size: 13px;
}
.menuHeading[data-strong] {
  color: var(--ink);
  font-weight: 500;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 34px;
  padding: 0 10px;
  border-radius: 9px;
  font-size: 13px;
  text-align: left;
  white-space: nowrap;
}
.menuItem > svg {
  width: 16px;
  height: 16px;
  color: var(--muted);
}
.menuItem:hover,
.menuItem:focus-visible {
  outline: none;
  background: var(--fill);
}
.menuCheck {
  margin-left: auto;
  color: var(--kebab) !important;
}
.menuRule {
  height: 1px;
  margin: 6px 4px;
  border: 0;
  background: var(--line);
}
.checkbox {
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  border-radius: 5px;
  box-shadow: inset 0 0 0 1.5px var(--line-2);
}
.menuItem[aria-checked='true'] .checkbox {
  background: var(--accent);
  box-shadow: none;
  color: var(--accent-ink);
}
.checkbox svg {
  width: 12px;
  height: 12px;
}
.danger {
  color: var(--danger);
}
.danger > svg {
  color: var(--danger) !important;
}
.accountHead {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 11px 6px 11px 10px;
}
.accountHead > span:nth-child(2) {
  flex: 1;
  display: grid;
  font-size: 12px;
  line-height: 16px;
  color: var(--muted);
}
.accountHead b {
  color: var(--ink);
  font-size: 13px;
  line-height: 18px;
  font-weight: 500;
}
.accountHead .dot {
  margin-right: 3px;
  vertical-align: 2px;
}
.menu[data-wide] .menuHeading {
  padding: 2px 10px;
  font-size: 12px;
}
.appearance {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  margin: 6px 0 0;
  padding: 2px;
  border-radius: 10px;
  background: var(--fill);
}
.appearance button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 26px;
  border-radius: 8px;
  color: var(--muted);
  font-size: 13px;
}
.appearance button svg {
  width: 14px;
  height: 14px;
}
.appearance button[aria-checked='true'] {
  background: var(--card);
  color: var(--ink);
  box-shadow:
    0 1px 2px var(--shadow-2),
    0 0 0 1px var(--line);
}
.appearance button:focus-visible {
  outline-offset: -2px;
}

/* ——— Dialog ——— */

.scrim {
  position: absolute;
  inset: 0;
  z-index: 30;
  background: var(--scrim);
  animation: loans-fade 0.2s ease both;
}
.dialog {
  position: absolute;
  left: 50%;
  width: min(440px, calc(100% - 24px));
  overflow: hidden;
  border-radius: 18px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--line),
    0 24px 64px var(--shadow),
    0 4px 12px var(--shadow-2);
  transform: translateX(-50%);
  animation: loans-rise 0.24s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.dialogHead {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 20px 16px 0 20px;
}
.dialogHead > div {
  flex: 1;
  min-width: 0;
}
.dialogHead h2 {
  font-size: 15px;
  font-weight: 500;
}
.dialogHead p {
  color: var(--muted);
  font-size: 13px;
  line-height: 18px;
}
.dialogBody {
  padding: 18px 20px 20px;
}
.amount {
  display: flex;
  align-items: center;
  height: 54px;
  padding: 0 16px 0 18px;
  border-radius: 14px;
  background: var(--panel);
  box-shadow: inset 0 0 0 1px transparent;
  transition: box-shadow 0.15s ease;
}
.amount:focus-within {
  box-shadow: inset 0 0 0 1px var(--line-2);
  background: var(--card);
}
.amount[data-invalid] {
  box-shadow: inset 0 0 0 1px var(--dot-risk);
}
.amount input {
  flex: 1;
  min-width: 0;
  border: 0;
  outline: none !important;
  background: none;
  font-size: 26px;
  font-weight: 500;
  letter-spacing: -0.01em;
}
.amountUnit {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--ink-2);
  font-size: 15px;
}
.amountUnit svg {
  width: 16px;
  height: 16px;
}
.wallet {
  margin-top: 8px !important;
  color: var(--muted);
  font-size: 12.5px;
}
.presets {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 12px;
}
.presets button {
  height: 28px;
  padding: 0 10px;
  border-radius: 14px;
  background: var(--fill);
  color: var(--ink-2);
  font-size: 12.5px;
  white-space: nowrap;
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.presets button:hover:not(:disabled) {
  background: var(--fill-hover);
}
.presets button[aria-pressed='true'] {
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 1px var(--accent-line);
  color: var(--accent-ink);
}
.root[data-color-scheme='dark'] .presets button[aria-pressed='true'] {
  color: var(--kebab);
}
@media (prefers-color-scheme: dark) {
  .root:not([data-color-scheme='light']) .presets button[aria-pressed='true'] {
    color: var(--kebab);
  }
}
.presets button:disabled {
  opacity: 0.6;
}
.presets span {
  opacity: 0.85;
}
.preview {
  display: grid;
  gap: 10px;
  margin-top: 14px;
  padding: 14px 16px;
  border-radius: 14px;
  background: var(--panel);
  font-size: 13px;
}
.previewRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.previewRow > span {
  color: var(--muted);
}
.previewRow b {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-weight: 500;
}
.previewRow b svg {
  width: 13px;
  height: 13px;
  color: var(--muted);
}
.previewTrack {
  position: relative;
  height: 20px;
}
.previewTrack span {
  position: absolute;
  top: 7px;
  height: 6px;
  border-radius: 3px;
  background: var(--track-safe);
}
.previewTrack [data-zone='liquidation'] {
  background: var(--track-liquidation);
}
.previewTrack [data-zone='risk'] {
  background: var(--track-risk);
}
.previewTrack [data-zone='watch'] {
  background: var(--track-watch);
}
.previewFrom,
.previewTo {
  position: absolute;
  top: 3px;
  width: 14px;
  height: 14px;
  margin-left: -7px;
  border-radius: 50%;
  background: var(--dot-safe);
  transition:
    left 0.35s cubic-bezier(0.3, 0.7, 0.3, 1),
    background-color 0.2s ease;
}
.previewFrom {
  opacity: 0.45;
}
.previewTo {
  box-shadow: 0 0 0 2px var(--panel);
}
.previewFrom[data-zone='liquidation'],
.previewTo[data-zone='liquidation'] {
  background: var(--dot-liquidation);
}
.previewFrom[data-zone='risk'],
.previewTo[data-zone='risk'] {
  background: var(--dot-risk);
}
.previewFrom[data-zone='watch'],
.previewTo[data-zone='watch'] {
  background: var(--dot-watch);
}
.zoneTag::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--dot-safe);
}
.zoneTag[data-zone='liquidation']::before {
  background: var(--dot-liquidation);
}
.zoneTag[data-zone='risk']::before {
  background: var(--dot-risk);
}
.zoneTag[data-zone='watch']::before {
  background: var(--dot-watch);
}
.dialogFoot {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 12px;
  padding: 16px 20px;
  border-top: 1px solid var(--line);
}
.dialogFoot button {
  height: 36px;
  border-radius: 18px;
}
.spinner {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 1.75px solid currentColor;
  border-right-color: transparent;
  animation: loans-spin 0.8s linear infinite;
}
.receipt {
  display: grid;
  justify-items: center;
  gap: 4px;
  padding: 28px 20px 22px;
  text-align: center;
  animation: loans-fade 0.3s ease both;
}
.receiptCheck {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-bottom: 14px;
  border-radius: 50%;
  background: var(--success-soft);
  color: var(--dot-safe);
  animation: loans-pop 0.35s cubic-bezier(0.3, 1.4, 0.5, 1) both;
}
.receiptCheck svg {
  width: 20px;
  height: 20px;
}
.receipt b {
  font-size: 15px;
  font-weight: 500;
}
.receipt > span:last-child {
  color: var(--muted);
  font-size: 12.5px;
}

/* ——— Toast ——— */

.toastLayer {
  position: absolute;
  inset: 0;
  z-index: 40;
  pointer-events: none;
}
.toast {
  position: absolute;
  right: 22px;
  display: flex;
  align-items: center;
  gap: 12px;
  max-width: calc(100% - 44px);
  padding: 10px 12px 10px 14px;
  border-radius: 12px;
  background: var(--toast);
  color: var(--toast-ink);
  box-shadow: 0 10px 30px rgb(0 0 0 / 0.18);
  pointer-events: auto;
  animation: loans-toast 0.3s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.toast > svg {
  width: 15px;
  height: 15px;
  color: var(--toast-muted);
}
.toast > span {
  display: grid;
  min-width: 0;
  font-size: 12.5px;
  line-height: 17px;
}
.toast b {
  font-size: 13px;
  font-weight: 500;
}
.toast > span > span {
  overflow: hidden;
  color: var(--toast-muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.toast button {
  margin-left: 12px;
  padding: 4px 6px;
  border-radius: 6px;
  color: var(--toast-link);
  font-size: 13px;
  font-weight: 500;
}
.toast button:hover {
  background: rgb(255 255 255 / 0.08);
}

/* ——— The demo's pointer ——— */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 50;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

@keyframes loans-fade {
  from {
    opacity: 0;
  }
}
@keyframes loans-pop {
  from {
    opacity: 0;
    scale: 0.96;
  }
}
@keyframes loans-drop {
  from {
    opacity: 0;
    translate: 0 -4px;
  }
}
@keyframes loans-rise {
  from {
    opacity: 0;
    translate: 0 8px;
    scale: 0.98;
  }
}
@keyframes loans-toast {
  from {
    opacity: 0;
    translate: 0 10px;
  }
}
@keyframes loans-spin {
  to {
    rotate: 1turn;
  }
}

/* ——— Narrower: an icon rail ——— */

@container loans (width < 1100px) {
  .shell {
    grid-template-columns: 68px minmax(0, 1fr);
  }
  .sidebar {
    align-items: center;
    padding: 20px 10px 16px;
  }
  .brand {
    padding: 0;
  }
  .navLabel,
  .alertsTitle,
  .alertsNote {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .navItem {
    justify-content: center;
    width: 40px;
    padding: 0;
  }
  .navHeading {
    width: 24px;
    height: 1px;
    margin: 18px auto !important;
    padding: 0;
    overflow: hidden;
    background: var(--line-2);
    color: transparent;
  }
  .alerts {
    padding: 10px 6px;
  }
  .alertsHead {
    flex-direction: column;
    gap: 8px;
  }
  .main {
    padding-inline: 28px;
  }
}

/* ——— The overview stacks ——— */

@container loans (width < 980px) {
  .overview {
    grid-template-columns: minmax(0, 1fr);
    gap: 20px;
  }
  .map {
    margin: 0;
    padding: 20px 0 0;
    border-left: 0;
    border-top: 1px solid var(--line-2);
  }
  .toolbar {
    align-items: stretch;
  }
  .tools {
    flex: 1;
    justify-content: flex-end;
  }
}

/* ——— Narrow: a drawer ——— */

@container loans (width < 760px) {
  .shell {
    grid-template-columns: minmax(0, 1fr);
  }
  .sidebar {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 25;
    align-items: stretch;
    width: min(264px, 82%);
    padding: 20px 16px 16px;
    box-shadow: 0 0 40px var(--shadow);
    transform: translateX(-105%);
    visibility: hidden;
    transition:
      transform 0.3s cubic-bezier(0.3, 0.7, 0.3, 1),
      visibility 0s 0.3s;
  }
  .root[data-drawer] .sidebar {
    transform: none;
    visibility: visible;
    transition: transform 0.3s cubic-bezier(0.3, 0.7, 0.3, 1);
  }
  .brand {
    padding-left: 8px;
  }
  .brand .drawerClose {
    display: grid;
  }
  .navLabel,
  .alertsTitle,
  .alertsNote {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }
  .navItem {
    justify-content: flex-start;
    width: 100%;
    padding: 0 10px;
  }
  .navHeading {
    width: auto;
    height: auto;
    margin: 22px 0 8px !important;
    padding-left: 10px;
    overflow: visible;
    background: none;
    color: var(--muted);
  }
  .alerts {
    margin-top: 24px;
    padding: 14px 16px;
  }
  .alertsHead {
    flex-direction: row;
  }
  .drawerScrim {
    position: absolute;
    inset: 0;
    z-index: 24;
    display: block;
    background: var(--scrim);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease;
  }
  .root[data-drawer] .drawerScrim {
    opacity: 1;
    pointer-events: auto;
  }
  .shell {
    min-height: 0;
  }
  .main {
    padding: 12px 16px 20px;
  }
  .menuButton {
    display: grid !important;
    margin-left: -6px;
    color: var(--ink);
  }
  .titleRow {
    flex-direction: column;
    align-items: flex-start;
    margin-top: 14px;
  }
  .overview {
    margin-top: 20px;
    padding: 18px 16px 14px;
  }
  .positions {
    margin-top: 24px;
  }
  .tabs {
    max-width: 100%;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .tabs button {
    min-width: 0;
    padding: 0 14px;
  }
  .tools {
    width: 100%;
  }
  .search {
    flex: 1;
    width: auto;
  }
}

@container loans (width < 480px) {
  .address,
  .breadcrumb > span:not([aria-current]) {
    display: none;
  }
  .account {
    padding-right: 8px;
  }
  .tool {
    padding: 0 11px;
  }
  .tools {
    flex-wrap: wrap;
  }
  .search {
    flex-basis: 100%;
  }
  .stats {
    gap: 20px;
  }
  .card {
    width: min(300px, calc(100cqi - 32px));
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .spinner {
    animation: none !important;
    border-right-color: currentColor;
    opacity: 0.6;
  }
}
```