29 KiB
Phase 3: Interaction Quality and Completeness - Research
Researched: 2026-03-11 Domain: React UI feedback patterns — loading states, hover affordances, flash animations, empty states, skeleton loaders, confirmation dialogs Confidence: HIGH
Summary
Phase 3 adds the UX feedback layer on top of an already-functional data app. All required shadcn/ui primitives (Spinner, Skeleton, Dialog) are installed and working. The core data components (InlineEditCell, BillsTracker, VariableExpenses, DebtTracker) are in place with proper TypeScript interfaces. The token system (--success, --destructive, palette.ts) is complete from Phase 1.
The implementation falls into four distinct work tracks: (1) spinner injection into four forms, (2) pencil-icon hover + save-flash in InlineEditCell, (3) delete confirmation dialog in CategoriesPage, and (4) empty states and tinted skeletons. Each track is self-contained with clear entry points.
One critical constraint was resolved during research: the database uses ON DELETE RESTRICT on the budget_items.category_id foreign key. This means attempting to delete a category that has associated budget items will fail with a 500 error from the backend. The confirmation dialog copy and error handling must account for this — users need to be warned, and the frontend must handle the ApiError gracefully.
Primary recommendation: Implement all four tracks in parallel waves — spinners first (lowest risk, 4 targeted edits), then InlineEditCell enhancements, then delete confirmation, then empty states and skeletons.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Edit affordance & save feedback
- Pencil icon appears on hover only, subtle opacity fade-in — not always visible
- Pencil positioned to the right of the cell value
- Save confirmation: soft green row highlight using --success token, fades over ~600ms, applies to entire row
- Save failure: red flash using --destructive token, value reverts to original — no toast, no modal
- All changes go into InlineEditCell.tsx (already extracted in Phase 1)
Empty states & loading skeletons
- Empty state style: icon + text only (lucide-react icon, no custom illustrations)
- Shared template structure (icon + heading + subtext + CTA button), unique content per section
- CTA tone: direct action — "Create your first budget" / "Add a category" — no fluff
- Loading skeletons tinted per section using palette.ts light shades (bills skeleton uses bill light shade, etc.)
Spinner placement & style
- Submit buttons replace text with spinner while loading (button maintains width via min-width)
- Button disabled during loading to prevent double-submit
- All four forms get spinners: Login, Register, Budget Create, Budget Edit
- Use existing shadcn spinner.tsx component as-is
Delete confirmation dialog
- Tone: clear and factual — "Delete [category name]? This cannot be undone."
- Confirm button: "Delete" in destructive variant (red), paired with neutral "Cancel"
- Delete button shows spinner + disables during API call (consistent with form submit pattern)
- Scope: category deletion only (per IXTN-05), not budget deletion
Claude's Discretion
- Exact animation timing and easing curves for hover/flash transitions
- Empty state icon choices per section (appropriate lucide-react icons)
- Skeleton layout structure (number of rows, widths) per section
- Whether to extract a shared EmptyState component or inline per page
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| IXTN-01 | Form submit buttons show a spinner during async operations (login, register, budget create/edit) | loading state already exists in LoginPage and RegisterPage; BudgetSetup has saving state. Spinner component is installed at ui/spinner.tsx. Pattern: replace button text with <Spinner /> when loading, add min-w-* to prevent layout shift |
| IXTN-02 | Inline-editable rows show a pencil icon on hover as an edit affordance | InlineEditCell display-mode span already has cursor-pointer hover:bg-muted. Pencil icon: Pencil from lucide-react. CSS: opacity-0 group-hover:opacity-100 transition-opacity on icon, group on the span wrapper |
| IXTN-03 | Inline edit saves show a brief visual confirmation (row background flash) | Flash must apply to the entire TableRow, not just the cell. InlineEditCell sits inside a TableRow it does not own. Pattern: callback-based — onSave resolves → parent sets a flash state on the row ID → row gets a timed className → clears after ~600ms |
| IXTN-05 | Category deletion triggers a confirmation dialog before executing | dialog.tsx is installed. Current handleDelete fires immediately on button click. Replace with: set pendingDeleteId state → Dialog opens → confirm triggers actual delete with spinner → catch ApiError (ON DELETE RESTRICT → 500) |
| STATE-01 | Dashboard shows a designed empty state with CTA when user has no budgets | DashboardPage already has a fallback Card when !current — needs replacement with full empty-state design. list.length === 0 && !loading is the trigger condition |
| STATE-02 | Categories page shows a designed empty state with create CTA when no categories exist | CategoriesPage grouped array will be empty when no categories exist. Currently renders nothing in that case. Needs empty-state block |
| STATE-03 | Loading skeletons are styled with pastel-tinted backgrounds matching section colors | DashboardPage already uses <Skeleton> in the loading branch. Skeleton accepts className — override bg-muted with style={{ backgroundColor: palette.bill.light }} pattern. Need skeletons in BillsTracker, VariableExpenses, DebtTracker sections too |
| </phase_requirements> |
Standard Stack
Core (all already installed)
| Library | Version | Purpose | Status |
|---|---|---|---|
ui/spinner.tsx |
shadcn (Loader2Icon) | Loading indicator in buttons | Installed, use as-is |
ui/skeleton.tsx |
shadcn (animate-pulse) | Loading placeholders | Installed, accepts className for tinting |
ui/dialog.tsx |
radix-ui Dialog | Modal confirmation | Installed, full API available |
lucide-react |
^0.577.0 | Icons for pencil affordance and empty states | Installed |
tw-animate-css |
installed | Utility animation classes (animate-in, fade-in, etc.) |
Available in index.css |
CSS Tokens (from index.css :root)
| Token | Value | Use in Phase 3 |
|---|---|---|
--success |
oklch(0.55 0.15 145) |
Row flash on successful save |
--destructive |
oklch(0.58 0.22 27) |
Row flash on failed save, delete button |
palette[type].light |
per-section oklch | Skeleton background tinting |
Supporting: palette.ts light shades for skeleton tinting
| Section | Component | Palette Key | Light Value |
|---|---|---|---|
| Bills Tracker | BillsTracker | palette.bill.light |
oklch(0.96 0.03 250) |
| Variable Expenses | VariableExpenses | palette.variable_expense.light |
oklch(0.97 0.04 85) |
| Debt Tracker | DebtTracker | palette.debt.light |
oklch(0.96 0.04 15) |
| Dashboard overview | DashboardPage initial skeleton | palette.saving.light |
oklch(0.95 0.04 280) |
Architecture Patterns
Pattern 1: Spinner in Submit Buttons
What: Replace button label text with Spinner component while async op is in-flight. Button stays disabled to prevent double-submit. Min-width prevents layout shift when text disappears.
Entry points:
LoginPage.tsxline 81 — hasloadingstate alreadyRegisterPage.tsxline 89 — hasloadingstate alreadyBudgetSetup.tsxline 92 — hassavingstate alreadyCategoriesPage.tsx(save button in dialog) — addsavingstate
Pattern:
// Source: existing project pattern + shadcn spinner.tsx
import { Spinner } from '@/components/ui/spinner'
<Button type="submit" disabled={loading} className="w-full min-w-[120px]">
{loading ? <Spinner /> : t('auth.login')}
</Button>
Key constraint: min-w-* class value depends on expected button text width. Use min-w-[120px] as a safe default, adjust per button.
Pattern 2: Pencil Icon Hover Affordance
What: The display-mode span in InlineEditCell gets a group wrapper. Pencil icon appears to the right of the value, fades in on hover.
Pattern:
// Source: Tailwind group-hover pattern, lucide-react Pencil icon
import { Pencil } from 'lucide-react'
{/* In display mode, not editing */}
<span
className="group flex cursor-pointer items-center justify-end gap-1 rounded px-2 py-1 hover:bg-muted"
onClick={handleClick}
>
{formatCurrency(value, currency)}
<Pencil className="size-3 opacity-0 transition-opacity group-hover:opacity-100 text-muted-foreground" />
</span>
Note: The outer TableCell already has text-right. The span needs flex justify-end to align the pencil icon after the value text while keeping right-alignment.
Pattern 3: Row Flash After Save (Callback Pattern)
What: InlineEditCell cannot flash the row itself (it only owns a <td>). The flash must be signaled up to the parent component that owns the <tr>.
Problem: BillsTracker, VariableExpenses, and DebtTracker render the <TableRow>. InlineEditCell is one cell in that row.
Solution: Add a onSaveSuccess?: () => void callback to InlineEditCellProps. Parent calls setFlashId(item.id) in response, adds flash className to TableRow, clears after 600ms with setTimeout.
// InlineEditCell.tsx — add to props and handleBlur
interface InlineEditCellProps {
value: number
currency: string
onSave: (value: number) => Promise<void>
onSaveSuccess?: () => void // NEW
onSaveError?: () => void // NEW
className?: string
}
// In handleBlur:
const handleBlur = async () => {
const num = parseFloat(inputValue)
if (!isNaN(num) && num !== value) {
try {
await onSave(num)
onSaveSuccess?.()
} catch {
setInputValue(String(value)) // revert
onSaveError?.()
}
}
setEditing(false)
}
// BillsTracker.tsx — flash state on parent
const [flashRowId, setFlashRowId] = useState<string | null>(null)
const [errorRowId, setErrorRowId] = useState<string | null>(null)
const flashRow = (id: string, type: 'success' | 'error') => {
if (type === 'success') setFlashRowId(id)
else setErrorRowId(id)
setTimeout(() => {
if (type === 'success') setFlashRowId(null)
else setErrorRowId(null)
}, 600)
}
// On the TableRow:
<TableRow
key={item.id}
className={cn(
flashRowId === item.id && 'bg-success/20 transition-colors duration-600',
errorRowId === item.id && 'bg-destructive/20 transition-colors duration-600'
)}
>
CSS note: bg-success/20 requires --success to be in the CSS token system. Confirmed: it is in index.css :root.
Pattern 4: Delete Confirmation Dialog
What: Replace the direct handleDelete(id) call with a two-step flow: set pending ID → Dialog opens → user confirms → delete executes with spinner.
// CategoriesPage.tsx additions
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const confirmDelete = async () => {
if (!pendingDeleteId) return
setDeleting(true)
setDeleteError(null)
try {
await categoriesApi.delete(pendingDeleteId)
setPendingDeleteId(null)
fetchCategories()
} catch (err) {
// ON DELETE RESTRICT: category has budget items
setDeleteError(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeleting(false)
}
}
// Dialog usage (dialog.tsx is already imported):
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => !open && setPendingDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {pendingCategoryName}?</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
</DialogHeader>
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>Cancel</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting} className="min-w-[80px]">
{deleting ? <Spinner /> : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
CRITICAL — ON DELETE RESTRICT: Database constraint prevents deleting a category that has associated budget items. The backend returns 500 with "failed to delete category". The frontend ApiError will have status: 500. Display the error inline in the dialog (not a toast). No copy change needed — the error message from the API surfaces naturally.
Pattern 5: Empty States
What: Replace placeholder text/empty content with an icon + heading + subtext + CTA pattern.
// Shared pattern (extract or inline — discretion area)
// icon: from lucide-react, chosen per section
// heading: bold, action-oriented
// subtext: one line of context
// CTA: Button with onClick pointing to create flow
<div className="flex flex-col items-center gap-4 py-16 text-center">
<FolderOpen className="size-12 text-muted-foreground" />
<div className="flex flex-col gap-1">
<p className="font-semibold">No budgets yet</p>
<p className="text-sm text-muted-foreground">Create your first budget to get started.</p>
</div>
<Button onClick={() => setShowCreate(true)}>Create your first budget</Button>
</div>
Dashboard trigger: list.length === 0 && !loading (not !current, which is also true when switching budgets)
Categories trigger: grouped.length === 0 && list.length === 0 — when the fetch has completed but returned no data
Pattern 6: Tinted Skeletons
What: Skeleton component accepts className and a style prop. Override bg-muted with a palette light shade using inline style.
// Source: skeleton.tsx — bg-muted is a Tailwind class, override via style prop
import { palette } from '@/lib/palette'
import { Skeleton } from '@/components/ui/skeleton'
// Bills section skeleton:
<div className="flex flex-col gap-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton
key={i}
className="h-10 w-full rounded-md"
style={{ backgroundColor: palette.bill.light }}
/>
))}
</div>
Note: Using style prop overrides Tailwind's bg-muted in skeleton.tsx without editing the component file. This aligns with the project rule: never edit src/components/ui/ source files.
Anti-Patterns to Avoid
- Editing ui/ source files: Never modify
spinner.tsx,skeleton.tsx,dialog.tsx— useclassNameandstyleprops only - Flash on the cell, not the row: Don't apply success/error background to the
<td>— apply to the<TableRow>for full visual impact - hardcoded hex colors for flash: Use
bg-success/20andbg-destructive/20Tailwind classes which reference the CSS tokens - Empty state before data loads: Guard empty state behind
!loadingto avoid flash of empty content - Calling delete without await on error:
handleDeletemust catch the ApiError from ON DELETE RESTRICT and show inline feedback
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Loading spinner | Custom SVG animation | <Spinner /> from ui/spinner.tsx |
Already installed, accessible (role=status, aria-label) |
| Skeleton loader | Div with custom CSS pulse | <Skeleton> from ui/skeleton.tsx |
animate-pulse built in, accepts className/style |
| Modal confirmation | Custom overlay/modal | <Dialog> from ui/dialog.tsx |
Radix focus trap, keyboard dismiss, a11y compliant |
| Pencil icon | Custom SVG or CSS | <Pencil> from lucide-react |
Already a dependency, consistent stroke width |
| Empty state icon | Custom illustration | lucide-react icons | No extra dependency, consistent visual language |
| CSS transition timing | Custom keyframe animation | tw-animate-css classes or Tailwind transition-* |
Already imported in index.css |
Key insight: All required primitives are installed. This phase is purely wiring them together — no new dependencies needed.
Common Pitfalls
Pitfall 1: Flash Timing — bg-success/20 Requires Tailwind to Know the Color
What goes wrong: bg-success/20 works only if --success is defined in CSS AND Tailwind knows about it. Tailwind 4 scans CSS variables automatically from @theme inline — but the token must appear as --color-success or be referenced in the theme config.
Why it happens: Tailwind 4's CSS-first config infers color utilities from --color-* variables. The project uses --success not --color-success.
How to avoid: Use inline style for the flash: style={{ backgroundColor: 'oklch(0.55 0.15 145 / 0.2)' }} or style={{ backgroundColor: 'color-mix(in oklch, var(--success) 20%, transparent)' }} rather than a Tailwind class. Alternatively, verify the theme config exposes success as a color utility.
Warning signs: bg-success/20 renders as no background in the browser, or TypeScript/Tailwind LSP shows it as invalid.
Resolution: Check if text-success works in existing components — it does (used in amountColorClass). This means --success IS exposed as a Tailwind color. bg-success/20 should therefore work. Confidence: MEDIUM — verify in browser.
Pitfall 2: Dialog State Management — pendingDeleteId vs pendingDeleteName
What goes wrong: The Dialog body needs to show the category name ("Delete Rent?") but pendingDeleteId is just a UUID.
Why it happens: ID is needed for the API call; name is needed for dialog copy.
How to avoid: Store both: const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null). Use pendingDelete?.name in dialog, pendingDelete?.id for API call.
Pitfall 3: InlineEditCell onSave Currently Swallows Errors
What goes wrong: Current handleBlur calls await onSave(num) without try/catch. If the API call fails, the input just closes with no feedback — the user sees no error and the value may appear to have saved.
Why it happens: Phase 1 only extracted the component, didn't add error handling.
How to avoid: Phase 3 adds try/catch in handleBlur, reverts inputValue to String(value) on error, and calls onSaveError?.().
Pitfall 4: Empty State vs Loading State Race
What goes wrong: On initial load, the component briefly shows the empty state before data arrives.
Why it happens: loading may be false before the data prop is populated, or the loading state is in the hook not the component.
How to avoid: In DashboardPage, the guard is loading && list.length === 0 for the skeleton (already correct). The empty state must be list.length === 0 && !loading. For CategoriesPage, add a loading state to the fetchCategories flow.
Pitfall 5: TableRow Background Overrides hover:bg-muted
What goes wrong: The success/error flash background and the row's hover background may conflict, leaving a stale color. Why it happens: Tailwind applies both classes; the transition clears the flash class but hover class remains. How to avoid: The flash is controlled via a timed state reset — after 600ms, the flash className is removed. The hover class is always present but only visible without the flash. No conflict since flash duration is short.
Pitfall 6: Category Delete Restricted by ON DELETE RESTRICT
What goes wrong: Attempting to delete a category with associated budget items returns a 500 error. Without error handling, the dialog closes and the user sees nothing.
Why it happens: budget_items.category_id has ON DELETE RESTRICT in the migration. The backend handler does not distinguish the constraint violation from other errors — it returns 500 + "failed to delete category".
How to avoid: Catch the ApiError in confirmDelete, display the message inline in the dialog (don't close it), let user dismiss manually. Consider adding a user-visible note to the dialog: "Cannot delete a category that has been used in a budget."
Code Examples
Spinner in Button (verified pattern)
// Source: ui/spinner.tsx + LoginPage.tsx existing pattern
import { Spinner } from '@/components/ui/spinner'
<Button type="submit" disabled={loading} className="w-full min-w-[120px]">
{loading ? <Spinner /> : t('auth.login')}
</Button>
Pencil Icon Hover in InlineEditCell display mode
// Source: Tailwind group pattern + lucide-react Pencil
import { Pencil } from 'lucide-react'
// Replace the existing <span> in display mode:
<span
className="group flex cursor-pointer items-center justify-end gap-1 rounded px-2 py-1 hover:bg-muted"
onClick={handleClick}
>
{formatCurrency(value, currency)}
<Pencil className="size-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 text-muted-foreground" />
</span>
Tinted Skeleton override
// Source: ui/skeleton.tsx className + inline style override
// style prop overrides bg-muted without editing the ui component
<Skeleton
className="h-10 w-full"
style={{ backgroundColor: palette.bill.light }}
/>
Delete Dialog structure
// Source: ui/dialog.tsx exported components
<Dialog open={!!pendingDelete} onOpenChange={(open) => { if (!open) setPendingDelete(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {pendingDelete?.name}?</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
</DialogHeader>
{deleteError && (
<p className="text-sm text-destructive">{deleteError}</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDelete(null)} disabled={deleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleting}
className="min-w-[80px]"
>
{deleting ? <Spinner /> : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Row flash pattern (parent component)
// cn() + timed state reset pattern
const [flashRowId, setFlashRowId] = useState<string | null>(null)
const [errorRowId, setErrorRowId] = useState<string | null>(null)
const triggerFlash = (id: string, type: 'success' | 'error') => {
if (type === 'success') {
setFlashRowId(id)
setTimeout(() => setFlashRowId(null), 600)
} else {
setErrorRowId(id)
setTimeout(() => setErrorRowId(null), 600)
}
}
// On the row:
<TableRow
key={item.id}
style={
flashRowId === item.id
? { backgroundColor: 'color-mix(in oklch, var(--success) 20%, transparent)' }
: errorRowId === item.id
? { backgroundColor: 'color-mix(in oklch, var(--destructive) 20%, transparent)' }
: undefined
}
>
Note on color-mix vs Tailwind class: Using inline color-mix() with CSS variables is more reliable than bg-success/20 for dynamic state since it avoids Tailwind class purging concerns and works at runtime.
State of the Art
| Old Approach | Current Approach | Notes |
|---|---|---|
| Toast notifications for all feedback | Row-level contextual flash | Already decided in CONTEXT.md — row flash is more contextual |
| Alert dialogs (window.confirm) | Radix Dialog | Accessible, styleable, non-blocking |
| Hardcoded grey skeleton | Section-tinted skeleton | Reinforces palette.ts color system |
Open Questions
-
bg-success/20Tailwind class availability- What we know:
text-successworks (used in amountColorClass).--successis in:root. - What's unclear: Whether Tailwind 4 generates
bg-successutilities from--successor only from--color-success. - Recommendation: Use
color-mix(in oklch, var(--success) 20%, transparent)as the inline style fallback — it works regardless of Tailwind utility availability. Ifbg-success/20is confirmed to work in testing, switch to the class for cleaner JSX.
- What we know:
-
EmptyState — shared component vs inline
- What we know: Three locations need empty states (Dashboard, Categories, potentially per-section). Structure is identical (icon + heading + subtext + CTA).
- What's unclear: Whether the CTA prop types are manageable in a shared component (different onClick signatures).
- Recommendation (discretion): Extract a shared
EmptyStatecomponent withicon,heading,subtext, andaction: { label: string; onClick: () => void }props. Avoids duplication of the flex/gap/text structure across pages.
-
Categories page loading state
- What we know: CategoriesPage currently has no
loadingstate —fetchCategoriesfires in useEffect but there's no indicator. - What's unclear: Whether a loading skeleton is in scope for CategoriesPage (STATE-03 mentions sections, not necessarily the categories table).
- Recommendation: STATE-03 specifies "section colors" referring to the dashboard tracker sections. CategoriesPage skeleton is not explicitly required but adds completeness. Add a simple
loadingstate guard to prevent empty-state flash on initial load.
- What we know: CategoriesPage currently has no
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 4.0.18 + @testing-library/react + jsdom |
| Config file | frontend/vite.config.ts (test section) |
| Quick run command | cd frontend && bun vitest run src/components/InlineEditCell.test.tsx |
| Full suite command | cd frontend && bun vitest run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| IXTN-01 | Submit button shows spinner and disables while loading | unit | bun vitest run src/pages/LoginPage.test.tsx |
✅ (extend existing) |
| IXTN-01 | Register button shows spinner while submitting | unit | bun vitest run src/pages/RegisterPage.test.tsx |
✅ (extend existing) |
| IXTN-01 | BudgetSetup create button shows spinner | unit | bun vitest run src/components/BudgetSetup.test.tsx |
❌ Wave 0 |
| IXTN-02 | Pencil icon not visible in normal state | unit | bun vitest run src/components/InlineEditCell.test.tsx |
✅ (extend existing) |
| IXTN-02 | Pencil icon present in DOM (discoverable via hover) | unit | bun vitest run src/components/InlineEditCell.test.tsx |
✅ (extend existing) |
| IXTN-03 | onSaveSuccess callback fires after successful save | unit | bun vitest run src/components/InlineEditCell.test.tsx |
✅ (extend existing) |
| IXTN-03 | onSaveError fires and value reverts on save failure | unit | bun vitest run src/components/InlineEditCell.test.tsx |
✅ (extend existing) |
| IXTN-05 | Delete button opens confirmation dialog, not immediate delete | unit | bun vitest run src/pages/CategoriesPage.test.tsx |
❌ Wave 0 |
| IXTN-05 | Confirm delete calls API; cancel does not | unit | bun vitest run src/pages/CategoriesPage.test.tsx |
❌ Wave 0 |
| STATE-01 | Empty state renders when budget list is empty | unit | bun vitest run src/pages/DashboardPage.test.tsx |
❌ Wave 0 |
| STATE-02 | Empty state renders when category list is empty | unit | bun vitest run src/pages/CategoriesPage.test.tsx |
❌ Wave 0 |
| STATE-03 | Skeleton renders with section-tinted background style | unit | bun vitest run src/components/BillsTracker.test.tsx |
❌ Wave 0 |
Sampling Rate
- Per task commit:
cd frontend && bun vitest run src/components/InlineEditCell.test.tsx - Per wave merge:
cd frontend && bun vitest run - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
frontend/src/components/BudgetSetup.test.tsx— covers IXTN-01 (budget form spinner)frontend/src/pages/CategoriesPage.test.tsx— covers IXTN-05 (delete confirmation), STATE-02 (empty state)frontend/src/pages/DashboardPage.test.tsx— covers STATE-01 (dashboard empty state)frontend/src/components/BillsTracker.test.tsx— covers STATE-03 (tinted skeleton)
Existing tests to extend:
LoginPage.test.tsx— add IXTN-01 spinner assertionRegisterPage.test.tsx— add IXTN-01 spinner assertionInlineEditCell.test.tsx— add IXTN-02 pencil icon + IXTN-03 flash callbacks
Sources
Primary (HIGH confidence)
- Direct codebase inspection —
frontend/src/components/InlineEditCell.tsx,LoginPage.tsx,RegisterPage.tsx,BudgetSetup.tsx,CategoriesPage.tsx,DashboardPage.tsx - Direct codebase inspection —
frontend/src/components/ui/spinner.tsx,skeleton.tsx,dialog.tsx,button.tsx - Direct codebase inspection —
frontend/src/lib/palette.ts,frontend/src/index.css - Direct codebase inspection —
backend/migrations/001_initial.sql(ON DELETE RESTRICT confirmed) - Direct codebase inspection —
backend/internal/api/handlers.go,db/queries.go(delete behavior confirmed) - Direct codebase inspection —
frontend/src/i18n/en.json(existing i18n keys)
Secondary (MEDIUM confidence)
- Tailwind 4 CSS variable → utility generation:
bg-success/20likely works iftext-successworks, butcolor-mix()inline style is a safer fallback
Metadata
Confidence breakdown:
- Standard stack: HIGH — all components inspected directly from source files
- Architecture: HIGH — patterns derived from existing codebase patterns and shadcn component APIs
- Pitfalls: HIGH — ON DELETE RESTRICT confirmed from migration SQL; flash/state patterns from direct code analysis
- Test infrastructure: HIGH — vitest config verified, existing test files inspected
Research date: 2026-03-11 Valid until: 2026-06-11 (stable dependencies, 90 days)