diff --git a/.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md b/.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md new file mode 100644 index 0000000..4790a85 --- /dev/null +++ b/.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md @@ -0,0 +1,656 @@ +# Phase 3: Collapsible Dashboard Sections - Research + +**Researched:** 2026-03-17 +**Domain:** React collapsible UI, Radix UI Collapsible, Tailwind CSS animation, ResizeObserver, dashboard data grouping +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Section header design** +- Badge-style chips for totals: two small colored badges showing `[Budget $X]` and `[Actual $X]` right-aligned in the header row +- Left border accent using the category's CSS variable color (thick colored left border on the header row) +- Difference shown in header with color coding: green (`--color-on-budget`) when under/on budget, red (`--color-over-budget`) when over budget +- Chevron-right icon that rotates to chevron-down when expanded (standard Radix Collapsible pattern) +- Group label (from `categoryLabels` in palette.ts) on the left, badges and difference on the right + +**Line-item table columns** +- Four columns: Item Name, Budgeted, Actual, Difference +- No tier badge — keep it clean for the dashboard summary view +- No notes column — full detail lives on BudgetDetailPage +- Difference column uses red text when over budget (`--color-over-budget`), no full-row tint +- Footer row with bold group totals summing Budget, Actual, and Diff columns +- Read-only — no clickable rows, no navigation links to BudgetDetailPage + +**Default expand/collapse behavior** +- Smart default: over-budget sections auto-expand on load, on/under-budget sections start collapsed +- Over-budget logic is direction-aware: + - Spending categories (bill, variable_expense, debt): actual > budget = over budget (expand) + - Income category: actual < budget = under-earned (expand) + - Savings/investment categories: actual < budget = under-saved (expand) +- Empty category groups (no items of that type) are hidden entirely — only show sections with at least one budget item +- Expand/collapse state resets on month navigation — smart defaults recalculate per month + +**Carryover display** +- Subtitle line below balance amount on the balance StatCard: "Includes $X carryover" when non-zero +- Carryover is included in the balance calculation: Balance = Income Actual - Expenses Actual + Carryover +- When carryover is zero, the subtitle line is hidden entirely (clean card for the common case) +- Negative carryover is supported: shown with red styling (e.g., "Includes -$150 carryover"), deducts from balance + +### Claude's Discretion +- Smooth collapse/expand CSS animation details (timing, easing) +- Preventing ResizeObserver loop errors when toggling rapidly (success criteria #3) +- Preventing chart resize jank when sections toggle (success criteria #3) +- Exact spacing between section headers and between sections and the chart grid above +- Table cell alignment and typography within line items +- DashboardSkeleton updates for the collapsible sections area +- How to derive and memoize per-group data from budget items + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| UI-DASH-01 | Redesign dashboard with hybrid layout — summary cards, charts, and collapsible category sections (income, bills, expenses, debt, savings) with budget/actual columns | Collapsible sections inserted between chart grid and QuickAdd in DashboardContent; all grouping/totals derived from existing `items` array via useMemo | +| UI-COLLAPSE-01 | Add collapsible inline sections on dashboard for each category group showing individual line items | Radix Collapsible v1.1.12 already installed and wrapped in collapsible.tsx; exposes `--radix-collapsible-content-height` CSS variable for height animation | + + +--- + +## Summary + +Phase 3 adds collapsible per-category sections to the dashboard between the chart grid and the QuickAdd button. The codebase is well-prepared: `collapsible.tsx` wraps Radix Collapsible v1.1.12, the `Badge`, `Table`, and `StatCard` primitives are ready, `categoryColors`/`categoryLabels` from `palette.ts` map cleanly to section styling, and `CATEGORY_TYPES` + `EXPENSE_TYPES` constants already define the display order. `BudgetDetailPage.tsx` already implements identical grouping logic (group items by `category.type`, derive per-group totals, render a `Table` with a `TableFooter` row) — the dashboard sections are a read-only, collapsible variant of that pattern. + +The primary technical considerations are: (1) animating the Radix `CollapsibleContent` height smoothly using the `--radix-collapsible-content-height` CSS variable it exposes, (2) preventing `ResizeObserver loop` errors that Recharts can trigger when layout shifts affect chart container dimensions, and (3) threading `budget.carryover_amount` through `SummaryStrip` → `StatCard` to display the carryover subtitle on the balance card. + +**Primary recommendation:** Build a `CategorySection` component that wraps `Collapsible`/`CollapsibleTrigger`/`CollapsibleContent` with inline `border-l-4` styling, derive all section data in a single `useMemo` in `DashboardContent`, and isolate Recharts charts in a stable wrapper div to prevent ResizeObserver jank. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `@radix-ui/react-collapsible` (via `radix-ui`) | 1.1.12 | Accessible expand/collapse primitive | Already installed; exposes `data-state`, `aria-expanded`, `--radix-collapsible-content-height` | +| `tailwindcss` | 4.2.x | Utility classes for animation, border accent, spacing | Already in use; v4 `@theme inline` CSS variable pattern used throughout | +| `lucide-react` | 0.577.x | ChevronRight / ChevronDown icons | Already in use | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `Badge` (ui/badge.tsx) | — | Budget/Actual chip badges in section header | Used for the two right-aligned total chips | +| `Table` / `TableBody` / `TableCell` / `TableFooter` (ui/table.tsx) | — | Line-item rows and group total footer | Used for the expanded content table | +| `StatCard` (dashboard/StatCard.tsx) | — | Balance card needing carryover subtitle | Needs a new optional `subtitle` prop | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Radix Collapsible | HTML `
`/`` | No animation support; no `data-state` for CSS targeting; not Radix-integrated | +| CSS height animation via `--radix-collapsible-content-height` | framer-motion `AnimatePresence` | framer-motion not in the stack; adding it would violate the "no new major dependencies" constraint | + +**Installation:** No new packages needed. All primitives are already installed. + +--- + +## Architecture Patterns + +### Recommended Project Structure + +New files for this phase: + +``` +src/components/dashboard/ +├── CategorySection.tsx # Collapsible section: header + table +├── CollapsibleSections.tsx # Renders ordered list of CategorySection +``` + +Modified files: +``` +src/components/dashboard/StatCard.tsx # Add optional subtitle prop +src/components/dashboard/SummaryStrip.tsx # Thread carryover subtitle to balance StatCard +src/pages/DashboardPage.tsx # DashboardContent: add grouped sections data, pass carryover to SummaryStrip +src/components/dashboard/DashboardSkeleton.tsx # Add skeleton rows for sections area +src/i18n/en.json # New keys: dashboard.sections.*, dashboard.carryoverIncludes +src/i18n/de.json # German equivalents +``` + +### Pattern 1: Radix Collapsible with CSS Height Animation + +**What:** `CollapsibleContent` exposes `--radix-collapsible-content-height` as an inline CSS variable on the content div. A Tailwind keyframe animation reads this variable to animate `max-height` from `0` to the measured natural height. + +**When to use:** Any time the Radix Collapsible content needs a smooth open/close height transition without a JS animation library. + +**How Radix sets the variable (from source):** + +```typescript +// @radix-ui/react-collapsible source (CollapsibleContentImpl) +style: { + [`--radix-collapsible-content-height`]: height ? `${height}px` : undefined, + [`--radix-collapsible-content-width`]: width ? `${width}px` : undefined, + ...props.style +} +``` + +The `height` value is measured from `getBoundingClientRect()` in a `useLayoutEffect` — it is the element's natural height when fully open. The variable is set on the content div itself. + +**Tailwind v4 animation pattern (index.css addition):** + +```css +@theme inline { + /* ... existing tokens ... */ + --animate-collapsible-open: collapsible-open 200ms ease-out; + --animate-collapsible-close: collapsible-close 200ms ease-out; +} + +@keyframes collapsible-open { + from { height: 0; overflow: hidden; } + to { height: var(--radix-collapsible-content-height); overflow: hidden; } +} + +@keyframes collapsible-close { + from { height: var(--radix-collapsible-content-height); overflow: hidden; } + to { height: 0; overflow: hidden; } +} +``` + +**CollapsibleContent usage:** + +```tsx +// Source: Radix Collapsible docs pattern + project CSS variable system + + {/* table content */} + +``` + +**Key detail:** `data-[state=open]` and `data-[state=closed]` are set by Radix on the `CollapsibleContent` div (from `getState(context.open)` — returns `"open"` or `"closed"`). Tailwind v4's arbitrary variant syntax `data-[state=open]:` works directly against these attributes. + +### Pattern 2: Controlled Collapsible with Smart Defaults + +**What:** Drive open/close state with local `useState` in `DashboardContent` (not inside `CategorySection`). Compute initial state from budget data, reset when `budgetId` changes (i.e., on month navigation). + +**When to use:** When expand state needs to be computed from data (smart defaults) and reset on navigation. + +**Example:** + +```typescript +// In DashboardContent — after all item-derived useMemos +const CATEGORY_TYPES_ALL: CategoryType[] = [ + "income", "bill", "variable_expense", "debt", "saving", "investment" +] + +const groupedSections = useMemo(() => + CATEGORY_TYPES_ALL + .map((type) => { + const groupItems = items.filter((i) => i.category?.type === type) + if (groupItems.length === 0) return null + const budgeted = groupItems.reduce((s, i) => s + i.budgeted_amount, 0) + const actual = groupItems.reduce((s, i) => s + i.actual_amount, 0) + // Direction-aware over-budget check + const isOverBudget = + type === "income" || type === "saving" || type === "investment" + ? actual < budgeted // under-earned / under-saved + : actual > budgeted // overspent + return { type, items: groupItems, budgeted, actual, isOverBudget } + }) + .filter(Boolean), + [items] +) + +// Initial expand state: over-budget sections open, others closed +// Key on budgetId so state resets when month changes +const [openSections, setOpenSections] = useState>(() => + Object.fromEntries( + (groupedSections ?? []).map((g) => [g!.type, g!.isOverBudget]) + ) +) + +// Reset when budgetId (month) changes +useEffect(() => { + setOpenSections( + Object.fromEntries( + (groupedSections ?? []).map((g) => [g!.type, g!.isOverBudget]) + ) + ) +}, [budgetId]) // budgetId is the stable dependency; groupedSections flows from it +``` + +**Note on Rules of Hooks:** `useState` initializer runs once. The `useEffect` driven by `budgetId` handles the reset-on-navigation requirement without violating hooks rules. All `useMemo` hooks for `groupedSections` must be declared before any early returns (established pattern from Phase 2). + +### Pattern 3: CategorySection Component + +**What:** A pure presentational component — accepts pre-computed group data and delegates all state management to the parent via `open` / `onOpenChange` props (controlled pattern). + +**Example:** + +```tsx +// Source: project conventions + Radix Collapsible controlled pattern +interface CategorySectionProps { + type: CategoryType + label: string + items: BudgetItem[] + budgeted: number + actual: number + currency: string + open: boolean + onOpenChange: (open: boolean) => void + t: (key: string, opts?: Record) => string +} + +export function CategorySection({ + type, label, items, budgeted, actual, currency, open, onOpenChange, t +}: CategorySectionProps) { + const diff = /* direction-aware difference */ + const isOver = /* direction-aware over-budget flag */ + const accentColor = categoryColors[type] // "var(--color-income)" etc. + + return ( + + + + + + {/* Table with items + footer */} + + + ) +} +``` + +**Chevron rotation note:** The `[[data-state=open]_&]:rotate-90` class uses Tailwind v4 ancestor-state targeting. An ancestor element with `data-state="open"` (the `CollapsibleTrigger` button itself has `data-state` set by Radix) rotates the icon. Alternative: target the trigger's own `data-state` with `group-data-[state=open]:rotate-90` if a `group` class is applied to the trigger. + +### Pattern 4: StatCard Carryover Subtitle + +**What:** Add an optional `subtitle` string prop to `StatCard`. When provided, renders below the value with small muted text. The balance card passes "Includes $X carryover" when `budget.carryover_amount !== 0`. + +**Modified StatCard interface:** + +```typescript +interface StatCardProps { + title: string + value: string + valueClassName?: string + subtitle?: string // NEW — optional small text below value + subtitleClassName?: string // NEW — optional class override (for negative carryover red) + variance?: { ... } +} +``` + +**SummaryStrip carryover prop threading:** + +```typescript +interface SummaryStripProps { + income: { value: string; budgeted: string } + expenses: { value: string; budgeted: string } + balance: { value: string; isPositive: boolean; carryoverSubtitle?: string; carryoverIsNegative?: boolean } + t: (key: string) => string +} +``` + +### Anti-Patterns to Avoid + +- **Storing expand state inside `CategorySection`:** Breaks the reset-on-navigation requirement. All expand state must live in `DashboardContent` keyed to `budgetId`. +- **Computing grouped data inside `CategorySection`:** Items should be pre-grouped in `DashboardContent` via `useMemo`. `CategorySection` is purely presentational. +- **Using `overflow: hidden` on the outer `Collapsible` root:** Only apply `overflow: hidden` to `CollapsibleContent` (animated element), not the outer root, to avoid clipping box-shadows on the header. +- **Declaring `useState`/`useMemo` after early returns:** Violates React hooks rules. All hooks must be declared before `if (loading) return `. +- **Animating with `max-height: 9999px`:** Produces visible animation lag. Use `--radix-collapsible-content-height` (exact measured height) with `height` animation instead. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Accessible expand/collapse | Custom `aria-expanded` + DOM toggle | `Collapsible` / `CollapsibleTrigger` / `CollapsibleContent` from `ui/collapsible.tsx` | Radix handles `aria-expanded`, `aria-controls`, `id` linkage, keyboard (Enter/Space), and disabled state | +| Height measurement for animation | `ResizeObserver` + state | `--radix-collapsible-content-height` CSS variable from Radix | Radix measures height in `useLayoutEffect` and sets the variable; no custom measurement needed | +| Category color accent border | Tailwind color class | Inline `style={{ borderLeftColor: categoryColors[type] }}` | `categoryColors` maps to CSS custom property strings (`"var(--color-income)"`); Tailwind can't generate arbitrary CSS variable values without JIT config | + +**Key insight:** The Radix `CollapsibleContent` implementation already handles the tricky edge cases: mount-animation prevention on initial render (the `isMountAnimationPreventedRef.current` flag), measurement timing (uses `useLayoutEffect` to measure before paint), and `hidden` attribute management (element is `hidden` when closed, preventing tab focus and screen reader access to invisible content). + +--- + +## Common Pitfalls + +### Pitfall 1: ResizeObserver Loop Errors from Recharts +**What goes wrong:** When collapsible sections open/close, the document layout shifts. Recharts' `ResponsiveContainer` uses a `ResizeObserver` internally. If the observer callback fires mid-layout and triggers a chart re-render that itself changes layout, the browser fires `ResizeObserver loop limit exceeded` console errors. + +**Why it happens:** Recharts charts are already rendered above the sections. The layout shift from section expand/collapse propagates upward through the document flow if the chart grid is not height-stable. + +**How to avoid:** +1. Give the chart grid a stable height by ensuring the three chart cards have `min-h-[xxx]` or fixed `h-[xxx]` Tailwind classes. The existing card + `ChartContainer` with `min-h-[250px]` (from Phase 2) already creates a floor. +2. Wrap the chart grid in a div with `overflow: hidden` or `contain: layout` to prevent section expand/collapse from reflowing chart dimensions. +3. Use CSS `contain: layout style` on the chart grid container: + +```tsx +{/* 3-column chart grid — isolated from section-toggle reflow */} +
+ ...charts... +
+``` + +**Warning signs:** `ResizeObserver loop limit exceeded` or `ResizeObserver loop completed with undelivered notifications` in the browser console after toggling sections. + +**Confidence:** MEDIUM — the `min-h-[250px]` on ChartContainer from Phase 2 may already be sufficient. Add `contain` only if errors appear in testing. + +### Pitfall 2: Mount-Time Animation Flicker +**What goes wrong:** Sections that start expanded (over-budget auto-expand) animate open on first render even though they should appear pre-opened. + +**Why it happens:** The Radix `CollapsibleContent` animation keyframe fires on mount if `defaultOpen={true}`. + +**How to avoid:** Radix handles this internally: `isMountAnimationPreventedRef.current` is initialized to `isOpen` (true if open on mount), and the `animationName` is set to `"none"` during the initial layout effect, then restored after a `requestAnimationFrame`. This means the animation is suppressed on mount automatically. No additional handling needed. + +### Pitfall 3: Chevron Rotation Targeting +**What goes wrong:** The chevron icon doesn't rotate because the Tailwind ancestor-state class references the wrong ancestor's `data-state`. + +**Why it happens:** In Radix Collapsible, the `data-state` attribute is set on both the `CollapsibleTrigger` button element AND the root `Collapsible` div. The icon is a child of the trigger button. + +**How to avoid:** Use the trigger's own `data-state` as the ancestor for rotation. The simplest approach — add `group` class to the `CollapsibleTrigger` (or its `asChild` element) and use `group-data-[state=open]:rotate-90` on the icon: + +```tsx + + + +``` + +### Pitfall 4: `useEffect` Reset Dependency Array +**What goes wrong:** Expand state doesn't reset on month navigation, or resets on every render. + +**Why it happens:** Wrong dependency in the `useEffect` that resets `openSections`. + +**How to avoid:** Depend on `budgetId` (the string prop from `DashboardContent`), not on `groupedSections` (which changes reference on every render due to `useMemo`). `budgetId` changes exactly when the user navigates months. + +### Pitfall 5: i18n Key Interpolation for Carryover Subtitle +**What goes wrong:** Carryover subtitle shows `"Includes {{amount}} carryover"` as literal text. + +**Why it happens:** i18next interpolation requires `t("key", { amount: "..." })` and the JSON to use `{{amount}}` syntax. + +**How to avoid:** +```json +// en.json +"dashboard": { + "carryoverIncludes": "Includes {{amount}} carryover" +} +``` +```typescript +t("dashboard.carryoverIncludes", { amount: formatCurrency(carryover, currency) }) +``` + +--- + +## Code Examples + +Verified patterns from source inspection and project conventions: + +### Collapsible Controlled Pattern +```tsx +// Source: @radix-ui/react-collapsible v1.1.12 type definitions + project conventions +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible" + + + + + + + {/* table */} + + +``` + +### CSS Keyframe Animation (index.css addition) +```css +/* Source: Radix docs pattern + project @theme inline convention */ +@theme inline { + --animate-collapsible-open: collapsible-open 200ms ease-out; + --animate-collapsible-close: collapsible-close 200ms ease-out; +} + +@keyframes collapsible-open { + from { height: 0; overflow: hidden; } + to { height: var(--radix-collapsible-content-height); overflow: hidden; } +} + +@keyframes collapsible-close { + from { height: var(--radix-collapsible-content-height); overflow: hidden; } + to { height: 0; overflow: hidden; } +} +``` + +### Direction-Aware Over-Budget Logic +```typescript +// Source: CONTEXT.md locked decisions +function isOverBudget(type: CategoryType, budgeted: number, actual: number): boolean { + if (type === "income" || type === "saving" || type === "investment") { + return actual < budgeted // under-earned / under-saved = problem + } + return actual > budgeted // overspent = problem +} +``` + +### Carryover Subtitle in StatCard +```typescript +// Modified StatCard — add optional subtitle prop +interface StatCardProps { + title: string + value: string + valueClassName?: string + subtitle?: string // e.g. "Includes €150.00 carryover" + subtitleClassName?: string // e.g. "text-over-budget" for negative carryover + variance?: { amount: string; direction: "up" | "down" | "neutral"; label: string } +} + +// In render: +{subtitle && ( +

+ {subtitle} +

+)} +``` + +### Read-Only Line-Item Table Pattern (Dashboard variant) +```tsx +// Source: BudgetDetailPage.tsx pattern, adapted for read-only dashboard use + + + + {t("categories.name")} + {t("budgets.budgeted")} + {t("budgets.actual")} + {t("budgets.difference")} + + + + {items.map((item) => ( + + {item.category?.name ?? item.category_id} + {formatCurrency(item.budgeted_amount, currency)} + {formatCurrency(item.actual_amount, currency)} + + {formatCurrency(Math.abs(diff), currency)} + + + ))} + + + + {t(`categories.types.${type}`)} Total + {formatCurrency(budgeted, currency)} + {formatCurrency(actual, currency)} + + {formatCurrency(Math.abs(diff), currency)} + + + +
+``` + +### New i18n Keys Required +```json +// en.json additions under "dashboard": +{ + "dashboard": { + "sections": { + "itemName": "Item", + "groupTotal": "{{label}} Total" + }, + "carryoverIncludes": "Includes {{amount}} carryover" + } +} +``` + +```json +// de.json additions: +{ + "dashboard": { + "sections": { + "itemName": "Posten", + "groupTotal": "{{label}} Gesamt" + }, + "carryoverIncludes": "Inkl. {{amount}} Übertrag" + } +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `max-height: 9999px` CSS hack | `height` animation with `--radix-collapsible-content-height` | Radix ~v1.0 | Smooth animation with no lag | +| Custom `aria-expanded` management | Radix `CollapsibleTrigger` manages `aria-expanded` automatically | Radix v1.x | Correct accessibility with zero effort | +| Separate `@radix-ui/react-collapsible` install | Included in `radix-ui` v1.4.3 umbrella package | radix-ui monorepo consolidation | Already present in project — no install needed | + +**Deprecated/outdated:** +- `defaultOpen` on `Collapsible` root: Still valid, but we use controlled `open` + `onOpenChange` for the reset-on-navigation requirement +- `hidden` prop removed from `CollapsibleContent` in newer Radix: Radix manages `hidden` attribute internally; never pass it manually + +--- + +## Open Questions + +1. **CSS `contain` on chart grid to prevent ResizeObserver errors** + - What we know: Recharts uses ResizeObserver; layout shifts from sections opening can trigger loop errors + - What's unclear: Whether Phase 2's `min-h-[250px]` on charts is already sufficient to prevent jank + - Recommendation: Implement without `contain` first; add `[contain:layout_style]` to chart grid div only if ResizeObserver errors appear in manual testing + +2. **Tailwind v4 `data-[]` variant syntax for `group-data-[state=open]`** + - What we know: Tailwind v4 supports arbitrary group variants; the project uses `group` pattern already (sidebar.tsx) + - What's unclear: Whether Tailwind v4's JIT generates `group-data-[state=open]:` without explicit config + - Recommendation: Use it — Tailwind v4 generates arbitrary variants JIT; if it doesn't compile, fall back to the `[[data-state=open]_&]:rotate-90` CSS selector approach + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | None installed — no test infrastructure in project | +| Config file | None — Wave 0 would need `vitest.config.ts` | +| Quick run command | N/A | +| Full suite command | `bun run lint` (only automated check available) | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| UI-DASH-01 | Collapsible sections render between charts and QuickAdd | manual-only | — | N/A | +| UI-DASH-01 | Over-budget sections auto-expand on load | manual-only | — | N/A | +| UI-DASH-01 | Carryover subtitle appears on balance card when non-zero | manual-only | — | N/A | +| UI-COLLAPSE-01 | Section expands/collapses with smooth animation | manual-only | — | N/A | +| UI-COLLAPSE-01 | No ResizeObserver loop errors on rapid toggle | manual-only | browser console check | N/A | +| UI-COLLAPSE-01 | Empty category groups are hidden | manual-only | — | N/A | +| UI-COLLAPSE-01 | State resets on month navigation | manual-only | — | N/A | + +**Justification for manual-only:** No test framework is installed. The project has no `vitest`, `jest`, `@testing-library/react`, or similar. Installing a test framework is out of scope for Phase 3 (the project's TESTING.md notes this explicitly). All validation will be manual browser testing. + +The primary automated check available is `bun run lint` (ESLint), which catches hooks rules violations, unused variables, and TypeScript errors. + +### Sampling Rate +- **Per task commit:** `bun run lint` (catches TypeScript errors and hooks violations) +- **Per wave merge:** `bun run build` (full TypeScript compile + Vite bundle) +- **Phase gate:** Manual browser testing of all 7 behaviors above before `/gsd:verify-work` + +### Wave 0 Gaps +None — no test infrastructure to create. Lint and build are the only automated gates. + +--- + +## Sources + +### Primary (HIGH confidence) +- `node_modules/@radix-ui/react-collapsible/dist/index.mjs` — Full source inspection of `CollapsibleContentImpl`; confirmed `--radix-collapsible-content-height` CSS variable, `data-state` values `"open"`/`"closed"`, `hidden` attribute management, mount-animation prevention via `isMountAnimationPreventedRef` +- `node_modules/@radix-ui/react-collapsible/dist/index.d.ts` — Confirmed type signatures: `CollapsibleProps.open`, `CollapsibleProps.onOpenChange`, `CollapsibleProps.defaultOpen` +- `src/components/ui/collapsible.tsx` — Confirmed wrapper already in project, exports `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` +- `src/pages/DashboardPage.tsx` — Confirmed existing `DashboardContent` structure, `useMemo` placement before early returns, `budget.carryover_amount` already in scope +- `src/pages/BudgetDetailPage.tsx` — Confirmed grouping pattern (`CATEGORY_TYPES.map`, `items.filter`, per-group totals), `Table`/`TableFooter` pattern, `DifferenceCell` logic +- `src/lib/palette.ts` — Confirmed `categoryColors` returns `"var(--color-income)"` etc.; `categoryLabels` for EN/DE display strings +- `src/lib/types.ts` — Confirmed `Budget.carryover_amount: number`, `BudgetItem.category?.type` +- `src/index.css` — Confirmed `--color-over-budget`, `--color-on-budget` semantic tokens; `@theme inline` pattern for CSS custom properties +- `src/i18n/en.json` + `de.json` — Confirmed existing keys; identified gaps for new keys +- `src/components/dashboard/StatCard.tsx` — Confirmed current interface (no subtitle prop); variance prop pattern + +### Secondary (MEDIUM confidence) +- `.planning/codebase/CONVENTIONS.md` — Component structure, hooks-before-returns rule, import ordering, TypeScript strict mode +- `.planning/codebase/TESTING.md` — Confirmed no test framework installed; lint/build are only automated checks +- `.planning/STATE.md` — Confirmed pre-existing lint errors in unrelated files; Phase 2 patterns (useMemo before early returns, QuickAdd position) + +### Tertiary (LOW confidence) +- None — all findings verified against installed source code + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all libraries inspected from installed node_modules source +- Architecture: HIGH — patterns derived directly from existing codebase files +- Pitfalls: MEDIUM — ResizeObserver issue is known Recharts behavior; specific CSS `contain` fix is speculative pending testing +- Animation pattern: HIGH — `--radix-collapsible-content-height` confirmed from source, Tailwind v4 `data-[]` variants confirmed from existing sidebar.tsx usage + +**Research date:** 2026-03-17 +**Valid until:** 2026-04-17 (stable libraries; CSS variables are fixed in installed source)