From 829d43851b671023569ad3dfc33414b0ca598862 Mon Sep 17 00:00:00 2001 From: Torres Date: Thu, 12 Nov 2026 15:04:40 -0400 Subject: [PATCH] docs: v1.1 implementation specification --- docs/V1.1-BUILD.md | 624 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 docs/V1.1-BUILD.md diff --git a/docs/V1.1-BUILD.md b/docs/V1.1-BUILD.md new file mode 100644 index 0000000..22de53f --- /dev/null +++ b/docs/V1.1-BUILD.md @@ -0,0 +1,624 @@ +# V1.1-BUILD — v1.1 implementation specification + +Build spec for the v1.1 QOL batch. Requirements: `docs/BACKLOG.md` "v1.1 — QOL wishlist" (both subsections). Frozen UI contract this amends: `docs/UI-SPEC.md` (v2 + P3 §10 + B1 §11). Version map: `docs/PLAN.md`. + +**Status:** spec — awaiting Glenn's go-ahead. Every item below states goal, exact change plan (file/function/line anchors verified against the code at `381b6c8`), pseudocode where the logic is heavy, edge cases, acceptance criteria, and the verification steps the parent will run. Nothing is built yet. + +**No data reset. No schema change. v1.1 is frontend + read-path/query only** (see "Data safety"). The only logic rewrite is the compose allocation itself (`compose_section`), which still writes *only* what it writes today (a new `sections`/`section_items` row set, exactly as a forge already does). + +## Code facts everyone should start from (verified 2026-11-12, commit `381b6c8`) + +Backend: + +- `app/sections.py` (488 lines): `NEW_POOL_SQL` (40–44), `RETURNS_SQL` (46–55), `WILDCARD_SQL` (57–61), constants `FAMILY_CAP=4 MAX_RETURNS=5 MAX_RETURN_NOPES=2 WILDCARDS=2 DEFAULT_SIZE=25 MIN_BANDS=3` (28–33), `pick_new` (160–178), `repair_bands` (181–256), `compose_section` (259–404), `section_items` (417–425), `section_counts` (428–454), `maybe_exhaust` (457–466), `read_section` (469–488). +- `app/api.py` (738 lines): `/api/stats` (234–264), `swipe` POST (299–357; `section_id` optional at 307–309, `append_event` call at 345–346), `section_state` (446–471), `get_section` (473–488; cold compose hardcodes `size=25` at 482–485), `forge` (490–527), `/api/prefs` (652–666; `allowed = {'forge_mode': ('ondemand','strict')}` at 656), `img` with `object-fit:contain` viewer served at 670–708. +- `app/db.py`: `swipe_events` schema with `section_id` (18–26), `append_event` (120–143) — already stamps `section_id` when the client sends it; P3 seeding copies events with `section_id=NULL` (111–117). + +Frontend: `app/static/index.html` (1282 lines, single-file app): + +- Keyboard handler ALREADY EXISTS at 1263–1270 (see item 1 — this must be reconciliated with the BACKLOG key map). +- Tap zones: `tapZone` (756–763) — sides flip, center opens viewer. Built faithful to B1 §11.4. v1.1 changes the center-target only. +- Deck→dossier door: `.ibtn` click (578–580); `openDetail` (768–796); dossier hero with `dphoto` (245) and hero-tap→viewer in `dEndH` (832–837). +- Viewer: `openViewer` (870–877), `closeViewer` (878–883), `vCtx={item,idx,back}` (853, 873); `.vimg` (197) already `object-fit:contain`. +- Card DOM: `phwrapHTML` (512–513) puts `` 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`