MyKindlebox/memory-bank/architecture.md

35 KiB
Raw Blame History

MyKindlebox — Architecture

Audience: AI developers. This file explains what each file/directory does and why, as the codebase grows. Keep it current — update after each major feature or milestone per CLAUDE.md.


Repo layout

mykindlebox/
  server/    — Express + TypeScript REST API, SQLite via Drizzle ORM
  client/    — React + Vite frontend
docker-compose.yml — orchestrates both services for self-hosted deployment

mykindlebox/server

File Purpose
index.ts Entry point. Creates the Express app, applies cors() (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and express.json(), imports db/client (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001.
db/client.ts Opens the SQLite connection and exports the Drizzle db instance used by all query code. Ensures server/data/ exists before opening the file — better-sqlite3 will not create missing parent directories on its own. The DB file lives at server/data/mykindlebox.db. On startup it now (Step 2.5) calls drizzle-orm/better-sqlite3/migrator's migrate() to apply any pending migrations, then seeds the two default settings rows (rabbitry_name, weight_unit) via raw INSERT OR IGNORE keyed on the key primary key — idempotent across restarts. Also exports dataDir and uploadsDir (data/uploads/, created here) so other modules (routes/rabbits.ts's multer config, index.ts's static file serving) share one canonical path instead of each recomputing it.
db/schema.ts Drizzle schema definitions: rabbits (24 base fields, 7 genetics loci columns, 14 chain-authority _name_text ancestor columns; sex/status enforced via SQLite CHECK constraints, not just TS union types; sire_id/dam_id self-referencing FKs), litters (sire_id/dam_id FKs to rabbits.id, breed_date, kindle_date, kit_count, notes, created_at — a litter row spans the breeding→kindle lifecycle: breed_date is set when a breeding is recorded, kindle_date is nullable until the doe kindles; at least one of the two is required, enforced in the route), and settings (key text primary key, value text — simple key-value store, not a typed config object).
db/migrations/ Generated by drizzle-kit generate, applied either manually via drizzle-kit migrate (CLI) or automatically on server startup via db/client.ts's in-code migrate() call. Both share the same __drizzle_migrations tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (0000_modern_power_pack.sql — rabbits, 0001_military_lord_tyger.sql — litters, 0002_luxuriant_the_stranger.sql — settings) — these are committed and intentionally not excluded by .dockerignore (only node_modules and data are).
drizzle.config.ts Points drizzle-kit at ./db/schema.ts for migration generation and ./data/mykindlebox.db as the target DB.
tsconfig.json TypeScript config: CommonJS module output (matches package.json's "type": "commonjs"), strict mode on, compiles to dist/.
package.json scripts dev — tsx watch index.ts (hot-reload local dev, no separate build step needed). build — tsc compiles to dist/. start — runs the compiled output, used in production/Docker.
Dockerfile Builds a Node 20 Alpine image, installs deps, runs npm run dev (tsx) inside the container — not yet switched to a production build for the container; revisit when deploying for real.
.dockerignore Excludes node_modules and data/ from the Docker build context.
routes/rabbits.ts Express Router mounted at /api/rabbits in index.ts. GET / (list, with sex/status/search/sort filters — sex=kits is a special case matching any sex with dob in the last 56 days), GET /search (autocomplete by substring on name only, must be registered before GET /:id), GET /quick-search (the global top-of-page quick-search bar — distinct from /search: a prefix match — "wildcard at the end" — across a chooseable subset of farm/name/ear_number/color via ?fields= comma-list, defaulting to all four; returns each hit plus a matched_fields array re-derived in JS so the dropdown can label which filter the row flagged on. Empty q or no valid fields → []. Also must be registered before GET /:id), GET /:id (404 if missing), POST / (validates name, 201 on success), PATCH /:id (partial update, refreshes updated_at), DELETE /:id (nulls referencing sire_id/dam_id before deleting), GET /:id/ancestors (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries id/name/farm/sex/ear_number/weight_lbs/dob/color_name — ear_number/weight_lbs/dob were added so the printable pedigree slip can show full per-ancestor detail), POST /:id/photo (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so fileFilter rejections become this route's own 400 rather than the global error handler's 500).
routes/litters.ts Express Router mounted at /api/litters. GET / and GET /:id join rabbits twice (via alias()) to embed sire/dam { id, name }. GET /:id derives its kits list from rabbits matching sire_id/dam_id/dob (the same no-Kit-table derivation the design doc specifies elsewhere). POST / (requires a breed_date or kindle_date; only generates kits when a kindle_date is present), PATCH /:id, DELETE /:id. Recording a breeding (POST with breed_date, no kindle_date) creates a litter-in-progress with no kits yet.
routes/settings.ts Express Router mounted at /api/settings. GET / flattens the settings table into a single object; PATCH / rejects any request containing a key outside the rabbitry_name/weight_unit allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly.
routes/stats.ts Express Router mounted at /api/stats. Returns dashboard counts (herd, bucks, does, kits, for_sale, on_hold, sold, archived) computed live from rabbits on every request — no cached counters. kits reuses the same 56-day dob window as the rabbits list's sex=kits filter; archived sums status IN ('other', 'deceased').
routes/import.ts Express Router mounted at /api/import (behind requireAuth). POST / accepts a single multipart file (multer memory storage, 10 MB cap) and replaces the entire herd: it wipes litters then rabbits and inserts the file's rabbits, all inside one db.transaction so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: legacy CSV (the old PHP Kindlebox export — header has ID/GENDER/SIRE_ID), native CSV (snake_case headers matching the API), and JSON (a rabbit array or { rabbits: [...] }). Legacy mapping: GENDER 1→buck/0→doe; numeric STATUS via LEGACY_STATUS_MAP (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original editrabbit.php status <select>); "0"/blank sentinels for ear/registration numbers → null; WEIGHT of 0 → null (unweighed); gene columns → locus_*. Parentage is preserved by importing original ids and a two-pass insert (insert all rows with null sire_id/dam_id, then set the FK only when it points at an id actually present in the file — legacy 9999 "unknown parent" sentinels and references to non-imported rabbits become null). Includes a hand-rolled RFC-4180-ish parseCsv (quoted fields, "" escapes, CR/LF, BOM strip) rather than adding a CSV dependency, matching the project's dependency-light ethos. Returns { format, rabbits_imported }. Scope is rabbits only (per user decision) — litters are wiped but not reconstructed from the legacy LITTER_ID column. Rejects non-UTF-8 input early (file.buffer.includes(0) — a NUL byte means a UTF-16 Excel "Unicode Text" export or a binary .xlsx/image) with an actionable message, since decoding such a file as UTF-8 would otherwise import garbage/phantom rows. The Settings page also pre-validates extension + 10 MB size before uploading, because an over-limit file is rejected by multer mid-stream and the browser surfaces that as an opaque "network error" rather than the server's 400.
utils/case.ts toSnakeCase/toCamelCase — generic recursive key-casing converters used at the API boundary. See casing convention below.

Why TypeScript: the implementation plan specifies db/client.ts for the Drizzle setup. The server started as plain CommonJS JS in Step 1.2; it was converted to TypeScript at Step 2.1 specifically so the schema/DB layer has type safety, per explicit user decision (plain JS was the lower-effort alternative but not chosen).

mykindlebox/client

File Purpose
src/App.jsx Root component. Wraps the app in QueryClientProvider (React Query) and BrowserRouter, defining every IA route nested under a single Layout layout-route (<Route element={<Layout />}>) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all * → NotFound route.
src/api.js Plain JS (not .ts — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, settings, stats, export, importData which POSTs a FormData file to /api/import), all targeting http://localhost:3001/api. Shared request() helper throws on non-2xx (surfacing the JSON { error } body's message) and only sets Content-Type: application/json when the body is a JSON string, so the multipart FormData photo-upload call isn't given the wrong header.
src/components/Layout.jsx Persistent app shell rendered via React Router's <Outlet />. Desktop: 240px left sidebar (hidden md:flex) with NavLinks for Dashboard/Herd/Litters/Genetics/Settings. Mobile (≤768px): fixed-bottom nav (md:hidden) with Dashboard/Herd/Litters/Add (→ /herd/add)/Settings. Active-route styling comes from NavLink's isActive callback, not manual path matching. Also renders <QuickSearch /> at the top of the <main> content area (above <Outlet />), so the quick-search bar appears on every page.
src/components/QuickSearch.jsx Global quick-search bar (top of every page via Layout). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), all selected by default, controlling which fields GET /api/rabbits/quick-search prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per matched_fields entry (value-bearing chips for ear/color, e.g. Ear #: A15). Selecting a result navigates to /herd/:id. Reuses lib/keyboardNav.js's handleSearchInputKeyDown/handleResultKeyDown for the same <ul><li><button> roving-focus dropdown shape as RabbitAutocomplete/PedigreeTree. The debounce effect deliberately does not call setState synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show.
src/components/Button.jsx variant (primary/secondary/danger) × size (sm/md) class-map component.
src/components/Badge.jsx Status → color/label class map covering all six Rabbit status values; unrecognized values fall back to the other style rather than crashing.
src/components/Card.jsx Bordered surface-raised container with shadow; just forwards className/props.
src/components/PageHeader.jsx Title + optional subtitle + optional right-side action slot.
src/components/EmptyState.jsx Centered title + optional description + optional action, used by list pages with no results.
src/pages/*.jsx One file per IA route (Dashboard, Herd, AddRabbit, RabbitDetail, EditRabbit, Litters, AddLitter, LitterDetail, Genetics, Settings, NotFound). Dashboard.jsx is built out (Phase 5): a stats bar (8 Card tiles from GET /api/stats, each linking to /herd with a sex or status query param), a quick-actions row (Buttons linking to /herd/add, /litters/add, /herd), and a two-column (md:grid-cols-2) widget row with Recent Litters (top 3 by kindle_date from GET /api/litters) and Upcoming Birthdays (a user-requested addition not in the original plan — computed client-side from GET /api/rabbits?status=active's dob field, no backend support; see the "Upcoming Birthdays" convention note below). Herd.jsx is built out (Phase 6): a sex/sort/search/view filter bar synced to URL query params (?sex=&sort=&q=&view=), and a RabbitGrid/RabbitListView pair sharing one useQuery over GET /api/rabbits — see the Herd filtering convention note below. AddRabbit.jsx (Phase 7) is a 3-step wizard (Pedigree → Details+Genetics → Review) composing the Step 7.1–7.4 components, holding details/genetics/pedigreeFields state and calling createRabbit then (if a photo was picked) uploadRabbitPhoto on save. EditRabbit.jsx (Phase 7) is a single page with all three sections visible, pre-populated from GET /api/rabbits/:id + GET /api/rabbits/:id/ancestors, sending only the changed fields via PATCH — see the pedigree convention note below for why it must wait on the ancestors fetch before mounting PedigreeTree. All other pages remain placeholders rendering just a heading — real content for them starts in Phase 8+.
src/pages/PedigreePrint.jsx Standalone print-optimized 4-generation pedigree slip at /herd/:id/pedigree, deliberately routed outside <Layout> (in App.jsx) so the sidebar/bottom-nav don't render or print. Reuses GET /api/rabbits/:id/ancestors + GET /api/settings (rabbitry name) + getRabbit. Modeled on the original Kindlebox PHP slip. Renders a rabbitry header band (rabbitry name, subject farm/name, printed-on date) above a CSS-grid bracket layout (subject column + parents + grandparents + great-grandparents, each generation's row-spans aligning it against the next). The leftmost column stacks a fill-in Sold To block, the subject box (color, ear number, DOB, weight, sex), and a certification + Signed line — mirroring the old slip. Each ancestor box shows farm+name, color, ear, weight, and DOB via a nodeInfo() helper that normalizes linked DB nodes, manual-JSON nodes (parsed via parseManualAncestor), and missing nodes (→ "Unknown"); fmtDob renders MM-DD-YYYY (or "Unknown"), fmtWeight omits unset/zero weights. The user produces the PDF via the browser's Print → Save as PDF — browser print was the chosen method over a bundled PDF library to keep the self-hosted footprint dependency-free. A component-scoped <style> block sets @page { size: landscape } (scoped to this mounted page so it doesn't leak to other routes), hides the on-screen Back/Print buttons (.no-print) when printing, and forces print-color-adjust: exact so the accent header band prints. A "Print Pedigree" button on RabbitDetail.jsx's action bar links here.
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/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.
src/components/RabbitDetailsForm.jsx Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a weightUnit prop; photo selection is reported via a separate onPhotoChange(file) callback since it's always a second request (uploadRabbitPhoto) after the rabbit row exists.
src/components/GeneticsForm.jsx Collapsible pill-group genetics section (Step 7.3) over lib/genetics.js's LOCI config, with a read-only generated color string field.
src/components/ReviewStep.jsx Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared dash() helper for empty values.
src/lib/theme.js Theme registry (post-Phase-11 feature) — the single source of truth for the list of selectable themes (classic/midnight/ocean/berry/sunset/carnival), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome theme-color. The actual palettes are CSS variables in index.css (one [data-theme='…'] block per theme); this module only knows ids + preview metadata. applyTheme(id) sets document.documentElement.dataset.theme (which swaps the variables), updates the theme-color meta, and mirrors the id to localStorage (key mykindlebox-theme). normalizeTheme falls back to classic for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below.
src/lib/keyboardNav.js handleSearchInputKeyDown/handleResultKeyDown (Phase 11 Step 11.4) — arrow-key roving focus for the <ul><li><button> search-results dropdown shape shared by PedigreeTree.jsx's AncestorSlot and RabbitAutocomplete.jsx. ArrowDown/ArrowUp move between the input and result buttons, Escape closes the dropdown and refocuses its trigger button.
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 <link> tags, applies font-body to <body> 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.
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).

Root

File Purpose
docker-compose.yml Two services, server (port 3001) and client (port 5173), each built from their own Dockerfile. A named volume server-data is mounted at /app/data in the server container so the SQLite file persists across container rebuilds. Not yet validated — Docker isn't installed on the dev machine; correctness here is unconfirmed until someone runs docker compose up.

Key conventions to remember when extending this code

  • Chain-authority rule (pedigree ancestors): a _name_text freeform column is only ever written/read when the immediate parent slot has no FK. Once a chain resolves through a real DB record, all deeper data comes from that ancestor's own columns, never the subject's flat columns. This is encoded in the rabbits schema as 14 plain nullable text columns (Step 2.2) — the DB schema itself doesn't enforce the rule, application code must. Implemented in routes/rabbits.ts's resolveAncestorNode(record, path): it walks a direction path (['sire','dam',...]) one step at a time, recursing into the FK'd DB row whenever one exists for the current step, and only consulting the current record's flat column when the path still has remaining depth but no FK to recurse through. Because recursion always switches the "current record" to the fetched ancestor, the original root's flat columns become structurally unreachable as soon as any FK in the path resolves — there's no separate "is broken" flag to get out of sync. Any future write path (the rabbit details/pedigree form, Phase 7) must preserve this: write a _name_text column only when the immediate parent FK for that slot is null, and clear deeper-generation flat columns for a branch once a FK is set for an ancestor in that branch (or at minimum, never read them — the read path already ignores them correctly).
  • Migrations are file-based via drizzle-kit, not auto-generated at runtime. After editing db/schema.ts, run npx drizzle-kit generate (writes a new SQL file to db/migrations/). Applying them happens automatically on server startup (db/client.ts calls migrate()) — running npx drizzle-kit migrate by hand also works and shares the same tracking table, but is no longer required day-to-day.
  • No separate Kit table — kits are full Rabbit rows; a litter's kit list is derived by matching sire_id/dam_id/dob against the litter, not via a join table.
  • Settings is a simple key-value table (settings), not a typed config object in code. Seeded with rabbitry_name/weight_unit defaults on every startup via INSERT OR IGNORE.
  • API JSON casing: Drizzle schema field names are camelCase (idiomatic JS/TS), but the REST API's request/response JSON bodies are snake_case, matching the design doc and implementation plan's literal examples (weight_lbs, sire_id, etc.). Every route handler must convert: incoming bodies via toCamelCase before passing to Drizzle, outgoing rows via toSnakeCase before res.json(). Don't res.json() a raw Drizzle row.
  • Upcoming Birthdays (Dashboard widget, added post-Phase-5, not in the implementation plan or design doc): there is intentionally no /api/birthdays endpoint. Dashboard.jsx's getUpcomingBirthdays() fetches active rabbits and computes the next occurrence of each dob's month/day client-side, windowed to 30 days. If this logic is ever needed elsewhere (e.g. a notifications feature), lift it into api.js or a backend endpoint rather than duplicating the date math.
  • Herd "Archived" filter: ?status=archived is a frontend-only convenience value, not a real status column value — it mirrors the same other+deceased grouping routes/stats.ts uses for the Dashboard's Archived count. Herd.jsx strips status=archived before calling the backend (which only does exact-match status filtering) and instead filters the response client-side to status IN ('other', 'deceased'). Any other status value passed in the URL goes straight to the backend unmodified.
  • URL-synced debounced inputs: don't sync a prop into local state via a useEffect that calls setState — this project's ESLint config (react-hooks/set-state-in-effect) treats that as an error. Herd.jsx's SearchBox is the established pattern: give the input component key={externalValue} from the parent so an external change (e.g. browser back/forward updating the URL) remounts the component and re-initializes its local state from props, instead of reconciling it via an effect.
  • One-shot default-fill from async data: when a query result should seed local state exactly once (e.g. AddRabbit.jsx defaulting details.farm from settings.rabbitry_name, or EditRabbit.jsx seeding details/genetics from the loaded rabbit), call setState directly in the render body guarded by a condition that becomes false after the first call (a useRef flag, or details === null) — this is the React-docs-sanctioned "adjusting state during render" pattern, not the banned useEffect-based prop-sync. It's distinct from the SearchBox case above because there's nothing to re-sync on every external change, only a single seed.
  • Pedigree chain-authority on the client (lib/pedigree.js, used by PedigreeTree.jsx/AddRabbit.jsx/EditRabbit.jsx): mirrors the backend's resolveAncestorNode (see the server-side note above) via resolveEffectiveNode(path, state, cascadeCache) — a node is "locked" (non-interactive, cascade-derived) whenever its immediate parent's effective node is mode: 'linked', sourcing its value from that ancestor's own AncestorNode subtree (fetched via GET /api/rabbits/:id/ancestors, cached by id) instead of any state on the subject. Because the rabbits table has no FK column below sire_id/dam_id, an editable gen2/gen3 node's "Search existing" option can only ever save freeform text (the matched rabbit's name) — there is no column to link it to. When seeding PedigreeTree for editing, EditRabbit.jsx must wait for the ancestors fetch to resolve before mounting it: a linked gen1 node's display name comes from ancestors.sire.name/ancestors.dam.name, not from the raw rabbit record (which only has sire_id, no name) — mounting PedigreeTree early renders a blank name for an otherwise-correctly-linked Sire/Dam (this was caught by Playwright during Step 7.6 validation, see progress.md).
  • Manually-entered ancestors store structured data, not a bare name (per explicit user request, post-Phase-7): each _name_text column is still a single text column (no schema migration), but "Enter manually" now collects Farm Name, Rabbit Name, and Ear Number (all required — the Save button is disabled until all three are non-empty) plus Weight (defaults to 0) and DOB (defaults to the literal string "Unknown", since a real ancestor record's actual DOB is virtually never known). These five fields are JSON-encoded into the _name_text column via serializeManualAncestor. Every place that reads a manual node's name for display (the PedigreeTree slot button/locked-node text, buildPedigreeSummary's display field used by ReviewStep) must run it through formatManualAncestorDisplay first, which renders "Name (Farm) • Ear #X" — never render node.name raw for a manual node, it's JSON. parseManualAncestor is defensive: a plain non-JSON string (a legacy entry, or hand-edited in the DB) is treated as just the name field with the other four defaulted, so old data doesn't break. Re-opening an already-manual node's editor (PedigreeTree.jsx's button onClick) calls parseManualAncestor to restore all five fields for further editing, rather than starting blank.
    • DOB is a date picker + separate "Unknown" checkbox in the form, but one string in storage: the manual-entry form's local state keeps dob (a real date-input value) and dobUnknown (the checkbox, checked by default) as two separate fields — they only collapse into the single stored string ("Unknown" or the real date) at save time. manualFormFromParsed(parsed) is the inverse: given a parsed ancestor record, it sets dobUnknown: true and blanks the date input whenever the stored dob is "Unknown" (or missing), otherwise populates the date input and unchecks the box. Don't bind the date <input type="date"> directly to a value that might be the string "Unknown" — it's not a valid date and the input will silently show blank, which is why the split exists.
    • Weight and DOB need real <label> elements, not placeholders: every other manual-entry field (Farm/Name/Ear Number) relies on placeholder text since they start empty, but Weight always has a default value (0) and type="date" inputs don't reliably render placeholders at all — a placeholder attribute on either is effectively invisible. Use a <label> above the input instead.
  • Focus ring is a global CSS rule, not per-component classes (Phase 11 Step 11.4): index.css's :focus-visible { outline: 2px solid theme('colors.accent') !important; ... } is the single source of the accent-colored keyboard focus ring across the whole app. The !important is required — many inputs/buttons already carry focus:outline-none (paired with their own focus:ring-2), which has higher CSS specificity than a bare :focus-visible selector and would otherwise win, leaving keyboard focus invisible on exactly those elements. Don't try to remove this rule or "clean it up" into per-component focus-visible: utility classes without re-auditing every interactive element for a visible ring first.
  • Search-dropdown keyboard nav lives in lib/keyboardNav.js, not duplicated per component: PedigreeTree.jsx's AncestorSlot and RabbitAutocomplete.jsx both render an identical <input> + <ul><li><button> results dropdown. Both wire their onKeyDown handlers to the same handleSearchInputKeyDown/handleResultKeyDown exports rather than each implementing their own arrow-key logic. Any third component with this same dropdown shape should reuse these instead of re-deriving roving focus.
  • Theming is CSS-variable swaps driven by data-theme, not a JS theme object (post-Phase-11 feature): the ten design tokens in tailwind.config.js resolve to CSS variables (var(--color-surface) etc.) rather than literal hex. src/index.css defines each theme as a [data-theme='…'] block overriding those ten variables (:root = the default Classic palette); setting document.documentElement.dataset.theme re-themes every existing utility class with no markup changes, which is why adding a theme is purely: a CSS block in index.css + an entry in src/lib/theme.js's THEMES array (keep the two id lists in sync, including the inline allowlist array in index.html). No alpha modifiers (bg-accent/20) are used on theme tokens, so the simple var(--x) format suffices — if you ever need one, the variables must switch to space-separated RGB channels + the rgb(var(--x) / <alpha-value>) format. Persistence is dual: the backend settings.theme row (added to routes/settings.ts's ALLOWED_KEYS and seeded 'classic' in db/client.ts) is the cross-device source of truth; localStorage is only a fast-path cache so the inline <script> in index.html can apply the last-known theme before first paint (avoiding a flash of the default palette). App.jsx's ThemeSync (a render-nothing child of the providers) reconciles the document with settings.theme once that query resolves. Settings.jsx's picker calls applyTheme on click for instant live preview, but only Save persists it to the backend — an un-saved preview lingers in localStorage/the current tab until the next backend reconcile, which is acceptable. The :focus-visible rule's theme('colors.accent') now compiles to var(--color-accent), so the focus ring themes correctly too.
    • 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.
  • Offline handling is one app-wide banner, not per-query state: Layout.jsx's OfflineBanner listens to the online/offline window events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own isError fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" within a specific page's content (not just a banner), that's new logic, not an extension of this banner.