Files
SimpleFinanceDash/.planning/milestones/v1.0-phases/03-collapsible-dashboard-sections/03-RESEARCH.md

657 lines
32 KiB
Markdown

# 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>
## 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.
</user_constraints>
---
<phase_requirements>
## 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 |
</phase_requirements>
---
## 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 `<details>`/`<summary>` | 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
<CollapsibleContent
className="data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close overflow-hidden"
>
{/* table content */}
</CollapsibleContent>
```
**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<Record<string, boolean>>(() =>
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, unknown>) => 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 (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<button
className="flex w-full items-center gap-3 rounded-md border-l-4 bg-card px-4 py-3 hover:bg-muted/40"
style={{ borderLeftColor: accentColor }}
>
<ChevronRight
className="size-4 shrink-0 transition-transform duration-200 [[data-state=open]_&]:rotate-90"
aria-hidden
/>
<span className="font-medium">{label}</span>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="tabular-nums">
{t("budgets.budgeted")} {formatCurrency(budgeted, currency)}
</Badge>
<Badge variant="secondary" className="tabular-nums">
{t("budgets.actual")} {formatCurrency(actual, currency)}
</Badge>
<span
className={cn(
"text-sm font-medium tabular-nums",
isOver ? "text-over-budget" : "text-on-budget"
)}
>
{formatCurrency(Math.abs(diff), currency)}
</span>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close overflow-hidden">
{/* Table with items + footer */}
</CollapsibleContent>
</Collapsible>
)
}
```
**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 <DashboardSkeleton />`.
- **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 */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 [contain:layout_style]">
...charts...
</div>
```
**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
<CollapsibleTrigger asChild>
<button className="group flex w-full items-center ...">
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]:rotate-90" />
...
</button>
</CollapsibleTrigger>
```
### 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"
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-md border-l-4 px-4 py-3"
style={{ borderLeftColor: "var(--color-income)" }}>
<ChevronRight className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
<span className="font-medium">Income</span>
{/* badges and diff */}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close">
{/* table */}
</CollapsibleContent>
</Collapsible>
```
### 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 && (
<p className={cn("mt-0.5 text-xs text-muted-foreground", subtitleClassName)}>
{subtitle}
</p>
)}
```
### Read-Only Line-Item Table Pattern (Dashboard variant)
```tsx
// Source: BudgetDetailPage.tsx pattern, adapted for read-only dashboard use
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("categories.name")}</TableHead>
<TableHead className="text-right">{t("budgets.budgeted")}</TableHead>
<TableHead className="text-right">{t("budgets.actual")}</TableHead>
<TableHead className="text-right">{t("budgets.difference")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.category?.name ?? item.category_id}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(item.budgeted_amount, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(item.actual_amount, currency)}</TableCell>
<TableCell className={cn("text-right tabular-nums", isOverItem ? "text-over-budget" : "text-muted-foreground")}>
{formatCurrency(Math.abs(diff), currency)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-medium">{t(`categories.types.${type}`)} Total</TableCell>
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(budgeted, currency)}</TableCell>
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(actual, currency)}</TableCell>
<TableCell className={cn("text-right tabular-nums font-medium", isOver ? "text-over-budget" : "text-on-budget")}>
{formatCurrency(Math.abs(diff), currency)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
```
### 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)