16 KiB
Architecture Patterns: Design System
Domain: shadcn/ui + Tailwind CSS 4 design system for personal finance dashboard Researched: 2026-03-11 Confidence: HIGH (based on direct codebase inspection + framework documentation patterns)
Recommended Architecture
The goal is a pastel design system layered on top of shadcn/ui without replacing it. The architecture has three tiers:
- Token layer — CSS custom properties in
index.css(already exists, needs pastel values) - Variant layer — CVA-based component variants in
components/ui/(shadcn files, lightly patched) - Composition layer — Feature components in
components/that assemble ui primitives with domain semantics
frontend/src/
index.css ← Token layer: ALL CSS variables live here
lib/
utils.ts ← cn() helper (already exists)
components/
ui/ ← Variant layer: shadcn primitives (owned, patchable)
button.tsx
card.tsx
badge.tsx
...
category-badge.tsx ← Composition layer: domain-specific wrapper
stat-card.tsx ← Composition layer: reusable financial card pattern
progress-bar.tsx ← Composition layer: budget vs actual bar
page-header.tsx ← Composition layer: consistent page headers
pages/ ← Page layer: route views that compose components
Component Boundaries
| Component | Responsibility | Communicates With |
|---|---|---|
index.css |
All CSS variables and @theme inline mappings |
Tailwind engine only |
components/ui/* |
Headless + styled primitives (shadcn-owned) | Tailwind classes, CVA variants |
components/*.tsx |
Domain-aware compositions (app-owned) | ui primitives, hooks, i18n |
pages/*.tsx |
Route-level views | components, hooks, router |
hooks/*.ts |
Data fetching + state | API client only |
The key boundary: components/ui/ components must not import domain types from lib/api.ts. Only components/*.tsx and pages/*.tsx know about API shapes.
Data Flow
CSS Variables (index.css)
↓ consumed by
Tailwind @theme inline → utility classes (bg-primary, text-muted-foreground, etc.)
↓ applied in
shadcn ui primitives (button, card, badge, input)
↓ assembled into
Domain components (stat-card, category-badge, progress-bar)
↓ composed into
Pages (DashboardPage, LoginPage, CategoriesPage, SettingsPage)
↑ data from
Hooks (useAuth, useBudgets) → API client (lib/api.ts) → Go REST API
Styling flows downward (tokens → primitives → components → pages). Data flows upward (API → hooks → pages → components via props).
Patterns to Follow
Pattern 1: Token-First Color System
What: Define all palette values as CSS custom properties in :root, then map them to Tailwind via @theme inline. Never write raw color values (oklch(0.88 0.06 310)) in component files.
When: Any time a new color is needed for the pastel palette.
How it works in this codebase:
The project uses Tailwind 4 which reads tokens exclusively from @theme inline {} in index.css. The two-step pattern already present in the codebase is correct — add raw values to :root {}, then expose them as Tailwind utilities in @theme inline {}.
/* Step 1: Define semantic tokens in :root */
:root {
/* Pastel palette — raw values */
--pastel-pink: oklch(0.92 0.04 340);
--pastel-blue: oklch(0.92 0.04 220);
--pastel-green: oklch(0.92 0.04 145);
--pastel-amber: oklch(0.92 0.06 80);
--pastel-violet: oklch(0.92 0.04 290);
--pastel-sky: oklch(0.94 0.04 215);
/* Semantic role tokens — reference palette */
--color-income: var(--pastel-green);
--color-bill: var(--pastel-blue);
--color-expense: var(--pastel-amber);
--color-debt: var(--pastel-pink);
--color-saving: var(--pastel-violet);
--color-investment: var(--pastel-sky);
/* Override shadcn semantic tokens with pastel values */
--primary: oklch(0.55 0.14 255); /* soft indigo */
--background: oklch(0.985 0.005 240); /* barely-blue white */
--card: oklch(1 0 0);
--muted: oklch(0.96 0.01 240);
}
/* Step 2: Expose as Tailwind utilities inside @theme inline */
@theme inline {
/* Existing shadcn bridge (keep as-is) */
--color-primary: var(--primary);
--color-background: var(--background);
/* ... */
/* New pastel category utilities */
--color-income: var(--color-income);
--color-bill: var(--color-bill);
--color-expense: var(--color-expense);
--color-debt: var(--color-debt);
--color-saving: var(--color-saving);
--color-investment: var(--color-investment);
}
Components then use bg-income, text-bill, bg-saving/30 etc. as Tailwind classes.
Pattern 2: Category Color Mapping via CVA
What: A single categoryVariants CVA definition maps category types to their pastel color classes. Any component that needs category coloring imports this one function.
When: CategoryBadge, table rows in FinancialOverview, chart color arrays, category icons.
// components/category-badge.tsx
import { cva } from "class-variance-authority"
export const categoryVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
{
variants: {
type: {
income: "bg-income/20 text-income-foreground",
bill: "bg-bill/20 text-bill-foreground",
variable_expense:"bg-expense/20 text-expense-foreground",
debt: "bg-debt/20 text-debt-foreground",
saving: "bg-saving/20 text-saving-foreground",
investment: "bg-investment/20 text-investment-foreground",
},
},
}
)
This is the single source of truth for category-to-color mapping. Chart colors derive from the same CSS variables (not hardcoded hex).
Pattern 3: Wrapper Components Over shadcn Modification
What: When domain semantics need to be expressed (e.g., a "stat card" showing budget vs. actual), create a wrapper component in components/ that uses shadcn Card internally. Do not modify components/ui/card.tsx for domain logic.
When: Any component that appears more than twice with the same structure across pages.
// components/stat-card.tsx — app-owned, domain-aware
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { cn } from "@/lib/utils"
interface StatCardProps {
title: string
value: string
subtext?: string
accent?: "income" | "bill" | "expense" | "debt" | "saving" | "investment"
}
export function StatCard({ title, value, subtext, accent }: StatCardProps) {
return (
<Card className={cn(accent && `border-l-4 border-l-${accent}`)}>
<CardHeader><CardTitle>{title}</CardTitle></CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{value}</p>
{subtext && <p className="text-sm text-muted-foreground">{subtext}</p>}
</CardContent>
</Card>
)
}
Pattern 4: Extend shadcn Primitives via className Override Only
What: When a shadcn component needs a slight visual adjustment, pass a className prop using cn() to override. Do not fork the component unless adding a new CVA variant that belongs at the primitive level.
When: One-off adjustments in page/feature components.
When to actually patch components/ui/: Adding a CVA variant used in 3+ places (e.g., adding a pastel button variant). Keep patches minimal and document them.
// Good — className override in consumer
<Button className="bg-primary/90 hover:bg-primary">Save Budget</Button>
// Good — new CVA variant if used everywhere
// In button.tsx, add to variants.variant:
pastel: "bg-primary/15 text-primary hover:bg-primary/25 border-primary/20",
Pattern 5: Recharts Color Consistency
What: Recharts color arrays must reference the same CSS variables used in Tailwind classes. Use getComputedStyle to read the variable at render time, not hardcoded hex values.
When: Any chart component (donut, bar, pie in ExpenseBreakdown, FinancialOverview).
// In chart component or a lib/colors.ts utility
function getCategoryColor(type: CategoryType): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(`--color-${type}`)
.trim()
}
Anti-Patterns to Avoid
Anti-Pattern 1: Inline Color Values in Components
What: Writing bg-sky-50, bg-emerald-50, bg-amber-50 directly in component files (already present in FinancialOverview.tsx).
Why bad: The color assignment is scattered across every component. Changing a category color requires hunting every file. The existing code in FinancialOverview.tsx rows array hardcodes color: 'bg-sky-50' — this must be replaced by the category variant system.
Instead: All category-to-color mappings live in categoryVariants (CVA, single file). Components only pass the category type.
Anti-Pattern 2: Separate CSS Files per Component
What: Creating StatCard.css, Dashboard.css etc.
Why bad: The project uses Tailwind 4's @theme inline — all custom styles belong in index.css or as Tailwind utilities. Split CSS files fragment the token system.
Instead: All tokens in index.css. All component-specific styles as Tailwind classes or CVA variants.
Anti-Pattern 3: Dark Mode Parallel Pastel Palette
What: Trying to create a full pastel dark mode in the same milestone.
Why bad: High complexity for low value (budgeting is primarily a desktop daytime activity). The PROJECT.md explicitly defers custom themes.
Instead: Keep .dark {} block in index.css as-is (it's already defined). Focus dark mode on the existing neutral dark values. Pastel = light mode only for this milestone.
Anti-Pattern 4: Tailwind tailwind.config.js for Custom Tokens
What: Creating a tailwind.config.js or tailwind.config.ts to add custom colors.
Why bad: This project uses Tailwind CSS 4 (@import "tailwindcss" in index.css, @tailwindcss/vite plugin). Tailwind 4 configures everything through CSS — tailwind.config.js is a Tailwind 3 pattern. Using both causes conflicts and confusion.
Instead: All custom tokens go in @theme inline {} inside index.css.
CSS Variable Organization
The existing index.css should be organized into clearly labeled sections:
/* 1. FRAMEWORK IMPORTS */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
/* 2. CUSTOM DARK VARIANT */
@custom-variant dark (&:is(.dark *));
/* 3. LIGHT MODE TOKENS */
:root {
/* === PASTEL PALETTE (raw values) === */
--pastel-pink: ...;
--pastel-blue: ...;
/* ... */
/* === CATEGORY SEMANTIC TOKENS === */
--color-income: var(--pastel-green);
/* ... */
/* === SHADCN SEMANTIC TOKENS (override defaults) === */
--background: ...;
--primary: ...;
/* ... all existing shadcn vars ... */
/* === CHART TOKENS === */
--chart-1: ...;
/* ... */
}
/* 4. DARK MODE OVERRIDES */
.dark {
/* shadcn dark vars only — no dark pastel */
}
/* 5. TAILWIND BRIDGE (@theme inline) */
@theme inline {
/* font */
--font-sans: 'Geist Variable', sans-serif;
/* shadcn token bridge (existing) */
--color-background: var(--background);
--color-primary: var(--primary);
/* ... */
/* category token bridge (new) */
--color-income: var(--color-income);
--color-bill: var(--color-bill);
/* ... */
/* radius scale (existing) */
--radius-sm: calc(var(--radius) * 0.6);
/* ... */
}
/* 6. BASE STYLES */
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground; }
html { @apply font-sans; }
}
File Structure: Design System
frontend/src/
index.css ← SINGLE SOURCE: all tokens, @theme inline, base
lib/
utils.ts ← cn() (keep as-is)
colors.ts ← NEW: getCategoryColor() for Recharts, CATEGORY_TYPES map
components/
ui/ ← shadcn primitives (patchable with CVA variants)
button.tsx ← Add: pastel variant
badge.tsx ← Add: category type variants via CVA
card.tsx ← Keep as-is (className override is sufficient)
input.tsx ← May need focus ring color patch
...
category-badge.tsx ← NEW: domain badge using badge.tsx + categoryVariants
stat-card.tsx ← NEW: financial metric card
progress-bar.tsx ← NEW: budget vs actual with color coding
page-header.tsx ← NEW: consistent page header with title + actions slot
empty-state.tsx ← NEW: consistent empty state for lists
Scalability Considerations
| Concern | Current scope | Future (theming milestone) |
|---|---|---|
| Color tokens | Pastel light mode only | Add data-theme="ocean" etc. on <html>, swap :root vars |
| Dark mode | Neutral dark (existing) | Pastel dark values in .dark {} |
| Component variants | Per-category CVA | Per-theme variant maps |
| Chart colors | CSS variable lookup | Same — already theme-aware |
The CSS variable architecture supports future theming without structural change. When custom themes are added, only :root values change; components need zero modification.
Build Order: Foundation to Pages
This is the correct sequence because each layer depends on the previous:
Phase A: Token foundation
- Define pastel palette in
index.css:root(raw oklch values) - Define category semantic tokens in
index.css - Add category tokens to
@theme inlinebridge - Define pastel overrides for shadcn semantic tokens (background, primary, etc.)
Phase B: Primitive polish
5. Patch button.tsx — add pastel variant, adjust default hover states
6. Patch badge.tsx — add category type variants
7. Patch input.tsx — ensure focus ring uses --ring pastel value
8. Create lib/colors.ts — getCategoryColor() and category type constants
Phase C: Domain components
9. Create category-badge.tsx — wraps badge with category semantics
10. Create stat-card.tsx — financial metric display card
11. Create progress-bar.tsx — budget vs actual visual
12. Create page-header.tsx — consistent header slot
13. Create empty-state.tsx — consistent empty list state
Phase D: Page-by-page polish 14. Login + Register pages — full branded treatment (no generic shadcn defaults) 15. Dashboard — replace hardcoded color strings with category variants, add stat cards 16. Categories page — category badge integration, polished CRUD UI 17. Settings page — form layout, preference toggles
Phase E: Charts 18. ExpenseBreakdown — donut chart with CSS variable colors 19. FinancialOverview chart — bar chart with category palette 20. AvailableBalance — progress indicator
Build order rationale: Pages cannot be polished until domain components exist. Domain components cannot use consistent tokens until the token layer is established. Charts come last because they depend on both the token system and the data components being stable.
Sources
- Direct inspection of
frontend/src/index.css— confirms Tailwind 4 (@import "tailwindcss",@theme inline) and oklch color space already in use - Direct inspection of
frontend/components.json— confirms shadcn/ui radix-nova style,cssVariables: true, Tailwind 4 CSS-only config (notailwind.config.ts) - Direct inspection of
frontend/src/components/ui/button.tsx— confirms CVA pattern withclass-variance-authorityalready in use - Direct inspection of
frontend/src/components/FinancialOverview.tsx— identifies hardcoded color anti-pattern (bg-sky-50,bg-emerald-50) that needs migration .planning/codebase/STACK.md— confirms Tailwind CSS 4.2.1, shadcn/ui 4.0.0, CVA 0.7.1.planning/PROJECT.md— confirms pastel spreadsheet aesthetic goal, CSS variable customization approach, desktop-first target