diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..86a63dc
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ac2ae6b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,118 @@
+name: CI
+
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_VERSION: '24'
+
+jobs:
+ secret-scan:
+ name: Secret scan
+ runs-on: ubuntu-latest
+ container:
+ image: ghcr.io/gitleaks/gitleaks:v8.30.1
+ options: --entrypoint ''
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - run: gitleaks git --config .gitleaks.toml --redact --verbose .
+
+ dependency-audit:
+ name: Dependency audit
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run security:audit
+
+ quality:
+ name: Quality
+ needs: [secret-scan, dependency-audit]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run quality
+
+ commitlint:
+ name: Commit lint
+ needs: [secret-scan, dependency-audit]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - name: Lint commit range
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ BEFORE_SHA: ${{ github.event.before }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -eu
+ if [ -n "${BASE_SHA:-}" ]; then
+ npx --no -- commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
+ elif [ -n "${BEFORE_SHA:-}" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
+ npx --no -- commitlint --from "$BEFORE_SHA" --to "$HEAD_SHA" --verbose
+ else
+ git fetch origin "$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH"
+ BASE="$(git merge-base "$HEAD_SHA" "origin/$DEFAULT_BRANCH")"
+ if [ "$BASE" = "$HEAD_SHA" ]; then
+ git log -1 --format=%B "$HEAD_SHA" | npx --no -- commitlint --verbose
+ else
+ npx --no -- commitlint --from "$BASE" --to "$HEAD_SHA" --verbose
+ fi
+ fi
+
+ test:
+ name: Test
+ needs: [quality, commitlint]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm test
+
+ build:
+ name: Build
+ needs: [test]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run build
diff --git a/.gitignore b/.gitignore
index a547bf3..abfdc49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,11 +11,23 @@ node_modules
dist
dist-ssr
*.local
+coverage
+.vite/
+.npm/
+
+.env
+.env.*
+!.env.example
+
+src/generated/
+public/generated/
+*.generated.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
+.claude/
.DS_Store
*.suo
*.ntvs*
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 0000000..5844738
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,4 @@
+title = "Interactive Event Seating Map secret scanning"
+
+[extend]
+useDefault = true
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 0000000..da99483
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1 @@
+npx --no -- commitlint --edit "$1"
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..f80fd4a
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,12 @@
+#!/usr/bin/env sh
+set -eu
+
+if ! command -v gitleaks >/dev/null 2>&1; then
+ echo "gitleaks 8.30.x is required before committing." >&2
+ exit 1
+fi
+
+git diff --cached --no-ext-diff --unified=0 | gitleaks stdin --config .gitleaks.toml --redact --no-banner
+npx lint-staged
+pnpm run quality
+pnpm run build
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..c20257b
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,4 @@
+engine-strict=true
+save-exact=true
+audit=true
+fund=false
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..a45fd52
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..43269ef
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,17 @@
+dist/
+coverage/
+node_modules/
+.claude/
+
+src/generated/
+public/generated/
+**/*.generated.*
+
+package-lock.json
+npm-shrinkwrap.json
+pnpm-lock.yaml
+yarn.lock
+bun.lock
+bun.lockb
+
+public/venue.json
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..642543d
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,12 @@
+{
+ "arrowParens": "always",
+ "bracketSameLine": false,
+ "endOfLine": "lf",
+ "printWidth": 100,
+ "semi": true,
+ "singleAttributePerLine": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "useTabs": false
+}
diff --git a/README.md b/README.md
index f8a6410..eae427b 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,411 @@
# Event Seating Map
-A high-performance, interactive seating map for large venues. Built to handle 15,000+ seats with smooth performance and a premium user experience.
+[](https://nodejs.org)
+[](https://react.dev)
+[](https://pnpm.io)
-##Quick Start
+A high-performance, interactive seating map for large venues, built to handle **15,000+ seats**
+with smooth panning, zooming, keyboard navigation, and persisted selection - without shipping a
+canvas/WebGL engine or a virtualization library.
-Get the project running in seconds:
+---
-1. **Install dependencies:**
+## π― Problem statement
+
+A venue booking UI has to render every seat in a venue (this project's sample data: 10 sections Γ
+30 rows Γ 50 seats = **15,000 seats**) as an individually selectable, keyboard-navigable element,
+while staying responsive to pan, zoom, and selection changes. Rendering 15,000 live SVG nodes at
+once is the naive approach - and it's slow: every zoom/selection change forces the browser to
+diff, layout, and paint tens of thousands of DOM nodes. This project's core engineering problem is
+keeping the map interactive at that scale using nothing heavier than React + SVG.
+
+## πΈ Screenshots
+
+**Overview** - the full venue map with all 10 sections collapsed, and the Booking History sidebar
+in its default state.
+
+
+
+**Section drill-in & seat selection** - every seat status (available, selected, sold, reserved,
+held) visible at once, with the live selected-seats list and running total in the sidebar.
+
+
+
+**Booking History** - completed purchases are grouped and listed; clicking one highlights its
+exact seats back on the map.
+
+
+
+---
+
+## ποΈ Architecture & performance
+
+```mermaid
+flowchart TD
+ A[App] --> B["useVenue() fetch /venue.json"]
+ A --> C[VenueMap]
+ C --> D["Section Γ 10 (memoized)"]
+ D -->|inactive| E["Collapsed <path> cover shape (1 SVG node per section)"]
+ D -->|active| F["Seats (memoized)"]
+ F --> G["~1,500 <rect> seat nodes (one section's worth)"]
+ A --> H["seatStore (Zustand) selection Β· holds Β· zoom Β· active section"]
+ H -.->|persist selectedSeats + deadline| I[(localStorage)]
+ F -.reads/writes.-> H
+ A --> J[Toast aria-live feedback]
+ H -.->|feedback| J
+ A --> K["HoldRuntime (renders nothing)"]
+ K -.expiry timer.-> H
+ K <-.BroadcastChannel.-> L["Other tabs"]
+```
+
+### 15,000+ seat rendering: section-level collapsing
+
+The map never mounts all 15,000 seats as DOM nodes at once. [`Section.tsx`](src/components/Section.tsx)
+renders each **inactive** section as a single collapsed `` "cover" shape traced around its
+outer row (one SVG node, regardless of how many seats it contains). Only the **one active**
+section (the one the user clicked into) mounts individual seat `` nodes via
+[`Seat.tsx`](src/components/Seat.tsx). That caps live, interactive seat nodes at ~1,500 (one
+section) instead of 15,000, at any given time.
+
+This is a deliberate alternative to two heavier options:
+
+- **Canvas/WebGL**: gives raw rendering throughput but throws away native SVG accessibility
+ (focus, `role`, `aria-*`) and hit-testing - you'd have to reimplement both by hand.
+- **Row/column virtualization** (`react-window`/`react-virtualized`): built for linear lists, not
+ an SVG coordinate space with seats laid out on radial rows. Section-level collapsing matches this
+ venue's natural UX unit (users think in sections, not in a scrollable seat list) and needs no
+ extra dependency.
+
+`Section` and `Seats` are both `React.memo`-wrapped, so panning, zooming, or selecting a seat in
+one section doesn't force every other (already-collapsed) section to re-render or recompute its
+cover path.
+
+### Zustand persistence strategy
+
+Selected seats survive a page refresh via Zustand's `persist` middleware - but `selectedSeats` is
+a `Set`, and `JSON.stringify` can't round-trip a `Set` on its own. [`seatStore.ts`](src/store/seatStore.ts)
+supplies a custom `PersistStorage` that serializes `Set β string[]` on `getItem`/`setItem`,
+so `localStorage` only ever stores an array, while the in-memory store keeps `O(1)` `has()`/`add()`/
+`delete()` lookups for selection toggling. `partialize` scopes persistence to `selectedSeats` only -
+`zoom`, `activeSectionId`, and transient `feedback` state are intentionally not persisted.
+
+See [`docs/engineering-notes.md`](docs/engineering-notes.md) for the tradeoffs behind these
+decisions and where they'd need to change at greater scale.
+
+### Best-seats solver
+
+[`findBestSeats.ts`](src/utils/findBestSeats.ts) finds the single best contiguous block of N seats
+for a party in a single pass over the venue (O(seats Γ party size)), ranked by a strict,
+deterministic total order - "Best view" sorts by row distance from the stage, then how centered the
+block is _within its row's own `col` range_ (not x/y - see the comment in that file for why arc
+position, not geometry, is the right unit), then price; "Best price" sorts the same way but leads
+with price. The last two keys (section, starting seat) exist purely to break ties, so the same
+query always returns the same seats rather than depending on `venue.json`'s array order.
+[`BestSeatsFinder.tsx`](src/components/BestSeatsFinder.tsx) runs it against live availability
+(`venue.json` status, seats sold this session, and any live hold - see below) and focuses/selects
+the winning block in one atomic store update.
+
+### Live seat-hold contention
+
+Real ticketing sites don't just mark seats "held" once and forget - holds expire, and two people
+(or two tabs) can race for the same seat. This project simulates that without a backend:
+
+- **A checkout timer.** Selecting a seat starts a 5-minute deadline ([`seatStore.ts`](src/store/seatStore.ts)'s
+ `selectionExpiresAt`, persisted so a refresh doesn't grant an unlimited hold). It's surfaced as
+ [`CheckoutTimer.tsx`](src/components/CheckoutTimer.tsx), driven by [`useCountdown.ts`](src/hooks/useCountdown.ts) -
+ the countdown owns its own render loop and never touches the store on a tick, so it can't cause
+ the seat layer to re-render.
+- **The sample `held` seats actually expire.** [`holdSimulation.ts`](src/utils/holdSimulation.ts)
+ seeds every JSON-`held` seat with a real, staggered expiry at runtime (never baked into the
+ committed file, which would just be stale on the next load) using a deterministic hash of the
+ seat id - reproducible, but spread across a window so seats visibly release a few at a time
+ instead of all at once.
+- **Cross-tab sync.** [`holdChannel.ts`](src/utils/holdChannel.ts) wraps `BroadcastChannel` behind
+ an injectable, feature-detected transport (it degrades to a no-op, single-tab mode when the API
+ is unavailable - which is also exactly the situation under Vitest/jsdom, so the same code path is
+ what the tests exercise). [`holdProtocol.ts`](src/utils/holdProtocol.ts) has the actual protocol
+ as pure, unit-tested reducers: a join-time handshake, full-set-replacement hold messages (so a
+ dropped packet self-heals on the next change instead of leaving a stale hold), and a
+ deterministic tie-break (lower session id wins) for the case where two tabs select the same seat
+ in the same instant.
+- **One resolver, one source of truth.** [`seatStatus.ts`](src/utils/seatStatus.ts) centralizes
+ `sold` β `held` β `selected` β `reserved` β `available` precedence into a single pure function,
+ replacing what used to be scattered boolean checks in `Seat.tsx`.
+- **Never re-renders the seat layer on a timer.** Pruning a lapsed hold (`pruneHolds` in
+ `holdProtocol.ts`) returns the _same_ Map reference when nothing has actually expired, so a
+ routine tick with nothing to do costs zero re-renders across the ~1,500 mounted seats - verified
+ by a reference-equality (`toBe`) test, not just a behavioral one.
+
+---
+
+## βΏ Accessibility
+
+- Every seat is a real interactive element: `role="checkbox"`, `aria-checked` reflecting selection,
+ `aria-label` describing row/seat/price/status, and `aria-disabled` for sold/reserved/held seats.
+- **Roving keyboard navigation**: only the active section's seats are focusable (`tabIndex={0}`,
+ or `-1` when unavailable). Arrow keys move focus seat-to-seat - `ArrowLeft`/`ArrowRight` within a
+ row, `ArrowUp`/`ArrowDown` to the nearest seat (by x-position) in the adjacent row - and
+ `Enter`/`Space` toggle selection, matching native checkbox semantics.
+- Feedback that used to be a blocking `alert()` (hitting the 8-seat cap, the payment stub) is now
+ an `aria-live="polite"` [`Toast`](src/components/Toast.tsx) region and inline confirmation state,
+ so screen readers announce it without interrupting keyboard flow.
+
+---
+
+## π Features
+
+- **Large-scale rendering**: Thousands of seats render smoothly using SVG optimization.
+- **Best-seats solver**: Pick a party size and priority ("Best view" or "Best price") and find the
+ single best contiguous block of adjacent seats, ranked by a deterministic algorithm.
+- **Live seat-hold contention**: Selecting seats starts a real 5-minute checkout timer; the sample
+ `held` seats expire and release on their own; two browser tabs share holds and sales live via
+ `BroadcastChannel`, with a deterministic tie-break if they pick the same seat at once.
+- **Map navigation**: Click-and-drag panning with smart zoom in/out controls.
+- **Accessibility**: Full arrow-key navigation between seats, keyboard-selectable seats (`role="checkbox"`).
+- **Persisted selection**: Selected seats survive a page refresh (Zustand + `localStorage`).
+- **Responsive layout**: A collapsible booking sidebar on mobile, side-by-side on desktop.
+
+---
+
+## π οΈ Tech Stack
+
+### Application
+
+- **Core**: React 19, TypeScript
+- **State Management**: Zustand (with persisted seat selection)
+- **Styling**: Tailwind CSS
+- **Build Tool**: Vite
+
+### Engineering standards & tooling
+
+- **Testing**: Vitest + Testing Library (jsdom)
+- **Linting/Formatting**: ESLint (React, hooks, jsx-a11y, strict + stylistic type-checked
+ TypeScript rules) + Prettier
+- **Git hooks**: Husky (`pre-commit` β Gitleaks + lint-staged + full quality check + production
+ build, `commit-msg` β commitlint)
+- **Commit convention**: Conventional Commits, enforced by commitlint
+- **Secret scanning**: Gitleaks (local pre-commit + full-history scan in CI)
+- **CI**: GitHub Actions (`secret-scan`, `dependency-audit`, `quality`, `commitlint`, `test`, `build`)
+
+See [Engineering standards](#-engineering-standards) below for details.
+
+---
+
+## π Repository Structure
+
+```text
+βββ docs/
+β βββ engineering-notes.md # Tradeoffs, performance bottlenecks, known gaps
+β βββ ui-ux-and-business-logic-flow.md # UI state/interaction reference
+β βββ screenshots/ # README screenshots
+βββ public/
+β βββ venue.json # Generated seating data served to the client
+β βββ robots.txt
+β βββ llms.txt
+βββ scripts/
+β βββ generateVenue.ts # Generates public/venue.json
+βββ src/
+β βββ components/ # VenueMap, Section, Seat, BookingSummary, SeatSelectionPanel,
+β β # SeatSelectionFooter, BookingHistoryPanel, BestSeatsFinder,
+β β # CheckoutTimer, HoldRuntime, ConfirmDialog, Toast, Icon, IconButton
+β βββ hooks/
+β β βββ useVenue.ts # Fetches and validates public/venue.json
+β β βββ useCountdown.ts # Self-correcting per-second countdown, isolated from the store
+β β βββ useHoldExpiry.ts # Arms one timer for the next hold/selection deadline
+β β βββ useHoldSync.ts # Cross-tab BroadcastChannel wiring
+β βββ store/
+β β βββ seatStore.ts # Zustand store (zoom, active section, selection + checkout
+β β # deadline, holds, sold seats, booking history, feedback)
+β βββ interfaces/
+β β βββ venue.interfaces.ts # Venue/seat types + the hold protocol's message types
+β βββ utils/ # rowLetter, seatIndex, venueBounds, viewTransform (pan/zoom math),
+β β # findBestSeats (best-seats solver), seatStatus (status resolver),
+β β # holdProtocol/holdChannel/holdSimulation/sessionId/formatCountdown
+β βββ test/
+β β βββ setup.ts # Vitest + Testing Library setup
+β β βββ fakeBroadcastChannel.ts # In-memory multi-tab bus for testing the hold protocol
+β βββ App.tsx
+β βββ main.tsx
+βββ .github/workflows/ci.yml # CI pipeline
+βββ .husky/ # pre-commit / commit-msg hooks
+βββ .nvmrc / .npmrc # Node/pnpm version + install policy
+βββ README.md
+```
+
+---
+
+## βοΈ Getting Started
+
+### Prerequisites
+
+- **Node.js** `>=24 <25` (see `.nvmrc` / `engines` in `package.json`)
+- **pnpm 11.x** (this project uses pnpm, not npm/yarn - pinned exactly via the `packageManager`
+ field in `package.json`; `.npmrc` sets `engine-strict=true` so installs fail fast on a
+ mismatched Node/pnpm version instead of silently drifting)
+- **Gitleaks v8.30.1** on `PATH` - required by the pre-commit hook (see [Secret scanning](#secret-scanning-gitleaks))
+
+### Installation & Setup
+
+1. **Clone the repository**
+
+ ```bash
+ git clone git@github.com:webvoltz/Interactive-Event-Seating-Map.git
+ cd Interactive-Event-Seating-Map
+ ```
+
+2. **Install dependencies**
+
+ ```bash
pnpm install
+ ```
+
+3. **Install the Husky git hooks** (one-time, after cloning)
+
+ ```bash
+ pnpm run prepare
+ ```
+
+### Running Locally
+
+```bash
+pnpm dev
+```
+
+The app expects seat data at `public/venue.json`; a sample file is already checked in. Regenerate it any time with:
+
+```bash
+pnpm run generate:venue
+```
+
+### Running a Production Build
+
+```bash
+pnpm run build
+pnpm start
+```
+
+`pnpm start` (an alias for `vite preview`) serves the `dist/` output from `pnpm run build` - use it
+to sanity-check the actual production bundle, or to satisfy hosting platforms that expect a
+`start` script.
+
+---
+
+## π Environment Variables
+
+There are **no environment variables** - seating data is served from the static `public/venue.json`, not an API. If a real backend is introduced later, add a Zod-validated env module rather than reading `import.meta.env` directly in components.
+
+---
+
+## π§ͺ Running Tests
+
+```bash
+pnpm test
+```
+
+Runs with V8 coverage enabled (`pnpm run security:audit` covers dependency vulnerabilities
+separately). Coverage thresholds in [`vite.config.ts`](vite.config.ts) are set just under today's
+actual numbers as a regression floor, not the org standard's targets (branches 85% / functions
+100% / lines 90% / statements 90%) - most components still don't have tests; see
+[`docs/engineering-notes.md`](docs/engineering-notes.md#known-remaining-gaps) for the honest
+current numbers and what closing that gap actually requires.
+
+Existing tests cover seat selection/deselection, the 8-seat selection cap, clearing the selection,
+the persisted-seats/sold-seats/booking-history/checkout-deadline `localStorage` round trip, and
+keyboard navigation (arrow keys, Enter/Space, skipping unavailable seats) - see
+[`src/store/seatStore.test.ts`](src/store/seatStore.test.ts) and
+[`src/components/Seat.test.tsx`](src/components/Seat.test.tsx). Pan/zoom math (fit-to-screen,
+zoom-at-pointer, pan clamping) is covered separately in
+[`src/utils/viewTransform.test.ts`](src/utils/viewTransform.test.ts). The best-seats solver's
+ranking/tiebreak logic, the hold protocol's reducers (including a reference-equality test proving a
+no-op prune never triggers a re-render), the runtime hold simulation's determinism, and the
+checkout countdown's stable-announcement-band behavior each have their own dedicated, near-100%-covered
+test files under `src/utils/` and `src/hooks/`.
+
+---
+
+## π Engineering standards
+
+This project follows the Webvoltz React engineering standards.
+
+### Code quality
+
+- **ESLint** (`eslint.config.js`) lints `.js/.jsx/.ts/.tsx` with `eslint-plugin-react`,
+ `eslint-plugin-react-hooks`, `eslint-plugin-jsx-a11y`, and `typescript-eslint`'s
+ `strictTypeChecked` + `stylisticTypeChecked` rule sets - `no-explicit-any`, every `no-unsafe-*`
+ rule, `only-throw-error`, `require-await`, `switch-exhaustiveness-check`, `ban-ts-comment`
+ (`@ts-expect-error` requires a real description), and `consistent-type-imports` are all errors,
+ not warnings. `restrict-template-expressions` allows `number` (seat coordinates/prices are
+ legitimately interpolated) but still disallows objects/`null`/`undefined`. `pnpm run lint` fails
+ on any warning (`--max-warnings=0`).
+- **Prettier** (`.prettierrc.json`) is the single source of formatting truth. Run
+ `pnpm run format`, or let the pre-commit hook format staged files for you.
+- **TypeScript** runs in strict mode plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+ `noImplicitReturns`, `noImplicitOverride`, `noPropertyAccessFromIndexSignature`,
+ `forceConsistentCasingInFileNames`, `allowUnreachableCode: false`, and `allowUnusedLabels: false`,
+ guarding indexed access explicitly instead of asserting it away. `skipLibCheck` stays `true`
+ (rather than the standard's `false`) as a documented exception: flipping it surfaces type
+ conflicts inside vitest/vite/testing-library's own bundled declarations, not this project's own
+ code (see the comment in `tsconfig.app.json`).
+- `pnpm run quality` runs format-check, lint, and typecheck together - this is what CI runs, and
+ what the pre-commit hook runs on the whole project (not just staged files) before every commit.
+- `pnpm run security:audit` runs `pnpm audit --audit-level high` for dependency vulnerabilities.
+
+### Git hooks (Husky)
+
+Installed via `pnpm run prepare`. Two hooks live in `.husky/`:
+
+- **`pre-commit`** - in order: scans the staged diff with Gitleaks, runs `lint-staged`
+ (ESLint `--fix` + Prettier) on the files you're committing, then `pnpm run quality` and
+ `pnpm run build` against the **whole project** (not just staged files) - a broken build or a
+ type error anywhere blocks the commit, even in a file you didn't touch.
+- **`commit-msg`** - runs `commitlint` against your commit message.
+
+Don't bypass either hook with `--no-verify` to get past a genuine failure - fix the issue instead.
+
+### Commit messages
+
+Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/), checked by `commitlint.config.cjs`:
+
+- Header, body, and footer lines must each be β€ 1000 characters - room to actually explain
+ yourself, not the old 72/100-character convention that just trains people to write useless
+ messages to fit.
+- `type` must be one of: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`.
+
+Example: `fix: prevent seat selection past the 8-seat limit`
+
+### Secret scanning (Gitleaks)
+
+The pre-commit hook requires the `gitleaks` binary on `PATH` and **fails closed** if it's missing:
+
+1. Download [Gitleaks v8.30.1](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) for your platform.
+2. Verify it per your usual tooling-verification process and put the `gitleaks` executable on `PATH`.
+3. Confirm `gitleaks version` works.
+
+GitHub Actions independently re-scans full repository history with the same pinned version, so a bypassed local hook is still caught in CI.
+
+**False positives:** investigate every finding first. Only add a narrowly scoped exception in `.gitleaks.toml` after a real secret has been ruled out, with a comment explaining why - never a blanket allowlist - and re-run the scan afterward.
+
+### Continuous integration
-2. **Generate venue data (Optional):**
- npx ts-node scripts/generateVenue.ts
+`.github/workflows/ci.yml` runs on every push and pull request, gated in stages via `needs`:
-3. **Start development server:**
- pnpm dev
+| Job | Needs | What it does |
+| ------------------ | --------------------------------- | ------------------------------------------------------------------------------ |
+| `secret-scan` | - | Gitleaks over full repository history. |
+| `dependency-audit` | - | `pnpm run security:audit` (`pnpm audit --audit-level high`). |
+| `quality` | `secret-scan`, `dependency-audit` | `pnpm run quality` (format check, lint, typecheck). |
+| `commitlint` | `secret-scan`, `dependency-audit` | Lints the commit range being pushed/merged. |
+| `test` | `quality`, `commitlint` | `pnpm test` (V8 coverage, checked against the thresholds in `vite.config.ts`). |
+| `build` | `test` | `pnpm run build`. |
-## Key Features
+All jobs must pass before merging.
-- Handles thousands of seats smoothly using SVG optimization.
-- Intuitive click-and-drag panning and smart zoom.
-- Full support for arrow-key navigation between seats.
-- Remembers your selected seats even after a page refresh.
-- Works beautifully on both desktop and mobile devices.
+---
-## Tech Stack
+## π€ Contributing
-- **React + TypeScript** β Core logic and type safety.
-- **Tailwind CSS** β Modern, responsive styling.
-- **Zustand** β Lightweight and fast state management.
-- **Vite** β Lightning-fast build tool.
+1. Fork or branch from `main`.
+2. Create your feature branch (`git checkout -b feature/amazing-feature`).
+3. Commit your changes following the [commit convention](#commit-messages) (`git commit -m 'feat: add amazing feature'`).
+4. Run `pnpm run quality && pnpm test && pnpm run build` before pushing.
+5. Push to the branch and open a pull request.
diff --git a/commitlint.config.cjs b/commitlint.config.cjs
new file mode 100644
index 0000000..85b67a3
--- /dev/null
+++ b/commitlint.config.cjs
@@ -0,0 +1,25 @@
+module.exports = {
+ extends: ['@commitlint/config-conventional'],
+ rules: {
+ 'header-max-length': [2, 'always', 1000],
+ 'body-max-line-length': [2, 'always', 1000],
+ 'footer-max-line-length': [2, 'always', 1000],
+ 'type-enum': [
+ 2,
+ 'always',
+ [
+ 'build',
+ 'chore',
+ 'ci',
+ 'docs',
+ 'feat',
+ 'fix',
+ 'perf',
+ 'refactor',
+ 'revert',
+ 'style',
+ 'test',
+ ],
+ ],
+ },
+};
diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md
new file mode 100644
index 0000000..e1fa31d
--- /dev/null
+++ b/docs/engineering-notes.md
@@ -0,0 +1,134 @@
+# Engineering notes
+
+This document records the tradeoffs behind the seating map's performance-critical decisions, and
+the bottlenecks they leave in place. It's aimed at anyone extending this codebase who needs to know
+_why_ something works the way it does before changing it.
+
+## Why section-level collapsing instead of a virtualization library
+
+`react-window`/`react-virtualized` virtualize a linear (or grid) list by only mounting the rows
+currently in the scroll viewport. That model doesn't map cleanly onto this venue: seats are laid
+out on radial rows around a stage (see [`scripts/generateVenue.ts`](../scripts/generateVenue.ts)),
+not a scrollable list, and the interaction unit users care about is a **section**, not a row.
+
+Instead, [`Section.tsx`](../src/components/Section.tsx) renders each inactive section as a single
+collapsed `` and defers mounting any seat nodes until the user clicks into it. This is simpler
+than adapting a list-virtualization library to a 2D radial layout, needs no extra dependency, and
+naturally matches the venue's real-world browsing pattern (pick a section, then pick a seat).
+
+**Bottleneck this leaves in place:** the _active_ section still mounts all of its seats at once
+(~1,500 in the current 10Γ30Γ50 sample venue). That's fine at this scale, but a venue with, say,
+5,000 seats in a single section would need row-level virtualization _within_ the active section -
+this codebase doesn't have that yet.
+
+## Why SVG over Canvas/WebGL
+
+SVG gives native accessibility (`role`, `aria-*`, real DOM focus) and hit-testing for free - every
+seat is a real, individually focusable element without any custom hit-testing code. Canvas/WebGL
+would render faster at very large scale, but every one of those things (focus order, screen-reader
+semantics, click targets) would need to be reimplemented by hand against an off-screen model.
+
+**Where this stops scaling:** SVG's per-node overhead (DOM presence, style recalculation, event
+listener) becomes the bottleneck somewhere in the tens-of-thousands of simultaneously-mounted
+nodes - this project stays well under that by only ever mounting one section's seats. If a future
+requirement needs _all_ sections' seats interactive simultaneously (e.g., a full-venue heatmap),
+that's the point to reach for Canvas with a manual hit-testing layer, or a WebGL instanced-rendering
+approach, accepting the accessibility rebuild that comes with it.
+
+## `memo()` boundaries and render cost
+
+`Section` and `Seats` (in [`Seat.tsx`](../src/components/Seat.tsx)) are both `React.memo`-wrapped so
+that panning/zooming or selecting a seat in one section doesn't re-render every other section's
+already-collapsed cover path. Per-seat fill/stroke/text-color used to be recomputed via an if/else
+ladder on every render; [`seatStatus.ts`](../src/utils/seatStatus.ts)'s `SEAT_STATUS_STYLE` replaced
+that with a lookup keyed by effective status, computed once at module scope. The one thing that
+lookup can't express is that an _available_ seat's stroke also depends on its own price tier -
+`Seat.tsx` still applies that as a small override after the lookup.
+
+A second render-cost fix landed alongside the live-hold work: `Seats` used to subscribe to the raw
+`zoom` number just to derive a `showLabels` boolean, which meant all ~1,500 mounted seats re-rendered
+on _every_ wheel/zoom event regardless of whether the threshold was actually crossed. It now
+subscribes to `zoom >= LABEL_ZOOM_THRESHOLD` directly - a boolean that only changes value when the
+threshold is crossed, so Zustand's default `Object.is` equality skips the re-render otherwise. This
+was a prerequisite for adding the `holds`/`simulationSeeded` subscriptions the resolver needs,
+not just a nicety on its own.
+
+## `localStorage` persistence limits
+
+[`seatStore.ts`](../src/store/seatStore.ts) persists `selectedSeats`, `soldSeats`, `bookings`, and
+`selectionExpiresAt` to `localStorage` via a custom `PersistStorage` that serializes `Set`
+to a plain array. This is enough for the current scope (survive a page refresh, single device, no
+login) but has real limits:
+
+- `localStorage` has a per-origin size ceiling (typically ~5β10MB depending on browser) - irrelevant
+ at 8 selected seats, but the pattern shouldn't be reused to persist large datasets.
+- No cross-device sync - a selection doesn't follow a user to a different browser or device, and
+ there's no server-side inventory, so two different people (or the same person on two devices)
+ could still both believe they've bought the same seat.
+- **Cross-_tab_ contention on one browser is now handled** (not cross-device) - see
+ [`holdProtocol.ts`](../src/utils/holdProtocol.ts) and the "Live seat-hold contention" section
+ below. `holds` and `selectionExpiresAt`'s live countdown are deliberately kept _out_ of
+ `partialize`/only partially persisted: holds are time-based shared state that would fight
+ `BroadcastChannel` sync if `localStorage` were also treated as a source of truth for them.
+- A real backend would still replace all of this with a server-held reservation (a TTL/hold expiry
+ enforced by a server nothing stops a client from lying about) and use `localStorage` only as an
+ optimistic local echo, not the source of truth.
+
+## Live seat-hold contention: the performance guarantee, and its real limits
+
+[`useHoldExpiry.ts`](../src/hooks/useHoldExpiry.ts) arms exactly one `setTimeout` for whichever
+comes soonest - a tracked hold lapsing or this tab's own checkout deadline - rather than polling.
+On fire, it calls `runExpiry()` in [`seatStore.ts`](../src/store/seatStore.ts), which prunes via
+[`holdProtocol.ts`](../src/utils/holdProtocol.ts)'s `pruneHolds()`. That function returns the
+**same** `Map` reference when nothing has actually lapsed, and the store skips its `set()` call
+entirely in that case - so a routine tick with nothing to do costs zero re-renders across the
+~1,500 mounted seats, not "one small one." This is asserted with a reference-equality (`toBe`) test
+in `seatStore.test.ts`, specifically so a future refactor that starts always returning a new `Map`
+fails a test instead of silently reintroducing a per-tick re-render.
+
+**What this simulation is honest about not being:** [`holdChannel.ts`](../src/utils/holdChannel.ts)'s
+`BroadcastChannel` transport only reaches other tabs of the _same browser on the same machine_ -
+there is no server, so two different visitors on two different computers still can't contend for
+the same seat here. The tie-break for a genuine same-instant conflict (lower session id wins,
+in [`holdProtocol.ts`](../src/utils/holdProtocol.ts)'s `ingestPeerHold`) is a deterministic
+convenience, not consensus - a real implementation would need a server to arbitrate. And the
+"simulated" holds seeded onto venue.json's static `held` seats are exactly that: a demonstration
+of the mechanism, not a claim about real inventory.
+
+## Known remaining gaps
+
+- No virtualization within an active section's own seat list (see above).
+- The "Proceed to Pay" flow (see
+ [`SeatSelectionFooter.tsx`](../src/components/SeatSelectionFooter.tsx)) is an intentional UI
+ stub - it shows an inline confirmation, not a real payment integration. `confirmPurchase()` does
+ re-validate the checkout deadline before completing, so at least the "the hold ran out mid-pay"
+ failure mode is handled - see the "Live seat-hold contention" section above.
+- ~~Seat availability (`sold`/`reserved`/`held`) is static sample data with no live update
+ mechanism (no websocket/polling) - a second user selecting the same seat isn't reflected without
+ a refresh.~~ **Partly resolved**: `held` seats now expire and release on a real timer, and a
+ second seat-selecting _tab of the same browser_ is reflected live via `BroadcastChannel` (see
+ above). The remaining, honest limitation: this is same-browser only. Two different visitors on
+ two different machines still can't see each other's live selections - that needs a server
+ pushing real inventory changes, which this project deliberately doesn't have.
+- **Test coverage is well below the org engineering standard.** The standard
+ ([`Webvoltz-Engineering-Standards/react`](../../Webvoltz-Engineering-Standards/react)) requires
+ branches 85% / functions 100% / lines 90% / statements 90%. Actual project-wide coverage today
+ (`pnpm test`, V8 provider, whole `src/` tree): **statements 54.1%, branches 56.2%, functions
+ 49.4%, lines 55.1%** - up substantially since the best-seats solver and live-hold work added
+ ~15 new, near-100%-covered pure-logic modules under `src/utils/` and `src/hooks/`, but most
+ _components_ still have no tests: `App.tsx`, `VenueMap.tsx`, `Section.tsx`, `BookingSummary.tsx`,
+ `SeatSelectionFooter.tsx`, `BookingHistoryPanel.tsx`, `HoldRuntime.tsx`, `IconButton.tsx`,
+ `Toast.tsx`, and `useVenue.ts` have none. The thresholds in [`vite.config.ts`](../vite.config.ts)
+ are ratcheted up alongside real coverage gains (set just under today's actual numbers as a floor
+ against regression), not set to the standard's targets - reaching those targets means writing
+ real test suites for all of the above, which is a substantial task on its own, not a quick config
+ change.
+- ~~CI's pnpm version pin doesn't match the actual pnpm in use~~ **Resolved**: standardized on
+ pnpm 11.x, matching this project's actual dev environment (the one pnpm version proven to work
+ here all session). `.github/workflows/ci.yml`'s `PNPM_VERSION` is now `'11'`, and
+ `package.json` pins `"packageManager": "pnpm@11.6.0"` to match - `pnpm install` confirmed clean
+ locally with both in place (this is exactly the combination that previously failed when CI said
+ `'10'` and `packageManager` said `pnpm@11.6.0`: pnpm tried to force-switch versions and aborted
+ without an interactive terminal). Not independently verified against a real GitHub Actions run
+ from here, but there's no reason pnpm 11 - a current, published major version - shouldn't install
+ and run cleanly in that environment the same way it has here.
diff --git a/docs/screenshots/booking-history.png b/docs/screenshots/booking-history.png
new file mode 100644
index 0000000..2878885
Binary files /dev/null and b/docs/screenshots/booking-history.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
new file mode 100644
index 0000000..526392c
Binary files /dev/null and b/docs/screenshots/overview.png differ
diff --git a/docs/screenshots/seat-selection.png b/docs/screenshots/seat-selection.png
new file mode 100644
index 0000000..1791320
Binary files /dev/null and b/docs/screenshots/seat-selection.png differ
diff --git a/docs/ui-ux-and-business-logic-flow.md b/docs/ui-ux-and-business-logic-flow.md
new file mode 100644
index 0000000..37277f3
--- /dev/null
+++ b/docs/ui-ux-and-business-logic-flow.md
@@ -0,0 +1,885 @@
+# UI/UX & Business Logic - Technical Flow
+
+This document walks through **exactly how the current codebase behaves**, end to end: every UI
+state, every interaction, and every business rule, with the file/line it lives in. It complements
+[`README.md`](../README.md) (project overview, setup) and
+[`engineering-notes.md`](engineering-notes.md) (performance tradeoffs) - this file is the "what
+happens when you click X" reference.
+
+---
+
+## 1. Architecture at a glance
+
+```mermaid
+flowchart TD
+ A["App.tsx loading/error/data states, zoom, pan, mobile sidebar"] --> B["useVenue() fetch /venue.json"]
+ A --> C[VenueMap.tsx]
+ A --> D[BookingSummary.tsx]
+ A --> E[Toast.tsx]
+ C --> F["Section.tsx Γ 10 collapsed or active"]
+ F -->|active only| G["Seat.tsx (Seats) ~1,500 seat + label nodes"]
+ A --> H["seatStore (Zustand) zoom Β· activeSectionId Β· selectedSeats Β· soldSeats Β· bookings Β· viewingBookingId Β· feedback"]
+ H -.persist.-> I[(localStorage: selectedSeats + soldSeats + bookings)]
+ D -.reads/writes.-> H
+ G -.reads/writes.-> H
+ F -.reads/writes.-> H
+ H -.feedback.-> E
+```
+
+Single-page app, no router. `App.tsx` owns page-level UI state (loading/error, mobile sidebar,
+mouse-drag panning); `seatStore` (Zustand) owns cross-component venue state (zoom, which section is
+open, which seats are selected/sold, transient toast feedback).
+
+---
+
+## 2. Data model
+
+[`venue.interfaces.ts`](../src/interfaces/venue.interfaces.ts):
+
+```ts
+SeatStatus = 'available' | 'reserved' | 'sold' | 'held'
+ISeat = { id, col, x, y, priceTier, status }
+Row = { index, seats: ISeat[] }
+Section = { id, label, transform, rows: Row[] }
+Venue = { venueId, name, map: { width, height }, sections: Section[] }
+```
+
+Generated once by [`scripts/generateVenue.ts`](../scripts/generateVenue.ts) into
+`public/venue.json`: **10 sections Γ 30 rows Γ 50 seats = 15,000 seats**, arranged radially around
+a 2400Γ2400 coordinate space. Price tier by row index: rows 0β4 β tier 1 (front, priciest), 5β14 β
+tier 2, 15β29 β tier 3 (back, cheapest). `status` is seeded per seat (~70% `available`, ~15%
+`sold`, ~10% `reserved`, ~5% `held` - see Β§14) rather than uniformly `available`.
+
+`x`/`y` per seat are pre-computed polar coordinates (already resolved to cartesian) - no
+trigonometry happens at render time, only at generation time.
+
+---
+
+## 3. State: `seatStore` ([`seatStore.ts`](../src/store/seatStore.ts))
+
+| Field | Type | Persisted? | Set by |
+| -------------------- | ------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `zoom` | `number` | β | fit-on-load effect, zoom buttons, section open/close, resume-selection effect |
+| `activeSectionId` | `string \| null` | β | clicking/keying a section, clicking the empty map background, viewing a booking, `selectSeatBlock` |
+| `selectedSeats` | `Set` | β (custom serializer) | `toggleSeat`, `selectSeatBlock`, `clearSelection`, `confirmPurchase` (empties it), a lost hold tie-break |
+| `selectionExpiresAt` | `number \| null` | β (plain number) | `toggleSeat` (first seat starts it), `selectSeatBlock`, cleared by `clearSelection`/`confirmPurchase`/expiry |
+| `soldSeats` | `Set` | β (custom serializer) | `confirmPurchase` (adds the seats just paid for), a remote `sold` broadcast |
+| `bookings` | `Booking[]` | β (plain JSON) | `confirmPurchase` (prepends the new booking; newest first) |
+| `viewingBookingId` | `string \| null` | β | `viewBooking`/`clearViewingBooking`, cleared by `toggleSeat`/`selectSeatBlock` |
+| `feedback` | `{type, message} \| null` | β | `toggleSeat`/`selectSeatBlock` (max-seat cap), the best-seats finder, hold contention, cleared by `Toast` after 4s or dismiss |
+| `holds` | `Map` | β | `seedSimulation`, `setHolds`, `applyRemoteSold`, `runExpiry` (prunes lapsed entries) |
+| `simulationSeeded` | `boolean` | β | `seedSimulation` (guards against seeding twice - e.g. a late cross-tab reply) |
+
+**Persistence**: `partialize` scopes `localStorage` (`key: venue-storage`) to `selectedSeats`,
+`soldSeats`, `bookings`, **and** `selectionExpiresAt`. Since `Set` isn't JSON-native, a custom
+`PersistStorage` converts each `Set β string[]` on `getItem`/`setItem` (`bookings` is a
+plain array of plain objects, so it round-trips through `JSON.stringify`/`parse` with no custom
+conversion needed) - `zoom`, `activeSectionId`, `viewingBookingId`, `feedback`, `holds`, and
+`simulationSeeded` are intentionally transient and reset on reload. `holds` in particular is
+deliberately excluded even though it's meaningful state: it's time-based (a stale hold from last
+session means nothing) and shared cross-tab via `BroadcastChannel`, so treating `localStorage` as a
+second source of truth for it would fight that sync instead of complementing it. See
+[`docs/engineering-notes.md`](engineering-notes.md#localstorage-persistence-limits).
+
+**`toggleSeat(seatId)`** - the core selection rule:
+
+```
+if the seat has a live foreign hold β set feedback (error toast), no-op
+else if already selected β remove it
+else if selectedSeats.size >= MAX_SELECTABLE_SEATS (8) β set feedback (error toast), no-op
+else β add it, and if this is the first seat, start selectionExpiresAt (now + 5 minutes)
+```
+
+Also always clears `viewingBookingId` - starting or editing a live selection supersedes browsing
+past booking history, so the two sidebar modes (Β§8) never end up fighting over what the map should
+show. Adding a second (or third, ...) seat does **not** push the deadline back - it's anchored to
+when the cart was opened, matching how a real ticketing site's hold works. No server round-trip for
+the sale itself, but see Β§15 for how holds now coordinate across tabs of the same browser.
+
+**`selectSeatBlock(seatIds, sectionId)`** - the best-seats finder's entry point (Β§16): replaces
+`selectedSeats` wholesale rather than merging, sets `activeSectionId` to the winning section in the
+same update (so there's no intermediate render where the seats are selected but not yet mounted),
+clears `viewingBookingId`, and starts a fresh `selectionExpiresAt`. Refuses (with the same cap
+feedback `toggleSeat` uses) if given zero seats or more than `MAX_SELECTABLE_SEATS`.
+
+**`confirmPurchase(total): Booking | null`** - the checkout transition (see Β§14 for the full
+picture). Re-checks `selectionExpiresAt` against `Date.now()` **inside its own synchronous `set()`
+call** before doing anything else:
+
+```
+if selectionExpiresAt is set and has already passed:
+ β clear the cart, set an error feedback, return null (no booking recorded)
+else:
+ β move selectedSeats into soldSeats, record one Booking, clear the cart and the
+ deadline, "view" the new booking, return the Booking
+```
+
+Because Zustand's `set()` runs synchronously and JS is single-threaded, this check can't race with
+the expiry timer (Β§15) - either the purchase's own `set()` call sees the deadline first, or the
+expiry's `runExpiry()` does; there's no interleaving where both think they went first. Moves
+whatever's currently selected into `soldSeats` (persisted, so it survives a reload) on success,
+records it as one `Booking` grouping those seats together (so history can show/highlight them as a
+unit rather than as loose entries in the flat `soldSeats` set), immediately "views" that new
+booking, and empties the cart - as opposed to `clearSelection()`, which just abandons the cart with
+no sale. `total` is passed in by the caller
+([`BookingSummary.tsx`](../src/components/BookingSummary.tsx), via
+[`SeatSelectionFooter.tsx`](../src/components/SeatSelectionFooter.tsx)'s Proceed to Pay button)
+rather than computed here, since per-seat pricing lives in `venue` data the store doesn't hold.
+`BookingSummary.tsx`'s `handleConfirmPurchase` only shows the "Booking confirmed" banner when the
+return value is non-null, so a hold that expired mid-payment can't produce a false confirmation.
+
+**`viewBooking(id)` / `clearViewingBooking()`** - set/clear which past booking (if any) should
+currently render its seats with the highlight ring described in Β§7; driven entirely by
+[`BookingHistoryPanel.tsx`](../src/components/BookingHistoryPanel.tsx) (Β§8).
+
+---
+
+## 4. Boot sequence & top-level UI states ([`App.tsx`](../src/App.tsx))
+
+`useVenue()` ([`useVenue.ts`](../src/hooks/useVenue.ts)) fetches `/venue.json` once on mount and
+exposes `{ venue, loading, error, refetch }`. `App` renders one of four exclusive states:
+
+1. **Loading** - centered spinner, `role="status" aria-live="polite"`, visible "Loading seating
+ mapβ¦" label.
+2. **Error** - `role="alert"` card (icon, message, **Try again** button calling `refetch()`, which
+ re-runs the fetch effect via an internal retry-token state).
+3. **No venue** (fetch succeeded but returned nothing) - plain empty-state card.
+4. **Main app** - the seating map + booking sidebar (below).
+
+### Fit-to-viewport zoom (runs once venue loads)
+
+```
+availableWidth = mapContainer.clientWidth - 64 (fallback: window.innerWidth - sidebar - 64)
+availableHeight = mapContainer.clientHeight - 64 (fallback: window.innerHeight - 64)
+bounds = getVenueContentBounds(venue) // seats' real x/y extent + padding - see below
+fitScale = min(availableWidth / bounds.width, availableHeight / bounds.height, 4) Γ 0.96
+```
+
+Uses the _real_ container size (via `mapContainerRef`), checking **both** dimensions - a
+width-only calculation was an earlier bug that let the map render taller than the viewport. The
+trailing `Γ 0.96` (`FIT_SAFETY_MARGIN`) guarantees a small buffer so the fitted content never lands
+exactly at the container's edge, which would otherwise risk forcing a scrollbar on its own - an
+artifact of measuring `clientWidth`/`clientHeight` before vs. after a scrollbar appears.
+
+`computeFitZoom()` is shared between this effect and the **Reset view** button (Β§5), so the two
+can never drift out of sync with each other.
+
+**Resume-selection branch**: if `selectedSeats` was restored from `localStorage` (non-empty), the
+effect instead finds which section contains the first selected seat, calls
+`setActiveSection(that section)` and `setZoom(2.5)`, and skips the fit-zoom entirely - so reloading
+mid-selection drops you back into that section, already zoomed in.
+
+---
+
+## 5. Zoom & pan
+
+| Control | Behavior |
+| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **`+` / `β` buttons** | `zoom Β± 0.2`. Floor: `0.2` (`MIN_ZOOM`). **No ceiling** - zoom can increase indefinitely. Does _not_ try to keep any point centered; it's a plain `setZoom(...)` call. |
+| **Reset view button** | Calls `setActiveSection(null)` (collapses whatever section is open) then re-runs the same `computeFitZoom()` the initial load uses - a one-click way back to the clean default view. |
+| **Click-drag** | `onMouseDown` captures `{clientX + scrollLeft, clientY + scrollTop}` as an anchor; `onMouseMove` sets `scrollLeft/Top = anchor β currentClientXY`. Cursor swaps `grab` β `grabbing`. |
+| **Native scroll / touch** | The map container is a plain `overflow-auto` element, so trackpad/touch scrolling works for free alongside the custom drag handler. |
+| **Section click** | Sets `zoom = 5` (in [`Section.tsx`](../src/components/Section.tsx)) - high enough that seat numbers/row letters (see Β§7) are legible immediately, not just after manual zooming. |
+
+Every zoom change (buttons, reset, section open/close) animates smoothly rather than snapping: the
+`