diff --git a/.planning/phases/09-weight-classification-and-visualization/09-RESEARCH.md b/.planning/phases/09-weight-classification-and-visualization/09-RESEARCH.md
new file mode 100644
index 0000000..857119e
--- /dev/null
+++ b/.planning/phases/09-weight-classification-and-visualization/09-RESEARCH.md
@@ -0,0 +1,553 @@
+# Phase 9: Weight Classification and Visualization - Research
+
+**Researched:** 2026-03-16
+**Domain:** Schema migration, classification UI, chart visualization (Recharts)
+**Confidence:** HIGH
+
+## Summary
+
+Phase 9 adds two features: (1) per-setup item classification (base weight / worn / consumable) stored on the `setup_items` join table, and (2) a donut chart visualization of weight distribution inside the setup detail page. The classification feature requires a schema migration adding a `classification` column with a default of `"base"` to `setup_items`, updates to the sync/query service layer, a new API endpoint for updating individual item classifications, and a click-to-cycle badge on each item card within setup context. The visualization feature requires installing Recharts and building a summary card component with a donut chart, weight subtotals, and a pill toggle for switching between category and classification breakdowns.
+
+The project has strong existing patterns to follow: the `StatusBadge` click-to-cycle component from Phase 8, the `formatWeight()` utility with `useWeightUnit()` hook, the TotalsBar pill toggle for weight units, and the Drizzle migration pattern established in prior phases (e.g., `0002_broken_roughhouse.sql` adding a column with `ALTER TABLE ... ADD`). Recharts v3.x is the decided chart library, which is mature, well-documented, and has a straightforward API for donut charts using `PieChart` + `Pie` with `innerRadius`.
+
+**Primary recommendation:** Use Recharts v3.x with `Cell` component for individual slice colors (still functional in v3, deprecated only for v4), `Label` for center text, and a custom `content` function on `Tooltip` for formatted hover data. Store classification as a text column on `setup_items` with a Zod enum for validation.
+
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- Click-to-cycle badge on each item card within a setup -- clicks cycle through base weight -> worn -> consumable -> base weight
+- Follows the StatusBadge pattern from Phase 8 (pill badge, click interaction)
+- Default classification is "base weight" when an item is added to a setup
+- Badge always visible on every item card in the setup (not hidden for default)
+- Muted gray color scheme for all classification badges (bg-gray-100 text-gray-600), consistent with Phase 8 status badges
+- Classification stored on `setup_items` join table (already decided in prior phases)
+- Summary section below the setup sticky bar, always visible when setup has items
+- Card with columns layout: Base | Worn | Consumable | Total -- each as a labeled column with weight value
+- Sticky bar keeps its existing simple stats (item count, total weight, total cost)
+- Summary card is a separate visual element, not inline text
+- Donut chart sits inside the summary card alongside the weight subtotals -- chart + numbers as one visual unit
+- Pill toggle above the chart for switching between "Category" and "Classification" views (same style as weight unit selector)
+- Total weight displayed in the center of the donut hole (e.g., "2.87kg")
+- Hover tooltips show segment name, weight (in selected unit), and percentage
+- Chart library: **Recharts** (PieChart + Pie with innerRadius for donut shape)
+
+### Claude's Discretion
+- Summary card exact layout (chart left/right, column arrangement)
+- Chart color palette for segments (should work with both category and classification views)
+- Minimum item threshold for showing chart vs a placeholder message
+- Donut chart sizing and proportions
+- Tooltip styling
+- Keyboard accessibility for classification cycling
+- Animation on chart transitions between category/classification views
+
+### Deferred Ideas (OUT OF SCOPE)
+None -- discussion stayed within phase scope
+
+
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| CLAS-01 | User can classify each item within a setup as base weight, worn, or consumable | Classification column on `setup_items`, click-to-cycle badge component, PATCH API endpoint |
+| CLAS-02 | Setup totals display base weight, worn weight, consumable weight, and total separately | Summary card component computing subtotals from items array grouped by classification |
+| CLAS-03 | Items default to "base weight" classification when added to a setup | Schema default `"base"` on classification column, Drizzle migration with DEFAULT |
+| CLAS-04 | Same item can have different classifications in different setups | Classification on `setup_items` join table (not `items` table) -- architecture already decided |
+| VIZZ-01 | User can view a donut chart showing weight distribution by category in a setup | Recharts PieChart + Pie with innerRadius, data grouped by category |
+| VIZZ-02 | User can toggle chart between category view and classification view | Pill toggle component (reuse TotalsBar pattern), local React state for view mode |
+| VIZZ-03 | User can hover chart segments to see category name, weight, and percentage | Recharts Tooltip with custom content renderer using formatWeight() |
+
+
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| recharts | ^3.8.0 | Donut chart visualization | Most popular React charting library, declarative API, built on D3, 27K GitHub stars |
+
+### Supporting (already in project)
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| drizzle-orm | ^0.45.1 | Schema migration for classification column | Add column to setup_items table |
+| zod | ^4.3.6 | Validation for classification enum | API input validation |
+| react | ^19.2.4 | UI components | Summary card, badge, chart wrapper |
+| tailwindcss | ^4.2.1 | Styling | Summary card layout, badge styling |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Recharts | Chart.js / react-chartjs-2 | Chart.js is imperative; Recharts is declarative React components -- better fit for this stack |
+| Recharts | Visx | Lower-level D3 wrapper; more control but more code for a simple donut chart |
+| Recharts | Tremor | Tremor wraps Recharts but adds full design system overhead -- too heavy for one chart |
+
+**Installation:**
+```bash
+bun add recharts
+```
+
+Note: Recharts has `react` and `react-dom` as peer dependencies, both already in the project. No additional peer deps needed.
+
+## Architecture Patterns
+
+### Schema Change: setup_items classification column
+
+```sql
+-- Migration: ALTER TABLE setup_items ADD classification text DEFAULT 'base' NOT NULL;
+ALTER TABLE `setup_items` ADD `classification` text DEFAULT 'base' NOT NULL;
+```
+
+The Drizzle schema change in `src/db/schema.ts`:
+```typescript
+export const setupItems = sqliteTable("setup_items", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ setupId: integer("setup_id")
+ .notNull()
+ .references(() => setups.id, { onDelete: "cascade" }),
+ itemId: integer("item_id")
+ .notNull()
+ .references(() => items.id, { onDelete: "cascade" }),
+ classification: text("classification").notNull().default("base"),
+});
+```
+
+Values: `"base"` | `"worn"` | `"consumable"` -- stored as text, validated with Zod enum.
+
+### API Design: Classification Update
+
+A new `PATCH /api/setups/:id/items/:itemId/classification` endpoint is the cleanest approach. It avoids modifying the existing sync endpoint (which does delete-all + re-insert and would lose classifications).
+
+Alternatively, a dedicated `PATCH /api/setup-items/:setupItemId` could work, but using the composite key `(setupId, itemId)` is more consistent with the existing `DELETE /api/setups/:id/items/:itemId` pattern.
+
+**Use:** `PATCH /api/setups/:setupId/items/:itemId/classification` with body `{ classification: "worn" }`.
+
+### syncSetupItems Must Preserve Classifications
+
+The existing `syncSetupItems` function does delete-all + re-insert. After adding classification, this will reset all classifications to "base" whenever items are synced. Two approaches:
+
+**Approach A (recommended):** Before deleting, read existing classifications into a map `{ itemId -> classification }`. After re-inserting, apply the saved classifications. This keeps the atomic sync pattern intact.
+
+**Approach B:** Change sync to diff-based (add new, remove missing, keep existing). More complex, breaks the simple pattern.
+
+Use Approach A -- preserves the established pattern with minimal changes.
+
+### getSetupWithItems Must Include Classification
+
+The `getSetupWithItems` query needs to select `classification` from `setupItems`:
+
+```typescript
+const itemList = db
+ .select({
+ id: items.id,
+ name: items.name,
+ weightGrams: items.weightGrams,
+ priceCents: items.priceCents,
+ categoryId: items.categoryId,
+ // ... existing fields ...
+ categoryName: categories.name,
+ categoryIcon: categories.icon,
+ classification: setupItems.classification, // NEW
+ })
+ .from(setupItems)
+ .innerJoin(items, eq(setupItems.itemId, items.id))
+ .innerJoin(categories, eq(items.categoryId, categories.id))
+ .where(eq(setupItems.setupId, setupId))
+ .all();
+```
+
+### Component Architecture
+
+```
+src/client/
+ components/
+ ClassificationBadge.tsx # Click-to-cycle badge (base/worn/consumable)
+ WeightSummaryCard.tsx # Summary card: subtotals + donut chart
+ routes/
+ setups/
+ $setupId.tsx # Modified: adds ClassificationBadge to ItemCard, adds WeightSummaryCard
+ hooks/
+ useSetups.ts # Modified: add useUpdateItemClassification mutation, update types
+```
+
+### Pattern: ClassificationBadge (Click-to-Cycle)
+
+Follow the StatusBadge pattern but simplified -- no popup menu needed since there are only 3 values and the user cycles through them. Direct click-to-cycle is faster UX for 3 options.
+
+```typescript
+const CLASSIFICATION_ORDER = ["base", "worn", "consumable"] as const;
+type Classification = typeof CLASSIFICATION_ORDER[number];
+
+const CLASSIFICATION_CONFIG = {
+ base: { label: "Base Weight", icon: "backpack" },
+ worn: { label: "Worn", icon: "shirt" },
+ consumable: { label: "Consumable", icon: "droplets" },
+} as const;
+```
+
+Click handler cycles to next classification: `base -> worn -> consumable -> base`.
+
+### Pattern: Donut Chart with Recharts v3
+
+```typescript
+import { PieChart, Pie, Cell, Tooltip, Label, ResponsiveContainer } from "recharts";
+
+// Cell is still functional in v3 (deprecated for v4 removal)
+// This is the standard pattern for v3.x
+
+
+
+ {chartData.map((entry, index) => (
+
+ ))}
+
+
+ } />
+
+
+```
+
+### Pattern: Custom Tooltip
+
+```typescript
+function CustomTooltip({ active, payload, unit }: any) {
+ if (!active || !payload?.length) return null;
+ const { name, weight, percent } = payload[0].payload;
+ return (
+
+ );
+}
+```
+
+### Pattern: Data Transformation for Chart
+
+```typescript
+// Category view: group items by category, sum weights
+function buildCategoryChartData(items: SetupItemWithCategory[]) {
+ const groups = new Map();
+ for (const item of items) {
+ const current = groups.get(item.categoryName) ?? 0;
+ groups.set(item.categoryName, current + (item.weightGrams ?? 0));
+ }
+ const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
+ return Array.from(groups.entries())
+ .filter(([_, weight]) => weight > 0)
+ .map(([name, weight]) => ({ name, weight, percent: total > 0 ? weight / total : 0 }));
+}
+
+// Classification view: group by classification, sum weights
+function buildClassificationChartData(items: SetupItemWithClassification[]) {
+ const groups = { base: 0, worn: 0, consumable: 0 };
+ for (const item of items) {
+ groups[item.classification] += item.weightGrams ?? 0;
+ }
+ const total = Object.values(groups).reduce((a, b) => a + b, 0);
+ return Object.entries(groups)
+ .filter(([_, weight]) => weight > 0)
+ .map(([key, weight]) => ({
+ name: CLASSIFICATION_CONFIG[key as Classification].label,
+ weight,
+ percent: total > 0 ? weight / total : 0,
+ }));
+}
+```
+
+### Pattern: Pill Toggle (View Mode Switcher)
+
+Reuse the exact pattern from TotalsBar's weight unit toggle:
+
+```typescript
+const VIEW_MODES = ["category", "classification"] as const;
+type ViewMode = typeof VIEW_MODES[number];
+
+const [viewMode, setViewMode] = useState("category");
+
+// Rendered as:
+
+ {VIEW_MODES.map((mode) => (
+
+ ))}
+
+```
+
+### Anti-Patterns to Avoid
+- **Modifying syncSetupItems to accept classifications in the itemIds array:** This couples the sync endpoint to classification data. Keep them separate -- sync manages membership, classification update manages role.
+- **Computing classification subtotals on the server:** The setup detail page already computes totals client-side from the items array. Keep classification subtotals client-side too for consistency.
+- **Using a separate table for classifications:** Overkill. A single column on `setup_items` is the right level of complexity.
+- **Using Recharts v4 patterns (RechartsSymbols.fill):** v4 is not released. Stick with `Cell` component which works in v3.x.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Donut chart rendering | Custom SVG arc calculations | Recharts `PieChart` + `Pie` | Arc math, hit detection, animation, accessibility -- all handled |
+| Chart tooltips | Custom hover position tracking | Recharts `Tooltip` with `content` prop | Viewport boundary detection, positioning, hover state management |
+| Responsive chart sizing | Manual resize observers | Recharts `ResponsiveContainer` | Handles debounced resize, prevents layout thrashing |
+| Weight unit formatting | Inline conversion in chart | Existing `formatWeight()` utility | Already handles all units with correct decimal places |
+
+**Key insight:** Recharts handles all the hard SVG/D3 work. The implementation should focus on data transformation (grouping items into chart segments) and styling (Tailwind classes on the summary card and tooltip).
+
+## Common Pitfalls
+
+### Pitfall 1: syncSetupItems Destroys Classifications
+**What goes wrong:** The existing sync function deletes all setup_items then re-inserts. After adding classification, every sync resets all items to "base".
+**Why it happens:** Delete-all + re-insert pattern was designed before classification existed.
+**How to avoid:** Save classifications before delete, restore after re-insert (Approach A above).
+**Warning signs:** Items losing their classification after adding/removing any item from the setup.
+
+### Pitfall 2: ResponsiveContainer Needs a Defined Parent Height
+**What goes wrong:** Recharts `ResponsiveContainer` with `height="100%"` renders at 0px if the parent container has no explicit height.
+**Why it happens:** CSS percentage heights require the parent to have a defined height.
+**How to avoid:** Use a fixed numeric height on `ResponsiveContainer` (e.g., `height={200}`) or ensure the parent div has an explicit height (e.g., `h-[200px]`).
+**Warning signs:** Chart not visible, 0-height container.
+
+### Pitfall 3: Chart Data with Zero-Weight Items
+**What goes wrong:** Items with `null` or `0` weight produce zero-size or NaN chart segments.
+**Why it happens:** Recharts renders slices proportional to `dataKey` values. Zero values create invisible or problematic slices.
+**How to avoid:** Filter out zero-weight entries before passing data to the chart. Show a "no weight data" placeholder if all items have null weight.
+**Warning signs:** Console warnings about NaN, invisible chart segments, misaligned tooltips.
+
+### Pitfall 4: Test Helper Must Match Schema
+**What goes wrong:** Tests fail because the in-memory DB schema in `tests/helpers/db.ts` doesn't include the new `classification` column.
+**Why it happens:** The test helper has hand-written CREATE TABLE statements that must be manually kept in sync with `src/db/schema.ts`.
+**How to avoid:** Update the test helper's `setup_items` CREATE TABLE to include `classification text NOT NULL DEFAULT 'base'` alongside updating the Drizzle schema.
+**Warning signs:** Tests failing with "no such column: classification" errors.
+
+### Pitfall 5: Classification Badge Click Propagates to ItemCard
+**What goes wrong:** Clicking the classification badge opens the item edit panel instead of cycling classification.
+**Why it happens:** ItemCard is a `