docs: v1.1 implementation specification

This commit is contained in:
Torres
2026-11-12 15:04:40 -04:00
parent 381b6c8a9c
commit 829d43851b

624
docs/V1.1-BUILD.md Normal file
View File

@@ -0,0 +1,624 @@
# V1.1-BUILD — v1.1 implementation specification
Build spec for the v1.1 QOL batch. Requirements: `docs/BACKLOG.md` "v1.1 — QOL wishlist" (both subsections). Frozen UI contract this amends: `docs/UI-SPEC.md` (v2 + P3 §10 + B1 §11). Version map: `docs/PLAN.md`.
**Status:** spec — awaiting Glenn's go-ahead. Every item below states goal, exact change plan (file/function/line anchors verified against the code at `381b6c8`), pseudocode where the logic is heavy, edge cases, acceptance criteria, and the verification steps the parent will run. Nothing is built yet.
**No data reset. No schema change. v1.1 is frontend + read-path/query only** (see "Data safety"). The only logic rewrite is the compose allocation itself (`compose_section`), which still writes *only* what it writes today (a new `sections`/`section_items` row set, exactly as a forge already does).
## Code facts everyone should start from (verified 2026-11-12, commit `381b6c8`)
Backend:
- `app/sections.py` (488 lines): `NEW_POOL_SQL` (40–44), `RETURNS_SQL` (46–55), `WILDCARD_SQL` (57–61), constants `FAMILY_CAP=4 MAX_RETURNS=5 MAX_RETURN_NOPES=2 WILDCARDS=2 DEFAULT_SIZE=25 MIN_BANDS=3` (28–33), `pick_new` (160–178), `repair_bands` (181–256), `compose_section` (259–404), `section_items` (417–425), `section_counts` (428–454), `maybe_exhaust` (457–466), `read_section` (469–488).
- `app/api.py` (738 lines): `/api/stats` (234–264), `swipe` POST (299–357; `section_id` optional at 307–309, `append_event` call at 345–346), `section_state` (446–471), `get_section` (473–488; cold compose hardcodes `size=25` at 482–485), `forge` (490–527), `/api/prefs` (652–666; `allowed = {'forge_mode': ('ondemand','strict')}` at 656), `img` with `object-fit:contain` viewer served at 670–708.
- `app/db.py`: `swipe_events` schema with `section_id` (18–26), `append_event` (120–143) — already stamps `section_id` when the client sends it; P3 seeding copies events with `section_id=NULL` (111–117).
Frontend: `app/static/index.html` (1282 lines, single-file app):
- Keyboard handler ALREADY EXISTS at 1263–1270 (see item 1 — this must be reconciliated with the BACKLOG key map).
- Tap zones: `tapZone` (756–763) — sides flip, center opens viewer. Built faithful to B1 §11.4. v1.1 changes the center-target only.
- Deck→dossier door: `.ibtn` click (578–580); `openDetail` (768–796); dossier hero with `dphoto` (245) and hero-tap→viewer in `dEndH` (832–837).
- Viewer: `openViewer` (870–877), `closeViewer` (878–883), `vCtx={item,idx,back}` (853, 873); `.vimg` (197) already `object-fit:contain`.
- Card DOM: `phwrapHTML` (512–513) puts `<img class="photo">` inside the `.phwrap`; `.card .photo` is `object-fit:cover` (CSS 36).
- Header: `#pill` (206, filled by `loadStats` 1127–1136 — it renders `live · new` from `/api/stats`), `#sect` (207) is the section progress pill, `.sect` CSS 137–138, `.pill` 29–30.
- Empty state: `#empty` (212) with copy "You're all caught up."; CSS 95–97; shown only by `maybeRefill` in **legacy mode** (652) — unreachable in section mode, where the report card (`#report`, 213–224, shown at 660–662) owns exhaustion.
- Section counter: client state `sectDecided/sectTotal` (1139); `paintSect` (1140–1145); increments in `commit` (684) and `undo` (706); server truth re-derived on every `updateSectionUI` (1157–1173) from `body.items[].decision` — the bug: `decision` is the listing's *lifetime* verdict, not a this-section decision.
- `bandHTML` (482–489); `.band` (139), `.band.wild` (140), `.band.sl` (141); `hasSegs` band offset (188); `updateSectionUI` (1157); `forge` button flow (1180–1206); prefs pattern `setMode` (1174–1179); `loadStats` (1127).
- Info-page hero: `.dphoto` (70) is `object-fit:cover`; list thumbs `.thumb` (110) are `cover` and stay that way.
- Rubric display: dossier "Why this rank" block `#drank` (250–257) filled by `fillRank` (798–821) — gauge + `{points}/100` (805) + up to 4 breakdown lines (806–812) + footer (813–814); list rows show tank only (985).
Discrepancies found between docs and code (code wins; both recorded):
- B1 §11.4 says center-tap = fullscreen viewer. Code matches. v1.1 re-maps center-tap = dossier (BACKLOG UI change 1) — the UI-SPEC amendment in this doc supersedes §11.3/§11.4.
- `sections.py` module docstring (2–17) and `docs/RANKING.md:29` still say "≤5 returns"; DATA-MODEL line 165 says "Max 5 returns per section". All replaced by item 9's formula (docs to update: RANKING.md:29, DATA-MODEL.md:165, the module docstring — the spec's one doc-touch, see item 9).
- The 2026-11-12 empty-pool symptom ("forges returned 2-card sections") means the eligible-return pool is *near-empty today*; details in item 9.
- Current keyboard handler maps **Backspace**, not ArrowDown, to undo, and does not prevent arrow page scroll — item 1 fixes this.
---
## Item 1 — Keyboard shortcuts
**Goal (BACKLOG "Shortcuts"):** ← nope, → like, ↑ maybe, ↓ undo (back one), Esc exits fullscreen views. Desktop/Bluetooth-keyboard benefit; no touch changes.
Notes before the plan:
- The expected row of actions shares handlers: commits go through `commit()` (`index.html` 677–689; button bindings at 721–723), undo through `undo()` (702–720; binding 724). The keys must call these exact functions so all P3 invariants (optimistic persist through `setDecision`, photo_idx, no-lock flight) hold — never bespoke handlers.
- A keyboard handler already exists (1263–1270): ← / → / ↑ do the right things, but undo is **Backspace**, ↓ is unmapped, Esc is unmapped, there is no scroll guard, and the text-input guard covers only `INPUT`.
**Plan (all in `app/static/index.html`):**
1. Replace the handler (1264–1270) with the v1.1 key map:
```
ArrowLeft -> nope (commit(pile[0].el,'nope'))
ArrowRight -> like (commit(pile[0].el,'like'))
ArrowUp -> maybe (commit(pile[0].el,'maybe'))
ArrowDown -> undo (undo())
Backspace -> undo (undo()) // kept: existing users' muscle memory — note in Open Questions
Escape -> exit fullscreen views (below)
```
2. Guard before acting: **ignore all keys while a text input has focus.** Extend the existing `e.target.tagName==='INPUT'` check to also skip `TEXTAREA`, `SELECT`, or any element with `isContentEditable` (the list screen has a live search input; searching with arrows must filter, not swipe):
```
const t=e.target;
if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable)) return;
```
3. **Prevent arrow-key page scroll** (the app body is `overflow:hidden`, but focused buttons can still scroll; be safe): for the four arrow keys, `e.preventDefault()` immediately after the guard passes — i.e. before letting the browser's default horizontal/vertical scroll run. Backspace default is harmless and untouched.
4. **Esc, one key, stack order** (viewer `#viewer` z-72 > dossier `#detail` z-70 > deck):
- if `#viewer` has class `on` -> `closeViewer()` (878)
- else if `#detail` has class `on` -> `closeDetail()` (822; returns to deck or, from a list row, to the list screen that stays open underneath — same as today's pull-down close)
- else if `#report` has class `on` -> do nothing (Esc never forges or navigates tabs)
- else -> ignore.
Note: the currently-open sheet also swallows keys before Esc gets them, because the existing early-return for open `#detail`/`#viewer` is removed in favour of the Esc-first ordering above. Esc only closes; arrows only work on the deck.
**Edge cases:**
- Arrow keys must do nothing when no pile: `commit` and `undo` already no-op on empty `pile[0]`/`hist` (677–678, 703).
- Key repeat (holding →): `commit` drains the pile as fast as repeat fires — same behaviour as mashing the like button, acceptable, no extra lock needed (the no-lock law already tolerates fast commits).
- The report card is up but the section is closed: arrows no-op (pile empty); Esc no-ops; the Forge button remains mouse/tap only.
**Acceptance criteria (objective):**
1. New chromium/headless page, deck served: dispatch `ArrowLeft/Right/Up` -> top card flies nope/like/maybe; the very same network payloads fire as button taps (verify no second code path: instrument or assert `sectDecided`/paintSect side effects identical).
2. Dispatch `ArrowDown` -> the swiped card returns to the top of the pile (undo flight + `DELETE /api/swipe/{id}` queued).
3. Dispatch `Backspace` -> same undo.
4. Focus the list search input, type arrows -> input cursor moves / text unchanged, zero swipe commits (assert pile length unchanged).
5. While the dossier is open, all arrow keys are inert; Esc closes it. While the viewer is open, Esc closes only the viewer (dossier, if underneath, stays open; a second Esc closes it).
6. Call `window.scrollTo(0,100)`-style check: with `document.body.scrollTop` manipulable, arrow dispatch does not change scroll (assert via `e.defaultPrevented` spy on `keydown`).
**Parent verification:**
1. `fe_walk_b1`-style headless walk: boot app, run acceptance 1–6 in order, assert green.
2. Optional human spot-check on MacBook over the tailnet.
---
## Item 2 — Info-first tap flow
**Goal (BACKLOG UI changes 1):** center tap → bike data dossier first; tapping the photo there → fullscreen image viewer; side taps still flip photos; Esc walks back out (viewer → data → deck). Resolves "can't get info unless I hit the ⓘ button". Esc routing is item 1's stack; this item changes the tap targets + the viewer's back-chain.
**Current state:** `tapZone` (756–763) sends the center third to `openViewer(entry.d, entry.cur, {type:'card',entry})`; side thirds flip (`flipCard`, 525–536). `openDetail` is only reachable via ⓘ (578–580) / list rows (1036) / matches rows (1113). Dossier hero tap opens the viewer (832–837). Viewer close returns to whatever was under it (878–883).
**Plan (all `app/static/index.html`):**
1. `tapZone` (761): change the center branch to `openDetail(entry.d, {type:'deck'})`. Sides unchanged. Do not clear the card's photo position — the dossier is a sheet over the deck.
2. **Esc walk-back chain (new):** `openDetail` (768) must record *how it was opened* so a viewer reached from it can walk back. Add one module-level variable, e.g. `let dEntry=null;` — set in `tapZone`'s new branch to `{...dCtx, entry}` (the pile entry with its live `cur` photo index), set to `null` for row/matches opens (1036, 1113) and for ⓘ (580). `closeDetail` (822) clears it.
3. Viewer back-chain: when `tapZone`'s center branch opens the dossier, the dossier hero keeps its existing tap→`openViewer(dData,0,{type:'dossier'})` (835). Extend the viewer context so it can walk back to the dossier-on-deck:
- in `openViewer` (870–877): pass anextra field on `back`, e.g. `{type:'dossier', walkBack:dEntry}` or simpler: make the dossier-hero call (835) pass `{type:'dossier', backToDetail:dEntry}`;
- item 1's Esc handler calls, before plain `closeViewer()`, a new tiny `vWalkBack()`: if `vCtx.back.backToDetail` exists, capture the item, call `closeViewer()`, then `openDetail(item, backToDetail)` — image returns to the deck dossier (photo 0, per §3's hero contract), where the *next* Esc closes to the deck. If no `backToDetail`, plain `closeViewer()`.
4. The dossier closes to exactly the deck it came from: when opened from a deck card, the ⓘ/pull-down close semantics (822) already leave the pile untouched; no change. From a list row, Esc closes to the list (already true).
**Edge cases:**
- One-photo / no-photo cards: center tap still opens the dossier (data-first is the point); the hero tap no-ops when `dData.img` is null (835 already guards).
- The dossier's own action buttons (838–848) still commit the current top card — unchanged; the flow is open data, read it, swipe from inside it. That is desired: dossier actions exist today.
- Viewer opened the old way from a deck card — gone: after item 2 there is no direct card→viewer path; every deck-carded viewer is reached via dossier. `{type:'card',entry}` back shape (873) becomes unused from the deck; leave the plumbing, back-chain field only added for the dossier path.
**Acceptance criteria:**
1. Center tap (≤6px movement) on the top card opens `#detail` (`.on`), populated with that card's payload; **no** viewer opens; no commit fires.
2. Side taps still flip photos both directions, clamped; no dossier opens (existing B1 §11.2 behaviour re-verified).
3. Tap the dossier hero photo -> `#viewer` opens at photo 0. Esc -> viewer closes, dossier still open. Esc -> dossier closes, same deck/pile state and same top card (photo index intact).
4. ⓘ tap still opens the dossier (regression).
5. From the list: row tap -> dossier -> hero tap -> viewer -> Esc -> dossier -> Esc -> list (regression with the new Esc routing).
**Parent verification:**
1. Headless walk of acceptance 1–5 plus the B1 §11.7 suite re-run green (flip clamps, viewer swarm, no-lock, one `.topcard`).
2. Human: phone pass — Glenn taps the middle of a card, sees data, taps the photo, sees fullscreen, Esc/Esc (desktop) or ✕/pull-close (touch) walks back out.
---
## Item 3 — Photos = option C (fit + blurred backdrop)
**Goal (BACKLOG UI changes 2, decided 2026-11-12: "OH C! I love C!"):** the whole photo is shown uncropped (fit) over a soft blurred backdrop of itself, on the deck card AND the info-page hero. Fullscreen viewer unchanged (already fits). List thumbs stay `cover` (56px, irrelevant). Demo reference panel C: `~/dev/bike-tinder-demos/fit-vs-fill.html`.
**Verified current CSS (index.html):**
- Deck card photo: `.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}` (36) — the element is inside `.phwrap` (513).
- Info hero: `.dphoto{width:100%;height:100%;object-fit:cover;display:block}` (70) inside `.dhero` (69; bg `#17171c`).
- Viewer: `.vimg{...object-fit:contain...}` (197) — already fits; **verify only, change nothing.**
- List thumbs: `.thumb{...object-fit:cover...}` (110) — **unchanged.**
**Plan (CSS-only, `app/static/index.html` <style>):**
1. Deck card:
- change line 36 to `object-fit:contain` (keep `position:absolute;inset:0;width:100%;height:100%`).
- add inside `.phwrap` (512–513) a backdrop layer behind the sharp photo. HTML: when a `src` exists render `<div class="phblur"></div><img class="photo" ...>` (use an inline `style="background-image:url('...')"` from the same `photoSrc` value; escape the URL-attrs as the file already does elsewhere — the src is app-server-generated, but quote-escape it to be safe). CSS:
```
.phblur{position:absolute;inset:-18px;background-size:cover;background-position:center;
background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08)}
```
The photo gets a touch of shadow so the edge reads on top of the blur: `filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))` on `.card .photo`. The `nophoto` fallback path (513) renders no `.phblur`, naturally.
- `container` presence check: `.card .grad` (37) sits over the fit area — fine (it is a scrim, UI-SPEC §2).
2. Info-page hero:
- change line 70 to `object-fit:contain`.
- give `#dphoto` the same twin: add a `.dblur` div inside `.dhero` (245) *behind* the image, styled identically to `.phblur` (`.dhero` has no `overflow:hidden`; add it). Populate on `openDetail` (772 area): same `data.img` URL, emptied when no photo / error fallback (774). Simplest robust approach: reuse a tiny helper `setBackdrop(shellEl, url)` called from `openDetail` and `phwrapHTML`'s error/success paths.
3. No-reflow guarantees: both fits put letterbox bars around the photo; because the photo is `position:absolute` and the blur is a sibling underneath, nothing shifts. Card title/text keep their spot (info block is below the fold of a 4:3-ish area; on unusually tall images the photo shrinks towards the center and the bottom gradient may clip more of it than today — accepted, that is the point of "uncropped").
4. Cost note: `filter:blur(22px)` per card × up to 5 cards. GPU-composited in Chrome/Safari; on the older iPhone, if paint cost shows, drop the blur radius to 16px or disable the backdrop layer when `prefers-reduced-motion` — implementer's knob, not a spec change.
**Edge cases:**
- 0-photo listing: `.nophoto` panel unchanged, no backdrop, dossier hero dimmed as today.
- Aspect extremes (portrait screenshots inside 16:9 crops): letterbox grows as the photo gets wider/taller; clamp with a `max-height:100%` implicit via fit (contain already does), nothing else needed.
- B1 preloader (538–551) fetches `photoSrc` — unaffected.
**Acceptance criteria:**
1. Computed style on a rendered deck card photo == `object-fit:contain`; a backdrop node exists per card and its background-image equals the photo's resolved URL; paddings/sizes of the info block are unchanged vs pre-change (measure before/after in the headless walk).
2. Dossier hero photo == `contain`, backdrop present, spec table layout unmoved.
3. Viewer `.vimg` computed style == `contain` (no diff), list `.thumb` == `cover` (no diff).
4. 0-photo card and 1-photo card still render their fallbacks (B1 §11.7 fallback checks re-run).
**Parent verification:** headless computed-style assertions 1–4 + half a dozen screenshots (portrait + landscape cards, dossier, viewer) eyeballed against the demo's panel C.
---
## Item 4 — Minimal rubric + debug "why" toggle
**Goal (BACKLOG UI changes 3):** bike views show ONLY the gas-tank gauge + percent full (e.g. "76%"); a setting re-enables the full "why" breakdown for debugging. Wired via the existing `prefs` key/value API (same pattern as `forge_mode`).
**Where rubric displays today (index.html):**
- Dossier "Why this rank" block `#drank` (250–257), filled by `fillRank` (798–821): gauge (`drankTank`), `{points}/100` (805), breakdown lines (806–812), footer (813–814).
- List rows: tank gauge only, inside `rowHTML` (985) — already minimal; **untouched**.
- Deck card face: no rubric display — nothing to do.
**Plan:**
Backend (`app/api.py`):
1. Extend the allowed prefs set (656): `allowed = {'forge_mode': ('ondemand','strict'), 'rubric_debug': ('off','on')}` — the key/value API then accepts `{key:'rubric_debug', value:'off'|'on'}` with zero new plumbing (the endpoint at 652–666 is generic).
2. Expose the value so the FE can paint: add to `section_state` (446–471) one field, e.g. `data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')}` (same read shape as `data['mode']` at 453). `/api/section` and the forge response already go through `section_state`, so every section load carries it. No `/api/stats` change needed.
3. Nothing else backend-side: the breakdown payload (`points_breakdown`) is already in section items (`include_breakdown=True`, 454–456) — cheap to keep sending.
Frontend (index.html):
4. `fillRank` (798–821) renders by default: the gauge (existing 804) + compact "76%" label. Change 805 from `data.tank.value+'/100'` to `data.tank.value+'%'` and move the label into the existing left gauge column or directly beside it (e.g. wrap gauge + `%` in the `.tankwrap`).
5. Guard the detailed parts behind the pref: build the `dlines` HTML (806–812) and the `dfoot` line (813–814) ONLY when the pref is `'on'`; otherwise empty both and keep `#drank` visible with just gauge + `%` (the block itself persists so the minimal state is still "gauge + %", matching the BACKLOG ask).
6. Toggle UI (findable by Glenn, harmless otherwise): add to the Trends "Actions" area (317–320) a second `.trow` exactly in the `setMode` style (1174–1179 + 1248–1249): "Why breakdown: on ⇄ off", both spans wired like `tOnd`/`tStr` to a new `setRubricDebug(m)` that POSTs `/api/prefs` and updates the sections-wide `section.prefs.rubric_debug` for the next `fillRank`. Persist per session: `updateSectionUI` (1157–1173) also stores `section.prefs` (already via body) so a re-loaded deck repaints correctly.
7. `fillRank` reads `((section&&section.prefs&&section.prefs.rubric_debug)==='on')`; also keep the existing on-the-quiet `/api/listing/` enrichment for list rows (816–819) guarded the same way — when off, don't fetch (saves a request; nothing is displayed).
**Edge cases:**
- Legacy (no `/api/section`) mode: `section` is null → pref off → minimal display; the API call for the toggle then target isn't the issue (legacy servers return 404 on `/api/prefs` POST; FE silently ignores failure, `setMode` precedent 1175–1176).
- `tank.value` null (unscored/deep rows): `fillRank` already hides the whole block (799–802) — unchanged.
- The percent must be the rounded tank value (0–100 clamp is already applied in `tankSVG` 455).
**Acceptance criteria:**
1. Dossier for a scored listing shows gauge + "NN%" and NO breakdown lines, NO footer line, on a fresh load (pref unset).
2. `POST /api/prefs {rubric_debug:'on'}` → 200 `{key,value}`; reload → same dossier shows breakdown lines + footer. `'off'` → they disappear. Invalid value → 400.
3. List rows and card face carry zero rubric text in both states (regression).
4. Pref survives server restart (SQLite `prefs`) and page reload; forge response carries it.
**Parent verification:**
1. TestClient: prefs happy path / 400 path; fresh-DB `/api/section` body includes `prefs.rubric_debug`.
2. Headless: dossier DOM assertions with the pref off and on (steps A2).
---
## Item 5 — Section counter fix (the 2/2 → 3/2 bug)
**Goal (BACKLOG "Counter bug"):** count a card as decided only if decided DURING this section (server-side per-item flag, from events stamped with this section_id — the source `section_counts` uses); clamp to total; no premature "hot"/complete state.
**Root cause (verified):** `section_items` (sections.py 417–425) LEFT-JOINs `swipes` so items carry `decision` = lifetime verdict. A second-look card enters its section with `decision='like'|'maybe'|'nope'` already set, but it has NOT been decided in this section (no event with `section_id` = this section). `read_section` progress (473) and the FE (`updateSectionUI` 1160–1164) count those as decided → fresh batch paints "2/2" before any touch; the next swipe increments optimistically (`commit` 684) → "3/2". Fix = use only events with `e.section_id = section.id` as the decided set, plus clamping at the FE.
**Plan:**
Server (`app/sections.py`):
1. Add a helper next to `section_counts`:
```sql
SELECT e.listing_id FROM swipe_events e
JOIN section_items si ON si.section_id = e.section_id
AND si.listing_id = e.listing_id
WHERE e.section_id = ? -- decisive: an event stamped in THIS section
```
as `section_decided_ids(conn, section) -> set(listing_id)`. (Filtering to `si.listing_id` membership keeps out-of-section stray events from a foreign section_id honest; the join is the same shape `section_counts` uses at 439–451.)
2. Add the per-item flag on the read path: `section_items` cannot see events per row without a second join; cheaper — after building `items` in `read_section` (471–472), set `item['decided_this_section'] = item['id'] in decided_ids`. (`sqlite3.Row` is read-only until cast; either materialize items as dicts there or attach the flag in `api.section_state` (447–457) where items are rebuilt as full payloads anyway — recompute `section_decided_ids` once in `section_state` and add `p['decided_this_section'] = p['id'] in ids` alongside `p['slot']` at 454–456. Prefer this: one read, no Row mutation.)
3. `read_section` progress (473): `decided = sum(1 for it in items if it['decided_this_section'])`. `maybe_exhaust` (457–466) **unchanged** — it must wait for real in-section verdicts too, which it now does automatically because `section_items` is ALSO the joined-swipes view it reads... No: `maybe_exhaust` reads `it['decision'] is not None`. Change line 462 to check `section_decided_ids(conn, section)` membership instead: `undecided = [it for it in items if it['id'] not in decided_ids]` and `if not undecided: close_section(...)`. Otherwise a returns-only section reports "complete" before a single strike. This is part of the same fix.
4. `section_counts` (428–454) already derives like/maybe/nope from `decision` (432–438) — same lifetime-verdict flaw in *report numbers*. Change its per-item loop to use the same decided-ids set: count an action for this section ONLY when an event with this `section_id` exists and matches (`SELECT e.listing_id, e.action ... WHERE e.section_id=?` joined to `section_items` the same way; the existing `restruck`/`wildcard_hits` queries at 439–451 already do exactly this join — mirror it). Note: this does not change what is *stored*, only what the report shows.
5. No DB writes anywhere in this item (flag is computed per read).
Frontend (`app/static/index.html`):
6. `updateSectionUI` (1157–1173) counting loop (1160–1163) → `if(local===undefined ? !!it.decided_this_section : local!=null) decided++`. (Local intent still wins while a swipe's POST is in flight — the optimistic window is seconds and cannot resurrect the 2/2 bug because it only ever reflects what the user just did in this session.)
7. Clamp everywhere `sectDecided` is incremented, so a stale/foreign path can never paint past the total: `commit` (684) → `sectDecided=Math.min(sectTotal, sectDecided+1)`; `undo` (706) already clamps the floor. `paintSect` (1140–1145) additionally renders `Math.min(sectDecided, sectTotal)`.
8. Hot state: `paintSect` toggles `hot` only when `sectDecided>=sectTotal` (1144, unchanged code) — with in-section counting this now fires only when the section truly has a verdict per card; `commit` keeps painting immediately after a full sweep (the server resync in `maybeRefill` 663–666 handles the exhausted flip and a rapid double-swipe resync on deck reload straightens the numbers, same as today).
**Edge cases:**
- **Undo of a swipe on a second look (return slot) that already has a lifetime verdict:** the DELETE restores the prior `swipes` row and clears no `section_id` event (out of scope — see Data safety), so the server's in-section decided count still contains that card until the section dies. The FE decrements optimistically (706), then a resync re-counts it. Net: `sectDecided` may show one more decided than the user feels, but NEVER past `sectTotal` thanks to the clamp, and never "hot" before the section is substantively done. This same artifact exists today, worse. Flagged in Open Questions (a true fix needs an event-deletion rule — data semantics, v1.2 territory).
- List/matches edits during a section: swipes posted WITHOUT `section_id` (setDecision from rows, 844–845) → no event with this section_id → a card in the section whose verdict was flipped from the list is (correctly) not counted as decided-in-section. The deck will simply re-offer it if still in buffer — acceptable and consistent.
- **Seeded legacy events have `section_id=NULL`** (db.py 111–117): they never count for any section — exactly the intended behaviour (they predate sections).
- `decided_this_section` must be present for ALL items in `section_state` payloads or the FE `local===undefined` branch misfires — set it unconditionally (not only for returns).
**Acceptance criteria:**
1. Compose a returns-only section (fresh pool 0 — reproducible by DELETE-ing all fresh rows' absence or staging a DB where every active listing has a swipe): `/api/section` `progress.decided == 0` even though every `item.decision` is non-null; every `item.decided_this_section == false`.
2. Post one swipe with `section_id`: progress 1/2; recount matches events; `paintSect` shows 1/2. Post the second: 2/2, `hot` class present, section flips to `report` on next read, report counts reflect the two verdicts (not lifetime).
3. With 2 items and a double-swipe (two commits before any resync): painted pill never exceeds `2/2` (clamp assertion).
4. A section that mixes new + return: decided counts only in-section events on both slots; a fresh item swiped from the LIST (no section_id) leaves `progress.decided` unchanged.
5. Full P3 regression: compose→strike→rounds→report→forge loop green; undo restores the previous pill value minus one.
**Parent verification:**
1. Selfcheck additions in `selfcheck.py` (new checks against a temp DB, same style as existing) + the staged-DB walk in acceptance 1–4.
2. Live-DB dry run: snapshot `swipe_events`, compute `section_decided_ids` for the current section, assert it matches the user-visible count story. No writes.
---
## Item 6 — Dry-catalog messaging
**Goal (BACKLOG "Dry-catalog messaging"):** whenever a composed/forged batch reports `new: 0`, surface "Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks" in the UI and label forge results accordingly, so the deck never silently drips 2 cards.
**Why it happens:** `NEW_POOL_SQL` (sections.py 40–44) is empty once every active listing has a swipe row; `compose_section` still forges from `RETURNS_SQL` — under v1 formula a few cards (night-of-symptom: 2). By design, but invisible.
**Plan:**
Server: the recipe already carries the number. `compose_section` returns `recipe: {'new': ..., 'return': ..., 'wildcard': ...}` (377–386) and `section_state` (446–471) already exposes `recipe` in `read_section` (479–480). **No server change required for the number itself.** Optional hardening: `forge` (490–527) appends to its `note` when the composed recipe has 0 new — e.g. `note += ' · catalog fully reviewed'` — so `section_state(note=...)` returns a labeled state (469–470); the FE below is the primary surface.
Frontend (index.html):
1. Shared renderer: a function `dryLabel(body)` → `body.recipe && body.recipe.new===0` (and `body.items.length>0`, i.e. there IS a section being served) → the BACKLOG sentence. Wire it into both hydrations:
- `updateSectionUI` (1157): show/hide a new caption node in the report block — reuse `#strictCap` pattern (223): add `<div class="rcap" id="dryCap">Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks</div>` to `#report` (212–224 area; display:none default), toggled by `dryLabel`. Report is where the deck lands after the batch, so the message persists exactly where Glenn looks.
- `btnForge` success path (1188–1198): when the forge response is dry → `$('forgeStatus').textContent = dry text` (instead of blanking at 1189–1190) and `dryCap` stays visible via the `updateSectionUI(body)` call (1197).
2. Fresh inventory restored mid-session (import lands): `loadSection` already refetches on reload/resync — `updateSectionUI` recomputes `dryLabel` every time; no timer needed.
3. Copy stays exactly the BACKLOG sentence (keep "08:30" hardcoded as today — same assumption the empty state makes).
**Edge cases:**
- Dry + zero items (nothing left at all): item 8's loud empty owns that state — `dryCap` must NOT show there (guard on `items.length>0`).
- `recipe` absent/legacy API: no label; `dryLabel` returns false (body check).
- Forge in `strict` mode can't be run anyway; the caption still paints from `GET /api/section` on load.
**Acceptance criteria:**
1. Forge with `recipe.new:0` and >0 items → forgeStatus shows the exact sentence; report shows `dryCap`; a subsequent full-import + reload clears it (recipe.new>0).
2. Non-dry forge → `dryCap` absent, forgeStatus behaves as before.
3. Deck behaviour unchanged otherwise (no new fetch loops; swipes unaffected).
**Parent verification:** staged-DB forge with an emptied fresh pool; assert the DOM states in A1/A2 + the existing section-loop selfcheck green.
---
## Item 7 — Fresh counter (header)
**Goal (BACKLOG "Fresh counter"):** extend `/api/stats` with `fresh` = active listings with no swipe (the `NEW_POOL_SQL` predicate); header shows "N fresh" by the existing live/new pill. Zero reads at a glance.
**Plan:**
Backend (`app/api.py`, `stats`, 234–264):
1. Add one query next to `live` (238–240):
```sql
SELECT COUNT(*) AS n FROM listings l
WHERE l.status='active'
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id)
```
— verbatim the `NEW_POOL_SQL` predicate (sections.py 40–44). Add `'fresh': fresh` to the return dict (254–262). Note the deliberate semantic: a like/nope/maybe row of ANY age removes freshness (fresh = never touched), which is the same predicate the pool uses. A maybe swiped 8+ days ago that the legacy deck re-admits is still not "fresh" — correct per definition.
2. No other backend change: `fresh` can differ from `live - decided` because `swipes` rows are never deleted by undos that keep events, and list edits create rows too — define fresh exactly by the predicate above and do not try to reconcile.
Frontend (index.html, `loadStats`, 1127–1136):
3. Change the pill line 1130 to `<b>${res.live}</b> live` + `· <b>${res.fresh}</b> fresh` (always shown; `` fresh `0` when the pool is dry — that's the point) + the existing new-count only when >0. Spacing stays in the one `.pill` (206). Match the UI-SPEC §10.1 fit rule (no layout shift — a pill string is fine).
4. Add the same fresh number to the list sub-line? No — keep `lsub` unchanged (1131); the header pill is the requested surface.
**Edge cases:**
- Fresh goes below 0/negative never (a COUNT).
- Badge `new` counts first-seen-since-visit, an orthogonal thing; both can coexist in the pill.
- Old API (missing `fresh`): `res.fresh` undefined → guard (`res.fresh??0` or `typeof res.fresh==='number'?res.fresh:0`), fallback renders no fresh segment.
**Acceptance criteria:**
1. Fresh staging DB: 5 actives, 3 with a swipes row, 2 not → `/api/stats.fresh == 2`; header pill text contains "2 fresh".
2. All-5 swiped → `fresh:0`; pill shows "0 fresh" (not hidden).
3. Swiping one card → next `loadStats` shows 4 fresh (server read reflects the upsert; FE doesn't optimistically touch the stats pill — the pill updates on next stats fetch, same as today).
**Parent verification:** TestClient stats assertions on the staged DB + headless DOM text check after a swipe.
---
## Item 8 — Loud empty state ("No more new bikes!")
**Goal (BACKLOG "Empty state, loud"):** when the deck can serve nothing at all — no fresh, no eligible second looks, no wildcards — show a prominent "No more new bikes!" keeping the "next import 08:30" line. Today's `.empty` ("You're all caught up") is unreachable in section mode (only `legacyMode` shows it — `maybeRefill` 649–654) and the report card masks exhaustion.
**Plan:**
Server (`app/sections.py` + `app/api.py`):
1. New pool helper in `sections.py` (mirrors the composer's candidate queries, minus the per-section exclusions — "can the forge serve anything?"):
```python
def pool_counts(conn):
fresh = count(NEW_POOL_SQL)
rets = count(RETURNS_SQL)
wilds = count(WILDCARD_SQL)
```
(`RETURNS_SQL` is the composer's own eligibility predicate — resting/one-shot/last-two rules included; the last-two exclusion inside `compose_section` (274–278) is skipped here so the answer is about *stock*, not *this batch*; document that choice.)
2. `section_state` (446–471) gains `data['pool'] = secmod.pool_counts(conn)` (fresh/returns/wildcards). Every section GET + forge response carries it. No write.
Frontend (index.html):
3. **Loud empty element:** new `#empty2` beside `#report` in `#stage` (after 212), styled prominently (CSS near 95–97): big title 22px/800, larger panel (min-height ~220px), accent border, centered; copy:
```
<b>No more new bikes!</b>
New bikes arrive nightly — next import 08:30.
(↺ still brings the last card back — keep the line only if it remains true after item 1.)
```
4. **Reachability (the fix):**
- `maybeRefill` (649–674) section-mode branch: BEFORE the `sectDecided>=sectTotal` report check, if `section.pool` exists and `!buffer.length&&!pile.length` and `section.pool.fresh===0 && section.pool.returns===0 && section.pool.wildcards===0` → show `#empty2`, hide `#report` and `#empty`.
- `btnForge` success path (1188–1206): after `updateSectionUI`, run the same check against the new body — if the forge returned zero items and `pool` is all-zero, show `#empty2` and don't leave the report's Forge button inviting a wasted agent run (hide the button or keep row; show empty2 above it).
- Report screen stays the state for "section finished, more remains" (pool non-zero) — unchanged.
5. Keep `.empty` (legacy mode) as-is; `#empty2` exists only for section mode. Undo (`undo()` 702–720) removes `#empty2`'s `.on` class exactly like it clears `.empty`/`.report` (718–719).
**Edge cases:**
- The last-two-sections exclusion: a listing only appears in the last two sections' worth of items — pool query ignores that; near-total exhaustion could still leave 1–2 stale-but-served listings? No: `section_items` only blocks `RETURNS_SQL` re-offer, not `NEW`/`WILDCARD`; if pool counts say zero, compose genuinely returns zero cards (same SQLs). The tiny edge where pool>0 but compose emits 0 due to last-two+wanted-order interplay is handled by the `items.length===0` fallback: empty2 shows when a forge returns no cards AND pool is all-zero; if forge returns 0 items with pool>0 (should not happen), show empty2 with the dry line instead and log — implementer's guard, not spec.
- Import lands at 08:30 → first visit reloads sections: pool.fresh>0 → empty2 clears automatically on the next `loadSection`. No push, same as all state today.
**Acceptance criteria:**
1. Staged DB with zero fresh, zero eligible returns, zero wildcards: `/api/section` body includes `pool` all-zero and `items` empty; FE shows `#empty2.on` with the exact title "No more new bikes!" and the 08:30 line; `#report` NOT shown.
2. `pool.returns>0` (dry but alive) → report card shown, `dryCap` (item 6) active, NO empty2.
3. Undo restores the last card from empty2 → empty2 hides (718–719 parity).
4. Report + forge loops still work when pool is non-zero (regression).
**Parent verification:** staged-DB headless walk A1–A4; plus the nightly-import path simulated by inserting one fresh listing + reload → normal deck.
---
## Item 9 — Batch formula v2 (compose rewrite)
**This item AMENDS the frozen P3 rule "≤5 returns per section"** (`RANKING.md:29`, `DATA-MODEL.md:165`, `sections.py` docstring 2–17 — all say ≤5 / max 5). Say so in the commit notes. New rule, Glenn's words (BACKLOG "Batch formula v2"):
- Every batch targets **25 cards**.
- Fresh first (up to 23 slots); **≥2 second looks per batch whenever eligible stock exists** *(DEFAULT PENDING GLENN'S CONFIRMATION — the alternative, pure-fresh batches on rich days with second looks as fill only, is below)*; when fresh < 23, second looks FILL the remainder up to 25 (his example: 5 fresh + 20 second looks).
- Leftover fresh rolls to the next batch.
- Limits kept: ≤2 re-noped returns per batch; family cap relaxes for fill-returns when needed (spec below); returns stay one-shot.
- Fresh exhausted → second-looks-only batches; returns exhausted → fresh-only; both → item 8's loud empty state (forges may return 0 items — already legal: `compose_section` inserts whatever it picks, 388–401).
### Allocation (the exact slot math, spelling out the 23/25/2 interplay)
Wildcards keep their 2 slots (BACKLOG leaves them standing; `WILDCARDS=2` 31). Resolve the equations in this order:
```
want_ret = 2 if len(ret_cands) >= 2 else len(ret_cands) # the >=2 guarantee
(0 when ret_cands empty -> pure-fresh batch)
want_wild = 2 if usable wildcard candidates exist else fewer
fresh_cap = 23
want_fresh = size(25) - want_ret - want_wild
clamped to min(len(new_cands), fresh_cap)
```
1. Returns and wildcards take their reserved slots FIRST (returns before wildcards before fresh, so the guarantee can never be crowded out by fresh).
2. Fresh fills the remainder: `want_fresh = 25 - want_ret - want_wild` (max 23 when want_ret=2&wild=0? No — min is 21 on a rich day with 2+2 reserved). Then, **fill slack**: if `len(ret_cands)` has more offers and `len(picked) < 25`, add returns in role order (maybe > like > nope, points DESC — existing key 305–310) until 25 or the pool dries.
The resulting shapes on Glenn's scenarios:
- **Rich day, returns exist:** 25 cards = up to 23 fresh + ≥2 second looks + 2 wildcards. With 2 wildcards AND the 2-return guarantee, a full deck actually reads 21 fresh / 2 returns / 2 wildcards. (This is the one arithmetic consequence of "≥2 always" + "wildcards keep 2" + "target 25". The veto alternative below restores 23 fresh + ≤2.)
- **His dry-day example:** 5 fresh + fill returns up to total 25 → 5 fresh + up to 18 returns + 2 wildcards (or 20 returns + 0 wildcards when the wildcard pool is dry — his literal "5 fresh + 20 second looks"). Fractional pools (e.g. 5 fresh + 12 returns) produce a short section (<25) — correct; nothing is invented to pad.
- **Fresh = 0:** returns-only section(s) up to 25 cards each until the ~one-shot pool (≈0–30 remaining tonight per BACKLOG) dries — burns fast; BACKLOG's noted consequence is accepted in v1.1: after that, only nightly imports feed the deck.
- **Returns = 0:** fresh 25-card batches again (plus wildcards).
- **Both 0:** the loud empty state (item 8). Forge then legitimately returns a zero-item composed row; the FE renders empty2 — the report screen's Forge button should also stop being the only thing on screen (item 8 handles the mask).
### Re-nope + family-cap rules for fill-returns
- Keep ≤2 nope-verdict returns per batch (`MAX_RETURN_NOPES` 30; the "surprising" gate at 317–321 stays for *reserved* nope slots only).
- **Fill-returns relax the family cap:** when selected as fill and the choice is between skipping the cap and landing a short deck, raise the per-family limit for `return`-slot items from 4 to 8 (fill-returns only; fresh picks and reserved returns keep 4). Deterministic: fill sweeps role order; a candidate that would exceed 8 is skipped; <=4 is always preferred pass 1 (two-pass fill: first under cap 4, then under cap 8).
- Returns stay one-shot (`RETURNS_SQL`'s `NOT EXISTS section_items slot='return'` 53–54) and re-noped items rest forever (50–52). **No change to those predicates.**
- Prefer not to burn the whole one-shot pool in two batches? The BACKLOG explicitly notes the pool ~30 tonight and accepts the burn; no v1.1 policy change. **If Glenn instead wants a drip feed, that is the veto line in Open Questions.**
### Compose pseudocode (replaces the flavour of `compose_section` at 259–404; structure kept)
```
compose_section(conn, engine, size=DEFAULT_SIZE, ...):
now, rows_by_id, last_two # unchanged (266-271)
new_cands = NEW_POOL_SQL - last_two # unchanged (273-274)
ret_cands = RETURNS_SQL - last_two # unchanged (275-276)
wild_cands = WILDCARD_SQL - last_two - excluded_fams # unchanged (277-289)
fam_count, picked_ids # unchanged (291-292)
top3 = pick_new(...) ; top_fams/top_bands # unchanged (294-298) — feeds wildcard
# preference; pure pick, no commit
# 1) reserved returns: min(2 + shortfall_unknown, len(ret_cands)), role order
reserved_rets = ret_cands sorted by role_order/points; take until want_ret_nopes-rule or 2-max;
always count the 2 GUARANTEED slots (skipping only ineligible nopes)
# 2) wildcards: n_wilds = 2 (existing novel/preferred pick 328-358, unchanged)
# 3) fresh: want_fresh = size - len(rets_reserved) - n_wilds, capped 23; pick_new (360-362)
# 4) fill returns: while len(rets)<size-n_wilds-len(picked_new) and ret_cands remain:
next in role order; nope cap (<=2 total incl. reserved);
family rescue pass at cap 8 for fill only
# 5) repair bands on new slots only (unchanged 367-375)
# 6) pinned order new -> returns -> wildcards (unchanged 364-365); recipe/JSON/inserts
unchanged (377-404) with recipe.new/return/wildcard the real counts
```
Deliberate simplifications vs a full re-derivation:
- `pick_new`'s family cap 4, band repair, wildcard novelty, excluded families, LAST_TWO exclusion: **all unchanged** (160–178, 181–256, 284–289, 329–358).
- The `wanted_rets` line 304 dies; the reserved-returns stage replaces it. Constants at 28–33: `MAX_RETURNS` is deleted/renamed (fill is bounded by size), `MAX_RETURN_NOPES` stays 2, add `FRESH_CAP=23` and `MIN_RETURNS=2` (pending Glenn's confirmation — the DEFAULT in this spec).
- `read_section`/`section_state`/FE need **zero changes for the formula itself** — a section of any size ≤25 renders as today (progress pill counts `body.progress.total`).
Edge cases:
- Fewer than 2 eligible returns but ≥1: guarantee degrades gracefully (1 return + fill from... nothing else exists, that's the pool) — never fake it.
- `size` stays the clamp (`size=25` default; the forge endpoint 492–495 accepts 1..100 — body `size` unchanged in the FE which always sends 25/`{}`).
- Batch smaller than 25 mid-drain: the FE, report, and progress all already key off `progress.total`; nothing to cap client-side.
The veto alternative (record for Glenn):
- **≥2-always** (this spec's DEFAULT): every batch with any eligible return carries ≥2 second looks, even on a rich fresh day (21/2/2 shape). Keeps Glenn's "min 2" literally true.
- **Pure-fresh-on-rich-days:** reserve 2 wildcards + up to 23 fresh; returns used ONLY as fill when fresh < 23 (rich day → 23 fresh + 2 wildcards + 0 returns). This obeys "fresh first, up to 23" literally but drops the "min 2" guarantee on the days he has the most fresh to see. One boolean switch in the allocator (`min_ret = 2 if Glenn confirms else 0`); nothing else moves.
**Acceptance criteria (staged DBs, deterministic pools):**
1. Pool 30 fresh / 10 eligible returns / wildcards available → forge composes 25: fresh + wildcard + return counts match the ≥2 + fill math; `recipe` JSON exact; `progress.total == 25`.
2. Pool 5 fresh / 20 eligible returns / wildcards available → 25 cards, 5 fresh, returns fill to 20 (with 2 wildcards → 18; without → 20); his example honoured modulo wildcards.
3. Pool 23 fresh / 5 returns / wildcards available → fresh 21 + returns 2 + wildcards 2 (the ≥2 formula) — and this exact result is the sentence Glenn must sign off (Open Questions).
4. Pool 0 fresh / 12 returns → 12-card returns-only section; ≤2 nopes present; no item repeated from either of the previous two sections (LAST_TWO).
5. Pool 40 fresh / 0 returns → 25-card fresh batch, order points DESC, leftover 15 fresh roll and appear in the NEXT forge (compose twice, zero overlap, no re-offer).
6. Pool 0/0/0 → composed section with `items=[]`, FE shows item 8's empty2.
7. Family cap: a fill-heavy batch with a dominant family raises its return-slot count only to ≤8 and never violates ≤4 for fresh/reserved; band-repair still reaches 3 bands when possible.
8. Re-nope gate: exactly ≤2 nope-verdict returns per batch; a rested item (round≥2 nope) never appears.
**Parent verification:**
1. `selfcheck.py` additions driving `compose_section` against generated temp DBs for A1–A8 (the module is pure Python + SQLite and already selfcheck-tested this way for P3).
2. Full P3 loop regression on a rich and a dry DB: compose → strikes → rounds → report → forge → counts; trends numbers unchanged in shape.
---
## Item 10 — Second Look band pops
**Goal (BACKLOG "Second Look band pops"):** `.band.sl` is near-invisible today — re-tint to a **violet accent** (bold fill + border), on card face + info view. Wildcard stays amber for contrast. *(DEFAULT violet pending Glenn's nod — one-line re-tint if he prefers another hue.)*
**Current:** `.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)}` (141) — white ~10% on dark, matches the P3 §10.3 second-look recipe (142). The band appears where `bandHTML` renders it: card face (560) and — verify — the dossier does NOT currently render a band at all: `openDetail` (768–796) has no band markup. "Info view" for the pill therefore needs a small addition, see 3 below.
**Plan (CSS + one markup line):**
1. Replace line 141:
```
.band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800}
```
(violet #7c3aed family; text near-white-violet `#c4b5fd`; bold fill + crisp border). Uppercase "SECOND LOOK" label (486) reads instantly against this.
2. `.band.wild` (140) untouched — amber stays the contrast.
3. Dossier/info view: add the same band to the dossier body so "info view" carries it. In `openDetail`, after the chips (778), insert the band when `data.slot==='return'|'wildcard'`:
- simplest: an anchor div in `#dbody` (246, e.g. `<div class="band" style="position:static;display:inline-block;margin-top:6px" id="dband"></div>` — `display:none` default) filled by `openDetail` with `bandHTML(data)` logic (reuse the string builder 482–488; note `.band` is `position:absolute` by default, so give the dossier variant the static class or a dedicated `.dband` style with the same palette, per UI-SPEC palette text below).
- `.card.hasSegs .band{top:22px}` (188) already handles the card-face coexistence with segment bars.
4. No other surface: list rows don't carry bands today (not required).
**Edge cases:**
- Wildcard + segments + violet coexistence on the card: bands at 22px (188), segments at 8px (185) — unchanged geometry.
- The band wraps up to 2 lines (139 `max-width:78%`) — violet fill still legible.
- Contrast check: `#a78bfa` border on `#0b0b0e`-family backgrounds ≈ 7:1; text `#c4b5fd` on the violet-22% fill ≈ 6.5:1 — passes WCAG AA while staying dark-mode.
**Acceptance criteria:**
1. Card with `slot:'return'` renders a violet band; computed styles match the new palette (assert colour/background/border strings); wildcard card still amber.
2. Dossier opened on a return card shows the same violet band; on a new-slot card, no band.
3. Segments + band layout regression green (B1 walk).
**Parent verification:** computed-style assertions + two screenshots (card, dossier) over the tailnet or headless.
---
## Data safety
v1.1 makes **ZERO schema migrations, zero data mutations, no reset.** Every DATA-MODEL table and every existing row survives untouched; Glenn's ledger (swipes/events) is not read-modified in any new way.
Writes that happen in v1.1 are only the writes that already happen today, unchanged:
- `POST /api/swipe` (upsert `swipes` + `append_event`), `DELETE /api/swipe`, `POST /api/prefs` (one new key: `rubric_debug` — a new `prefs` row is the ONLY new write in v1.1, and it is a settings row, not data).
- Compose/forge writes a new `sections` + `section_items` row set — the normal P3 flow, identical write shape.
New READS only:
- `/api/stats` +1 COUNT (fresh).
- `GET /api/section` / forge response +2 small derived reads: `decided_this_section` flags (from `swipe_events` WHERE section_id — read-only), `pool` counts (the three existing candidate SQLs, counted).
- `/api/section` payload +1 prefs read (`rubric_debug`).
No new tables, no new columns, no imports, no re-seeding (the `swipe_events` seed in `db.init_db`, db.py 109–117, runs only when empty and is not re-run by anything here, and v1.1 ships no changes to `schema.sql` or `P3_SCHEMA`).
Rollback: revert the commit; the only residue is the `rubric_debug` pref row, harmless if left.
---
## Open Questions (blocking decisions → Glenn / Alex)
1. **Item 9 — the "≥2 second looks" guarantee on rich days (confirmed needed to build).** Default specced: every batch carries ≥2 returns whenever eligible stock exists → a full 25-deck on a rich day reads **21 fresh + 2 second looks + 2 wildcards** (fresh capped at 23 = 25-2, and the two wildcard slots come out of fresh). Alternative for Glenn to veto: pure-fresh batches on rich days (23 fresh + 2 wildcards + 0 returns; returns fill only when fresh < 23). One switch in the allocator; everything else identical.
2. **Item 10 — violet nod.** Default `#7c3aed`/`#a78bfa`/`#c4b5fd` (see amendment text). Trivial to re-tint if he picks another hue.
3. **Item 1 — Backspace vs ArrowDown for undo.** BACKLOG names ↓; the live v1.0 handler already uses Backspace. Spec keeps BOTH (↓ primary per Glenn's words, Backspace retained as the muscle-memory alias). Confirm or drop Backspace.
4. **Item 2 — dossier opened from a deck card, swiped via its own buttons:** `openDetail` from the deck currently allows committing the top card from inside the dossier (`dAction` 838–848). Under info-first flow this stays. If Glenn wants the sheet to be read-only from the deck, that's a v1.2 behaviour change — not in this spec.
5. **Item 5 — undo-on-a-return residual:** un-deciding a second look that carries a lifetime verdict restores the swipes row but leaves the `section_id`-stamped event; the in-section count re-includes it on resync (clamped so it can never exceed the total, but it can sit AT the total with one undone card). A clean fix needs an event-deletion/„undo" semantics decision (v1.2 territory alongside analytics); accepted as-is for v1.1 and stated explicitly so the parent doesn't chase it as a leak.
6. **Item 9 — one-shot return-pool burn:** with fill-to-25 the remaining eligible second looks (~≤30 as of 2026-11-12) drain within a batch or two. Accepted per BACKLOG's noted consequence; the drip-vs-burn preference belongs to Glenn before build if he cares.
7. **Item 8 — copy retention:** "↺ brings the last card back" line kept only if still true after item 1's undo mapping (it is — undo exists either way). Any copy tweaks Glenn wants on "No more new bikes!" (exclamation, wording) noted before build.
8. **Found while verifying (pre-existing, NOT v1.1 scope — flagged):** `maybe_exhaust` uses lifetime `decision` (sections.py 462) and the FE send of `photo_idx` is conditional on `nPhotos>1` (683) so single-photo cards don't update rotation; both are covered or neutral after item 5, no action. Also verified: the live "keyboard arrows" feature in v1.0 was live-deployed ahead of the BACKLOG as part of the P2 build (DESIGN.md:63, index.html 1263) — v1.1 formalizes and extends it rather than introducing it.
---
## Proposed UI-SPEC amendments (verbatim replacement text — DO NOT apply during the docs phase)
### A) §11.4 "Doors" — replace the whole subsection's body paragraph
> ### 11.4 Doors (supersedes the old tap→dossier)
>
> - ⓘ (top-right of card) → dossier (unchanged). The card face now owns: **sides = flip, center = dossier (bike data first)**; the "tap anywhere → dossier" gesture is RETIRED and the B1 center-tap→fullscreen gesture is RETIRED in favour of it.
> - Tapping the photo in the dossier hero opens the fullscreen image viewer (§11.3). **Viewer close (✕, pull-down, or Esc) returns to the dossier; the next Esc returns to the deck.** The dossier stays reachable from ⓘ and from list rows (§3/§4 unchanged).
### B) §11.3 "Fullscreen viewer" — amend the entry sentence
> - Tap the CENTER third → fullscreen viewer opens on the photo currently shown.
>
> becomes:
>
> - Tap the photo in the dossier hero → fullscreen viewer opens (starts at photo 0). Direct card→viewer is retired (see §11.4); the viewer is reached from the dossier.
### C) §2 card geometry — photo fit: add a new bullet after "Photo full-bleed"
> - Photo fit is **contain**: the whole photo is shown uncropped over a soft blurred backdrop of the same image (option C). The backdrop is decorative (blurred, dimmed, overflow-clipped); the sharp photo sits on top with a gentle shadow. The fullscreen viewer already fits; list thumbs stay `cover` (56px).
### D) §10.3 Card bands — replace the SECOND LOOK palette line
> - **SECOND LOOK — you {liked|maybed|noped} this** — color `rgba(255,255,255,.85)`; bg `rgba(255,255,255,.10)`; border `1px solid rgba(255,255,255,.22)`.
>
> becomes:
>
> - **SECOND LOOK — you {liked|maybed|noped} this** — color `#c4b5fd`; bg `rgba(124,58,237,.22)`; border `1px solid rgba(167,139,250,.65)`; font-weight 800. Wildcard stays amber (`#ffb43a` family values unchanged) for contrast. The same band renders in the dossier ("info view") when the listing's slot is `return` or `wildcard`.
### E) §10.5 Dossier "Why this rank" — add a debug-visibility paragraph
> - Hidden-by-default detail: the breakdown lines and the "from N strikes" footer render only when `prefs['rubric_debug'] === 'on'` (default `'off'`). With the pref off the block shows **the tank gauge + "{points}%"** only — the minimal rubric display. The toggle lives in the Trends "Actions" area (why-breakdown on ⇄ off, same control style as the forge-mode toggle).
### F) §10.1 Sections — replace the composition sentence within the section-progress bullet
> - Section composition (engine side — RANKING.md): ≤5 SECOND LOOK cards + 2 WILDCARD cards + the remainder new.
>
> becomes:
>
> - Section composition (engine side — RANKING.md): every batch targets 25 cards = fresh first (up to 23 slots) + ≥2 SECOND LOOK cards whenever eligible stock exists + 2 WILDCARD cards; when fresh < 23, second looks fill the remainder up to 25. Re-nope returns stay ≤2 per batch; fresh shortfalls are never padded.
### G) §2 Empty state + §10.2 report — add the loud exhausted state
> - §2's quiet "You're all caught up." remains only for legacy (pre-section) mode. In section mode, when the deck can serve nothing at all — no fresh listings, no eligible second looks, no wildcards — the deck shows a prominent **"No more new bikes!"** panel with "New bikes arrive nightly — next import 08:30." and, when true, "↺ brings the last card back." This panel supersedes the report card in that state only; the report card remains the end-of-section screen whenever more cards can still be served.
### H) §1 Globals — header pill
> - Header pill shows `{live} live · {fresh} fresh` always, and `· {new} new` only when new > 0. `fresh` = active listings with no swipe (the deck's new-pool predicate).
### I) §10.2 Forge — dry-catalog note
> - When a forged batch reports `new: 0` the report shows: "Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks." and the forge reads the same note in its status line.
(Collecting all in one place per protocol — the file itself is not touched in this phase.)
---
## Verification plan (summary the parent will re-run after the build)
1. Fresh-clone checkout of the v1.1 build, temp DB copies only (never the live DB): existing selfcheck + new v1.1 checks, all green.
2. Per item with the acceptance criteria above: 1 (keyboard: key dispatch, guards, scroll, Esc stack) · 2 (tap-flow walk + B1 §11.7 suite) · 3 (computed styles + screenshots vs demo panel C) · 4 (prefs API 200/400, dossier DOM with pref off/on) · 5 (staged returns-only section: 0/2 → 1/2 → 2/2 → report, clamp, list-edit neutrality) · 6 (dry forge → caption + status line) · 7 (stats fresh count + pill text) · 8 (0/0/0 pool → loud empty2, report mask gone) · 9 (A1–A8 compose matrix on generated pools) · 10 (violet band computed styles, wildcard unchanged, segments coexistence).
3. Full section loop regression: compose → strikes → rounds → report → forge on a rich DB and a dry DB; trends, deck feed, undos unchanged in shape.
4. Zero-mutation audit: diff against `381b6c8` shows no `schema.sql`/`P3_SCHEMA`/migration writes; `rubric_debug` pref row is the only new write.
5. Glenn phone pass (human): keyboard on MacBook; center-tap data-first on phone; violet band visible; fresh counter reads; empty2 appears when shown a dry copy (staged), loud and clear.