@cmgfi/clear-ds
v1.6.0
Published
CMG Financial — Clear Design System React component library
Maintainers
Readme
@cmgfi/clear-ds
Clear Design System — CMG Financial's official React component library.
The UI foundation for CMG's internal loan origination platform. Built from scratch for pixel-fidelity control, token ownership, and zero dead code. Every component was designed in Pencil before it was written in code.
- npm:
@cmgfi/clear-ds - Storybook: clear-ds.cmgfinancial.ai
- Version: 1.6.0
- License: UNLICENSED (CMG Financial internal)
Table of Contents
- Installation
- Consumer Setup
- Component Catalog
- Migrating
- Token System
- Tech Stack
- Project Structure
- Local Development
- Build Output
- Publishing
- Contributing
- Hard-won Rules
Installation
Full install — with PrimeIcons (recommended):
npm install @cmgfi/clear-ds primeiconsWithout PrimeIcons — if your composition does not use any icon-bearing components:
npm install @cmgfi/clear-dsPrimeIcons is a
peerDependency. It must be installed in the consumer app — it is not bundled into the library.
Consumer Setup
Add these three imports once, in your application root (e.g. main.tsx or App.tsx):
import '@cmgfi/clear-ds/tokens'; // CSS custom property definitions (:root)
import '@cmgfi/clear-ds/styles'; // compiled component styles
import 'primeicons/primeicons.css'; // omit if PrimeIcons not installedSet the base font size. The token system is built on 1rem = 12px:
/* global.css or index.css */
html {
font-size: 12px;
}Then use components anywhere in your app:
import { Button, InputText, Modal, DataTable } from '@cmgfi/clear-ds';Component Catalog
45 components across 7 sections. All are fully typed with JSDoc-annotated props, React.forwardRef-wrapped, and documented in Storybook.
Buttons
All button components share a canonical three-size ramp:
| Size | Height | Font | Padding |
|------|--------|------|---------|
| sm | 24px | 10px | 4px 12px |
| md | 36px | 12px | 8px 14px (default) |
| lg | 40px | 15px | 10px 16px |
Icon sizes are explicitly defined per button size using design tokens:
| Button size | Icon token | Value |
|-------------|------------|-------|
| sm | --icon-size-sm | 12px |
| md | --icon-size-md | 14px |
| lg | --icon-size-lg | 18px |
Pass any React node to icon props — PrimeIcons (<i className="pi pi-*" />) are the standard.
| Component | Props | Description |
|---|---|---|
| Button | variant, size, leadingIcon, trailingIcon, badge | Primary action button. Variants: primary (default), secondary, ghost, danger, link. Sizes: sm, md (default), lg. Optional leading/trailing PrimeIcon slots and a notification badge. |
| IconButton | icon, size, variant | Circular icon-only button. Sizes: sm (24×24px), md (36×36px, default), lg (40×40px). Tooltip showing aria-label rendered automatically on hover. |
| DropdownButton | label, items, size, variant, leadingIcon, disabled | Button that opens a dropdown action menu. Chevron uses pi pi-chevron-down. Sizes: sm, md (default), lg. |
| SplitButton | label, items, size, variant, leadingIcon, disabled | Two joined pill buttons: main action (left) + dropdown trigger (right). primary — both halves dark teal. secondary — both halves light teal. Sizes: sm, md (default), lg. Chevron trigger shows a tooltip with triggerAriaLabel on hover. |
| LightningButton | items, size, variant, disabled | Icon-only quick-action button with pi pi-bolt + pi pi-chevron-down. Variants: basic, filled. Sizes: sm, md (default), lg. Tooltip showing aria-label rendered automatically on hover. |
| CloseButton | size, disabled | Circular pi pi-times dismiss button. Default size is sm (24×24px). Sizes: sm (default), md (36×36px), lg (40×40px). Tooltip showing aria-label rendered automatically on hover. |
Form Controls
| Component | Description |
|---|---|
| InputText | Single-line text input with label, helper text, and validation states. |
| TextArea | Multi-line text input. Props: showLabel (boolean), counterPlacement (inside | outside). |
| RichTextEditor | Tiptap-based rich text editor. Every toolbar button (showBold, showItalic, showUnderline, showStrike, showCode, showHighlight, showSubscript, showSuperscript, showHeading, showBlockquote, showCodeBlock, showBulletList, showOrderedList, showTaskList, showHorizontalRule, showTextAlign, showLink, showImage, showMention, showUndo, showRedo) is an individual boolean prop. toolbarMode: always (default) | on-focus. mentionSuggestions accepts a static array or async function for @ mention data. |
| Checkbox | Controlled checkbox with label. Supports indeterminate state. |
| RadioButton | Single radio option. Compose multiples in a group. |
| Select | Single-select dropdown. |
| MultiSelect | Multi-select dropdown with chip display and search. |
| ListBox | Inline scrollable single or multi-select list. |
| SelectButton | Joined segmented button group for mutually exclusive options. size prop (sm/md/lg), plus invalid for validation failure. Each option accepts an optional icon?: React.ReactNode rendered before its label. For detached cards use CardTabs. |
| DatePicker | Date input with calendar popover. |
| ToggleSwitch | On/off toggle. |
| FileUpload | Drag-and-drop file upload with type validation. |
Data
| Component | Description |
|---|---|
| DataTable | Feature-rich table with sorting, filtering, row selection, grouping, inline editing, column resizing, and row expansion. |
| Paginator | Standalone pagination control for use with any data list. |
| Picklist | Dual-list transfer control for moving items between two sets. |
Messages
| Component | Description |
|---|---|
| BannerAlert | Full-width page-level alert. Severities: info, success, warning, error. |
| InlineAlert | Compact inline alert for form-level feedback. |
| InlineContainedAlert | Bordered inline alert for use inside panels or cards. |
| Toast / Toaster / toast | Programmatic toast notifications. Mount <Toaster /> once; call toast(options) anywhere. |
Overlay
| Component | Description |
|---|---|
| Modal | Dialog overlay with header, body, and footer action slots. |
| Drawer | Slide-in panel overlay. |
| SidePanel / SidePanelLayout | Fixed side panel for persistent secondary content. |
| Popup | Anchored floating popup (e.g. contextual menus, micro-overlays). |
| Tooltip | Hover tooltip. Placements: top, bottom, left, right. |
Panel
| Component | Description |
|---|---|
| Card | Surface container with optional header and footer. |
| Accordion | Collapsible section list. Variants: default, flush. |
| Tabs | Horizontal tab navigation with panel content. |
| CardTabs | Row of detached, individually-bordered tab cards with a gap between them; single-select. Extracted from SelectButton's former spaced variant. The active card is marked by a teal stroke alone — no fill. tabs/activeTab/onChange, size (sm/md/lg), and per-card count, badge, and icon. |
| BannerTabs | Loan-workflow tab bar with status chips and badge groups. |
Status
| Component | Description |
|---|---|
| SeverityChip | Color-coded severity label. Variants: info, success, warning, error. |
| MiscChip | General-purpose label chip with configurable color. |
| ProfileChip | Avatar-style chip showing a person's initials or image. |
| AUSChip | Automated Underwriting System result chip. |
| ProgressBar | Horizontal progress indicator. |
| ProgressSpinner | Circular loading indicator. Large variant includes the Clear brand logo. |
Navigation
Compose the nav with Shell — not tier by tier.
The navigation is three stacked tiers: a top bar, a loan-context banner, and a URLA sub-nav. Shell owns that stacking order as part of owning the frame, and renders each tier only when you supply its props — so one component covers the pipeline view (top bar only), a loan file (top bar + banner), and a URLA section (all three). It also picks the desktop or mobile tier set for you, so there is one call rather than one per breakpoint.
// ✅ Preferred — one component, correct stacking, tiers appear as you supply them
<Shell
profile={profile}
topBar={{ version, navItems, onSearch }}
loanBannerNav={{ tabs, activeTabId, onTabChange }} // omit → pipeline view
urlaTabsNav={{ applicants, tabs, activeTabId, onTabChange }} // omit → no URLA sub-nav
>
{page}
</Shell>
// ⚠️ Avoid — you now own the stacking order, the breakpoint, and every future tier change
<div>
<TopBar … />
<LoanBannerNav … />
<URLATabsNav … />
</div>Reach for the individual tiers only when you genuinely need one in isolation — a standalone TopBar on a page with no loan context, or a full-width URLA band, which is the one arrangement Shell does not produce (it renders that tier inside the content column). If you are stacking two or more of them by hand, use Shell instead.
Each tier still ships its desktop and mobile halves separately, and both are exported: Shell is the one place that has to choose between them, so a consumer who wants to own the breakpoint themselves still can.
| Component | Description |
|---|---|
| TopBar | Tier 1, desktop — logo, version, nav items with dropdowns, search, profile. |
| TopBarMobile | Tier 1, mobile — Clear mark left; search handoff, AI menu, profile, hamburger right. |
| LoanBannerNav | Tier 2, desktop — loan banner with tabs, action bar, condition badge, alert strip. |
| LoanBannerNavMobile | Tier 2, mobile — borrower name + Loan Details, section switcher, Actions, Save. |
| URLATabsNav | Tier 3, desktop — URLA section tabs with applicant selector and sort. |
| URLATabsNavMobile | Tier 3, mobile — AUS pills, applicant selector, sort, tab dropdown. |
| VerticalMenu | Slide-in nav panel opened from a hamburger — teal surface, grouped destinations, expand-in-place submenus. Sits outside the tier stack: it is position: fixed and hangs below the nav via top, anchored to either edge via side. |
VerticalMenu takes however many groups you pass rather than a variant prop — loan sections only on desktop (the global links are already in the TopBar), both groups on mobile (where the top bar has none). Inside a Shell, render it through the menu slot and point top at the frame's published nav height:
<Shell
/* … */
menu={
<VerticalMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
groups={[LOAN_MENU_GROUP]}
activeItemId={activeItemId}
defaultExpandedIds={['credit']}
onSelect={handleSelect}
top="var(--shell-nav-height)"
/>
}
>
<CreditScreen />
</Shell>The menu slot matters here even though the panel positions itself: it renders the panel inside the frame's root, which is where --shell-nav-height is published, so the custom property resolves.
Two things about the panel follow the tier set, so inside a Shell drive both from its onLayoutChange:
sidematches the hamburger that opens it.LoanBannerNav's sits on the left (the'left'default);TopBarMobile's is the last control in its right-hand cluster, so the mobile frame wantsside="right".groupsgains the global links on mobile. The mobile top bar carries no nav items, so the menu is the only route to them — where desktop passes the loan sections alone.
Shell reports the switch rather than leaving it to be re-derived, because the breakpoint is only one of the two triggers: the frame also goes mobile when the loan banner runs out of room, at whatever width that happens.
Templates
A template is a whole surface composed from the components above — the largest unit this library ships. It owns the layout between DS parts and nothing about the domain: every label and value arrives from the consumer, so no loan schema lives in the design system. That boundary is what keeps a template from needing a DS release every time a field is added.
Templates are otherwise ordinary components: same four-file structure, same token rules, same src/components/<Name>/ home, exported from the package root. Only their Storybook home differs — they file under Templates, not under a Components group.
| Component | Description |
|---|---|
| Shell | The application frame — pinned nav tiers, an optional Loan Details rail, and the one scrolling content region. That region is an empty slot: the frame renders nothing into it and adds no padding, and contentBackground overrides its surface-200 default. Which props you pass select the surface: topBar alone is outside a loan file, + loanBannerNav is a loan file, + urlaTabsNav is the URLA section. loanDetailsOpen turns the Loan Details panel off and on — one flag for the desktop rail and the mobile drawer alike. Swaps to the mobile tier set and a scrimmed drawer below mobileBreakpoint. |
| LoanDetailsPanel | Loan-details surface — borrower identity, collapsible key/value sections, per-row breakdown cards. variant switches between a static rail and a compact-viewport left drawer. |
Shell is the outermost template: it stacks the nav tiers, places LoanDetailsPanel for you, and owns the frame's scroll plumbing, so prefer it over assembling those three by hand.
<Shell
profile={profile}
topBar={{ navItems, searchPlaceholder: 'Search Leads and Loans', onSearch }}
topBarMobile={{ onMenuOpen: openMenu, onSearchOpen: openSearch }}
loanBannerNav={{ tabs: loanTabs, activeTabId, onTabChange }} // presence ⇒ loan file
urlaTabsNav={{ applicants, selectedApplicantId, onApplicantChange,
tabs: urlaTabs, activeTabId: urlaTab, onTabChange: setUrlaTab }}
loanDetails={{ borrowerName, sections, openIds, onChange: setOpenIds }} // presence ⇒ rail
loanDetailsOpen={detailsOpen} // rail on desktop, drawer on mobile
onLoanDetailsOpenChange={setDetailsOpen}
menu={<VerticalMenu top="var(--shell-nav-height)" />}
>
<CreditScreen />
</Shell>const [openIds, setOpenIds] = useState(['loan-info', 'pricing']);
<LoanDetailsPanel
variant={isCompact ? 'drawer' : 'rail'} // placement only — same interior
onClose={closeRail} // drawer only
borrowerName="Ana Reyes"
coBorrowerName="Luis Reyes"
phone="(512) 555-0198"
loanNumber="1000123456"
onEmailClick={sendMail}
openIds={openIds}
onChange={setOpenIds}
sections={[
{
id: 'loan-info',
title: 'Loan Info',
rows: [{ label: 'LO', value: 'H. Chen' }, { label: 'Status', value: 'LE Pending' }],
footer: <Button variant="secondary" size="sm">Add Tag</Button>,
},
{
id: 'pricing',
title: 'Pricing',
intro: <div>Conventional 30 Yr Fixed</div>, // free content above the rows
rows: [
{ label: 'Interest Rate', value: '6.875%' },
{
label: 'Total Housing',
value: '$3,018.68',
breakdown: { // opts the row into an info-circle + card
title: 'Total Housing',
columns: [{ field: 'name', header: 'Housing' },
{ field: 'amount', header: 'Amount', align: 'right' }],
rows: [{ name: 'Principal & Interest', amount: '$2,104.18' }],
total: '$3,018.68', // becomes the table's footer row
},
},
],
},
]}
/>A section's rows is optional — a section that is only a header action plus an "Add …" button needs no escape hatch. intro and footer are the slots for anything that is not a key/value pair. The drawer variant does not render a scrim: apps typically share one across several drawers, so that stays the consumer's.
Migrating
Renames and removals a consumer needs to apply when upgrading. Each row is a mechanical find-and-replace unless noted.
Renamed — update imports and JSX
| Before | After |
|---|---|
| FullNav / CompleteDesktopNav | Shell |
| FullNavMobile / CompleteMobileNav | Shell |
| FullNavProps / CompleteDesktopNavProps | ShellProps |
| FullNavMobileProps / CompleteMobileNavProps | ShellProps |
Not a pure rename: Shell picks the tier set itself, so the desktop and mobile calls collapse into one.
- import { FullNav, FullNavMobile } from '@cmgfi/clear-ds';
- import type { FullNavProps, FullNavMobileProps } from '@cmgfi/clear-ds';
+ import { Shell } from '@cmgfi/clear-ds';
+ import type { ShellProps } from '@cmgfi/clear-ds';There are no back-compat aliases — the old names are gone. See the Navigation section for the prop shape, and note that profile is hoisted out of topBar and the URLA tier renders inside the content column rather than as a full-width band.
Split — URLATabsNavMobile row 1 is now LoanBannerNavMobile
URLATabsNavMobile used to render two rows: a loan-banner row (borrower name, Loan Details, section switcher, sort pill, Actions, Save) and the URLA sub-nav row. The first row was loan-banner content, so it is now its own component. Props moved, not renamed — split the single call into two:
- <URLATabsNavMobile
- borrowerName="Ryan Smith"
- onLoanDetailsClick={openDetails}
- sectionLabel="URLA"
- sectionItems={SECTIONS}
- onSectionSelect={setSection}
- onSectionSort={sortSections}
- actionItems={ACTIONS}
- onActionSelect={runAction}
- onSave={save}
- applicants={applicants}
- selectedApplicantId={applicantId}
- onApplicantChange={setApplicant}
- tabs={tabs}
- activeTabId={tabId}
- onTabChange={setTab}
- />
+ <LoanBannerNavMobile
+ borrowerName="Ryan Smith"
+ onLoanDetailsClick={openDetails}
+ sectionLabel="URLA"
+ sectionItems={SECTIONS}
+ onSectionSelect={setSection}
+ onSectionSort={sortSections}
+ actionItems={ACTIONS}
+ onActionSelect={runAction}
+ onSave={save}
+ />
+ <URLATabsNavMobile
+ applicants={applicants}
+ selectedApplicantId={applicantId}
+ onApplicantChange={setApplicant}
+ tabs={tabs}
+ activeTabId={tabId}
+ onTabChange={setTab}
+ />Better still, hand both to Shell and let it stack them — and pick the tier set, so the same call covers desktop:
<Shell
profile={profile}
topBarMobile={{ onMenuOpen, onSearchOpen }}
loanBannerNav={{ tabs, activeTabId, onTabChange }}
loanBannerNavMobile={{ borrowerName: 'Ryan Smith', onSave: save }}
urlaTabsNav={{ applicants, selectedApplicantId, onApplicantChange, tabs, activeTabId, onTabChange }}
>
{page}
</Shell>The loan tiles are declared once on loanBannerNav and fed to whichever bar renders; loanBannerNavMobile carries only what has no desktop equivalent.
Changed behavior — no code change required, but check your UI
| Change | What to check |
|---|---|
| TopBar / TopBarMobile are 60px tall, up from 56 | Anything offsetting page content by a hardcoded 56px shifts by 4px. |
| LoanBannerNav's primaryItems defaults to [] | Save Loan renders as a plain button with no caret, and Save History is now its own top-level button. Pass primaryItems to keep the SplitButton. |
| LoanBannerNav's Refresh is now labeled | It was an icon-only button; it now reads "Refresh" with the icon. Collapsed mode still uses the icon. |
| "Generate Needs List" opens a popover, not a menu | It now shows a checkbox tree of applicants with a Generate button, supplied via LoanBannerNavAction.needsList. An action's items is ignored when needsList is set. The default ships empty — pass real applicants to make it useful. |
| Tooltip's className now actually applies | The prop existed but was never attached to the wrapper. If you were passing one, it starts taking effect. |
Removed — no direct replacement
| Removed | What to do |
|---|---|
| URLATabsNavTablet, URLATabsNavTabletProps | No tablet layout. Use URLATabsNav or URLATabsNavMobile per your breakpoint. |
| LoanBannerNav's toolbar prop, LoanBannerNavToolbar | The secondary toolbar row is gone. Move the left/center/right slot content into the page body. |
| TopBarMobile's borrower prop, TopBarMobileBorrower | URLA mode is gone. The borrower name is already shown by LoanBannerNavMobile. |
| QuickActionsPanel's options + items props | Replaced by one sections array — each entry is a dropdown option and its panel body (items for a menu body, content for anything else). |
On mobile there is no standalone Loan Notes icon button — Loan Notes is an entry in the quick-actions menu (DEFAULT_QUICK_ITEMS, now exported and shared by both bars). The icon button remains on desktop.
Also worth doing while you're here
If the app stacks nav tiers by hand, replace that with Shell — see Navigation above for why.
Token System
All tokens are CSS custom properties defined in src/tokens/tokens.css and exported via @cmgfi/clear-ds/tokens.
Base scale: 1rem = 12px — the consumer app must set html { font-size: 12px }.
Colors
Seven families, 10 shades each (-50 lightest → -900 darkest):
| Family | Prefix | Role |
|---|---|---|
| Teal | --teal-* | Brand color; primary actions, focus rings, active states |
| Surface | --surface-* | Neutral backgrounds; 50 = white, 900 = near-black |
| Navy | --navy-* | Text and borders |
| Yellow | --yellow-* | Warning severity |
| Blue | --blue-* | Informational severity |
| Green | --green-* | Success severity |
| Red | --red-* | Error severity |
Semantic aliases:
--color-text: var(--navy-800); /* primary body text */
--color-text-secondary: var(--navy-500); /* helper / secondary text */Spacing
Two complementary scales:
/* Numeric — direct pixel mapping */
--spacing-02: 2px; --spacing-04: 4px; --spacing-06: 6px;
--spacing-08: 8px; --spacing-12: 12px; --spacing-16: 16px;
--spacing-20: 20px; --spacing-24: 24px; --spacing-32: 32px;
--spacing-40: 40px;
/* Named aliases — semantic */
--spacing-xxxs: 2px; --spacing-xxs: 4px; --spacing-xs: 6px;
--spacing-sm: 8px; --spacing-md: 12px; --spacing-lg: 16px;
--spacing-xl: 24px; --spacing-xxl: 32px;Typography
Font family: Open Sans (--font-family: 'Open Sans', sans-serif)
The typeface ships with the package — importing the tokens stylesheet is all you
need, no font <link> or @font-face on your end:
import '@cmgfi/clear-ds/tokens';Open Sans is embedded in that file as a variable font (latin subset) covering a
continuous 300–800 weight axis, so --font-weight-semibold (600) and
--font-weight-bold (700) render as distinct weights. Non-latin glyphs fall back
to the next family in the stack. The font is licensed under SIL OFL 1.1 —
see LICENSE-Open-Sans.txt in the package.
Heading utility classes: .text-h1 through .text-h6
Body utility classes — 4 sizes × 3 weights = 12 combinations:
.text-xl-bold .text-xl-semibold .text-xl-regular
.text-large-bold .text-large-semibold .text-large-regular
.text-normal-bold .text-normal-semibold .text-normal-regular
.text-small-bold .text-small-semibold .text-small-regularElevation
--shadow-depth-1: 0 1px 4px rgba(0,0,0,0.12);
--shadow-depth-2: 0 2px 8px rgba(0,0,0,0.16);
--shadow-depth-3: 0 4px 16px rgba(0,0,0,0.20);
--shadow-depth-4: 0 8px 32px rgba(0,0,0,0.24);
--scrim: /* navy-500 at 25% opacity */;Icons
--icon-size-sm: 12px;
--icon-size-md: 14px;
--icon-size-lg: 18px;
--icon-size-xl: 24px;Usage with PrimeIcons:
<i className="pi pi-check" style={{ fontSize: 'var(--icon-size-lg)' }} />Grid
/* Mobile — 4 column */
--grid-columns-mobile: 4; --grid-gutter-mobile: 16px;
--grid-margin-mobile: 24px; --grid-container-mobile: 576px;
/* Tablet — 6 column */
--grid-columns-tablet: 6; --grid-gutter-tablet: 16px;
--grid-margin-tablet: 48px; --grid-container-tablet: 768px;
/* Desktop — 12 column */
--grid-columns-desktop: 12; --grid-gutter-desktop: 18px;
--grid-margin-desktop: 128px; --grid-container-desktop: 1200px;
/* Breakpoints */
--breakpoint-sm: 576px; --breakpoint-md: 768px; --breakpoint-lg: 1200px;Tech Stack
| Concern | Choice | Notes |
|---|---|---|
| Framework | React >=17 | Peer dep — never bundled; consumer's React is used |
| Language | TypeScript 5 strict | Exported types are part of the public API |
| Build | Vite 5 library mode | ESM + CJS dual output; CSS Modules built-in |
| Type declarations | vite-plugin-dts rollupTypes: true | Single rolled-up index.d.ts |
| Styles | CSS Modules | Scoped, zero runtime overhead; token vars pass through without JS |
| Docs / QA | Storybook v8 @storybook/react-vite | Shares the same Vite config; no duplicate setup |
| Icons | PrimeIcons >=7 | CSS font glyphs; pi pi-* class API; must be peer dep |
| Node / npm | 22 / 11 (LTS) | |
Not present (intentional):
- No CSS-in-JS — runtime overhead is unacceptable in a library
- No Tailwind — custom token system conflicts with Tailwind's class API
- No third-party component library — pixel-fidelity requires full control
Project Structure
clear-ds/
├── src/
│ ├── index.ts ← explicit public API; all component + type exports
│ ├── tokens/
│ │ ├── tokens.css ← all CSS custom properties (428 lines)
│ │ ├── TokenDocs.module.css ← shared layout for token Storybook pages
│ │ ├── Colors.stories.tsx
│ │ ├── Typography.stories.tsx
│ │ ├── Spacing.stories.tsx
│ │ ├── Elevation.stories.tsx
│ │ ├── Icons.stories.tsx
│ │ └── Grid.stories.tsx
│ └── components/
│ └── ComponentName/
│ ├── ComponentName.tsx ← React.forwardRef; JSDoc props; displayName
│ ├── ComponentName.module.css← token vars only; no hardcoded values
│ ├── ComponentName.stories.tsx← autodocs; named states; AllStates last
│ └── index.ts ← re-exports component + all types
├── .storybook/
│ ├── main.ts ← framework, addons, stories glob
│ └── preview.ts ← global CSS imports, backgrounds, storySort
├── dist/ ← build output (not committed)
├── vercel.json ← Storybook deployment config
├── vite.config.ts
├── tsconfig.json
└── package.jsonLocal Development
Prerequisites: Node 22, npm 11
# Install dependencies
npm install
# Start Storybook dev server (localhost:6006)
npm run storybook
# Build the library (outputs to dist/)
npm run build
# Type-check without emitting
npm run type-check
# Watch mode for library build
npm run build:watchAdding a new component
Every component follows the same invariant structure — do not deviate:
- Create
src/components/ComponentName/with four files:ComponentName.tsx— alwaysReact.forwardRef; props extendReact.HTMLAttributes<HTMLElement>; every prop has a JSDoc comment; setdisplayName; spread...props; mergeclassNameComponentName.module.css— token vars exclusively; no hardcoded hex or pixel values (except sub-pixel precision:border: 1px,outline-offset: 2px); use:focus-visiblenot:focusComponentName.stories.tsx—tags: ['autodocs']on meta; individual state stories;AllStatesis always the last exportindex.ts— re-export component and all types
- Add exports to
src/index.ts - Add the component to
storySort.orderin.storybook/preview.ts
Adding a new template
A template follows the component structure above without exception — same four files in src/components/<Name>/, same token-only CSS, same export from src/index.ts. Two things differ:
- The story
titleisTemplates/<Name>rather thanComponents/<Group>/<Name>, and the name goes in theTemplatesarray ofstorySort.order - It composes existing DS components and adds no new primitive. If a template needs a visual part that does not exist yet, that part becomes its own component first
A template must not carry domain data. Take labels, values, and section structure as props — the moment a template knows what a loan field is called, every schema change becomes a DS release.
Storybook conventions
- Sidebar order:
Best Practices → Tokens → Components (Buttons, Data, Form Controls, Messages, Overlay, Panel, Status) → Navigation → Templates tags: ['autodocs']is mandatory on everymeta— without it there is no Docs tabAllStates(orAllSizes/AllSeverities) must always be the last story export — Storybook renders in declaration order- When a story
render:function needsuseState, define a named inner component and call the hook inside it (hooks cannot be called insiderenderdirectly)
Build Output
dist/
├── index.mjs ← ESM bundle (tree-shakeable), 230 KB
├── index.cjs ← CommonJS bundle, 153 KB
├── index.css ← all compiled component styles, 126 KB
├── index.d.ts ← single rolled-up TypeScript declarations, 81 KB
├── index.mjs.map ← ESM source map
├── index.cjs.map ← CJS source map
└── tokens/
└── tokens.css ← design tokens (separate import path), 14 KBVite config highlights:
cssCodeSplit: false— all CSS Modules compile into a singleindex.cssexternal: ['react', 'react/jsx-runtime', 'react-dom']— React is never bundledrollupTypes: true— singleindex.d.tsinstead of one file per source fileassetFileNames: 'index.css'— predictable output filenamepostbuildnpm script — copiessrc/tokens/tokens.css→dist/tokens/tokens.css
Publishing
Releases are fully automated by GitHub Actions — do not run npm publish or vercel deploy from a laptop. Teammates need only write access to the repo (via XD Team membership); CI uses org-owned NPM_TOKEN and VERCEL_TOKEN secrets.
npm — publish a new version
git checkout main && git pull
npm version patch # or: minor / major
git push origin main && git push origin --tags
gh run watch # confirm the publish workflow goes greenThe v* tag push triggers .github/workflows/publish-npm.yml, which builds and publishes @cmgfi/clear-ds to public npm.
Storybook (Vercel)
Storybook deploys automatically on every push to main via .github/workflows/deploy-vercel.yml, hosted at clear-ds.cmgfinancial.ai under the cmgprojects team. No manual vercel deploy needed.
vercel.json configures the build:
{
"buildCommand": "npm run build-storybook",
"outputDirectory": "storybook-static"
}Full release instructions and the emergency manual fallback live in RELEASING.md.
Contributing
Git workflow
- Remote:
https://github.com/cmg-pilot-program/clear-ds.git - Branch from
main, PR back tomain - Commit messages follow conventional commits (
feat:,fix:,chore:,docs:) - No approval required to merge — open a PR, wait for the
buildcheck (.github/workflows/ci.yml) to pass, then squash-merge your own PR. Admins can--admin-bypass in a true emergency. SeeRELEASING.mdfor the full release flow.
Before submitting a PR
- [ ]
npm run type-checkpasses with zero errors - [ ]
npm run buildcompletes successfully - [ ] New component has all four required files
- [ ]
tags: ['autodocs']is on the story meta - [ ]
AllStatesis the last story export - [ ] No hardcoded colors or spacing values — token vars only
- [ ]
src/index.tsexports the new component and all its types - [ ] Component added to
storySort.orderin.storybook/preview.ts
CI runs
npm run build+npm run build-storybookon every PR (the requiredbuildcheck). A PR cannot merge until it passes — but no human approval is needed.
Hard-won Rules
These are not guidelines — they are invariants derived from real failures during the build of this library:
| Rule | Why |
|---|---|
| tags: ['autodocs'] on every story meta | Without it there is no Docs tab. Retroactively adding it across many files is painful. Add it when the file is created. |
| AllStates always last story export | Storybook renders in declaration order. A misplaced overview story at the top pollutes the component's story list. |
| primeicons in peerDependencies, not devDependencies | If it is only in devDeps, it will not be available in consumer bundles — icon glyphs silently disappear. |
| publishConfig: { "access": "public" } is required | Scoped packages are private by default on npm. Without this field, npm publish is rejected even with a valid token. |
| Never hardcode colors or spacing | Always use token vars. Hardcoded values break theming and make design drift invisible. |
| React.forwardRef on every component | Consumers need ref access for focus management, animations, and third-party integrations. |
| :focus-visible not :focus | :focus shows focus rings on mouse clicks. :focus-visible restricts them to keyboard navigation. |
| min-width: 0 on shrinkable flex children | flex: 1 alone does not allow an item to shrink below its content size. Without min-width: 0, the item overflows its container. |
| Merge consumer className, never replace | Spreading ...props is not enough. Explicitly merge: className={[styles.root, props.className].filter(Boolean).join(' ')}. |
| The postbuild script must stay in package.json | It copies src/tokens/tokens.css → dist/tokens/tokens.css. Without it, the @cmgfi/clear-ds/tokens import path resolves to a file that does not exist. |
Design Source
All visual decisions originate in Pencil — a design tool integrated as an MCP server. The .pen source files are in the project experiments directory alongside this package. The code is a direct translation of those designs: same structure, same hierarchy, same property names.
The token master copy lives at ../clear-ds-tokens.css (one level above this package). When updating tokens, sync that file into src/tokens/tokens.css.
