diff --git a/.planning/phases/03-interaction-quality-and-completeness/03-RESEARCH.md b/.planning/phases/03-interaction-quality-and-completeness/03-RESEARCH.md
new file mode 100644
index 0000000..17f2b62
--- /dev/null
+++ b/.planning/phases/03-interaction-quality-and-completeness/03-RESEARCH.md
@@ -0,0 +1,537 @@
+# 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 (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
+
+
+---
+
+
+## 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 `` 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 `` 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 |
+
+
+---
+
+## 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.tsx` line 81 — has `loading` state already
+- `RegisterPage.tsx` line 89 — has `loading` state already
+- `BudgetSetup.tsx` line 92 — has `saving` state already
+- `CategoriesPage.tsx` (save button in dialog) — add `saving` state
+
+**Pattern:**
+```tsx
+// Source: existing project pattern + shadcn spinner.tsx
+import { Spinner } from '@/components/ui/spinner'
+
+
+```
+
+**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:**
+```tsx
+// Source: Tailwind group-hover pattern, lucide-react Pencil icon
+import { Pencil } from 'lucide-react'
+
+{/* In display mode, not editing */}
+
+ {formatCurrency(value, currency)}
+
+
+```
+
+**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 `
`). The flash must be signaled up to the parent component that owns the `
`.
+
+**Problem:** `BillsTracker`, `VariableExpenses`, and `DebtTracker` render the ``. `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`.
+
+```tsx
+// InlineEditCell.tsx — add to props and handleBlur
+interface InlineEditCellProps {
+ value: number
+ currency: string
+ onSave: (value: number) => Promise
+ 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)
+}
+```
+
+```tsx
+// BillsTracker.tsx — flash state on parent
+const [flashRowId, setFlashRowId] = useState(null)
+const [errorRowId, setErrorRowId] = useState(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:
+
+```
+
+**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.
+
+```tsx
+// CategoriesPage.tsx additions
+const [pendingDeleteId, setPendingDeleteId] = useState(null)
+const [deleting, setDeleting] = useState(false)
+const [deleteError, setDeleteError] = useState(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):
+
+```
+
+**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.
+
+```tsx
+// 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
+
+
+
+
+
No budgets yet
+
Create your first budget to get started.
+
+
+
+```
+
+**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.
+
+```tsx
+// 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:
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+```
+
+**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` — use `className` and `style` props only
+- **Flash on the cell, not the row:** Don't apply success/error background to the `
` — apply to the `` for full visual impact
+- **hardcoded hex colors for flash:** Use `bg-success/20` and `bg-destructive/20` Tailwind classes which reference the CSS tokens
+- **Empty state before data loads:** Guard empty state behind `!loading` to avoid flash of empty content
+- **Calling delete without await on error:** `handleDelete` must 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 | `` from `ui/spinner.tsx` | Already installed, accessible (role=status, aria-label) |
+| Skeleton loader | Div with custom CSS pulse | `` from `ui/skeleton.tsx` | animate-pulse built in, accepts className/style |
+| Modal confirmation | Custom overlay/modal | `