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

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 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 SummaryStripStatCard 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

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 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:
{/* 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:

  • 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)