25 Commits
v1.0.0 ... main

Author SHA1 Message Date
Alexander Rozhenko
7088304eda merge: issue #3 fix 2026-11-15 12:41:48 -04:00
Alexander Rozhenko
beeace231d fix #3: guard breakdown() so a delisted mid-batch card cannot 500 the deck
A section can outlive an ad: when a card's listing left the active pool after compose and has no swipe history, it is absent from the engine's by_id map. breakdown() indexed the missing row without a guard, so one such card 500'd GET /api/section (and /api/listing/{id}) - live incident 2026-11-15: section 57 + delisted listing 298. Degrade to no-signal lines instead. Regression test added; before/after verified on a copy of the live DB (section 500 -> 200; forge rebuild 200).
2026-11-15 11:06:51 -04:00
Alexander Rozhenko
f252ea06c7 merge: issue #2 fix (header pill liveness) 2026-11-13 16:01:43 -04:00
Alexander Rozhenko
79e7a4f61f fix #2: header pill refreshes itself after swipes, undo and new batches
loadStats() only ran once at boot, so the live/fresh/new pill (and the
list sub-line) went stale until a full reload. Refresh it, debounced and
fire-and-forget, after any decision change, when a new batch lands, and
on app foreground. Adds two fe_walk_v11 regression checks + a UI-SPEC note.
2026-11-13 15:58:36 -04:00
Alexander Rozhenko
618cd4a61c docs: refresh README deployment status (live 2026-11-12, sidecar authorized, nightly chain) (#1) 2026-11-13 15:01:31 -04:00
Torres
4ec86c23d1 merge: v1.1 QOL patrol (verified) — counter fix, fresh pill, loud empty state, batch formula v2, violet SL band, info-first taps, fit+blur photos, minimal rubric 2026-11-13 14:17:42 -04:00
Torres
66d3cc4da3 docs: apply the v1.1 UI-SPEC amendments (A-I) from V1.1-BUILD.md
§2 card geometry contain/blur bullet · §2 loud empty state · §7.3 header
pill fresh counter · §10.1 batch-formula-v2 composition sentence · §10.2
dry-catalog note · §10.3 violet SECOND LOOK band + dossier mirror · §10.5
minimal rubric + rubric_debug visibility · §11.3/§11.4 info-first doors
and the viewer walk-back chain.
2026-11-13 14:05:33 -04:00
Torres
35138bdc22 test(v1.1): fe_walk_v11 harness (89 checks, 3 staged catalogs) + polish from the walk
- app/fe_walk_v11.py: self-contained headless walk — stages rich /
  returns-only / dead-catalog Dbs, boots the scratch server on 9455
  (OPENROUTER_API_KEY unset), and verifies all ten items on the real page:
  keyboard map + guards + no-scroll, tap flow + Esc chains (deck and list
  paths), contain/blur computed styles, minimal rubric + debug toggle
  round-trip, the 0/25 -> 25/25 counter with undos, dry messaging, fresh
  pill, loud empty2 + nightly-recovery, violet bands, B1 regressions
  (flip clamps, one .topcard, no-lock storm) and the full P3 loop.
- Keyboard: ArrowDown/Backspace now no-op while a sheet is open (arrows
  work on the deck only — spec note); found by the walk.
- get_section: an empty composed section (fully drained catalog)
  recomposes on read once pool_counts shows stock again — the nightly
  import re-lights the deck without a manual forge (item 8 recovery).
2026-11-13 14:04:11 -04:00
Torres
08f945a029 feat(v1.1): item 10 — Second Look band in violet (bold fill + border)
.band.sl re-tinted to the confirmed violet palette (#c4b5fd text,
rgba(124,58,237,.22) fill, rgba(167,139,250,.65) border, weight 800);
wildcard stays amber for contrast. The card band mirrors into the
dossier body (new #dband, static variant of the pill) on return and
wildcard listings; new-slot dossiers show no band. bandHTML/bandLabel
split reuses the same labels on both surfaces.
2026-11-13 13:54:01 -04:00
Torres
2160ba6ce8 feat(v1.1): item 9 — batch formula v2 compose rewrite (AMENDS the frozen '<=5 returns' rule)
Allocator now: reserved returns (>=2 whenever eligible stock exists,
role order, surprising-gate on reserved nope slots, cap-4 family) ->
wildcards (2) -> fresh (remainder, capped at 23; wilds draw from the
fresh pool without consuming fresh slots) -> fill returns to size (role
order, <=2 re-nopes total, two-pass family cap: 4 preferred, 8 rescue for
fill slots only). MAX_RETURNS dies; FRESH_CAP=23, MIN_RETURNS=2,
RETURN_FILL_FAMILY_CAP=8 land. Rich day = 21 fresh + 2 second looks +
2 wildcards (the sign-off shape); dry day fills to 25 with returns; 0/0/0
composes an empty section; shortfalls are never padded.

Docs: module docstring, RANKING.md section recipe and DATA-MODEL returns
eligibility rewritten to the v1.1 contract (+ rubric_debug pref noted).

Selfcheck: A1-A8 compose matrix on deterministic staged pools (21/2/2
rich; 5 fresh + 20 SL fill incl. wild-from-fresh derivation; 21/2/2
sign-off; 12-return dry with <=2 nopes + LAST_TWO re-compose; 40 fresh ->
23+2 then the 15 leftover with zero overlap; 0/0/0 empty; family-cap 8
fill rescue; rested/gate-blocked re-nope rules). Pool-honest note: the
spec's A2 '5 fresh + 18 returns + 2 wild' assumed 7 fresh listings —
wildcards are by definition never-swiped, so a 5-fresh day reads 5 fresh
total (3 new + 2 wild) + 20 second looks.
2026-11-13 13:53:33 -04:00
Torres
a1455aade4 feat(v1.1): items 7-8 — fresh counter pill + loud 'No more new bikes!' empty state
Item 7: /api/stats gains fresh (actives with no swipe row — the deck's
new-pool predicate verbatim); the header pill reads 'N live · M fresh'
(new badge unchanged); old APIs without fresh render no segment.

Item 8: sections.pool_counts() answers can-the-forge-serve-anything from
the composer's own candidate SQLs (stock answer, LAST_TWO not applied)
and rides every section_state body. The FE adds #empty2 ('No more new
bikes!' + 08:30 line + undo note): shown whenever the pile/buffer drain
and pool is 0/0/0 (report masked), on a zero-item dry forge too; undo
hides it; legacy .empty untouched. UpdateUI repaints the caption per
hydrate; the stats key-set selfcheck gains fresh.
2026-11-13 13:42:08 -04:00
Torres
0d2e5a592f feat(v1.1): item 6 — dry-catalog messaging (forged batches with 0 fresh are labeled)
section_state now mirrors the composed recipe at body.recipe; the forge
note appends 'catalog fully reviewed' when the batch has zero fresh cards.
The FE shows the BACKLOG sentence in the report (dryCap) and in the forge
status line whenever recipe.new===0 with cards served; a non-dry forge
behaves exactly as before. No new fetch loops.
2026-11-13 13:39:22 -04:00
Torres
98d345ceee fix(v1.1): item 5 — section counter keys off in-section events (2/2 -> 3/2 bug)
A card is decided only when a swipe event carries THIS section's id;
the lifetime swipes.action a second look wears into its section no longer
paints it as pre-decided. progress/undecided, report counts (latest
in-section event per listing) and maybe_exhaust all use the new
section_decided_ids() set; read path only, no writes. Every section_state
item carries decided_this_section; the FE counts server truth unless a
local intent is in flight, and sectDecided is clamped to sectTotal
everywhere (commit + paintSect) so the pill can never read past N/N.

Semantics note: completing a section now means striking EVERY item in it
(returns re-struck on their second look) — the exhaust selfcheck was
updated to strike all items, matching the deck's real flow. Undo-on-a-
return residual (event stays after the DELETE) is the accepted Q5 clamp-
only behavior, covered by a dedicated selfcheck case.
2026-11-13 13:38:18 -04:00
Torres
ac613e680a feat(v1.1): item 4 — minimal rubric (gauge + %) and rubric_debug 'why' toggle
- prefs API accepts the new rubric_debug key (off|on), the only new runtime
  write in v1.1; every section_state body now carries prefs.rubric_debug.
- fillRank renders the tank gauge + rounded % by default; the breakdown
  lines, audit footer and the quiet listing-enrichment fetch run only when
  the pref is on. List rows and the card face stay rubric-free (unchanged).
- Trends 'Actions' gains a 'Why breakdown: off | on' row in the setMode
  style; updateSectionUI repaints it on every section hydrate.
2026-11-13 13:31:21 -04:00
Torres
e67f8a0cda feat(v1.1): items 1-3 — keyboard map, info-first tap flow, photos = fit + blurred backdrop
Item 1: ArrowLeft/Right/Up drive nope/like/maybe through commit(); ArrowDown
and Backspace (kept alias) drive undo(); Esc closes the top open sheet only
(viewer -> dossier -> deck, inert on report). Text inputs/TEXTAREA/SELECT/
contentEditable swallow keys; arrow defaults are prevented (no page scroll).

Item 2: card center tap now opens the dossier (bike data first); side taps
keep flipping photos; the direct card -> viewer door is retired. The deck
dossier records its pile entry; the hero tap opens the viewer with a
backToDetail chain so Esc walks viewer -> dossier -> deck (repeatable).

Item 3: deck card + dossier hero photos switch from cover to contain over a
soft blurred backdrop of the same image (option C, demo panel C); thumb and
fullscreen viewer untouched; nophoto paths render no backdrop.
2026-11-13 13:30:09 -04:00
Torres
7a1d14f23a docs: v1.1 build authorized — Glenn's sign-offs (>=2-SL default, violet band, Backspace alias); status -> implementation 2026-11-13 13:22:16 -04:00
Torres
829d43851b docs: v1.1 implementation specification 2026-11-12 15:04:40 -04:00
Torres
381b6c8a9c docs: v1.1 round 2 — fresh counter, loud empty state, batch formula v2 (25-target, second-look fill), Second Look band pop 2026-11-12 14:51:43 -04:00
Torres
7e4c41cffd docs: v1.1 gains section-counter bug fix + dry-catalog messaging (diagnosed live 2026-11-12) 2026-11-12 14:44:07 -04:00
Torres
d1c3adddff docs: version map — v1.1 (QOL batch, awaiting build order) / v1.2 (analytics suite + fresh-ledger reset) 2026-11-12 14:39:07 -04:00
Torres
085b2bc2ce docs: BACKLOG — v1.1 photo treatment decided: option C (fit + blurred backdrop) for card + info hero 2026-11-12 14:37:31 -04:00
Torres
e77ed38ebf docs: BACKLOG — gallery mirror gap (galleries not locally hedged; primaries only) 2026-11-12 14:35:15 -04:00
Torres
eff1ae313f docs: BACKLOG — v1.1 QOL wishlist (shortcuts, tap flow, photo fit, rubric) + analytics phase notes 2026-11-12 14:31:29 -04:00
Torres
066329e92d Merge branch 'dev' 2026-11-12 14:17:38 -04:00
Torres
47cecbc28b docs: record v1.0.0 Gitea release (tag on main head) 2026-11-12 14:17:38 -04:00
13 changed files with 2449 additions and 99 deletions

View File

@@ -2,9 +2,9 @@
A private, Tinder-style web app for browsing the motorcycle market: swipe real listings (like / nope / maybe), see everything on one editable list, and let the deck learn your taste. Built for Glenn; runs on the Rotarran, published to the tailnet only. A private, Tinder-style web app for browsing the motorcycle market: swipe real listings (like / nope / maybe), see everything on one editable list, and let the deck learn your taste. Built for Glenn; runs on the Rotarran, published to the tailnet only.
**Status:** Phase 3 (personalization) built on `dev` (2026-11-11): sections of 25 (wildcards + second looks + new), taste engine with match points, tank gauges, dossier "Why this rank", real Trends tab, agent pass (mock / live / fallback). Phase 2 remains shipped as-is; deployment (podman stack) shipped 2026-11-12 — stack live under `podman-stack-bike-tinder.service`, nightly import + agent read + DB backup armed; the private phone URL (`https://biketinder.tailf35a34.ts.net`) awaits the one-time Tailscale sidecar authorization. **Status:** Live. Phases 1–3 are deployed (sections — wildcards + second looks + new; taste engine with match points; tank gauges; dossier "Why this rank"; real Trends tab; agent pass mock / live / fallback), B1 is live (photo galleries, deck flips, fullscreen viewer), and v1.1.0 (QOL patrol) was deployed + released on 2026-11-13. The podman stack has served since 2026-11-12 under `podman-stack-bike-tinder.service`, with the nightly import + agent read + DB backup armed; the private phone URL (`https://biketinder.tailf35a34.ts.net`) was authorized (Tailscale sidecar) on 2026-11-12 and is in use from Glenn's phone.
**B1 (photo experience v2)** built on `dev`: full photo galleries mirrored locally, deck flips (tap left/right thirds, clamped), segment bars, a fullscreen viewer (swipe nav, pinch/double-tap zoom, pull-down close), and repeat rotation (a re-shown card leads at its next photo). See UI-SPEC §11 + the "B1 develop notes" section below. **B1 (photo experience v2)** shipped 2026-11-11: full photo galleries mirrored locally, deck flips (tap left/right thirds, clamped), segment bars, a fullscreen viewer (swipe nav, pinch/double-tap zoom, pull-down close), and repeat rotation (a re-shown card leads at its next photo). See UI-SPEC §11 + the "B1 develop notes" section below.
## How it works ## How it works
@@ -54,7 +54,7 @@ python3 -m app.importer --state-dir ~/.hermes/scripts/motorcycle-hunt.d --db dat
# serve the app (127.0.0.1:9453 by default) # serve the app (127.0.0.1:9453 by default)
.venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453 .venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453
# self-check: P1 + P2 + P3 (25 checks, temp DB copies only) # self-check: P1–P3 + B1 + v1.1 (35 checks, temp DB copies only)
.venv/bin/python -m app.selfcheck .venv/bin/python -m app.selfcheck
# P3: agent pass — mock is deterministic + offline (tests, offline forge); # P3: agent pass — mock is deterministic + offline (tests, offline forge);
@@ -131,9 +131,11 @@ Stage 2 + the task brief.
`b1/test-img/` = staged `{id}_{n}.webp` photos · `b1/test-copy.sqlite3` = `b1/test-img/` = staged `{id}_{n}.webp` photos · `b1/test-copy.sqlite3` =
pristine live-DB snapshot (selfcheck copies it; never write to it). pristine live-DB snapshot (selfcheck copies it; never write to it).
## Deployment (planned) ## Deployment (live)
A small podman compose stack on the Rotarran under `/var/mnt/DATA/podman-stacks/bike-tinder/`, its own systemd user unit (`Delegate=yes`), SQLite database. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling) and serves the app privately at `https://biketinder.tailf35a34.ts.net`. Single user; reachable only from Glenn's own devices. The app runs as a small podman compose stack on the Rotarran under `/var/mnt/DATA/podman-stacks/bike-tinder/` (a checkout of `release`), under its own systemd user unit (`podman-stack-bike-tinder.service`, `Delegate=yes`), SQLite database — deployed 2026-11-12. The stack ships its own Tailscale node (sidecar container — no host-tailnet coupling); the node was authorized on 2026-11-12, and the app serves privately at `https://biketinder.tailf35a34.ts.net` — in use from Glenn's phone. Single user; reachable only from Glenn's own devices.
The nightly chain (`bike-tinder-import`, 08:30 daily) keeps it current: catalog import → live agent read (taste profile) → SQLite backup into the stack's `data/backup/` (rotation 14 deep).
## Crew ## Crew

View File

@@ -238,6 +238,14 @@ def create_app(db_path, img_dir, state_dir=None):
live = conn.execute( live = conn.execute(
"SELECT COUNT(*) AS n FROM listings WHERE status='active'" "SELECT COUNT(*) AS n FROM listings WHERE status='active'"
).fetchone()['n'] ).fetchone()['n']
# item 7: fresh = active with no swipe row (the deck's new-pool
# predicate, verbatim). A swipe of ANY age removes freshness.
fresh = conn.execute(
"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)"
).fetchone()['n']
badge = get_pref(conn, 'badge_seen_at') or '' badge = get_pref(conn, 'badge_seen_at') or ''
new = conn.execute( new = conn.execute(
"SELECT COUNT(*) AS n FROM listings" "SELECT COUNT(*) AS n FROM listings"
@@ -253,6 +261,7 @@ def create_app(db_path, img_dir, state_dir=None):
' ORDER BY id DESC LIMIT 1').fetchone() ' ORDER BY id DESC LIMIT 1').fetchone()
return { return {
'live': live, 'live': live,
'fresh': fresh,
'new': new, 'new': new,
'likes': per['like'], 'likes': per['like'],
'maybes': per['maybe'], 'maybes': per['maybe'],
@@ -451,11 +460,20 @@ def create_app(db_path, img_dir, state_dir=None):
sec = conn.execute('SELECT * FROM sections WHERE id=?', sec = conn.execute('SELECT * FROM sections WHERE id=?',
(sec['id'],)).fetchone() (sec['id'],)).fetchone()
mode = get_pref(conn, 'forge_mode', 'ondemand') mode = get_pref(conn, 'forge_mode', 'ondemand')
decided_ids = secmod.section_decided_ids(conn, sec)
items = [p3_payload(conn, engine, r, slot=r['slot'], items = [p3_payload(conn, engine, r, slot=r['slot'],
include_breakdown=True) include_breakdown=True)
for r in data['items']] for r in data['items']]
for p in items:
# item 5: decided IN this section (real verdict), not the joined
# lifetime swipes decision — present on EVERY item so the FE's
# local-intent branch can't misfire
p['decided_this_section'] = p['id'] in decided_ids
data['items'] = items data['items'] = items
data['mode'] = mode data['mode'] = mode
data['recipe'] = (json.loads(sec['recipe']) if sec['recipe'] else {})
data['pool'] = secmod.pool_counts(conn)
data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')}
data['can_forge'] = (mode != 'strict' data['can_forge'] = (mode != 'strict'
or secmod.fresh_profile_exists(conn, sec)) or secmod.fresh_profile_exists(conn, sec))
data['counts'] = secmod.section_counts(conn, sec) data['counts'] = secmod.section_counts(conn, sec)
@@ -483,6 +501,22 @@ def create_app(db_path, img_dir, state_dir=None):
conn, engine, size=25, conn, engine, size=25,
profile_version=prof['version'] if prof else None, profile_version=prof['version'] if prof else None,
note='cold compose (first read)') note='cold compose (first read)')
else:
# item 8: an empty composed section (the catalog was fully
# drained) recomposes on the read once stock exists — the
# nightly-import recovery path. Zero items + zero pool
# keeps serving the empty section (loud empty2).
n_items = conn.execute(
'SELECT COUNT(*) AS n FROM section_items'
' WHERE section_id=?', (sec['id'],)).fetchone()['n']
if n_items == 0 and any(secmod.pool_counts(conn).values()):
prof = secmod.latest_profile(conn)
engine = secmod.build_engine(conn, prof, now=utcnow())
secmod.close_section(conn, sec, utcnow(), status='closed')
sec = secmod.compose_section(
conn, engine, size=25,
profile_version=prof['version'] if prof else None,
note='refill compose (stock returned)')
return section_state(conn, sec) return section_state(conn, sec)
finally: finally:
conn.close() conn.close()
@@ -522,6 +556,11 @@ def create_app(db_path, img_dir, state_dir=None):
conn, engine, size=size, conn, engine, size=size,
profile_version=prof['version'] if prof else None, profile_version=prof['version'] if prof else None,
note=note) note=note)
# v1.1 item 6: label the dry-catalog state on the forge note
recipe = (json.loads(newsec['recipe'])
if newsec['recipe'] else {})
if recipe.get('new') == 0:
note = '%s · catalog fully reviewed' % (note or '')
return section_state(conn, newsec, note=note) return section_state(conn, newsec, note=note)
finally: finally:
conn.close() conn.close()
@@ -653,7 +692,8 @@ def create_app(db_path, img_dir, state_dir=None):
def set_pref_endpoint(body: dict): def set_pref_endpoint(body: dict):
key = body.get('key') key = body.get('key')
value = body.get('value') value = body.get('value')
allowed = {'forge_mode': ('ondemand', 'strict')} allowed = {'forge_mode': ('ondemand', 'strict'),
'rubric_debug': ('off', 'on')}
if key not in allowed or value not in allowed[key]: if key not in allowed or value not in allowed[key]:
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail='unknown or invalid pref') detail='unknown or invalid pref')

876
app/fe_walk_v11.py Normal file
View File

@@ -0,0 +1,876 @@
"""v1.1 FE walk — headless-chromium proof of every v1.1 item on the real page.
Self-contained: stages its own temp DBs (never the live file), boots the
scratch server on 127.0.0.1:9455 (never 9453), runs the walks, kills the
server. Mock forge only (OPENROUTER_API_KEY is not consulted — the server
is started with the key unset).
cd /var/home/admin/dev/bike-tinder-v11/build
.venv/bin/python -m app.fe_walk_v11 setup # (auto-runs at walk start)
.venv/bin/python -m app.fe_walk_v11 # full walk
Phases:
WALK1 (rich: pristine catalog + synthetic multi-photo fixtures 701-706,
705 a liked return) — items 1, 2, 3, 4, 7, 10, B1 §11 regressions
(flip clamps / one .topcard / no-lock swipe storm), full P3 loop
(compose -> strike all -> report -> forge), item 6 non-dry.
WALK2 (returns-only: every active pre-swiped maybe) — item 5 counter
(0/N -> 1/N -> undo -> sweep -> 25/25 hot -> report counts),
item 6 dry caption + dry forge status.
WALK3 (0/0/0: returns one-shot-burned) — item 8 loud empty2, nightly-
import recovery (a fresh listing re-lights the deck).
"""
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
ARENA = '/var/home/admin/dev/bike-tinder-v11'
REPO = os.path.join(ARENA, 'build')
PRISTINE = os.path.join(ARENA, 'scratch-v11-pristine.sqlite3')
WALK1 = os.path.join(ARENA, 'scratch-v11-walk1.sqlite3')
WALK2 = os.path.join(ARENA, 'scratch-v11-walk2.sqlite3')
WALK3 = os.path.join(ARENA, 'scratch-v11-walk3.sqlite3')
IMG = os.path.join(ARENA, 'img-v11')
MEDIA = os.path.join(ARENA, 'media-sample-v11')
SHOTS = os.path.join(ARENA, 'shots-v11')
BASE_URL = 'http://127.0.0.1:9455'
PORT = 9455
CDN = ('https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/x/%s'
'?rule=kijijica-640-webp')
FIX = {
701: (8, 'gallery-8'),
702: (3, 'gallery-3'),
703: (2, 'gallery-2'),
704: (1, 'single-photo'),
705: (8, 'return-rotation'),
706: (0, 'zero-photo'),
}
SCORE_BASE = 2000
server_proc = None
page = None
# --- staging ---------------------------------------------------------------
def stage_img_files():
os.makedirs(IMG, exist_ok=True)
names = sorted(m for m in os.listdir(MEDIA) if m.endswith('.webp'))
assert len(names) == 12, names
k = 0
for lid in sorted(FIX):
n = FIX[lid][0]
if n == 0:
continue
if n == 1:
shutil.copy2(os.path.join(MEDIA, names[k % len(names)]),
os.path.join(IMG, '%d.webp' % lid))
k += 1
continue
for i in range(n):
shutil.copy2(os.path.join(MEDIA, names[k % len(names)]),
os.path.join(IMG, '%d_%d.webp' % (lid, i)))
k += 1
def seeds():
"""Fixture rows + return candidates on a temp copy; returns the maybe-pair."""
from app import db as dbmod
conn = sqlite3.connect(WALK1)
conn.row_factory = sqlite3.Row
try:
dbmod.init_db(conn) # guarded B1/P3 migrations
for lid in sorted(FIX):
n = FIX[lid][0]
imgs = [CDN % ('x%d-%d' % (lid, i)) for i in range(n)]
img = imgs[0] if imgs else None
score = SCORE_BASE + (750 - lid)
conn.execute("DELETE FROM listings WHERE id=?", (lid,))
conn.execute(
"INSERT INTO listings (id, region, src_id, title, url, status,"
" img_url, imgs, score, first_seen, desc) VALUES"
" (?, 'ns', ?, ?, ?, 'active', ?, ?, ?,"
" '2026-11-11T00:00:00Z', ?)",
(lid, 'v11fix%d' % lid, 'V1.1 test %s' % FIX[lid][1],
'https://www.kijiji.ca/v-motorcycles/v11/%d' % lid,
img, json.dumps(imgs) if imgs else None, score,
'V1.1 fixture'))
conn.execute(
"INSERT OR REPLACE INTO swipes (listing_id, action, created_at,"
" last_photo_idx) VALUES (705, 'like', '2026-11-08T00:00:00Z', 0)")
# fresh fixtures: round-1 events keep them OUT of the wildcard pool
for lid in FIX:
conn.execute(
"INSERT INTO swipe_events (listing_id, action, round,"
" section_id) VALUES (?, ?, 1, NULL)",
(lid, 'like' if lid == 705 else 'maybe'))
# second reserved maybe-return: the highest-scored existing swiped
# active swings to maybe so 705 keeps its reserved return slot
x = conn.execute(
"SELECT s.listing_id FROM swipes s"
" JOIN listings l ON l.id = s.listing_id"
" WHERE l.status='active' ORDER BY l.score DESC LIMIT 1"
).fetchone()['listing_id']
conn.execute("UPDATE swipes SET action='maybe' WHERE listing_id=?", (x,))
conn.execute(
"UPDATE swipe_events SET action='maybe' WHERE listing_id=?"
" AND round=1", (x,))
conn.execute("UPDATE sections SET status='closed'"
" WHERE status!='closed'")
conn.commit()
return x
finally:
conn.close()
def setup():
assert os.path.exists(PRISTINE), 'pristine copy missing'
os.makedirs(SHOTS, exist_ok=True)
stage_img_files()
shutil.copy2(PRISTINE, WALK1)
seeds()
shutil.copy2(PRISTINE, WALK2)
conn = sqlite3.connect(WALK2)
try:
conn.execute('DELETE FROM section_items')
conn.execute("UPDATE sections SET status='closed'"
" WHERE status!='closed'")
conn.execute(
"""INSERT INTO swipes (listing_id, action, created_at)
SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings
WHERE status='active'
AND id NOT IN (SELECT listing_id FROM swipes)""")
conn.execute(
"""INSERT INTO swipe_events (listing_id, action, round, section_id)
SELECT id, 'maybe', 1, NULL FROM listings
WHERE status='active'
AND id NOT IN (SELECT listing_id FROM swipe_events)""")
conn.commit()
finally:
conn.close()
print('setup: walk DBs staged (%s, %s)' % (WALK1, WALK2))
# --- server ----------------------------------------------------------------
def start_server(db_path):
global server_proc
env = dict(os.environ)
env.pop('OPENROUTER_API_KEY', None) # mock forge pass only
server_proc = subprocess.Popen(
[os.path.join(REPO, '.venv/bin/python'), '-m', 'app.serve',
'--db', db_path, '--img-dir', IMG, '--host', '127.0.0.1',
'--port', str(PORT), '--state-dir',
os.path.join(ARENA, 'state-v11')],
cwd=REPO, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(60):
try:
with urllib.request.urlopen(BASE_URL + '/api/health',
timeout=2) as r:
if r.status == 200:
return
except Exception:
pass
time.sleep(0.25)
raise RuntimeError('scratch server did not come up')
def stop_server():
global server_proc
if server_proc:
server_proc.terminate()
try:
server_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
server_proc.kill()
server_proc = None
# --- walk helpers ----------------------------------------------------------
CHECKS = [0, 0]
def wait(selector, timeout=10000):
page.wait_for_selector(selector, timeout=timeout)
def ok(msg):
CHECKS[0] += 1
print('ok ' + msg)
def bad(msg):
CHECKS[1] += 1
print('FAIL ' + msg)
page.screenshot(path=os.path.join(SHOTS, 'fail-%s.png' % int(time.time())))
raise SystemExit(1)
def check(cond, msg):
if cond:
ok(msg)
else:
bad(msg)
def top_state():
return page.evaluate("""() => {
const t=document.querySelector('.topcard');
const r={topcards:document.querySelectorAll('.topcard').length,
photo:null, segOn:-1, segs:0, band:null, bandClass:null};
if(!t) return r;
const s=t.querySelector('.segments');
if(s){ r.segs=s.children.length;
[...s.children].forEach((x,k)=>{ if(x.classList.contains('on'))
r.segOn=k; }); }
const p=t.querySelector('.phwrap img.photo');
if(p) r.photo=p.getAttribute('src');
const b=t.querySelector('.band');
if(b){ r.band=b.textContent; r.bandClass=b.className; }
return r; }""")
def top_id():
t = top_state()
if not t['photo']:
return None
m = re.search(r'/img/(\d+)(?:\?i=\d+)?$', t['photo'] or '')
return int(m.group(1)) if m else None
def sect_pill():
return page.evaluate("() => ({txt:document.getElementById('sect').textContent,"
" hot:document.getElementById('sect').classList.contains('hot')})")
def pile_len():
return page.evaluate("() => document.querySelectorAll('.topcard').length +"
" + [...document.querySelectorAll('#stage .card')].filter("
" e=>!e.classList.contains('topcard')).length")
def key(k):
page.keyboard.press(k)
time.sleep(0.08)
def api(path):
with urllib.request.urlopen(BASE_URL + path, timeout=10) as r:
return json.loads(r.read())
def api_post(path, body):
req = urllib.request.Request(BASE_URL + path,
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def section_body():
return api('/api/section')
def flick(ms=350):
time.sleep(ms / 1000.0)
def tap_center():
b = page.evaluate("""() => { const el=document.querySelector('.topcard');
const r=el.getBoundingClientRect();
return {x:r.x+r.width/2,y:r.y+r.height*0.72}; }""")
page.mouse.click(b['x'], b['y'])
time.sleep(0.08)
def tap_side(side):
b = page.evaluate("""() => { const el=document.querySelector('.topcard');
const r=el.getBoundingClientRect();
return {x:r.x, w:r.width, y:r.y+r.height*0.72}; }""")
x = b['x'] + (0.16 if side == 'left' else 0.84) * b['w']
page.mouse.click(x, b['y'])
time.sleep(0.08)
def taps(clicks, gap=40):
# real commit storm through the like button: the no-lock law must
# survive button-speed swipes
for _ in range(clicks):
page.evaluate("() => document.getElementById('btnLike').click()")
time.sleep(gap / 1000.0)
def dossier_on():
return page.evaluate(
"() => document.getElementById('detail').classList.contains('on')")
def viewer_on():
return page.evaluate(
"() => document.getElementById('viewer').classList.contains('on')")
def dhero_click():
b = page.evaluate("""() => { const el=document.getElementById('dhero')||
document.querySelector('.dhero');
const r=el.getBoundingClientRect();
return {x:r.x+r.width/2,y:r.y+r.height/2}; }""")
page.mouse.click(b['x'], b['y'])
time.sleep(0.15)
# --- WALK1: rich deck ------------------------------------------------------
def walk_rich():
check(page.url.startswith(BASE_URL), 'page served from the app root')
wait('.topcard')
flick(800)
body = section_body()
check(len(body['items']) == 25, 'rich compose serves 25 items')
check(body['recipe']['new'] == 21 and body['recipe']['return'] == 2
and body['recipe']['wildcard'] == 2,
'recipe 21/2/2 on the rich pool (the sign-off shape)')
by_id = {i['id']: i for i in body['items']}
for lid in (701, 702, 703, 704, 706):
check(lid in by_id and by_id[lid]['slot'] == 'new',
'fixture %s sits in the fresh slots' % lid)
check(by_id[705]['slot'] == 'return', '705 composes into a return slot')
t = top_state()
check(t['topcards'] == 1, 'exactly one .topcard on load')
check(top_id() == 701, 'top card is the highest-scored fixture (701)')
# item 3: computed styles on the top card (fit + backdrop)
css = page.evaluate("""() => { const c=document.querySelector('.topcard');
const p=c.querySelector('.phwrap img.photo');
const b=c.querySelector('.phwrap .phblur');
return {fit:getComputedStyle(p).objectFit,
blur:!!b, bg:b?b.style.backgroundImage:null,
src:p.getAttribute('src')}; }""")
check(css['fit'] == 'contain', 'item 3: card photo object-fit == contain')
check(bool(css['blur']) and css['src'] in (css['bg'] or ''),
'item 3: card backdrop exists and mirrors the photo URL')
page.screenshot(path=os.path.join(SHOTS, 'item3-card-fit.png'))
# --- item 5+1: strikes through the real key map ------------------------
check(sect_pill()['txt'] == '0/25', 'fresh rich section starts 0/25')
key('ArrowUp')
check(sect_pill()['txt'] == '1/25', 'ArrowUp (maybe) -> 1/25')
key('ArrowDown')
check(sect_pill()['txt'] == '0/25', 'ArrowDown (undo) -> 0/25')
key('ArrowUp')
check(sect_pill()['txt'] == '1/25', 're-decide -> 1/25')
key('Backspace')
check(sect_pill()['txt'] == '0/25', 'Backspace is the undo alias')
pill_before = sect_pill()['txt']
# scroll prevention spy
page.evaluate("""() => { window.__prev=[]; document.addEventListener(
'keydown',e=>window.__prev.push([e.key,e.defaultPrevented])); }""")
key('ArrowLeft')
key('ArrowRight')
prevented = page.evaluate("""() => window.__prev.filter(
p=>p[0].startsWith('Arrow')).map(p=>p[1])""")
check(all(prevented), 'item 1: arrows defaultPrevented (no page scroll)')
pn = sect_pill()['txt']
check(int(pn.split('/')[0]) == int(pill_before.split('/')[0]) + 2,
'ArrowLeft/ArrowRight commit through the normal path (%s -> %s)'
% (pill_before, pn))
# --- item 1: text-input guard (list search) ---------------------------
page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()")
wait('#lsearch')
page.evaluate("""() => { const s=document.getElementById('lsearch');
s.focus(); }""")
page.keyboard.type('abc')
before_search = page.evaluate(
"() => document.getElementById('lsearch').value")
check(before_search == 'abc', 'search input accepts typing (focus guard on)')
for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'):
page.keyboard.press(kk)
flick(60)
check(page.evaluate(
"() => document.getElementById('lsearch').value") == 'abc',
'arrows inside the search input never swipe (text + value unchanged)')
check(sect_pill()['txt'] == pn,
'input-focused arrows leave the section counter untouched')
page.keyboard.press('Backspace')
check(page.evaluate(
"() => document.getElementById('lsearch').value") == 'ab',
'Backspace edits text while an input owns focus (no undo fired)')
page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()")
flick(300)
# --- item 2: center tap -> dossier -> hero -> viewer; Esc/Es ----------
tid_before = top_id()
src_before = top_state()['photo']
pill_pre_tap = sect_pill()['txt']
tap_center()
check(sect_pill()['txt'] == pill_pre_tap,
'item 2: center tap commits nothing')
check(dossier_on(), 'item 2: center tap opens the dossier')
check(not viewer_on(), 'item 2: no viewer on center tap')
check(bool(page.evaluate(
"() => document.getElementById('dtitle').textContent")),
'dossier carries the card title')
dhero_click()
check(viewer_on(), 'dossier hero tap opens the viewer (photo 0)')
vidx = page.evaluate("""() => { const im=document.getElementById('vimg');
return {src:im.getAttribute('src'),
fot:getComputedStyle(im).objectFit}; }""")
check(vidx['fot'] == 'contain', 'item 3: viewer vimg stays contain (no diff)')
page.screenshot(path=os.path.join(SHOTS, 'item3-viewer.png'))
# arrows are inert while the viewer is open
pill_now = sect_pill()['txt']
for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp'):
page.keyboard.press(kk)
check(sect_pill()['txt'] == pill_now,
'arrows are inert while the viewer is open')
key('Escape')
check(not viewer_on() and dossier_on(),
'Esc closes the viewer only (dossier stays open)')
key('Escape')
check(not dossier_on() and not viewer_on(),
'second Esc closes the dossier to the deck')
check(top_id() == tid_before and top_state()['photo'] == src_before,
'walk-back leaves the same card on the same photo')
# item 2 A5: list row -> dossier -> viewer -> Esc -> dossier -> Esc -> list
page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()")
wait('#lrows .row')
flick(400)
thumb_fit = page.evaluate("""() => { const t=document.querySelector(
'.thumb'); return t?getComputedStyle(t).objectFit:null; }""")
check(thumb_fit == 'cover', 'item 3: list thumbs stay cover (no diff)')
page.evaluate("""() => document.querySelector('#lrows .row').click()""")
flick(400)
check(dossier_on(), 'list row tap opens the dossier')
dhero_click()
check(viewer_on(), 'list dossier hero opens the viewer')
key('Escape')
check(not viewer_on() and dossier_on(), 'Esc: viewer -> dossier (list)')
key('Escape')
check(not dossier_on()
and page.evaluate("() => document.getElementById('listScreen')"
".classList.contains('on')"),
'second Esc returns to the list screen')
page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()")
flick(300)
# arrows inert while the dossier is open
tap_center()
check(dossier_on(), 'dossier open for the key-inertness check')
before_pill = sect_pill()['txt']
for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'):
page.keyboard.press(kk)
flick(40)
check(sect_pill()['txt'] == before_pill,
'arrow keys are inert while the dossier is open')
# item 10+4: dossier band + minimal rubric on the fresh card
db = page.evaluate("""() => { const b=document.getElementById('dband');
if(b.style.display==='none') return {off:true};
return {off:false, cls:b.className}; }""")
check(bool(db.get('off')), 'dossier on a fresh card shows no band')
drank = page.evaluate("""() => { const d=document.getElementById('drank');
return {shown:d.style.display!=='none',
pts:document.getElementById('dpts').textContent,
lines:document.getElementById('dlines').innerHTML,
foot:document.getElementById('dfoot').textContent}; }""")
check(re.search(r'^\d+%$', drank['pts']),
'item 4: rank block shows gauge + NN%% (%s)' % drank['pts'])
check(not drank['lines'] and not drank['foot'],
'item 4: breakdown lines + footer hidden with rubric_debug off')
dc = page.evaluate("""() => { const p=document.getElementById('dphoto');
const b=document.getElementById('dblur');
return {fit:getComputedStyle(p).objectFit,
bg:b.style.backgroundImage}; }""")
check(dc['fit'] == 'contain' and bool(dc['bg']),
'item 3: dossier hero fits contain over its backdrop')
page.screenshot(path=os.path.join(SHOTS, 'item3-dossier.png'))
key('Escape')
# item 2: side taps still flip (clamped), never open a sheet
before_photo = top_state()['photo']
tap_side('left')
check(top_state()['photo'] == before_photo, 'left tap clamps at photo 0')
check(not dossier_on() and not viewer_on(),
'side taps never open sheets (B1 §11.2 regression)')
tap_side('right')
check(top_state()['photo'] != before_photo, 'right tap flips forward')
# --- item 4: rubric_debug on -> breakdown appears ----------------------
api_post('/api/prefs', {'key': 'rubric_debug', 'value': 'on'})
page.reload(wait_until='networkidle')
wait('.topcard')
flick(400)
tap_center()
check(dossier_on(), 'dossier open for the debug-mode check')
drank = page.evaluate("""() => { const d=document.getElementById('drank');
if(d.style.display==='none') return {shown:false};
return {shown:true,
lines:document.getElementById('dlines').textContent,
foot:document.getElementById('dfoot').textContent}; }""")
check(bool(drank['shown']) and bool(drank['lines']),
'item 4: rubric_debug on -> breakdown lines render')
page.screenshot(path=os.path.join(SHOTS, 'item4-debug-on.png'))
key('Escape')
page.evaluate("() => document.querySelector('.tab[data-tab=trends]').click()")
flick(300)
page.evaluate("() => document.getElementById('tRubOff').click()")
flick(400)
check(api('/api/section').get('prefs', {}).get('rubric_debug') == 'off',
'Trends toggle wrote rubric_debug=off to the server')
heck = page.evaluate("""() => document.getElementById(
'tRubOff').classList.contains('on')""")
check(bool(heck), 'toggle UI paints the off state')
page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()")
flick(300)
# --- item 7: header pill
stats = api('/api/stats')
pill_txt = page.evaluate(
"() => document.getElementById('pill').textContent")
check(('%d live' % stats['live']) in pill_txt
and ('%d fresh' % stats['fresh']) in pill_txt,
'item 7: pill reads live + fresh (%s)' % pill_txt.replace('\n', ' '))
# --- v1.1.1 (issue #2): the header pill keeps itself live --------------
pl0 = api('/api/stats')
key('ArrowRight') # probe swipe, undone right after
flick(300)
pl1 = api('/api/stats')
dl = time.time() + 5
ptx = ''
while time.time() < dl:
ptx = page.evaluate("() => document.getElementById('pill').textContent")
if ('%d fresh' % pl1['fresh']) in ptx:
break
time.sleep(0.2)
check(('%d fresh' % pl1['fresh']) in ptx and ('%d live' % pl1['live']) in ptx,
'issue #2: pill refreshes itself after a swipe, no reload (%s -> %s)'
% (pl0['fresh'], ptx))
key('ArrowDown') # undo the probe swipe (net-zero)
flick(300)
pl2 = api('/api/stats')
dl = time.time() + 5
while time.time() < dl:
ptx = page.evaluate("() => document.getElementById('pill').textContent")
if ('%d fresh' % pl2['fresh']) in ptx:
break
time.sleep(0.2)
check(('%d fresh' % pl2['fresh']) in ptx,
'issue #2: pill refreshes itself after an undo, no reload (%s)' % ptx)
# --- B1 no-lock: rapid commit storm (no sheet, no lock) ----------------
before = sect_pill()['txt']
taps(2, 40)
flick(500)
t = top_state()
check(t['topcards'] == 1, 'no-lock storm keeps exactly one .topcard')
after = sect_pill()['txt']
check(int(after.split('/')[0]) - int(before.split('/')[0]) == 2,
'both storm swipes landed (%s -> %s)' % (before, after))
# --- item 10 + P3 loop: sweep to the return band and exhaust ------------
bands = {}
for _ in range(30):
tid = top_id()
if tid is not None and tid in by_id:
slot = by_id[tid]['slot']
if slot in ('return', 'wildcard') and slot not in bands:
bands[slot] = page.evaluate("""() => { const b=document.querySelector(
'.topcard .band'); if(!b) return null;
const s=getComputedStyle(b);
return {cls:b.className, color:s.color, bg:s.backgroundColor,
text:b.textContent}; }""")
if not page.evaluate("() => document.querySelector('.topcard')"):
break
key('ArrowRight')
flick(80)
check(bool(bands.get('return')),
'item 10: a SECOND LOOK card topped and carried its band')
if bands.get('return'):
check(bands['return']['color'] == 'rgb(196, 181, 253)',
'item 10: SL band text is the violet tint')
check(bands['return']['bg'] == 'rgba(124, 58, 237, 0.22)',
'item 10: SL band fill is violet 22%')
check('SECOND LOOK' in bands['return']['text'],
'item 10: SL band label intact')
page.screenshot(path=os.path.join(SHOTS, 'item10-card-band.png'))
if bands.get('wildcard'):
check('wild' in bands['wildcard']['cls'],
'item 10: wildcard band keeps its amber variant')
# dossier band on a return item: drive the real openDetail path
ret_item = next((i for i in body['items'] if i['slot'] == 'return'), None)
db = page.evaluate("""(item) => { openDetail(item, {type:'deck'});
const b=document.getElementById('dband');
const s=getComputedStyle(b);
return {off:b.style.display==='none', color:s.color,
bg:s.backgroundColor, text:b.textContent}; }""", ret_item)
check(not db['off'] and db['color'] == 'rgb(196, 181, 253)'
and 'SECOND LOOK' in db['text'],
'item 10: dossier mirrors the violet SECOND LOOK band')
key('Escape')
# finish the section -> report -> forge
for _ in range(10):
if not page.evaluate("() => document.querySelector('.topcard')"):
break
key('ArrowRight')
flick(80)
wait('#report.on, #empty2.on', timeout=10000)
rep = page.evaluate("""() => ({report:document.getElementById('report')
.classList.contains('on'),
dry:document.getElementById('dryCap').style.display!=='none',
counts:document.getElementById('repCounts').textContent,
empty2:document.getElementById('empty2').classList.contains('on')})""")
check(bool(rep['report']), 'P3 loop: report card owns the exhausted deck')
check(not rep['dry'], 'item 6: rich batch shows no dry caption')
check(not rep['empty2'], 'item 8: empty2 stays hidden while stock remains')
check(sect_pill()['hot'], 'pill is hot at the section end')
page.screenshot(path=os.path.join(SHOTS, 'p3-report.png'))
sec_before = section_body()['section']['id']
page.evaluate("() => document.getElementById('btnForge').click()")
wait('.topcard', timeout=20000)
check(section_body()['section']['id'] != sec_before,
'forge opened a brand-new section')
pill = sect_pill()
check(re.search(r'^\d+/25$', pill['txt']),
'new section pill starts at a plain N/25 (%s)' % pill['txt'])
check(not dossier_on() and not viewer_on(), 'forge leaves no sheets open')
print('rich walk complete')
# --- WALK2: returns-only (counter fix + dry messaging) ---------------------
def walk_returns_only():
wait('.topcard')
flick(800)
body = section_body()
check(len(body['items']) == 25 and body['recipe']['new'] == 0,
'returns-only catalog composes 25 second looks (recipe %s)'
% json.dumps(body['recipe']))
check(body['progress']['decided'] == 0
and body['progress']['total'] == 25,
'item 5: fresh batch reads 0/25 (old code painted 25/25)')
pill = sect_pill()
check(pill['txt'] == '0/25' and not pill['hot'],
'pill shows 0/25, no premature hot state')
# one real strike -> 1/25 (in-section event, not lifetime verdict)
key('ArrowRight')
pill = sect_pill()
check(pill['txt'] == '1/25', 'first strike -> 1/25 (%s)' % pill['txt'])
check(section_body()['progress']['decided'] == 1,
'server truth agrees: one in-section decision')
key('ArrowUp')
check(sect_pill()['txt'] == '2/25', 'ArrowUp maybe -> 2/25')
key('ArrowDown')
check(sect_pill()['txt'] == '1/25', 'ArrowDown undo -> 1/25')
key('Backspace')
check(sect_pill()['txt'] == '0/25', 'Backspace undo -> 0/25')
# mixed actions -> report counts proven in-section only
key('ArrowLeft')
key('ArrowUp')
key('ArrowRight')
check(sect_pill()['txt'] == '3/25', 'three strikes -> 3/25 (clamped)')
# sweep to exhaustion
for _ in range(30):
if not page.evaluate("() => document.querySelector('.topcard')"):
break
key('ArrowRight')
flick(60)
wait('#report.on', timeout=10000)
pill = sect_pill()
check(pill['txt'] == '25/25' and pill['hot'],
'pill clamps at 25/25 and goes hot (never 26/25)')
counts = api('/api/section')['counts']
check(counts['likes'] + counts['maybes'] + counts['nopes'] == 25,
'report counts the 25 in-section verdicts (not lifetime)')
rep = page.evaluate("""() => ({dry:document.getElementById('dryCap')
.style.display!=='none',
status:document.getElementById('forgeStatus').textContent})""")
check(bool(rep['dry']), 'item 6: dry caption shows on the 0-fresh report')
# dry forge -> the sentence + a fresh 0/25 returns-only deck
page.evaluate("() => document.getElementById('btnForge').click()")
for _ in range(40):
st = page.evaluate(
"() => document.getElementById('forgeStatus').textContent")
if 'Catalog fully reviewed' in st:
break
flick(250)
st = page.evaluate(
"() => document.getElementById('forgeStatus').textContent")
check('Catalog fully reviewed — fresh listings arrive with the next'
' 08:30 import; showing second looks' in st,
'item 6: dry forge status shows the exact BACKLOG sentence')
wait('.topcard', timeout=15000)
body = section_body()
check(body['recipe']['new'] == 0 and body['progress']['decided'] == 0,
'dry forge decks another 25 second looks at 0/25')
print('returns-only walk complete')
# --- WALK3: dead catalog (loud empty2) -------------------------------------
def burn_returns(db_path):
conn = sqlite3.connect(db_path)
try:
conn.execute('DELETE FROM section_items')
conn.execute("UPDATE sections SET status='closed'"
" WHERE status!='closed'")
conn.execute(
"""INSERT INTO section_items (section_id, listing_id, slot,
position)
SELECT (SELECT id FROM sections ORDER BY id DESC LIMIT 1),
listing_id, 'return', 1 FROM swipes
WHERE listing_id IN (SELECT id FROM listings
WHERE status='active')""")
conn.commit()
finally:
conn.close()
def walk_dead_catalog():
body = section_body()
check(body['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0},
'item 8: drained catalog reports pool 0/0/0')
check(body['items'] == [], 'compose emits zero items on a dead pool')
wait('#empty2.on', timeout=10000)
st = page.evaluate("""() => { const e=document.getElementById('empty2');
return {on:e.classList.contains('on'),
txt:e.textContent,
report:document.getElementById('report').classList.contains('on')}; }""")
check(bool(st['on']), 'item 8: loud empty2 owns the stage')
check('No more new bikes!' in st['txt'] and '08:30' in st['txt'],
'item 8: exact title + next-import line')
check(not st['report'], 'item 8: report card masked on the dead catalog')
page.screenshot(path=os.path.join(SHOTS, 'item8-empty2.png'))
# forge on the dead catalog: still empty2, no wasted Forge invitation
page.evaluate("() => document.getElementById('btnForge').click()")
flick(1500)
st = page.evaluate("""() => { const e=document.getElementById('empty2');
return {on:e.classList.contains('on'),
report:document.getElementById('report').classList.contains('on')}; }""")
check(bool(st['on']) and not st['report'],
'forge on 0/0/0 leaves empty2 (report never re-invites)')
# nightly import simulated: a brand-new listing re-lights the deck
stop_server()
conn = sqlite3.connect(WALK3)
try:
lid = 990
conn.execute("DELETE FROM listings WHERE id=?", (lid,))
conn.execute(
"INSERT INTO listings (id, region, src_id, title, url, status,"
" img_url, score, first_seen, desc) VALUES"
" (?, 'ns', 'v11night', 'Fresh overnight listing',"
" 'https://www.kijiji.ca/v-motorcycles/v11/990', 'active',"
" NULL, 50, '2026-11-13T08:30:00Z', 'arrived with the import')",
(lid,))
conn.commit()
finally:
conn.close()
start_server(WALK3)
page.reload(wait_until='networkidle')
wait('.topcard', timeout=15000)
st = page.evaluate("""() => ({empty2:document.getElementById('empty2')
.classList.contains('on'), top:!!document.querySelector('.topcard')})""")
check(bool(st['top']) and not st['empty2'],
'item 8: one fresh listing clears empty2 and relights the deck')
print('dead-catalog walk complete')
# --- WALK0: hand the rich DB a quick dry run through items 1 acceptance ---
def main():
global page
setup() # always: fresh staged DBs per run
# clear any stray scratch server left by an aborted run
import signal
for line in subprocess.run(
['ps', '-eo', 'pid,args'], capture_output=True,
text=True).stdout.splitlines():
if 'app.serve' in line and str(PORT) in line:
try:
os.kill(int(line.split()[0]), signal.SIGTERM)
except (ValueError, ProcessLookupError):
pass
time.sleep(0.5)
try:
from playwright.sync_api import sync_playwright
except ImportError:
print('playwright missing — run: .venv/bin/python -m app.fe_walk_v11')
return 1
with sync_playwright() as pw:
exe = os.path.expanduser(
'~/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome')
if not os.path.exists(exe):
exe = None
# phase 1: rich
start_server(WALK1)
try:
browser = pw.chromium.launch(headless=True, executable_path=exe,
args=['--no-sandbox'])
ctx = browser.new_context(viewport={'width': 390, 'height': 844},
has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(BASE_URL + '/', wait_until='networkidle')
walk_rich()
ctx.close()
finally:
stop_server()
# phase 2: returns-only (uses WALK2 fresh from setup)
start_server(WALK2)
try:
browser = pw.chromium.launch(headless=True, executable_path=exe,
args=['--no-sandbox'])
ctx = browser.new_context(viewport={'width': 390, 'height': 844},
has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(BASE_URL + '/', wait_until='networkidle')
walk_returns_only()
ctx.close()
finally:
stop_server()
# phase 3: dead catalog
shutil.copy2(WALK2, WALK3)
burn_returns(WALK3)
start_server(WALK3)
try:
browser = pw.chromium.launch(headless=True, executable_path=exe,
args=['--no-sandbox'])
ctx = browser.new_context(viewport={'width': 390, 'height': 844},
has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(BASE_URL + '/', wait_until='networkidle')
walk_dead_catalog()
ctx.close()
finally:
stop_server()
print('---')
print('%d/%d walk checks passed' % (CHECKS[0], CHECKS[0] + CHECKS[1]))
return 1 if CHECKS[1] else 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,8 +1,12 @@
"""Bike-Tinder P3 section engine — compose / read / exhaust / forge. """Bike-Tinder P3 section engine — compose / read / exhaust / forge.
The deck is one finite SECTION at a time (default 25 cards): v1.1 batch formula (amends the frozen "<=5 returns" rule — one-shot,
2 wildcards + <=5 returns (<=2 nopes, "surprising" only) + remainder new, re-nope and one-return rules unchanged): every batch targets 25 cards =
per docs/RANKING.md + docs/DATA-MODEL.md "P3 additions". fresh first (up to 23 slots) + >=2 second looks whenever eligible stock
exists + the 2 wildcard slots; when fresh < the remainder, second looks
FILL the batch up to 25 (family cap relaxes to 8 for fill returns only).
Fresh exhausted -> returns-only batches; returns exhausted -> fresh-only;
both -> an empty composed section (the loud empty state, item 8).
Pinned order within a section (position 1 first): Pinned order within a section (position 1 first):
new (points DESC) -> returns (maybe > like > nope, points DESC within new (points DESC) -> returns (maybe > like > nope, points DESC within
@@ -11,10 +15,12 @@ Pinned order within a section (position 1 first):
wildcard stretches sit at the back. (Wildcard placement inside the deck wildcard stretches sit at the back. (Wildcard placement inside the deck
is not pinned anywhere in the frozen docs — noted in the P3 report.) is not pinned anywhere in the frozen docs — noted in the P3 report.)
Constraints: <=4 per non-NULL family per section; >=3 price bands when the Constraints: <=4 per non-NULL family per section (fill-return slots relax
pool allows (soft repair swaps new-slot items within the final list). to <=8); >=3 price bands when the pool allows (soft repair swaps new-slot
A re-nope (round>=2 nope event) rests an item: no more second looks. items within the final list). <=2 re-nope returns per batch (reserved
Rounds cap at 2 for influence (taste.py); returns are offered once. nope slots must be "surprising"). A re-nope (round>=2 nope event) rests
an item: no more second looks. Rounds cap at 2 for influence (taste.py);
returns are offered once.
Modes: 'ondemand' (default) — forge runs the agent pass then composes; Modes: 'ondemand' (default) — forge runs the agent pass then composes;
'strict' — forge refuses (409) unless a taste_profile strictly newer than 'strict' — forge refuses (409) unless a taste_profile strictly newer than
@@ -26,9 +32,11 @@ from . import taste as tastemod
from .taste import NOPE from .taste import NOPE
FAMILY_CAP = 4 FAMILY_CAP = 4
MAX_RETURNS = 5 RETURN_FILL_FAMILY_CAP = 8
MAX_RETURN_NOPES = 2 MAX_RETURN_NOPES = 2
WILDCARDS = 2 WILDCARDS = 2
FRESH_CAP = 23
MIN_RETURNS = 2
DEFAULT_SIZE = 25 DEFAULT_SIZE = 25
MIN_BANDS = 3 MIN_BANDS = 3
@@ -262,6 +270,11 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
Caller closes/reports any prior section first. `engine` scores the Caller closes/reports any prior section first. `engine` scores the
candidates (a TasteEngine over active listings + events + profile). candidates (a TasteEngine over active listings + events + profile).
v1.1 slot math (see module docstring; this amends the frozen "<=5
returns" rule): reserved returns (>=2 when eligible stock exists) ->
wildcards (2) -> fresh (capped at 23) -> fill returns to `size`
(family cap 4 preferred, 8 rescue; <=2 re-nopes total).
""" """
import datetime import datetime
now = now or datetime.datetime.now(datetime.timezone.utc).replace( now = now or datetime.datetime.now(datetime.timezone.utc).replace(
@@ -297,18 +310,18 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
top_bands = {tastemod.price_band(r['price_num']) for r in top3 top_bands = {tastemod.price_band(r['price_num']) for r in top3
if tastemod.price_band(r['price_num']) is not None} if tastemod.price_band(r['price_num']) is not None}
# 2) returns: maybe > like > nope, points DESC within role; nopes must be # 2) reserved returns: the >=2 guarantee (role order maybe > like > nope,
# "surprising" (points >=55 or net-positive family); <=5, <=2 nopes. # points DESC within role). Nopes must be "surprising" for RESERVED
# Budget: wildcards keep their 2 slots; returns never push the # slots only (points >=55 or net-positive family); <=2 re-nope
# section past `size`. # returns per batch overall; family cap 4 applies here.
wanted_rets = min(MAX_RETURNS, max(0, size - min(WILDCARDS, size)))
role_order = {'maybe': 0, 'like': 1, 'nope': 2} role_order = {'maybe': 0, 'like': 1, 'nope': 2}
ret_ordered = sorted(ret_cands, reverse=True,
key=lambda r: (-role_order.get(r['verdict'], 9),
_points_key(engine, r)))
rets = [] rets = []
nope_count = 0 nope_count = 0
for cand in sorted(ret_cands, reverse=True, for cand in ret_ordered:
key=lambda r: (-role_order.get(r['verdict'], 9), if len(rets) >= min(MIN_RETURNS, size):
_points_key(engine, r))):
if len(rets) >= wanted_rets:
break break
fam = cand['family'] fam = cand['family']
if fam and fam_count.get(fam, 0) >= FAMILY_CAP: if fam and fam_count.get(fam, 0) >= FAMILY_CAP:
@@ -325,7 +338,8 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
if fam: if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1 fam_count[fam] = fam_count.get(fam, 0) + 1
# 3) wildcards: min(2, size), preferred different family AND price band # 3) wildcards: min(2, remaining slots), preferred different family AND
# price band (existing novel/preferred pick, unchanged)
liked_ids = [r['listing_id'] for r in conn.execute( liked_ids = [r['listing_id'] for r in conn.execute(
"SELECT listing_id FROM swipes WHERE action='like'")] "SELECT listing_id FROM swipes WHERE action='like'")]
nov = novelty(rows_by_id, liked_ids, nov = novelty(rows_by_id, liked_ids,
@@ -339,7 +353,7 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
and (tastemod.price_band(r['price_num']) is None and (tastemod.price_band(r['price_num']) is None
or tastemod.price_band(r['price_num']) not in top_bands)] or tastemod.price_band(r['price_num']) not in top_bands)]
n_wilds = min(WILDCARDS, size) n_wilds = min(WILDCARDS, max(0, size - len(rets)))
wilds = [] wilds = []
avail = sorted([r for r in wild_cands if r['id'] not in picked_ids], avail = sorted([r for r in wild_cands if r['id'] not in picked_ids],
key=wild_key) key=wild_key)
@@ -355,12 +369,41 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
picked_ids.add(cand['id']) picked_ids.add(cand['id'])
if fam: if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1 fam_count[fam] = fam_count.get(fam, 0) + 1
n_wilds = len(wilds) # pool shortfall reverts to new n_wilds = len(wilds) # pool shortfall reverts to fresh/fill
# 4) new fills the remainder of the section (size - returns - wildcards) # 4) fresh fills the remainder (size - returns - wildcards), never
want_new = max(0, size - len(rets) - n_wilds) # beyond FRESH_CAP. Wildcards draw from the fresh pool but do NOT
# count against the fresh-slot cap (25 - 0 - 2 = 23 new + 2 wild on
# a returns-free rich day; 21 new + 2 wild + 2 returns when both
# guarantees hold — the sign-off shape). Pool-limited by what the
# wildcard pick left in new_cands; a dry wild pool reverts slots.
want_new = min(size - len(rets) - len(wilds), FRESH_CAP,
max(0, len(new_cands) - len(wilds)))
picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids) picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids)
# 5) fill returns to size: role order; re-nope cap <=2 total (reserved
# included, no surprising gate here); two-pass family cap — prefer
# <=4, rescue pass relaxes to <=8 for FILL slots only (fresh picks
# and reserved returns keep 4). Deterministic: role order, points
# DESC within role.
for pass_cap in (FAMILY_CAP, RETURN_FILL_FAMILY_CAP):
for cand in ret_ordered:
if len(rets) >= size - len(picked_new) - n_wilds:
break
if cand['id'] in picked_ids:
continue
fam = cand['family']
if fam and fam_count.get(fam, 0) >= pass_cap:
continue
if cand['verdict'] == NOPE and nope_count >= MAX_RETURN_NOPES:
continue
rets.append(cand)
picked_ids.add(cand['id'])
if cand['verdict'] == NOPE:
nope_count += 1
if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1
# final pinned order: new -> returns -> wildcards # final pinned order: new -> returns -> wildcards
ordered = list(picked_new) + rets + wilds ordered = list(picked_new) + rets + wilds
@@ -425,16 +468,58 @@ def section_items(conn, section):
ORDER BY si.position ASC""", (section['id'],)).fetchall() ORDER BY si.position ASC""", (section['id'],)).fetchall()
def section_decided_ids(conn, section):
"""Listing ids with a swipe event stamped in THIS section.
The decided set for progress/report/exhaustion is always these ids —
never the lifetime `swipes.action` verdict (a second-look card enters
its section wearing an old decision; only an in-section event means it
was actually decided here). The join keeps out-of-section stray events
honest (same shape `section_counts` uses).
"""
return {r['listing_id'] for r in conn.execute(
"""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 = ?""", (section['id'],))}
def pool_counts(conn):
"""Stock answer to \"can the forge serve anything?\" — fresh / eligible
returns / wildcards candidate counts, each from the composer's own
candidate SQL. The per-section LAST_TWO exclusion inside compose_section
is deliberately NOT applied here: the answer is about stock, not about
what one batch will pick (item 8)."""
def _count(sql):
return conn.execute(
'SELECT COUNT(*) AS n FROM (%s)' % sql).fetchone()['n']
return {'fresh': _count(NEW_POOL_SQL),
'returns': _count(RETURNS_SQL),
'wildcards': _count(WILDCARD_SQL)}
def section_counts(conn, section): def section_counts(conn, section):
"""Per-action counts + second-look / wildcard engagement for this """Per-action counts + second-look / wildcard engagement for this
section (events stamped with this section_id only).""" section (events stamped with this section_id only)."""
counts = {'likes': 0, 'maybes': 0, 'nopes': 0} counts = {'likes': 0, 'maybes': 0, 'nopes': 0}
for item in section_items(conn, section): # one verdict per listing: the latest in-section event wins (an undo +
if item['decision'] == 'like': # re-swipe within a section must not double-count the report numbers)
latest = {}
for ev in conn.execute(
"""SELECT e.listing_id, e.action 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 = ?
ORDER BY e.id ASC""", (section['id'],)):
latest[ev['listing_id']] = ev['action']
for action in latest.values():
if action == 'like':
counts['likes'] += 1 counts['likes'] += 1
elif item['decision'] == 'maybe': elif action == 'maybe':
counts['maybes'] += 1 counts['maybes'] += 1
elif item['decision'] == 'nope': elif action == 'nope':
counts['nopes'] += 1 counts['nopes'] += 1
restruck = conn.execute( restruck = conn.execute(
"""SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e
@@ -458,8 +543,9 @@ def maybe_exhaust(conn, section, now):
"""Flip an all-decided open section to 'report' (returns the fresh row).""" """Flip an all-decided open section to 'report' (returns the fresh row)."""
if section['status'] != 'open': if section['status'] != 'open':
return section return section
decided_ids = section_decided_ids(conn, section)
items = section_items(conn, section) items = section_items(conn, section)
if all(it['decision'] is not None for it in items): if items and not [it for it in items if it['id'] not in decided_ids]:
close_section(conn, section, now, status='report') close_section(conn, section, now, status='report')
return conn.execute('SELECT * FROM sections WHERE id=?', return conn.execute('SELECT * FROM sections WHERE id=?',
(section['id'],)).fetchone() (section['id'],)).fetchone()
@@ -470,7 +556,8 @@ def read_section(conn, section, now):
"""Meta + items + progress + exhausted for GET /api/section.""" """Meta + items + progress + exhausted for GET /api/section."""
section = maybe_exhaust(conn, section, now) section = maybe_exhaust(conn, section, now)
items = section_items(conn, section) items = section_items(conn, section)
decided = sum(1 for it in items if it['decision'] is not None) decided_ids = section_decided_ids(conn, section)
decided = sum(1 for it in items if it['id'] in decided_ids)
return { return {
'section': { 'section': {
'id': section['id'], 'id': section['id'],

View File

@@ -382,7 +382,17 @@ def test_api_stats_health():
conn.close() conn.close()
s = c.get('/api/stats').json() s = c.get('/api/stats').json()
assert s['live'] == active, (s['live'], active) assert s['live'] == active, (s['live'], active)
assert set(s) == {'live', 'new', 'likes', 'maybes', 'nopes', 'last_import'} assert set(s) == {'live', 'fresh', 'new', 'likes', 'maybes',
'nopes', 'last_import'} # v1.1 item 7: + fresh
# fresh = actives with no swipe row (the deck's new-pool predicate)
conn = sqlite3.connect(os.path.join(d, 'test.sqlite3'))
expect_fresh = conn.execute(
"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)").fetchone()[0]
conn.close()
assert s['fresh'] == expect_fresh, (s['fresh'], expect_fresh)
assert s['last_import'] is not None and s['last_import']['ok'] is True assert s['last_import'] is not None and s['last_import']['ok'] is True
assert s['new'] == expect_new, (s['new'], expect_new) assert s['new'] == expect_new, (s['new'], expect_new)
h = c.get('/api/health').json() h = c.get('/api/health').json()
@@ -739,7 +749,9 @@ def test_p3_exhaust_and_report():
c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'ondemand'}) c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'ondemand'})
f = c.post('/api/section/forge', json={'size': 6}).json() f = c.post('/api/section/forge', json={'size': 6}).json()
sid = f['section']['id'] sid = f['section']['id']
for it in [i for i in f['items'] if i['decision'] is None]: # v1.1 (item 5): EVERY item needs an in-section strike to complete
# the section — returns re-enter the deck and are re-struck too.
for it in f['items']:
r = c.post('/api/swipe', json={'listing_id': it['id'], r = c.post('/api/swipe', json={'listing_id': it['id'],
'action': 'like', 'section_id': sid}) 'action': 'like', 'section_id': sid})
assert r.status_code == 200 assert r.status_code == 200
@@ -922,6 +934,10 @@ def test_p3_points_and_band():
br = eng.breakdown(1) br = eng.breakdown(1)
assert {b['source'] for b in br} <= {'rubric', 'taste', 'agent'} assert {b['source'] for b in br} <= {'rubric', 'taste', 'agent'}
assert br[0]['source'] == 'rubric' assert br[0]['source'] == 'rubric'
# regression (#3): a listing OUTSIDE the engine's pool (delisted
# mid-batch, never swiped) must degrade quietly, not crash the render
assert eng.points(9999) is None
assert eng.breakdown(9999) == []
def test_p3_payload_additions(): def test_p3_payload_additions():
@@ -947,6 +963,39 @@ def test_p3_payload_additions():
shutil.rmtree(d, ignore_errors=True) shutil.rmtree(d, ignore_errors=True)
def test_issue3_section_survives_midsection_delist():
"""#3 regression: one card delisted after compose must not 500 the deck.
The section render asks the taste engine for a breakdown of every
card. A listing that left the active pool mid-batch AND has no swipe
history is not in the engine's by_id map; the unguarded lookup used
to raise, killing GET /api/section and GET /api/listing/{id} with a
500 (live incident 2026-11-15: section 57 + delisted listing 298).
"""
d, c, db_path = p3_env()
try:
sec = c.get('/api/section')
assert sec.status_code == 200, sec.status_code
body = sec.json()
conn = _sq_conn(db_path)
ev_ids = {r['listing_id'] for r in conn.execute(
'SELECT DISTINCT listing_id FROM swipe_events')}
it = next((x for x in body['items'] if x['id'] not in ev_ids), None)
assert it is not None, 'fixture section has no unswiped card'
conn.execute("UPDATE listings SET status='gone' WHERE id=?",
(it['id'],))
conn.commit()
conn.close()
r = c.get('/api/section')
assert r.status_code == 200, r.status_code
assert it['id'] in [x['id'] for x in r.json()['items']]
one = c.get('/api/listing/%d' % it['id'])
assert one.status_code == 200, one.status_code
assert one.json()['points_breakdown'] == []
finally:
shutil.rmtree(d, ignore_errors=True)
# --- B1 checks (photo experience v2, UI-SPEC §11 / DATA-MODEL B1) ----------- # --- B1 checks (photo experience v2, UI-SPEC §11 / DATA-MODEL B1) -----------
def make_b1_item(title, imgs=None, img=None, price='$5,000', km='8000'): def make_b1_item(title, imgs=None, img=None, price='$5,000', km='8000'):
@@ -1279,6 +1328,456 @@ def test_b1_swipe_photo_idx():
shutil.rmtree(d, ignore_errors=True) shutil.rmtree(d, ignore_errors=True)
# --- v1.1 checks (temp copies of the source DB only — see p3_env) ----------
def test_v11_item5_section_counter():
"""Returns-only section must read 0/N decided until real in-section
events exist; report counts and exhaustion key off those events."""
d, c, db_path = p3_env()
try:
conn = _sq_conn(db_path)
conn.execute('DELETE FROM section_items')
conn.execute("UPDATE sections SET status='closed'"
" WHERE status!='closed'")
conn.execute(
"""INSERT INTO swipes (listing_id, action, created_at)
SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings
WHERE status='active'
AND id NOT IN (SELECT listing_id FROM swipes)""")
conn.execute(
"""INSERT INTO swipe_events (listing_id, action, round, section_id)
SELECT id, 'maybe', 1, NULL FROM listings
WHERE status='active'
AND id NOT IN (SELECT listing_id FROM swipe_events)""")
conn.commit()
conn.close()
s = c.get('/api/section').json() # cold compose
total = s['progress']['total']
assert total > 0, s
assert s['recipe']['new'] == 0, s['recipe']
# every item wears a LIFETIME verdict but zero in-section decisions
for it in s['items']:
assert it['decision'] is not None, it['id']
assert it['decided_this_section'] is False, it['id']
assert s['progress']['decided'] == 0, s['progress']
sec_id = s['section']['id']
# list edit (no section_id) must not count as an in-section decision
lid0 = s['items'][0]['id']
c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like'})
assert c.get('/api/section').json()['progress']['decided'] == 0
# first real in-section swipe -> 1/total
c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like',
'section_id': sec_id})
s2 = c.get('/api/section').json()
assert s2['progress']['decided'] == 1, s2['progress']
# second -> 2/total; report counts reflect in-section verdicts only
lid1 = s2['items'][1]['id']
c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe',
'section_id': sec_id})
s3 = c.get('/api/section').json()
assert s3['progress']['decided'] == 2, s3['progress']
assert s3['counts']['likes'] == 1, s3['counts']
assert s3['counts']['maybes'] == 1, s3['counts']
# undo residual (Q5, accepted): DELETE clears the swipe row but the
# section-stamped event stays -> count holds (clamp-only behavior)
c.delete('/api/swipe/%d' % lid1)
s4 = c.get('/api/section').json()
assert s4['progress']['decided'] == 2, s4['progress']
c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe',
'section_id': sec_id})
# strike the rest in-section -> report flips, counts match verdicts
for it in s4['items']:
if it['id'] not in (lid0, lid1):
c.post('/api/swipe', json={'listing_id': it['id'],
'action': 'maybe',
'section_id': sec_id})
s5 = c.get('/api/section').json()
assert s5['exhausted'] is True
assert s5['section']['status'] == 'report'
assert s5['progress']['decided'] == total, s5['progress']
assert s5['counts']['likes'] == 1, s5['counts']
assert s5['counts']['maybes'] == total - 1, s5['counts']
# a fresh returns-only section of the same shape composes cleanly
# (lifetime verdicts present, in-section decided == 0 again)
f = c.post('/api/section/forge', json={}).json()
assert f['recipe']['new'] == 0, f['recipe']
assert f['progress']['decided'] == 0, f['progress']
for it in f['items']:
assert it['decision'] is not None and not it['decided_this_section']
finally:
shutil.rmtree(d, ignore_errors=True)
def test_v11_item7_stats_fresh():
"""Item 7: /api/stats.fresh counts actives with no swipe row and moves
on swipe (the deck's new-pool predicate)."""
d, c, db_path = p3_env()
try:
conn = _sq_conn(db_path)
rows = conn.execute(
"SELECT id FROM listings WHERE status='active' LIMIT 5"
).fetchall()
assert len(rows) == 5
ids = [r['id'] for r in rows]
conn.execute("UPDATE listings SET status='gone' WHERE status='active'")
conn.execute("DELETE FROM swipes WHERE listing_id IN (%s)"
% ','.join('?' * len(ids)), ids)
conn.execute("DELETE FROM swipe_events WHERE listing_id IN (%s)"
% ','.join('?' * len(ids)), ids)
for lid in ids:
conn.execute("UPDATE listings SET status='active' WHERE id=?", (lid,))
for lid in ids[:3]:
conn.execute(
"INSERT INTO swipes (listing_id, action, created_at)"
" VALUES (?, 'maybe', '2026-11-01T00:00:00Z')", (lid,))
conn.commit()
conn.close()
st = c.get('/api/stats').json()
assert st['live'] == 5, st
assert st['fresh'] == 2, st # 2 of 5 still never-touched
# swiping one of the untouched pair moves fresh to 1 on next read
c.post('/api/swipe', json={'listing_id': ids[3], 'action': 'like'})
assert c.get('/api/stats').json()['fresh'] == 1
finally:
shutil.rmtree(d, ignore_errors=True)
def test_v11_item8_pool_and_dry():
"""Item 8/6: pool counts ride section_state; 0/0/0 composes an empty
section; a fresh=0, returns>0 catalog labels the forge note dry."""
d, c, db_path = p3_env()
try:
conn = _sq_conn(db_path)
conn.execute('DELETE FROM section_items')
conn.execute("UPDATE sections SET status='closed'"
" WHERE status!='closed'")
# every active gets a maybe swipe + round-1 event (returns alive,
# fresh + wildcards drained)
conn.execute(
"""INSERT INTO swipes (listing_id, action, created_at)
SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings
WHERE status='active'
AND id NOT IN (SELECT listing_id FROM swipes)""")
conn.execute(
"""INSERT INTO swipe_events (listing_id, action, round, section_id)
SELECT id, 'maybe', 1, NULL FROM listings
WHERE status='active'
AND id NOT IN (SELECT listing_id FROM swipe_events)""")
conn.commit()
conn.close()
s = c.get('/api/section').json() # dry but alive
assert s['pool']['fresh'] == 0 and s['pool']['returns'] > 0, s['pool']
assert s['recipe']['new'] == 0 and len(s['items']) > 0
f = c.post('/api/section/forge', json={}).json()
assert f['recipe']['new'] == 0 and len(f['items']) > 0
assert 'catalog fully reviewed' in (f.get('note') or ''), f.get('note')
# drain returns too (one-shot) + wildcards: pool 0/0/0 -> empty compose
conn = _sq_conn(db_path)
conn.execute("DELETE FROM section_items")
conn.execute("UPDATE sections SET status='closed'"
" WHERE status!='closed'")
# burn return eligibility: every active already had its return slot
# -> re-insert one-shot return rows for all of them
conn.execute(
"""INSERT INTO section_items (section_id, listing_id, slot, position)
SELECT (SELECT id FROM sections ORDER BY id DESC LIMIT 1),
listing_id, 'return', 1 FROM swipes
WHERE listing_id IN (SELECT id FROM listings
WHERE status='active')""")
conn.commit()
conn.close()
s2 = c.get('/api/section').json()
assert s2['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0}, s2['pool']
assert s2['items'] == []
assert s2['recipe']['new'] == 0 and s2['recipe']['return'] == 0
assert s2['recipe']['wildcard'] == 0, s2['recipe']
finally:
shutil.rmtree(d, ignore_errors=True)
# --- item 9: batch formula v2 compose matrix (A1-A8, staged pools) --------
def _v11_wipe(conn):
"""Reset a temp copy to a controlled catalog: no sections/swipes/events,
everything parked 'gone' (rows kept so ids/families can be re-used)."""
conn.execute('DELETE FROM section_items')
conn.execute('DELETE FROM sections')
conn.execute('DELETE FROM swipes')
conn.execute('DELETE FROM swipe_events')
conn.execute("UPDATE listings SET status='gone' WHERE status!='dup'")
def _v11_ids(conn, n, start=0):
return [r['id'] for r in conn.execute(
'SELECT id FROM listings ORDER BY id LIMIT ? OFFSET ?',
(n, start))]
def _v11_activate(conn, ids, family=None, score=None):
for lid in ids:
if score is None:
conn.execute("UPDATE listings SET status='active', family=?"
" WHERE id=?", (family, lid))
else:
conn.execute("UPDATE listings SET status='active', family=?,"
" score=? WHERE id=?", (family, score, lid))
def _v11_stage_returns(conn, ids, action='maybe', rests=()):
"""swipes row + round-1 event per id; rests get a round-2 nope event
(the REST rule bars them from returns forever)."""
rests = set(rests)
for lid in ids:
conn.execute("INSERT INTO swipes (listing_id, action, created_at)"
" VALUES (?, ?, '2026-11-01T00:00:00Z')",
(lid, action))
conn.execute("INSERT INTO swipe_events (listing_id, action, round,"
" section_id) VALUES (?, ?, 1, NULL)", (lid, action))
if lid in rests:
conn.execute("INSERT INTO swipe_events (listing_id, action,"
" round, section_id) VALUES (?, 'nope', 2, NULL)",
(lid,))
def _v11_stage_fresh(conn, ids, seen=False):
"""No swipes row (=fresh). seen=True drops wildcard eligibility via a
round-1 event (fresh pool and wildcard pool are otherwise the same)."""
for lid in ids:
if seen:
conn.execute("INSERT INTO swipe_events (listing_id, action,"
" round, section_id) VALUES (?, 'maybe', 1, NULL)",
(lid,))
def _v11_stage_support(conn, ids, family):
"""Gone-status listings whose events tilt a family's net (nope-gate)."""
for lid in ids:
conn.execute("UPDATE listings SET family=? WHERE id=?", (family, lid))
conn.execute("INSERT INTO swipe_events (listing_id, action, round,"
" section_id) VALUES (?, 'like', 1, NULL)", (lid,))
def _v11_compose(db_path, size=25):
from . import sections as secmod
conn = db.connect(db_path)
db.init_db(conn)
engine = secmod.build_engine(
conn, now=datetime.datetime.now(datetime.timezone.utc).replace(
tzinfo=None))
sec = secmod.compose_section(conn, engine, size=size)
recipe = json.loads(sec['recipe'])
items = [dict(r) for r in secmod.section_items(conn, sec)]
conn.commit()
conn.close()
return recipe, items, engine
def _slots(items):
out = {'new': [], 'return': [], 'wildcard': []}
for it in items:
out[it['slot']].append(it)
return out
def test_v11_item9_compose_matrix():
"""A1-A8 of the item 9 acceptance criteria on deterministic pools."""
d, _c, db_path = p3_env()
def env():
conn = _sq_conn(db_path)
_v11_wipe(conn)
conn.commit()
return conn
try:
# --- A1: 30 fresh / 10 returns / wildcards -> 25: 21/2/2 ----------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:28]) # 28 fresh
_v11_stage_fresh(conn, ids[0:28]) # (all wild-eligible)
_v11_activate(conn, ids[28:38]) # 10 returns
_v11_stage_returns(conn, ids[28:38])
conn.commit(); conn.close()
recipe, items, engine = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 25, len(items)
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (21, 2, 2), recipe
assert (len(slots['new']), len(slots['return']),
len(slots['wildcard'])) == (21, 2, 2)
# pinned order: new -> return -> wildcard
roles = [it['slot'] for it in items]
assert roles == ['new'] * 21 + ['return'] * 2 + ['wildcard'] * 2, \
roles[:2]
# fresh picks ordered points DESC (non-increasing)
pts = [engine.points(it['id']) for it in slots['new']]
assert all(a >= b for a, b in zip(pts, pts[1:])), pts
# --- A2a: 5 fresh / 20 returns / wildcards -> 5/18/2 ---------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:3])
_v11_stage_fresh(conn, ids[0:3]) # 3 fresh-only
_v11_activate(conn, ids[3:5])
_v11_stage_fresh(conn, ids[3:5]) # 2 fresh+wild
_v11_activate(conn, ids[5:25]) # 20 returns
_v11_stage_returns(conn, ids[5:25])
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
assert len(items) == 25, len(items)
# Wildcards are BY DEFINITION never-swiped (wild pool ⊆ fresh pool),
# so on a 5-fresh day the 2 wilds come from those 5: the deck reads
# 5 fresh-total (3 new + 2 wild) + 20 second looks = 25 — Glenn's
# "5 fresh listed, rest second looks". The spec's "5 fresh + 18
# returns + 2 wild" arithmetic assumed 7 fresh-eligible listings;
# with 5 the pool-honest fill is 20 (implementer-verified).
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (3, 20, 2), recipe
# --- A2b: same but the wildcard pool is dry -> 5/20/0 --------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:5])
_v11_stage_fresh(conn, ids[0:5], seen=True) # fresh, no wilds
_v11_activate(conn, ids[5:25]) # 20 returns
_v11_stage_returns(conn, ids[5:25]) #
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
assert len(items) == 25, len(items)
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (5, 20, 0), recipe
# --- A3: 23 fresh / 5 returns / wildcards -> 21/2/2 (the sign-off
# --- sentence shape) ----------------------------------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:23])
_v11_stage_fresh(conn, ids[0:23])
_v11_activate(conn, ids[23:28]) # 5 returns
_v11_stage_returns(conn, ids[23:28])
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 25 and len(slots['new']) == 21 \
and len(slots['return']) == 2 \
and len(slots['wildcard']) == 2, recipe
# --- A4: 0 fresh / 12 returns (2 nope) -> 12 returns, <=2 nopes,
# --- LAST_TWO excl: second compose repeats nothing -----------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:12])
_v11_stage_returns(conn, ids[0:10], 'maybe')
_v11_stage_returns(conn, ids[10:12], 'nope') # 2 re-nope cands
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 12 and len(slots['new']) == 0 \
and len(slots['return']) == 12 \
and len(slots['wildcard']) == 0, recipe
n_nopes = sum(1 for it in slots['return']
if it['decision'] == 'nope')
assert n_nopes <= 2, n_nopes
first_ids = {it['id'] for it in items}
# second compose on the same pool: LAST_TWO bars the 12 just served
# and returns stay one-shot -> nothing to serve
recipe2, items2, _e = _v11_compose(db_path)
assert len(items2) == 0, items2
assert not ({it['id'] for it in items2} & first_ids)
# --- A5: 40 fresh / 0 returns -> 25 then the 15 leftover, no
# --- overlap, fresh ordered points DESC ----------------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:40])
_v11_stage_fresh(conn, ids[0:40])
# spread scores so the points DESC order check is meaningful
for k, lid in enumerate(ids[0:40]):
conn.execute("UPDATE listings SET score=? WHERE id=?",
(40 - k, lid))
conn.commit(); conn.close()
recipe, items, engine = _v11_compose(db_path)
assert len(items) == 25, len(items)
assert recipe['return'] == 0, recipe
first_ids = {it['id'] for it in items}
slots1 = _slots(items)
pts = [engine.points(it['id']) for it in slots1['new']]
# scores unique DESC -> fresh-slot points non-increasing (wildcards
# sit pinned at the back and don't participate in that order)
assert pts == sorted(pts, reverse=True), pts
recipe2, items2, _e = _v11_compose(db_path)
slots2 = _slots(items2)
assert recipe2['return'] == 0, recipe2
assert len(items2) == 15, len(items2)
second_ids = {it['id'] for it in items2}
assert not (first_ids & second_ids) # zero overlap
# --- A6: 0/0/0 -> empty composed section (FE side = item 8) --------
conn = env()
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
assert items == [], items
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (0, 0, 0), recipe
# --- A7: family cap — fill-heavy cruiser pool: fresh/reserved hold
# --- <=4, fill raises return slots to <=8 --------------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:40], family='cruiser')
_v11_stage_fresh(conn, ids[0:10], seen=True) # 10 fresh cruiser
_v11_stage_returns(conn, ids[10:40], 'maybe') # 30 cruiser rets
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
fam = {}
for it in items:
fam[it['family']] = fam.get(it['family'], 0) + 1
assert fam.get('cruiser', 0) <= 8, fam
assert len(slots['new']) == 2, recipe # fresh stops at cap 4
assert len(slots['return']) == 6, recipe # 2 reserved + 4 fill
assert (recipe['new'], recipe['return']) == (2, 6), recipe
# --- A8: re-nope gate — <=2 nope returns total; a rested item never
# --- returns; gate can empty the reserved slots --------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:10])
_v11_stage_returns(conn, ids[0:10], 'maybe') # 10 maybes
_v11_activate(conn, ids[10:15], family='gatefam', score=100)
_v11_stage_returns(conn, ids[10:15], 'nope') # 5 nope re-nopes
# family net positive: gate passes for gatefam's nope candidates
_v11_stage_support(conn, ids[30:40], family='gatefam') # 10 likes
_v11_activate(conn, ids[15:16], family='gatefam')
_v11_stage_returns(conn, ids[15:16], 'maybe',
rests=[ids[15]]) # rested: r2 nope
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 12, len(items) # 10 maybe + 2 nope
n_nopes = sum(1 for it in slots['return']
if it['decision'] == 'nope')
assert n_nopes == 2, n_nopes
assert all(it['id'] != ids[15] for it in slots['return']) # rested
# gate-block: low-score nope family, net NOT positive -> reserved
# slots skipped; fill still caps at 2 nopes (gate-free fill rule)
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:3], family='blockfam', score=1)
_v11_stage_returns(conn, ids[0:3], 'nope') # 3 gate-blocked
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert recipe['return'] == 2, recipe # fill cap only
assert all(it['decision'] == 'nope'
for it in slots['return']), items
finally:
shutil.rmtree(d, ignore_errors=True)
P3_API_TESTS = [ P3_API_TESTS = [
('p3: events seeding idempotent (from swipes)', test_p3_event_seeding_idempotent), ('p3: events seeding idempotent (from swipes)', test_p3_event_seeding_idempotent),
('p3: swipe round math + clear-then-re-decide', test_p3_round_math_and_clear), ('p3: swipe round math + clear-then-re-decide', test_p3_round_math_and_clear),
@@ -1289,12 +1788,28 @@ P3_API_TESTS = [
('p3: agentpass mock determinism + live fallback', test_p3_agentpass_mock_and_fallback), ('p3: agentpass mock determinism + live fallback', test_p3_agentpass_mock_and_fallback),
('p3: points/band function known answers', test_p3_points_and_band), ('p3: points/band function known answers', test_p3_points_and_band),
('p3: payload additions (list/listing/section)', test_p3_payload_additions), ('p3: payload additions (list/listing/section)', test_p3_payload_additions),
('p3: mid-section delist keeps the deck up (#3)',
test_issue3_section_survives_midsection_delist),
] ]
if API_DEPS: if API_DEPS:
TESTS += P3_API_TESTS TESTS += P3_API_TESTS
V11_TESTS = [
('v1.1: item 5 — section counter keys off in-section events',
test_v11_item5_section_counter),
('v1.1: item 7 — stats fresh = active minus swiped',
test_v11_item7_stats_fresh),
('v1.1: item 8/6 — pool counts, 0/0/0 empty compose, dry forge note',
test_v11_item8_pool_and_dry),
('v1.1: item 9 — batch formula v2 compose matrix A1-A8',
test_v11_item9_compose_matrix),
]
if API_DEPS:
TESTS += V11_TESTS
B1_TESTS = [ B1_TESTS = [
('b1: guarded migrations (imgs + last_photo_idx, idempotent)', ('b1: guarded migrations (imgs + last_photo_idx, idempotent)',
test_b1_migration_guarded), test_b1_migration_guarded),

View File

@@ -33,7 +33,8 @@ header{height:calc(50px + env(safe-area-inset-top,0px));display:flex;align-items
.card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card); .card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card);
box-shadow:0 0 0 1px rgba(255,255,255,.07), 0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none} box-shadow:0 0 0 1px rgba(255,255,255,.07), 0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none}
.nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6} .nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6}
.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} .card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))}
.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);pointer-events:none}
.card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)} .card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)}
.card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3} .card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3}
.chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap} .chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
@@ -66,8 +67,9 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
.dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none} .dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none}
.dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block} .dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
.dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer} .dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer}
.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none} .dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none;overflow:hidden}
.dphoto{width:100%;height:100%;object-fit:cover;display:block} .dblur{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);pointer-events:none}
.dphoto{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))}
.dphoto.ph{opacity:.25} .dphoto.ph{opacity:.25}
.dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))} .dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))}
.dbody{flex:1;overflow-y:auto;padding:14px 18px 20px} .dbody{flex:1;overflow-y:auto;padding:14px 18px 20px}
@@ -95,6 +97,10 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
.empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px} .empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px}
.empty.on{display:flex} .empty.on{display:flex}
.empty b{color:#fff;font-size:16px} .empty b{color:#fff;font-size:16px}
/* item 8: loud exhausted state — section mode only (legacy .empty stays) */
.empty2{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;border:1px dashed #4a4a55;border-radius:18px;background:#141419;color:var(--mut);text-align:center;padding:32px;font-size:14px;min-height:220px;z-index:3}
.empty2.on{display:flex}
.empty2 b{color:#fff;font-size:22px;font-weight:800}
/* list screen */ /* list screen */
.list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px} .list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px}
.list.on{display:flex} .list.on{display:flex}
@@ -138,7 +144,8 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
.sect.hot{color:var(--accent1)} .sect.hot{color:var(--accent1)}
.band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none} .band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none}
.band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)} .band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)}
.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)} .band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800}
.dband{position:static;display:inline-block;margin-top:6px}
.report{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px;z-index:3} .report{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px;z-index:3}
.report.on{display:flex} .report.on{display:flex}
.report b{color:#fff;font-size:16px} .report b{color:#fff;font-size:16px}
@@ -210,6 +217,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
<div class="stage" id="stage"> <div class="stage" id="stage">
<div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div> <div class="empty" id="empty"><b>You're all caught up.</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
<div class="empty2" id="empty2"><b>No more new bikes!</b>New bikes arrive nightly — next import 08:30.<br>↺ brings the last card back.</div>
<div class="report" id="report"> <div class="report" id="report">
<b>Section complete.</b> <b>Section complete.</b>
<div id="repCounts"></div> <div id="repCounts"></div>
@@ -217,6 +225,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
<div id="repLine2"></div> <div id="repLine2"></div>
<button class="rforge" id="btnForge">Forge next 25</button> <button class="rforge" id="btnForge">Forge next 25</button>
<div class="rstatus" id="forgeStatus"></div> <div class="rstatus" id="forgeStatus"></div>
<div class="rcap" id="dryCap" style="display:none">Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks</div>
<div class="rmode" id="modeRow"> <div class="rmode" id="modeRow">
<span id="modeOnd" class="on">on demand</span><i class="sep">⇄</i><span id="modeStr">strict</span> <span id="modeOnd" class="on">on demand</span><i class="sep">⇄</i><span id="modeStr">strict</span>
<div class="rcap" id="strictCap" style="display:none">Waiting for the nightly read — next section after the agent runs.</div> <div class="rcap" id="strictCap" style="display:none">Waiting for the nightly read — next section after the agent runs.</div>
@@ -242,11 +251,12 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
<div class="detail" id="detail"> <div class="detail" id="detail">
<div class="dgrab" id="dgrab"><i></i></div> <div class="dgrab" id="dgrab"><i></i></div>
<button class="dclose" id="dclose">⌄</button> <button class="dclose" id="dclose">⌄</button>
<div class="dhero"><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div> <div class="dhero"><div class="dblur" id="dblur"></div><img class="dphoto" id="dphoto" alt=""><div class="dherograd"></div></div>
<div class="dbody" id="dbody"> <div class="dbody" id="dbody">
<div class="dtitle" id="dtitle"></div> <div class="dtitle" id="dtitle"></div>
<div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div> <div class="drow"><div class="dprice" id="dprice"></div><div class="dsub" id="dsub"></div></div>
<div class="dchips" id="dchips"></div> <div class="dchips" id="dchips"></div>
<div class="band dband" id="dband" style="display:none"></div>
<div class="drank" id="drank" style="display:none"> <div class="drank" id="drank" style="display:none">
<div class="tankwrap" id="drankTank"></div> <div class="tankwrap" id="drankTank"></div>
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
@@ -319,6 +329,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
<div class="tstatus" id="runStatus"></div> <div class="tstatus" id="runStatus"></div>
</div> </div>
<div class="trow"><span>Forge:</span><span id="tOnd" class="on">on demand</span><i>⇄</i><span id="tStr">strict</span></div> <div class="trow"><span>Forge:</span><span id="tOnd" class="on">on demand</span><i>⇄</i><span id="tStr">strict</span></div>
<div class="trow"><span>Why breakdown:</span><span id="tRubOff" class="on">off</span><i>⇄</i><span id="tRubOn">on</span></div>
<div class="tmuted" id="tLastRun"></div> <div class="tmuted" id="tLastRun"></div>
</div> </div>
</div> </div>
@@ -404,6 +415,7 @@ function setDecision(id, act, secId=null, photoIdx=null){
localDecisions.set(id, act); localDecisions.set(id, act);
updateRowDecision(id, act); // optimistic paint updateRowDecision(id, act); // optimistic paint
enqueue(id, act, secId, photoIdx); enqueue(id, act, secId, photoIdx);
refreshStats(); // v1.1.1 (#2): every decision change nudges the header counters
} }
/* ---------- payload -> display helpers ---------- */ /* ---------- payload -> display helpers ---------- */
@@ -479,14 +491,19 @@ function setStamps(el,a,b,c){
el.querySelector('.stamp.nope').style.opacity=b; el.querySelector('.stamp.nope').style.opacity=b;
el.querySelector('.stamp.maybe').style.opacity=c; el.querySelector('.stamp.maybe').style.opacity=c;
} }
function bandHTML(item){ // P3 system bands — the ONLY card-face additions function bandLabel(item){ // P3 system bands — text for card + dossier
if(item.slot==='wildcard') return `<div class="band wild">WILDCARD · maybe you&rsquo;ll like this one instead</div>`; if(item.slot==='wildcard') return 'WILDCARD · maybe you’ll like this one instead';
if(item.slot==='return'){ if(item.slot==='return'){
const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen'; const verb= item.decision==='like'?'liked':item.decision==='maybe'?'maybed':item.decision==='nope'?'noped':'seen';
return `<div class="band sl">SECOND LOOK — you ${verb} this</div>`; return 'SECOND LOOK — you '+verb+' this';
} }
return ''; return '';
} }
function bandHTML(item){ // card face only
const lbl=bandLabel(item);
if(!lbl) return '';
return `<div class="band${item.slot==='wildcard'?' wild':' sl'}">${lbl}</div>`;
}
/* ---------- B1 §11: photo galleries ---------- */ /* ---------- B1 §11: photo galleries ---------- */
function nPhotos(it){ // payload n_photos; tolerant of old APIs function nPhotos(it){ // payload n_photos; tolerant of old APIs
const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0); const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0);
@@ -509,8 +526,10 @@ function segHTML(n,cur){
if(!n||n<2) return ''; if(!n||n<2) return '';
return `<div class="segments">${segBars(n,cur)}</div>`; return `<div class="segments">${segBars(n,cur)}</div>`;
} }
function escAttr(s){ return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function phwrapHTML(src){ function phwrapHTML(src){
return `<div class="phwrap">${src?`<img class="photo" src="${src}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`}</div>`; const img=src?`<div class="phblur" style="background-image:url('${escAttr(src)}')"></div><img class="photo" src="${escAttr(src)}" alt="" draggable="false">`:`<div class="nophoto">Photo pending — thumbnail backfill queued</div>`;
return `<div class="phwrap">${img}</div>`;
} }
function attachPhError(entry){ function attachPhError(entry){
const wrap=entry.el.querySelector('.phwrap'); const wrap=entry.el.querySelector('.phwrap');
@@ -642,10 +661,17 @@ async function loadSection(){
buffer.push(...fresh); buffer.push(...fresh);
fillPile(); fillPile();
updateSectionUI(body); updateSectionUI(body);
refreshStats(); // v1.1.1 (#2): a new batch landed — freshen the counters
}catch(e){ setTimeout(()=>loadSection(),15000); } }catch(e){ setTimeout(()=>loadSection(),15000); }
finally{ deckLoading=false; } finally{ deckLoading=false; }
maybeRefill(); maybeRefill();
} }
/* item 8: the deck can serve nothing at all (pool all-zero) -> loud empty */
function poolDead(){
return !!(section && section.pool
&& section.pool.fresh===0 && section.pool.returns===0
&& section.pool.wildcards===0);
}
function maybeRefill(){ function maybeRefill(){
if(legacyMode){ if(legacyMode){
if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck(); if(buffer.length<DEPTH&&deckCursor&&!deckLoaded) loadDeck();
@@ -653,23 +679,31 @@ function maybeRefill(){
return; return;
} }
if(!pile.length&&!buffer.length&&!deckLoading){ if(!pile.length&&!buffer.length&&!deckLoading){
if(section&&sectDecided>=sectTotal){ if(poolDead()){
// nothing to serve at all: the loud empty owns the stage (no report)
$('empty2').classList.add('on');
$('report').classList.remove('on');
$('empty').classList.remove('on');
}else if(section&&sectDecided>=sectTotal){
// locally exhausted (last POSTs may still be flushing — the report // locally exhausted (last POSTs may still be flushing — the report
// shows from localDecisions; a quiet resync freshens counts and the // shows from localDecisions; a quiet resync freshens counts and the
// server's report flip, rate-limited so a failing last POST can't // server's report flip, rate-limited so a failing last POST can't
// cause a resync loop) // cause a resync loop)
$('report').classList.add('on'); $('report').classList.add('on');
$('empty').classList.remove('on'); $('empty').classList.remove('on');
$('empty2').classList.remove('on');
if(!section.exhausted&&Date.now()-lastResyncAt>2000){ if(!section.exhausted&&Date.now()-lastResyncAt>2000){
lastResyncAt=Date.now(); lastResyncAt=Date.now();
loadSection(); loadSection();
} }
}else if(section){ }else if(section){
$('empty').classList.remove('on'); $('report').classList.remove('on'); $('empty').classList.remove('on'); $('report').classList.remove('on');
$('empty2').classList.remove('on');
} }
}else{ }else{
$('empty').classList.remove('on'); $('empty').classList.remove('on');
$('report').classList.remove('on'); $('report').classList.remove('on');
$('empty2').classList.remove('on');
} }
} }
@@ -681,7 +715,7 @@ function commit(el,dir){
const act= dir==='maybe'?'maybe':dir; const act= dir==='maybe'?'maybe':dir;
setDecision(t.d.id, act, (!legacyMode&&section&&!section.exhausted)?section.section.id:null, setDecision(t.d.id, act, (!legacyMode&&section&&!section.exhausted)?section.section.id:null,
nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike
sectDecided++; sectDecided=Math.min(sectTotal, sectDecided+1); // clamp: never past the total
paintSect(); paintSect();
flyOut(el,dir); flyOut(el,dir);
fillPile(); relayer(); fillPile(); relayer();
@@ -717,6 +751,7 @@ function undo(){
preloadPhotos(); preloadPhotos();
$('empty').classList.remove('on'); $('empty').classList.remove('on');
$('report').classList.remove('on'); $('report').classList.remove('on');
$('empty2').classList.remove('on');
} }
$('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); }; $('btnLike').onclick=()=>{ if(pile[0]) commit(pile[0].el,'like'); };
$('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); }; $('btnNope').onclick=()=>{ if(pile[0]) commit(pile[0].el,'nope'); };
@@ -751,31 +786,46 @@ function upH(e){
else if(dy<-140) commit(el,'maybe'); else if(dy<-140) commit(el,'maybe');
else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); } else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); }
} }
/* §11.2/§11.3/§11.4 tap zones: sides flip (clamped), center = fullscreen. /* §11.4 v1.1 tap zones: sides flip (clamped), CENTER = dossier (bike data
The old tap-anywhere -> dossier gesture is retired (ⓘ stays the door). */ first — the old center-tap→fullscreen and tap-anywhere→dossier gestures
are retired). The deck dossier remembers its pile entry so a viewer
opened from its hero walks back to it on Esc. */
function tapZone(entry,e){ function tapZone(entry,e){
const n=nPhotos(entry.d);
if(n<1 || !entry.d.img) return;
const r=stage.getBoundingClientRect(); const r=stage.getBoundingClientRect();
const rel=e.clientX-r.left, w=r.width||360; const rel=e.clientX-r.left, w=r.width||360;
if(rel>=w/3 && rel<=w*2/3) openViewer(entry.d, entry.cur||0, {type:'card',entry}); if(rel>=w/3 && rel<=w*2/3){
else flipCard(entry, rel<w/3?-1:1); dEntry=entry; // live pile entry (cur photo index)
openDetail(entry.d,{type:'deck'});
}else{
flipCard(entry, rel<w/3?-1:1); // no-ops on single/no-photo cards
}
} }
/* ---------- docs/mockup core: fullscreen dossier ---------- */ /* ---------- docs/mockup core: fullscreen dossier ---------- */
let dCtx=null; let dCtx=null;
let dData=null; // B1: dossier payload (hero -> viewer) let dData=null; // B1: dossier payload (hero -> viewer)
let dEntry=null; // v1.1: pile entry when opened from the deck
function openDetail(data,ctx){ function openDetail(data,ctx){
dCtx=ctx||null; dCtx=ctx||null;
dData=data; dData=data;
dEntry=null; // only tapZone re-sets it (deck path)
const ph=$('dphoto'); const ph=$('dphoto');
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); } const blur=$('dblur');
else { ph.removeAttribute('src'); ph.classList.add('ph'); } if(data.img){ ph.src=data.img; ph.classList.remove('ph'); blur.style.backgroundImage='url("'+data.img+'")'; }
ph.onerror=()=>{ ph.removeAttribute('src'); ph.classList.add('ph'); }; else { ph.removeAttribute('src'); ph.classList.add('ph'); blur.style.backgroundImage='none'; }
ph.onerror=()=>{ ph.removeAttribute('src'); ph.classList.add('ph'); blur.style.backgroundImage='none'; };
$('dtitle').textContent=data.title||''; $('dtitle').textContent=data.title||'';
$('dprice').textContent=priceFor(data); $('dprice').textContent=priceFor(data);
$('dsub').textContent=subFor(data); $('dsub').textContent=subFor(data);
$('dchips').innerHTML=cardChips(data).map(x=>`<span class="chip">${x}</span>`).join(''); $('dchips').innerHTML=cardChips(data).map(x=>`<span class="chip">${x}</span>`).join('');
// item 10: the card band mirrors into the info view (violet SL / amber wild)
const db=$('dband');
const bl=bandLabel(data);
if(bl){
db.className='band'+(data.slot==='wildcard'?' wild':' sl')+' dband';
db.textContent=bl;
db.style.display='inline-block';
}else{ db.style.display='none'; }
const rows=[ const rows=[
["Year",data.year!=null?String(data.year):null], ["Year",data.year!=null?String(data.year):null],
["Odometer",data.km!=null?(fmtNum(data.km)+' km'):null], ["Odometer",data.km!=null?(fmtNum(data.km)+' km'):null],
@@ -794,7 +844,9 @@ function openDetail(data,ctx){
$('detail').classList.add('on'); $('dback').classList.add('on'); $('detail').classList.add('on'); $('dback').classList.add('on');
$('dbody').scrollTop=0; $('dbody').scrollTop=0;
} }
/* P3 dossier rank block: gauge + points + strongest ± lines + audit footer */ /* P3 dossier rank block: gauge + points% minimal; the full "why" breakdown
and the audit footer render only when prefs.rubric_debug === 'on' (§10.5). */
function rubricOn(){ return !!(section&&section.prefs&&section.prefs.rubric_debug==='on'); }
function fillRank(data){ function fillRank(data){
if(!data||data.tank==null||data.tank.value==null){ if(!data||data.tank==null||data.tank.value==null){
$('drank').style.display='none'; $('drank').style.display='none';
@@ -802,24 +854,30 @@ function fillRank(data){
} }
$('drank').style.display='flex'; $('drank').style.display='flex';
$('drankTank').innerHTML=tankSVG(data.tank,true); $('drankTank').innerHTML=tankSVG(data.tank,true);
$('dpts').textContent=data.tank.value+'/100'; $('dpts').textContent=(data.tank.value|0)+'%';
const lines=(data.points_breakdown||[]).slice() if(rubricOn()){
.sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4); const lines=(data.points_breakdown||[]).slice()
$('dlines').innerHTML=lines.map(l=>{ .sort((a,b)=>Math.abs(b.value)-Math.abs(a.value)).slice(0,4);
const lbl= l.source==='agent'?('agent: '+l.label):l.label; $('dlines').innerHTML=lines.map(l=>{
const n= l.value>=0?(' +'+l.value):(' '+l.value); const lbl= l.source==='agent'?('agent: '+l.label):l.label;
return `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`; const n= l.value>=0?(' +'+l.value):(' '+l.value);
}).join(''); return `<div class="${l.value>=0?'pos':'neg'}">· ${lbl}${n}</div>`;
const ri= section&&section.read_info; }).join('');
$('dfoot').textContent= ri?`from ${ri.events} strikes · read ${relTime(ri.profile_at)}`:''; const ri= section&&section.read_info;
// list/matches payloads carry no breakdown — fill it on the quiet $('dfoot').textContent= ri?`from ${ri.events} strikes · read ${relTime(ri.profile_at)}`:'';
if(!data.points_breakdown&&data.id&&!legacyMode){ // list/matches payloads carry no breakdown — fill it on the quiet
getJSON('/api/listing/'+data.id).then(full=>{ if(!data.points_breakdown&&data.id&&!legacyMode){
if(full&&full.points_breakdown&&dCtx) fillRank(full); getJSON('/api/listing/'+data.id).then(full=>{
}); if(full&&full.points_breakdown&&dCtx) fillRank(full);
});
}
}else{
// minimal display: gauge + % only; skip the quiet enrichment fetch
$('dlines').innerHTML='';
$('dfoot').textContent='';
} }
} }
function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; } function closeDetail(){ const d=$('detail'); d.style.transition=''; d.style.transform=''; d.classList.remove('on'); $('dback').classList.remove('on'); dCtx=null; dEntry=null; }
$('dclose').onclick=closeDetail; $('dclose').onclick=closeDetail;
$('dclose').addEventListener('pointerdown',e=>e.stopPropagation()); $('dclose').addEventListener('pointerdown',e=>e.stopPropagation());
/* pull-down-to-close (grabber bar + photo) */ /* pull-down-to-close (grabber bar + photo) */
@@ -829,10 +887,12 @@ function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDra
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null; function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
if(dy>110){ closeDetail(); } if(dy>110){ closeDetail(); }
else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform='';
/* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at photo 0 */ /* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at
photo 0. When the dossier sits over the deck, the viewer back-chain
walks Esc back to the dossier (item 2), then to the deck. */
if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList
&& e.currentTarget.classList.contains('dhero') && dData&&dData.img){ && e.currentTarget.classList.contains('dhero') && dData&&dData.img){
openViewer(dData,0,{type:'dossier'}); openViewer(dData,0,{type:'dossier', backToDetail:dEntry});
} } } } } }
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); }); [document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
function dAction(a){ function dAction(a){
@@ -881,6 +941,19 @@ function closeViewer(){
v.style.transition=''; v.style.transform=''; v.style.transition=''; v.style.transform='';
vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null; vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null;
} }
/* v1.1 walk-back chain: when the viewer was reached from the deck dossier
hero, Esc returns to the dossier (photo 0) instead of the deck. */
function vWalkBack(){
const back=vCtx&&vCtx.back;
if(back&&back.backToDetail){
const item=vCtx.item;
closeViewer();
openDetail(item, back.backToDetail);
dEntry=back.backToDetail.entry||null; // keep the chain repeatable
} else {
closeViewer();
}
}
$('vback').addEventListener('click',closeViewer); $('vback').addEventListener('click',closeViewer);
$('vclose').onclick=closeViewer; $('vclose').onclick=closeViewer;
$('vclose').addEventListener('pointerdown',e=>e.stopPropagation()); $('vclose').addEventListener('pointerdown',e=>e.stopPropagation());
@@ -1127,20 +1200,34 @@ let badgeFired=false;
async function loadStats(){ async function loadStats(){
const res=await getJSON('/api/stats'); const res=await getJSON('/api/stats');
if(!res) return; if(!res) return;
$('pill').innerHTML=`<b>${res.live}</b> live`+(res.new>0?` · <b>${res.new}</b> new`:''); const fresh=typeof res.fresh==='number'?res.fresh:null; // old API: no segment
$('pill').innerHTML=`<b>${res.live}</b> live${fresh!=null?` · <b>${fresh}</b> fresh`:''}`+(res.new>0?` · <b>${res.new}</b> new`:'');
$('lsub').innerHTML=`${res.live} active · `+(res.new>0?`<b style="color:#fff">${res.new} new</b>`:res.new+' new')+' since your last visit'; $('lsub').innerHTML=`${res.live} active · `+(res.new>0?`<b style="color:#fff">${res.new} new</b>`:res.new+' new')+' since your last visit';
if(!badgeFired){ if(!badgeFired){
badgeFired=true; badgeFired=true;
fetch('/api/badge/seen',{method:'POST'}).catch(()=>{}); fetch('/api/badge/seen',{method:'POST'}).catch(()=>{});
} }
} }
/* v1.1.1 (issue #2): loadStats only ran at boot, so the header pill and the
list sub-line went stale after every swipe until a full reload. Refresh
shortly after any decision change (swipes, undo, dossier/list/matches
edits) and whenever a new batch lands. Coalesced + fire-and-forget: a
swipe never waits on it. */
let statsTimer=null;
function refreshStats(){
if(statsTimer) return; // bursts collapse into one read
statsTimer=setTimeout(()=>{ statsTimer=null; loadStats(); },600);
}
/* foreground/resume: catch anything that changed while the app slept
(e.g. the 08:30 import) without waiting for the next swipe */
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) refreshStats(); });
/* ---------- P3 section UI: progress pill + report + forge + modes ---------- */ /* ---------- P3 section UI: progress pill + report + forge + modes ---------- */
let sectDecided=0, sectTotal=0; let sectDecided=0, sectTotal=0;
function paintSect(){ function paintSect(){
const t=$('sect'); const t=$('sect');
if(legacyMode||!section){ t.textContent=''; return; } if(legacyMode||!section){ t.textContent=''; return; }
t.textContent=`${sectDecided}/${sectTotal}`; t.textContent=`${Math.min(sectDecided,sectTotal)}/${sectTotal}`; // clamp: never paints past the total
t.classList.toggle('hot',sectDecided>=sectTotal); t.classList.toggle('hot',sectDecided>=sectTotal);
} }
function paintMode(){ function paintMode(){
@@ -1156,14 +1243,20 @@ function paintMode(){
} }
function updateSectionUI(body){ function updateSectionUI(body){
sectTotal=body.progress.total; sectTotal=body.progress.total;
// item 6: the dry-catalog caption rides every hydrate (legacy API -> absent)
$('dryCap').style.display= dryLabel(body)?'':'none';
let decided=0; let decided=0;
for(const it of (body.items||[])){ for(const it of (body.items||[])){
const local=localDecisions.get(it.id); const local=localDecisions.get(it.id);
if(local===undefined? it.decision!=null : local!=null) decided++; // item 5: server truth = decided DURING this section (an in-section
// event), never the lifetime decision a second look wears into it;
// local intent wins while a swipe's POST is in flight
if(local===undefined? !!it.decided_this_section : local!=null) decided++;
} }
sectDecided=decided; sectDecided=decided;
paintSect(); paintSect();
paintMode(); paintMode();
paintRubricMode();
const c=body.counts||{}; const c=body.counts||{};
$('repCounts').innerHTML=`♥ ${c.likes||0} · ★ ${c.maybes||0} · ✕ ${c.nopes||0}`; $('repCounts').innerHTML=`♥ ${c.likes||0} · ★ ${c.maybes||0} · ✕ ${c.nopes||0}`;
const hits=[]; const hits=[];
@@ -1177,6 +1270,28 @@ async function setMode(m){
} }
$('modeOnd').onclick=()=>setMode('ondemand'); $('modeOnd').onclick=()=>setMode('ondemand');
$('modeStr').onclick=()=>setMode('strict'); $('modeStr').onclick=()=>setMode('strict');
function paintRubricMode(){
const on=rubricOn();
$('tRubOff').classList.toggle('on',!on);
$('tRubOn').classList.toggle('on',on);
}
async function setRubricDebug(m){
const res=await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:'rubric_debug',value:m})}).catch(()=>null);
if(res&&res.ok){
if(section){ section.prefs=section.prefs||{}; section.prefs.rubric_debug=m; }
paintRubricMode();
if(!legacyMode) loadSection(); // repaint any dossier already open
}
}
$('tRubOff').onclick=()=>setRubricDebug('off');
$('tRubOn').onclick=()=>setRubricDebug('on');
/* item 6: dry catalog — a forged batch with zero fresh cards is labeled,
never silent (BACKLOG "Dry-catalog messaging"). */
const DRY_MSG='Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks';
function dryLabel(body){
return !!(body && body.recipe && body.recipe.new===0
&& body.items && body.items.length>0);
}
$('btnForge').onclick=async()=>{ $('btnForge').onclick=async()=>{
if(forgeBusy||!section) return; if(forgeBusy||!section) return;
forgeBusy=true; forgeBusy=true;
@@ -1186,8 +1301,10 @@ $('btnForge').onclick=async()=>{
const res=await fetch('/api/section/forge',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); const res=await fetch('/api/section/forge',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const body=await res.json(); const body=await res.json();
if(res.ok){ if(res.ok){
$('forgeStatus').textContent= body.note&&body.note.indexOf('fallback')>=0 $('forgeStatus').textContent= dryLabel(body)
?'Agent offline — forging on the last read.':''; ?DRY_MSG
:(body.note&&body.note.indexOf('fallback')>=0
?'Agent offline — forging on the last read.':'');
section=body; section=body;
buffer.length=0; buffer.length=0;
const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id)]); const live=new Set([...pile.map(c=>c.d.id),...hist.map(h=>h.id)]);
@@ -1195,7 +1312,14 @@ $('btnForge').onclick=async()=>{
buffer.push(...fresh); buffer.push(...fresh);
fillPile(); fillPile();
updateSectionUI(body); updateSectionUI(body);
refreshStats(); // v1.1.1 (#2): forged batch — freshen the counters
if(pile.length){ $('report').classList.remove('on'); } if(pile.length){ $('report').classList.remove('on'); }
// item 8: a zero-item forge on a fully drained catalog -> empty2,
// no lone Forge button inviting a wasted agent run
if((body.items||[]).length===0 && poolDead()){
$('report').classList.remove('on');
$('empty2').classList.add('on');
}
}else{ }else{
$('forgeStatus').textContent= body.detail||'Forge failed'; $('forgeStatus').textContent= body.detail||'Forge failed';
paintMode(); paintMode();
@@ -1260,13 +1384,29 @@ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
else if(name==='trends') fetchTrends(); else if(name==='trends') fetchTrends();
}); });
/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */ /* ---------- v1.1 keyboard map (BACKLOG "Shortcuts"): ← nope · → like ·
↑ maybe · ↓ undo · Backspace = undo alias (kept for muscle memory) ·
Esc closes the top open sheet (viewer → dossier → deck). Arrows act only
on the deck; text-input focus and open sheets swallow them; arrow-key
defaults are prevented so no page scroll can fire. -------------------- */
document.addEventListener('keydown',e=>{ document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')||$('viewer').classList.contains('on')) return; const t=e.target;
if(e.key==='ArrowRight'){ if(pile[0]) commit(pile[0].el,'like'); } if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable)) return;
else if(e.key==='ArrowLeft'){ if(pile[0]) commit(pile[0].el,'nope'); } const k=e.key;
else if(e.key==='ArrowUp'){ if(pile[0]) commit(pile[0].el,'maybe'); } if(k==='Escape'){
else if(e.key==='Backspace'){ undo(); } if($('viewer').classList.contains('on')) vWalkBack(); // viewer → dossier (when reached from it) → deck
else if($('detail').classList.contains('on')) closeDetail();
return; // Esc never forges or navigates tabs
}
const sheetOpen=()=>$('detail').classList.contains('on')||$('viewer').classList.contains('on');
if(k==='ArrowDown'){ e.preventDefault(); if(!sheetOpen()) undo(); return; }
if(k==='Backspace'){ if(!sheetOpen()) undo(); return; }
if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'){
e.preventDefault();
if(sheetOpen()) return;
const el=pile[0]&&pile[0].el;
if(el) commit(el, k==='ArrowLeft'?'nope':k==='ArrowRight'?'like':'maybe');
}
}); });
/* ---------- boot ---------- */ /* ---------- boot ---------- */

View File

@@ -379,8 +379,13 @@ class TasteEngine:
"""Source-labeled breakdown lines for the dossier.""" """Source-labeled breakdown lines for the dossier."""
row = self.by_id.get(listing_id) row = self.by_id.get(listing_id)
lines = [] lines = []
lines.append({'source': 'rubric', 'label': 'rubric fit', # issue #3: a section can outlive an ad. A listing that left the
'value': int(row['score'] or 0)}) # active pool mid-batch (and has no swipe history) is not in
# by_id; its breakdown degrades to no-signal lines instead of a
# TypeError that would 500 the whole section render.
if row is not None:
lines.append({'source': 'rubric', 'label': 'rubric fit',
'value': int(row['score'] or 0)})
for label, s, _d, _v in self.contributions(listing_id)[:3]: for label, s, _d, _v in self.contributions(listing_id)[:3]:
lines.append({'source': 'taste', 'label': label, lines.append({'source': 'taste', 'label': label,
'value': round(s, 1)}) 'value': round(s, 1)})

View File

@@ -2,10 +2,58 @@
Glenn's "later versions" idea queue. He drops ideas in conversation; they land here (near-verbatim + a scope sketch + open questions), and get built in a future version — never mid-phase. Newest first. Glenn's "later versions" idea queue. He drops ideas in conversation; they land here (near-verbatim + a scope sketch + open questions), and get built in a future version — never mid-phase. Newest first.
## v1.1 — QOL wishlist (drafted 2026-11-12; feasibility triaged same day; build awaits Glenn's go-ahead)
Glenn's words: *"Going to go through a laundry list of fixes before we do lets say 1.1?"* — nothing here is started.
### Bugs & dry-catalog messaging (reported live 2026-11-12 evening)
Glenn's words: *"I just got a new batch and its marked as 2/2. When I go to the next, its 3/2… I don't believe it's counting the entries correctly. Furthermore, I'm only again getting 2 bikes at a time… I feel I should be getting batches of 25 at a time?"*
- **Counter bug (diagnosed in code 2026-11-12):** the pill shows `decided/total` for the current section. **Returns arrive wearing their previous decision** (the section payload joins `swipes.action`), so a fresh second-looks-only batch instantly counts as fully decided — a new batch paints "2/2" before it is touched, and the hot/complete state fires prematurely. Swiping then increments optimistically on top (`sectDecided++`), so the count runs past the total — "3/2", "4/2". **Fix sketch:** count a card as decided only if decided *during this section* (server per-item flag from events stamped with this section_id — the source `section_counts` already uses); clamp to total; keep the hot state off until a real in-section decision exists.
- **Dry-catalog messaging (pulled forward from the Dry-pool UX note below):** when the fresh pool is 0 the deck silently degrades to returns-only sections (2–3 cards: ≤5 returns budget, one-shot per item, family caps) — by design but confusing. v1.1: surface the state ("Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks") whenever a composed/forged batch reports `new: 0`, and label forge results accordingly so it never silently drips 2 cards again.
### Batch formula v2 + fresh counter + empty state + Second Look pop (requested live 2026-11-12 evening, round 2)
Glenn's words: *"Alright lets add a new counter to the top of 'bikes lef' or something to indicate how many bikes are fresh. I think if there is no more new bikes, the empty page (all bikes gone through) should say 'no more new bikes!'. Maybe there is already but I didn't read it. Make the UI more pronounced so I can see it clearly. One more thing, I think there should be 25 bikes at a time, min 2 of second look. So say there is 5 bikes left, those 5 fresh bikes are listed, with the rest being second looks. Ok last thing, the Second Look indicator, make it more pronounced visually. Change the color a bit so it pops a bit more."*
- **Fresh counter (header):** new chip in the top header showing **fresh (undecided) bikes left** — extend `/api/stats` with `fresh` (active listings with no swipe — the exact NEW_POOL predicate). Header pill gains `<b>K</b> fresh`. When 0, the state of the hunt reads at a glance.
- **Empty state, loud:** the exhausted screen becomes prominent **"No more new bikes!"** (today `.empty` says a quiet "You're all caught up" and the report screen can mask it in section mode). Show whenever the deck can serve *nothing* at all (no fresh, no eligible second looks, no wildcards); keep "New bikes arrive nightly — next import 08:30".
- **Batch formula v2** *(amends the frozen ≤5-returns rule — build-time UI-SPEC/P3 note):* every batch targets **25 cards**. Fresh first (up to 23 slots); **≥2 second looks per batch whenever eligible stock exists** *(reading Glenn's "min 2": a full-fresh day = 23 fresh + 2 second looks; veto line if he wants pure-fresh instead)*; 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 (build detail); returns stay one-shot. Fresh exhausted → second-looks-only batches; returns exhausted → fresh-only batches; both → the loud empty state.
- *Noted consequence:* with fill-to-25, the one-shot second-look pool (~30 remaining as of tonight) burns through in a batch or two — after that only nightly imports feed the deck. Re-offer policy (e.g. re-eligible after everything's had its turn) stays queued (option (c) below); revisit after living with v2.
- **Second Look band pops:** `.band.sl` is currently near-invisible (white at ~10% on dark); re-tint to a distinct **violet accent** (bold fill + border) on card face + info view. Wildcard stays amber for contrast.
- **UI-SPEC amendments at build:** §11 tap flow (already queued) + `.band.sl` palette + empty-state copy + header fresh counter.
- **Glenn's sign-offs (2026-11-12, live):** ≥2-second-looks default CONFIRMED (rich-day deck = 21 fresh + 2 second looks + 2 wildcards) · violet band CONFIRMED · Backspace kept as undo alias CONFIRMED. **Build phase AUTHORIZED** the same day — implementation spec = `docs/V1.1-BUILD.md`.
### Shortcuts
- Arrow keys: Left = Nope, Right = Like, Up = Maybe, **Down = back one** *(read as: undo the last swipe — confirm at spec time)*. **Esc** = exit fullscreen views.
- Feasibility: trivial — keyboard invokes the exact actions gestures/buttons already call (undo exists). Care: ignore keys while a text input has focus; prevent arrow-key page scroll. Desktop/Bluetooth-keyboard benefit only; the app is reachable from any tailnet device (MacBook included).
### UI changes
1. **Tap flow — info first, then photos.** Center tap opens the fullscreen **bike data view** (today it opens the photo); tapping the photo there opens the fullscreen **image viewer**. Side taps keep flipping photos on the card. Esc/back pops viewer → data → deck. Resolves "can't get info unless I hit the ⓘ button." Revisit `UI-SPEC.md` §11 tap map before build (v1.1 spec bump).
2. **Photo fit: fill → contain, option C chosen.** Show photos uncropped — no "zoomed" crops. **Decided 2026-11-12 (Glenn: "OH C! I love C!"): option C — fit + blurred backdrop (whole photo over a soft blur of the same image) on the card AND the info-page hero.** Fullscreen viewer already fits (unchanged); list thumbs stay fill (56px, irrelevant). Demo reference: `~/dev/bike-tinder-demos/fit-vs-fill.html` (panel C).
3. **Rubric display goes minimal.** Show ONLY the gas-tank gauge + percent full (e.g. "76%"); drop the rest of the rubric-fit display in bike views — **plus a setting that re-enables the full "why" breakdown for debugging** (Glenn, 2026-11-12; wire via the existing `prefs` key/value API — same pattern as `forge_mode`).
## v1.2 — Analytics suite (version split set with Glenn 2026-11-12)
Glenn's words: *"take in just about all interactions… how long I have looked at each picture, how many pictures I flipped through, how quickly I swiped… kept on an individual bike perspective and fed to the model for further accuracy"* + *"start the database from scratch, giving myself a new start."*
- **Capture (local-only, per bike):** dwell time per photo, flips (count/order), decision latency (card visible → swipe), maybe/undo/list-edit events, session id; card-dwell vs fullscreen-dwell as separate signals.
- **Store:** new append-only `interaction_events` table (same pattern as `swipe_events`); batched client flush (`sendBeacon`/pagehide); iOS PWA: pause dwell clocks on `visibilitychange` so background time can't pollute.
- **Feed the model:** nightly agent pass consumes per-bike aggregates as extra taste features. HARD LAW unchanged: no LLM in the swipe path — capture is write-only until the nightly read.
- **Reset:** ships with v1.2, paired with a from-scratch DB reset so telemetry starts clean on the new ledger. Scope to confirm: wipe swipes + events + profile only (listings/catalog stay — re-import is idempotent); legacy data stays in `data/backup/` archives. (v1.1 does NOT reset anything — swipe history survives it untouched.)
- **Open questions:** reset scope; raw events forever vs aggregate-and-prune; include list-browsing events; surface any of it in the UI (probably not).
## Gallery mirror gap (found 2026-11-12, filing for a later phase)
Verified while preparing the fit/fill demo: the local image mirror (`data/img/{id}.webp`) holds **primaries only**. Gallery photos beyond the first (`/img/{id}?i≥1`) serve a **302 redirect to the Kijiji CDN** — not hedged locally. If an ad is deleted we keep photo 1, but the gallery (often where the good shots are) is gone. Fix options for a later phase: mirror galleries on import/first-touch (estimates in the B1 notes: roughly +350–600 MB for the full catalog — fine for the mirror volume), keep the redirect as fallback. Not urgent; filed so it is not forgotten.
## Dry-pool UX + agent feedback (noted 2026-11-12) ## Dry-pool UX + agent feedback (noted 2026-11-12)
Glenn hit a fully-reviewed catalog: 563/563 actives decided → fresh pool = 0 → forges returned 2-card returns-only sections ("stuck on a couple bikes"), and "Run agent now" looked inert (a pass ran, but the deck can't change without fresh listings). Possible polish: (a) report/forge state says "catalog fully reviewed — fresh listings arrive with the next import" instead of silently dripping 2 cards; (b) agent button shows a richer confirmation (profile version + lean delta); (c) re-open "second look" eligibility or widen the wildcard pool when the catalog is dry; (d) a "review your Maybes" shortcut at exhaustion. Related: the nightly import + nightly agent read make the pool self-refreshing (Phase 4). Glenn hit a fully-reviewed catalog: 563/563 actives decided → fresh pool = 0 → forges returned 2-card returns-only sections ("stuck on a couple bikes"), and "Run agent now" looked inert (a pass ran, but the deck can't change without fresh listings). Possible polish: (a) report/forge state says "catalog fully reviewed — fresh listings arrive with the next import" instead of silently dripping 2 cards; (b) agent button shows a richer confirmation (profile version + lean delta); (c) re-open "second look" eligibility or widen the wildcard pool when the catalog is dry; (d) a "review your Maybes" shortcut at exhaustion. Related: the nightly import + nightly agent read make the pool self-refreshing (Phase 4).
**Pulled into v1.1 (2026-11-12):** option (a) — dry-state messaging — is now part of the v1.1 scope (see Bugs & dry-catalog messaging above); options (b)/(c)/(d) remain queued for a later version.
## B1 · Photo experience v2 — multi-image galleries, photo swipe, fullscreen viewer ## B1 · Photo experience v2 — multi-image galleries, photo swipe, fullscreen viewer
**Queued 2026-11-10** (during the P3 build; for the version after it). **PULLED FORWARD 2026-11-10; SHIPPED 2026-11-11 — built, verified (walk 390/390, selfcheck 31/31), LIVE on :9453; see `PLAN.md`.** Spec frozen in `UI-SPEC.md` §11 + `DATA-MODEL.md` B1 additions. **Queued 2026-11-10** (during the P3 build; for the version after it). **PULLED FORWARD 2026-11-10; SHIPPED 2026-11-11 — built, verified (walk 390/390, selfcheck 31/31), LIVE on :9453; see `PLAN.md`.** Spec frozen in `UI-SPEC.md` §11 + `DATA-MODEL.md` B1 additions.

View File

@@ -162,9 +162,9 @@ Semantics:
- Round = prior events for that listing + 1, assigned at write time. Rounds cap at 2 for engine influence (any later events weigh as round 2; v1 returns never produce rounds >2 by design). - Round = prior events for that listing + 1, assigned at write time. Rounds cap at 2 for engine influence (any later events weigh as round 2; v1 returns never produce rounds >2 by design).
- **Migration seeding:** when `swipe_events` is empty and `swipes` has rows → insert one round-1 event per swipe row (created_at copied). Only-when-empty, hence idempotent. - **Migration seeding:** when `swipe_events` is empty and `swipes` has rows → insert one round-1 event per swipe row (created_at copied). Only-when-empty, hence idempotent.
- **Sections:** at most ONE `open` section at a time. The deck = that section's `section_items` in `position` order, filtered to items undecided at read. Exhausted = no item undecided → `status='report'`. Forge = close current + open new. First-ever read composes a cold section (rubric-weighted, w=0). - **Sections:** at most ONE `open` section at a time. The deck = that section's `section_items` in `position` order, filtered to items undecided at read. Exhausted = no item undecided → `status='report'`. Forge = close current + open new. First-ever read composes a cold section (rubric-weighted, w=0).
- **Returns eligibility (v1 contract):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family). Max 5 returns per section, ≤2 of them nopes. - **Returns eligibility (v1.1 contract, amends the frozen ≤5 rule):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family) for the RESERVED slots only. Every batch carries ≥2 returns whenever eligible stock exists and fills the remainder up to 25 when fresh < 23; ≤2 nope-verdict returns per batch; fill returns relax the per-family cap from 4 to 8 (fill slots only).
- **`points` is computed, not stored** (deterministic from events + profile; a cache is allowed only if it never changes semantics). - **`points` is computed, not stored** (deterministic from events + profile; a cache is allowed only if it never changes semantics).
- **prefs:** `forge_mode` ('ondemand' default | 'strict'). Existing keys unchanged. - **prefs:** `forge_mode` ('ondemand' default | 'strict') · `rubric_debug` ('off' default | 'on' — dossier "why" breakdown visibility; v1.1). Existing keys unchanged.
- **Legacy deck semantics:** `/api/deck` (cursor paging; 7-day maybe re-admit) stays as a read-only fallback for debugging; sections are the production flow. - **Legacy deck semantics:** `/api/deck` (cursor paging; 7-day maybe re-admit) stays as a read-only fallback for debugging; sections are the production flow.
## B1 additions — frozen 2026-11-10 (photo experience v2) ## B1 additions — frozen 2026-11-10 (photo experience v2)

View File

@@ -1,6 +1,12 @@
# PLAN — Bike-Tinder # PLAN — Bike-Tinder
**Status:** Build phase — Phase 2 complete + phone-proven (2026-11-10); **Phase 3 built, verified, and live (2026-11-11); B1 photo experience v2 built, verified, and live (2026-11-11)**; Phase 4 next. Phases are gated: each lands, is verified, then the next begins. Glenn's standing rule applies — "pause" stops all spend immediately. **Status:** **v1.0 SHIPPED (2026-11-12)** — Phases 1–4 complete: stack live under its unit, nightly chain armed, tailnet URL in daily use, Gitea release `v1.0.0` cut. **v1.1** (QOL batch) is spec'd and awaits Glenn's build order; **v1.2** (analytics suite + fresh-ledger reset) queued. Phases are gated: each lands, is verified, then the next begins. Glenn's standing rule applies — "pause" stops all spend immediately.
## Version map
- **v1.0** — shipped 2026-11-12: the working loop (Phases 1–4 — catalog, swipe deck, personalization, deployed stack + nightly automation). Gitea release `v1.0.0`.
- **v1.1** — QOL batch, spec'd 2026-11-12 (`BACKLOG.md`): keyboard shortcuts · info-first tap flow · option C photos (fit + blurred backdrop) · minimal rubric + debug "why" toggle · section-counter fix + dry-catalog messaging · fresh counter + loud "No more new bikes!" empty state · batch formula v2 (25-target, second-look fill, ≥2 per batch) + Second Look band pop. No data reset. **Build authorized 2026-11-12** — sign-offs: ≥2-SL default (rich day 21 fresh/2 SL/2 wild) · violet band · Backspace alias; spec = `docs/V1.1-BUILD.md`; build phase in progress.
- **v1.2** — analytics suite (`BACKLOG.md`): full interaction capture + from-scratch ledger reset ("fresh start").
## v1 goal ## v1 goal
@@ -70,6 +76,7 @@ Glenn's approved design (2026-11-10):
- ✅ DB + image mirror migrated intact into the stack volume; backup drill produced its first files - ✅ DB + image mirror migrated intact into the stack volume; backup drill produced its first files
- ✅ Deploy fix (found on first in-container agent call): `requests` was missing from requirements — the host venv had it incidentally, the slim image did not - ✅ Deploy fix (found on first in-container agent call): `requests` was missing from requirements — the host venv had it incidentally, the slim image did not
- ✅ Runbook in DESIGN.md (deploy/rollback/logs/import gates/backup-restore/phone checklist) - ✅ Runbook in DESIGN.md (deploy/rollback/logs/import gates/backup-restore/phone checklist)
- ✅ **Release v1.0.0 published 2026-11-12** — Gitea release, tag pinned to `main` head; notes summarize the working loop; source zip/tar.gz auto-generated by Gitea (nothing attached)
- ✅ In use from Glenn's phone (2026-11-12 — first photo flips + swipes logged through the sidecar); PWA "Add to Home Screen" available; full reboot test optional/pending - ✅ In use from Glenn's phone (2026-11-12 — first photo flips + swipes logged through the sidecar); PWA "Add to Home Screen" available; full reboot test optional/pending
- **Exit criteria:** survives a reboot (service-level verified; power-level test optional); refreshes overnight unattended (armed, first automatic run 08:30 next day); installable and used from the phone — **achieved 2026-11-12.** - **Exit criteria:** survives a reboot (service-level verified; power-level test optional); refreshes overnight unattended (armed, first automatic run 08:30 next day); installable and used from the phone — **achieved 2026-11-12.**

View File

@@ -26,7 +26,7 @@ Deterministic, explainable, pure-Python. Learning reads `swipe_events` only.
**Score.** `taste_raw` = Σ shrunk weights over the listing's values; `taste_norm` = percentile rank among active listings (0–100). Blend: `w = min(0.85, N/60)` where N = total events; `points = clip( round( (1−w)·rubric + w·taste_norm + agent_adj ), 0, 100 )`. `agent_adj` = Σ profile adjustments (clamped ±10 per listing). Breakdown for the dossier: rubric share · top 3 ± taste features · agent lines — all source-labeled. **Score.** `taste_raw` = Σ shrunk weights over the listing's values; `taste_norm` = percentile rank among active listings (0–100). Blend: `w = min(0.85, N/60)` where N = total events; `points = clip( round( (1−w)·rubric + w·taste_norm + agent_adj ), 0, 100 )`. `agent_adj` = Σ profile adjustments (clamped ±10 per listing). Breakdown for the dossier: rubric share · top 3 ± taste features · agent lines — all source-labeled.
**Section recipe.** 25 cards = 2 wildcards + ≤5 returns + remainder new (points DESC). Constraints: ≤4 per family per section; ≥3 price bands when the pool allows; shortfalls fill from new. **Section recipe.** Every batch targets 25 cards = fresh first (up to 23 slots) + ≥2 second looks whenever eligible stock exists + the 2 wildcard slots (wildcards draw from the fresh pool). When fresh < the remainder, second looks fill up to 25 (family cap relaxes to 8 for fill returns only); leftover fresh rolls to the next batch; fresh/return shortfalls are never padded — a drained pool yields a short (or empty) section. Constraints: ≤4 per family per section for fresh + reserved returns; ≥3 price bands when the pool allows.
**Wildcards.** Candidates: active, unseen, not in the last two sections. Exclusions: families with ≥3 cumulative nopes and net-negative weight. Prefer: different family AND different price band than the section's top new items. Score = `0.5·points + 0.5·novelty` (novelty = distance from the liked-profile centroid). Picks: 2 per section. **Wildcards.** Candidates: active, unseen, not in the last two sections. Exclusions: families with ≥3 cumulative nopes and net-negative weight. Prefer: different family AND different price band than the section's top new items. Score = `0.5·points + 0.5·novelty` (novelty = distance from the liked-profile centroid). Picks: 2 per section.

View File

@@ -20,6 +20,7 @@
### Card geometry ### Card geometry
- `.card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card);box-shadow:0 0 0 1px rgba(255,255,255,.07),0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none}` - `.card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card);box-shadow:0 0 0 1px rgba(255,255,255,.07),0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none}`
- Photo full-bleed (`object-fit:cover`); bottom gradient 52% tall (transparent → rgba(0,0,0,.55) @55% → .88). Info block at bottom:14px: chips row → title (23px/800, clamp 2 lines) → price (20px/600) → sub (13px, var(--mut)). - Photo full-bleed (`object-fit:cover`); bottom gradient 52% tall (transparent → rgba(0,0,0,.55) @55% → .88). Info block at bottom:14px: chips row → title (23px/800, clamp 2 lines) → price (20px/600) → sub (13px, var(--mut)).
- Photo fit is **contain** (v1.1): 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).
- No photo → placeholder panel: 45° repeating stripes (#141419/#18181f), centered copy: "Photo pending — thumbnail backfill queued". - No photo → placeholder panel: 45° repeating stripes (#141419/#18181f), centered copy: "Photo pending — thumbnail backfill queued".
- ⓘ button: 32px circle, top-right of card (rgba(0,0,0,.45), 1px rgba(255,255,255,.25) border) → opens the dossier for that card. - ⓘ button: 32px circle, top-right of card (rgba(0,0,0,.45), 1px rgba(255,255,255,.25) border) → opens the dossier for that card.
- Chips: 11px/600, rgba(255,255,255,.14) bg, radius 999px. Production content: max 3 = seller chip ("Dealer" only when seller_type=dealer) + first 2 extras. - Chips: 11px/600, rgba(255,255,255,.14) bg, radius 999px. Production content: max 3 = seller chip ("Dealer" only when seller_type=dealer) + first 2 extras.
@@ -51,6 +52,8 @@
### Empty state ### Empty state
Dashed panel, copy exactly: **"You're all caught up."** / "New bikes arrive nightly — next import 08:30." / "↺ brings the last card back." Undo restores the last card. Dashed panel, copy exactly: **"You're all caught up."** / "New bikes arrive nightly — next import 08:30." / "↺ brings the last card back." Undo restores the last card.
§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 (v1.1).
## 3. Dossier (fullscreen sheet) — one sheet, both doors ## 3. Dossier (fullscreen sheet) — one sheet, both doors
Opened from deck ⓘ (or tap) AND list row tap. iOS-sheet geometry: top gap 26px; `border-radius:18px 18px 0 0`; bg #0f0f13; slide-in `.3s cubic-bezier(.2,.8,.3,1)` from `translateY(103%)`. Backdrop: rgba(0,0,0,.38), z 69/70. Opened from deck ⓘ (or tap) AND list row tap. iOS-sheet geometry: top gap 26px; `border-radius:18px 18px 0 0`; bg #0f0f13; slide-in `.3s cubic-bezier(.2,.8,.3,1)` from `translateY(103%)`. Backdrop: rgba(0,0,0,.38), z 69/70.
@@ -87,7 +90,7 @@ Opened from deck ⓘ (or tap) AND list row tap. iOS-sheet geometry: top gap 26px
1. **Data wiring.** Deck buffer from `GET /api/deck` (limit 40, opaque cursor); refill when buffer <5 and a cursor exists. List from `GET /api/list`. Dossier from the row/card payload. Header from `GET /api/stats`. 1. **Data wiring.** Deck buffer from `GET /api/deck` (limit 40, opaque cursor); refill when buffer <5 and a cursor exists. List from `GET /api/list`. Dossier from the row/card payload. Header from `GET /api/stats`.
2. **Swipe persistence.** Commits fire `POST /api/swipe` optimistically — the UI never waits. Failures go to a retry queue (attempts at ~2s/5s/10s, then every 15s while the tab lives); toast "Couldn't save — retrying" on first failure. Undo fires `DELETE /api/swipe/{id}` through the same queue. 2. **Swipe persistence.** Commits fire `POST /api/swipe` optimistically — the UI never waits. Failures go to a retry queue (attempts at ~2s/5s/10s, then every 15s while the tab lives); toast "Couldn't save — retrying" on first failure. Undo fires `DELETE /api/swipe/{id}` through the same queue.
3. **Badge.** `POST /api/badge/seen` once per session when the deck first renders. Header shows "{live} live" always; "· {new} new" only when new>0. 3. **Badge.** `POST /api/badge/seen` once per session when the deck first renders. 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) — v1.1. The pill and the list sub-line refresh themselves shortly after any decision change (swipes, undo, dossier/list/matches edits) and whenever a new batch lands — no reload needed (v1.1.1, issue #2).
4. **List filters + search** per §4 (mockup had placeholder chips only). 4. **List filters + search** per §4 (mockup had placeholder chips only).
5. **Matches real (§5); Trends stays stub.** 5. **Matches real (§5); Trends stays stub.**
6. **Images.** Every card/thumb references `/img/{id}` (server: cached file → 302 CDN → 404 → placeholder). The client never hotlinks directly. 6. **Images.** Every card/thumb references `/img/{id}` (server: cached file → 302 CDN → 404 → placeholder). The client never hotlinks directly.
@@ -119,7 +122,7 @@ Human (parent-run after build): Glenn swipes a real deck from his phone and edit
- The deck serves ONE section at a time: 25 cards (default). A section is the unit of a swipe burst; when it is exhausted, the next one is forged (with an agent pass in between — §10.2). - The deck serves ONE section at a time: 25 cards (default). A section is the unit of a swipe burst; when it is exhausted, the next one is forged (with an agent pass in between — §10.2).
- Header right side, next to the stats: section progress "7/25" (12px/650, muted; turns `--accent1` at 25/25; fits within the existing header, no layout shift). - Header right side, next to the stats: section progress "7/25" (12px/650, muted; turns `--accent1` at 25/25; fits within the existing header, no layout shift).
- Section composition (engine side — RANKING.md): ≤5 SECOND LOOK cards + 2 WILDCARD cards + the remainder new. - 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 (v1.1).
- ALL §2 mechanics apply unchanged inside a section: 5-deep stack, `.topcard` invariant, no-lock commit, stamps, undo; the buffer refills from the pinned section order. - ALL §2 mechanics apply unchanged inside a section: 5-deep stack, `.topcard` invariant, no-lock commit, stamps, undo; the buffer refills from the pinned section order.
### 10.2 Section report + forge ### 10.2 Section report + forge
@@ -133,13 +136,14 @@ Human (parent-run after build): Glenn swipes a real deck from his phone and edit
- **Forge mode `ondemand` (default):** primary button **"Forge next 25"**. Tap → inline status "The agent is reading your strikes…" → new section hydrates. If the pass fails: "Agent offline — forging on the last read." and the forge still lands (fallback profile). - **Forge mode `ondemand` (default):** primary button **"Forge next 25"**. Tap → inline status "The agent is reading your strikes…" → new section hydrates. If the pass fails: "Agent offline — forging on the last read." and the forge still lands (fallback profile).
- **Forge mode `strict`:** button disabled; caption "Waiting for the nightly read — next section after the agent runs." - **Forge mode `strict`:** button disabled; caption "Waiting for the nightly read — next section after the agent runs."
- Both modes: small toggle row under the actions — "Forge: on demand ⇄ strict" (persists `prefs['forge_mode']`). - Both modes: small toggle row under the actions — "Forge: on demand ⇄ strict" (persists `prefs['forge_mode']`).
- 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 (v1.1).
- Undo from the report screen re-opens the section with the last card (§2 undo semantics). - Undo from the report screen re-opens the section with the last card (§2 undo semantics).
### 10.3 Card bands (the ONLY card-face additions) ### 10.3 Card bands (the ONLY card-face additions)
- Position: top-left inset (12px); `pointer-events:none`; no layout shift; no effect on stack metrics or drag. 11px/650, letter-spacing .4px, radius 999px, padding 4px 9px, max-width 78%; may wrap to 2 lines. - Position: top-left inset (12px); `pointer-events:none`; no layout shift; no effect on stack metrics or drag. 11px/650, letter-spacing .4px, radius 999px, padding 4px 9px, max-width 78%; may wrap to 2 lines.
- **WILDCARD — maybe you'll like this one instead** — color `#ffb43a`; bg `rgba(255,180,58,.14)`; border `1px solid rgba(255,180,58,.35)`. - **WILDCARD — maybe you'll like this one instead** — color `#ffb43a`; bg `rgba(255,180,58,.14)`; border `1px solid rgba(255,180,58,.35)`.
- **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)`. - **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` (v1.1).
- No other text may sit on the card face in v3. - No other text may sit on the card face in v3.
### 10.4 Tank gauge (match points, non-numeric) ### 10.4 Tank gauge (match points, non-numeric)
@@ -151,9 +155,10 @@ Human (parent-run after build): Glenn swipes a real deck from his phone and edit
### 10.5 Dossier — "Why this rank" ### 10.5 Dossier — "Why this rank"
- New block after chips, before "The machine": - New block after chips, before "The machine":
- Gauge + "{points}/100" (17px/700). - Gauge + "{points}%" (17px/700).
- Up to 4 breakdown lines, strongest first, muted: "· {feature} {±n}"; agent contributions render as "· agent: {why} {±n}". - Up to 4 breakdown lines, strongest first, muted: "· {feature} {±n}"; agent contributions render as "· agent: {why} {±n}".
- Footer: "from {N} strikes · read {relative}" (N = events count; time = profile version time). - Footer: "from {N} strikes · read {relative}" (N = events count; time = profile version time).
- 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) — v1.1.
- This is the only numeric display in the app (accepted — it exists for audit). - This is the only numeric display in the app (accepted — it exists for audit).
### 10.6 Trends tab (real) ### 10.6 Trends tab (real)
@@ -192,14 +197,15 @@ Top→bottom:
### 11.3 Fullscreen viewer ### 11.3 Fullscreen viewer
- Tap the CENTER third → fullscreen viewer opens on the photo currently shown. - 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 (v1.1).
- Viewer: horizontal swipe = prev/next (clamp); pinch-zoom + double-tap = zoom (1×–~4×); segment bars stay visible; close via ✕ (36px, top-left) or pull-down (grabber or photo, `dy > 110` — the §3 idiom). Closing returns to the same card, same photo position; deck state untouched. - Viewer: horizontal swipe = prev/next (clamp); pinch-zoom + double-tap = zoom (1×–~4×); segment bars stay visible; close via ✕ (36px, top-left) or pull-down (grabber or photo, `dy > 110` — the §3 idiom). Closing returns to the same card, same photo position; deck state untouched.
- Also opens from the dossier hero (starts at photo 0). Same sheet geometry as the dossier (z-band 69/70), photo-first: no body, no actions row. - Also opens from the dossier hero (starts at photo 0). Same sheet geometry as the dossier (z-band 69/70), photo-first: no body, no actions row.
- Viewer close (✕, pull-down, or Esc) returns to the dossier; the next Esc returns to the deck (v1.1).
### 11.4 Doors (supersedes the old tap→dossier) ### 11.4 Doors (supersedes the old tap→dossier)
- ⓘ (top-right of card) → dossier (unchanged). The card face now owns: **sides = flip, center = fullscreen**; the "tap anywhere → dossier" gesture is RETIRED. - ⓘ (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 (v1.1).
- The dossier stays reachable from ⓘ and from list rows (§3/§4 unchanged). - 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).
### 11.5 Repeat rotation ### 11.5 Repeat rotation

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

@@ -0,0 +1,624 @@
# V1.1-BUILD — v1.1 implementation specification
Build spec for the v1.1 QOL batch. Requirements: `docs/BACKLOG.md` "v1.1 — QOL wishlist" (both subsections). Frozen UI contract this amends: `docs/UI-SPEC.md` (v2 + P3 §10 + B1 §11). Version map: `docs/PLAN.md`.
**Status:** 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):
4. `fillRank` (798–821) renders by default: the gauge (existing 804) + compact "76%" label. Change 805 from `data.tank.value+'/100'` to `data.tank.value+'%'` and move the label into the existing left gauge column or directly beside it (e.g. wrap gauge + `%` in the `.tankwrap`).
5. Guard the detailed parts behind the pref: build the `dlines` HTML (806–812) and the `dfoot` line (813–814) ONLY when the pref is `'on'`; otherwise empty both and keep `#drank` visible with just gauge + `%` (the block itself persists so the minimal state is still "gauge + %", matching the BACKLOG ask).
6. Toggle UI (findable by Glenn, harmless otherwise): add to the Trends "Actions" area (317–320) a second `.trow` exactly in the `setMode` style (1174–1179 + 1248–1249): "Why breakdown: on ⇄ off", both spans wired like `tOnd`/`tStr` to a new `setRubricDebug(m)` that POSTs `/api/prefs` and updates the sections-wide `section.prefs.rubric_debug` for the next `fillRank`. Persist per session: `updateSectionUI` (1157–1173) also stores `section.prefs` (already via body) so a re-loaded deck repaints correctly.
7. `fillRank` reads `((section&&section.prefs&&section.prefs.rubric_debug)==='on')`; also keep the existing on-the-quiet `/api/listing/` enrichment for list rows (816–819) guarded the same way — when off, don't fetch (saves a request; nothing is displayed).
**Edge cases:**
- Legacy (no `/api/section`) mode: `section` is null → pref off → minimal display; the API call for the toggle then target isn't the issue (legacy servers return 404 on `/api/prefs` POST; FE silently ignores failure, `setMode` precedent 1175–1176).
- `tank.value` null (unscored/deep rows): `fillRank` already hides the whole block (799–802) — unchanged.
- The percent must be the rounded tank value (0–100 clamp is already applied in `tankSVG` 455).
**Acceptance criteria:**
1. Dossier for a scored listing shows gauge + "NN%" and NO breakdown lines, NO footer line, on a fresh load (pref unset).
2. `POST /api/prefs {rubric_debug:'on'}` → 200 `{key,value}`; reload → same dossier shows breakdown lines + footer. `'off'` → they disappear. Invalid value → 400.
3. List rows and card face carry zero rubric text in both states (regression).
4. Pref survives server restart (SQLite `prefs`) and page reload; forge response carries it.
**Parent verification:**
1. TestClient: prefs happy path / 400 path; fresh-DB `/api/section` body includes `prefs.rubric_debug`.
2. Headless: dossier DOM assertions with the pref off and on (steps A2).
---
## Item 5 — Section counter fix (the 2/2 → 3/2 bug)
**Goal (BACKLOG "Counter bug"):** count a card as decided only if decided DURING this section (server-side per-item flag, from events stamped with this section_id — the source `section_counts` uses); clamp to total; no premature "hot"/complete state.
**Root cause (verified):** `section_items` (sections.py 417–425) LEFT-JOINs `swipes` so items carry `decision` = lifetime verdict. A second-look card enters its section with `decision='like'|'maybe'|'nope'` already set, but it has NOT been decided in this section (no event with `section_id` = this section). `read_section` progress (473) and the FE (`updateSectionUI` 1160–1164) count those as decided → fresh batch paints "2/2" before any touch; the next swipe increments optimistically (`commit` 684) → "3/2". Fix = use only events with `e.section_id = section.id` as the decided set, plus clamping at the FE.
**Plan:**
Server (`app/sections.py`):
1. Add a helper next to `section_counts`:
```sql
SELECT e.listing_id FROM swipe_events e
JOIN section_items si ON si.section_id = e.section_id
AND si.listing_id = e.listing_id
WHERE e.section_id = ? -- decisive: an event stamped in THIS section
```
as `section_decided_ids(conn, section) -> set(listing_id)`. (Filtering to `si.listing_id` membership keeps out-of-section stray events from a foreign section_id honest; the join is the same shape `section_counts` uses at 439–451.)
2. Add the per-item flag on the read path: `section_items` cannot see events per row without a second join; cheaper — after building `items` in `read_section` (471–472), set `item['decided_this_section'] = item['id'] in decided_ids`. (`sqlite3.Row` is read-only until cast; either materialize items as dicts there or attach the flag in `api.section_state` (447–457) where items are rebuilt as full payloads anyway — recompute `section_decided_ids` once in `section_state` and add `p['decided_this_section'] = p['id'] in ids` alongside `p['slot']` at 454–456. Prefer this: one read, no Row mutation.)
3. `read_section` progress (473): `decided = sum(1 for it in items if it['decided_this_section'])`. `maybe_exhaust` (457–466) **unchanged** — it must wait for real in-section verdicts too, which it now does automatically because `section_items` is ALSO the joined-swipes view it reads... No: `maybe_exhaust` reads `it['decision'] is not None`. Change line 462 to check `section_decided_ids(conn, section)` membership instead: `undecided = [it for it in items if it['id'] not in decided_ids]` and `if not undecided: close_section(...)`. Otherwise a returns-only section reports "complete" before a single strike. This is part of the same fix.
4. `section_counts` (428–454) already derives like/maybe/nope from `decision` (432–438) — same lifetime-verdict flaw in *report numbers*. Change its per-item loop to use the same decided-ids set: count an action for this section ONLY when an event with this `section_id` exists and matches (`SELECT e.listing_id, e.action ... WHERE e.section_id=?` joined to `section_items` the same way; the existing `restruck`/`wildcard_hits` queries at 439–451 already do exactly this join — mirror it). Note: this does not change what is *stored*, only what the report shows.
5. No DB writes anywhere in this item (flag is computed per read).
Frontend (`app/static/index.html`):
6. `updateSectionUI` (1157–1173) counting loop (1160–1163) → `if(local===undefined ? !!it.decided_this_section : local!=null) decided++`. (Local intent still wins while a swipe's POST is in flight — the optimistic window is seconds and cannot resurrect the 2/2 bug because it only ever reflects what the user just did in this session.)
7. Clamp everywhere `sectDecided` is incremented, so a stale/foreign path can never paint past the total: `commit` (684) → `sectDecided=Math.min(sectTotal, sectDecided+1)`; `undo` (706) already clamps the floor. `paintSect` (1140–1145) additionally renders `Math.min(sectDecided, sectTotal)`.
8. Hot state: `paintSect` toggles `hot` only when `sectDecided>=sectTotal` (1144, unchanged code) — with in-section counting this now fires only when the section truly has a verdict per card; `commit` keeps painting immediately after a full sweep (the server resync in `maybeRefill` 663–666 handles the exhausted flip and a rapid double-swipe resync on deck reload straightens the numbers, same as today).
**Edge cases:**
- **Undo of a swipe on a second look (return slot) that already has a lifetime verdict:** the DELETE restores the prior `swipes` row and clears no `section_id` event (out of scope — see Data safety), so the server's in-section decided count still contains that card until the section dies. The FE decrements optimistically (706), then a resync re-counts it. Net: `sectDecided` may show one more decided than the user feels, but NEVER past `sectTotal` thanks to the clamp, and never "hot" before the section is substantively done. This same artifact exists today, worse. Flagged in Open Questions (a true fix needs an event-deletion rule — data semantics, v1.2 territory).
- List/matches edits during a section: swipes posted WITHOUT `section_id` (setDecision from rows, 844–845) → no event with this section_id → a card in the section whose verdict was flipped from the list is (correctly) not counted as decided-in-section. The deck will simply re-offer it if still in buffer — acceptable and consistent.
- **Seeded legacy events have `section_id=NULL`** (db.py 111–117): they never count for any section — exactly the intended behaviour (they predate sections).
- `decided_this_section` must be present for ALL items in `section_state` payloads or the FE `local===undefined` branch misfires — set it unconditionally (not only for returns).
**Acceptance criteria:**
1. Compose a returns-only section (fresh pool 0 — reproducible by DELETE-ing all fresh rows' absence or staging a DB where every active listing has a swipe): `/api/section` `progress.decided == 0` even though every `item.decision` is non-null; every `item.decided_this_section == false`.
2. Post one swipe with `section_id`: progress 1/2; recount matches events; `paintSect` shows 1/2. Post the second: 2/2, `hot` class present, section flips to `report` on next read, report counts reflect the two verdicts (not lifetime).
3. With 2 items and a double-swipe (two commits before any resync): painted pill never exceeds `2/2` (clamp assertion).
4. A section that mixes new + return: decided counts only in-section events on both slots; a fresh item swiped from the LIST (no section_id) leaves `progress.decided` unchanged.
5. Full P3 regression: compose→strike→rounds→report→forge loop green; undo restores the previous pill value minus one.
**Parent verification:**
1. Selfcheck additions in `selfcheck.py` (new checks against a temp DB, same style as existing) + the staged-DB walk in acceptance 1–4.
2. Live-DB dry run: snapshot `swipe_events`, compute `section_decided_ids` for the current section, assert it matches the user-visible count story. No writes.
---
## Item 6 — Dry-catalog messaging
**Goal (BACKLOG "Dry-catalog messaging"):** whenever a composed/forged batch reports `new: 0`, surface "Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks" in the UI and label forge results accordingly, so the deck never silently drips 2 cards.
**Why it happens:** `NEW_POOL_SQL` (sections.py 40–44) is empty once every active listing has a swipe row; `compose_section` still forges from `RETURNS_SQL` — under v1 formula a few cards (night-of-symptom: 2). By design, but invisible.
**Plan:**
Server: the recipe already carries the number. `compose_section` returns `recipe: {'new': ..., 'return': ..., 'wildcard': ...}` (377–386) and `section_state` (446–471) already exposes `recipe` in `read_section` (479–480). **No server change required for the number itself.** Optional hardening: `forge` (490–527) appends to its `note` when the composed recipe has 0 new — e.g. `note += ' · catalog fully reviewed'` — so `section_state(note=...)` returns a labeled state (469–470); the FE below is the primary surface.
Frontend (index.html):
1. Shared renderer: a function `dryLabel(body)` → `body.recipe && body.recipe.new===0` (and `body.items.length>0`, i.e. there IS a section being served) → the BACKLOG sentence. Wire it into both hydrations:
- `updateSectionUI` (1157): show/hide a new caption node in the report block — reuse `#strictCap` pattern (223): add `<div class="rcap" id="dryCap">Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks</div>` to `#report` (212–224 area; display:none default), toggled by `dryLabel`. Report is where the deck lands after the batch, so the message persists exactly where Glenn looks.
- `btnForge` success path (1188–1198): when the forge response is dry → `$('forgeStatus').textContent = dry text` (instead of blanking at 1189–1190) and `dryCap` stays visible via the `updateSectionUI(body)` call (1197).
2. Fresh inventory restored mid-session (import lands): `loadSection` already refetches on reload/resync — `updateSectionUI` recomputes `dryLabel` every time; no timer needed.
3. Copy stays exactly the BACKLOG sentence (keep "08:30" hardcoded as today — same assumption the empty state makes).
**Edge cases:**
- Dry + zero items (nothing left at all): item 8's loud empty owns that state — `dryCap` must NOT show there (guard on `items.length>0`).
- `recipe` absent/legacy API: no label; `dryLabel` returns false (body check).
- Forge in `strict` mode can't be run anyway; the caption still paints from `GET /api/section` on load.
**Acceptance criteria:**
1. Forge with `recipe.new:0` and >0 items → forgeStatus shows the exact sentence; report shows `dryCap`; a subsequent full-import + reload clears it (recipe.new>0).
2. Non-dry forge → `dryCap` absent, forgeStatus behaves as before.
3. Deck behaviour unchanged otherwise (no new fetch loops; swipes unaffected).
**Parent verification:** staged-DB forge with an emptied fresh pool; assert the DOM states in A1/A2 + the existing section-loop selfcheck green.
---
## Item 7 — Fresh counter (header)
**Goal (BACKLOG "Fresh counter"):** extend `/api/stats` with `fresh` = active listings with no swipe (the `NEW_POOL_SQL` predicate); header shows "N fresh" by the existing live/new pill. Zero reads at a glance.
**Plan:**
Backend (`app/api.py`, `stats`, 234–264):
1. Add one query next to `live` (238–240):
```sql
SELECT COUNT(*) AS n FROM listings l
WHERE l.status='active'
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id)
```
— verbatim the `NEW_POOL_SQL` predicate (sections.py 40–44). Add `'fresh': fresh` to the return dict (254–262). Note the deliberate semantic: a like/nope/maybe row of ANY age removes freshness (fresh = never touched), which is the same predicate the pool uses. A maybe swiped 8+ days ago that the legacy deck re-admits is still not "fresh" — correct per definition.
2. No other backend change: `fresh` can differ from `live - decided` because `swipes` rows are never deleted by undos that keep events, and list edits create rows too — define fresh exactly by the predicate above and do not try to reconcile.
Frontend (index.html, `loadStats`, 1127–1136):
3. Change the pill line 1130 to `<b>${res.live}</b> live` + `· <b>${res.fresh}</b> fresh` (always shown; `` fresh `0` when the pool is dry — that's the point) + the existing new-count only when >0. Spacing stays in the one `.pill` (206). Match the UI-SPEC §10.1 fit rule (no layout shift — a pill string is fine).
4. Add the same fresh number to the list sub-line? No — keep `lsub` unchanged (1131); the header pill is the requested surface.
**Edge cases:**
- Fresh goes below 0/negative never (a COUNT).
- Badge `new` counts first-seen-since-visit, an orthogonal thing; both can coexist in the pill.
- Old API (missing `fresh`): `res.fresh` undefined → guard (`res.fresh??0` or `typeof res.fresh==='number'?res.fresh:0`), fallback renders no fresh segment.
**Acceptance criteria:**
1. Fresh staging DB: 5 actives, 3 with a swipes row, 2 not → `/api/stats.fresh == 2`; header pill text contains "2 fresh".
2. All-5 swiped → `fresh:0`; pill shows "0 fresh" (not hidden).
3. Swiping one card → next `loadStats` shows 4 fresh (server read reflects the upsert; FE doesn't optimistically touch the stats pill — the pill updates on next stats fetch, same as today).
**Parent verification:** TestClient stats assertions on the staged DB + headless DOM text check after a swipe.
---
## Item 8 — Loud empty state ("No more new bikes!")
**Goal (BACKLOG "Empty state, loud"):** when the deck can serve nothing at all — no fresh, no eligible second looks, no wildcards — show a prominent "No more new bikes!" keeping the "next import 08:30" line. Today's `.empty` ("You're all caught up") is unreachable in section mode (only `legacyMode` shows it — `maybeRefill` 649–654) and the report card masks exhaustion.
**Plan:**
Server (`app/sections.py` + `app/api.py`):
1. New pool helper in `sections.py` (mirrors the composer's candidate queries, minus the per-section exclusions — "can the forge serve anything?"):
```python
def pool_counts(conn):
fresh = count(NEW_POOL_SQL)
rets = count(RETURNS_SQL)
wilds = count(WILDCARD_SQL)
```
(`RETURNS_SQL` is the composer's own eligibility predicate — resting/one-shot/last-two rules included; the last-two exclusion inside `compose_section` (274–278) is skipped here so the answer is about *stock*, not *this batch*; document that choice.)
2. `section_state` (446–471) gains `data['pool'] = secmod.pool_counts(conn)` (fresh/returns/wildcards). Every section GET + forge response carries it. No write.
Frontend (index.html):
3. **Loud empty element:** new `#empty2` beside `#report` in `#stage` (after 212), styled prominently (CSS near 95–97): big title 22px/800, larger panel (min-height ~220px), accent border, centered; copy:
```
<b>No more new bikes!</b>
New bikes arrive nightly — next import 08:30.
(↺ still brings the last card back — keep the line only if it remains true after item 1.)
```
4. **Reachability (the fix):**
- `maybeRefill` (649–674) section-mode branch: BEFORE the `sectDecided>=sectTotal` report check, if `section.pool` exists and `!buffer.length&&!pile.length` and `section.pool.fresh===0 && section.pool.returns===0 && section.pool.wildcards===0` → show `#empty2`, hide `#report` and `#empty`.
- `btnForge` success path (1188–1206): after `updateSectionUI`, run the same check against the new body — if the forge returned zero items and `pool` is all-zero, show `#empty2` and don't leave the report's Forge button inviting a wasted agent run (hide the button or keep row; show empty2 above it).
- Report screen stays the state for "section finished, more remains" (pool non-zero) — unchanged.
5. Keep `.empty` (legacy mode) as-is; `#empty2` exists only for section mode. Undo (`undo()` 702–720) removes `#empty2`'s `.on` class exactly like it clears `.empty`/`.report` (718–719).
**Edge cases:**
- The last-two-sections exclusion: a listing only appears in the last two sections' worth of items — pool query ignores that; near-total exhaustion could still leave 1–2 stale-but-served listings? No: `section_items` only blocks `RETURNS_SQL` re-offer, not `NEW`/`WILDCARD`; if pool counts say zero, compose genuinely returns zero cards (same SQLs). The tiny edge where pool>0 but compose emits 0 due to last-two+wanted-order interplay is handled by the `items.length===0` fallback: empty2 shows when a forge returns no cards AND pool is all-zero; if forge returns 0 items with pool>0 (should not happen), show empty2 with the dry line instead and log — implementer's guard, not spec.
- Import lands at 08:30 → first visit reloads sections: pool.fresh>0 → empty2 clears automatically on the next `loadSection`. No push, same as all state today.
**Acceptance criteria:**
1. Staged DB with zero fresh, zero eligible returns, zero wildcards: `/api/section` body includes `pool` all-zero and `items` empty; FE shows `#empty2.on` with the exact title "No more new bikes!" and the 08:30 line; `#report` NOT shown.
2. `pool.returns>0` (dry but alive) → report card shown, `dryCap` (item 6) active, NO empty2.
3. Undo restores the last card from empty2 → empty2 hides (718–719 parity).
4. Report + forge loops still work when pool is non-zero (regression).
**Parent verification:** staged-DB headless walk A1–A4; plus the nightly-import path simulated by inserting one fresh listing + reload → normal deck.
---
## Item 9 — Batch formula v2 (compose rewrite)
**This item AMENDS the frozen P3 rule "≤5 returns per section"** (`RANKING.md:29`, `DATA-MODEL.md:165`, `sections.py` docstring 2–17 — all say ≤5 / max 5). Say so in the commit notes. New rule, Glenn's words (BACKLOG "Batch formula v2"):
- Every batch targets **25 cards**.
- Fresh first (up to 23 slots); **≥2 second looks per batch whenever eligible stock exists** *(DEFAULT PENDING GLENN'S CONFIRMATION — the alternative, pure-fresh batches on rich days with second looks as fill only, is below)*; when fresh < 23, second looks FILL the remainder up to 25 (his example: 5 fresh + 20 second looks).
- Leftover fresh rolls to the next batch.
- Limits kept: ≤2 re-noped returns per batch; family cap relaxes for fill-returns when needed (spec below); returns stay one-shot.
- Fresh exhausted → second-looks-only batches; returns exhausted → fresh-only; both → item 8's loud empty state (forges may return 0 items — already legal: `compose_section` inserts whatever it picks, 388–401).
### Allocation (the exact slot math, spelling out the 23/25/2 interplay)
Wildcards keep their 2 slots (BACKLOG leaves them standing; `WILDCARDS=2` 31). Resolve the equations in this order:
```
want_ret = 2 if len(ret_cands) >= 2 else len(ret_cands) # the >=2 guarantee
(0 when ret_cands empty -> pure-fresh batch)
want_wild = 2 if usable wildcard candidates exist else fewer
fresh_cap = 23
want_fresh = size(25) - want_ret - want_wild
clamped to min(len(new_cands), fresh_cap)
```
1. Returns and wildcards take their reserved slots FIRST (returns before wildcards before fresh, so the guarantee can never be crowded out by fresh).
2. Fresh fills the remainder: `want_fresh = 25 - want_ret - want_wild` (max 23 when want_ret=2&wild=0? No — min is 21 on a rich day with 2+2 reserved). Then, **fill slack**: if `len(ret_cands)` has more offers and `len(picked) < 25`, add returns in role order (maybe > like > nope, points DESC — existing key 305–310) until 25 or the pool dries.
The resulting shapes on Glenn's scenarios:
- **Rich day, returns exist:** 25 cards = up to 23 fresh + ≥2 second looks + 2 wildcards. With 2 wildcards AND the 2-return guarantee, a full deck actually reads 21 fresh / 2 returns / 2 wildcards. (This is the one arithmetic consequence of "≥2 always" + "wildcards keep 2" + "target 25". The veto alternative below restores 23 fresh + ≤2.)
- **His dry-day example:** 5 fresh + fill returns up to total 25 → 5 fresh + up to 18 returns + 2 wildcards (or 20 returns + 0 wildcards when the wildcard pool is dry — his literal "5 fresh + 20 second looks"). Fractional pools (e.g. 5 fresh + 12 returns) produce a short section (<25) — correct; nothing is invented to pad.
- **Fresh = 0:** returns-only section(s) up to 25 cards each until the ~one-shot pool (≈0–30 remaining tonight per BACKLOG) dries — burns fast; BACKLOG's noted consequence is accepted in v1.1: after that, only nightly imports feed the deck.
- **Returns = 0:** fresh 25-card batches again (plus wildcards).
- **Both 0:** the loud empty state (item 8). Forge then legitimately returns a zero-item composed row; the FE renders empty2 — the report screen's Forge button should also stop being the only thing on screen (item 8 handles the mask).
### Re-nope + family-cap rules for fill-returns
- Keep ≤2 nope-verdict returns per batch (`MAX_RETURN_NOPES` 30; the "surprising" gate at 317–321 stays for *reserved* nope slots only).
- **Fill-returns relax the family cap:** when selected as fill and the choice is between skipping the cap and landing a short deck, raise the per-family limit for `return`-slot items from 4 to 8 (fill-returns only; fresh picks and reserved returns keep 4). Deterministic: fill sweeps role order; a candidate that would exceed 8 is skipped; <=4 is always preferred pass 1 (two-pass fill: first under cap 4, then under cap 8).
- Returns stay one-shot (`RETURNS_SQL`'s `NOT EXISTS section_items slot='return'` 53–54) and re-noped items rest forever (50–52). **No change to those predicates.**
- Prefer not to burn the whole one-shot pool in two batches? The BACKLOG explicitly notes the pool ~30 tonight and accepts the burn; no v1.1 policy change. **If Glenn instead wants a drip feed, that is the veto line in Open Questions.**
### Compose pseudocode (replaces the flavour of `compose_section` at 259–404; structure kept)
```
compose_section(conn, engine, size=DEFAULT_SIZE, ...):
now, rows_by_id, last_two # unchanged (266-271)
new_cands = NEW_POOL_SQL - last_two # unchanged (273-274)
ret_cands = RETURNS_SQL - last_two # unchanged (275-276)
wild_cands = WILDCARD_SQL - last_two - excluded_fams # unchanged (277-289)
fam_count, picked_ids # unchanged (291-292)
top3 = pick_new(...) ; top_fams/top_bands # unchanged (294-298) — feeds wildcard
# preference; pure pick, no commit
# 1) reserved returns: min(2 + shortfall_unknown, len(ret_cands)), role order
reserved_rets = ret_cands sorted by role_order/points; take until want_ret_nopes-rule or 2-max;
always count the 2 GUARANTEED slots (skipping only ineligible nopes)
# 2) wildcards: n_wilds = 2 (existing novel/preferred pick 328-358, unchanged)
# 3) fresh: want_fresh = size - len(rets_reserved) - n_wilds, capped 23; pick_new (360-362)
# 4) fill returns: while len(rets)<size-n_wilds-len(picked_new) and ret_cands remain:
next in role order; nope cap (<=2 total incl. reserved);
family rescue pass at cap 8 for fill only
# 5) repair bands on new slots only (unchanged 367-375)
# 6) pinned order new -> returns -> wildcards (unchanged 364-365); recipe/JSON/inserts
unchanged (377-404) with recipe.new/return/wildcard the real counts
```
Deliberate simplifications vs a full re-derivation:
- `pick_new`'s family cap 4, band repair, wildcard novelty, excluded families, LAST_TWO exclusion: **all unchanged** (160–178, 181–256, 284–289, 329–358).
- The `wanted_rets` line 304 dies; the reserved-returns stage replaces it. Constants at 28–33: `MAX_RETURNS` is deleted/renamed (fill is bounded by size), `MAX_RETURN_NOPES` stays 2, add `FRESH_CAP=23` and `MIN_RETURNS=2` (pending Glenn's confirmation — the DEFAULT in this spec).
- `read_section`/`section_state`/FE need **zero changes for the formula itself** — a section of any size ≤25 renders as today (progress pill counts `body.progress.total`).
Edge cases:
- Fewer than 2 eligible returns but ≥1: guarantee degrades gracefully (1 return + fill from... nothing else exists, that's the pool) — never fake it.
- `size` stays the clamp (`size=25` default; the forge endpoint 492–495 accepts 1..100 — body `size` unchanged in the FE which always sends 25/`{}`).
- Batch smaller than 25 mid-drain: the FE, report, and progress all already key off `progress.total`; nothing to cap client-side.
The veto alternative (record for Glenn):
- **≥2-always** (this spec's DEFAULT): every batch with any eligible return carries ≥2 second looks, even on a rich fresh day (21/2/2 shape). Keeps Glenn's "min 2" literally true.
- **Pure-fresh-on-rich-days:** reserve 2 wildcards + up to 23 fresh; returns used ONLY as fill when fresh < 23 (rich day → 23 fresh + 2 wildcards + 0 returns). This obeys "fresh first, up to 23" literally but drops the "min 2" guarantee on the days he has the most fresh to see. One boolean switch in the allocator (`min_ret = 2 if Glenn confirms else 0`); nothing else moves.
**Acceptance criteria (staged DBs, deterministic pools):**
1. Pool 30 fresh / 10 eligible returns / wildcards available → forge composes 25: fresh + wildcard + return counts match the ≥2 + fill math; `recipe` JSON exact; `progress.total == 25`.
2. Pool 5 fresh / 20 eligible returns / wildcards available → 25 cards, 5 fresh, returns fill to 20 (with 2 wildcards → 18; without → 20); his example honoured modulo wildcards.
3. Pool 23 fresh / 5 returns / wildcards available → fresh 21 + returns 2 + wildcards 2 (the ≥2 formula) — and this exact result is the sentence Glenn must sign off (Open Questions).
4. Pool 0 fresh / 12 returns → 12-card returns-only section; ≤2 nopes present; no item repeated from either of the previous two sections (LAST_TWO).
5. Pool 40 fresh / 0 returns → 25-card fresh batch, order points DESC, leftover 15 fresh roll and appear in the NEXT forge (compose twice, zero overlap, no re-offer).
6. Pool 0/0/0 → composed section with `items=[]`, FE shows item 8's empty2.
7. Family cap: a fill-heavy batch with a dominant family raises its return-slot count only to ≤8 and never violates ≤4 for fresh/reserved; band-repair still reaches 3 bands when possible.
8. Re-nope gate: exactly ≤2 nope-verdict returns per batch; a rested item (round≥2 nope) never appears.
**Parent verification:**
1. `selfcheck.py` additions driving `compose_section` against generated temp DBs for A1–A8 (the module is pure Python + SQLite and already selfcheck-tested this way for P3).
2. Full P3 loop regression on a rich and a dry DB: compose → strikes → rounds → report → forge → counts; trends numbers unchanged in shape.
---
## Item 10 — Second Look band pops
**Goal (BACKLOG "Second Look band pops"):** `.band.sl` is near-invisible today — re-tint to a **violet accent** (bold fill + border), on card face + info view. Wildcard stays amber for contrast. *(DEFAULT violet pending Glenn's nod — one-line re-tint if he prefers another hue.)*
**Current:** `.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)}` (141) — white ~10% on dark, matches the P3 §10.3 second-look recipe (142). The band appears where `bandHTML` renders it: card face (560) and — verify — the dossier does NOT currently render a band at all: `openDetail` (768–796) has no band markup. "Info view" for the pill therefore needs a small addition, see 3 below.
**Plan (CSS + one markup line):**
1. Replace line 141:
```
.band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800}
```
(violet #7c3aed family; text near-white-violet `#c4b5fd`; bold fill + crisp border). Uppercase "SECOND LOOK" label (486) reads instantly against this.
2. `.band.wild` (140) untouched — amber stays the contrast.
3. Dossier/info view: add the same band to the dossier body so "info view" carries it. In `openDetail`, after the chips (778), insert the band when `data.slot==='return'|'wildcard'`:
- simplest: an anchor div in `#dbody` (246, e.g. `<div class="band" style="position:static;display:inline-block;margin-top:6px" id="dband"></div>` — `display:none` default) filled by `openDetail` with `bandHTML(data)` logic (reuse the string builder 482–488; note `.band` is `position:absolute` by default, so give the dossier variant the static class or a dedicated `.dband` style with the same palette, per UI-SPEC palette text below).
- `.card.hasSegs .band{top:22px}` (188) already handles the card-face coexistence with segment bars.
4. No other surface: list rows don't carry bands today (not required).
**Edge cases:**
- Wildcard + segments + violet coexistence on the card: bands at 22px (188), segments at 8px (185) — unchanged geometry.
- The band wraps up to 2 lines (139 `max-width:78%`) — violet fill still legible.
- Contrast check: `#a78bfa` border on `#0b0b0e`-family backgrounds ≈ 7:1; text `#c4b5fd` on the violet-22% fill ≈ 6.5:1 — passes WCAG AA while staying dark-mode.
**Acceptance criteria:**
1. Card with `slot:'return'` renders a violet band; computed styles match the new palette (assert colour/background/border strings); wildcard card still amber.
2. Dossier opened on a return card shows the same violet band; on a new-slot card, no band.
3. Segments + band layout regression green (B1 walk).
**Parent verification:** computed-style assertions + two screenshots (card, dossier) over the tailnet or headless.
---
## Data safety
v1.1 makes **ZERO schema migrations, zero data mutations, no reset.** Every DATA-MODEL table and every existing row survives untouched; Glenn's ledger (swipes/events) is not read-modified in any new way.
Writes that happen in v1.1 are only the writes that already happen today, unchanged:
- `POST /api/swipe` (upsert `swipes` + `append_event`), `DELETE /api/swipe`, `POST /api/prefs` (one new key: `rubric_debug` — a new `prefs` row is the ONLY new write in v1.1, and it is a settings row, not data).
- Compose/forge writes a new `sections` + `section_items` row set — the normal P3 flow, identical write shape.
New READS only:
- `/api/stats` +1 COUNT (fresh).
- `GET /api/section` / forge response +2 small derived reads: `decided_this_section` flags (from `swipe_events` WHERE section_id — read-only), `pool` counts (the three existing candidate SQLs, counted).
- `/api/section` payload +1 prefs read (`rubric_debug`).
No new tables, no new columns, no imports, no re-seeding (the `swipe_events` seed in `db.init_db`, db.py 109–117, runs only when empty and is not re-run by anything here, and v1.1 ships no changes to `schema.sql` or `P3_SCHEMA`).
Rollback: revert the commit; the only residue is the `rubric_debug` pref row, harmless if left.
---
## Open Questions (blocking decisions → Glenn / Alex)
1. **Item 9 — the "≥2 second looks" guarantee on rich days (confirmed needed to build).** Default specced: every batch carries ≥2 returns whenever eligible stock exists → a full 25-deck on a rich day reads **21 fresh + 2 second looks + 2 wildcards** (fresh capped at 23 = 25-2, and the two wildcard slots come out of fresh). Alternative for Glenn to veto: pure-fresh batches on rich days (23 fresh + 2 wildcards + 0 returns; returns fill only when fresh < 23). One switch in the allocator; everything else identical.
2. **Item 10 — violet nod.** Default `#7c3aed`/`#a78bfa`/`#c4b5fd` (see amendment text). Trivial to re-tint if he picks another hue.
3. **Item 1 — Backspace vs ArrowDown for undo.** BACKLOG names ↓; the live v1.0 handler already uses Backspace. Spec keeps BOTH (↓ primary per Glenn's words, Backspace retained as the muscle-memory alias). Confirm or drop Backspace.
4. **Item 2 — dossier opened from a deck card, swiped via its own buttons:** `openDetail` from the deck currently allows committing the top card from inside the dossier (`dAction` 838–848). Under info-first flow this stays. If Glenn wants the sheet to be read-only from the deck, that's a v1.2 behaviour change — not in this spec.
5. **Item 5 — undo-on-a-return residual:** un-deciding a second look that carries a lifetime verdict restores the swipes row but leaves the `section_id`-stamped event; the in-section count re-includes it on resync (clamped so it can never exceed the total, but it can sit AT the total with one undone card). A clean fix needs an event-deletion/„undo" semantics decision (v1.2 territory alongside analytics); accepted as-is for v1.1 and stated explicitly so the parent doesn't chase it as a leak.
6. **Item 9 — one-shot return-pool burn:** with fill-to-25 the remaining eligible second looks (~≤30 as of 2026-11-12) drain within a batch or two. Accepted per BACKLOG's noted consequence; the drip-vs-burn preference belongs to Glenn before build if he cares.
7. **Item 8 — copy retention:** "↺ brings the last card back" line kept only if still true after item 1's undo mapping (it is — undo exists either way). Any copy tweaks Glenn wants on "No more new bikes!" (exclamation, wording) noted before build.
8. **Found while verifying (pre-existing, NOT v1.1 scope — flagged):** `maybe_exhaust` uses lifetime `decision` (sections.py 462) and the FE send of `photo_idx` is conditional on `nPhotos>1` (683) so single-photo cards don't update rotation; both are covered or neutral after item 5, no action. Also verified: the live "keyboard arrows" feature in v1.0 was live-deployed ahead of the BACKLOG as part of the P2 build (DESIGN.md:63, index.html 1263) — v1.1 formalizes and extends it rather than introducing it.
---
## Proposed UI-SPEC amendments (verbatim replacement text — DO NOT apply during the docs phase)
### A) §11.4 "Doors" — replace the whole subsection's body paragraph
> ### 11.4 Doors (supersedes the old tap→dossier)
>
> - ⓘ (top-right of card) → dossier (unchanged). The card face now owns: **sides = flip, center = dossier (bike data first)**; the "tap anywhere → dossier" gesture is RETIRED and the B1 center-tap→fullscreen gesture is RETIRED in favour of it.
> - Tapping the photo in the dossier hero opens the fullscreen image viewer (§11.3). **Viewer close (✕, pull-down, or Esc) returns to the dossier; the next Esc returns to the deck.** The dossier stays reachable from ⓘ and from list rows (§3/§4 unchanged).
### B) §11.3 "Fullscreen viewer" — amend the entry sentence
> - Tap the CENTER third → fullscreen viewer opens on the photo currently shown.
>
> becomes:
>
> - Tap the photo in the dossier hero → fullscreen viewer opens (starts at photo 0). Direct card→viewer is retired (see §11.4); the viewer is reached from the dossier.
### C) §2 card geometry — photo fit: add a new bullet after "Photo full-bleed"
> - Photo fit is **contain**: the whole photo is shown uncropped over a soft blurred backdrop of the same image (option C). The backdrop is decorative (blurred, dimmed, overflow-clipped); the sharp photo sits on top with a gentle shadow. The fullscreen viewer already fits; list thumbs stay `cover` (56px).
### D) §10.3 Card bands — replace the SECOND LOOK palette line
> - **SECOND LOOK — you {liked|maybed|noped} this** — color `rgba(255,255,255,.85)`; bg `rgba(255,255,255,.10)`; border `1px solid rgba(255,255,255,.22)`.
>
> becomes:
>
> - **SECOND LOOK — you {liked|maybed|noped} this** — color `#c4b5fd`; bg `rgba(124,58,237,.22)`; border `1px solid rgba(167,139,250,.65)`; font-weight 800. Wildcard stays amber (`#ffb43a` family values unchanged) for contrast. The same band renders in the dossier ("info view") when the listing's slot is `return` or `wildcard`.
### E) §10.5 Dossier "Why this rank" — add a debug-visibility paragraph
> - Hidden-by-default detail: the breakdown lines and the "from N strikes" footer render only when `prefs['rubric_debug'] === 'on'` (default `'off'`). With the pref off the block shows **the tank gauge + "{points}%"** only — the minimal rubric display. The toggle lives in the Trends "Actions" area (why-breakdown on ⇄ off, same control style as the forge-mode toggle).
### F) §10.1 Sections — replace the composition sentence within the section-progress bullet
> - Section composition (engine side — RANKING.md): ≤5 SECOND LOOK cards + 2 WILDCARD cards + the remainder new.
>
> becomes:
>
> - Section composition (engine side — RANKING.md): every batch targets 25 cards = fresh first (up to 23 slots) + ≥2 SECOND LOOK cards whenever eligible stock exists + 2 WILDCARD cards; when fresh < 23, second looks fill the remainder up to 25. Re-nope returns stay ≤2 per batch; fresh shortfalls are never padded.
### G) §2 Empty state + §10.2 report — add the loud exhausted state
> - §2's quiet "You're all caught up." remains only for legacy (pre-section) mode. In section mode, when the deck can serve nothing at all — no fresh listings, no eligible second looks, no wildcards — the deck shows a prominent **"No more new bikes!"** panel with "New bikes arrive nightly — next import 08:30." and, when true, "↺ brings the last card back." This panel supersedes the report card in that state only; the report card remains the end-of-section screen whenever more cards can still be served.
### H) §1 Globals — header pill
> - Header pill shows `{live} live · {fresh} fresh` always, and `· {new} new` only when new > 0. `fresh` = active listings with no swipe (the deck's new-pool predicate).
### I) §10.2 Forge — dry-catalog note
> - When a forged batch reports `new: 0` the report shows: "Catalog fully reviewed — fresh listings arrive with the next 08:30 import; showing second looks." and the forge reads the same note in its status line.
(Collecting all in one place per protocol — the file itself is not touched in this phase.)
---
## Verification plan (summary the parent will re-run after the build)
1. Fresh-clone checkout of the v1.1 build, temp DB copies only (never the live DB): existing selfcheck + new v1.1 checks, all green.
2. Per item with the acceptance criteria above: 1 (keyboard: key dispatch, guards, scroll, Esc stack) · 2 (tap-flow walk + B1 §11.7 suite) · 3 (computed styles + screenshots vs demo panel C) · 4 (prefs API 200/400, dossier DOM with pref off/on) · 5 (staged returns-only section: 0/2 → 1/2 → 2/2 → report, clamp, list-edit neutrality) · 6 (dry forge → caption + status line) · 7 (stats fresh count + pill text) · 8 (0/0/0 pool → loud empty2, report mask gone) · 9 (A1–A8 compose matrix on generated pools) · 10 (violet band computed styles, wildcard unchanged, segments coexistence).
3. Full section loop regression: compose → strikes → rounds → report → forge on a rich DB and a dry DB; trends, deck feed, undos unchanged in shape.
4. Zero-mutation audit: diff against `381b6c8` shows no `schema.sql`/`P3_SCHEMA`/migration writes; `rubric_debug` pref row is the only new write.
5. Glenn phone pass (human): keyboard on MacBook; center-tap data-first on phone; violet band visible; fresh counter reads; empty2 appears when shown a dry copy (staged), loud and clear.