LCO DMA Design System¶
Single source of truth for the visual design language. All tokens are defined as CSS custom properties using Tailwind CSS v4's CSS-native configuration.
Figma source: LCO-DMA Dev Design
Architecture¶
design-tokens.css ← Color primitives, semantic tokens, typography, spacing (source of truth)
↓
index.css ← @theme inline (shadcn bridge) + @utility rules (text-lco-*) + sidebar theme
↓
Components ← Use Tailwind classes (never hardcoded values)
Tailwind v4: No
tailwind.config.jsorpostcss.config.js— configuration is CSS-native via@theme inlineblocks and@utilityrules. The Vite plugin (@tailwindcss/vite) handles compilation. See the Tailwind v4 gotcha section below.
Rules:
1. All colors, sizes, and dimensions are CSS variables — never hardcode hex/rgb/px
2. Use semantic Tailwind classes (e.g. text-lco-heading-01) not raw values (text-sm, text-xl)
3. Use semantic color tokens (text-foreground, bg-primary) not raw colors (text-neutral-500, bg-white, bg-blue-900)
4. Use cn() from @/lib/utils for conditional class merging
5. Use shadcn/ui components from @/components/ui/ — don't rebuild primitives
Configuration Files¶
| File | Purpose |
|---|---|
src/design-tokens.css |
CSS variables — color primitives, semantic tokens, typography, spacing |
src/index.css |
shadcn bridge (@theme inline), @utility rules (text-lco-*), sidebar theme, dark mode |
src/lib/utils.ts |
cn() utility (clsx + extendTailwindMerge — prevents text-lco-* stripping) |
components.json |
shadcn/ui configuration |
vite.config.ts |
@tailwindcss/vite plugin (replaces tailwind.config.js + postcss) |
Font¶
Family: Geist by Vercel — geometric sans-serif optimized for UI.
Package: geist@1.7.0 — import in main.tsx:
Weights used: 400 (normal), 500 (medium), 600 (semibold)
Typography Scale¶
Semantic tokens map design intent to sizes. Use text-lco-* classes — they include size, line-height, and default weight.
Implementation note:
text-lco-*classes are defined as@utilityrules inindex.css(not auto-generated from@theme). This is because@theme inlinein imported CSS files does not generate Tailwind v4 utilities — only the main entry file's@themeblock does. When adding new typography tokens, add the CSS variable todesign-tokens.cssand a matching@utilityrule toindex.css.
| Token | Class | Size | Line-Height | Weight | Usage |
|---|---|---|---|---|---|
--font-size-lco-display |
text-lco-display |
36px | 1.1 | 500 | Hero headings (Home welcome) |
--font-size-lco-heading-01 |
text-lco-heading-01 |
24px | 1.2 | 600 | Page titles |
--font-size-lco-heading-02 |
text-lco-heading-02 |
20px | 1.3 | 500 | Section headings |
--font-size-lco-heading-03 |
text-lco-heading-03 |
16px | 1.4 | 600 | Sub-section headings, card titles |
--font-size-lco-body-01 |
text-lco-body-01 |
16px | 1.5 | — | Primary body text |
--font-size-lco-body-02 |
text-lco-body-02 |
14px | 1.43 | — | Secondary body, table cell text |
--font-size-lco-caption |
text-lco-caption |
12px | 1.33 | — | Captions, badges, breadcrumbs, metadata |
--font-size-lco-overline |
text-lco-overline |
11px | 1.45 | 500 | Column headers, overline labels (tracking: 0.025em) |
Tailwind v4 Gotcha¶
@theme inline blocks in imported CSS files (like design-tokens.css) define CSS variables but do NOT auto-generate Tailwind utility classes. Only the main entry file's @theme inline generates utilities. That's why text-lco-* classes are defined as explicit @utility rules in index.css.
When adding new design tokens:
- Colors: Add --color-* to design-tokens.css @theme inline — color utilities ARE generated because they're bridged through index.css @theme inline via var() references
- Typography: Add --font-size-* to design-tokens.css, then add a matching @utility rule in index.css. Also add the new class name to the extendTailwindMerge font-size list in lib/utils.ts so cn() doesn't strip it.
- Spacing/layout: Add to index.css @theme inline directly (e.g., --spacing-header)
When to Override Weight¶
The text-lco-* classes set a default weight, but you can override with font-normal, font-medium, font-semibold:
<h1 className="text-lco-display font-medium">Welcome back, User</h1>
<p className="text-lco-body-02 font-normal text-muted-foreground">Description text</p>
<th className="text-lco-overline font-medium uppercase text-muted-foreground">Column</th>
Do NOT add leading-* classes alongside text-lco-* — line-height is already baked in.
Migration from Raw Tailwind Classes¶
| Old (raw Tailwind) | New (semantic) | Context |
|---|---|---|
text-2xl font-semibold |
text-lco-heading-01 |
Page titles (24px) |
text-xl font-semibold or text-lg font-semibold |
text-lco-heading-02 |
Section headings (20px) |
text-base font-semibold |
text-lco-heading-03 |
Card/subsection titles (16px) |
text-base |
text-lco-body-01 |
Primary body text (16px) |
text-sm |
text-lco-body-02 |
General body text, table cells (14px) |
text-xs |
text-lco-caption |
Metadata, timestamps, badges (12px) |
text-xs font-medium uppercase |
text-lco-overline |
Table column headers (11px) |
Color System¶
Semantic Colors (via CSS Variables)¶
These are the primary colors used across the app. All defined in index.css, consumed via Tailwind.
| Token | Tailwind | Hex (Light) | Usage |
|---|---|---|---|
--foreground |
text-foreground |
#151619 | Primary text (lco-gray-900) |
--muted-foreground |
text-muted-foreground |
#86878B | Secondary text (lco-gray-400) |
--primary |
bg-primary, text-primary |
#0f4380 | Brand blue — CTAs, links |
--primary-foreground |
text-primary-foreground |
#FFFFFF | Text on primary bg |
--destructive |
bg-destructive |
#DA1E28 | Delete, danger actions |
--border |
border-border |
#D7D7D8 | Standard borders (lco-gray-200) |
--muted |
bg-muted |
#F9F9FA | Subtle backgrounds (lco-gray-50) |
--accent |
bg-accent |
#F9F9FA | Hover/focus backgrounds (lco-gray-50) |
--card |
bg-card |
#FFFFFF | Card backgrounds |
--secondary |
bg-secondary |
#F9F9FA | Secondary backgrounds (lco-gray-50) |
LCO Color Palettes¶
| Palette | Key shades | Usage |
|---|---|---|
lco-primary |
50–900 (mid: #0f4380) | Brand blue, CTAs, links |
lco-gray |
25–900 (warmer custom gray) | All UI chrome via semantic tokens — replaces Tailwind neutral |
lco-slate |
50–900 (mid: #2F4054) | Sidebar dark theme |
lco-blue-accent |
25–900 (mid: #5A8DE0) | Charts, info states, secondary blue |
lco-yellow-accent |
25–900 (mid: #D9AF3A) | Charts, warm accents |
Note:
lco-grayis a warmer custom gray that replaces Tailwind'sneutralfor semantic tokens. Status palettes (red, green, amber, blue) still use Tailwind built-ins.
Status Colors (Direct Tailwind)¶
| Status | Background | Text | Border |
|---|---|---|---|
| Active | bg-green-50 |
text-green-700 |
border-green-200 |
| Inactive | bg-yellow-50 |
text-yellow-900 |
border-yellow-500 |
| Archived | bg-neutral-100 |
text-neutral-600 |
border-neutral-300 |
| Error | bg-red-50 |
text-red-700 |
border-red-200 |
| Warning | bg-yellow-50 |
text-yellow-900 |
border-yellow-500 |
| Info | bg-blue-50 |
text-blue-700 |
border-blue-200 |
Color Migration — Use Semantic Tokens¶
Prefer semantic tokens over raw Tailwind colors. These adapt to dark mode and theme changes:
| Old (raw) | New (semantic) | Why |
|---|---|---|
text-neutral-950 |
text-foreground |
Primary text |
text-neutral-700, text-neutral-600 |
text-muted-foreground |
Secondary text |
text-neutral-500 |
text-muted-foreground |
Muted text, breadcrumbs |
bg-white |
bg-card or bg-background |
Card / page backgrounds |
bg-neutral-100 |
bg-muted |
Page backgrounds, subtle fills |
bg-neutral-50 |
bg-accent |
Table headers, hover states |
border-neutral-200 |
border-border |
Standard borders |
bg-blue-900, bg-blue-800 |
bg-primary, hover:bg-primary/90 |
Brand CTA buttons |
text-white (on brand bg) |
text-primary-foreground |
Text on primary bg |
Layout Dimensions¶
CSS variables defined in index.css @theme inline:
| Token | Tailwind | Value | Usage |
|---|---|---|---|
--spacing-header |
h-header |
48px | Top header bar height |
--spacing-sidebar-collapsed |
w-sidebar-collapsed |
56px | Sidebar icon-only width |
--spacing-sidebar-expanded |
w-sidebar-expanded |
256px | Sidebar full width |
Spacing Scale¶
Standard Tailwind spacing. Key values from the Figma:
| Tailwind | px | Figma Variable | Usage |
|---|---|---|---|
p-1 |
4px | spacing/1 |
Tight gaps |
p-2 |
8px | spacing/2 |
Icon padding, compact spacing |
p-3 |
12px | spacing/3 |
Sidebar item gaps |
p-4 |
16px | spacing/4 |
Standard padding, header px |
p-6 |
24px | spacing/6 |
Primary containers, page padding |
p-9 |
36px | spacing/9 |
Large section spacing |
gap-2 |
8px | — | Compact element spacing |
gap-4 |
16px | — | Form fields, button groups |
gap-6 |
24px | — | Card grids, major sections |
Border Radius¶
Base --radius: 0.625rem (10px). All computed from this value:
| Tailwind | Value | Usage |
|---|---|---|
rounded-sm |
6px | Buttons, breadcrumb items, inputs |
rounded-md |
8px | Logo wrapper, cards |
rounded-lg |
10px | Cards, sidebar items |
rounded-xl |
14px | Large cards, modals |
rounded-full |
9999px | Avatars, badges, pill tabs |
Shadow¶
| Name | Value | Usage |
|---|---|---|
shadow-xs |
0 1px 2px #0000000d |
Subtle card elevation |
shadow-sm |
Default Tailwind | Standard card shadow |
Sidebar Themes¶
Two sidebar themes via CSS class on the sidebar root:
Default (Light)¶
White background, dark text. No extra class needed.
Slate (Dark)¶
Apply .sidebar-slate class to the sidebar container:
This overrides all --sidebar-* variables to the dark navy palette.
Shell Layout¶
The app uses a consistent shell: Sidebar + Header + Page Content.
┌─────┬────────────────────────────────────┐
│ │ Header (48px) — breadcrumbs + utils│
│ S ├────────────────────────────────────┤
│ i │ │
│ d │ Page Header — title, desc, actions │
│ e │ │
│ b │ Content Area │
│ a │ │
│ r │ │
│ │ │
└─────┴────────────────────────────────────┘
Page Structure Pattern¶
<div className="flex flex-col h-full">
<PageHeader title="Page Title" description="Description" actions={...} />
<div className="flex-1 bg-muted p-6">
{/* Content */}
</div>
</div>
Component Patterns¶
Buttons¶
All variants use semantic tokens — never hardcode bg-blue-* or text-white in button styling.
| Variant | When | Example |
|---|---|---|
default |
Main page action ("Create Project") | <Button>Create</Button> |
primary |
Equivalent to default — brand blue CTA | <Button variant="primary">Save</Button> |
outline |
Secondary actions ("Export", "Cancel") | <Button variant="outline">Export</Button> |
outlineSecondary |
Subtle secondary with shadow | <Button variant="outlineSecondary">Manage</Button> |
ghost |
Icon buttons, tertiary actions | <Button variant="ghost" size="icon"><X /></Button> |
destructive |
Delete, dangerous actions | <Button variant="destructive">Delete</Button> |
Button sizes (default = h-8, compact):
| Size | Height | Use |
|---|---|---|
default / sm |
32px (h-8) | Standard buttons |
xs |
24px (h-6) | Compact UI, inline |
md |
40px (h-10) | Prominent CTAs, forms |
lg |
48px (h-12) | Hero actions |
icon / icon-sm |
32px | Icon buttons |
icon-xs |
24px | Compact icon buttons |
icon-md |
40px | Prominent icon buttons |
icon-lg |
48px | Large icon buttons |
Cards¶
Three Figma card layouts:
| Layout | Usage | Pattern |
|---|---|---|
| Default (horizontal) | Compact clickable row | Icon + title + description + arrow |
| Vertical | Feature cards with link | Icon + title + desc + action link |
| Vertical 2 | Universal Tables hub | Icon + title + desc + button |
Tabs¶
Two tab styles:
| Variant | Tailwind | Usage |
|---|---|---|
| Underline | Default shadcn Tabs |
Page-level navigation (PageHeader tabs) |
| Pill | Rounded bg on active | Section filtering (Home "Recent Activity" / "Bookmarks") |
Data Tables¶
Tables use TanStack Table v8 with two toolbar variants:
| Variant | Controls | Usage |
|---|---|---|
| Extensive | Select view, Filter, Sort, Group by, Hide, overflow, Search | Detail pages (Crew Members, etc.) |
| Basic | Filter, Sort, Hide, Search | List pages (Projects, Clients) |
Bulk Actions Bar: Blue bar (bg-lco-primary-400) at top of table when rows selected.
Row Overflow Menu: 3-dot menu on each row (right side).
Table Footers / Totals Rows¶
Decision Rule (LCO-247 / LCO-256, stable token LCO-256-C3)¶
- Has in-table totals row → add a
Total Man-Hourssum cell to that footer row (use the canonical hyphenated labelTotal Man-Hours, matching thetotalManhourscolumn header). Sum must recompute on filter/sort (client-side only; no backend changes). - No in-table totals row → no-op. Document the decision in the audit table below and in a JSDoc marker on the relevant column-config file (search the codebase for token
LCO-256-C3to find all such markers).
Audit Results — May 2026¶
| Surface | Table tech | In-table totals row? | Verdict |
|---|---|---|---|
| Current Budget items tab | VirtualizedDataTable | No — rollups live in KPI header cards | DEFER — adding a footer requires PM UX decision (LCO-247 out of scope); rollup already in KPI cards |
| Current Budget "By Package" tab | shadcn Table + manual summary card |
No — manual rollup card sits below the table | DEFER — sibling summary card already serves as the rollup; no in-table totals row to attach to |
| Control Budget items tab | VirtualizedDataTable | No — rollups live in KPI header cards | DEFER — same as Current Budget items tab |
| Control Budget "By Package" tab | shadcn Table + manual summary card |
No — manual rollup card sits below the table | DEFER — same as Current Budget By-Package |
| MTO Discipline Sheets | VirtualizedDataTable | No — virtualized; no <TableFooter> plumbing exists |
DEFER — adding requires VirtualizedDataTable footer support (separate ticket) |
| Estimation Master | VirtualizedDataTable | No | DEFER — same as MTO discipline sheets |
ECR Budget Variance Table (BudgetVarianceTable.tsx) |
shadcn Table |
Verify on next PM review — currently no <TableFooter> |
DEFER — not in LCO-247 cluster scope; flag for future audit if a totals row is added |
Labor Distribution Report (LaborDistributionReportPage.tsx → LaborDistributionTable.tsx) |
shadcn Table, prop totalManhours already passed from page |
No | DEFER — report-style page; rollup is conveyed via the totalManhours prop displayed in the report header, not via a table footer |
Labor Distribution Chart (LaborDistributionChart.tsx) |
chart, not a table | N/A | NOT-A-TABLE — out of scope for table-footer pattern |
S-Curve Phase Calculate Card (PhaseCalculateCard.tsx) |
summary card with inline <strong>Total Man-Hours:</strong> |
N/A | NOT-A-TABLE — already surfaces total man-hours in a labelled summary line |
Labor Settings Tab (LaborSettingsTab.tsx) |
formula-doc helper with inline <strong>Total Man-Hours:</strong> |
N/A | NOT-A-TABLE — documentation helper, not a data table |
Discipline Validation Panel (DisciplineValidationPanel.tsx) |
shadcn Table with <TableFooter> for validation.totalObsHours (OBS hours, not man-hours) |
Footer exists, but for a non-man-hour metric | N/A — surface does not expose man-hours; out of contract scope |
When to Revisit¶
Revisit this decision when PM confirms that any of the above views should display in-table aggregate totals. At that point:
- Wire
getFooterGroups()consumption intoVirtualizedDataTable(currently absent). - Add a
footercallback to thetotalManhourscolumn def on the target surface. - For non-virtualized shadcn tables (Current/Control Budget "By Package", ECR Budget Variance), sum via
table.getFilteredRowModel().rows. For VirtualizedDataTable, source the sum from the page's already-maintained post-filter-sort array (not from the virtualized DOM slice). See behavior contract C-256-3.1.
Badges¶
Badges are pill-shaped (rounded-full) with 6 variants: default, secondary, destructive, outline, ghost, link.
Status badges use the outline variant with direct Tailwind colors for data states:
<Badge variant="outline" className="border-green-500 text-green-700">Active</Badge>
<Badge variant="outline" className="border-yellow-500 text-yellow-800">Inactive</Badge>
<Badge variant="outline" className="border-neutral-300 text-neutral-600">Archived</Badge>
Form Patterns¶
react-hook-form+zodfor validation- Schemas in
frontend/src/schemas/ - Use
<Form>/<FormField>from@/components/ui/form - Layout:
space-y-4for field stacking,grid grid-cols-2 gap-4for multi-column
Input Styling¶
All inputs inherit from shadcn — h-10 rounded-md border-input text-sm.
Combobox Variants¶
| Component | Height | Usage |
|---|---|---|
Combobox |
h-10 | Standard searchable dropdown |
CellCombobox |
h-8 | Table cell editing |
FilterCombobox |
h-6 | Table header filters |
COACombobox |
Configurable | Server-side search with infinite scroll |
Loading & Feedback¶
Toast Notifications¶
| Variant | Usage |
|---|---|
default |
Success confirmations |
destructive |
Error messages |
warning |
Warnings (yellow) |
Loading States¶
<Loader2 className="h-4 w-4 animate-spin" /> // Inline
<Loader2 className="h-8 w-8 animate-spin text-primary" /> // Full page
Skeleton Loaders¶
Icons¶
Library: Lucide React (lucide-react)
| Context | Size |
|---|---|
| Standard UI (buttons, nav) | h-4 w-4 (16px) |
| Small indicators | h-3 w-3 |
| Feature icons (cards) | h-5 w-5 |
| Large (empty states) | h-6 w-6 |
| Loading spinners | h-8 w-8 |
Z-Index Reference¶
| Element | z-index |
|---|---|
| Sidebar | z-10 |
| Header | z-20 |
| Modals / Dialogs | z-50 |
| Floating action bar | z-50 |
| Popovers / Tooltips | z-50 |
Quick Reference: Adding New UI¶
- Use semantic
text-lco-*classes — not rawtext-xl - Use
bg-primary/text-primary— not hardcoded#0f4380 - Use
h-header/w-sidebar-collapsedfor layout dimensions - Use
cn()for conditional classes — never string concatenation - Import shadcn components from
@/components/ui/ - Check this doc + Figma before creating new visual patterns