diff --git a/memory-bank/FEATURE_OCR_Pedigree_Scan.md b/memory-bank/FEATURE_OCR_Pedigree_Scan.md
new file mode 100644
index 0000000..e0573f6
--- /dev/null
+++ b/memory-bank/FEATURE_OCR_Pedigree_Scan.md
@@ -0,0 +1,313 @@
+# MyKindlebox — OCR Pedigree Scanning Feature
+
+**Feature:** OCR-assisted pedigree entry with manual zone correction fallback
+**Stack:** Node.js / React / SQLite
+**Packages:** `tesseract.js`, `jimp`, `react-image-crop`
+
+---
+
+## Integration Point
+
+OCR scanning integrates into the **existing Add Rabbit wizard** rather than a separate route. No `/herd/scan` page is needed.
+
+- Step 1 of the wizard gets a **"Scan Pedigree"** button alongside the existing manual entry option
+- Scanning pre-fills the wizard's `PedigreeTree` + `details` state
+- After OCR completes, the user is dropped into the normal wizard flow at Step 1 with fields populated
+- The zone picker panel renders within the wizard context — no separate route or modal
+- The existing wizard review/save path handles confirmation; no duplicate save flow is needed
+
+## Overview
+
+Allow users to photograph or upload a printed rabbit pedigree and have the app auto-populate the pedigree entry form. The system attempts automatic extraction first; if any field is wrong or missing, the user can click that field and manually draw a zone on the image to re-run OCR on just that region.
+
+This feature does **not** use any LLM API calls. All processing is client-side.
+
+---
+
+## Architectural Decisions
+
+### Q1 — Integration point
+Resolved: option (a). OCR integrates into the existing Add Rabbit wizard via a "Scan Pedigree" button on Step 1. No separate `/herd/scan` route. See Integration Point section above.
+
+### Q2 — How extracted ancestors land in PedigreeTree
+- **Gen1 (sire/dam):** attempt herd match first using `rabbitName` + `earNumber` as the lookup key against existing herd rabbits via the existing autocomplete/search. On confident match → insert as a linked node. On no match → fall back to `serializeManualAncestor`. No user prompt to resolve — auto-link or auto-manual, silently.
+- **Gen2/gen3:** always insert as manual JSON nodes. No FK columns exist below `sire_id`/`dam_id`. This is the intended target and no special handling is needed.
+
+### Q3 — Subject populates both wizard steps simultaneously
+Yes. On OCR complete, both state slices are pre-filled at once:
+- `PedigreeTree` state ← sire, dam, gen2/gen3 ancestors
+- `details` state ← subject `farmName` → `farm`, `rabbitName` → `name`, `earNumber` → `ear_number`, `dob` → `dob`, `weight` → `weight_lbs`
+
+User reviews Step 1 (tree), advances normally, reviews Step 2 (details) which is already populated, confirms and saves. No special wizard behavior beyond pre-populating both slices.
+
+### Q4 — Weight and DOB normalization
+- **Weight:** normalize to decimal lbs at extraction time. Examples: `"8 lbs 4 oz"` → `8.25`, `"8.5 lbs"` → `8.5`, `"142 oz"` → `8.875`. The app's lb+oz vs decimal display setting handles rendering; storage is always a decimal float.
+- **DOB:** convert to `YYYY-MM-DD` at extraction time. Common input formats: `MM/DD/YYYY`, `MM-DD-YY`, `M/D/YY`. If parsing fails or no DOB is found, use `"Unknown"` — the app's existing sentinel convention for manual ancestors.
+
+### Q5 — Zone picker behavior for ancestor slots
+Zone picker result opens/refills the slot's **manual-entry editor** — it does not write silently to the tree. Ancestor slots are mode-bearing buttons; the user must see the populated values in the editor and explicitly confirm. Silent write would bypass the existing UX contract for those slots.
+
+### Q6 — Locking & matching behavior
+The pedigree tree's chain-authority locking (a gen1 node linked to a herd rabbit causes that branch's grandparents to lock with a 🔒 and derive from the linked rabbit's own record) interacts with OCR population. Resolved model:
+
+1. **OCR populates everything as editable manual nodes.** Subject → `details`; sire/dam/grandparents → manual ancestor JSON nodes (`mode: 'manual'`). Nothing locks during Step 1 — every scanned node is reviewable/editable exactly like hand entry, right up until the user clicks Next.
+2. **Gen1 matches resolve into links at the Next boundary, not live.** When the user advances from Step 1, each gen1 node (sire/dam) that *confidently* matches an existing herd rabbit is converted to a linked node (`mode: 'linked'`, setting `sire_id`/`dam_id`). That branch's grandparents then **lock and defer to the stored record** — the OCR-extracted grandparents on that branch are intentionally **discarded** in favor of the linked rabbit's own pedigree (the herd record is authoritative).
+3. **Unmatched ancestors stay manual.** A gen1 with no confident match remains a manual node, fully editable, and its scanned grandparents persist as manual `_name_text` JSON. Identical to hand entry.
+
+**Mechanical caveat — only gen1 can link/lock.** The schema has FK columns only for the subject's sire/dam (`sire_id`/`dam_id`); there is no column to tie a grandparent (gen2/gen3) directly to a herd rabbit. Therefore:
+- A gen2/gen3 node can never produce a link or a lock *on its own*, even if its scanned name happens to exist in the herd.
+- A grandparent only ends up locked/stored-derived **because its gen1 parent linked** (chain-authority cascade). If gen1 stays manual, its grandparents stay manual text regardless.
+- So "does this scanned ancestor match a herd rabbit?" is only ever asked at gen1; the gen2/gen3 case folds entirely into "did its gen1 parent match."
+
+"Confident match" definition (gen1): exact `ear_number` match → link; else exactly one exact-`name` match → link; otherwise → manual node. Ear number takes precedence as the primary key (unique-ish and OCR-reliable); the existing `GET /api/rabbits/search` substring search is too loose to auto-link on directly.
+
+### Q7 — Tesseract assets are self-hosted, not CDN-fetched
+By default `tesseract.js` downloads its WASM core, worker script, and the `eng.traineddata` language file (~10–15 MB) from a CDN (jsDelivr/unpkg) on first use. This app is **self-hosted on a single PC** and is built to work offline (service worker, offline banner, PWA), so all OCR assets must be **bundled and served locally** — no third-party CDN calls at runtime.
+
+- Vendor the WASM core, worker, and `eng.traineddata` into the client (e.g. `client/public/tesseract/`) and point `tesseract.js` at them via its `workerPath` / `corePath` / `langPath` options.
+- The service worker (`public/sw.js`) should treat these as cacheable app-shell assets so the scanner works fully offline after first load. (Note: the `.traineddata` is several MB — confirm the SW cache strategy handles it; it's static and versioned, so cache-first is appropriate, unlike the network-first app shell.)
+- Accept the resulting client bundle/footprint increase as a deliberate trade for offline, dependency-self-contained operation — consistent with the project's self-hosted ethos.
+
+---
+
+## Supported Input
+
+- Printed pedigrees of any layout (ARBA-standard and non-standard)
+- 3-generation family trees (subject + 2 parents + 4 grandparents)
+- Photo captures from device camera or file upload
+- Scanned flat documents or photographed paper (angled/shadowed)
+
+---
+
+## Pipeline
+
+```
+[1] Image Input
+ ↓
+[2] Client-side Preprocessing
+ ↓
+[3] Tesseract.js Full-Image OCR (with bounding boxes)
+ ↓
+[4] Layout Analysis → Tree Position Mapping
+ ↓
+[5] Form Population (nulls highlighted)
+ ↓
+[6] User Review
+ ├── Happy → Save
+ └── Field wrong → Zone Picker → Re-OCR → Field Update → Save
+```
+
+---
+
+## Step-by-Step Implementation
+
+### Step 1 — Image Input
+
+Use a standard file input with camera capture support:
+
+```jsx
+
+```
+
+Both file picker and camera capture work via this single input on mobile and desktop.
+
+---
+
+### Step 2 — Client-Side Preprocessing
+
+Before passing to Tesseract, normalize the image using canvas or `jimp`:
+
+- **Resize:** Clamp max width to 1600px. Larger images don't improve OCR and slow processing.
+- **Grayscale:** Convert to single-channel.
+- **Binarization (threshold):** Apply adaptive threshold to convert to black-and-white. This is the single most impactful step for photos of paper.
+- **Deskew:** Optional. Detect dominant angle via Hough transform and rotate to correct. Skip if too complex for v1 — binarization recovers most of the value.
+
+Recommended: use the canvas API for resize and grayscale, then apply a simple threshold pass. `jimp` can handle all of this if preferred.
+
+---
+
+### Step 3 — Tesseract.js OCR
+
+Run Tesseract on the preprocessed image with bounding box output enabled:
+
+```js
+import Tesseract from 'tesseract.js';
+
+const { data } = await Tesseract.recognize(preprocessedImage, 'eng', {
+ logger: m => console.log(m),
+});
+
+// data.words → array of { text, bbox: { x0, y0, x1, y1 }, confidence }
+// data.lines → array of { text, bbox, words[] }
+```
+
+Use `data.words` with bounding boxes as the primary input to layout analysis. Discard words with confidence below ~60.
+
+---
+
+### Step 4 — Layout Analysis
+
+Pedigrees are structured as a left-to-right generational tree. Map word bounding boxes to tree positions using x/y clustering.
+
+#### Column detection
+
+Divide the image width into 3 bands (or 4 if great-grandparents are present):
+
+| Band | Generation |
+|------|------------|
+| Leftmost ~25% | Subject |
+| 25–50% | Parents (sire top, dam bottom) |
+| 50–75% | Grandparents (sire_sire, sire_dam, dam_sire, dam_dam) |
+| 75–100% | Great-grandparents (optional) |
+
+Band boundaries can be tuned — start with even splits of image width.
+
+#### Sire / dam split within each column
+
+For each column, sort all words by `y0`. Find the largest vertical gap — this is the dividing line between the sire half and dam half. Everything above the gap → sire lineage, below → dam lineage.
+
+#### Text aggregation
+
+Within each detected cell region, sort words by `y0` then `x0` and join into lines. The first line is typically the rabbit's name, subsequent lines are metadata (tattoo, DOB, breed, variety, weight).
+
+#### Field parsing
+
+Only five fields are extracted per cell:
+
+- **Name:** first non-label line in the cell. Split on possessive pattern to extract farm name and rabbit name:
+ ```js
+ function parsePedigreeName(fullName) {
+ // Handles both "Cody's Black Betty" and "Peachy Buns' Hoppin-pop"
+ const match = fullName.match(/^(.+?'s?)\s+(.+)$/);
+ if (match) return { farmName: match[1], rabbitName: match[2] };
+ return { farmName: null, rabbitName: fullName };
+ }
+ ```
+- **Ear number:** matches `[A-Z0-9]{2,8}` after label "Tattoo:", "Ear #:", "Ear No:", or similar
+- **DOB:** matches date patterns (MM/DD/YYYY, MM-DD-YY, etc.)
+- **Weight:** matches `\d+(\.\d+)?\s*(lbs?|oz)?`
+
+Breed, variety, sex, and all other fields are not extracted. The user fills them in manually after scanning.
+
+Unmatched or low-confidence fields → `null`.
+
+#### Output shape
+
+```js
+{
+ subject: { farmName, rabbitName, earNumber, dob, weight },
+ sire: { farmName, rabbitName, earNumber, dob, weight },
+ dam: { farmName, rabbitName, earNumber, dob, weight },
+ sire_sire: { farmName, rabbitName, earNumber, dob, weight },
+ sire_dam: { farmName, rabbitName, earNumber, dob, weight },
+ dam_sire: { farmName, rabbitName, earNumber, dob, weight },
+ dam_dam: { farmName, rabbitName, earNumber, dob, weight },
+}
+// breed, variety, sex — user fills in manually after scanning
+```
+
+---
+
+### Step 5 — Form Population
+
+Populate the existing pedigree entry form with extracted values.
+
+- **Filled fields:** render normally
+- **Null fields:** highlight with yellow background and a small "?" indicator
+- Show a banner: *"OCR complete — review highlighted fields before saving."*
+
+Do not auto-save. The user must explicitly confirm.
+
+---
+
+### Step 6 — User Review & Zone Picker
+
+#### Normal flow
+
+User scans the form, corrects any highlighted or wrong fields by typing, and saves.
+
+#### Zone picker flow (triggered per field)
+
+When the user clicks a field they want to correct using the image:
+
+1. A panel slides open showing the original (un-preprocessed) image
+2. The bounding region currently assigned to that field is highlighted with a colored overlay
+3. User drags to draw a new rectangle over the correct area on the image
+4. On release, `react-image-crop` returns the crop coordinates
+5. Crop is extracted from the preprocessed image, passed back to Tesseract
+6. Tesseract result populates the field
+7. User can accept or discard the re-OCR result
+
+The zone picker panel should sit alongside or below the form, not in a modal, so the user can see both at once.
+
+#### Zone picker trigger
+
+Add a small camera/scan icon button next to each form field. Clicking the field label or the icon opens the zone picker for that field. Do not require the user to enter a separate "correction mode."
+
+---
+
+## Component Structure
+
+```
+PedigreeScanFlow/
+ ├── ImageCapture — file input + camera, triggers preprocessing
+ ├── OCRProcessor — runs Tesseract, exposes result + status
+ ├── LayoutAnalyzer — maps bounding boxes to tree positions
+ ├── PedigreeFormWithOCR — existing pedigree form, extended with:
+ │ ├── OCR value injection
+ │ ├── Null field highlighting
+ │ └── Zone picker trigger per field
+ └── ZonePicker — image display + react-image-crop + re-OCR
+```
+
+---
+
+## State Shape
+
+```js
+{
+ rawImage: Blob, // original upload
+ processedImage: ImageData, // after preprocessing
+ ocrResult: { // output of layout analysis
+ subject, sire, dam,
+ sire_sire, sire_dam,
+ dam_sire, dam_dam
+ },
+ ocrStatus: 'idle' | 'preprocessing' | 'scanning' | 'done' | 'error',
+ activeZoneField: string|null, // which field has zone picker open
+}
+```
+
+---
+
+## Packages
+
+| Package | Purpose |
+|---------|---------|
+| `tesseract.js` | Client-side OCR |
+| `jimp` | Image preprocessing (grayscale, threshold, resize) |
+| `react-image-crop` | Zone selection UI for manual correction |
+
+All processing is client-side. No server calls, no API tokens.
+
+---
+
+## Error Handling
+
+- If Tesseract fails entirely → show error, fall back to manual entry
+- If all fields come back null → warn the user the image may be too blurry or skewed, suggest retaking
+- Individual null fields → highlighted in form, zone picker available
+
+---
+
+## Out of Scope (v1)
+
+- Deskew / perspective correction (nice to have, not required)
+- Great-grandparent (4th generation) extraction
+- Handwriting support (degrades gracefully to nulls, zone picker handles correction)
+- Multi-page pedigrees
diff --git a/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md b/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md
new file mode 100644
index 0000000..18d1bf5
--- /dev/null
+++ b/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md
@@ -0,0 +1,489 @@
+# MyKindlebox — OCR Pedigree Scanning: Implementation Plan
+
+**Feature:** OCR-assisted pedigree entry with manual zone correction fallback
+**Reference:** `FEATURE_OCR_Pedigree_Scan.md`, `architecture.md`
+**Scope:** Base feature only — image capture, OCR pipeline, form pre-fill, zone picker correction.
+
+---
+
+## Overview
+
+This plan wires a "Scan Pedigree" button into the existing Add Rabbit wizard (Step 1). Tapping it lets the user photograph or upload a printed pedigree; the app extracts up to five fields per ancestor cell and pre-fills the wizard. The user reviews, corrects with a zone picker if needed, then proceeds through the normal wizard flow.
+
+All OCR processing is client-side. No server changes are required for the OCR feature itself (Phase 5 adds one new API call for herd matching).
+
+---
+
+## Architectural notes
+
+**PedigreeTree injection:** `PedigreeTree` reads `initialState` once at mount and owns its state internally. OCR results must be ready before `PedigreeTree` mounts. The pattern mirrors the existing "Add Kit" flow: block `PedigreeTree` render in `AddRabbit.jsx` until the OCR `initialState` object is fully built, then mount. If the user rescans, bump `PedigreeTree`'s `key` prop to remount with a fresh `initialState`. Do not refactor `PedigreeTree` to accept controlled post-mount updates.
+
+**Chain-authority cascade:** `PedigreeTree` cascades live — the moment a gen1 node is `mode: 'linked'`, `fetchCascade` fires and its grandparent branch locks immediately. There is no deferred-link model. If a gen1 node matches a herd rabbit, seed it as `mode: 'linked'` in `initialState`; the OCR-extracted grandparents on that branch are discarded at mount and replaced by the linked rabbit's stored pedigree. This is correct behavior — the herd record is authoritative.
+
+**Herd matching endpoint:** Use `GET /api/rabbits/quick-search?fields=ear_number&q=` and filter client-side to an exact `ear_number` match. If no exact ear number match, fall back to `quick-search?fields=name&q=` with a single exact name match. Do not use `/search` — it is name-only substring and will not match ear numbers.
+
+**Field remapping:** OCR emits `{ farmName, rabbitName, earNumber, weight, dob }`. `serializeManualAncestor` expects `{ name, farm, ear_number, weight_lbs, dob }`. Always remap explicitly: `rabbitName → name`, `farmName → farm`, `earNumber → ear_number`, `weight → weight_lbs`. Never pass OCR output to `serializeManualAncestor` directly.
+
+**Zone picker image:** The zone picker displays the preprocessed image blob (not the original). All stored bounding box coordinates are in preprocessed (≤1600px) coordinate space and only align correctly against the preprocessed image. Discard the original file after preprocessing completes — only the preprocessed blob is retained in state.
+
+**Null farmName and the Save guard:** The manual-entry editor requires farm + name + ear number before enabling Save. An ancestor whose OCR-extracted `farmName` is null will pre-fill with an empty farm field and the Save button will be disabled until the user fills it in. The yellow highlight on the farm field signals this. This is intentional — do not weaken the existing Save guard.
+
+---
+
+## Phase 1 — Install and vendor OCR dependencies
+
+### Step 1.1 — Install packages
+
+Install the two required packages into the client:
+
+- `tesseract.js`
+- `react-image-crop`
+
+Do not install `jimp` — all image preprocessing is handled via the canvas API (see Phase 2).
+
+**Test:** Run the client dev server. Confirm no import errors in the browser console. Confirm both packages appear in `client/package.json` dependencies.
+
+---
+
+### Step 1.2 — Vendor Tesseract assets locally
+
+Copy the following Tesseract runtime assets into `client/public/tesseract/`:
+
+- The Tesseract WASM core file
+- The Tesseract worker script
+- The English language training data file (`eng.traineddata`)
+
+These files must be served from the app itself — no CDN calls at runtime. The app is self-hosted and must work offline.
+
+Configure `tesseract.js` (wherever it is initialized) to point `workerPath`, `corePath`, and `langPath` at `./tesseract/` relative to the app's public root.
+
+**Test:** Disconnect the dev machine from the internet. Load the app. Trigger a Tesseract recognize call on any test image (a photo of text). Confirm it completes successfully without any network requests to jsDelivr, unpkg, or any external CDN. Check the Network tab in DevTools to verify.
+
+---
+
+### Step 1.3 — Add Tesseract assets to service worker cache
+
+In `client/public/sw.js`, add the `tesseract/` directory assets to the app-shell cache list alongside other static assets. Use cache-first strategy for these files — they are static, versioned, and large.
+
+**Test:** Load the app once online. Open DevTools → Application → Cache Storage. Confirm the Tesseract WASM, worker, and `eng.traineddata` files are present in the cache. Disconnect from the internet and reload. Confirm the scanner still initializes without errors.
+
+---
+
+## Phase 2 — Build the preprocessing utility
+
+### Step 2.1 — Create `client/src/lib/ocrPreprocess.js`
+
+Create a new utility module using only the browser canvas API (no third-party image library). It exports a single async function `preprocessImage(file)` that accepts a `File` or `Blob` and returns a processed image blob ready for Tesseract.
+
+The function must perform these steps in order:
+
+1. Decode the file into an `HTMLImageElement` via `URL.createObjectURL`
+2. Draw it onto a canvas, scaling down proportionally if width exceeds 1600px
+3. Read pixel data via `getImageData` and convert each pixel to grayscale (standard luminance formula: `0.299R + 0.587G + 0.114B`)
+4. Apply a binarization pass: convert each grayscale pixel to black (0) or white (255) based on a luminance threshold (start at 128, tune if needed)
+5. Write the result back to the canvas and return it as a PNG `Blob` via `canvas.toBlob`
+
+**Test:** Import the function in a temporary test component. Pass it a phone photo of printed text. Render the output blob in an `` tag. Confirm the result is black-and-white, high-contrast (text clearly black on white background), and no wider than 1600px.
+
+---
+
+### Step 2.2 — Validate preprocessing on a real pedigree photo
+
+Using a real printed pedigree photographed at a slight angle or in non-ideal lighting, run it through `preprocessImage`. Confirm the output is readable.
+
+**Test:** Visually inspect the output image. Text must be legible and clearly distinct from the background. If binarization produces large black blobs obscuring text, lower the threshold value. If text is faint or missing, raise it.
+
+---
+
+## Phase 3 — Build the OCR and layout analysis utilities
+
+### Step 3.1 — Create `client/src/lib/ocrRun.js`
+
+Create a utility module that exports `runOCR(imageBlob)`. It accepts a preprocessed image blob and returns the raw Tesseract result object containing `words` (with bounding boxes and confidence scores) and `lines`.
+
+Initialize Tesseract using the local vendor paths configured in Step 1.2.
+
+Before returning, filter out any word objects with a confidence score below 60.
+
+**Test:** Pass a preprocessed pedigree image into `runOCR`. Log the returned `words` array. Confirm word objects contain `text`, `bbox` (with `x0`, `y0`, `x1`, `y1`), and `confidence`. Confirm low-confidence noise words are absent from the result.
+
+---
+
+### Step 3.2 — Create `client/src/lib/ocrLayout.js` — column detection
+
+Create a utility module for layout analysis. First export: `detectColumns(words, imageWidth)`.
+
+This function divides the image width into 3 equal bands and assigns each word to a column (0 = subject, 1 = parents, 2 = grandparents) based on its `x0` position. Returns an array of three word arrays, one per column.
+
+**Test:** Run `detectColumns` on a word list from a real pedigree OCR result. Log the three column arrays. Confirm the subject column contains the leftmost text, and the grandparent column contains the rightmost text.
+
+---
+
+### Step 3.3 — Add sire/dam split to `ocrLayout.js`
+
+Add export: `splitSireDam(columnWords)`.
+
+For a given column's word array, sort by `y0`. Find the largest vertical gap between adjacent words. Return `{ sire: wordsAboveGap, dam: wordsBelowGap }`.
+
+**Test:** Run `splitSireDam` on the parents column from a real pedigree OCR result. Confirm the top half contains the sire entry and the bottom half contains the dam entry. The split point should fall in the blank space between the two ancestor cells.
+
+---
+
+### Step 3.4 — Add cell text aggregation to `ocrLayout.js`
+
+Add export: `aggregateCellText(words)`.
+
+For a given array of words belonging to one cell, sort by `y0` then `x0`. Group words whose `y0` values are within ~10px of each other onto the same line. Join lines with newline characters.
+
+Returns a plain multiline string representing everything OCR'd in that cell.
+
+**Test:** Run `aggregateCellText` on the sire word array from a real pedigree. Confirm the output reads as natural lines of text matching what is printed in that cell on the pedigree.
+
+---
+
+### Step 3.5 — Create `client/src/lib/ocrParse.js` — field extraction
+
+Create a utility module that exports `parseCellFields(cellText)`.
+
+It accepts a multiline string (output of `aggregateCellText`) and returns:
+
+```
+{
+ farmName: string | null,
+ rabbitName: string | null,
+ earNumber: string | null,
+ dob: string | null, // YYYY-MM-DD or null
+ weight: number | null, // decimal lbs or null
+}
+```
+
+Rules per field:
+
+- **Name (farmName + rabbitName):** Take the first non-label line of the cell. Apply the possessive split: match `^(.+?'s?)\s+(.+)$`. If matched, `farmName` = first capture group, `rabbitName` = second. If no possessive pattern is found, set `farmName = null` and `rabbitName` = the full first line.
+- **Ear number:** Scan all lines for a label (`Tattoo:`, `Ear #:`, `Ear No:`, or similar) followed by a value matching `[A-Z0-9]{2,8}`. Also accept a bare `[A-Z0-9]{2,8}` token on its own line when no label is present.
+- **DOB:** Scan all lines for date patterns: `MM/DD/YYYY`, `MM-DD-YYYY`, `MM/DD/YY`, `M/D/YY`. Convert any match to `YYYY-MM-DD`. Return `null` if no date is found or parsing fails. Do not use `"Unknown"` here — that sentinel is only written at save time.
+- **Weight:** Scan all lines for weight patterns and normalize to a decimal float in lbs: `"8 lbs 4 oz"` → `8.25`, `"8.5 lbs"` → `8.5`, `"8.5"` with a trailing `lbs`/`lb` → `8.5`. Return `null` if no weight is found.
+
+**Test:** Write at least three test cases using real cell text strings extracted from pedigree OCR results:
+1. A cell with all five fields present and labelled
+2. A cell with only name and ear number (DOB and weight absent)
+3. A cell where the name has no possessive (rabbit name only, no farm)
+
+Confirm outputs match expected values for each case.
+
+---
+
+### Step 3.6 — Create `client/src/lib/ocrPipeline.js` — full pipeline orchestrator
+
+Create a utility that exports `extractPedigree(imageBlob)`. It:
+
+1. Calls `runOCR(imageBlob)` to get the word list
+2. Calls `detectColumns` to split words into subject / parents / grandparents columns
+3. Subject column: calls `aggregateCellText` on all words, then `parseCellFields`
+4. Parents column: calls `splitSireDam`, then `aggregateCellText` + `parseCellFields` on each half
+5. Grandparents column: divides into four quadrants at the horizontal and vertical midpoints of the column (top-left = sire_sire, bottom-left = sire_dam, top-right = dam_sire, bottom-right = dam_dam), then `aggregateCellText` + `parseCellFields` on each
+6. Returns the full output shape:
+
+```
+{
+ subject: { farmName, rabbitName, earNumber, dob, weight },
+ sire: { farmName, rabbitName, earNumber, dob, weight },
+ dam: { farmName, rabbitName, earNumber, dob, weight },
+ sire_sire: { farmName, rabbitName, earNumber, dob, weight },
+ sire_dam: { farmName, rabbitName, earNumber, dob, weight },
+ dam_sire: { farmName, rabbitName, earNumber, dob, weight },
+ dam_dam: { farmName, rabbitName, earNumber, dob, weight },
+}
+```
+
+**Test:** Run `extractPedigree` on a preprocessed real pedigree image. Log the output. Confirm all seven keys are present. Confirm at minimum the subject name and sire name are correctly extracted. Null values on sparse fields are acceptable — confirm no crashes or thrown errors.
+
+---
+
+## Phase 4 — Build the OCR UI components
+
+### Step 4.1 — Create `ImageCapture` component
+
+Create `client/src/components/ocr/ImageCapture.jsx`.
+
+Renders a styled button labelled "Scan Pedigree". When clicked, it triggers a hidden file input that accepts images and requests the rear camera on mobile (`accept="image/*" capture="environment"`).
+
+On file selection, calls an `onImage(file)` prop with the selected `File` object. Does not perform any processing — the caller handles that.
+
+**Test:** Mount `ImageCapture` in isolation. Click the button. Confirm the file picker opens. Select an image file. Confirm `onImage` is called with the correct `File` object. In Chrome's mobile device emulator, confirm the camera capture option appears.
+
+---
+
+### Step 4.2 — Create `OCRProcessor` component
+
+Create `client/src/components/ocr/OCRProcessor.jsx`.
+
+Accepts props: `file` (File | null), `onResult({ ocrData, processedBlob })`, `onError(message)`.
+
+When `file` changes to a non-null value, it:
+
+1. Sets internal status to `'preprocessing'`
+2. Calls `preprocessImage(file)` → stores the resulting blob as `processedBlob`
+3. Sets status to `'scanning'`
+4. Calls `extractPedigree(processedBlob)`
+5. Calls `onResult` with `{ ocrData, processedBlob }` — both are needed by the caller (ocrData to build `initialState`, processedBlob to retain for the zone picker)
+6. Sets status to `'done'`
+
+Renders a loading indicator with status-appropriate text while processing (`'preprocessing'` → "Preparing image…", `'scanning'` → "Reading pedigree…"). Renders nothing when idle or done.
+
+On any error, calls `onError` with a human-readable message.
+
+**Test:** Mount `OCRProcessor` with a real pedigree image `File`. Confirm the loading states render in sequence. Confirm `onResult` is called with a non-null `ocrData` object containing the seven ancestor keys and a valid `processedBlob`. Confirm no errors on a clean image.
+
+---
+
+### Step 4.3 — Create `ZonePicker` component
+
+Create `client/src/components/ocr/ZonePicker.jsx`.
+
+Accepts props: `image` (Blob — the **preprocessed** image), `fieldLabel` (string), `onConfirm(rawText)`, `onCancel()`.
+
+There is no `initialCrop` prop — bounding boxes are not retained by `extractPedigree`, so no pre-drawn rectangle is shown. The user always draws the crop region from scratch.
+
+Renders:
+- The preprocessed image displayed via `react-image-crop` with no initial selection
+- A "Re-scan" button that crops the image canvas to the drawn rectangle, runs `runOCR` on the crop, extracts the raw text from the result, and calls `onConfirm(rawText)`
+- A "Cancel" button that calls `onCancel`
+
+`ZonePicker` does not parse or apply field values — it returns raw OCR text only. The caller is responsible for parsing and updating state.
+
+**Test:** Mount `ZonePicker` with a preprocessed pedigree image. Confirm the image renders in the crop UI. Draw a rectangle around a single line of text on the pedigree. Click "Re-scan". Confirm `onConfirm` is called with a string that matches the text in the drawn region.
+
+---
+
+## Phase 5 — Integrate into the Add Rabbit wizard
+
+### Step 5.1 — Add "Scan Pedigree" entry point to Step 1
+
+In `AddRabbit.jsx`, add a "Scan Pedigree" button to Step 1 (the pedigree tree step), above the existing tree controls.
+
+Add the following state to `AddRabbit.jsx`:
+
+- `ocrFile` — the raw `File` selected by the user (null until a scan is triggered)
+- `ocrData` — the structured result from `extractPedigree` (null until OCR completes)
+- `processedBlob` — the preprocessed image blob retained for the zone picker (null until OCR completes)
+- `ocrStatus` — `'idle' | 'preprocessing' | 'scanning' | 'done' | 'error'`
+
+Clicking "Scan Pedigree" renders `ImageCapture`. On file selection, set `ocrFile`. `OCRProcessor` watches `ocrFile` and runs the pipeline. On `onResult`, store `ocrData` and `processedBlob` in state and set `ocrStatus` to `'done'`.
+
+The existing pedigree tree controls remain unchanged and fully functional.
+
+**Test:** Open the Add Rabbit wizard. Confirm "Scan Pedigree" is visible on Step 1. Confirm clicking it reveals the image picker. Confirm selecting an image triggers the OCR pipeline (loading states appear). Confirm the existing tree controls are still usable after the button is added.
+
+---
+
+### Step 5.2 — Build `initialState` from OCR result and mount `PedigreeTree`
+
+`PedigreeTree` must not mount until `initialState` is ready. Block its render while `ocrStatus` is `'preprocessing'` or `'scanning'`.
+
+When `ocrData` is available, build `initialState` for `PedigreeTree` as follows:
+
+**Gen1 (sire/dam) — herd matching (see Step 5.3 for matching logic):**
+- Confident herd match found → seed as `{ mode: 'linked', id: }`
+- No match → seed as `{ mode: 'manual', name: serializeManualAncestor(remapped) }`
+
+**Gen2/gen3 — always manual:**
+- Seed as `{ mode: 'manual', name: serializeManualAncestor(remapped) }`
+
+**Field remapping for `serializeManualAncestor`** (apply to every manual node):
+
+| OCR field | `serializeManualAncestor` field |
+|---|---|
+| `rabbitName` | `name` |
+| `farmName` | `farm` |
+| `earNumber` | `ear_number` |
+| `weight` (null → `0`) | `weight_lbs` |
+| `dob` (null → `"Unknown"`) | `dob` |
+
+Once `initialState` is built, mount `PedigreeTree` with it. If the user rescans, bump `PedigreeTree`'s `key` prop to remount with a fresh `initialState` built from the new `ocrData`.
+
+**Test:** Scan a pedigree. Confirm `PedigreeTree` does not render during preprocessing/scanning. Confirm it mounts with the correct ancestor data once `ocrData` is ready. Rescan with a different pedigree — confirm the tree remounts and shows the new data.
+
+---
+
+### Step 5.3 — Gen1 herd matching
+
+For each of the sire and dam nodes independently, attempt a herd match before building `initialState`:
+
+1. Call `GET /api/rabbits/quick-search?fields=ear_number&q=` (skip if `earNumber` is null)
+2. Filter results client-side to an exact `ear_number` match (case-insensitive)
+3. If exactly one exact match → use that rabbit's id for a linked node
+4. If no exact ear number match → call `GET /api/rabbits/quick-search?fields=name&q=` (skip if `rabbitName` is null)
+5. Filter results client-side to an exact `name` match
+6. If exactly one exact name match → use that rabbit's id for a linked node
+7. If still no match → fall back to a manual node
+
+Ear number takes precedence. Both searches must complete before `initialState` is built and `PedigreeTree` is allowed to mount.
+
+**Test:**
+- Add a rabbit to the herd with a known ear number. Scan a pedigree where that ear number appears as the sire. Confirm the sire slot mounts as a linked node showing the matched rabbit's name.
+- Scan a pedigree where neither sire nor dam exists in the herd. Confirm both gen1 slots mount as manual nodes with the OCR-extracted name and ear number.
+
+---
+
+### Step 5.4 — Wire subject fields into `details` state
+
+When `ocrData` is available, apply the subject fields to the wizard's `details` state slice:
+
+| OCR field | `details` field |
+|---|---|
+| `subject.farmName` | `farm` |
+| `subject.rabbitName` | `name` |
+| `subject.earNumber` | `ear_number` |
+| `subject.dob` | `dob` (already `YYYY-MM-DD`) |
+| `subject.weight` | `weight_lbs` |
+
+For any field where the OCR result is `null`, leave the existing `details` value unchanged. Do not overwrite with null.
+
+Use the "adjusting state during render" pattern (a `useRef` flag) consistent with `AddRabbit.jsx`'s existing one-shot default-fill pattern (e.g. how `details.farm` is seeded from `settings.rabbitry_name`). Do not use a `useEffect` that calls `setState`.
+
+**Test:** Scan a pedigree. Navigate to Step 2. Confirm the subject's farm, name, ear number, DOB, and weight are pre-filled. Confirm null fields are blank (not showing "null"). Confirm already-typed details values are not overwritten by null OCR results.
+
+---
+
+### Step 5.5 — Highlight null OCR fields
+
+After OCR data is applied, any field that received a `null` OCR result should display a yellow background and a small "?" indicator alongside it.
+
+This applies to:
+- Individual fields within each ancestor slot's manual editor in `PedigreeTree`
+- The five subject fields in the Step 2 details form
+
+The highlight is visual only — the field remains fully editable. Remove the highlight when the user types a non-empty value into that field.
+
+**Test:** Scan a pedigree where DOB is absent from one ancestor. Open that ancestor's manual editor. Confirm the DOB field has a yellow highlight and "?" indicator. Type a value. Confirm the highlight disappears.
+
+---
+
+### Step 5.6 — Show OCR status banner
+
+After OCR completes, display a dismissible banner above the pedigree tree on Step 1:
+
+- **Success (some fields populated):** *"Pedigree scanned — review the tree and check highlighted fields before continuing."*
+- **All fields null (no text found):** *"No text detected. Try retaking the photo with better lighting."*
+- **Tesseract error:** *"Couldn't read this pedigree. Check the image quality or enter manually."*
+
+The wizard must remain fully usable for manual entry in all three cases.
+
+**Test:**
+- Scan a clean pedigree. Confirm the success banner appears.
+- Pass a completely black or blank image. Confirm the no-text banner appears.
+- Temporarily move the Tesseract WASM out of `public/tesseract/` to force a load failure. Confirm the error banner appears and the wizard is still usable for manual entry.
+
+---
+
+## Phase 6 — Zone picker correction flow
+
+### Step 6.1 — Add zone picker trigger to ancestor slot editors
+
+In the manual-entry editor for each ancestor slot (the panel that opens when the user clicks a manual node in `PedigreeTree.jsx`), add a small scan/camera icon button next to each of the five fields (farm name, rabbit name, ear number, DOB, weight).
+
+Clicking the icon opens the `ZonePicker` panel. Pass it:
+- `image` — the `processedBlob` from `AddRabbit` state
+Do not pass `initialCrop` — the prop does not exist. The user draws the crop region from scratch.
+- `fieldLabel` — a human-readable label for the field being corrected
+
+Track which field triggered the picker in local state (`activeZoneField`).
+
+The `ZonePicker` panel should render alongside or below the editor, not in a modal, so the user can see both simultaneously.
+
+**Test:** After scanning a pedigree, click an ancestor slot to open its editor. Confirm each field has a scan icon button. Click the scan icon next to the ear number field. Confirm the `ZonePicker` opens showing the preprocessed pedigree image with no pre-drawn selection. Confirm the user can draw a crop rectangle freely.
+
+---
+
+### Step 6.2 — Apply zone picker result to the ancestor editor
+
+When `ZonePicker` calls `onConfirm(rawText)`:
+
+1. Run `rawText` through the appropriate parser from `ocrParse.js` for the active field (ear number regex, possessive name split, date parser, or weight normalizer)
+2. Update the relevant field(s) in the ancestor editor's local state
+3. Close `ZonePicker` (clear `activeZoneField`)
+4. Remove the yellow highlight from the updated field
+
+When `ZonePicker` calls `onCancel`, close the panel without modifying any state.
+
+Note: for a name re-scan, the possessive split may produce both `farmName` and `rabbitName`. Update both fields if the scan icon was on either name field.
+
+**Test:** After scanning a pedigree where ear number was null, click the ear number scan icon. Draw a rectangle over the ear number. Click "Re-scan". Confirm the ear number field updates with the correct value. Confirm the yellow highlight is gone. Confirm no other fields in the editor changed.
+
+---
+
+### Step 6.3 — Add zone picker trigger to Step 2 subject fields
+
+In the Step 2 details form, add scan icon buttons next to the five OCR-populated fields: farm name, rabbit name, ear number, DOB, weight.
+
+These work identically to Steps 6.1–6.2 but update `details` state in `AddRabbit` instead of an ancestor editor's local state.
+
+**Test:** After scanning a pedigree where subject DOB was null (yellow highlight visible on DOB field), navigate to Step 2. Click the scan icon next to DOB. Draw a rectangle over the DOB on the pedigree image. Click "Re-scan". Confirm the DOB field updates with the correct value and the yellow highlight is removed.
+
+---
+
+## Phase 7 — End-to-end validation
+
+### Step 7.1 — Full happy-path scan and save
+
+Using a real printed pedigree (ARBA-format or non-standard):
+
+1. Open Add Rabbit wizard
+2. Click "Scan Pedigree" and photograph or upload the pedigree
+3. Wait for OCR to complete
+4. Review Step 1 — confirm the pedigree tree is populated with ancestor data
+5. Click Next
+6. Review Step 2 — confirm subject fields are pre-filled
+7. Fill in breed and any null fields manually
+8. Save
+
+**Test:** Confirm the saved rabbit appears in the herd with correct name, ear number, and pedigree ancestors. Confirm no console errors during the entire flow.
+
+---
+
+### Step 7.2 — Linked gen1 cascade behavior
+
+Scan a pedigree where the sire exists in the herd (matched by ear number).
+
+**Test:** Confirm the sire slot in the tree shows as a linked node. Confirm the sire's grandparent branch (sire_sire, sire_dam) is locked and derives from the linked rabbit's own stored pedigree — not from the OCR-extracted grandparent text. Confirm the dam branch (if unmatched) retains OCR-extracted manual text.
+
+---
+
+### Step 7.3 — Zone picker correction round-trip
+
+1. Scan a pedigree where at least one field is wrong or null
+2. Use the zone picker to correct that field
+3. Complete the wizard and save
+
+**Test:** Confirm the corrected value — not the original OCR extraction — is saved to the database.
+
+---
+
+### Step 7.4 — Offline scan
+
+1. Load the app once while online
+2. Disconnect from the internet
+3. Complete a full scan and save
+
+**Test:** Confirm OCR completes without network errors. Confirm the saved rabbit appears in the herd. Confirm no console errors.
+
+---
+
+### Step 7.5 — Manual entry fallback after failed scan
+
+1. Scan an unreadable image (blank paper, very dark photo)
+2. Confirm the appropriate error banner appears
+3. Dismiss the banner and complete the wizard via manual entry
+
+**Test:** Confirm the wizard is fully usable for manual entry after a failed scan. Confirm no state corruption from the failed OCR attempt — the tree and details form behave as if no scan was attempted.
+
+---
+
+## Out of Scope (do not implement in this phase)
+
+- Deskew / perspective correction
+- Great-grandparent (4th generation) extraction
+- Handwriting-specific handling
+- Multi-page pedigrees
+- Bulk scanning multiple rabbits at once
diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md
index 946d539..2af9b11 100644
--- a/memory-bank/architecture.md
+++ b/memory-bank/architecture.md
@@ -59,6 +59,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
+| `src/lib/ocrPreprocess.js` | OCR Pedigree Scanning, Phase 2. Canvas-only (no `jimp`) image normalization for OCR. Exports `preprocessImage(file)` — decode → downscale to `MAX_WIDTH` (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG `Blob`. Also exports the tunable `MAX_WIDTH` and `BINARIZE_THRESHOLD` (binarization cutoff, tuned to **103** during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
@@ -72,10 +73,11 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `` tags, applies `font-body` to `` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
-| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. |
+| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. **OCR addition:** the five vendored `/tesseract/` assets (see `public/tesseract/` below) are precached on `install` (a `TESSERACT_URLS` list alongside the existing `SHELL_URLS`, same cache) and served **cache-first** via a dedicated `fetch`-handler branch — they're large/static/versioned, so once stored they're served straight from cache with no network round-trip (the rest of the shell stays network-first). See the OCR-assets convention note below. |
+| `public/tesseract/` | Vendored Tesseract OCR runtime (OCR Phase 1 Step 1.2), served from the app's own origin so the pedigree scanner makes **no CDN calls** and works offline. Five files: `worker.min.js` (the tesseract.js web-worker script); `eng.traineddata` (the **uncompressed** English LSTM language data); and three feature-detect WASM core builds — `tesseract-core-lstm.wasm.js`, `tesseract-core-simd-lstm.wasm.js`, `tesseract-core-relaxedsimd-lstm.wasm.js` (each ~3.9 MB, self-contained — the WASM is base64-embedded in the `.wasm.js`). At runtime tesseract.js (default OEM `LSTM_ONLY`) feature-detects and loads exactly **one** of the three cores; all three are vendored so a directory-based `corePath` auto-selects correctly on any browser. See the OCR-assets convention note below. |
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
-| `Dockerfile` | Builds a Node 20 Alpine image and runs the Vite dev server bound to `0.0.0.0` (required so the container's port mapping works — Vite binds to localhost only by default). |
+| `Dockerfile` | Multi-stage production build: a Node 20 Alpine stage runs `npm ci && npm run build`, then an nginx 1.27 Alpine stage serves the built `dist/` with `nginx.conf` (which reverse-proxies `/api` + `/uploads` to the server container and serves the SPA fallback + `sw.js` no-cache header). The Vite dev server is never run in Docker. |
## Root
@@ -106,4 +108,5 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `