# Handoff: Fantasy Football Draft Board ("Betta Bring It")

**Project root:** `C:\Users\Matth\OneDrive\Projects\AI\FantasyFootball\2026`
**All files live flat in that root** (no subfolders).
**Season:** 2026–2027 · **League:** "Betta Bring It" · 12 teams · snake by default

You (Claude Code) are picking up a working, tested draft-board app. This document is the
single source of truth for what exists, how it works, and how to run/extend it. Read it fully
before changing anything.

---

## 1. What this is

A **local, offline, dual-screen fantasy football draft board** for an in-person draft party.
One operator drives a control screen on a laptop; two TVs show (a) a live draft grid and
(b) a giant "latest pick" reveal with a countdown timer. All state persists to the browser's
`localStorage` on the host PC and survives crashes. Player data is pulled from ESPN's free
public API the day of the draft.

**Design constraints that must be preserved:**
- **Light mode is the default**, but each screen now has an **independent dark/light toggle**
  (🌙 button + `d` key), added at the user's request (2026-07-01). The choice persists per screen
  in its own `localStorage` key (`ffdraft_theme_control` / `_board` / `_pick`) so TV1 and TV2 can be
  set independently of each other and of the control screen. Implementation is a CSS-variable flip
  under `html[data-theme=dark]` (position/team colors stay the same; only the chrome inverts),
  applied by a tiny inline `<head>` script **before first paint** to avoid a flash. Keep both
  palettes working when you touch CSS — don't hardcode a light-only background/text color; use the
  existing `--bg/--panel/--txt/--muted/--line/--fill` vars (or add a `html[data-theme=dark]` override).
- **All configuration lives behind the ⚙️ gear** in a single Settings modal on the control
  screen. The two TV screens stay **display-only** except for the **single small 🌙 theme toggle**
  (the only button allowed on a TV) — no other controls belong on board.html / pick.html.
- **Fully offline & dependency-free at runtime.** No CDN scripts, no npm packages loaded by
  the browser. Exports (xlsx/docx) are generated with hand-rolled ZIP+OOXML in vanilla JS.
  Keep it that way — the draft PC may have no internet during the draft.
- **Crash recovery:** every pick writes to `localStorage` immediately. Never batch writes.

---

## 2. Files in the root

| File | Role | Notes |
|---|---|---|
| `draftboard.html` | **Control screen** (the operator UI) | The big one. All logic + exports live here. |
| `board.html` | **TV 1** — running draft grid | Read-only. Polls + BroadcastChannel. |
| `pick.html` | **TV 2** — zoomed latest pick + live timer | Read-only. Mirrors timer via `localStorage`. |
| `lobby.html` | **Phone client** — managers draft from their own device | New. Talks to the relay only; turn-gated. |
| `guide.html` | **Operator Draft Guide** — private, PIN-gated war room | New. Suggestions, sleepers, opponent predictions; can draft. |
| `guide_data.json` | Curated draft intel (sleepers/fades/tiers/strategy) | Researched via web 2026-08-03. **Served only via `/guide_data` + token.** |
| `fetch_guide_stats.py` | Pulls FantasyPros (ECR/tiers/proj/news, API key) + ESPN (proj, 4-yr history) | Run draft morning after fetch_players. Key in `.fantasypros.env` (never served/printed). |
| `guide_stats.json` | Tiers, projections, league-scored 4-yr history, news ticker | Generated. **Served only via `/guide_stats` + token.** |
| `draft_server.py` | **Relay server** — serves files + bridges phones to the control browser | New. Stdlib only. Run instead of `http.server`. |
| `server_state.json` | Relay's on-disk mirror + team claims (auto-written) | Generated. Safe to delete when idle. |
| `fetch_players.py` | Pulls NFL rosters from ESPN → `players.json` | Python 3, stdlib only. No API key. |
| `players.json` | Generated player data (~990 fantasy-relevant players) | Regenerated by the fetch script. |
| `1_fetch_players.bat` | Double-click: runs `fetch_players.py` | |
| `2_start_draft.bat` | Double-click: runs `draft_server.py` (auto-restart) + opens control screen | |
| `README.md` | End-user (operator) instructions | Non-technical. |

---

## 3. How to run (must use the local server)

The app **must** be served over HTTP, not opened as a `file://` path. Two reasons:
1. `draftboard.html` does `fetch("players.json")` — blocked on `file://`.
2. Export blob downloads and BroadcastChannel behave correctly under http origin.

**Run order:**
```
1_fetch_players.bat      → writes players.json   (needs internet; do this draft-day)
2_start_draft.bat        → serves on http://localhost:8777 and opens draftboard.html
```
Then in the control screen: ⚙️ → **Load / Refresh Players**, set teams/order/timer, then
**📺 Open TV Screens** (opens board.html + pick.html in named windows; drag to TVs, F11).

If you (Claude Code) need to test locally, `cd` to the root and run
`python -m http.server 8777`, then hit the three URLs. Do **not** test by opening the HTML
files directly — you'll get false "players won't load" failures.

---

## 4. Architecture & data model

### Cross-window communication
Three separate browser windows share state through **`localStorage` + `BroadcastChannel`**:
- Control screen **owns** the state. On every mutation it calls `save()` →
  `localStorage.setItem(LS_STATE, ...)` then `broadcast()` which posts a `BroadcastChannel("ffdraft")`
  message `{type:"state"}` **and** bumps a `ffdraft_ping` key (storage-event fallback).
- `board.html` / `pick.html` listen for the BroadcastChannel message and the `storage` event,
  re-read `localStorage`, and redraw. They also poll every ~1.2–1.5s as a safety net.
- The **timer** is mirrored separately: control writes
  `localStorage["ffdraft_timer"] = {remaining, running, on}` every tick; `pick.html` reads it.

### localStorage keys
| Key | Written by | Contents |
|---|---|---|
| `ffdraft_state_v2` | control | The entire draft state (below). **Bump the version suffix if you change the schema in a breaking way.** |
| `ffdraft_players_v1` | control | Cached copy of the loaded player array (so a refresh works even offline). |
| `ffdraft_timer` | control | `{remaining:int, running:bool, on:bool}` for the TV timer mirror. |
| `ffdraft_ping` | control | Timestamp; only exists to trigger `storage` events. |
| `ffdraft_players_meta` | control | `{generated, count}` of the cached players, for the data-fallback banner. |
| `ffdraft_theme_control` / `ffdraft_theme_board` / `ffdraft_theme_pick` | each screen | `"light"` \| `"dark"` — per-screen theme, read on load, written on toggle. **Independent** — one screen's theme never touches another's key. |

### State shape (`ffdraft_state_v2`)
```js
{
  league: "Betta Bring It",
  teams: [ { id:1, name:"12AM", mgr:"", color:"#2563eb", logo:"" }, ... 12 total ],  // ARRAY ORDER = DRAFT ORDER
  rounds: 16,
  snake: true,                 // false = linear
  timerSecs: 90,               // default pick clock
  timerOn: true,
  autoBackupEvery: 10,         // auto-download a backup every N picks (0 = off)
  blurMode: false,             // Next Pick Player Blur — hide the board between picks
  lineup: { QB:1,RB:2,WR:3,TE:1,FLEX:2,K:1,DST:1,BENCH:5 },  // starting-lineup slots (NFL.com); drives roster NEEDS
  revealSecs: 10,              // Settings: how long the big reveal card holds (control + board TV; NOT pick.html)
  draftLive: false,            // false until the operator presses ▶ Start Draft — gates ALL picking
  draftStartedAt: 1785787000000,  // stamped when Start Draft is pressed
  lineupDefaultV: 2,           // migration marker: v2 = the NFL.com default (WR3/FLEX2/bench5)
  rulesText: "SCORING — ...",  // editable league rules/scoring reference (Settings); DEFAULT_RULES seeds it
  subLeagues: null,            // or [[id,id,id,id],[...],[...]] — the 3-league (4-teams-each) random split
  stateEpoch: 1782924000000,   // bumped (Date.now) on restore/reset so the TVs re-seed reveals instead of replaying
  picks: [                     // append-only; index+1 == overall pick number
    {
      overall: 1, round: 1, slot: 1, teamId: 1,
      player: { id, name, team, teamName, pos, img, logo, jersey, bye, adp, writein:false },
      seconds: 38,             // time the manager took on this pick
      over: 0,                 // max(0, seconds - timerSecs)
      keeper: false            // true = auto-placed from state.keepers (0 seconds)
    }, ...
  ],
  drafted: { "<playerId>": true, ... },  // fast "is taken" lookup
  keepers: [                   // pre-draft: pre-assigned kept players (see §5b)
    { teamId: 5, round: 3, player: { id, name, team, teamName, pos, img, logo, jersey } }, ...
  ]
}
```

**Key invariants:**
- `teams` array order **is** the draft order. Reordering teams in Settings reorders the draft.
  `orderForPick(overall)` computes round/slot/teamId from array position + snake flag.
- `picks` is append-only during normal play; **Undo** pops the last element and deletes its
  `drafted[id]`. Overall pick number is always `picks.length + 1`.
- A **write-in** player has `id` like `"WI_" + Date.now()` and `writein:true`. Write-ins are
  NOT added to `drafted` uniqueness beyond their generated id (they can't collide).
- Player `id` comes from ESPN athlete id (string). `drafted[id]` removes them from the pool.

### `players.json` shape (from fetch_players.py)
```json
{
  "generated": "2026-06-30 19:59:47",
  "season": "2026-2027",
  "count": 989,
  "adpRanked": 148,
  "players": [
    { "id":"3918298", "name":"Josh Allen", "team":"BUF", "teamName":"Buffalo Bills",
      "pos":"QB", "jersey":"17",
      "img":"https://a.espncdn.com/i/headshots/nfl/players/full/3918298.png",
      "logo":"https://a.espncdn.com/i/teamlogos/nfl/500/buf.png",
      "adp":3.1, "bye":7, "news":"...latest note...", "newsDate":"Wed Jun 24" },
    { "id":"DST_KC", "name":"Kansas City Chiefs D/ST", "team":"KC", "teamName":"Kansas City Chiefs",
      "pos":"DST", "jersey":"", "img":"<team logo>", "logo":"<team logo>", "adp":999.9 },
    ...
  ]
}
```
- **`adp`** — real fantasy ADP (float) from the FantasyFootballCalculator public API (PPR, 12-team,
  current year). Players/defenses with no ADP get the sentinel **`999.9`** so they sort last.
  `adpRanked` = how many got a real ADP (~148). Board sorts the pool by `adp` ascending.
- **`logo`** — the team's ESPN logo URL (used for card watermarks + the pick-reveal badge).
- **DST** — one `"DST_<ABBR>"` entry per team (32 total) so defenses are draftable, not just write-ins.
  Their `img`/`logo` are the team logo. Kickers now come through correctly (ESPN's `PK` → `K`).

---

## 5. The draft-order / snake math (don't break this)

```js
function orderForPick(overall){            // overall is 1-based
  const n = state.teams.length;            // 12
  const round = Math.floor((overall-1)/n) + 1;
  let idx = (overall-1) % n;               // 0-based slot in round
  if (state.snake && round % 2 === 0) idx = n - 1 - idx;  // reverse even rounds
  return { round, slot: idx+1, teamId: state.teams[idx].id };
}
```
Verified behavior (12 teams, snake): pick 1→team[0], pick 12→team[11], pick 13→team[11]
(round 2 reversed), pick 24→team[0], pick 25→team[0] (round 3 normal). `board.html` reproduces
the SAME formula independently to lay out the grid — **if you change the algorithm, change it in
both `draftboard.html` and `board.html`.**

### 5b. Keepers (auto-draft engine)
Keepers live in `state.keepers` as `{teamId, round, player}` and are set pre-draft in Settings.
- A keeper's player is **excluded from the available pool** (`keeperIdSet()` filters `renderPlayers`
  and the keeper picker).
- **Keeper player picker is a custom typeahead, not a `<datalist>`.** A native `<input list>`/`<datalist>`
  silently caps/drops suggestions when it holds ~1000 options (top players like Josh Allen went missing),
  so it was replaced with `keepSearch`/`keepPick` (in `#keepPlayerDD`): we filter the full available pool
  ourselves (name/team match, ADP-sorted, drafted+kept excluded) and render clickable rows. `keepSelId`
  holds the chosen id; `addKeeper` prefers it, falling back to `findPlayerByDisplay`. **Don't revert this to
  a `<datalist>`** — it will drop players again.
- **`autoDraftKeepers()`** is the whole engine: starting at the current overall pick, while that
  pick's `(teamId, round)` matches a keeper, it appends the keeper as a real pick
  (`keeper:true, seconds:0`) and advances. It's called after every `doPick`, after `saveSettings`,
  after `doUndo`, and once at load. This is why keepers only ever fill *in order* — the pointer must
  reach their slot naturally.
- **Undo is keeper-aware:** `lastRealIndex()` finds the last non-keeper pick; `doUndo` drops any
  trailing auto-keeper picks, then the one real pick, then re-runs `autoDraftKeepers()`. A lone
  keeper can't be undone (by design — keepers are fixed). Undo requires the PIN (§5d).
- `removeKeeper()` also pops the keeper's board pick if it's the last pick; it refuses if the keeper
  is buried mid-draft (undo later picks first). Manage keepers **before** the draft to avoid this.
- **Indicators:** keeper picks carry a `🔑 KEEPER` badge on the board cell (`.kbadge`) and export
  with `Keeper = "Yes"`.
- **Board reservations (pre-draft):** keepers are auto-drafted only when the pointer *reaches* their
  slot, so before then `board.html` **pre-places them in their reserved cell** as a `.cell.kreserve`
  (dashed, position-colored border, dimmed, `🔑 KEEPER` badge) so the whole board shows the keepers
  up front. `draw()` builds `keeperAt[teamId-round]` and, for any empty cell, uses `order(s,overall)`
  to find that cell's true (teamId, round) — **not** the loop's snake-arithmetic `idx` — and renders
  the reservation. When the draft reaches the slot, `autoDraftKeepers` makes it a real (solid) pick and
  the reservation is replaced automatically. (The old control-screen `.pick.kp` team-chip indicator is
  gone — Teams-panel pick chips were removed in the compaction pass.)
- **`pick.html` must walk picks IN ORDER (don't jump to the newest).** When a manager's pick is
  immediately followed by an auto-drafted keeper, `autoDraftKeepers` appends both in the *same* tick, so
  rendering `picks[picks.length-1]` skipped the manager's pick entirely and the "Latest Pick" TV jumped
  straight to the keeper. `draw()` now tracks `shownOverall`/`holdUntil` and advances **one pick at a
  time**, holding each for `HOLD_MS` (10 s — matches the board's reveal hold) *only while more picks are
  queued behind it*. Normal single picks still appear instantly; an undo, a new `stateEpoch`, or a jump of
  more than one round snaps straight to the newest. (`board.html` never had this bug — it has a real
  reveal queue.)
- **Keeper reveal:** auto-drafted keepers now play the reveal too, with a `🔑 KEEPER` badge on the
  card. `doPick` enqueues the human pick + any keepers that followed via the reveal queue
  (`queueReveals`/`pumpReveals`), and the board's `maybeReveal` no longer filters keepers out.

### 5c. ADP ordering
The pool is sorted by `adp` ascending (unranked = 999.9 → sort last), then name. Each card shows a
small ADP pill (top-right); the confirm popup and settings picker also surface it. Change the
scoring format by editing `ADP_URL` in `fetch_players.py` (`ppr` → `half-ppr` / `standard`).

### 5d. PIN gate (`PIN = "3133"`)
`requirePin(msg, cb)` opens a shared modal; `cb` runs only on the correct 4-digit PIN. It guards
**Undo Last Pick**, the **timer Reset** button, and — **once the draft has started** — the Settings
**Reset Draft** (PIN **and** a second confirm) and the **Randomize / Reset order** buttons. Before
any real pick is made those order buttons are ungated. To change the PIN, edit the `PIN` constant.

### 5e. Draft reveal animation (`revealPick`)
`doPick` commits the pick, then calls `revealPick(pick, onDone)` which shows the `#revealOverlay`:
a ~75%-screen card (team/manager, "PICK #N · ROUND R", headshot + team-logo badge, player name)
that **zooms in**, **holds ~10 seconds** (`revealTO`; click anywhere to skip), then **flies/shrinks to
the drafting team's card** (`#teamList [data-teamid=...]`) and fades out. The pick clock is **paused**
during the reveal and `timerStartFresh()` runs in `onDone` when it finishes. Keeper auto-drafts do
**not** trigger the reveal. CSS note: the big headshot is styled via `.rv-shot>.rv-face` (a dedicated
class) so the small `.rv-logo` badge isn't sized up with it.

**`board.html` runs the same reveal** (draft-day setup = control screen on the dev box + the grid on
the external TV, both showing the reveal). There it's driven reactively: `maybeReveal(s)` (called at
the end of `draw()`) fires `playReveal` on the newest **non-keeper** pick whose `overall` exceeds
`lastRevealedOverall`, then flies the card to that pick's grid cell (`td[data-overall=...]`). On first
load `lastRevealedOverall` seeds to the current max so existing picks don't replay. The board reveal
auto-runs (no click-to-skip); if the operator skips early on the control screen the two can desync by
a few seconds — harmless. `pick.html` is unchanged (still the optional giant-latest-pick screen).

**Restore/reset must NOT replay reveals (`state.stateEpoch`).** A wholesale state swap (Import/Restore,
Reset Draft) jumps the pick count without going through `doPick`, which would otherwise make every
"new" pick look fresh and replay the entire draft on `board.html`. To prevent this, the control screen
**bumps `state.stateEpoch = Date.now()`** in `importBackup` and `doResetDraft` (and seeds it on load).
`board.html.maybeReveal` tracks `lastEpoch`: when `s.stateEpoch` changes it calls **`abortReveals()`**
(cancels the queue + any in-flight overlay/round card) and **re-seeds `lastRevealedOverall` to the new
max without revealing** — so a restore just snaps to the restored grid, and only genuinely new *live*
picks (same epoch, `+1`) animate. It also re-seeds down on an undo/shrink and ignores any jump bigger
than one round (belt-and-suspenders). `pick.html` resets its `lastOverall` on an epoch change so it
cleanly re-renders the restored latest pick. **If you add another bulk state mutation, bump `stateEpoch`.**

---

## 6. Exports (xlsx / docx / csv) — how they work

There are **no libraries**. `draftboard.html` contains:
- `crc32()`, `sB()` (string→UTF-8 bytes), `makeZip(files)` — a minimal **STORE-method** (no
  compression) ZIP writer. Produces a `Uint8Array`.
- `exportCSV()` — BOM + quoted CSV.
- `exportXLSX()` — builds `[Content_Types].xml`, `_rels`, `workbook.xml`, `styles.xml`,
  `worksheets/sheet1.xml` (inlineStr cells, one styled header row) → `makeZip` → download.
  Helpers: `colRef(n)`, `sheetXml(headers, rows)`, `xesc()`.
- `exportDOCX()` — builds `[Content_Types].xml`, `_rels`, `word/document.xml` (title +
  subtitle + bordered table, blue header row) → `makeZip` → download. Helper: `docxCell()`.
- `exportTimes()` — per-manager aggregation (total/avg/longest/over) → CSV.

All were **validated end-to-end**: generated files open cleanly in `openpyxl` and `python-docx`.
The Excel file triggers a harmless "no default style" warning in openpyxl — cosmetic, ignore.

**If you extend exports:** keep them dependency-free. If you truly need compression or complex
styling, prefer adding a *separate optional* Python export path over bloating the offline HTML.
The commissioner imports these into NFL.com Fantasy manually, so **stable columns matter** —
current column order is:
`Overall, Round, Pick, Team, Manager, Player, Pos, NFL Team, Write-In, Keeper, Seconds, Over(s)`.
(`Keeper` = "Yes" for auto-drafted keeper picks. DOCX uses short headers and includes `Keep`.)

---

## 7. Timer behavior

- Owned by the control screen. `timerSecs` default 90, set in Settings.
- Auto-starts fresh on each completed pick (`timerStartFresh()`), amber ≤15s, counts **up in
  red** (negative `remaining`) when over.
- `pickElapsed` accumulates the seconds the current manager has taken; on pick it's stored as
  `seconds`, and `over = max(0, seconds - timerSecs)`.
- **Undo resets the timer** (`timerReset()`), by design.
- Mirrored to `pick.html` via `localStorage["ffdraft_timer"]`.

---

## 8. fetch_players.py notes

- Stdlib only (`urllib`). Hits:
  - `https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams` (32 teams + logos)
  - `.../teams/{id}/roster` per team
  - `https://fantasyfootballcalculator.com/api/v1/adp/ppr?teams=12&year=2026` (ADP)
- Skips `injuredReserveOrOut / suspended / practiceSquad` groups.
- **`POS_MAP = {"PK":"K", "FB":"RB"}`** — ESPN labels kickers `PK` (the board expects `K`; without
  the map **zero kickers** come through). `FB` folds into `RB` — the league has no fullback slot.
- `KEEP_ALL = False` by default → keeps only `QB, RB, WR, TE, K, FB`. **Set `KEEP_ALL = True`
  to include defense/IDP/OL** (for IDP leagues) and re-run.
- **ADP** (`fetch_adp` + `norm_name`): matches ESPN players to FFC ADP by normalized name
  (lowercased, suffixes/punctuation stripped); defenses match by team abbr. `TEAM_FIX` maps FFC's
  odd codes (e.g. `WAS`→`WSH`). Unmatched → `ADP_UNRANKED = 999.9`. To change scoring, edit the
  `ppr` in `ADP_URL`.
- **D/ST**: after rosters, one `DST_<ABBR>` entry per team is appended (image = team logo).
- **Logos**: pulled from each team's ESPN `logos[0].href`; attached to every player as `logo`.
- Headshot URL pattern: `https://a.espncdn.com/i/headshots/nfl/players/full/{id}.png`.
- Polite 0.15s sleep between teams. Sorts by `(adp, pos, name)`. Writes `players.json` next to the script.
- These are offseason **90-man rosters** (draft is preseason), so WR/TE counts run high (~957 skill
  players). That's expected, not a bug — search narrows the pool; ADP sorts the meaningful names first.
- ESPN's API is undocumented/unofficial. If the shape changes, the parse is in `main()` —
  the athlete objects come in `data["athletes"][group]["items"][]` with `id`, `fullName`,
  `position.abbreviation`, `jersey`.

---

## 9. Built since the initial handoff (2026-06-30) & what's still open

**Shipped & verified end-to-end (all three screens):**
- ✅ **ADP ordering** — real 2026 ADP from FantasyFootballCalculator; pool sorts by it, cards show a pill (§5c).
- ✅ **Defense (D/ST)** — 32 draftable team defenses with logos + a DST filter chip/color (green `--dst`).
- ✅ **Kicker fix** — ESPN `PK`→`K` (kickers were previously dropped entirely).
- ✅ **Keepers** — Settings UI to pre-assign `{player, team, round}`; auto-draft engine (§5b).
- ✅ **PIN gate (3133)** — Undo, timer Reset, and post-start Settings resets (§5d).
- ✅ **Confirm popup picture** — headshot + team logo + ADP in the draft-confirm modal.
- ✅ **Position circles** — per-team colored count circles (QB/RB/WR/TE/K/DST) in the Teams column.
- ✅ **Team logos on cards** — faint watermark on each player card + a logo badge on the pick reveal.
- ✅ **Layout** — Write-In moved to a compact button at the right of the filter row (out of the pool);
  "Open TV Screens" is now a **📺 icon** next to the ⚙️ gear in the top row.
- ✅ **Draft reveal animation** (§5e) — drafting a player zooms a ~75% card overlay, holds ~10s, then
  flies it to the drafting team's card. Runs on **both** the control screen (flies to the Teams card)
  and `board.html` (flies to the grid cell).
- ✅ **Position color scheme** — unified to **QB red, RB green, WR blue, TE orange, K purple, DST brown**
  across badges, circles, and outlines (all three screens; via the `--qb/--rb/...` vars).
- ✅ **Position-colored card outlines** — each player card (control) and each filled draft-board cell
  gets a border in its position color (`.o-<POS>` classes).
- ✅ **FB folded into RB** — no fullback slot; fullbacks now count/color/filter as RB everywhere.
- ✅ **Teams column = upcoming pick order** — current picker on top, then ON DECK / 3rd / 4th…
  (`upcomingTeamIds()`, snake-aware); compact tiles; column auto-scrolls to top on each pick so the
  current picker never scrolls off (`_lastTeamsOverall`).
- ✅ **Keeper indicators + export** — 🔑 on team chips and board cells; `Keeper` column in exports.
- ✅ **Keepers trigger the reveal** (both screens) with a `🔑 KEEPER` badge (reveal queue).
- ✅ **Bye weeks** on every player (from FFC `bye` → team map) shown on cards/confirm/reveal.
- ✅ **Team colors + logos** — auto-assigned color per team (`TEAM_COLORS`, editable in Settings
  with a color picker + image upload → resized data URL in `team.logo`); shown on team tiles, the
  on-clock panel, board column headers, and the reveal card accent.
- ✅ **Auto-backup + restore** — full-state JSON backup auto-downloaded every N picks
  (`state.autoBackupEvery`, default 10; `maybeAutoBackup`), plus **Download Backup Now** and
  **Import / Restore Draft** in Settings (`downloadBackup`/`importBackup`) to resume after a crash.
- ✅ **Draft recap / awards** — `computeRecap`/`recapHTML` (biggest steal/reach vs ADP, fastest/
  slowest drafter, longest pick, Mr. Irrelevant, keepers). Auto-shows full-screen on `board.html`
  when the draft completes; **🏆 Draft Recap** button in Settings on the control screen.
- ✅ **Board = fixed equal-width columns** (`table-layout:fixed`; RD col 40px) with team/player names
  wrapping; **tight tiles** (`td{padding:1px}`) that show only the pos badge + name (no `TEAM · #pick`);
  **rows auto-match the tallest tile in that row** (no forced global height → empty rows stay short),
  and **every `.cell` fills its row height** so all tiles in a row are equal — via the `td{height:1px}` +
  `.cell{height:100%}` table hack (plain `height:100%` on a table-cell child won't resolve otherwise);
  the body **auto-scrolls to the current round** (guarded by `_lastScrollRound`).
- ✅ **Sticky header (frozen, no bleed-through)** — the header is a `<thead>` with `position:sticky`,
  opaque bg, and high z-index; the table uses **`border-collapse:separate`** and the **`.grid-wrap` has
  no padding** (padding created an 8px gap above the header where scrolling rows peeked through — the
  real cause of the reported bleed).
- ✅ **Control player column scroll** — search + position filters + Write-In are **locked at the top**;
  only `#plist` scrolls (`#playersCol` is a flex column, `overflow:hidden`; `#plist{flex:1;overflow-y:auto}`).
  Note: the grid list needs **`align-content:start;grid-auto-rows:max-content`** or a grid-in-flex bug
  collapses every card to ~19px.
- ✅ **Teams column compacted** — dropped the per-player pick chips (the board shows picks); kept the
  position circles; narrowed the column (`.app` grid `... 244px`) to cut whitespace.
- ✅ **PIN field is not a password input** — it's `type="text"` with `-webkit-text-security:disc` (masked)
  + `autocomplete=off` + `data-1p-ignore`, so the browser/password manager no longer offers to "save a
  login" when drafting, and the "password field not in a form" console warning is gone.
- ✅ **Next Pick Player Blur** (`state.blurMode`, Settings toggle) — after each pick's reveal the players
  column is covered by `#blurgate`, a fixed overlay using **`backdrop-filter: blur+grayscale`** (NOT a
  `filter` on `#plist`, which froze the renderer with 300 cards) that hides names, ranks, AND position
  colors. The card (`#bgInner`, rebuilt in `showBlur`) shows the **next manager**, the **last pick** with
  a **pick-vs-ADP verdict** (`adpVerdictText`: STEAL/REACH/on-ADP) and **recent news**, and a **rotating
  NFL fact** (`NFL_FACTS`, 97 of them → none repeats until pick 98 / round 9; indexed by `currentOverall`).
  The pick **clock starts when the reveal ends even while hidden**; **Ready to Pick** (`readyToPick`) just
  reveals the board. `hideBlur` on undo / toggle-off.
- ✅ **Timer states** — `.tval`/`.tmr` go **red + flashing under 10s** (`.danger`, `@keyframes tflash`)
  and **steady red when over** (`.over`, no flash) on both the control screen and `pick.html`.
- ✅ **Player news** — `fetch_players.py` pulls a short recent-news blurb (ESPN `rotowire`, threaded,
  `FETCH_NEWS`) for ranked players into `player.news`/`player.newsDate`; shown in the blur's last-pick card.

**Shipped 2026-07-01 (Turn 13–14):**
- ✅ **Starting-lineup config + roster NEEDS** — `state.lineup` (default matches the NFL.com league:
  `QB1/RB2/WR3/TE1/FLEX2/K1/DST1/bench5` = 11 starters + 5 bench = 16 rounds). Editable in Settings
  ("League Rules & Starting Lineup", 8 number inputs). `starterNeeds(teamId)` computes remaining
  **starter** slots and shows `.need` badges on the on-clock panel + the blur card. **FLEX is filled by
  surplus RB/WR/TE** (dedicated slots first, then any RB/WR/TE overflow covers FLEX). A one-time
  migration (`lineupDefaultV`) bumps any saved lineup still on the old `WR2/FLEX1/bench6` default.
  **Badge-class gotcha (fixed 2026-08):** build the badge class from **`x.pos`**, never `posClass(x.pos)` —
  `posClass` maps anything outside QB/RB/WR/TE/K/FB/DST to `WI`, so **FLEX** became `.need.WI`.
  `lobby.html`/`guide.html` have no `.need.WI` rule, so the FLEX badge painted white-on-white and looked
  **missing entirely** (the needs math was always right — only the color was). Fixed on all three screens.
- ✅ **FLEX position filter** — a `FLEX` chip in the player filter row shows any **RB/WR/TE**
  (`renderFilters` array + `renderPlayers` special-cases `posFilter==="FLEX"`).
- ✅ **League rules/scoring reference** — Settings shows an editable `#setRules` textarea (seeded from
  `DEFAULT_RULES`, stored in `state.rulesText`) for quick in-room reference of scoring/roster limits.
- ✅ **Per-team draft grades** — `computeRecap` adds a `grades` map (avg of `overall − adp` per team)
  → `letterGrade()` (A+…D); rendered in a `.rc-grades` block in the recap on both the control screen and board.html.
- ✅ **End-of-round recap overlay** — `#roundOverlay` on the **board TV only** (deliberately not on the
  control screen). When a round finishes, `roundRecap(state,round,onDone)` lists **every player taken
  during that round** — `R.SS` pick number, position badge (color-coded), player name (🔑 for keepers),
  and the manager — in a 2-column grid, held for **15 s**. Detection is by **overall position, not
  `slot`**: `isRoundEnd(pick,n,rounds)` = `overall % n === 0 && round < rounds`, i.e. the round's *last*
  pick. (`slot` is the reversed board column on even rounds, so it can't be used; an earlier `slot===1`
  test wrongly fired on the last pick of even rounds.) The **final round is skipped** so it doesn't
  collide with the draft-complete awards recap. It runs inside the reveal queue (`pumpReveals`), so the
  order is *reveal (10 s) → recap (15 s) → next reveal*; it re-reads state via `load()` so the list is
  complete. At **Start Draft** there's nothing to recap, so `roundKickoff()` shows a 3 s "ROUND 1 /
  DRAFT IS LIVE" card instead. Both auto-dismiss (no click-to-skip on a TV).
- ✅ **Randomize into 3 leagues** — Settings button Fisher-Yates-shuffles the 12 teams into 3 groups of
  4 (`randomizeSubLeagues`/`renderSubLeagues`, stored in `state.subLeagues`; clearable).
- ✅ **Headshot 404 cleanup** — `fetch_players.py` `VALIDATE_HEADSHOTS` HEAD-checks every headshot
  (threaded) and blanks the ~148 that 404, killing the console error spam / broken-image flicker.
- ✅ **Data-fallback banner** — if `players.json` can't be fetched, `loadPlayers` falls back to the
  cached `ffdraft_players_v1` and shows a `#dataBanner` ("using cached data from <generated>").
- ✅ **Per-screen dark mode** — independent 🌙/`d` toggle on all three screens (see §1 constraints).
- ✅ **Simulate Full Draft (testing)** — Settings → 🧪 Simulate / Test → `simulateDraft()` clears the picks
  and auto-drafts the entire board with realistic randomness (`simPickPlayer`: ADP-sorted pool, weighted
  `r*r` pick for variance, K/DST held until the last 2 rounds), assigns random pick times (so the time
  awards populate), and **respects keepers** (reuses `autoDraftKeepers` + `keeperIdSet`, so keepers land
  in their slots and aren't double-drafted). It builds real pick objects directly — **no `revealPick`, no
  timer, no per-pick `save()`** — then `save()`s once and stamps a new `stateEpoch`, so the TVs **snap** to
  the finished board with no reveal replay (same mechanism as restore, §5e) and auto-show the recap. Opens
  the recap on the control screen too. Click it repeatedly for fresh random drafts to shake out bugs.

**Still open / good next tasks:**
1. **Hard roster-slot enforcement** — NEEDS are now *surfaced* (see above) but never *blocked*.
   Optionally warn/confirm in `doPick()` when a team drafts beyond a filled slot with no bench room.
2. **Per-pick sound / horn** on the TV when a pick lands, and an audible 10s-left warning.
3. **Draft-clock auto-advance** option (auto-pick highest ADP if the timer hits 0).

When implementing anything player-data-related, remember the **light mode**, **gear-only
config**, and **offline** constraints above. (ADP/logo data needs internet at *fetch* time only —
once `players.json` is written, everything runs offline as before.)

---

## 10. Multi-device / phones — the relay (`draft_server.py` + `lobby.html`)

Added 2026-08 so managers can draft from their own phones on the LAN. **The control browser stays the
single brain and single writer** — the relay never runs draft logic. It's a bridge only.

**Topology.** Run `draft_server.py` (via `2_start_draft.bat`) INSTEAD of `python -m http.server`. It
serves the static files AND holds: a **mirror** of the draft state (pushed by the control browser), the
**claims** registry (which device owns which team), and a **pick-request mailbox**. Everyone connects to
`http://<host>:8777`. **Open the control/board/pick screens on the host PC via `http://localhost:8777`;
phones use `http://<reserved-ip>:8777/lobby.html`.**

**Privilege boundary (no secrets needed).** Privileged endpoints — `/push /timer /pending /resolve
/claims /release /whoami` — are **loopback-only** (`client_address in 127.0.0.1/::1`). Only the control
browser (on localhost) can call them; phones (LAN IPs) can't. That's why the control MUST be opened via
localhost — it self-checks `/whoami` and toasts a warning if it's not seen as local. Phone endpoints:
`/state /teams /join /claim /pick /pickresult`. Everything is same-origin per client, so no CORS.

**Pick flow (why a manager can NEVER double-pick).** Phone → `POST /pick {token, playerId, forOverall}`
→ relay soft-checks (valid token, on-clock team per its mirror, no existing queued request for that team)
→ queues it. The control browser's `netTick()` (1 Hz) polls `/pending` and **ratifies each once**
(`_netProcessed` set) via `ratifyPhonePick`: rejects unless the requesting team is on the clock AND
`forOverall === currentOverall` (kills stale/duplicates) AND the player is undrafted/not-a-keeper, then
commits through the **normal `doPick`** (reveal, timer, save→push). Because JS is single-threaded and
`doPick` advances `currentOverall`, a second request for the same slot always fails the `forOverall`
check. `doPick` also has a top guard (`state.drafted[id]` → abort) so the **operator** can't commit a
player a phone just took. Net: one writer, turn-gated, idempotent — verified end-to-end.

**Claims / identity (Light).** One join PIN (`state.joinPin`, default `"2026"`, editable in Settings →
Connections) gates entry; then the phone taps its team, which **locks to that device** (`secrets` token
stored in the phone's localStorage). `/claim` refuses a team already held by a *different, still-connected*
device. Admin **Release** (Settings → Connections, PIN-gated via `releaseTeam`/`releaseAllTeams` →
`POST /release`) frees a team's claim (clears identity only, never picks); the released phone's next
`/state` returns `me.valid=false` and it drops back to the team picker.

**Connection indicator.** `netTick` writes `localStorage["ffdraft_conns"]` = `{teamId:{connected,lastSeen,label}}`;
`board.html` reads it and shows a **green dot** on each connected manager's column header. "Connected" =
last-seen within `CONN_WINDOW` (12 s); a live phone polls every 1.2 s so it stays green.

**Lobby QR on the pick TV.** The operator sets a **Lobby URL** and uploads a **QR image** (Settings →
Connections). The URL auto-fills from the relay's **`/lan`** endpoint (`get_lan_ip()` picks the default-route
NIC), but it's editable — the host often has several IPs (Hyper-V/WSL virtual adapters), so it must be
verifiable/overridable. Both live in `state.lobbyUrl` / `state.lobbyQR` (a data URL, downscaled to ≤512px on
upload). `pick.html`'s `drawJoin()` shows the QR + URL + join PIN **full-screen before the draft**
(`picks.length===0`, class `.pre`) and shrinks it to a **bottom-left corner card** once picks start
(`.mini`); if only a URL (no image) is set it still shows the URL+PIN. **QR generation is intentionally
user-provided** — generated offline for the known IP, guaranteed scannable, and it sidesteps both the
multi-NIC auto-detect problem and shipping an unverifiable in-app QR encoder.

**Recovery.** The relay writes `server_state.json` (state + timer + claims) atomically (temp→fsync→rename)
on every change; on restart it reloads it. `2_start_draft.bat` wraps the server in an auto-restart loop,
so a crash/reboot recovers to the last state within seconds. The control browser's own localStorage +
Downloads backups remain independent recovery sources. If wifi/the relay dies, the control PC (localhost)
is unaffected — you just lose the phones and keep drafting.

**No stale HTML / never-blank lobby.** The relay serves app HTML (`.html`) with `Cache-Control: no-store`
(via an `end_headers` override) so phones always get the latest `lobby.html`. The lobby loads the large
`players.json` in the **background** (never blocks boot), wraps every render in try/catch that surfaces
errors as an **on-screen red banner** (`showFatal` + `window.onerror`/`unhandledrejection`) so a phone can
never go silently blank, and shows a clear *"connected — waiting for the host PC"* state when the relay has
no pushed state yet. If a tester reports a blank screen, it means an *older cached* lobby — hard-refresh. **Gotcha (fixed):**
`showLobby()`/`showGate()` must set an **explicit** `display` (`block`/`flex`), never `''` — the CSS has
`#lobby{display:none}`, so `style.display=''` reverts to `none` and the whole lobby stays invisible even
though its content is fully rendered. (That was the original "connected but blank" bug.)

**⚠ Malformed connections (fixed 2026-08-25).** `end_headers()` is called for error responses too, and on a
request that fails to PARSE (`send_error` from `parse_request`) **`self.path` does not exist yet** — the
no-store override crashed with `AttributeError: 'Handler' object has no attribute 'path'` and dumped a
30-line traceback per bad packet. Always use `getattr(self, "path", "")` in `end_headers`. The traffic that
triggered it was a LAN device speaking **HTTPS to this HTTP-only port** (phone browsers increasingly try
https:// first) — a TLS ClientHello starts with byte `0x16`, which the HTTP parser reads as junk. Now:
`send_error()` detects that byte and logs one rate-limited hint (`_warn_https`, once per IP per minute)
telling the operator to send the player to `http://…`, and `QuietServer.handle_error` prints a single line
instead of a traceback for anything else malformed (port scans, phones dropping off Wi-Fi are normal on a
party LAN). Verified against TLS handshakes, bad versions, binary garbage, HTTP/0.9 and truncated requests:
**0 tracebacks, server stayed healthy, `/whoami` `/state` `/lobby.html` all still 200.**

**Invariants:** don't move draft logic into the server (keep it a relay); keep privileged endpoints
loopback-only; open the control via localhost; every state mutation on the control must funnel through
`doPick`/`save` so it ratifies + pushes uniformly.

---

### 10b. Start Draft (`state.draftLive`)

The draft used to begin implicitly on the first pick. There is now an explicit **▶ Start Draft** button
(top of the control column, above On The Clock) driving `state.draftLive`:

- **Gates every pick path.** `doPick` refuses ("Press ▶ Start Draft first"), `ratifyPhonePick` rejects, and
  the relay's `/pick` rejects with *"The draft hasn't started yet"* — so neither the operator nor a phone can
  pick during setup. **Keepers still auto-place** (they go through `autoDraftKeepers`, not `doPick`), which is
  why the QR/lobby key off `draftLive` rather than `picks.length`.
- **Starts the clock** — `startDraft()` calls `timerStartFresh()` (and `showBlur()` if blur mode is on).
- **Drives the TVs:** `pick.html` keeps the join QR **full-screen until `draftLive`**, then shrinks it to the
  corner; `board.html` fires the **"ROUND 1 / DRAFT IS LIVE"** kickoff card on the false→true transition
  (tracked by `_lastLive`; end-of-round recaps fire from the reveal queue). The lobby shows *"Waiting for the draft to start…"* and
  makes nothing tappable until then.
- **Lifecycle:** `doResetDraft` sets it back to `false` (so you can re-start), `simulateDraft` sets `true`,
  and load/import derive it (`picks.some(p=>!p.keeper)`) so an in-progress draft or older backup stays live.

### 10c. Operator Draft Guide (`guide.html` + `guide_data.json`)

Private war room for the operator only. **Entry:** lobby → claim your team (the one set in Settings →
Connections → *Draft Guide — your team*, `state.guideTeamId`; the 🧠 button appears only for that team) →
guide.html asks for the **admin PIN** (relay-side `ADMIN_PIN`, default **2060** — deliberately NOT the shared
**3133** Settings/Undo PIN that others may see over your shoulder; enforced by the SERVER, not the page). `/guide_auth {pin}` issues a `gtoken` (persisted in
`server_state.json`, last 8 kept); `/guide_data?g=` serves the intel only with a valid token. The static
handler 403s `guide_data.json` AND `server_state.json` (`PRIVATE_FILES`) so neither can be fetched directly
— and `/state` no longer echoes `joinPin` (was an information leak; no client used it).

**Intel layers:** (1) *live per-player data* from `fetch_players.py` — PPR ADP, news, byes, and the new
`inj` field (ESPN roster `injuries[].status`, fallback non-Active roster status); (2) *curated research* in
`guide_data.json` (sleepers with `targetRound` grounded against this board's own FFC PPR ADP, fades, tier
notes, strategy bullets — compiled from public 2026 draft analysis; **refresh it draft week by re-asking
Claude**); (3) *live logic in guide.html*: `predict()` simulates every pick between now and the operator's
next turn (each opponent takes its top-ADP need, K/DST suppressed until late) → "likely gone" + positional
run counts; `suggest()` scores ~60 candidates (value vs ADP ± need fit ± predicted-run urgency ± tier cliff
± sleeper take-now window − injury − bye-stacking − fade-at-retail) and renders the top 5 **with the reasons
spelled out**. Sleeper tracker strikes out taken sleepers and badges **TAKE NOW** once `currentRound >=
targetRound`. Drafting from the guide reuses the exact lobby flow (`/pick` with the device's lobby claim
token) — so every turn/availability/double-pick guarantee applies unchanged; off-turn the buttons hide AND
the relay rejects. Names match by `normName(name)+pos` — if a sleeper is missing from the pool it still
lists (unmatched, no tracking).

### 10c-2. War-room data layer (`fetch_guide_stats.py` → `guide_stats.json`)

Run **draft morning, after `fetch_players.py`**. Sources merged per player (keyed `normName|POS`, DST by
`DST|team`): **FantasyPros** public v2 API with the key from `.fantasypros.env` — consensus draft rankings
PPR (ECR, expert **tier**, pos rank, rank min–max), season PPR projections, and a 10-item news ticker.
The public tier hard-caps every request at **10 rows** (no paging; bursts → 429, handled with backoff +
1.3 s pacing), so FP covers the **top 10 per position** only. **ESPN kona** (`lm-api-reads.fantasy.espn.com
… leaguedefaults/3?view=kona_player_info`, `X-Fantasy-Filter` limit 700) backfills season PPR projections
for everyone plus last-year actuals. **ESPN athlete stats** (threaded) supplies the last 4 seasons, rescored
to THIS league (1/25 pass yd, 4 pTD, −2 INT, 1/10 rush+rec yd, 6 TD, 1/rec, −2 fum lost — `league_points()`).
`guide.html` extends FP tiers past the top-10s by ADP-gap clustering (`buildTiers`; synthesized tiers render
as `T5~` and say "ADP-based estimate"). **Never commit/serve `.fantasypros.env`** — it's in `PRIVATE_FILES`
along with `guide_stats.json`; both guide files are only served via token-gated endpoints (`/guide_data`,
`/guide_stats`). Note: ESPN's roster `injuries[].details` is empty in practice — injury drill-down = status
(`inj`) + the rotowire news line, which names the body part.

**War-room UI contracts** (guide.html): every player row/suggestion/injury row **click-opens the detail
modal** (headshot, tier/ECR/posRank, ADP + round, FP vs ESPN projections, last-year actual, 4-season
league-scored table with PPG, news, injury box, fade/sleeper notes) with a **DRAFT button only when it's
the operator's turn** (routes through the same `askDraft` → `/pick` relay flow). Green `▲N fell` = fell N
past ADP (value); red `▼N early` = N before ADP (reach). Sleeper tracker: drafted sleepers are **removed**
(counted in the header), `SOON · ~N` (amber) when within **15 picks** of the sleeper's ADP, `TAKE NOW`
(purple) once the current round reaches `targetRound`. Suggestion engine adds: hard roster caps (skip 2nd
K/DST, 3rd QB/TE), "N picks left for N starter holes" pressure when tight, and **tier-cliff risk**: boost +
reason when a candidate is the last of his tier and the next man up is a tier (or ≥25 projected points) worse.

### 10c-3. War-room analytics v3 (the "ultimate research" pass)

Ten additions, all in `guide.html` unless noted:
1. **VBD/VORP** — `buildRepl()` computes the replacement-starter projection per position for a 12-team
   QB1/RB2/WR3/TE1/FLEX2 league (baselines ≈ QB14, RB35, WR47, TE16); `vorpOf()` = projection − replacement.
   Drives a scoring term, a stat tile, a pool badge, and a **Sort: ADP / VBD / Proj** toggle.
2. **Survival odds** — `survProb(p,pick)` = 1 − Φ((pick − ADP)/σ), σ from FantasyPros rank min/max spread
   (`(max−min)/3.5`) or `0.16×ADP`. Replaces the old binary "likely gone" with "**≈2% he survives to #54**".
3. **Cost of waiting** — `renderWaitCost()`: per position, best available NOW vs the best with ≥55% survival
   at your next pick, and the projected-points delta (e.g. "TE −34"). The round-plan brain.
4. **Opponent tendencies** — `teamProfiles()` learns each manager's needRate + avg reach from their real
   picks; `predict()` switches a proven BPA drafter (needRate<0.5 over ≥3 picks) off need-based prediction
   and tags rows **BPA** / **reacher**.
5. **Durability** — `durabilityOf()` = games played ÷ (17 × seasons) from the 4-year history.
6. **Age/experience** — new `age`/`exp` fields in `fetch_players.py`; RBs entering age-28+ take a scoring hit.
7. **Playoff SOS** — `playoff_sos()` in `fetch_guide_stats.py` reads each team's weeks 15–17 opponents
   (schedule API; **note `ev["seasonType"]["type"]`, not `ev["season"]["type"]` — that bug returned 0 teams**)
   and averages opponent 2025 win%. Shown as 🎄 soft / 🧱 tough with the actual matchups.
8. **ADP momentum** — each fetch writes `adpSnap`; the next run keeps the oldest as `adpPrev`, so
   `momentumOf()` shows ↑/↓ vs a prior date. **Requires ≥2 runs on different days to show anything.**
9. **Mid-draft refresh** — `/guide_refresh` (POST, gtoken) spawns `_do_refresh()` on the relay: re-pulls all
   32 rosters for injury status + threaded news for ranked players, writes `players.json` atomically;
   `/guide_refresh_status` polls. One-at-a-time guard. The 🔄 News button reloads the pool in place.
10. **Cheat sheet** — `cheatSheet()` opens a printable page (top 14 per position by VBD with tier/bye/ADP/
    proj, sleepers with target rounds, fades, strategy). Paper backup if the war-room device dies.

**⚠ ESPN blocking + the never-lose-data rule (both bit us live, 2026-08-25).** ESPN's edge flip-flopped
twice in one day on which User-Agents it accepts: it 403'd `DraftBoard/1.0`, then started 403'ing
browser-style `Mozilla/5.0` too, while **urllib's own default sailed through**. Hard-coding a UA is
therefore not a fix. Both fetchers now carry a `UA_PROFILES` list and a `_open()`/`get()` that **rotates
profiles on 403/429, locks onto whichever works, and backs off + retries** for ESPN's *rolling rate limit*
on sustained volume (that limit is why a big run returns thin news/headshots even when single calls work).
Thread pools were also reduced (news 8→5, headshots 16→8), and ESPN truncates very large payloads
(`IncompleteRead` at ~14 MB), so `espn_kona()` steps its `limit` down 450→300→200.

**Never lose data.** Every fetch is now additive, never destructive:
* `fetch_players.py` — `load_prior()` reads the existing `players.json` before anything else and
  `merge_prior()` restores any field this run failed to pull (news, logo, bye, injury, age/exp, jersey,
  team, name), **restores ADP** if the FFC feed was down (otherwise the whole board goes unranked),
  **restores headshots** unless *this run* proved a definite 404 (`validate_headshots()` returns that id
  set), and **re-adds every player of a team whose roster request failed** (`failed_teams`). Verified
  against a simulated catastrophic run (0 ranked / 0 news / 0 images / KC missing): all 979 players, 268
  ADPs, 243 news and 941 headshots came back, while the one genuine 404 correctly stayed blank.
* `fetch_guide_stats.py` — carries forward `tier/ecr/posRank/rank spread/projections/seasons` per player
  and reuses the previous `teamPlayoff` (playoff SOS) and `newsTicker` when those sources don't answer.
  Proven live: a run where the projection feed threw `IncompleteRead` **and** SOS returned 0 teams still
  produced 501 projections and 32 SOS teams.
* Neither script writes a partial file on failure, so the last good data always survives.

`1_fetch_players.bat` now checks for Python properly (the old "Python not found" message fired on *any*
error, which masked the real 403), runs **both** fetchers, and says plainly that previous data is retained
if a step fails.

### 10c-4. Auto-pick on timer expiry (main draft — **off by default**)

`state.autoPickOn` (default `false`) + `state.autoPickGrace` (default 15s, clamped 0–300), both in
Settings under the pick timer. `timerTick()` (shared by `timerToggle`/`timerStartFresh`) calls
`maybeAutoPick()` every second: when `timerRemaining <= -grace` it stops the clock, picks a **random player
from the top 20 available by ADP** (`autoPickPool()`), and commits it through the normal `doPick(p,true)` —
so reveal, TVs, keepers, backups and relay push all behave identically. The pick is stamped `auto:true`
(stored for reference; exports intentionally unchanged). Guards: off by default, requires `draftLive` AND
`timerOn`, never past the last pick, `_autoPickBusy` re-entrancy lock (three ticks in one expired window
produce exactly one pick), and it closes an open confirm dialog so it can't race the operator. While
over-time the timer subtitle counts down "**OVER — auto-pick in Ns**".

### 10c-5. Reveal duration setting (`state.revealSecs`)

Settings → under the pick timer: **"Draft reveal — seconds on screen"** (default **10**, clamped 1–60).
Read by `revealPick()` in `draftboard.html` and `playReveal()` in `board.html` (the board pulls it off the
state object it already has, so it needs no extra plumbing). **Deliberately does NOT affect `pick.html`** —
that screen always shows the most recent pick, and its `HOLD_MS` (10 s) only paces the *queue* when a
manager's pick and an auto-keeper land in the same tick (§5b). If you ever want them to track together,
have `pick.html` read `s.revealSecs` into `HOLD_MS`; the operator asked for them decoupled.
Note the round-recap overlay is separate again (15 s, §9).

### 10c-6. Operator-only lobby view + lobby-URL normalisation

* **`isOperator()`** in `lobby.html` = `MYTEAM === STATE.guideTeamId`. For that one phone the pool is sorted
  by **ADP** (matching the main board) and shows the ADP value; **every other manager keeps the deliberately
  alphabetical, ADP-free list** so nobody can read the rankings off their screen. Gotcha: `poolSig` (the
  render cache key) must include the operator flag, or switching teams on a device leaves the previous
  ordering on screen.
* **`normalizeLobbyUrl()`** in `draftboard.html` runs on save: adds `http://` if missing, appends **`:PORT`**
  (learned from `/lan`, so it tracks the real server port) when the URL has none, and defaults the path to
  `/lobby.html`. A URL saved without the port silently sent phones to port 80. An explicit port is untouched.
* `pick.html`'s corner QR card now **shows the URL** too (it was `display:none` in `.mini`), so the operator
  can eyeball that it encodes the right host:port. **The QR image itself is user-supplied** — regenerate it
  whenever the Lobby URL changes.

### 10c-7. Operator tab bar + remote Big Board

The operator needs to move between **their** three screens on one phone; nobody else gets this.

* **`.opnav` bottom tab bar** (identical block in `lobby.html`, `guide.html`, `board.html`): 📋 My Draft ·
  🧠 War Room · 📊 Big Board, with the current page highlighted. `initOpNav(state)` shows it on **any device
  that has claimed a team** (`+localStorage["ffdraft_lobby_team"]`), so every manager gets Draft + Board on
  their own phone; the **War Room tab is hidden unless** `state.guideTeamId === thatDeviceTeam`. It is never
  shown when the page is on `localhost/127.0.0.1/::1`. That second guard is what keeps it off the **TVs and control PC** — they are
  always opened via localhost — so a stray team claim in the host browser can never put buttons on a TV.
  It also sets `body.hasnav{padding-bottom:64px}` so the fixed bar never covers content.
* **`board.html` now works remotely.** It used to read the draft only from `localStorage`, which is
  per-origin — so on a phone (`http://<ip>:8777`) it showed "No draft data". `load()` now falls back to
  `_relayState`, filled by `pollRelay()` (`GET /state` every 1.5 s) whenever no localStorage copy exists.
  `REMOTE` (= no localStorage state) additionally **suppresses the full-screen reveal, round-recap and
  kickoff overlays** — those are for the TV; on a phone you just want the grid. The TV path is byte-for-byte
  unchanged (`REMOTE=false`, still localStorage + BroadcastChannel).

Verified from a real LAN origin: normal manager (team 4) → no nav; operator (team 6) → nav on all three
pages with the right tab highlighted, Big Board rendering 14 filled cells live off the relay with no
overlays; and on `localhost` the board shows **no nav, `REMOTE=false`**, grid unaffected.

### 10c-8. Long-run performance (6+ hour draft) — DO NOT REGRESS

The TVs poll forever, so anything rebuilt per tick is multiplied by ~20,000 over a draft night.

* **`board.html`** rebuilt the whole 16x13 grid via `innerHTML` **every 1.5 s** (~14,400 rebuilds / ~3M
  DOM nodes churned over 6 h). `draw()` now computes `sig` from everything that can change the grid
  (picks length, epoch, rounds, snake, current pick, team name/mgr/color/logo, keeper reservations,
  connection dots) and **returns early via `afterDraw()`** when it matches `_tblSig`. Measured: **200 idle
  draws -> 0 rebuilds** (was 200), DOM node count flat, and exactly **1 rebuild** when state really changes.
* **`pick.html`** rebuilt the big card (including its `<img>`, forcing headshot re-decode) **every second**.
  Guarded by `_cardSig` (`overall|playerId|stateEpoch`). Measured: **300 idle draws -> 0 rebuilds**.
  `_cardSig` must be cleared on epoch change / empty board or a restore would show a stale card.
* **Relay queue TTL** (`QUEUE_TTL = 90`): a pick request the control never ratified (control closed
  mid-pick) used to sit in `_data["queue"]` forever, and the `"Pick already submitted"` guard then locked
  that manager's phone out **for the rest of the draft**. `/pending` (the control's 1 Hz poll) now prunes
  entries older than the TTL, writes a `"Timed out - try again"` result for the phone, and also expires old
  `results`. Verified with a shortened TTL: queue drained, phone told, team able to submit again.
* Checked and found bounded (no action needed): `_netProcessed` (<= one entry per pick), `gtokens`
  (capped at 8), `_https_warned` (one per IP), and Python's `ThreadingHTTPServer` thread list (daemon
  threads are not tracked, so it does not grow).
* Sustained-load check: **400 `render()` calls -> 0 DOM growth**; a full 192-pick simulate runs in ~150 ms.

### 10c-9. First write-in ceremony + the #2 pencil

* `PENCIL_IMG` in `draftboard.html` is an **inline SVG data URI** of a No.2 pencil (~1.3 KB, no extra file,
  works offline). It is written into `player.img` for write-ins created **both** by the operator
  (`confirmWriteIn`) and from a phone (`ratifyPhonePick`), so it flows automatically to the reveal card,
  the board cell, `pick.html` and the exports.
* `isFirstWriteIn(pick, allPicks)` is true only when the pick is a write-in **and no earlier pick was** —
  so the ceremony fires exactly once per draft. `announceWriteIn()` shows `#wiOverlay` (bouncing pencil,
  "ANNOUNCING OUR VERY FIRST / WRITE-IN!", an SVG scribble that draws itself, and "Please welcome <name>"),
  holds **10 s**, fades 0.8 s, then calls back into the **normal reveal** — so the player's name still gets its
  usual card. Wired into `pumpReveals()` on the control screen and `board.html` (suppressed when `REMOTE`,
  i.e. the operator's phone). Verified: plays on write-in #1, does **not** play on #2 or on normal picks.

### 10c-10. Mixed local + remote (Tailscale) access

The draft runs with some managers **in the room on Wi-Fi** and some **joining remotely over Tailscale
Funnel**, simultaneously. Both hit the same relay; only the entry URL differs.

**Security — the loopback guard had to change.** `tailscale serve/funnel` reverse-proxies to the backend
**from 127.0.0.1**, so every internet visitor would have satisfied the old `_local()` check and could have
called `/push` (overwrite the entire draft) or `/release` (kick every phone). `_local()` now requires a
loopback socket **AND** the absence of any proxy header (`X-Forwarded-For/-Proto/-Host`, `Forwarded`,
`X-Real-Ip`, `Tailscale-*`), logging a rate-limited warning when it blocks one. Verified:
`/push /release /claims /pending` = **200 direct, 403 through a proxy**; `/state /join /teams` still 200
through the proxy so remote managers work. **Consequence: the control screen must be opened on
`http://localhost:PORT` on the host PC** — driving it through the tunnel will not work, by design.
Caveat: this relies on the proxy sending forwarding headers, which HTTP-mode `serve`/`funnel` does. A raw
**TCP-mode** service would not, so do not expose this over TCP mode.

**Two join routes.** `state.lobbyUrl`/`lobbyQR` (local Wi-Fi) and `state.lobbyUrl2`/`lobbyQR2`
(remote/Tailscale), both set in Settings -> Connections. `normalizeLobbyUrl()` adds `:PORT` for the LAN
address; `normalizeRemoteUrl()` forces `https://` and **never adds a port** (funnel is on 443, a port would
break it). `pick.html`'s `drawJoin()` renders whichever are configured side by side, labelled
"Here on our Wi-Fi" / "Joining remotely"; `.jp-codes` gets `one`/`both` so a lone code drops its label and
the pair shrinks to 30vh (pre-draft) / 9.5vh (corner) to fit. People in the room should use the Wi-Fi code:
lower latency, and it keeps working if the internet drops.

**Known wrinkle:** the two URLs are different browser origins, so a manager who joins on Wi-Fi and later
switches to the remote URL starts with fresh `localStorage` and must re-claim their team. The old claim
frees itself after `CONN_WINDOW` (12 s) once the first tab stops polling, so it self-heals - but tell
people to pick one URL and stay on it.

### 10d. Phone write-ins

The lobby has a **✎ Write-In** button (next to "Show drafted players") for rookies/anyone not in the data —
same capability the operator has. The phone sends `{writein:{name,team,pos}}` instead of `playerId`; the relay
passes it through the queue, and `ratifyPhonePick` builds the player (`id:"WI_"+Date.now()`, team uppercased,
position validated against QB/RB/WR/TE/K/DST, `writein:true`) and commits it via the normal `doPick`. It's
turn-gated like any other pick and appears in exports with `Write-In = Yes`.

---

### 10e. Backup / restore — verified end-to-end (2026-08)

Round-tripped a realistic mid-draft (25 picks, a keeper auto-placed on the board, custom team
names/managers/colors, per-pick seconds/over, and every Settings field): wiped `localStorage` + reset
`state` to simulate a crash, then imported the backup file. **Every field matched** - picks, keepers,
teams, pick times, `guideTeamId`, `revealSecs`, `autoPickGrace`, and the keeper's board pick.
Also confirmed: no duplicate picks, no drafted player left in the pool, the keeper stays out of the pool
after restore (it is filtered by `keeperIdSet()`, **not** by `state.drafted` - keepers deliberately never
enter the drafted map), and `maybeAutoBackup()` fires exactly on multiples of `autoBackupEvery`.

---

## 11. Testing checklist before handing back to the user

- [ ] `python -m http.server 8777` from the root; load all three URLs (never `file://`).
- [ ] Settings: change league name, drag-reorder teams, randomize, set rounds/timer — all persist after refresh.
- [ ] Make several picks: player leaves pool, appears on grid + pick TV, timer resets, autosave dot goes green.
- [ ] Undo asks for confirmation, returns player to pool, resets timer.
- [ ] Write-in a player (e.g. a rookie), confirm it drafts and exports.
- [ ] Kill the browser mid-draft, reopen via the bat file → state fully restored.
- [ ] Export xlsx/docx/csv + time report; open each and confirm columns/data.
- [ ] Verify board.html and pick.html have no config buttons **except** the single 🌙 theme toggle, and update live.
- [ ] Default body background is light (`rgb(244,246,251)`) on all three screens; the 🌙 button / `d` key flips
      **that screen only** to dark (body `rgb(15,20,32)`) and back, and the choice survives a refresh — while the
      other two screens keep their own theme (independent keys).

---

## 12. Environment

- **OS:** Windows (user path `C:\Users\Matth\...`, OneDrive-synced — note: OneDrive may lock
  files briefly during sync; if a write fails, that's usually why).
- **Python 3** required for the fetch script and the local server (must be on PATH).
- No build step, no bundler, no package.json. It's plain HTML/CSS/JS + one Python script.
- Browser: any modern Chromium/Edge/Firefox. Uses `BroadcastChannel`, `localStorage`,
  `TextEncoder`, blob downloads — all standard.

---

*Prepared as a handoff from the initial build session. The app is complete and tested; treat
sections 1 and 4–6 as invariants unless the user explicitly asks to change them.*
