docs(01): research phase design token foundation
This commit is contained in:
650
.planning/phases/01-design-token-foundation/01-RESEARCH.md
Normal file
650
.planning/phases/01-design-token-foundation/01-RESEARCH.md
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
# Phase 1: Design Token Foundation - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-11
|
||||||
|
**Domain:** shadcn/ui CSS variable theming, oklch pastel color system, React component extraction
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
**Pastel palette direction:**
|
||||||
|
- Rainbow pastels: distinct pastel hue per category type, unified lightness and saturation across all hues
|
||||||
|
- Base shadcn tokens (primary, secondary, accent, muted, ring) carry a soft lavender tint — not plain grey
|
||||||
|
- App background gets a very subtle lavender tint (e.g., `oklch(0.98 0.005 290)`); cards stay pure white so they float on the tinted surface
|
||||||
|
- Chart colors (chart-1 through chart-5) match the category colors from palette.ts — same color in charts as in tables
|
||||||
|
|
||||||
|
**Category color mapping:**
|
||||||
|
- income: soft green (money in)
|
||||||
|
- bills: soft blue (recurring, stable)
|
||||||
|
- variable_expense: soft amber (variable, caution)
|
||||||
|
- debt: soft rose (obligation)
|
||||||
|
- saving: soft violet (aspirational)
|
||||||
|
- investment: soft pink (growth)
|
||||||
|
- carryover: soft sky (neutral carry-forward)
|
||||||
|
- 3 shades per category in palette.ts: light (row backgrounds), medium (header gradients, badges), base (chart fills, text accents)
|
||||||
|
- Card header gradients go from category light to category medium (within the same hue)
|
||||||
|
- FinancialOverview table rows each tinted with their category's light shade (per-row category color)
|
||||||
|
|
||||||
|
**Hero element prominence:**
|
||||||
|
- FinancialOverview and AvailableBalance get larger titles (text-2xl font-semibold vs text-lg font-medium on regular cards) and more generous padding (p-6 vs p-4)
|
||||||
|
- AvailableBalance center amount uses text-3xl font-bold, color-coded: green when positive, destructive red when negative
|
||||||
|
- Small muted-foreground label ("Available") sits below the center amount in the donut
|
||||||
|
- FinancialOverview header uses a multi-pastel gradient (e.g., sky-light via lavender-light to green-light) to signal it represents all categories
|
||||||
|
|
||||||
|
**Amount coloring rules:**
|
||||||
|
- Amber triggers when actual_amount > budgeted_amount for expense categories (bills, variable_expense, debt). Exactly on budget = normal (no amber)
|
||||||
|
- Only the actual column gets colored; budget column stays neutral
|
||||||
|
- Income: any positive actual shows green (money received is always positive). Zero actual stays neutral
|
||||||
|
- Amount coloring applies everywhere consistently: FinancialOverview summary rows AND individual item rows in BillsTracker, VariableExpenses, DebtTracker
|
||||||
|
- Remaining/Available row: green when positive, destructive red when negative
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact oklch values for all palette colors (lightness, chroma, hue angles) — as long as they feel pastel and harmonious
|
||||||
|
- Loading skeleton tint colors
|
||||||
|
- Exact spacing values and font weight choices beyond the hero vs regular distinction
|
||||||
|
- InlineEditCell extraction approach (props interface design, where to place the shared component)
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| DSGN-01 | All shadcn CSS variables (primary, accent, muted, sidebar, chart-1 through chart-5) use pastel oklch values instead of zero-chroma neutrals | Covered by CSS variable replacement in `:root` block of `index.css`; zero-chroma tokens identified in codebase |
|
||||||
|
| DSGN-02 | Semantic category color tokens defined in a single source of truth (`lib/palette.ts`) replacing scattered hardcoded hex and Tailwind classes | Covered by `palette.ts` creation; three locations with hardcoded hex arrays and Tailwind color classes identified |
|
||||||
|
| DSGN-03 | Dashboard card header gradients unified to a single pastel palette family | Covered by replacing per-component gradient classNames with palette.ts references; five components with hardcoded gradients identified |
|
||||||
|
| DSGN-04 | Typography hierarchy established — FinancialOverview and AvailableBalance sections are visually dominant hero elements | Covered by Tailwind class changes to CardTitle and CardContent; no new dependencies needed |
|
||||||
|
| DSGN-05 | Consistent positive/negative amount coloring across all tables and summaries (green for positive, destructive for negative, amber for over-budget) | Covered by `cn()` logic on amount cells; requires adding `--success` CSS variable for green |
|
||||||
|
| FIX-02 | `InlineEditRow` extracted into a shared component (currently duplicated in BillsTracker, VariableExpenses, DebtTracker) | Covered by extracting `InlineEditCell.tsx` to `@/components/InlineEditCell.tsx`; three near-identical implementations found |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 1 is a pure frontend styling and refactoring phase — no backend changes, no new npm packages, and no new shadcn components. The stack is already fully assembled. The work falls into three buckets: (1) replace zero-chroma oklch values in `index.css` with pastel-tinted equivalents, (2) create `lib/palette.ts` as the single source of truth for category colors and wire it into all components that currently use hardcoded hex arrays or Tailwind color class strings, and (3) extract the triplicated `InlineEditRow` function into a shared `InlineEditCell.tsx` component.
|
||||||
|
|
||||||
|
The existing codebase already uses the correct patterns — oklch in `:root`, `@theme inline` Tailwind v4 bridging, `cn()` for conditional classes, and `CardHeader/CardContent` composition. This phase replaces hardcoded values with semantic references; it does not restructure component architecture. The `shadcn` skill constrains the approach: customize via CSS variables only, never edit `src/components/ui/` source files, and use semantic tokens rather than raw Tailwind color utilities.
|
||||||
|
|
||||||
|
The primary technical decisions are: (a) the oklch values to use for each category's three shades (light/medium/base), (b) how to expose those values to both CSS (for Tailwind utilities) and TypeScript (for inline `style` props on chart cells), and (c) the exact props interface for the extracted `InlineEditCell`. The CONTEXT.md has locked all product decisions; what remains is the mechanical implementation.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement in three sequential tasks — CSS token replacement first (establishes the foundation all other changes reference), then `palette.ts` creation and component wiring, then `InlineEditCell` extraction. Commit after each task so partial work is always in a clean state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already installed — no new packages)
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Tailwind CSS | 4.2.1 | Utility classes and `@theme inline` token bridging | Already the project's styling layer |
|
||||||
|
| shadcn/ui | 4.0.0 | Component primitives; CSS variable contract | Already installed; all 18 components wired |
|
||||||
|
| oklch (CSS native) | CSS Color Level 4 | Perceptually uniform color space for pastels | Already in use throughout `index.css` |
|
||||||
|
| lucide-react | 0.577.0 | Icons | Project standard per `components.json` |
|
||||||
|
| tw-animate-css | 1.4.0 | CSS animation utilities | Already imported in `index.css` |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| clsx + tailwind-merge (via `cn()`) | already installed | Conditional class merging | Use for amount-coloring logic on `<TableCell>` |
|
||||||
|
| Recharts | 2.15.4 | Chart rendering | Already used; palette.ts colors will feed into `<Cell fill>` props |
|
||||||
|
| @fontsource-variable/geist-mono | optional | Tabular numbers on currency amounts | Install only if tabular numeral rendering is needed; not strictly required for this phase |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| CSS variable replacement in-place | shadcn preset swap (`npx shadcn@latest init --preset`) | Preset overwrites existing component customizations; in-place is safer |
|
||||||
|
| Single `palette.ts` export | CSS-only custom properties | TypeScript type safety is needed for chart `<Cell fill>` — a `.ts` file serves both CSS and JS consumers |
|
||||||
|
| Inline `style` props for gradient headers | Tailwind arbitrary gradient classes | `style` props allow full dynamic color references from `palette.ts`; arbitrary classes would re-hardcode values |
|
||||||
|
|
||||||
|
**Installation:** No new packages required for this phase. All tools are present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure (additions only)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── InlineEditCell.tsx # NEW — extracted from BillsTracker, VariableExpenses, DebtTracker
|
||||||
|
│ └── ui/ # UNCHANGED — never edit shadcn source files
|
||||||
|
├── lib/
|
||||||
|
│ ├── palette.ts # NEW — single source of truth for category colors
|
||||||
|
│ ├── api.ts # unchanged
|
||||||
|
│ ├── format.ts # unchanged
|
||||||
|
│ └── utils.ts # unchanged
|
||||||
|
└── index.css # MODIFIED — replace zero-chroma tokens with pastel oklch values
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: CSS Variable Replacement (DSGN-01)
|
||||||
|
|
||||||
|
**What:** Replace every `oklch(L 0 0)` zero-chroma value in the `:root` block with a pastel-tinted oklch that carries a small chroma value (C ≈ 0.005–0.02 for near-neutral tokens, C ≈ 0.10–0.15 for primary/accent tokens).
|
||||||
|
|
||||||
|
**When to use:** All shadcn semantic tokens (`--background`, `--primary`, `--secondary`, `--muted`, `--accent`, `--ring`, `--border`, `--input`, `--sidebar-*`, `--chart-1` through `--chart-5`) must be updated.
|
||||||
|
|
||||||
|
**Example — the before/after for `:root`:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* BEFORE (zero-chroma neutral) */
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
/* AFTER (pastel lavender tint; exact values are Claude's discretion) */
|
||||||
|
--background: oklch(0.98 0.005 290); /* very subtle lavender tint */
|
||||||
|
--card: oklch(1 0 0); /* pure white — floats on tinted bg */
|
||||||
|
--primary: oklch(0.50 0.12 260); /* soft lavender-blue */
|
||||||
|
--primary-foreground: oklch(0.99 0 0);
|
||||||
|
--secondary: oklch(0.95 0.015 280); /* near-white with lavender cast */
|
||||||
|
--muted: oklch(0.95 0.010 280);
|
||||||
|
--accent: oklch(0.94 0.020 280);
|
||||||
|
--ring: oklch(0.65 0.08 260); /* tinted focus ring */
|
||||||
|
--sidebar: oklch(0.97 0.010 280); /* slightly distinct from background */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key constraint from shadcn skill:** Edit only `src/index.css`. Never touch `src/components/ui/*.tsx` to change colors.
|
||||||
|
|
||||||
|
### Pattern 2: Palette TypeScript Module (DSGN-02)
|
||||||
|
|
||||||
|
**What:** A `lib/palette.ts` file that exports typed color objects for each category. Three shades per category (light, medium, base). Used by both component inline styles (gradients, row backgrounds) and chart `<Cell fill>` attributes.
|
||||||
|
|
||||||
|
**When to use:** Any component that previously had hardcoded hex arrays (`PASTEL_COLORS = [...]`) or Tailwind color classes like `bg-blue-50`, `from-amber-50 to-yellow-50`.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: CONTEXT.md locked decision + codebase analysis
|
||||||
|
export type CategoryType =
|
||||||
|
| 'income' | 'bill' | 'variable_expense' | 'debt'
|
||||||
|
| 'saving' | 'investment' | 'carryover'
|
||||||
|
|
||||||
|
export interface CategoryShades {
|
||||||
|
light: string // oklch — row backgrounds, tinted surfaces
|
||||||
|
medium: string // oklch — header gradient to-color, badges
|
||||||
|
base: string // oklch — chart fills, text accents
|
||||||
|
}
|
||||||
|
|
||||||
|
export const palette: Record<CategoryType, CategoryShades> = {
|
||||||
|
income: {
|
||||||
|
light: 'oklch(0.96 0.04 145)',
|
||||||
|
medium: 'oklch(0.88 0.08 145)',
|
||||||
|
base: 'oklch(0.76 0.14 145)',
|
||||||
|
},
|
||||||
|
bill: {
|
||||||
|
light: 'oklch(0.96 0.03 250)',
|
||||||
|
medium: 'oklch(0.88 0.07 250)',
|
||||||
|
base: 'oklch(0.76 0.12 250)',
|
||||||
|
},
|
||||||
|
variable_expense: {
|
||||||
|
light: 'oklch(0.97 0.04 85)',
|
||||||
|
medium: 'oklch(0.90 0.08 85)',
|
||||||
|
base: 'oklch(0.80 0.14 85)',
|
||||||
|
},
|
||||||
|
debt: {
|
||||||
|
light: 'oklch(0.96 0.04 15)',
|
||||||
|
medium: 'oklch(0.88 0.08 15)',
|
||||||
|
base: 'oklch(0.76 0.13 15)',
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
light: 'oklch(0.95 0.04 280)',
|
||||||
|
medium: 'oklch(0.87 0.08 280)',
|
||||||
|
base: 'oklch(0.75 0.13 280)',
|
||||||
|
},
|
||||||
|
investment: {
|
||||||
|
light: 'oklch(0.96 0.04 320)',
|
||||||
|
medium: 'oklch(0.88 0.07 320)',
|
||||||
|
base: 'oklch(0.76 0.12 320)',
|
||||||
|
},
|
||||||
|
carryover: {
|
||||||
|
light: 'oklch(0.96 0.03 210)',
|
||||||
|
medium: 'oklch(0.88 0.06 210)',
|
||||||
|
base: 'oklch(0.76 0.11 210)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: gradient style object for CardHeader
|
||||||
|
export function headerGradient(type: CategoryType): React.CSSProperties {
|
||||||
|
const { light, medium } = palette[type]
|
||||||
|
return { background: `linear-gradient(to right, ${light}, ${medium})` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: determine amount color class based on category and comparison
|
||||||
|
export function amountColorClass(opts: {
|
||||||
|
type: CategoryType
|
||||||
|
actual: number
|
||||||
|
budgeted: number
|
||||||
|
isIncome?: boolean
|
||||||
|
isAvailable?: boolean
|
||||||
|
}): string {
|
||||||
|
const { type, actual, budgeted, isIncome, isAvailable } = opts
|
||||||
|
if (isAvailable || isIncome) {
|
||||||
|
if (actual > 0) return 'text-success'
|
||||||
|
if (actual < 0) return 'text-destructive'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// Expense categories (bill, variable_expense, debt)
|
||||||
|
if (actual > budgeted) return 'text-warning'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on `--color-bill` CSS custom properties:** The `@theme inline` block in `index.css` should also expose `--chart-1` through `--chart-5` mapped to the palette base colors, so the shadcn `ChartContainer` / `ChartConfig` pattern works correctly. The palette.ts values and the `--chart-*` CSS variables must be kept in sync manually.
|
||||||
|
|
||||||
|
### Pattern 3: Card Header Gradient Application (DSGN-03)
|
||||||
|
|
||||||
|
**What:** Replace hardcoded gradient classNames on `CardHeader` with inline `style` props driven by `palette.ts`.
|
||||||
|
|
||||||
|
**Current (hardcoded — remove this pattern):**
|
||||||
|
```tsx
|
||||||
|
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (palette-driven):**
|
||||||
|
```tsx
|
||||||
|
import { headerGradient } from '@/lib/palette'
|
||||||
|
|
||||||
|
<CardHeader style={headerGradient('bill')}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components to update:** `BillsTracker.tsx`, `VariableExpenses.tsx`, `DebtTracker.tsx`, `AvailableBalance.tsx`, `ExpenseBreakdown.tsx`, `FinancialOverview.tsx`.
|
||||||
|
|
||||||
|
**FinancialOverview special case:** Uses a multi-category gradient (sky-light via lavender-light to green-light). This cannot use the single-category `headerGradient()` helper. Define a separate `overviewHeaderGradient` export in `palette.ts`.
|
||||||
|
|
||||||
|
### Pattern 4: Amount Color Logic (DSGN-05)
|
||||||
|
|
||||||
|
**What:** Wrap the `actual` amount `<TableCell>` with `cn()` plus the `amountColorClass()` helper from `palette.ts`.
|
||||||
|
|
||||||
|
**Important:** Requires adding `--success` and `--warning` CSS custom properties to `index.css` since shadcn's default tokens only include `--destructive`. The `@theme inline` block must also expose them as `--color-success` and `--color-warning`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* index.css :root additions */
|
||||||
|
--success: oklch(0.55 0.15 145); /* green — positive amounts */
|
||||||
|
--success-foreground: oklch(0.99 0 0);
|
||||||
|
--warning: oklch(0.60 0.14 75); /* amber — over-budget */
|
||||||
|
--warning-foreground: oklch(0.99 0 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* @theme inline additions */
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in component:**
|
||||||
|
```tsx
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { amountColorClass } from '@/lib/palette'
|
||||||
|
|
||||||
|
<TableCell className={cn('text-right', amountColorClass({ type: 'bill', actual, budgeted }))}>
|
||||||
|
{formatCurrency(actual, currency)}
|
||||||
|
</TableCell>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: InlineEditCell Extraction (FIX-02)
|
||||||
|
|
||||||
|
**What:** Extract the near-identical `InlineEditRow` private function from `BillsTracker.tsx` (lines 59–110), `VariableExpenses.tsx` (lines 86–142), and `DebtTracker.tsx` (lines 61–112) into a single shared `components/InlineEditCell.tsx`.
|
||||||
|
|
||||||
|
**Differences between the three implementations:**
|
||||||
|
- `VariableExpenses.InlineEditRow` has an extra `remaining` computed cell — this means the extracted component needs an optional `showRemaining?: boolean` prop, or the `remaining` cell can be composed externally by the caller.
|
||||||
|
- All three share identical edit-mode logic (state, handleBlur, onKeyDown, onBlur).
|
||||||
|
|
||||||
|
**Recommended approach (Claude's discretion):** Keep the component focused on the actual-amount cell only. The caller renders the remaining cell separately. This avoids a prop-driven layout branch inside the extracted component.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/InlineEditCell.tsx
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { TableCell } from '@/components/ui/table'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
|
||||||
|
interface InlineEditCellProps {
|
||||||
|
value: number
|
||||||
|
currency: string
|
||||||
|
onSave: (value: number) => Promise<void>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineEditCell({ value, currency, onSave, className }: InlineEditCellProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(String(value))
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
const num = parseFloat(inputValue)
|
||||||
|
if (!isNaN(num) && num !== value) {
|
||||||
|
await onSave(num)
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell className={cn('text-right', className)}>
|
||||||
|
{editing ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||||
|
className="ml-auto w-28 text-right"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="cursor-pointer rounded px-2 py-1 hover:bg-muted"
|
||||||
|
onClick={() => { setInputValue(String(value)); setEditing(true) }}
|
||||||
|
>
|
||||||
|
{formatCurrency(value, currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After extraction:** Each caller replaces its private `InlineEditRow` with `<InlineEditCell>` inside the existing `<TableRow>`. The amount coloring `className` prop can pass the `amountColorClass()` result, giving DSGN-05 coloring "for free" at the call site.
|
||||||
|
|
||||||
|
### Pattern 6: Hero Typography (DSGN-04)
|
||||||
|
|
||||||
|
**What:** Increase visual weight of `FinancialOverview` and `AvailableBalance` card headers and content. Apply larger text and more padding than regular section cards.
|
||||||
|
|
||||||
|
**Regular cards (BillsTracker, VariableExpenses, DebtTracker, ExpenseBreakdown):**
|
||||||
|
```tsx
|
||||||
|
<CardHeader style={headerGradient('bill')} className="px-4 py-3">
|
||||||
|
<CardTitle className="text-base font-semibold">{t('...')}</CardTitle>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hero cards (FinancialOverview, AvailableBalance):**
|
||||||
|
```tsx
|
||||||
|
<CardHeader style={overviewHeaderGradient()} className="px-6 py-5">
|
||||||
|
<CardTitle className="text-2xl font-semibold">{t('...')}</CardTitle>
|
||||||
|
```
|
||||||
|
|
||||||
|
**AvailableBalance center amount:**
|
||||||
|
```tsx
|
||||||
|
<span className={cn('text-3xl font-bold tabular-nums', available >= 0 ? 'text-success' : 'text-destructive')}>
|
||||||
|
{formatCurrency(available, budget.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('dashboard.available')}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Never use hardcoded Tailwind color names for category colors** (`bg-blue-50`, `from-amber-50`): these won't respond to theme changes. Use `style={headerGradient('bill')}` instead.
|
||||||
|
- **Never import hex strings from palette.ts into className strings**: className strings must use CSS variables or Tailwind utilities. Raw hex/oklch values belong in `style` props or CSS files.
|
||||||
|
- **Never edit `src/components/ui/*.tsx`**: This is a firm project constraint. All color/typography changes happen in `index.css` or at component call sites.
|
||||||
|
- **Never add `dark:` manual overrides**: Use semantic tokens. The `.dark` block in `index.css` will be updated as a separate concern.
|
||||||
|
- **Never color the budget column**: Only the `actual` column receives amount coloring per CONTEXT.md locked decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Conditional class merging | Manual template literal ternaries | `cn()` from `@/lib/utils` | Already available; handles Tailwind class conflicts correctly |
|
||||||
|
| Currency formatting | Custom number formatter | `formatCurrency()` from `@/lib/format` | Already in use across all components |
|
||||||
|
| Status color tokens | Raw green/amber hex values | `--success`/`--warning` CSS variables + `text-success`/`text-warning` utilities | Theming-safe; consistent with shadcn semantic token pattern |
|
||||||
|
| Gradient definitions | Repeated inline gradient strings | `headerGradient()` from `palette.ts` | Single source of truth; change once, update everywhere |
|
||||||
|
| Inline edit state logic | New hook or HOC | Inline state in `InlineEditCell.tsx` | Complexity doesn't justify abstraction beyond a single component |
|
||||||
|
|
||||||
|
**Key insight:** This phase is entirely about connecting existing infrastructure (shadcn CSS variables, Tailwind v4 `@theme inline`, `cn()`) to a typed TypeScript palette module. The hard infrastructure work was done when the project was initialized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Forgetting to sync `--chart-*` variables with `palette.ts` base colors
|
||||||
|
|
||||||
|
**What goes wrong:** `palette.ts` has `bill.base: 'oklch(0.76 0.12 250)'` but `--chart-2` in `index.css` still has the original blue value. Charts using `ChartConfig` (which reads CSS vars) show different colors from table rows (which read `palette.ts` directly).
|
||||||
|
|
||||||
|
**Why it happens:** Two parallel color systems — CSS custom properties for shadcn's `ChartContainer`, and TypeScript objects for direct `fill=` props.
|
||||||
|
|
||||||
|
**How to avoid:** In `palette.ts`, add a secondary export that maps the five expense-category base colors to `--chart-1` through `--chart-5`. Document that `index.css` `--chart-*` values must be updated whenever `palette.ts` base values change.
|
||||||
|
|
||||||
|
**Warning signs:** Donut chart segments use different shades than the corresponding table row backgrounds.
|
||||||
|
|
||||||
|
### Pitfall 2: Using Tailwind v3 CSS variable syntax
|
||||||
|
|
||||||
|
**What goes wrong:** Writing `bg-[--color-bill]` (brackets) instead of `bg-(--color-bill)` (parentheses) causes the utility class to not generate in Tailwind v4.
|
||||||
|
|
||||||
|
**Why it happens:** Tailwind v4 changed arbitrary property syntax from square brackets to parentheses for CSS variable references.
|
||||||
|
|
||||||
|
**How to avoid:** Use `bg-(--color-bill)/20` for CSS variable references with opacity. Use `style={{ background: palette.bill.light }}` for dynamic values where a utility class isn't appropriate.
|
||||||
|
|
||||||
|
**Warning signs:** Background color doesn't appear; no Tailwind class is generated for the element.
|
||||||
|
|
||||||
|
### Pitfall 3: Breaking the `<Card>` vs `<CardHeader>` className/style split
|
||||||
|
|
||||||
|
**What goes wrong:** Adding `style={{ background: ... }}` to `<Card>` instead of `<CardHeader>` changes the entire card background, removing the white card body that "floats" on the tinted gradient header.
|
||||||
|
|
||||||
|
**Why it happens:** The gradient should only apply to the header area, not the full card.
|
||||||
|
|
||||||
|
**How to avoid:** Always apply gradient styles to `<CardHeader>`, never to `<Card>`.
|
||||||
|
|
||||||
|
**Warning signs:** Full card shows the gradient color including the table/content area.
|
||||||
|
|
||||||
|
### Pitfall 4: `InlineEditCell` prop mismatch after extraction
|
||||||
|
|
||||||
|
**What goes wrong:** `VariableExpenses.InlineEditRow` renders a 4-column row (label, budget, actual, remaining) while `BillsTracker.InlineEditRow` renders 3 columns. Naively extracting to a shared "row" component requires conditional rendering based on a prop, making the component more complex than the original duplicates.
|
||||||
|
|
||||||
|
**Why it happens:** The three implementations have slightly different column structures.
|
||||||
|
|
||||||
|
**How to avoid:** Extract only the editable *cell* (the actual amount cell), not the full row. The caller continues to own the `<TableRow>` and renders the label cell, budget cell, and (optionally) remaining cell around the shared `<InlineEditCell>`.
|
||||||
|
|
||||||
|
**Warning signs:** VariableExpenses rows show an extra empty column or miss the remaining calculation.
|
||||||
|
|
||||||
|
### Pitfall 5: Missing `--success` semantic token causes raw color fallback
|
||||||
|
|
||||||
|
**What goes wrong:** Amount coloring code writes `text-green-600` as a fallback when `--success` isn't defined. This hardcodes a raw Tailwind color rather than a semantic token, violating the shadcn skill's "No raw color values for status/state indicators" rule.
|
||||||
|
|
||||||
|
**Why it happens:** `--success` is not in shadcn's default token set; it must be added manually.
|
||||||
|
|
||||||
|
**How to avoid:** Add `--success` and `--warning` to `index.css` `:root` AND the `@theme inline` block before any component uses `text-success` or `text-warning` Tailwind utilities.
|
||||||
|
|
||||||
|
**Warning signs:** `text-success` has no effect (browser applies no color); amounts appear in default foreground color.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from existing codebase and shadcn skill documentation:
|
||||||
|
|
||||||
|
### Adding custom semantic tokens (Tailwind v4 pattern)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* index.css — in :root block */
|
||||||
|
--success: oklch(0.55 0.15 145);
|
||||||
|
--success-foreground: oklch(0.99 0 0);
|
||||||
|
--warning: oklch(0.60 0.14 75);
|
||||||
|
--warning-foreground: oklch(0.99 0 0);
|
||||||
|
|
||||||
|
/* index.css — in @theme inline block */
|
||||||
|
@theme inline {
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: `customization.md` — "Adding Custom Colors" section + existing `index.css` `@theme inline` pattern.
|
||||||
|
|
||||||
|
### Conditional amount coloring with cn()
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Source: shadcn skill styling.md + CONTEXT.md locked rules
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
<TableCell className={cn(
|
||||||
|
'text-right tabular-nums',
|
||||||
|
// income: green when positive, neutral when zero
|
||||||
|
isIncome && actual > 0 && 'text-success',
|
||||||
|
// expense: amber when over budget
|
||||||
|
isExpense && actual > budgeted && 'text-warning',
|
||||||
|
// available/remaining: green positive, red negative
|
||||||
|
isAvailable && actual > 0 && 'text-success',
|
||||||
|
isAvailable && actual < 0 && 'text-destructive',
|
||||||
|
)}>
|
||||||
|
{formatCurrency(actual, currency)}
|
||||||
|
</TableCell>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chart cell fill from palette.ts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Source: existing AvailableBalance.tsx pattern + STACK.md research
|
||||||
|
import { palette } from '@/lib/palette'
|
||||||
|
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={index}
|
||||||
|
fill={palette[entry.categoryType]?.base ?? palette.carryover.base}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline gradient on CardHeader
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Source: CONTEXT.md + shadcn customization pattern
|
||||||
|
import { headerGradient } from '@/lib/palette'
|
||||||
|
|
||||||
|
<CardHeader style={headerGradient('bill')} className="px-4 py-3">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{t('dashboard.billsTracker')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach (in codebase) | Current Correct Approach | Impact |
|
||||||
|
|----------------------------|--------------------------|--------|
|
||||||
|
| `bg-gradient-to-r from-blue-50 to-indigo-50` on CardHeader | `style={headerGradient('bill')}` from palette.ts | Colors controlled from one file, not scattered across 6 components |
|
||||||
|
| `const PASTEL_COLORS = ['#93c5fd', '#f9a8d4', ...]` arrays | `palette[type].base` references | Charts and tables use identical colors; no more separate hex arrays |
|
||||||
|
| `text-destructive` only for negative values | `text-success`, `text-warning`, `text-destructive` semantic trio | Consistent amount coloring vocabulary |
|
||||||
|
| `oklch(L 0 0)` zero-chroma shadcn defaults | Pastel-tinted oklch with low-but-nonzero chroma | Components show intentional color tones, not grey neutrals |
|
||||||
|
| Private `InlineEditRow` function in each file | Shared `InlineEditCell.tsx` component | Single edit/fix point for inline editing behavior |
|
||||||
|
|
||||||
|
**Deprecated/outdated in this codebase (to remove):**
|
||||||
|
- `bg-blue-50`, `from-amber-50`, `from-blue-50 to-indigo-50` etc. on CardHeaders: hardcoded Tailwind color utilities for category identity
|
||||||
|
- `const PASTEL_COLORS = [...]` hex arrays in `AvailableBalance.tsx` and `ExpenseBreakdown.tsx`
|
||||||
|
- Three copies of the `InlineEditRow` private function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **`VariableExpenses` has 4 columns; `BillsTracker` and `DebtTracker` have 3**
|
||||||
|
- What we know: The extra "remaining" column in VariableExpenses is `budgeted - actual`
|
||||||
|
- What's unclear: Whether any future tracker will also need a remaining column
|
||||||
|
- Recommendation: Extract only the `InlineEditCell` (actual column cell). Callers own their row structure. The remaining cell stays in `VariableExpenses.tsx` as a plain `<TableCell>`.
|
||||||
|
|
||||||
|
2. **Exact oklch values for the pastel palette**
|
||||||
|
- What we know: Lightness ≈ 0.88–0.97 for light/medium shades; chroma ≈ 0.03–0.15; hue angles from CONTEXT.md
|
||||||
|
- What's unclear: Precise values that render harmoniously across the hues — this requires visual browser testing
|
||||||
|
- Recommendation: Claude's discretion to choose initial values; plan should include a review/adjustment step after first implementation
|
||||||
|
|
||||||
|
3. **`--chart-*` variable count vs category count**
|
||||||
|
- What we know: shadcn provides `--chart-1` through `--chart-5`; the project has 7 category types (including carryover)
|
||||||
|
- What's unclear: Which 5 chart colors to prioritize
|
||||||
|
- Recommendation: Map `--chart-1` through `--chart-5` to the 5 non-carryover expense-type categories (bills, variable_expense, debt, saving, investment). Income and carryover use `base` color from palette.ts directly where needed in charts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | vitest (not yet installed — see Wave 0) |
|
||||||
|
| Config file | `vite.config.ts` (extend with `test` block) or `vitest.config.ts` |
|
||||||
|
| Quick run command | `cd frontend && bun vitest run --reporter=verbose` |
|
||||||
|
| Full suite command | `cd frontend && bun vitest run` |
|
||||||
|
|
||||||
|
**Note:** The `package.json` has no `vitest` dependency and no test script. The CLAUDE.md documents `bun vitest` as the test runner command, indicating it is the intended framework but not yet installed. Wave 0 must install it.
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| DSGN-01 | All `:root` oklch values have non-zero chroma | unit (CSS parsing) | manual visual check — CSS values are not unit-testable without a browser | manual-only |
|
||||||
|
| DSGN-02 | `palette.ts` exports all 7 category types with 3 shades each | unit | `bun vitest run src/lib/palette.test.ts -t "exports all categories"` | ❌ Wave 0 |
|
||||||
|
| DSGN-02 | `amountColorClass` returns correct class per scenario | unit | `bun vitest run src/lib/palette.test.ts -t "amountColorClass"` | ❌ Wave 0 |
|
||||||
|
| DSGN-03 | `headerGradient` returns a style object with a `background` string | unit | `bun vitest run src/lib/palette.test.ts -t "headerGradient"` | ❌ Wave 0 |
|
||||||
|
| DSGN-04 | Visual hierarchy — no automated test; verified by screenshot | manual-only | n/a | manual-only |
|
||||||
|
| DSGN-05 | Amount coloring: positive income → text-success, over-budget → text-warning, negative available → text-destructive | unit | `bun vitest run src/lib/palette.test.ts -t "amountColorClass"` | ❌ Wave 0 |
|
||||||
|
| FIX-02 | `InlineEditCell` renders display mode showing formatted currency | unit | `bun vitest run src/components/InlineEditCell.test.tsx -t "renders formatted value"` | ❌ Wave 0 |
|
||||||
|
| FIX-02 | `InlineEditCell` enters edit mode on click | unit | `bun vitest run src/components/InlineEditCell.test.tsx -t "enters edit mode"` | ❌ Wave 0 |
|
||||||
|
| FIX-02 | `InlineEditCell` calls onSave with parsed number on blur | unit | `bun vitest run src/components/InlineEditCell.test.tsx -t "calls onSave"` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `cd frontend && bun vitest run src/lib/palette.test.ts src/components/InlineEditCell.test.tsx`
|
||||||
|
- **Per wave merge:** `cd frontend && bun vitest run`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
|
||||||
|
- [ ] `frontend/package.json` — add `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom` as devDependencies: `cd frontend && bun add -d vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom`
|
||||||
|
- [ ] `frontend/vite.config.ts` — add `test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'] }` to `defineConfig`
|
||||||
|
- [ ] `frontend/src/test-setup.ts` — `import '@testing-library/jest-dom'`
|
||||||
|
- [ ] `frontend/src/lib/palette.test.ts` — covers DSGN-02, DSGN-03, DSGN-05
|
||||||
|
- [ ] `frontend/src/components/InlineEditCell.test.tsx` — covers FIX-02
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
|
||||||
|
- Codebase direct read: `frontend/src/index.css` — confirmed zero-chroma token baseline and `@theme inline` structure
|
||||||
|
- Codebase direct read: `frontend/src/components/BillsTracker.tsx`, `VariableExpenses.tsx`, `DebtTracker.tsx` — confirmed three duplicate `InlineEditRow` implementations, confirmed hardcoded gradient classes
|
||||||
|
- Codebase direct read: `frontend/src/components/AvailableBalance.tsx`, `ExpenseBreakdown.tsx` — confirmed hardcoded `PASTEL_COLORS` hex arrays
|
||||||
|
- Codebase direct read: `frontend/src/components/FinancialOverview.tsx` — confirmed hardcoded row color strings and gradient
|
||||||
|
- Codebase direct read: `frontend/components.json` — confirmed `style: "radix-nova"`, `tailwindVersion` is v4, `iconLibrary: "lucide"`, `rsc: false`, alias `@/`
|
||||||
|
- Skill file: `.agents/skills/shadcn/customization.md` — confirmed "Adding Custom Colors" pattern (define in `:root`, register in `@theme inline`, use as utility class)
|
||||||
|
- Skill file: `.agents/skills/shadcn/rules/styling.md` — confirmed "No raw color values for status/state indicators" rule
|
||||||
|
- Codebase direct read: `frontend/package.json` — confirmed no vitest installed; confirmed bun as package manager
|
||||||
|
- Prior planning research: `.planning/research/STACK.md` — confirmed Tailwind v4 `bg-(--var)` syntax, oklch chroma guidance, no new packages needed
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
|
||||||
|
- `.planning/phases/01-design-token-foundation/01-CONTEXT.md` — locked product decisions about color assignments, hero sizing, amount coloring rules
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
|
||||||
|
- None — all key claims are backed by direct codebase inspection or skill documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — confirmed from package.json and existing source files; no external research needed
|
||||||
|
- Architecture patterns: HIGH — confirmed from direct reading of all 6 dashboard components plus shadcn skill documentation
|
||||||
|
- Pitfalls: HIGH — pitfalls derived from direct diff of three InlineEditRow implementations plus known Tailwind v4 syntax changes observed in codebase
|
||||||
|
- Test infrastructure: HIGH for gap identification (no vitest present); MEDIUM for exact vitest/jsdom config syntax (based on training data)
|
||||||
|
|
||||||
|
**Research date:** 2026-03-11
|
||||||
|
**Valid until:** 2026-04-11 (stable library versions; no fast-moving dependencies in scope)
|
||||||
Reference in New Issue
Block a user