59 KiB
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), constantsFAMILY_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),swipePOST (299–357;section_idoptional at 307–309,append_eventcall at 345–346),section_state(446–471),get_section(473–488; cold compose hardcodessize=25at 482–485),forge(490–527),/api/prefs(652–666;allowed = {'forge_mode': ('ondemand','strict')}at 656),imgwithobject-fit:containviewer served at 670–708.app/db.py:swipe_eventsschema withsection_id(18–26),append_event(120–143) — already stampssection_idwhen the client sends it; P3 seeding copies events withsection_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:
.ibtnclick (578–580);openDetail(768–796); dossier hero withdphoto(245) and hero-tap→viewer indEndH(832–837). - Viewer:
openViewer(870–877),closeViewer(878–883),vCtx={item,idx,back}(853, 873);.vimg(197) alreadyobject-fit:contain. - Card DOM:
phwrapHTML(512–513) puts<img class="photo">inside the.phwrap;.card .photoisobject-fit:cover(CSS 36). - Header:
#pill(206, filled byloadStats1127–1136 — it renderslive · newfrom/api/stats),#sect(207) is the section progress pill,.sectCSS 137–138,.pill29–30. - Empty state:
#empty(212) with copy "You're all caught up."; CSS 95–97; shown only bymaybeRefillin 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 incommit(684) andundo(706); server truth re-derived on everyupdateSectionUI(1157–1173) frombody.items[].decision— the bug:decisionis the listing's lifetime verdict, not a this-section decision. bandHTML(482–489);.band(139),.band.wild(140),.band.sl(141);hasSegsband offset (188);updateSectionUI(1157);forgebutton flow (1180–1206); prefs patternsetMode(1174–1179);loadStats(1127).- Info-page hero:
.dphoto(70) isobject-fit:cover; list thumbs.thumb(110) arecoverand stay that way. - Rubric display: dossier "Why this rank" block
#drank(250–257) filled byfillRank(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.pymodule docstring (2–17) anddocs/RANKING.md:29still 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.html677–689; button bindings at 721–723), undo throughundo()(702–720; binding 724). The keys must call these exact functions so all P3 invariants (optimistic persist throughsetDecision, 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):
- 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) - Guard before acting: ignore all keys while a text input has focus. Extend the existing
e.target.tagName==='INPUT'check to also skipTEXTAREA,SELECT, or any element withisContentEditable(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; - 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. - Esc, one key, stack order (viewer
#viewerz-72 > dossier#detailz-70 > deck):- if
#viewerhas classon->closeViewer()(878) - else if
#detailhas classon->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
#reporthas classon-> 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/#vieweris removed in favour of the Esc-first ordering above. Esc only closes; arrows only work on the deck.
- if
Edge cases:
- Arrow keys must do nothing when no pile:
commitandundoalready no-op on emptypile[0]/hist(677–678, 703). - Key repeat (holding →):
commitdrains 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):
- 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 assertsectDecided/paintSect side effects identical). - Dispatch
ArrowDown-> the swiped card returns to the top of the pile (undo flight +DELETE /api/swipe/{id}queued). - Dispatch
Backspace-> same undo. - Focus the list search input, type arrows -> input cursor moves / text unchanged, zero swipe commits (assert pile length unchanged).
- 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).
- Call
window.scrollTo(0,100)-style check: withdocument.body.scrollTopmanipulable, arrow dispatch does not change scroll (assert viae.defaultPreventedspy onkeydown).
Parent verification:
fe_walk_b1-style headless walk: boot app, run acceptance 1–6 in order, assert green.- 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):
tapZone(761): change the center branch toopenDetail(entry.d, {type:'deck'}). Sides unchanged. Do not clear the card's photo position — the dossier is a sheet over the deck.- 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 intapZone's new branch to{...dCtx, entry}(the pile entry with its livecurphoto index), set tonullfor row/matches opens (1036, 1113) and for ⓘ (580).closeDetail(822) clears it. - 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 onback, 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 tinyvWalkBack(): ifvCtx.back.backToDetailexists, capture the item, callcloseViewer(), thenopenDetail(item, backToDetail)— image returns to the deck dossier (photo 0, per §3's hero contract), where the next Esc closes to the deck. If nobackToDetail, plaincloseViewer().
- in
- 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.imgis 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:
- Center tap (≤6px movement) on the top card opens
#detail(.on), populated with that card's payload; no viewer opens; no commit fires. - Side taps still flip photos both directions, clamped; no dossier opens (existing B1 §11.2 behaviour re-verified).
- Tap the dossier hero photo ->
#vieweropens at photo 0. Esc -> viewer closes, dossier still open. Esc -> dossier closes, same deck/pile state and same top card (photo index intact). - ⓘ tap still opens the dossier (regression).
- From the list: row tap -> dossier -> hero tap -> viewer -> Esc -> dossier -> Esc -> list (regression with the new Esc routing).
Parent verification:
- Headless walk of acceptance 1–5 plus the B1 §11.7 suite re-run green (flip clamps, viewer swarm, no-lock, one
.topcard). - 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>):
- Deck card:
- change line 36 to
object-fit:contain(keepposition:absolute;inset:0;width:100%;height:100%). - add inside
.phwrap(512–513) a backdrop layer behind the sharp photo. HTML: when asrcexists render<div class="phblur"></div><img class="photo" ...>(use an inlinestyle="background-image:url('...')"from the samephotoSrcvalue; escape the URL-attrs as the file already does elsewhere — the src is app-server-generated, but quote-escape it to be safe). CSS:The photo gets a touch of shadow so the edge reads on top of the blur:.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)}filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))on.card .photo. Thenophotofallback path (513) renders no.phblur, naturally. containerpresence check:.card .grad(37) sits over the fit area — fine (it is a scrim, UI-SPEC §2).
- change line 36 to
- Info-page hero:
- change line 70 to
object-fit:contain. - give
#dphotothe same twin: add a.dblurdiv inside.dhero(245) behind the image, styled identically to.phblur(.dherohas nooverflow:hidden; add it). Populate onopenDetail(772 area): samedata.imgURL, emptied when no photo / error fallback (774). Simplest robust approach: reuse a tiny helpersetBackdrop(shellEl, url)called fromopenDetailandphwrapHTML's error/success paths.
- change line 70 to
- No-reflow guarantees: both fits put letterbox bars around the photo; because the photo is
position:absoluteand 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"). - 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 whenprefers-reduced-motion— implementer's knob, not a spec change.
Edge cases:
- 0-photo listing:
.nophotopanel 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:
- 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). - Dossier hero photo ==
contain, backdrop present, spec table layout unmoved. - Viewer
.vimgcomputed style ==contain(no diff), list.thumb==cover(no diff). - 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 byfillRank(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):
- 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). - 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 asdata['mode']at 453)./api/sectionand the forge response already go throughsection_state, so every section load carries it. No/api/statschange needed. - 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):
fillRank(798–821) renders by default: the gauge (existing 804) + compact "76%" label. Change 805 fromdata.tank.value+'/100'todata.tank.value+'%'and move the label into the existing left gauge column or directly beside it (e.g. wrap gauge +%in the.tankwrap).- Guard the detailed parts behind the pref: build the
dlinesHTML (806–812) and thedfootline (813–814) ONLY when the pref is'on'; otherwise empty both and keep#drankvisible with just gauge +%(the block itself persists so the minimal state is still "gauge + %", matching the BACKLOG ask). - Toggle UI (findable by Glenn, harmless otherwise): add to the Trends "Actions" area (317–320) a second
.trowexactly in thesetModestyle (1174–1179 + 1248–1249): "Why breakdown: on ⇄ off", both spans wired liketOnd/tStrto a newsetRubricDebug(m)that POSTs/api/prefsand updates the sections-widesection.prefs.rubric_debugfor the nextfillRank. Persist per session:updateSectionUI(1157–1173) also storessection.prefs(already via body) so a re-loaded deck repaints correctly. fillRankreads((section&§ion.prefs&§ion.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:sectionis null → pref off → minimal display; the API call for the toggle then target isn't the issue (legacy servers return 404 on/api/prefsPOST; FE silently ignores failure,setModeprecedent 1175–1176). tank.valuenull (unscored/deep rows):fillRankalready hides the whole block (799–802) — unchanged.- The percent must be the rounded tank value (0–100 clamp is already applied in
tankSVG455).
Acceptance criteria:
- Dossier for a scored listing shows gauge + "NN%" and NO breakdown lines, NO footer line, on a fresh load (pref unset).
POST /api/prefs {rubric_debug:'on'}→ 200{key,value}; reload → same dossier shows breakdown lines + footer.'off'→ they disappear. Invalid value → 400.- List rows and card face carry zero rubric text in both states (regression).
- Pref survives server restart (SQLite
prefs) and page reload; forge response carries it.
Parent verification:
- TestClient: prefs happy path / 400 path; fresh-DB
/api/sectionbody includesprefs.rubric_debug. - 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):
- Add a helper next to
section_counts:asSELECT 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 sectionsection_decided_ids(conn, section) -> set(listing_id). (Filtering tosi.listing_idmembership keeps out-of-section stray events from a foreign section_id honest; the join is the same shapesection_countsuses at 439–451.) - Add the per-item flag on the read path:
section_itemscannot see events per row without a second join; cheaper — after buildingitemsinread_section(471–472), setitem['decided_this_section'] = item['id'] in decided_ids. (sqlite3.Rowis read-only until cast; either materialize items as dicts there or attach the flag inapi.section_state(447–457) where items are rebuilt as full payloads anyway — recomputesection_decided_idsonce insection_stateand addp['decided_this_section'] = p['id'] in idsalongsidep['slot']at 454–456. Prefer this: one read, no Row mutation.) read_sectionprogress (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 becausesection_itemsis ALSO the joined-swipes view it reads... No:maybe_exhaustreadsit['decision'] is not None. Change line 462 to checksection_decided_ids(conn, section)membership instead:undecided = [it for it in items if it['id'] not in decided_ids]andif not undecided: close_section(...). Otherwise a returns-only section reports "complete" before a single strike. This is part of the same fix.section_counts(428–454) already derives like/maybe/nope fromdecision(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 thissection_idexists and matches (SELECT e.listing_id, e.action ... WHERE e.section_id=?joined tosection_itemsthe same way; the existingrestruck/wildcard_hitsqueries at 439–451 already do exactly this join — mirror it). Note: this does not change what is stored, only what the report shows.- No DB writes anywhere in this item (flag is computed per read).
Frontend (app/static/index.html):
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.)- Clamp everywhere
sectDecidedis 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 rendersMath.min(sectDecided, sectTotal). - Hot state:
paintSecttoggleshotonly whensectDecided>=sectTotal(1144, unchanged code) — with in-section counting this now fires only when the section truly has a verdict per card;commitkeeps painting immediately after a full sweep (the server resync inmaybeRefill663–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
swipesrow and clears nosection_idevent (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:sectDecidedmay show one more decided than the user feels, but NEVER pastsectTotalthanks 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_sectionmust be present for ALL items insection_statepayloads or the FElocal===undefinedbranch misfires — set it unconditionally (not only for returns).
Acceptance criteria:
- 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/sectionprogress.decided == 0even though everyitem.decisionis non-null; everyitem.decided_this_section == false. - Post one swipe with
section_id: progress 1/2; recount matches events;paintSectshows 1/2. Post the second: 2/2,hotclass present, section flips toreporton next read, report counts reflect the two verdicts (not lifetime). - With 2 items and a double-swipe (two commits before any resync): painted pill never exceeds
2/2(clamp assertion). - 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.decidedunchanged. - Full P3 regression: compose→strike→rounds→report→forge loop green; undo restores the previous pill value minus one.
Parent verification:
- Selfcheck additions in
selfcheck.py(new checks against a temp DB, same style as existing) + the staged-DB walk in acceptance 1–4. - Live-DB dry run: snapshot
swipe_events, computesection_decided_idsfor 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):
- Shared renderer: a function
dryLabel(body)→body.recipe && body.recipe.new===0(andbody.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#strictCappattern (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 bydryLabel. Report is where the deck lands after the batch, so the message persists exactly where Glenn looks.btnForgesuccess path (1188–1198): when the forge response is dry →$('forgeStatus').textContent = dry text(instead of blanking at 1189–1190) anddryCapstays visible via theupdateSectionUI(body)call (1197).
- Fresh inventory restored mid-session (import lands):
loadSectionalready refetches on reload/resync —updateSectionUIrecomputesdryLabelevery time; no timer needed. - 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 —
dryCapmust NOT show there (guard onitems.length>0). recipeabsent/legacy API: no label;dryLabelreturns false (body check).- Forge in
strictmode can't be run anyway; the caption still paints fromGET /api/sectionon load.
Acceptance criteria:
- Forge with
recipe.new:0and >0 items → forgeStatus shows the exact sentence; report showsdryCap; a subsequent full-import + reload clears it (recipe.new>0). - Non-dry forge →
dryCapabsent, forgeStatus behaves as before. - 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):
- Add one query next to
live(238–240):— verbatim theSELECT COUNT(*) AS n FROM listings l WHERE l.status='active' AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id)NEW_POOL_SQLpredicate (sections.py 40–44). Add'fresh': freshto 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. - No other backend change:
freshcan differ fromlive - decidedbecauseswipesrows 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):
- Change the pill line 1130 to
<b>${res.live}</b> live+· <b>${res.fresh}</b> fresh(always shown; `` fresh0when 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). - Add the same fresh number to the list sub-line? No — keep
lsubunchanged (1131); the header pill is the requested surface.
Edge cases:
- Fresh goes below 0/negative never (a COUNT).
- Badge
newcounts first-seen-since-visit, an orthogonal thing; both can coexist in the pill. - Old API (missing
fresh):res.freshundefined → guard (res.fresh??0ortypeof res.fresh==='number'?res.fresh:0), fallback renders no fresh segment.
Acceptance criteria:
- Fresh staging DB: 5 actives, 3 with a swipes row, 2 not →
/api/stats.fresh == 2; header pill text contains "2 fresh". - All-5 swiped →
fresh:0; pill shows "0 fresh" (not hidden). - Swiping one card → next
loadStatsshows 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):
- 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_SQLis the composer's own eligibility predicate — resting/one-shot/last-two rules included; the last-two exclusion insidecompose_section(274–278) is skipped here so the answer is about stock, not this batch; document that choice.) section_state(446–471) gainsdata['pool'] = secmod.pool_counts(conn)(fresh/returns/wildcards). Every section GET + forge response carries it. No write.
Frontend (index.html):
- Loud empty element: new
#empty2beside#reportin#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.) - Reachability (the fix):
maybeRefill(649–674) section-mode branch: BEFORE thesectDecided>=sectTotalreport check, ifsection.poolexists and!buffer.length&&!pile.lengthandsection.pool.fresh===0 && section.pool.returns===0 && section.pool.wildcards===0→ show#empty2, hide#reportand#empty.btnForgesuccess path (1188–1206): afterupdateSectionUI, run the same check against the new body — if the forge returned zero items andpoolis all-zero, show#empty2and 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.
- Keep
.empty(legacy mode) as-is;#empty2exists only for section mode. Undo (undo()702–720) removes#empty2's.onclass 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_itemsonly blocksRETURNS_SQLre-offer, notNEW/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 theitems.length===0fallback: 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:
- Staged DB with zero fresh, zero eligible returns, zero wildcards:
/api/sectionbody includespoolall-zero anditemsempty; FE shows#empty2.onwith the exact title "No more new bikes!" and the 08:30 line;#reportNOT shown. pool.returns>0(dry but alive) → report card shown,dryCap(item 6) active, NO empty2.- Undo restores the last card from empty2 → empty2 hides (718–719 parity).
- 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_sectioninserts 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)
- Returns and wildcards take their reserved slots FIRST (returns before wildcards before fresh, so the guarantee can never be crowded out by fresh).
- 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: iflen(ret_cands)has more offers andlen(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_NOPES30; 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'sNOT 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_retsline 304 dies; the reserved-returns stage replaces it. Constants at 28–33:MAX_RETURNSis deleted/renamed (fill is bounded by size),MAX_RETURN_NOPESstays 2, addFRESH_CAP=23andMIN_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 countsbody.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.
sizestays the clamp (size=25default; the forge endpoint 492–495 accepts 1..100 — bodysizeunchanged 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):
- Pool 30 fresh / 10 eligible returns / wildcards available → forge composes 25: fresh + wildcard + return counts match the ≥2 + fill math;
recipeJSON exact;progress.total == 25. - 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.
- 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).
- 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).
- 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).
- Pool 0/0/0 → composed section with
items=[], FE shows item 8's empty2. - 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.
- Re-nope gate: exactly ≤2 nope-verdict returns per batch; a rested item (round≥2 nope) never appears.
Parent verification:
selfcheck.pyadditions drivingcompose_sectionagainst generated temp DBs for A1–A8 (the module is pure Python + SQLite and already selfcheck-tested this way for P3).- 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):
- Replace line 141:
(violet #7c3aed family; text near-white-violet
.band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800}#c4b5fd; bold fill + crisp border). Uppercase "SECOND LOOK" label (486) reads instantly against this. .band.wild(140) untouched — amber stays the contrast.- 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 whendata.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:nonedefault) filled byopenDetailwithbandHTML(data)logic (reuse the string builder 482–488; note.bandisposition:absoluteby default, so give the dossier variant the static class or a dedicated.dbandstyle 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.
- simplest: an anchor div in
- 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:
#a78bfaborder on#0b0b0e-family backgrounds ≈ 7:1; text#c4b5fdon the violet-22% fill ≈ 6.5:1 — passes WCAG AA while staying dark-mode.
Acceptance criteria:
- Card with
slot:'return'renders a violet band; computed styles match the new palette (assert colour/background/border strings); wildcard card still amber. - Dossier opened on a return card shows the same violet band; on a new-slot card, no band.
- 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(upsertswipes+append_event),DELETE /api/swipe,POST /api/prefs(one new key:rubric_debug— a newprefsrow is the ONLY new write in v1.1, and it is a settings row, not data).- Compose/forge writes a new
sections+section_itemsrow 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_sectionflags (fromswipe_eventsWHERE section_id — read-only),poolcounts (the three existing candidate SQLs, counted)./api/sectionpayload +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)
- 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.
- Item 10 — violet nod. Default
#7c3aed/#a78bfa/#c4b5fd(see amendment text). Trivial to re-tint if he picks another hue. - 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.
- Item 2 — dossier opened from a deck card, swiped via its own buttons:
openDetailfrom the deck currently allows committing the top card from inside the dossier (dAction838–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. - 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. - 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.
- 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.
- Found while verifying (pre-existing, NOT v1.1 scope — flagged):
maybe_exhaustuses lifetimedecision(sections.py 462) and the FE send ofphoto_idxis conditional onnPhotos>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); bgrgba(255,255,255,.10); border1px solid rgba(255,255,255,.22).becomes:
- SECOND LOOK — you {liked|maybed|noped} this — color
#c4b5fd; bgrgba(124,58,237,.22); border1px solid rgba(167,139,250,.65); font-weight 800. Wildcard stays amber (#ffb43afamily values unchanged) for contrast. The same band renders in the dossier ("info view") when the listing's slot isreturnorwildcard.
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} freshalways, and· {new} newonly 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: 0the 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)
- Fresh-clone checkout of the v1.1 build, temp DB copies only (never the live DB): existing selfcheck + new v1.1 checks, all green.
- 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).
- Full section loop regression: compose → strikes → rounds → report → forge on a rich DB and a dry DB; trends, deck feed, undos unchanged in shape.
- Zero-mutation audit: diff against
381b6c8shows noschema.sql/P3_SCHEMA/migration writes;rubric_debugpref row is the only new write. - 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.