# @startsimpli/ui

Shared React UI primitives for every StartSimpli Next.js app. Buttons, dialogs, the
production `UnifiedTable`, a Tailwind preset, a CSS-variable design-token contract,
and all the domain compositions (compose flows, kanban, gantt, email editor, command
palette, dashboard widgets) that are reused across `raise-simpli`, `market-simpli`,
`trade-simpli`, `vault-web`, and friends.

- **Package**: `@startsimpli/ui` · v0.4.13
- **Source**: ~240 `.ts`/`.tsx` files across 30 barrel files
- **Tests**: 58 test files (~1600 `test`/`it` cases) under `src/components/**/__tests__/`
- **Peer deps**: `react` 18/19, `react-dom` 18/19, `next` 14/15/16, optional `@tanstack/react-query` ≥5
- **Status**: the biggest shared package; the inventory below is exhaustive (read from the barrel files, not aspirational)

> Shared-first policy (monorepo CLAUDE.md rule 9): any reusable button, dialog,
> table, hook, util, layout, or domain widget belongs **here**, not in an app's
> `src/`. Wrapping `@startsimpli/ui` in an app-local provider just to add a field
> is also forbidden — extend the shared component.

---

## Install / wire-up

Every app must do four things to consume the UI package end-to-end:

```ts
// app/layout.tsx
import { AuthProvider } from '@startsimpli/auth';
import { QueryProvider, Toaster } from '@startsimpli/ui';
import '@startsimpli/ui/theme/contract'; // or copy contract.css into your theme dir
import './globals.css';

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body suppressHydrationWarning>
        <AuthProvider config={{ apiBaseUrl: process.env.NEXT_PUBLIC_API_URL ?? '' }}>
          <QueryProvider>
            {children}
            <Toaster />
          </QueryProvider>
        </AuthProvider>
      </body>
    </html>
  );
}
```

1. **Mount `<QueryProvider>`** — shared `QueryClient` with sane defaults
   (`staleTime: 5min`, `refetchOnWindowFocus: false`). One per app, at the root.
2. **Mount `<Toaster>`** — driver for `useToast` / `notify`. Mount once, near the
   root. Fixed bottom-right, accessible region with live-region semantics.
3. **Define the theme tokens** — copy `theme/contract.css` (the public contract,
   exported as `@startsimpli/ui/theme/contract`) into your app's globals or import
   it directly. See "Theme tokens" below.
4. **Wire Tailwind content paths** — described next. Skipping this is the cause
   of the "classes silently tree-shaken" bug.

---

## Tailwind: content paths are MANDATORY

Apps' `tailwind.config.ts` MUST list this package's `src/` (and every other
`@startsimpli/*` package whose JSX renders Tailwind classes) under `content`.
Otherwise Tailwind walks only the app source and tree-shakes every class that
only exists inside the package — the rendered HTML has `className="bg-blue-600
text-white"` but no matching CSS rule, so featured CTAs render grey-on-grey and
buttons collapse.

This was the root cause of **startsim-ja6** ("Pro CTA grey-on-grey" in vault-web)
and is now codified in `vault-web/CLAUDE.md` rule 3. Reference config:

```ts
// vault-web/tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/**/*.{js,ts,jsx,tsx,mdx}",
    "../packages/ui/src/**/*.{js,ts,jsx,tsx}",
    "../packages/billing/src/**/*.{js,ts,jsx,tsx}",
    "../packages/auth/src/**/*.{js,ts,jsx,tsx}",
    "../packages/forms/src/**/*.{js,ts,jsx,tsx}",
    "../packages/hooks/src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: { /* ... */ },
};

export default config;
```

You also need this package as a `presets` entry or to merge its theme by hand:

```js
// (option A) preset
module.exports = { presets: [require('@startsimpli/ui/tailwind')], content: [/* ... */] }
```

…and add `'@startsimpli/ui'` to `transpilePackages` in `next.config.{ts,js}` so
Next compiles its JSX with the app.

---

## Theme tokens (CSS-variable contract)

The design system is HSL-channel CSS variables. Changing one variable reshapes
every component — radii, shadows, brand colors, typography stacks. The
**single contract** lives in `theme/contract.css` and is mirrored in
`src/theme/contract.ts` as a typed list (`THEME_TOKENS`).

```css
:root {
  --primary: 221.2 83.2% 53.3%;
  --radius: 0.5rem;          /* set 0 = sharp editorial, 1.5rem = pill */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --font-heading: var(--font-sans);
  --border-width: 1px;
  /* ...19 colors · 6 radii · 4 shadows · 3 type · 4 motion · 1 border-width */
}
```

The Tailwind preset (`src/theme/tailwind.preset.ts`) consumes those variables —
`bg-primary` resolves to `hsl(var(--primary))`, `rounded-md` resolves to
`var(--radius-md)`, etc. To swap the look of an entire app, override the
variables in your globals; no Tailwind config change needed.

```ts
import { THEME_TOKENS, type ThemeColorTokens } from '@startsimpli/ui';
// THEME_TOKENS.colors → ['--background', '--foreground', '--primary', ...]
```

**Cross-link**: `startsim-sw1.8` tracks single-sourcing these tokens between this
web contract.css and `@startsimpli/ui-native/tokens.ts` (currently mirrored by
hand — the goal is one source feeding both). When that lands, the canonical token
file moves up a level; this README's contract path will be updated accordingly.

---

## Public surface

5 root exports plus a handful of subpath exports. The root re-exports almost
everything by domain; subpaths exist for the heaviest features so apps can
tree-shake.

### Subpath exports

| Subpath | Purpose |
|---|---|
| `@startsimpli/ui` | Everything except `email-editor` blocks; primary surface |
| `@startsimpli/ui/table` | `UnifiedTable` only (lighter import) |
| `@startsimpli/ui/gantt` | Gantt chart family (`GanttChart`, views, hooks, types) |
| `@startsimpli/ui/gantt/styles` | Required CSS for the gantt views |
| `@startsimpli/ui/email-editor` | Block-based email editor (heavy; opt-in) |
| `@startsimpli/ui/components` | Same as `.` minus theme/utils — historical alias |
| `@startsimpli/ui/utils` | `cn`, `formatDate`, `formatCurrency`, `getInitials`, ... |
| `@startsimpli/ui/theme` | `tailwindPreset`, `tailwindConfig`, `THEME_TOKENS` |
| `@startsimpli/ui/theme/contract` | The CSS variables file (import in globals.css) |
| `@startsimpli/ui/tailwind` | CommonJS preset (for legacy JS configs) |

### Inventory by domain

Drawn directly from the barrel files in `src/components/**/index.ts`. If
something isn't listed here, it isn't exported.

**shadcn/ui primitives** (`src/components/ui/`) — every Radix-backed primitive
the rest of the package builds on:

> Accordion · Alert · Badge · Button · Calendar · Card · Checkbox · Collapsible
> · Dialog · DropdownMenu · Input · Label · Popover · Progress · ScrollArea ·
> Select · Separator · Skeleton · Table · Tabs · Textarea · Tooltip ·
> **FeatureGate** · **ApiErrorBoundary** · **PageLoader** · **QueryProvider**

**UnifiedTable** (`src/components/unified-table/`) — the production data table.

- `UnifiedTable`, `StandardTableToolbar`, `MobileView`, `MobileCard`, `MobileCardActions`, `MOBILE_BREAKPOINT`
- Hooks: `useTableState`, `useSelection`, `usePagination`, `useFilters`, `useResponsive`, `useColumnVisibility`, `useColumnReorder`, `useColumnResize`, `useTablePreferences`, `useTableKeyboard`, `useTableURL`
- All the column / sort / filter / bulk-action / saved-view types are exported from `./types`
- See "UnifiedTable" section below for the full API

**Dialogs** (`src/components/dialog/`):
- `BaseDialog` (compound API: `BaseDialog.Header`, `.Title`, `.Body`, `.Footer`), `useDialogContext`, `ConfirmDialog`

**Toast / Notify** (`src/components/toast/`):
- `Toaster` (mount once at root)
- `useToast()`, `toast()`, `clearAllToasts()` — low-level imperative API (shadcn-style)
- `useNotify()`, `notify()` — opinionated wrapper (success / error / warning / info) used across the apps

**State components** (`src/components/states/`):
- `EmptyState`, `ErrorState` — standardized empty/error UI with optional action

**Loading** (`src/components/loading/`):
- `TableSkeleton`, `DashboardSkeleton`

**Badges** (`src/components/badge/`):
- `StatusBadge`, `StageBadge` (config-driven, brand-aware)

**Wizard** (`src/components/wizard/`):
- `StepIndicator` + `StepConfig`

**Account forms** (`src/components/account/`):
- `ProfileForm`, `ChangePasswordForm` — used by raise-simpli's settings

**Compose flow** (`src/components/compose/`) — shared email/draft composer:
- `useAutoSave`, `SaveStatusIndicator`, `ComposeHeader`, `SubjectInput`, `SendConfirmationDialog`, `ComposeLoading`

**Email editor** (`src/components/email-editor/`) — block-based builder with drag/drop, undo/redo, HTML renderer:
- `EmailEditor`, `BlockRenderer`
- Block types: `Block`, `TextBlock`, `MetricsBlock`, `DividerBlock`, `CTABlock`, `ImageBlock`, `SpacerBlock`, `SocialBlock`, `HeaderBlock`, `FooterBlock`, `Section`, `Row`, `ColumnLayout`, `GlobalStyles`
- Helpers: `createBlock`, `createRow`, `createSection`, `serializeSections`, `deserializeSections`, `migrateFromLegacy`, `flattenToLegacy`
- Renderer: `renderToEmailHtml`, `renderToPreviewHtml`
- Templates: `createInvestorUpdateTemplate`, `createEmptyTemplate`, `THEME_PRESETS`

**Email dialogs** (`src/components/email-dialogs/`):
- `ScheduleDialog`, `TestSendDialog`, `PreviewDialog`, `TemplatePicker`
- Merge fields: `MergeFieldsMenu`, `MergeFieldPreview`, `replaceMergeFields`, `DEFAULT_MERGE_FIELDS`, `DEFAULT_CATEGORIES`

**Dashboard** (`src/components/dashboard/`):
- `MetricCard`, `PeriodSelector`, `SparklineTrend`, `DashboardGrid`, `DashboardSection`, `PipelineFunnel`, `TopCampaigns`

**Enrichment** (`src/components/enrichment/`):
- `QualityBadge`, `EnrichButton`, `EnrichmentProgress`, `ApolloEnrichButton`

**Integrations** (`src/components/integrations/`):
- `IntegrationCard`, `ConnectionStatus`

**Command palette** (`src/components/command-palette/`):
- `CommandPalette`, `CommandPaletteProvider`, `useCommandPalette`, `useCommandPaletteSearch`, `CommandGroup`, `CommandResultItem`

**Settings layout** (`src/components/settings/`):
- `SettingsLayout`, `SettingsNav`, `SettingsCard`

**Kanban** (`src/components/kanban/`):
- `KanbanBoard` (drag-drop via `@hello-pangea/dnd`)

**Lists** (`src/components/lists/`):
- `ListCard`, `CreateListDialog`

**Pipeline** (`src/components/pipeline/`):
- `StageTransitionModal`

**Calendar / meetings** (`src/components/calendar/`):
- `CalendarView` (wraps `react-big-calendar`), `UpcomingMeetings`, `MeetingsList`

**Gantt** (`src/components/gantt/`, also via `@startsimpli/ui/gantt`):
- `GanttChart`, `GanttTimelineView`, `GanttBoardView`, `GanttListView`, `GanttFilterBar`, `useGanttState`
- Pure helpers: `calculateExpectedProgress`, `calculateHealthStatus`, `getHealthColor`, `parseDateRangeFromTitle`, `getHierarchyLevel`

**Activity** (root `src/components/`):
- `ActivityTimeline`, `QuickLogButtons`, `LogActivityDialog`

**Navigation** (`src/components/navigation/sidebar.tsx`):
- `Sidebar`, `SidebarLayout`, types `SidebarLink`, `SidebarSection`

**Safe HTML**:
- `SafeHtml` — dompurify-backed; `raise-simpli/.eslintrc.json` forbids
  `dangerouslySetInnerHTML` in favor of this.

### Top headline components (by reach across the monorepo)

| Component | Used by | Why |
|---|---|---|
| `UnifiedTable` | market-simpli (CRM lists, campaigns, contacts), raise-simpli | Every server-paginated table |
| `QueryProvider` | vault-web, market-simpli, trade-simpli, raise-simpli | Single shared `QueryClient` per app |
| `Toaster` | vault-web, market-simpli, trade-simpli, raise-simpli | Notification region |
| `notify` / `useToast` | All apps | Imperative success/error/warn |
| `Button` | All apps | shadcn primitive, gradient variants |
| `Dialog` + `BaseDialog` + `ConfirmDialog` | market-simpli, raise-simpli | Modal flows |
| `EmptyState` / `ErrorState` | market-simpli, raise-simpli | Empty/list/page fallbacks |
| `Card` (+ Header/Title/Content) | trade-simpli (dashboards), market-simpli | Container primitive |
| `Badge` / `StatusBadge` | trade-simpli, market-simpli | Pipeline + status chips |
| `SettingsLayout` / `SettingsCard` / `SettingsNav` | market-simpli | Whole settings shell |
| `ProfileForm` + `ChangePasswordForm` | raise-simpli | Account settings |
| `MeetingsList` | raise-simpli | Upcoming meetings widget |
| `DashboardGrid` + `MetricCard` + `PeriodSelector` | trade-simpli, market-simpli | Dashboard composition |
| `IntegrationCard` | market-simpli | Integrations grid |
| `GanttChart` (via `@startsimpli/ui/gantt`) | raise-simpli | Roadmaps / timelines |
| `EmailEditor` + email-dialogs (`ScheduleDialog`, `TestSendDialog`, `TemplatePicker`) | market-simpli (campaign composer), raise-simpli (investor updates) | Block-based email builder |
| `ApolloEnrichButton` / `EnrichmentProgress` | raise-simpli (Apollo enrichment) | Enrichment lifecycle UI |
| `SafeHtml` | raise-simpli (ESLint-enforced) | XSS-safe HTML rendering |
| `FeatureGate` | raise-simpli | Plan-gated UI |
| `StepIndicator` | market-simpli (onboarding wizard) | Multi-step UX |

---

## UnifiedTable

Production-ready data table. **Every table in every app uses server-side
pagination, sorting, and search — this is non-negotiable** (CLAUDE.md rule 8).

Features: pagination, sorting, search, column visibility / reordering / resizing,
bulk + row actions, inline editing with validation, filter sections with chip /
range / select / boolean inputs, CSV + Excel export, saved views, URL state
persistence, mobile card view, keyboard navigation.

### Basic example

```tsx
import { UnifiedTable } from '@startsimpli/ui/table';
import type { ColumnConfig } from '@startsimpli/ui';

function UsersTable() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');
  const [sort, setSort] = useState({ sortBy: 'name', sortDirection: 'asc' as const });
  const { data, isLoading } = useQuery({
    queryKey: ['users', page, search, sort],
    queryFn: () => api.users.list({ page, search, ...sort }),
  });

  const columns: ColumnConfig<User>[] = [
    { id: 'name',  header: 'Name',  accessorKey: 'name',  sortable: true },
    { id: 'email', header: 'Email', accessorKey: 'email', sortable: true },
  ];

  return (
    <UnifiedTable<User>
      tableId="users"
      data={data?.items ?? []}
      columns={columns}
      getRowId={(u) => u.id}
      loading={isLoading}
      pagination={{
        enabled: true,
        pageSize: 25,
        totalCount: data?.total ?? 0,
        currentPage: page,
        serverSide: true,        // REQUIRED
        onPageChange: setPage,
      }}
      sorting={{
        enabled: true,
        serverSide: true,        // REQUIRED
        value: sort,
        onChange: setSort,
      }}
      search={{
        enabled: true,
        placeholder: 'Search users…',
        value: search,
        onChange: setSearch,
      }}
    />
  );
}
```

### Why server-side, always

```tsx
// CORRECT
pagination={{ serverSide: true,  totalCount, currentPage: page, onPageChange }}
sorting   ={{ serverSide: true,  onChange: (s) => fetchData({ ...s }) }}

// WRONG — loads everything into memory, doesn't scale
pagination={{ serverSide: false }}
sorting   ={{ serverSide: false }}
```

### Bulk actions

```tsx
<UnifiedTable
  selection={{ enabled: true, selectedIds, onSelectionChange: setSelectedIds }}
  bulkActions={[
    {
      id: 'delete',
      label: 'Delete',
      icon: Trash2,
      variant: 'gradient-purple',
      onClick: async (ids) => deleteUsers(Array.from(ids)),
      confirmMessage: 'Delete {count} users?',
    },
  ]}
/>
```

### Filters

```tsx
<UnifiedTable
  filters={{
    enabled: true,
    position: 'top',
    collapsible: true,
    config: {
      sections: [{
        id: 'status',
        type: 'chips',
        label: 'Status',
        filters: [{ id: 'status', label: 'Status', type: 'chips', options: ['active', 'inactive'] }],
      }],
    },
    value: filters,
    onChange: setFilters, // forward to your API query
  }}
/>
```

### Inline editing

```tsx
<UnifiedTable
  columns={[{
    id: 'name', header: 'Name', accessorKey: 'name',
    editable: true, editType: 'text',
    validate: (v) => (!v ? 'Name is required' : null),
  }]}
  inlineEdit={{
    enabled: true,
    optimisticUpdate: true,
    onSave: async (rowId, columnId, value) => updateUser(rowId, { [columnId]: value }),
  }}
/>
```

### Export, saved views, mobile

```tsx
<UnifiedTable
  export={{ enabled: true, baseFilename: 'users', formats: ['csv', 'excel'], showProgress: true }}
  savedViews={{
    enabled: true,
    views: savedViews,
    currentViewId,
    onSaveView: async (v) => saveView(v),
    onLoadView: (id) => applyView(savedViews.find(v => v.id === id)!),
  }}
  mobileConfig={{
    titleKey: 'name',
    subtitleKey: 'email',
    primaryFields: ['name', 'email'],
    secondaryFields: ['status', 'createdAt'],
  }}
  urlPersistence={{ enabled: true, debounceMs: 300 }}
/>
```

### Column config

```ts
interface ColumnConfig<TData> {
  id: string
  header: string | ((props: any) => ReactNode)
  accessorKey?: string                              // dot notation OK: 'user.name'
  accessorFn?: (row: TData) => any
  cell?: (row: TData) => ReactNode
  sortable?: boolean
  sortingFn?: (a: TData, b: TData) => number
  width?: string | number
  minWidth?: string | number
  maxWidth?: string | number
  mobilePrimary?: boolean
  mobileSecondary?: boolean
  hideOnMobile?: boolean
  hideable?: boolean
  editable?: boolean
  editType?: 'text' | 'number' | 'select' | 'date'
  editOptions?: string[]
  validate?: (value: any, row: TData) => string | null
}
```

The full prop reference (`pagination`, `sorting`, `search`, `selection`,
`bulkActions`, `rowActions`, `filters`, `columnVisibility`, `columnReorder`,
`columnResize`, `inlineEdit`, `export`, `savedViews`, `urlPersistence`,
`mobileConfig`, `loading`, `loadingRows`, `emptyState`, `errorState`,
`onRowClick`) lives in `src/components/unified-table/types.ts` — that is the
source of truth.

---

## QueryProvider

A pre-configured `QueryClientProvider` so every app shares the same defaults:

```ts
// src/components/ui/query-provider.tsx
new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,     // 5 minutes — avoids hot refetch storms
      refetchOnWindowFocus: false,  // explicit control over refetches
    },
  },
});
```

Mount it once at the root (see "Install / wire-up"). Apps that need different
defaults should override per-query, not re-instantiate.

`@tanstack/react-query` is an **optional** peer dep — the package compiles
without it, but `QueryProvider` requires it at runtime.

---

## Toaster + notify

`<Toaster />` is the renderer; mount once at the root. Variants: `default`,
`destructive`, `success`, `warning`, `info`. The container is a polite live
region (`role="region"`, `aria-live="polite"`), fixed bottom-right, with
slide-in animation via `tailwindcss-animate`.

Two APIs are exposed:

```tsx
// Low-level (shadcn shape)
import { useToast, toast, clearAllToasts } from '@startsimpli/ui';
toast({ title: 'Saved', description: 'Draft saved', variant: 'success' });

// Opinionated wrapper (most apps use this)
import { notify } from '@startsimpli/ui';
notify.success('Saved');
notify.error('Could not save', { description: err.message });
notify.info('Heads up');
notify.warning('Almost out of credits');
```

---

## Utils

```ts
import { cn, generateId, sleep, formatDate, formatRelativeTime, formatRelativeDate, formatCurrency, getInitials } from '@startsimpli/ui';
```

- `cn(...classes)` — `clsx` + `tailwind-merge` (the standard merge)
- `formatDate(d)` → `"May 28, 2026"`
- `formatRelativeTime(d)` → `"Today" | "Yesterday" | "3 days ago" | "May 5, 2026"`
- `formatRelativeDate(d)` → fine-grained `"just now" | "5m ago" | "2h ago" | "3d ago"`
- `formatCurrency(n)` → `"$1,000,000"` (USD, no cents)
- `getInitials(name)` → up to 2-char uppercase initials
- `generateId(prefix?)`, `sleep(ms)`

---

## Real consumer examples

**vault-web** (`vault-web/src/app/layout.tsx`) — root wiring is the canonical
example: `<AuthProvider>` → `<QueryProvider>` → `<Toaster />`. Vault's billing
page composes `SubscriptionManager` from `@startsimpli/billing`, which itself
sits on the design tokens defined here.

**market-simpli** — heaviest consumer (~64 import sites). Uses `UnifiedTable`
extensively for CRM lists, `SettingsLayout`/`SettingsNav`/`SettingsCard` for the
settings shell, `IntegrationCard` for the integrations grid, `EmailEditor` +
email-dialogs for campaign compose, `StepIndicator` for onboarding,
`PeriodSelector`/`DashboardGrid` for dashboards.

**raise-simpli** — settings pages use `useToast`; `ProfileForm` +
`ChangePasswordForm` mounted under account settings; `SafeHtml` enforced via
ESLint over `dangerouslySetInnerHTML`; `FeatureGate` for plan-gated UI;
`ApolloEnrichButton` + `EnrichmentProgress` drive Apollo enrichment;
`GanttChart` via `@startsimpli/ui/gantt` for roadmap views; `MeetingsList` for
upcoming meetings; consumes the Tailwind preset directly
(`presets: [require('@startsimpli/ui/tailwind')]`).

**trade-simpli** — dashboards: `Card`, `Badge`, `StatusBadge`, `DashboardGrid`,
plus `<QueryProvider>` + `<Toaster />` at the root.

`recipesimpli` and `crochet-patterns` are standalone Next apps with their own
DB and have not yet adopted `@startsimpli/ui`.

---

## Testing

```bash
pnpm --filter @startsimpli/ui test              # all tests
pnpm --filter @startsimpli/ui test:watch
pnpm --filter @startsimpli/ui test:coverage
pnpm --filter @startsimpli/ui type-check
```

- **58 test files** under `src/components/**/__tests__/` (~1600 `test`/`it`
  cases), covering UnifiedTable (17 files), email editor (8 files), and every
  domain barrel (dashboard, dialogs, kanban, gantt, settings, compose,
  command-palette, navigation, lists, pipeline, enrichment, integrations,
  account, calendar, badge, loading, states, wizard, toast, safe-html).
- Jest + `@swc/jest` + `jest-environment-jsdom`, with `next/navigation` and
  `next/link` mocked in `src/__mocks__/next/`.
- Coverage thresholds enforced at 70% (branches / functions / lines /
  statements) via `jest.config.js`.

---

## Shared-first reminder

Before adding any reusable button / dialog / hook / formatter / layout / domain
widget to an app's `src/`, ask whether another app could plausibly want it.
If yes — or if you're unsure — it goes here. See the monorepo
`CLAUDE.md` rule 9 and `.claude/docs/conventions.md#shared-packages-policy`.

## License

Proprietary — StartSimpli.
