@startsimpli/ui
v0.4.133
Published
Shared UI components package for StartSimpli applications
Maintainers
Readme
@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/.tsxfiles across 30 barrel files - Tests: 58 test files (~1600
test/itcases) undersrc/components/**/__tests__/ - Peer deps:
react18/19,react-dom18/19,next14/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/uiin 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:
// 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>
);
}- Mount
<QueryProvider>— sharedQueryClientwith sane defaults (staleTime: 5min,refetchOnWindowFocus: false). One per app, at the root. - Mount
<Toaster>— driver foruseToast/notify. Mount once, near the root. Fixed bottom-right, accessible region with live-region semantics. - 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. - 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:
// 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:
// (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).
: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.
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(wrapsreact-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, typesSidebarLink,SidebarSection
Safe HTML:
SafeHtml— dompurify-backed;raise-simpli/.eslintrc.jsonforbidsdangerouslySetInnerHTMLin 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
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
// 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
<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
<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
<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
<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
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:
// 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:
// 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
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 initialsgenerateId(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
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__/(~1600test/itcases), 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, withnext/navigationandnext/linkmocked insrc/__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.
