32 KiB
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
categoryLabelsin 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):
// @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):
@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:
// 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:
// 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:
// 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:
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:
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 inDashboardContentkeyed tobudgetId. - Computing grouped data inside
CategorySection: Items should be pre-grouped inDashboardContentviauseMemo.CategorySectionis purely presentational. - Using
overflow: hiddenon the outerCollapsibleroot: Only applyoverflow: hiddentoCollapsibleContent(animated element), not the outer root, to avoid clipping box-shadows on the header. - Declaring
useState/useMemoafter early returns: Violates React hooks rules. All hooks must be declared beforeif (loading) return <DashboardSkeleton />. - Animating with
max-height: 9999px: Produces visible animation lag. Use--radix-collapsible-content-height(exact measured height) withheightanimation 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:
- Give the chart grid a stable height by ensuring the three chart cards have
min-h-[xxx]or fixedh-[xxx]Tailwind classes. The existing card +ChartContainerwithmin-h-[250px](from Phase 2) already creates a floor. - Wrap the chart grid in a div with
overflow: hiddenorcontain: layoutto prevent section expand/collapse from reflowing chart dimensions. - Use CSS
contain: layout styleon the chart grid container:
{/* 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:
<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:
// en.json
"dashboard": {
"carryoverIncludes": "Includes {{amount}} carryover"
}
t("dashboard.carryoverIncludes", { amount: formatCurrency(carryover, currency) })
Code Examples
Verified patterns from source inspection and project conventions:
Collapsible Controlled Pattern
// 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)
/* 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
// 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
// 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)
// 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
// en.json additions under "dashboard":
{
"dashboard": {
"sections": {
"itemName": "Item",
"groupTotal": "{{label}} Total"
},
"carryoverIncludes": "Includes {{amount}} carryover"
}
}
// 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:
defaultOpenonCollapsibleroot: Still valid, but we use controlledopen+onOpenChangefor the reset-on-navigation requirementhiddenprop removed fromCollapsibleContentin newer Radix: Radix manageshiddenattribute internally; never pass it manually
Open Questions
-
CSS
containon 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
containfirst; add[contain:layout_style]to chart grid div only if ResizeObserver errors appear in manual testing
-
Tailwind v4
data-[]variant syntax forgroup-data-[state=open]- What we know: Tailwind v4 supports arbitrary group variants; the project uses
grouppattern 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-90CSS selector approach
- What we know: Tailwind v4 supports arbitrary group variants; the project uses
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 ofCollapsibleContentImpl; confirmed--radix-collapsible-content-heightCSS variable,data-statevalues"open"/"closed",hiddenattribute management, mount-animation prevention viaisMountAnimationPreventedRefnode_modules/@radix-ui/react-collapsible/dist/index.d.ts— Confirmed type signatures:CollapsibleProps.open,CollapsibleProps.onOpenChange,CollapsibleProps.defaultOpensrc/components/ui/collapsible.tsx— Confirmed wrapper already in project, exportsCollapsible,CollapsibleTrigger,CollapsibleContentsrc/pages/DashboardPage.tsx— Confirmed existingDashboardContentstructure,useMemoplacement before early returns,budget.carryover_amountalready in scopesrc/pages/BudgetDetailPage.tsx— Confirmed grouping pattern (CATEGORY_TYPES.map,items.filter, per-group totals),Table/TableFooterpattern,DifferenceCelllogicsrc/lib/palette.ts— ConfirmedcategoryColorsreturns"var(--color-income)"etc.;categoryLabelsfor EN/DE display stringssrc/lib/types.ts— ConfirmedBudget.carryover_amount: number,BudgetItem.category?.typesrc/index.css— Confirmed--color-over-budget,--color-on-budgetsemantic tokens;@theme inlinepattern for CSS custom propertiessrc/i18n/en.json+de.json— Confirmed existing keys; identified gaps for new keyssrc/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
containfix is speculative pending testing - Animation pattern: HIGH —
--radix-collapsible-content-heightconfirmed from source, Tailwind v4data-[]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)