Files
Bike-Tinder/docs/V1.1-BUILD.md

59 KiB
Raw Blame History

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: APPROVED 2026-11-12 (Glenn): ≥2-second-looks default confirmed (rich day = 21 fresh + 2 second looks + 2 wildcards); violet band; Backspace kept as undo alias. Build authorized — implementation in progress on dev. 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):

  1. 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).
  2. 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).
  3. 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.
  4. 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:
    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):

  1. 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.)
  2. 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).
  3. 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):
    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):

  1. 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).
  2. 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?"):
    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):

  1. 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.)
    
  2. 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.
  3. 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.