Polish layout for mobile and desktop

- Mobile bottom nav: icons, 56px targets, safe-area padding, and a
  "More" menu so Genetics/Shows/Settings are reachable on phones
- Sticky sidebar, max-width content column, min-w-0 so wide children
  (pedigree tree, tables) scroll in place instead of widening the page
- PageHeader owns no gutter and stacks actions on small screens; pages
  share one flex/gap structure and a consistent loading header
- 16px inputs on mobile to stop iOS focus zoom; disabled button styling
- Remove placeholder "0 litters / 0 kits" on rabbit cards, duplicate
  name on Rabbit Detail; proper Genetics placeholder and 404 pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-28 09:42:14 -04:00
parent 9d45911934
commit afff8388b0
23 changed files with 300 additions and 100 deletions

View File

@ -45,12 +45,12 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|---|---| |---|---|
| `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/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, events CRUD (`getEvents`/`createEvent`/`updateEvent`/`deleteEvent`), settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`, shows (`getArbaShows`/`refreshArbaShows`), show events (`getShowEvents`/`getShowEvent`/`createShowEvent`/`updateShowEvent`/`deleteShowEvent`/`addShowToEvent`/`removeShowFromEvent`), show entries + report (`addShowEntry`/`updateShowEntry`/`removeShowEntry`/`getEventReport`), judges + placements (`addShowJudge`/`updateShowJudge`/`removeShowJudge`/`setShowEntryPlacement`)), 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/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, events CRUD (`getEvents`/`createEvent`/`updateEvent`/`deleteEvent`), settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`, shows (`getArbaShows`/`refreshArbaShows`), show events (`getShowEvents`/`getShowEvent`/`createShowEvent`/`updateShowEvent`/`deleteShowEvent`/`addShowToEvent`/`removeShowFromEvent`), show entries + report (`addShowEntry`/`updateShowEntry`/`removeShowEntry`/`getEventReport`), judges + placements (`addShowJudge`/`updateShowJudge`/`removeShowJudge`/`setShowEntryPlacement`)), 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 `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/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/Layout.jsx` | Persistent app shell rendered via React Router's `<Outlet />`. Desktop: 240px left sidebar (`hidden md:flex`) with `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`, icon + label, 56px tall, padded by `env(safe-area-inset-bottom)`) with Dashboard/Herd/Add (→ `/herd/add`)/Litters/More — "More" opens a small menu for Genetics/Shows/Settings so every page is reachable on a phone. The sidebar is `sticky` and the content column is `min-w-0` + `max-w-6xl`, so wide children (tables, the 900px pedigree tree) scroll inside their own containers instead of stretching the page. `<main>` owns the page gutter (`px-4 md:px-8`); pages must not add their own horizontal padding. 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/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/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/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/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/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. No horizontal padding (the Layout gutter supplies it); stacks the action row under the title below `sm`. Pages render it as the first child of a `flex flex-col gap-6` root, and loading states render `<PageHeader title=… subtitle="Loading…" />`. |
| `src/components/EmptyState.jsx` | Centered title + optional description + optional action, used by list pages with no results. | | `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 (`Button`s 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/*.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 (`Button`s 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/Shows.jsx` | The `/shows` ARBA show browser (Shows feature Phase 1 — browse-only, no event/entry actions yet). One `useQuery(['arba-shows'])` over `getArbaShows`; a "Refresh Data" button mutates via `refreshArbaShows` and writes the result straight into the query cache (`setQueryData`). All filtering/pagination is **client-side** over the loaded list: State/District/Show Type multi-selects (an inline `MultiSelect` checkbox-popover component with outside-click close; State/District options derived from the data, Show Type a fixed 5-bucket list matching `lib/arba.ts`'s `ShowType`), Date From (defaults to today) / Date To, and a keyword box (club/city/secretary). 50 rows/page. Reads the snake_case show fields the API returns (`raw_date`, `club_name`, `show_type`/`show_type_full`, `special_breed`, `maps_url`, `catalog_url`, etc.). | | `src/pages/Shows.jsx` | The `/shows` ARBA show browser (Shows feature Phase 1 — browse-only, no event/entry actions yet). One `useQuery(['arba-shows'])` over `getArbaShows`; a "Refresh Data" button mutates via `refreshArbaShows` and writes the result straight into the query cache (`setQueryData`). All filtering/pagination is **client-side** over the loaded list: State/District/Show Type multi-selects (an inline `MultiSelect` checkbox-popover component with outside-click close; State/District options derived from the data, Show Type a fixed 5-bucket list matching `lib/arba.ts`'s `ShowType`), Date From (defaults to today) / Date To, and a keyword box (club/city/secretary). 50 rows/page. Reads the snake_case show fields the API returns (`raw_date`, `club_name`, `show_type`/`show_type_full`, `special_breed`, `maps_url`, `catalog_url`, etc.). |

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4A6741" /> <meta name="theme-color" content="#4A6741" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- Apply the cached theme before first paint to avoid a flash of the <!-- Apply the cached theme before first paint to avoid a flash of the
default palette. The backend `settings.theme` is the source of truth default palette. The backend `settings.theme` is the source of truth
(synced across devices) and reconciles this once it loads; this just (synced across devices) and reconciles this once it loads; this just

View File

@ -4,15 +4,17 @@ const VARIANT_CLASSES = {
danger: 'bg-danger text-surface hover:opacity-90', danger: 'bg-danger text-surface hover:opacity-90',
} }
// Heights hit ~40px (sm) / 44px (md) on touch screens; sm relaxes to a denser
// 32px on desktop where pointer precision allows it.
const SIZE_CLASSES = { const SIZE_CLASSES = {
sm: 'px-3 py-1.5 text-sm', sm: 'min-h-[40px] px-3 py-1.5 text-sm md:min-h-[32px]',
md: 'px-4 py-2 text-base', md: 'min-h-[44px] px-4 py-2 text-base md:min-h-[40px]',
} }
function Button({ variant = 'primary', size = 'md', className = '', ...props }) { function Button({ variant = 'primary', size = 'md', className = '', ...props }) {
return ( return (
<button <button
className={`rounded-md font-body font-medium transition-colors ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`} className={`inline-flex items-center justify-center rounded-md font-body font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
{...props} {...props}
/> />
) )

View File

@ -1,14 +1,93 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet, useLocation } from 'react-router-dom'
import QuickSearch from './QuickSearch' import QuickSearch from './QuickSearch'
// Stroke icons shared by the sidebar and the mobile bottom nav. Kept inline
// (no icon library) to match the other hand-rolled SVGs in the app.
function Icon({ name, className = 'h-5 w-5' }) {
const paths = {
dashboard: (
<>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" />
</>
),
herd: (
<>
<circle cx="9" cy="8" r="3.5" />
<path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5" />
<path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.7.8 2.7 2.5 3 5.2" />
</>
),
litters: (
<>
<path d="M4 10h16v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-8Z" />
<path d="M2.5 10h19M8 6.5c0-1.4 1.8-2.5 4-2.5s4 1.1 4 2.5" />
</>
),
genetics: (
<path d="M7 3c0 6 10 6 10 12 0 3-2 5-2 6M17 3c0 6-10 6-10 12 0 3 2 5 2 6M8.5 7h7M8.5 17h7" />
),
shows: (
<>
<circle cx="12" cy="9" r="5.5" />
<path d="m9 14-1.5 7 4.5-2.5 4.5 2.5L15 14" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</>
),
add: <path d="M12 5v14M5 12h14" />,
more: (
<>
<circle cx="5" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
</>
),
}
return (
<svg
viewBox="0 0 24 24"
className={className}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{paths[name]}
</svg>
)
}
const NAV_ITEMS = [
{ to: '/', label: 'Dashboard', icon: 'dashboard', end: true },
{ to: '/herd', label: 'Herd', icon: 'herd' },
{ to: '/litters', label: 'Litters', icon: 'litters' },
{ to: '/genetics', label: 'Genetics', icon: 'genetics' },
{ to: '/shows', label: 'Shows', icon: 'shows' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
]
// Mobile has room for five tabs. Dashboard/Herd/Add/Litters are the barn-side
// essentials; everything else lives behind "More" so no page is unreachable on
// a phone (Shows and Genetics previously had no mobile entry point at all).
const MORE_ITEMS = NAV_ITEMS.filter((item) => ['/genetics', '/shows', '/settings'].includes(item.to))
const navLinkClass = ({ isActive }) => const navLinkClass = ({ isActive }) =>
`block rounded-md px-3 py-2 font-body text-sm ${ `flex items-center gap-3 rounded-md px-3 py-2 font-body text-sm ${
isActive ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken' isActive ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
}` }`
const bottomNavLinkClass = ({ isActive }) => const bottomNavLinkClass = ({ isActive }) =>
`flex flex-1 flex-col items-center justify-center py-2 text-xs ${ `flex min-h-[56px] flex-1 flex-col items-center justify-center gap-1 font-body text-[11px] font-medium ${
isActive ? 'text-accent' : 'text-text-secondary' isActive ? 'text-accent' : 'text-text-secondary'
}` }`
@ -37,62 +116,130 @@ function OfflineBanner() {
if (!isOffline) return null if (!isOffline) return null
// Sticky in the content column (not fixed over the viewport) so it pushes
// content down instead of covering the page title / quick search.
return ( return (
<div className="fixed inset-x-0 top-0 z-20 bg-danger px-4 py-2 text-center font-body text-sm text-surface"> <div role="status" className="sticky top-0 z-20 bg-danger px-4 py-2 text-center font-body text-sm text-surface">
You&rsquo;re offline. Showing cached data where available. You&rsquo;re offline. Showing cached data where available.
</div> </div>
) )
} }
function MoreMenu() {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const { pathname } = useLocation()
const isActive = MORE_ITEMS.some((item) => pathname.startsWith(item.to))
// Closes on outside tap / Escape. Closing on navigation is handled by the
// parent remounting this component with `key={pathname}`.
useEffect(() => {
if (!open) return
function onPointerDown(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
function onKeyDown(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
return (
<div ref={ref} className="relative flex flex-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
className={bottomNavLinkClass({ isActive: isActive || open })}
>
<Icon name="more" />
More
</button>
{open && (
<div
role="menu"
className="absolute bottom-full right-2 mb-2 w-48 rounded-lg border border-border bg-surface-raised p-1 shadow-lg"
>
{MORE_ITEMS.map((item) => (
<NavLink key={item.to} to={item.to} role="menuitem" className={navLinkClass}>
<Icon name={item.icon} />
{item.label}
</NavLink>
))}
</div>
)}
</div>
)
}
function Layout() { function Layout() {
const { pathname } = useLocation()
// Herd tab covers the herd list and rabbit pages, but not /herd/add, which
// has its own tab — NavLink's prefix matching can't express that exclusion.
const herdActive = pathname.startsWith('/herd') && pathname !== '/herd/add'
return ( return (
<div className="flex min-h-screen bg-surface text-text-primary"> <div className="flex min-h-screen bg-surface text-text-primary">
<OfflineBanner /> <aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col gap-1 border-r border-border p-4 md:flex">
<aside className="hidden w-60 shrink-0 flex-col gap-1 border-r border-border p-4 md:flex"> <p className="mb-4 px-3 font-display text-xl font-bold text-text-primary">MyKindlebox</p>
<h1 className="mb-4 font-display text-xl">MyKindlebox</h1>
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
<NavLink to="/" end className={navLinkClass}> {NAV_ITEMS.map((item) => (
Dashboard <NavLink key={item.to} to={item.to} end={item.end} className={navLinkClass}>
</NavLink> <Icon name={item.icon} />
<NavLink to="/herd" className={navLinkClass}> {item.label}
Herd
</NavLink>
<NavLink to="/litters" className={navLinkClass}>
Litters
</NavLink>
<NavLink to="/genetics" className={navLinkClass}>
Genetics
</NavLink>
<NavLink to="/shows" className={navLinkClass}>
Shows
</NavLink>
<NavLink to="/settings" className={navLinkClass}>
Settings
</NavLink> </NavLink>
))}
</nav> </nav>
</aside> </aside>
<main className="flex-1 p-4 pb-20 md:p-8 md:pb-8"> {/* min-w-0 lets wide children (tables, the pedigree tree) scroll inside
their own containers instead of stretching the whole page sideways. */}
<div className="flex min-w-0 flex-1 flex-col">
<OfflineBanner />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(5rem+env(safe-area-inset-bottom))] pt-4 md:px-8 md:pb-8 md:pt-6">
<p className="mb-3 font-display text-lg font-bold text-text-primary md:hidden">MyKindlebox</p>
<QuickSearch /> <QuickSearch />
<Outlet /> <Outlet />
</main> </main>
</div>
<nav className="fixed inset-x-0 bottom-0 flex border-t border-border bg-surface-raised md:hidden"> <nav
aria-label="Primary"
className="fixed inset-x-0 bottom-0 z-20 flex border-t border-border bg-surface-raised pb-[env(safe-area-inset-bottom)] md:hidden"
>
<NavLink to="/" end className={bottomNavLinkClass}> <NavLink to="/" end className={bottomNavLinkClass}>
<Icon name="dashboard" />
Dashboard Dashboard
</NavLink> </NavLink>
<NavLink to="/herd" className={bottomNavLinkClass}> <NavLink to="/herd" className={() => bottomNavLinkClass({ isActive: herdActive })}>
<Icon name="herd" />
Herd Herd
</NavLink> </NavLink>
<NavLink to="/herd/add" className={bottomNavLinkClass}>
{({ isActive }) => (
<>
<span
className={`flex h-7 w-7 items-center justify-center rounded-full ${
isActive ? 'bg-accent-hover text-surface' : 'bg-accent text-surface'
}`}
>
<Icon name="add" className="h-4 w-4" />
</span>
Add
</>
)}
</NavLink>
<NavLink to="/litters" className={bottomNavLinkClass}> <NavLink to="/litters" className={bottomNavLinkClass}>
<Icon name="litters" />
Litters Litters
</NavLink> </NavLink>
<NavLink to="/herd/add" className={bottomNavLinkClass}> <MoreMenu key={pathname} />
Add
</NavLink>
<NavLink to="/settings" className={bottomNavLinkClass}>
Settings
</NavLink>
</nav> </nav>
</div> </div>
) )

View File

@ -1,11 +1,14 @@
// No horizontal padding: <Layout>'s <main> already supplies the page gutter,
// so the header lines up with the content below it. On phones the action row
// drops below the title so a multi-button action group can wrap freely.
function PageHeader({ title, subtitle, action }) { function PageHeader({ title, subtitle, action }) {
return ( return (
<div className="flex items-start justify-between gap-4 p-6"> <div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<h1 className="font-display text-2xl text-text-primary">{title}</h1> <h1 className="break-words font-display text-2xl font-bold text-text-primary">{title}</h1>
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>} {subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
</div> </div>
{action && <div className="shrink-0">{action}</div>} {action && <div className="sm:shrink-0">{action}</div>}
</div> </div>
) )
} }

View File

@ -371,7 +371,11 @@ function PedigreeTree({
}, []) }, [])
return ( return (
<div className="flex w-full min-w-[900px] items-center gap-6 overflow-x-auto p-4 pb-[300px]"> // The tree needs ~900px; on narrower screens the outer wrapper scrolls
// sideways rather than the min-width stretching the whole page.
<div className="-mx-4 overflow-x-auto md:mx-0">
<p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p>
<div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]">
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center"> <div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span> <span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
</div> </div>
@ -379,6 +383,7 @@ function PedigreeTree({
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> <Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> <Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
</div> </div>
</div>
) )
} }

View File

@ -108,7 +108,7 @@ function QuickSearch() {
const showDropdown = open && query.trim() !== '' const showDropdown = open && query.trim() !== ''
return ( return (
<div ref={containerRef} className="relative mb-4 max-w-xl"> <div ref={containerRef} className="relative mb-6 max-w-xl">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
ref={inputRef} ref={inputRef}
@ -143,7 +143,7 @@ function QuickSearch() {
{filtersOpen && ( {filtersOpen && (
<div className="absolute right-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-surface-raised p-2 shadow-sm"> <div className="absolute right-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-surface-raised p-2 shadow-sm">
<p className="px-1 pb-1 font-label text-xs uppercase tracking-wide text-text-secondary"> <p className="px-1 pb-1 font-body text-xs font-medium uppercase tracking-wide text-text-secondary">
Search in Search in
</p> </p>
{SEARCH_FIELDS.map((field) => ( {SEARCH_FIELDS.map((field) => (

View File

@ -36,7 +36,6 @@ function RabbitCard({ rabbit }) {
{age} {age}
{weight ? ` • ${weight}` : ''} {weight ? ` • ${weight}` : ''}
</p> </p>
<p className="font-body text-xs text-text-secondary">0 litters • 0 kits</p>
</div> </div>
</Card> </Card>
</Link> </Link>

View File

@ -155,6 +155,17 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
/* iOS Safari zooms the page when focusing a control whose font-size is under
16px, which is jarring in the installed PWA. Most controls use text-sm, so
bump them to 16px on small screens only; desktop keeps the denser size. */
@media (max-width: 767px) {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='color']),
textarea,
select {
font-size: 16px;
}
}
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {
color: var(--color-text-secondary); color: var(--color-text-secondary);

View File

@ -28,7 +28,7 @@ function AddBreeding() {
const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId) const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId)
if ((sireId && sireLoading) || (damId && damLoading)) { if ((sireId && sireLoading) || (damId && damLoading)) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Record Breeding</h1> return <PageHeader title="Record Breeding" subtitle="Loading…" />
} }
async function handleSubmit(payload) { async function handleSubmit(payload) {
@ -44,9 +44,9 @@ function AddBreeding() {
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader title="Record Breeding" /> <PageHeader title="Record Breeding" />
<div className="px-6 pb-6"> <div>
<LitterForm <LitterForm
mode="breeding" mode="breeding"
initialValues={{ initialValues={{

View File

@ -25,7 +25,7 @@ function AddLitter() {
const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId) const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId)
if ((sireId && sireLoading) || (damId && damLoading)) { if ((sireId && sireLoading) || (damId && damLoading)) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Record Litter</h1> return <PageHeader title="Record Litter" subtitle="Loading…" />
} }
async function handleSubmit(payload) { async function handleSubmit(payload) {
@ -41,9 +41,9 @@ function AddLitter() {
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader title="Record Litter" /> <PageHeader title="Record Litter" />
<div className="px-6 pb-6"> <div>
<LitterForm <LitterForm
initialValues={{ initialValues={{
sire: sireRabbit ? { id: sireRabbit.id, name: sireRabbit.name } : null, sire: sireRabbit ? { id: sireRabbit.id, name: sireRabbit.name } : null,

View File

@ -82,7 +82,7 @@ function AddRabbit() {
// loading before PedigreeTree mounts, since it only reads `initialState` // loading before PedigreeTree mounts, since it only reads `initialState`
// once, at mount (same constraint EditRabbit.jsx works around). // once, at mount (same constraint EditRabbit.jsx works around).
if ((sireId && sireLoading) || (damId && damLoading)) { if ((sireId && sireLoading) || (damId && damLoading)) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Add Rabbit</h1> return <PageHeader title="Add Rabbit" subtitle="Loading…" />
} }
const pedigreeInitialState = const pedigreeInitialState =
@ -136,14 +136,14 @@ function AddRabbit() {
) : null ) : null
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Add Rabbit" title="Add Rabbit"
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`} subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
action={headerAction} action={headerAction}
/> />
<div className="px-6 pb-6"> <div>
{step === 1 && ( {step === 1 && (
<PedigreeTree <PedigreeTree
initialState={pedigreeInitialState} initialState={pedigreeInitialState}

View File

@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getStats, getLitters, getRabbits } from '../api' import { getStats, getLitters, getRabbits, getSettings } from '../api'
import Card from '../components/Card' import Card from '../components/Card'
import Button from '../components/Button' import Button from '../components/Button'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import PageHeader from '../components/PageHeader'
const BIRTHDAY_WINDOW_DAYS = 30 const BIRTHDAY_WINDOW_DAYS = 30
@ -197,8 +198,11 @@ function UpcomingBirthdays() {
} }
function Dashboard() { function Dashboard() {
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<PageHeader title={settings?.rabbitry_name || 'Dashboard'} subtitle="Herd overview" />
<section> <section>
<StatsBar /> <StatsBar />
</section> </section>

View File

@ -17,7 +17,7 @@ function EditLitter() {
}) })
if (isLoading || !litter) { if (isLoading || !litter) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Edit Litter</h1> return <PageHeader title="Edit Litter" subtitle="Loading…" />
} }
async function handleSubmit(payload) { async function handleSubmit(payload) {
@ -33,9 +33,9 @@ function EditLitter() {
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader title="Edit Litter" /> <PageHeader title="Edit Litter" />
<div className="px-6 pb-6"> <div>
<LitterForm <LitterForm
initialValues={{ initialValues={{
sire: litter.sire, sire: litter.sire,

View File

@ -51,7 +51,7 @@ function EditRabbit() {
} }
if (rabbitLoading || !rabbit || details === null || genetics === null) { if (rabbitLoading || !rabbit || details === null || genetics === null) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Edit Rabbit</h1> return <PageHeader title="Edit Rabbit" subtitle="Loading…" />
} }
function updateDetails(partial) { function updateDetails(partial) {
@ -89,10 +89,10 @@ function EditRabbit() {
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader title="Edit Rabbit" subtitle={rabbit.name} /> <PageHeader title="Edit Rabbit" subtitle={rabbit.name} />
<div className="space-y-6 px-6 pb-6"> <div className="space-y-6">
<RabbitDetailsForm <RabbitDetailsForm
value={details} value={details}
onChange={updateDetails} onChange={updateDetails}

View File

@ -1,5 +1,21 @@
import Card from '../components/Card'
import EmptyState from '../components/EmptyState'
import PageHeader from '../components/PageHeader'
// Placeholder until the pairing calculator is built (see app-design-document
// "Genetics Calculator"); renders a proper page shell instead of a bare <h1>.
function Genetics() { function Genetics() {
return <h1>Genetics Calculator</h1> return (
<div className="flex flex-col gap-6">
<PageHeader title="Genetics Calculator" subtitle="Predict kit colors from a buck × doe pairing." />
<Card>
<EmptyState
title="Coming soon"
description="The pairing calculator isn't built yet. Genotypes you record on each rabbit will feed into it."
/>
</Card>
</div>
)
} }
export default Genetics export default Genetics

View File

@ -84,14 +84,15 @@ function SearchBox({ q, onSearchChange }) {
}, [searchInput]) }, [searchInput])
return ( return (
<div className="flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5"> <div className="flex min-w-0 flex-1 basis-full items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 sm:basis-auto md:flex-none">
<SearchIcon /> <SearchIcon />
<input <input
type="text" type="text"
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search by name" placeholder="Search by name"
className="w-40 bg-transparent font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none" aria-label="Search herd by name"
className="w-full min-w-0 bg-transparent md:w-40 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
/> />
</div> </div>
) )

View File

@ -32,7 +32,7 @@ function LitterDetail() {
const weightUnit = useWeightUnit() const weightUnit = useWeightUnit()
if (isLoading || !litter) { if (isLoading || !litter) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Litter Detail</h1> return <PageHeader title="Litter Detail" subtitle="Loading…" />
} }
const sireName = litter.sire?.name ?? 'Unknown' const sireName = litter.sire?.name ?? 'Unknown'
@ -52,7 +52,7 @@ function LitterDetail() {
if (kindled) addKitParams.set('dob', litter.kindle_date) if (kindled) addKitParams.set('dob', litter.kindle_date)
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title={`${sireName} × ${damName}`} title={`${sireName} × ${damName}`}
subtitle={ subtitle={
@ -75,7 +75,7 @@ function LitterDetail() {
} }
/> />
<div className="space-y-6 px-6 pb-6"> <div className="space-y-6">
<Card className="grid grid-cols-2 gap-4 p-4 md:grid-cols-4"> <Card className="grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div> <div>
<p className="font-body text-xs text-text-secondary">Sire</p> <p className="font-body text-xs text-text-secondary">Sire</p>
@ -107,8 +107,8 @@ function LitterDetail() {
)} )}
</Card> </Card>
<Card className="overflow-hidden"> <Card className="overflow-x-auto">
<table className="w-full text-left"> <table className="w-full whitespace-nowrap text-left">
<thead className="bg-surface-sunken"> <thead className="bg-surface-sunken">
<tr> <tr>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Name</th> <th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Name</th>

View File

@ -10,7 +10,7 @@ function Litters() {
const { data: litters, isLoading } = useQuery({ queryKey: ['litters'], queryFn: getLitters }) const { data: litters, isLoading } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Litters" title="Litters"
action={ action={
@ -20,7 +20,7 @@ function Litters() {
} }
/> />
<div className="px-6 pb-6"> <div>
{isLoading ? null : litters.length === 0 ? ( {isLoading ? null : litters.length === 0 ? (
<EmptyState <EmptyState
title="No litters yet" title="No litters yet"

View File

@ -1,5 +1,22 @@
import { Link } from 'react-router-dom'
import Button from '../components/Button'
import Card from '../components/Card'
import EmptyState from '../components/EmptyState'
function NotFound() { function NotFound() {
return <h1>Not Found</h1> return (
<Card>
<EmptyState
title="Page not found"
description="That page doesn't exist — it may have been moved or deleted."
action={
<Link to="/">
<Button>Back to Dashboard</Button>
</Link>
}
/>
</Card>
)
} }
export default NotFound export default NotFound

View File

@ -47,13 +47,8 @@ function DetailColumn({ rabbit }) {
</div> </div>
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<div> {/* Name + farm are already the page title; just the status here. */}
<h1 className="font-display text-2xl text-text-primary">{rabbit.name}</h1>
{rabbit.farm && <p className="font-body text-sm text-text-secondary">{rabbit.farm}</p>}
<div className="mt-2">
<Badge status={rabbit.status} /> <Badge status={rabbit.status} />
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<StatRow label="Sex" value={SEX_LABELS[rabbit.sex] ?? rabbit.sex} /> <StatRow label="Sex" value={SEX_LABELS[rabbit.sex] ?? rabbit.sex} />
@ -104,7 +99,7 @@ function LitterHistory({ rabbitId }) {
if (history.length === 0) return null if (history.length === 0) return null
return ( return (
<Card className="overflow-hidden"> <Card className="overflow-x-auto">
<table className="w-full text-left"> <table className="w-full text-left">
<thead className="bg-surface-sunken"> <thead className="bg-surface-sunken">
<tr> <tr>
@ -160,7 +155,7 @@ function RabbitDetail() {
}) })
if (isLoading || !rabbit) { if (isLoading || !rabbit) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Rabbit Detail</h1> return <PageHeader title="Rabbit Detail" subtitle="Loading…" />
} }
async function patchStatus(status) { async function patchStatus(status) {
@ -194,7 +189,7 @@ function RabbitDetail() {
const addLitterHref = `/litters/add${parentQuery}` const addLitterHref = `/litters/add${parentQuery}`
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title={rabbit.name} title={rabbit.name}
subtitle={rabbit.farm} subtitle={rabbit.farm}
@ -235,7 +230,7 @@ function RabbitDetail() {
} }
/> />
<div className="space-y-6 px-6 pb-6"> <div className="space-y-6">
{error && <p className="font-body text-sm text-danger">{error}</p>} {error && <p className="font-body text-sm text-danger">{error}</p>}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start"> <div className="flex flex-col gap-6 lg:flex-row lg:items-start">

View File

@ -114,16 +114,16 @@ function Settings() {
if (isLoading || !form) { if (isLoading || !form) {
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader title="Settings" /> <PageHeader title="Settings" />
</div> </div>
) )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<PageHeader title="Settings" /> <PageHeader title="Settings" />
<div className="space-y-6 p-6 pt-0"> <div className="space-y-6">
<Card className="space-y-4 p-6"> <Card className="space-y-4 p-6">
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label> <label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label>

View File

@ -638,7 +638,7 @@ function ShowEventDetail() {
return <p className="p-6 font-body text-sm text-danger">Failed to load this event.</p> return <p className="p-6 font-body text-sm text-danger">Failed to load this event.</p>
} }
if (isLoading || !event) { if (isLoading || !event) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Show Event</h1> return <PageHeader title="Show Event" subtitle="Loading…" />
} }
const shows = event.shows ?? [] const shows = event.shows ?? []
@ -650,7 +650,7 @@ function ShowEventDetail() {
title={event.name} title={event.name}
subtitle={`${shows.length} show${shows.length === 1 ? '' : 's'} · ${entryCount} entr${entryCount === 1 ? 'y' : 'ies'}`} subtitle={`${shows.length} show${shows.length === 1 ? '' : 's'} · ${entryCount} entr${entryCount === 1 ? 'y' : 'ies'}`}
action={ action={
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Link to="/shows"> <Link to="/shows">
<Button variant="secondary">Add Shows</Button> <Button variant="secondary">Add Shows</Button>
</Link> </Link>