# @bureaulabs/web-ds — Figma Make Guidelines

A React component library and design-token system for Ebury products. This file is read by Figma Make to guide code generation. **Follow the rules in Section 1 before generating any code.**

---

## 1. HARD RULES — read these first

1. **Every page-level UI MUST use `<Page>` from this package as the root.** The user's content goes inside `<Page>`'s `children` prop. **Never** build a custom `<div className="page">` or `<main>` shell. The sidebar nav, action bar, page title, and content-area chrome are all provided by this package.

2. **Never re-implement a component that already exists in this package.**
   - Button → `<Button>` (don't write your own button)
   - Dropdown → `<Select>`
   - Icon → `<Icon>` (don't install `lucide-react`, `react-icons`, `@heroicons/react`, or any other icon library)
   - Sidebar → `<Nav>`
   - Page header → `<PageTitle>`
   - Header action row → handled automatically by `<Page>`. Do NOT render `<PageActionBar>` yourself — it's fixed and rendered internally.
   - App shell → `<Page>`
   - Table / list view → `<DataTable>` (don't write your own `<table>` or grid layout)
   - Status pill / badge / category label → `<Tag>` (don't write a styled `<span>` for "Pending" / "Settled" / "Failed")
   - Notification / toast / snackbar → `<Toast>` (don't write a styled `<div>` overlay with a hand-rolled close button)
   - Detail / side / drawer overlay → `<Panel>` (don't write a styled `<div>` slide-in or install a third-party drawer)
   - Filter dropdown / popover (checkbox filters above a table) → `<Filter>` + `<FilterSection>` + `<FilterCheckbox>` (don't write a custom dropdown or menu library)
   - Date / date-range filter or picker → `<DateFilter>` (don't write a custom calendar or install a date-picker library)
   - Tab strip / segmented control / pill-shaped selector → `<Tabs>` + `<Tab>` (don't write `<Button>`s styled as tabs)
   - Country / currency flag → `<Flag>` (don't use emoji flags, don't install another flag library, don't reference CDN flag URLs)
   - Account / balance card (FX account, wallet, currency holding) → `<AccountWidget>` (don't write a styled `<div>` card with a hand-placed flag and balance)

3. **Never write raw colour values or pixel sizes.** Use the CSS custom properties listed in Section 5. `#f5f5f5` is wrong; `var(--control-primary-background-rest)` is right. `padding: 16px` is wrong; `padding: var(--sizing-16)` is right.

4. **Import the stylesheet exactly once at the app root:**
   ```tsx
   import '@bureaulabs/web-ds/styles'
   ```
   Without this line, no styles render. **Do not** add Tailwind, do not write custom CSS resets — the stylesheet handles all of it.

5. **If you see a wrapper package** (e.g. `@make-kits/web-ds-test`) that re-exports our components, the component names and prop shapes are unchanged. Components are still called `Button`, `Page`, `Nav`, etc. If the wrapper appears empty or missing exports, install `@bureaulabs/web-ds` directly and import from there.

6. **ANY pill-shaped label MUST be a `<Tag>` — no exceptions.** Status indicators, category chips, type tags, priority levels, "new" / "beta" / "draft" badges — anything pill-shaped uses `<Tag variant="...">`. This applies everywhere on the page, especially inside `<DataTable>`: status / category / type / priority columns MUST use the column's `render` callback to wrap each value in `<Tag>`. NEVER render a status as plain text. NEVER style a `<span>` to look like a pill. NEVER use a `<Button>` as a status indicator.

7. **`<PageActionBar />` is a FIXED, PERMANENT part of `<Page />` (App variant).** In the default `variant="app"`, every `<Page>` automatically renders a `<PageActionBar />` in its header containing fixed buttons (Pay, Convert & Pay, Drawdown, More, Notifications). You CANNOT change, replace, reorder, or restyle this bar. Do NOT render `<PageActionBar />` yourself. Do NOT pass `actionBar` to `<Page>` — that prop has been removed. The ONLY thing you may do is pass click handlers via `<Page actionBarProps={{ onPay, onConvertAndPay, onDrawdown, onMore, onNotifications }}>`. Page-specific actions belong on `<PageTitle actions={...}>`, not as a replacement action bar. The **one allowed exception**: `<Page variant="form">` intentionally strips the PageActionBar (and the Nav) for focused single-task flows — see Hard Rule 13.

8. **Every `<DataTable>` showing list data MUST be paired with a `<Panel>` for row drill-in.** The canonical "row click → detail" wiring is non-negotiable:
   ```tsx
   const [selectedRow, setSelectedRow] = useState<Row | null>(null)

   <DataTable rows={…} columns={…} onRowClick={(row) => setSelectedRow(row)} />

   <Panel open={selectedRow !== null} onClose={() => setSelectedRow(null)} title="…" subtitle={selectedRow?.primaryLabel}>
     {selectedRow && (
       <PanelSection heading="…">
         {/* EVERY column from the DataTable appears here as a <PanelField>,
             value={selectedRow.<columnKey>}. Plus any extra row fields. */}
       </PanelSection>
     )}
   </Panel>
   ```
   Rules:
   - `onRowClick` MUST set a selected-row state. Never wire it to navigation, a toast, or a no-op.
   - **Every column shown in the DataTable MUST appear as a `<PanelField>` in the Panel**, mapping `selectedRow.<key>` to `value`. Include any extra row fields not shown as columns too (the Panel is the full-detail view).
   - A DataTable without a paired Panel is broken UX — the user clicks a row and nothing happens.

9. **`<Button variant="brand">` is reserved for form CTAs.** The brand variant is the strongest visual call-to-action — saturated blue, high attention. Use it ONLY for:
   - The primary submit button of a form (`Save`, `Submit`, `Send`, `Pay`, `Confirm` — the action that completes the form's workflow).
   - **The `footerAction` of `<Page variant="form">`** — the textbook form CTA.
   - When the user explicitly asks for a brand-coloured button.

   Do **NOT** use `variant="brand"` for generic "New" / "Create" / "Add" buttons on a list page, `<PageTitle actions>`, inline row actions, filter / toolbar buttons, or `<Panel>` headers. Default to `variant="primary"` for those (or `secondary` / `ghost` per context). Mental model: if removing the button would prevent the user from completing the form, it's a form CTA → `brand`. Otherwise → `primary`.

10. **In a `<DataTable>`, the amount (or monetary value) column is ALWAYS the last column.** Any column for "Amount", "Total", "Balance", "Value", "Cost", "Price", "Fee" appears LAST in the `columns` array, right-aligned. Status / category columns come *before* the amount, never after. Amounts are scan-targets — keeping them at the right edge gives every table a consistent landing spot.

11. **Table filters are individual per-column controls — one per important column — and they MUST actually filter the rows.** There is NO single "Filters" button. Each important/filterable column gets its own control in `filterControls`: enum/category columns (Status, Currency, Type) use `<Filter label="…">` (checkbox popover); date columns use `<DateFilter label="Date">` (month-range picker). Wire each to local state, then derive `rows` from that state (e.g. via `useMemo`). `filterTabs` (optional segment tabs) uses `<Tabs>` + `<Tab>` — wire each `<Tab>`'s `isSelected` from local state and update on `onClick`. Never use `<Button>`s styled as tabs. A filter that doesn't filter is broken UX — if a column has no sensible filter values, don't give it a filter; if the whole table is short and uniform, don't add filters at all.

12. **Always infer the ISO 4217 currency code from context. Never ask the user for it.** Whenever the user mentions a currency by name, country, symbol, or locale, infer the three-letter ISO 4217 code (`GBP`, `EUR`, `USD`, …) and pass it as `currency` to every currency-aware component (`<AccountWidget>`, `<Flag>`, currency rows in `<Filter>` / `<DataTable>`).

    | User says… | Pass as `currency` |
    |---|---|
    | British pound, pound sterling, UK, £ | `GBP` |
    | Euro, EU account, € | `EUR` |
    | US dollar, dollar (default for $), USA | `USD` |
    | Yen, Japanese yen, ¥ | `JPY` |
    | Swiss franc, CHF | `CHF` |
    | Australian dollar, AUD, A$ | `AUD` |
    | Canadian dollar, CAD, C$ | `CAD` |
    | Indian rupee, ₹ | `INR` |
    | Mexican peso, MXN | `MXN` |
    | Brazilian real, R$ | `BRL` |
    | Hong Kong dollar, HK$ | `HKD` |
    | Singapore dollar, S$ | `SGD` |

    Ambiguous symbol (`$`) → use surrounding context (country/region); default to `USD`. Currency not in this table → use its ISO 4217 code directly (e.g. "Polish złoty" → `PLN`, "Norwegian krone" → `NOK`). **Do not** prompt the user with "What's the ISO code?".

13. **Default every page to `<Page>` (App variant). Use `<Page variant="form">` ONLY for focused single-task flows the user explicitly creates as a new screen.** The default root for any page you generate is `<Page>` — Nav + PageActionBar + PageTitle slot. Switch to `<Page variant="form">` (no Nav, no PageActionBar, centered 560 px column, fixed top-left Close, fixed footer with a brand primary CTA) **only** when the user explicitly creates a new page that is a focused single-task flow.

    | Variant | Use for | Has Nav? | Has PageActionBar? | Has bottom CTA? |
    |---|---|---|---|---|
    | `app` (default) | Dashboards, lists, account pages, tables, settings index, beneficiaries list | yes | yes | no |
    | `form` | Focused single-task flows: Send money, Pay, Add a new beneficiary, Confirm trade, KYC step, Onboarding step, Sign up / Log in / Verify identity | no | no | yes (via `footerAction`) |

    **Trigger patterns for Form:** *"Send …"*, *"Pay …"*, *"Add a new …"*, *"Create a new …"*, *"Confirm …"*, *"Set up …"*, *"Sign up / log in / verify identity"*, *"Onboarding step"*, *"KYC step"*. **Heuristic:** *a page that primarily shows things is App; a page that primarily collects input to submit is Form.* Never switch an existing App page to Form just because it contains a form section — embed the form inline within the App page instead.

---

## 2. Canonical page template — start here, then fill in the body

Every page you generate should follow this structure. **Copy this template verbatim, then customise only `children`** to put the page's actual content (cards, tables, charts, forms, etc.) into the body slot.

```tsx
import { useState } from 'react'
import '@bureaulabs/web-ds/styles'
import {
  Page,
  Nav,
  PageTitle,
  PageActionBar,
  DataTable,
  Panel,
  PanelSection,
  PanelField,
  Filter,
  FilterSection,
  FilterCheckbox,
  DateFilter,
  Tabs,
  Tab,
  Flag,
  AccountWidget,
  Tag,
  Toast,
  Button,
  Select,
  Icon,
} from '@bureaulabs/web-ds'

export default function MyPage() {
  const [activePage, setActivePage] = useState('Accounts')

  return (
    <Page
      sidebar={
        <Nav
          activePage={activePage}
          onPageChange={setActivePage}
        />
      }
      title={
        <PageTitle
          title="Accounts"
          subtitle="All currencies"
        />
      }
      // PageActionBar is rendered automatically inside <Page> and is fixed.
      // Wire its handlers via actionBarProps (all optional).
    >
      {/* ───── PAGE BODY GOES HERE ─────
          Put cards, tables, lists, charts, forms — whatever the page
          is about. Use semantic tokens for any custom styling:
            color: var(--text-primary)
            background: var(--page-bg-color-subtle)
            padding: var(--sizing-24)
            border-radius: var(--radius-medium)
          Compose <Button>, <Select>, <Icon> from this package wherever
          you need interactive controls or iconography.
      */}
    </Page>
  )
}
```

### Page layout & spacing rules

1. **Every page should have a `<PageTitle />` in the `title` slot.** No exceptions for AI-generated pages.
2. **The gap between the PageActionBar (top bar) and the first body element is always 72 px** — handled automatically by `<Page>`. Don't add extra `marginTop` to the first body element to "improve" the spacing.
3. **Back navigation lives in `<PageTitle backButton={…}>`, not in `<Page>` children.** Use on drilled-down detail pages — canonical form is `<Button variant="ghost" size="sm" leadingIcon="IconChevronLeft">Back</Button>`. Skip on top-level pages reachable directly from the Nav.

### Choosing which slot of `<Page>` to use

| You want to add… | Pass it to `<Page>`'s prop… | Typical wrapper |
|---|---|---|
| A sidebar navigation | `sidebar` | `<Nav>` |
| A row of action buttons at the top of the content | `actionBar` | `<PageActionBar>` |
| The page heading + optional subtitle + heading-level actions | `title` | `<PageTitle>` |
| Cards, tables, charts, forms, the actual page content | `children` | (your own composition, using `<Button>` / `<Select>` / `<Icon>` from this package) |

Any of `sidebar`, `actionBar`, `title` is optional — omit the prop if the page doesn't need that section. `children` is where your real content lives.

---

## 3. Installation & setup

### Install
```bash
npm install @bureaulabs/web-ds
```
No runtime dependencies beyond `react` / `react-dom` (peer deps). Bundle includes all icons and fonts.

### Import the stylesheet **once** at the app root
```tsx
import '@bureaulabs/web-ds/styles'
```
Loads all tokens (`:root` CSS variables), `@font-face` declarations for KMR Apparat + Inter Variable, and global resets.

### Import components by name
```tsx
import { Button, Nav, Page, PageTitle, PageActionBar, Select, Icon } from '@bureaulabs/web-ds'
import type { ButtonProps, NavProps, PageProps /* … */ } from '@bureaulabs/web-ds'
```

---

## 4. Component reference

### `<Icon />`
Renders an icon inside a sized wrapper that inherits `currentColor` from its parent.

```tsx
<Icon name="IconHome" size={20} />
<Icon name="IconChevronRight" size={16} aria-label="Next" />
```

| Prop | Type | Default | Notes |
|---|---|---|---|
| `name` | `CentralIconName` | — | Required. See "Common icons" below. |
| `size` | `16 \| 20 \| 28` | `16` | Maps to `--icon-size-*` tokens |
| `aria-label` | `string` | — | Provide for meaningful icons; omit for decorative ones (`aria-hidden` set automatically) |

#### Common icons (use these names — all are confirmed to exist)

- **Navigation:** `IconHome`, `IconWallet1`, `IconBank`, `IconCreditCard1`, `IconPayment`, `IconCoins`, `IconUmbrellaSecurity`, `IconConnectors1`, `IconChecklist`, `IconSidebar`
- **UI affordances:** `IconChevronBottom`, `IconChevronTop`, `IconChevronLeft`, `IconChevronRight`, `IconPlusMedium`, `IconPlusSmall`, `IconCheckmark`, `IconArrowUpRight`
- **Currency / finance:** `IconCurrencyDollar`, `IconCurrencyEuro`, `IconCurrencyPounds`, `IconCurrencyYen`, `IconCurrencyLira`, `IconCurrencyPesos`, `IconCurrencyRupees`, `IconBank2`, `IconBanknote1`, `IconBanknote2`
- **Search / filter:** `IconSearchMenu`, `IconChartCompare`
- **Status / state:** `IconChecklist`, `IconBurger`
- **Other:** `IconSend`, `IconSendLater`, `IconCoinStack`, `IconCoinsAdd`, `IconAddToBasket`, `IconAddImage`

Any name from the `CentralIconName` type union is valid — TypeScript autocomplete will show you the full list (~2,000 icons). Stick to the names above unless you have a specific reason; they cover the vast majority of needs.

---

### `<Flag />`
Circular country / currency flag. Three sizes (16 / 20 / 28). Use as the `leading` slot of a `<Select>` for currency pickers, alongside currency text, or wherever a flag is needed.

```tsx
<Flag currency="EUR" size={20} />
<Flag currency="USD" size={20} />
<Flag country="eu" size={20} aria-label="European Union" />

<Select size="sm" leading={<Flag currency="EUR" size={16} />}>EUR — Euro</Select>
```

| Prop | Type | Default | Notes |
|---|---|---|---|
| `currency` | `CurrencyCode` | — | ISO 4217 code (`'USD'`, `'EUR'`, `'GBP'`, …). Case-insensitive. Mapped internally to a country flag. Takes precedence over `country`; falls back if not mapped. |
| `country` | `CountryCode` | — | ISO 3166-1 alpha-2 (`'us'`, `'gb'`, `'eu'`, `'un'`, `'gb-sct'`, …). Use for non-currency flags. Case-insensitive. |
| `size` | `16 \| 20 \| 28` | `20` | Matches `<Icon>` sizes. |
| `aria-label` | `string` | — | Provide when the flag is the sole visual indicator; otherwise `aria-hidden` is applied automatically. |

Pass `currency` whenever the flag represents a currency (the FX domain); use `country` for non-currency flags (EU, UN, Hawaii, Scotland, …). Never use emoji flags, never install another flag library, never reference CDN flag URLs. Inside an account / balance card, use `<AccountWidget currency=…>` (below) — it renders the flag for you.

---

### `<AccountWidget />`
The canonical FX-app account / balance card. Composes `<Flag>` internally. Use anywhere the user prompts for an account, wallet, balance, holding, or currency card.

```tsx
<AccountWidget currency="GBP" title="GBP Account" subtitle="Incoming 0.00" balance="12,450.00" />
<AccountWidget currency="EUR" title="EUR Account" subtitle="Incoming 1,250.00" balance="8,200.50" onClick={() => openPanel('EUR')} />
```

| Prop | Type | Notes |
|---|---|---|
| `currency` | `string` | **Required.** ISO 4217 code (`'GBP'`, `'EUR'`, `'USD'`, …). Drives the flag AND the unit suffix on the balance. Infer from user input per Hard Rule 12 — never ask. |
| `title` | `ReactNode` | **Required.** Top primary line ("GBP Account", "Acme Inc."). |
| `subtitle` | `ReactNode` | **Always `"Incoming {amount}"`** by convention — see the rules below. |
| `balance` | `ReactNode` | **Required.** Pre-formatted string ("12,450.00"). Currency code is suffixed automatically. |
| `onClick` | `() => void` | Optional. Makes the card interactive (`role="button"`, focusable, Enter/Space). |

Pass `currency` as an ISO 4217 code. Do NOT render a separate `<Flag>` next to it — the widget already includes one. Default width is 100% — place in a grid (e.g. `repeat(3, 1fr)`) for multi-account dashboards. **The `subtitle` is ALWAYS `"Incoming {amount}"`** (e.g. `"Incoming 0.00"`, `"Incoming 1,250.00"`) — default to `"Incoming 0.00"` when no incoming amount is given. The only exception is if the user explicitly asks for a different second line (account number, last activity, etc.). Don't omit `subtitle` for account widgets.

---

### `<Button />`
Primary interactive trigger. Extends all native `<button>` props.

```tsx
<Button variant="brand" size="md">Save changes</Button>
<Button variant="primary" size="sm" leadingIcon="IconHome">Home</Button>
<Button variant="ghost" size="sm" leadingIcon="IconHome" aria-label="Home" />  {/* icon-only */}
<Button variant="secondary" disabled>Disabled</Button>
```

| Prop | Type | Default |
|---|---|---|
| `variant` | `'action' \| 'brand' \| 'primary' \| 'secondary' \| 'ghost'` | `'action'` |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` |
| `leadingIcon` | `CentralIconName` | — |
| `trailingIcon` | `CentralIconName` | — |
| `disabled` | `boolean` | `false` |
| …plus all native `<button>` props (`onClick`, `type`, `aria-*`, etc.) |

**Variant guidance:**
- `brand` — the single most-important action on a page (e.g. "Save", "Create", "Submit")
- `action` — primary dark button (e.g. confirm dialogs)
- `primary` — neutral filled button (toolbar actions)
- `secondary` — outlined neutral (cancel, alternative actions)
- `ghost` — transparent (subtle / icon-only / nav-style buttons)

**Rules:**
- Icon-only buttons (`leadingIcon` without `children`) **must** have `aria-label`.
- Use `size="sm"` inside `<PageActionBar>` and `<PageTitle actions>` (matches 32 px row height).

---

### `<Select />`
Dropdown trigger button. Extends all native `<button>` props.

```tsx
<Select placeholder="Choose currency" />
<Select variant="secondary" size="md" leading={<Icon name="IconBank" size={20} />}>EUR — Euro</Select>
<Select error placeholder="Required" />
```

| Prop | Type | Default | Notes |
|---|---|---|---|
| `variant` | `'primary' \| 'secondary'` | `'primary'` | |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | |
| `error` | `boolean` | `false` | Red error border |
| `leading` | `ReactNode` | — | Typically an `<Icon>` |
| `placeholder` | `string` | — | |
| `children` | `ReactNode` | — | Displayed value; falls back to `placeholder` when empty |
| `onClear` | `() => void` | — | When provided, the chevron becomes a clear (✕) control; clicking it calls `onClear` and stops propagation. Used by `<Filter>` / `<DateFilter>` for clearable triggers. |

---

### `<NavItem />`
Single navigation row. Used inside `<Nav>`; rarely standalone.

| Prop | Type | Default | Notes |
|---|---|---|---|
| `label` | `string` | — | Required |
| `icon` | `CentralIconName` | — | Only renders at `level="root"` |
| `level` | `'root' \| 'child'` | `'root'` | Child items are indented and don't show icons |
| `isSelected` | `boolean` | `false` | |
| `hasChevron` | `boolean` | `false` | Root only — indicates an expandable group |
| `isExpanded` | `boolean` | `false` | Controls chevron direction |
| `isCollapsed` | `boolean` | `false` | Root only — sidebar icon-only mode |
| `onClick` | `(e) => void` | — | |

---

### `<Nav />`
Application sidebar. Composes `Select` (account picker) + nav-toggle + `NavItem`s + ebury wordmark + 3-dot kebab. Default `items` mirror the full Figma nav catalog; pass `items` to override.

```tsx
<Nav activePage="Accounts" onPageChange={setPage} />
<Nav activePage="Hedge" isCollapsed onToggleCollapse={() => …} />
<Nav items={[...DEFAULT_NAV_ITEMS, { label: 'Reports', icon: 'IconChartBar' }]} />
```

| Prop | Type | Default |
|---|---|---|
| `items` | `NavItemConfig[]` | `DEFAULT_NAV_ITEMS` |
| `activePage` | `string` | — |
| `isCollapsed` | `boolean` | `false` |
| `onPageChange` | `(label: string) => void` | — |
| `onToggleCollapse` | `() => void` | — |
| `account` | `string` | `'CBC Canada'` |
| `onMenuClick` | `() => void` | — |

The group containing `activePage` auto-expands. **Width: 250 px expanded, 68 px collapsed.** Always use inside `<Page sidebar={…} />`, never standalone in a page-level UI.

---

### `<PageActionBar />` — fixed, auto-rendered by `<Page>`

**Do not render this yourself.** Every `<Page>` automatically renders a `<PageActionBar />` in its header. The bar's content is fixed:

- **Left:** `Pay` · `Convert & Pay` · `Drawdown` · `…` (More)
- **Right:** 🔔 Notifications

The labels, icons, order, count, and styling of these buttons are immutable. To wire click handlers, pass them via `<Page actionBarProps={...}>`:

```tsx
<Page
  sidebar={<Nav />}
  title={<PageTitle title="Accounts" />}
  actionBarProps={{
    onPay:            () => navigate('/pay'),
    onConvertAndPay:  () => navigate('/convert'),
    onDrawdown:       () => navigate('/drawdown'),
    onMore:           () => openMoreMenu(),
    onNotifications:  () => openNotifications(),
  }}
>
  {pageBody}
</Page>
```

All handlers are optional. Buttons without a handler still render identically — they just don't do anything on click.

Page-specific actions (Export, Filter, New transfer, etc.) belong on the `<PageTitle actions={...}>` slot — **not** as a replacement action bar.

---

### `<PageTitle />`
Page heading inside `<Page>`'s `title` slot. 32 px KMR Apparat title + optional subtitle + optional right-aligned actions + optional back-navigation button above the heading.

```tsx
<PageTitle title="Accounts" />
<PageTitle title="All transactions" subtitle="March 2025" />
<PageTitle
  title="Hedge"
  actions={<>
    <Button variant="secondary" size="sm">Export</Button>
    <Button variant="primary" size="sm">New trade</Button>
  </>}
/>
{/* Drilled-down detail page */}
<PageTitle
  backButton={<Button variant="ghost" size="sm" leadingIcon="IconChevronLeft">Back</Button>}
  title="Acme Supplies Ltd."
  subtitle="Beneficiary details"
/>
```

| Prop | Type | Default |
|---|---|---|
| `title` | `ReactNode` | — |
| `subtitle` | `ReactNode` | — |
| `actions` | `ReactNode` | — |
| `backButton` | `ReactNode` | — |
| `as` | `'h1' \| 'h2' \| 'h3' \| 'h4'` | `'h1'` |

Use `as="h2"` on non-top-level pages — visual stays identical, semantic tag changes.

---

### `<DataTable />`
Generic tabular list. Pass a `columns` array (describing each header + alignment + optional cell renderer) and a `rows` array. Optional slots for filters (above the header) and footer controls (below the body).

```tsx
<DataTable
  // Optional filter row above the header — tabs on left, controls on right.
  filterTabs={
    /* Use <Tabs> + <Tab> — never <Button>s styled as tabs. First tab default-selected. */
    <Tabs aria-label="Filter">
      <Tab label="All"         isSelected={tab === 'all'} onClick={() => setTab('all')} />
      <Tab label="Incoming"    isSelected={tab === 'in'}  onClick={() => setTab('in')} />
      <Tab label="Sent"        isSelected={tab === 'out'} onClick={() => setTab('out')} />
      <Tab label="Settlements" isSelected={tab === 'set'} onClick={() => setTab('set')} />
    </Tabs>
  }
  filterControls={
    <>
      {/* One <Filter> per important column — no single "Filters" button. */}
      <Filter label="Status" value={anyStatus ? `${statusCount} selected` : undefined}>
        <FilterSection heading="Status">
          <FilterCheckbox label="Settled" checked={status.Settled} onChange={() => toggleStatus('Settled')} />
          <FilterCheckbox label="Pending" checked={status.Pending} onChange={() => toggleStatus('Pending')} />
          <FilterCheckbox label="Failed"  checked={status.Failed}  onChange={() => toggleStatus('Failed')} />
        </FilterSection>
      </Filter>
      <Filter label="Currency" searchable searchPlaceholder="Search currency">
        <FilterSection heading="Currency">
          <FilterCheckbox label="EUR" checked={ccy.EUR} onChange={() => toggleCcy('EUR')} />
          <FilterCheckbox label="GBP" checked={ccy.GBP} onChange={() => toggleCcy('GBP')} />
        </FilterSection>
      </Filter>
      {/* Date column → DateFilter (month-range picker), not checkboxes. */}
      <DateFilter label="Date" value={dateRange} onChange={setDateRange} />
    </>
  }
  columns={[
    { key: 'beneficiary', header: 'Beneficiary', align: 'left',  width: 'md' },
    { key: 'reference',   header: 'Reference',   align: 'left',  width: '1fr' },
    { key: 'amount',      header: 'Amount',      align: 'right', width: 'sm' },
    { key: 'date',        header: 'Date',        align: 'right', width: 'sm' },
    { key: 'status',      header: 'Status',      align: 'right', width: 'sm',
      render: (row) => <StatusTag status={row.status} /> },  // optional custom cell
  ]}
  rows={transactions}
  footerControls={
    <>
      <Button variant="primary" size="sm" leadingIcon="IconChevronLeft"  aria-label="Previous" />
      <Button variant="primary" size="sm" leadingIcon="IconChevronRight" aria-label="Next" />
    </>
  }
  footerInfo="Showing 1–10 of 247"
  onRowClick={(row) => navigate(`/tx/${row.id}`)}
/>
```

| Prop | Type | Default | Notes |
|---|---|---|---|
| `columns` | `DataTableColumn<T>[]` | — | Required. Order = left-to-right column order. |
| `rows` | `T[]` | — | Required. |
| `filterTabs` | `ReactNode` | — | Optional left-side filter group above the header — a `<Tabs>` containing `<Tab>`s for mutually-exclusive segment filters (e.g. "All / Incoming / Sent"). Never `<Button>`s styled as tabs. |
| `filterControls` | `ReactNode` | — | Optional right-side filter group above the header — one filter per important column: `<Filter label="…">` (checkbox popover) for enum columns, `<DateFilter label="Date">` (month-range picker) for date columns. No single "Filters" button. |
| `footerControls` | `ReactNode` | — | Left-aligned footer slot. Typically pagination `<Button>`s. |
| `footerInfo` | `ReactNode` | — | Right-aligned footer slot. Typically range text. |
| `onRowClick` | `(row, index) => void` | — | **Required** for any list-of-records table. Must set a selected-row state that drives a paired `<Panel>` (see Hard Rule 8). Don't wire it to navigation, a toast, or a no-op. Rows get hover styling + cursor pointer automatically. |

**When to add filters:**
- Add `filterTabs` when the data has distinct segments the user might want to toggle between (Incoming / Sent / All).
- Add `filterControls` when there are column values worth filtering (amount range, currency, date) — use `<Select size="sm">` dropdowns + a final `<Filter>` popover (multi-select checkbox groups + search) for a richer filter panel.
- Skip both for short tables that fit on screen with no segmentation.
- Each slot is independent — you can use one without the other.

**Tab-selection convention** (when using `filterTabs`):
- The **first tab is the default-selected tab** (typically `All`) and renders with `variant="primary"`.
- All other tabs render with `variant="ghost"`.
- On click, swap which tab has `variant="primary"`.
- Always include `All` (or equivalent) as the first tab.

**Per-column props (`DataTableColumn<T>`):**

| Prop | Type | Default | Notes |
|---|---|---|---|
| `key` | `keyof T & string` | — | Required. Used for React keys and default value lookup. |
| `header` | `ReactNode` | — | Required. |
| `align` | `'left' \| 'right'` | `'left'` | Right-align numeric / amount / status columns. |
| `width` | `'sm' \| 'md' \| '1fr' \| string` | `'sm'` | Named tokens: `'sm'` = 120px (short values: amounts, dates, statuses); `'md'` = 260px (longer text: names, references); `'1fr'` = absorbs remaining space. **Set exactly ONE column to `'1fr'` so the table fills its container width** (see rule below). Any other CSS grid track value (e.g. `'200px'`, `'minmax(80px, 1fr)'`) is passed through. |
| `render` | `(row, index) => ReactNode` | — | Optional cell renderer. If omitted, `row[key]` is rendered as text. |

**Rules:**
- Use `width: 'sm'` (120 px) for short, atom-sized values: amounts, dates, statuses, single-letter codes. This is the default if `width` is omitted.
- Use `width: 'md'` (260 px) for longer text: names, references, descriptions.
- **Always set exactly ONE column to `width: '1fr'` so the table fills its container's full width**, and it **MUST be a left-aligned text column** — the most important / most variable-length one (Name, Reference, Description, Type, Beneficiary…). Leave the rest at their token size. **Never put `'1fr'` on a right-aligned column** (Amount, Currency, Date, Status): a right-aligned `'1fr'` balloons in width with its short value pinned to the right edge, leaving a big empty gap before it. If no column is obviously long (all values short), still pick the left-most meaningful left-aligned column — never a trailing right-aligned one. Don't distribute width across every column.
- Right-align amount / number / date / status columns (`align: 'right'`).
- **The amount (or any monetary value) column is ALWAYS the last column** — see Hard Rule 10. Status / category columns go *before* amount, not after.
- Use the `render` callback for non-text cells — tags, icons, formatted numbers, links. Plain-string cell values truncate automatically with `…` ellipsis when they overflow.
- **Any column representing a status, category, type, priority, or other pill-shaped value MUST use `render` to wrap the value in `<Tag variant="...">`.** Status / category / type / priority columns are never rendered as plain text.
- **If filters are present, they MUST actually filter the rows** — see Hard Rule 11. Wire `filterTabs` / `filterControls` to local state, then derive `rows` from that state.

---

### `<Tabs />`, `<Tab />`
Pill-shaped segmented control for mutually-exclusive views. Canonical use: the `filterTabs` slot of `<DataTable>`, but usable anywhere a tab-style selector belongs.

```tsx
const [tab, setTab] = useState<'all' | 'incoming' | 'sent'>('all')

<Tabs aria-label="Filter">
  <Tab label="All"      isSelected={tab === 'all'}      onClick={() => setTab('all')} />
  <Tab label="Incoming" isSelected={tab === 'incoming'} onClick={() => setTab('incoming')} />
  <Tab label="Sent"     isSelected={tab === 'sent'}     onClick={() => setTab('sent')} />
</Tabs>
```

| `<Tab>` prop | Type | Default | Notes |
|---|---|---|---|
| `label` | `ReactNode` | — | Required. Tab text. |
| `isSelected` | `boolean` | `false` | Wire from state; exactly one tab selected at a time. |
| `onClick` | `() => void` | — | Updates the selection state. |
| `disabled` | `boolean` | `false` | Disable the tab. |

`<Tabs>` takes `children` (the `<Tab>`s) and an optional `aria-label`. Use this primitive for **any** pill-shaped segment selector — never `<Button>`s styled as tabs, never a custom segmented control. The first tab is default-selected when used as `filterTabs` (typically `All`). For larger sets of options (more than ~5), prefer a `<Filter>` checkbox popover.

---

### `<Filter />`, `<FilterSection />`, `<FilterCheckbox />`
A per-column table filter: a `<Select>`-style trigger (labelled with the column name) that opens a dropdown popover of checkbox options, with an optional search field. Use **one `<Filter>` per important column** in a `<DataTable>`'s `filterControls` slot — there is no single "Filters" button. Right-anchored, non-modal, closes on outside-click or ESC.

```tsx
const [status, setStatus] = useState<Record<string, boolean>>({ Settled: false, Pending: false, Failed: false })
const toggleStatus = (k: string) => setStatus(s => ({ ...s, [k]: !s[k] }))
const anyStatus = Object.values(status).some(Boolean)
const statusCount = Object.values(status).filter(Boolean).length
const filteredRows = useMemo(() => rows.filter(r => !anyStatus || status[r.status]), [status, anyStatus])

<Filter label="Status" value={anyStatus ? `${statusCount} selected` : undefined}>
  <FilterSection heading="Status">
    <FilterCheckbox label="Settled" checked={status.Settled} onChange={() => toggleStatus('Settled')} />
    <FilterCheckbox label="Pending" checked={status.Pending} onChange={() => toggleStatus('Pending')} />
    <FilterCheckbox label="Failed"  checked={status.Failed}  onChange={() => toggleStatus('Failed')} />
  </FilterSection>
</Filter>
```

| `<Filter>` prop | Type | Default | Notes |
|---|---|---|---|
| `label` | `ReactNode` | — | **Required.** Shown on the Select trigger — name the column ("Status", "Currency", "Date") |
| `value` | `ReactNode` | — | Optional selection summary shown on the trigger once filters apply (e.g. `"2 selected"`) |
| `searchable` | `boolean` | `false` | Show a search field at the top of the popover |
| `searchValue` / `onSearchChange` | `string` / `(v) => void` | — | Controlled search field |
| `onClear` | `() => void` | — | When provided AND `value` is set, the trigger's chevron becomes a clear (✕); clicking it runs `onClear` without opening the popover. Wire it to reset the filter's state. |
| `children` | `ReactNode` | — | A stack of `<FilterSection>`s |

`<FilterSection heading={…}>` groups checkboxes under a small heading. `<FilterCheckbox label checked onChange>` is one toggleable option. **One `<Filter>` per important column** — never a single generic "Filters" button. **Wire each `<FilterCheckbox>` to state and derive the table `rows` from it** — a filter that doesn't filter is broken UX (Hard Rule 11). **Pass `onClear` (resetting that state) so the user can clear in one click.** Add `searchable` when a column may have many options. Don't render `<FilterCheckbox>`s directly as `<Filter>` children — group them in sections. Use `<Filter>` for enum/category columns; use `<DateFilter>` (below) for date columns.

---

### `<DateFilter />`
The per-column filter for **date columns**: a `<Select>`-style trigger opening a popover with preset shortcuts, From/To fields, and a two-year month-range picker. Goes in `filterControls` for a date column. Month-granularity range selection. Non-modal; closes on outside-click or ESC.

```tsx
const [dateRange, setDateRange] = useState<DateRange>({ from: null, to: null })
const filteredRows = useMemo(() => rows.filter(r =>
  !dateRange.from || (new Date(r.dateISO) >= dateRange.from && (!dateRange.to || new Date(r.dateISO) <= dateRange.to))
), [dateRange])

<DateFilter label="Date" value={dateRange} onChange={setDateRange} />
```

| `<DateFilter>` prop | Type | Default | Notes |
|---|---|---|---|
| `label` | `ReactNode` | `'Date'` | Trigger label; replaced by the range summary once selected |
| `value` | `{ from: Date \| null, to: Date \| null }` | — | Controlled range |
| `defaultValue` | same | — | Initial range for uncontrolled usage |
| `onChange` | `(range) => void` | — | Fires when the range changes |
| `presets` | `{ label, getRange }[]` | This/Last month, This/Last year, Last 12 months | Preset shortcuts |

Use `<DateFilter>` for **date columns only** (use `<Filter>` checkboxes for enum columns). **Wire `value`/`onChange` to state and derive `rows` from the range** (Hard Rule 11). Once a range is set, the trigger's chevron becomes a clear (✕) automatically — clicking it resets the range (fires `onChange`); no extra prop needed. Don't build a custom calendar or install a date-picker library.

---

### `<Panel />`, `<PanelSection />`, `<PanelField />`
Slide-in detail overlay pinned to the right edge of the viewport. Non-modal — no backdrop. Canonical use: click a row in a `<DataTable>` → open `<Panel>` populated with that row's fields.

```tsx
const [selectedRow, setSelectedRow] = useState<Row | null>(null)

<DataTable rows={…} columns={…} onRowClick={setSelectedRow} />

<Panel
  open={selectedRow !== null}
  onClose={() => setSelectedRow(null)}
  title="Transaction Details"
  subtitle={selectedRow?.beneficiary}
  headerActions={<Button variant="primary" size="sm">Download</Button>}
>
  {selectedRow && (
    <PanelSection heading="Transaction Information">
      <PanelField label="Beneficiary" value={selectedRow.beneficiary} />
      <PanelField label="Amount"      value={selectedRow.amount} />
      <PanelField label="Date"        value={selectedRow.date} />
    </PanelSection>
  )}
</Panel>
```

| `<Panel>` prop | Type | Notes |
|---|---|---|
| `open` | `boolean` | Required |
| `onClose` | `() => void` | Required — fires on Close click + ESC |
| `title` | `ReactNode` | Required |
| `subtitle` | `ReactNode` | Optional |
| `headerActions` | `ReactNode` | Optional right-side actions. **MUST be `<Button variant="primary" size="sm">`** — every button in the Panel topbar uses the primary variant. |
| `children` | `ReactNode` | Body — a stack of `<PanelSection>`s |

`<PanelSection heading={…}>` wraps a group of fields in a rounded card. `<PanelField label value />` is one label-value row inside a section. Always nest fields inside sections; don't render `<PanelField>` directly as a Panel child.

Panel is non-modal — the page behind stays scrollable and visible. Use for detail drill-in, not for stateful workflows.

**Pairing rules (see Hard Rule 8):**
- A `<DataTable>` of list data MUST be paired with a `<Panel>`, wired via `onRowClick` setting a `selectedRow` state that drives `<Panel open={selectedRow !== null}>`.
- Every column in the source DataTable MUST appear as a `<PanelField>` in the Panel, with `value={selectedRow.<columnKey>}`. Include any extra row fields not in the table too.
- If a column renders a `<Tag>` (status, type, priority), keep it a `<Tag>` inside the Panel.

**Panel layout rules (do not override):**
- Floats 10 px in from the viewport's top, right, and bottom edges, with a 14 px border radius.
- Every button in the topbar (Close on the left + any `headerActions` on the right) is `<Button variant="primary" size="sm">`. No ghost/secondary/brand/action in the topbar.
- 72 px gap between the topbar (header row) and the heading — built in, don't add more.

---

### `<Tag />`
Small outlined pill for status / category labels (24 px tall). Use for transaction statuses, category chips, anything that says "this row is in state X".

```tsx
<Tag variant="green">Settled</Tag>
<Tag variant="yellow">Pending</Tag>
<Tag variant="red">Failed</Tag>
<Tag variant="blue">Processing</Tag>
<Tag variant="purple">Reviewed</Tag>
<Tag variant="grey">Draft</Tag>
```

| Prop | Type | Default |
|---|---|---|
| `variant` | `'grey' \| 'yellow' \| 'red' \| 'green' \| 'blue' \| 'purple'` | `'grey'` |
| `children` | `ReactNode` | — (required) |

**Status-to-variant convention:**
- `green` — success / settled / active / approved
- `yellow` — pending / warning / in-progress
- `red` — failed / error / rejected
- `blue` — informational / processing
- `purple` — reviewed / special / on-hold
- `grey` — neutral / draft

**Common pattern: status column in `<DataTable>`** — use the column's `render` callback:

```tsx
{ key: 'status', header: 'Status', align: 'right', width: 'sm',
  render: (row) => <Tag variant={statusToVariant(row.status)}>{row.status}</Tag>
}
```

---

### `<Toast />`
Notification pill — dark background with white text, optional leading icon, optional dismiss (X) button. Always inverse-coloured for high contrast against any page background (does NOT flip in dark mode).

```tsx
<Toast>This is a toast</Toast>
<Toast icon="IconBell">New transfer received</Toast>
<Toast icon="IconCheckCircleDashed" dismissable={false}>Saved successfully</Toast>
<Toast icon="IconInfoSimple" onDismiss={() => removeToast(id)}>You have unread messages</Toast>
```

| Prop | Type | Default | Notes |
|---|---|---|---|
| `children` | `ReactNode` | — (required) | Toast message body |
| `icon` | `CentralIconName` | — | Optional leading icon |
| `dismissable` | `boolean` | `true` | Whether to show the dismiss (X) button |
| `onDismiss` | `() => void` | — | Called when the dismiss button is clicked |

`<Toast>` is presentational only — it renders one toast and does not own timing, stacking, queueing, or animations. Render it inside your own toast container (typically a `position: fixed` element pinned to a viewport corner).

---

### `<Page />`
Application shell — **the root of every page**. Sidebar + content area. The content area's header **always** contains a fixed `<PageActionBar />` on top, with an optional `<PageTitle />` below.

```tsx
<Page
  sidebar={<Nav activePage={page} onPageChange={setPage} />}
  title={<PageTitle title="…" subtitle="…" actions={…} />}
  actionBarProps={{ onPay, onConvertAndPay, onDrawdown, onMore, onNotifications }}
>
  {/* main page body content */}
</Page>
```

| Prop | Type | Notes |
|---|---|---|
| `variant` | `'app'` (default) \| `'form'` | Switches the page layout. See Hard Rule 13 for when to use which. |
| `sidebar` | `ReactNode` | **App only.** Typically `<Nav>`. |
| `title` | `ReactNode` | **App:** `<PageTitle>` slot (optional). **Form:** required heading text, rendered as `<h1>` internally. |
| `subtitle` | `ReactNode` | **Form only.** Optional secondary line (typically a large numeric value like `"$5,000.00"`) rendered in the Inter Numerals Large style. |
| `actionBarProps` | `PageActionBarProps` | **App only.** Click handlers for the (fixed) PageActionBar — the bar itself can't be replaced or customised. |
| `onClose` | `() => void` | **Form only.** Wires the fixed top-left Close button. Omit to hide it. |
| `closeLabel` | `ReactNode` | **Form only.** Default `'Close'`. |
| `footerAction` | `ReactNode` | **Form only.** Primary submit in the fixed footer. MUST be `<Button variant="brand" size="md">…</Button>` — the textbook form CTA. Omit to hide the footer. |
| `children` | `ReactNode` | Page body. |

**App variant chrome:** Nav (`sidebar`) left, PageActionBar auto-rendered, PageTitle slot, body. **There is no `actionBar` prop** — PageActionBar is rendered automatically and is fixed.

**Form variant chrome:** No Nav, no PageActionBar. Centered 560 px column, 120 px top/bottom padding, fixed top-left Close, fixed footer with the brand CTA.

```tsx
{/* Form variant — focused single-task flow */}
<Page
  variant="form"
  title="Send payment"
  subtitle="$5,000.00"
  onClose={() => router.back()}
  footerAction={<Button variant="brand" size="md" onClick={submit}>Send payment</Button>}
>
  <Select size="md" placeholder="From account" />
  <Select size="md" placeholder="To beneficiary" />
</Page>
```

**Layout chrome (don't try to override):**
- Sidebar pinned left at intrinsic width
- Content area: `pl: 32, pr: 24, pt: 24, pb: 0`; flex column; 32 px gap between header and body
- Header section: PageActionBar (auto) on top, PageTitle below, **72 px** between them

`<Page>` defaults to `min-height: 100vh` — it always fills at least the viewport, growing if the page body content overflows. No outer wrapper required.

---

## 5. Design tokens

Tokens are CSS custom properties exposed on `:root` after importing `@bureaulabs/web-ds/styles`. **Always reference tokens, never raw colours or pixel values.**

### Use semantic tokens, never base palette tokens

| ✅ Do | ❌ Don't |
|---|---|
| `var(--control-brand-background-rest)` | `var(--eds-brand-1000)` |
| `var(--text-primary)` | `var(--eds-grey-1000)` |
| `var(--border-primary)` | `#e5e5e5` |
| `var(--sizing-16)` | `16px` |
| `var(--radius-medium)` | `8px` |

### Token categories

| Prefix | Purpose | Example |
|---|---|---|
| `--page-*` | Page backgrounds | `--page-bg-color-default`, `--page-bg-color-subtle` |
| `--text-*` | Text colours | `--text-primary`, `--text-secondary`, `--text-tertiary` |
| `--border-*` | Border colours | `--border-primary`, `--border-subtle` |
| `--control-*` | Interactive controls | `--control-brand-background-rest`, `--control-text-dark`, `--control-error-stroke` |
| `--switch-*` | Switches | `--switch-base-on`, `--switch-knob-off` |
| `--tag-*` | Tags / badges | `--tag-blue-text`, `--tag-red-stroke` |
| `--font-family-*` | Font families | `--font-family-ui` (Inter), `--font-family-heading` (KMR Apparat) |
| `--font-weight-*` | Font weights | `--font-weight-default` (450), `--font-weight-medium` (500), `--font-weight-semibold` (600) |
| `--font-size-control-*` | Control font sizes | `--font-size-control-sm` (13), `--font-size-control-md` (14), `--font-size-control-lg` (15) |
| `--font-size-heading-*` | Heading font sizes | `--font-size-heading-h1` (32) |
| `--sizing-*` | Spacing scale (px) | `--sizing-4`, `--sizing-8`, `--sizing-12`, … `--sizing-80` |
| `--radius-*` | Border radii | `--radius-small` (4), `--radius-medium` (8), `--radius-large` (12), `--radius-infinite` |
| `--icon-size-*` | Icon dimensions | `--icon-size-16`, `--icon-size-20`, `--icon-size-28` |
| `--stroke-*` | Border widths | `--stroke-default` (1px) |
| `--eds-*` | **Base palette — DO NOT USE in app code.** Reserved for the semantic token layer. | `--eds-grey-500` |

### Sizing scale

Available sizing steps: `0, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 64, 72, 80`. Token name = the px value (`--sizing-16` = 16 px).

### Dark mode

Follows the OS preference by default. To override programmatically:
```ts
document.documentElement.setAttribute('data-theme', 'dark')   // force dark
document.documentElement.setAttribute('data-theme', 'light')  // force light
document.documentElement.removeAttribute('data-theme')        // back to OS preference
```
All semantic tokens flip automatically — components need no per-mode code.

---

## 6. CSS class naming (contributors only)

If you're authoring a new component for this package (not consuming it):

- Root class matches component name in kebab-case: `.button`, `.nav-item`, `.page-title`
- BEM modifiers with `--`: `.button--primary`, `.button--sm`, `.nav-item--selected`
- BEM elements with `__`: `.page-title__heading`, `.nav-item__label`
- No prefix (`eds-*` etc.) — keep selectors short
- One CSS file per component, colocated with the `.tsx`

---

## 7. Authoring conventions (contributors only)

### File layout
```
src/components/ComponentName/
├── ComponentName.tsx       ← Component + prop types (both exported)
├── ComponentName.css       ← Scoped BEM styles
├── ComponentName.figma.tsx ← Figma Code Connect mapping
└── index.ts                ← Re-export
```

### TypeScript
- Named exports only (no `default export`)
- Export the props type from the same file
- Extend native element props with `React.ComponentPropsWithoutRef<'button'>` (or `HTMLAttributes<…>`) when wrapping a DOM element

### Publishing
```bash
npm run build:check        # tsc --noEmit
npm run build              # tsup + bundle-styles → dist/
npm run publish:dry        # verify tarball contents
npm publish --access public --otp=<6-digit code>
npx @figma/code-connect publish   # update Figma Make mappings
```

---

*This file ships inside the npm tarball. Figma Make reads it to learn how to compose @bureaulabs/web-ds components. Keep it current as components are added or APIs change.*
