docs: freeze Phase 2 contract — UI-SPEC v2 (approved mockup A, stack engine, deltas) + DATA-MODEL P2 addendum (migrations, cursor pagination, media mirror) + frozen mockup reference

This commit is contained in:
AlexanderRozhenko
2026-11-10 21:11:27 -04:00
parent d8510c184a
commit 9bc6948861
5 changed files with 550 additions and 41 deletions

View File

@@ -93,3 +93,15 @@ LIMIT :deck_size;
- **Backup:** nightly `sqlite3 .backup` to `data/backup/` immediately after import (house rule; the DB is the app's only mutable state).
- **Prune:** mirrored image files for rows `gone` >30 days; rows themselves are never auto-deleted in v1.
- **VACUUM:** optional; the DB is KV-sized (a few thousand rows).
## P2 additions — frozen 2026-11-10 (production build)
- **New columns** (guarded, idempotent migrations in `db.init_db`: `ALTER TABLE ... ADD COLUMN` when missing):
- `listings.year INTEGER` — parsed from title at import (`rubric.year_from_title`); NULL when absent.
- `listings.seller_type TEXT` — `'dealer'` | `'private'` | NULL. Filled ONLY from a real page signal (the hunt image backfill / a later enrichment pass); never inferred from titles.
- `listings.extras TEXT` — JSON array of rubric-matched extra labels (`MVI/cert, whitewalls, spokes, windshield, bags, ABS`); UI displays at most 3.
- **Importer:** fills the three new fields; all P1 behavior unchanged (gates, upsert, dedupe, gone-marking, idempotence, counts 640/560/80).
- **Deck feed + cursor pagination (production):** semantics unchanged (no swipe, or `maybe` older than 7 days; `active` only); order `score DESC, first_seen DESC, id DESC`. Cursor = opaque `"<score>|<first_seen>|<id>"` of the last returned row; next page = strictly-after in that tuple order (OR-chain on the three keys). `GET /api/deck?limit&cursor` → `{items, next_cursor, remaining}`. Cursor stays correct while swipes delete rows underneath it.
- **Badge:** `prefs['badge_seen_at']` (ISO). `new` = active rows with `first_seen` > COALESCE(badge_seen_at,'0'); `POST /api/badge/seen` stamps now.
- **Media mirror:** files at `data/img/{listing_id}.webp`; `GET /img/{id}` → file (200) → `img_url` (302) → 404 (UI placeholder). Downloads via `app/imgfetch.py` (polite: ~1 req/s, skip-existing, resumable). `img_url` enrichment happens upstream in the hunt state (`motorcycle-hunt-img.py`; atomic writes; rescans keep curated imgs) and lands via import.
- **Dedupe refinement (stretch, targeted):** for cross-province groups with >2 members, replace collapse-to-one with `min(ns,nb)` completeness pairing; surplus siblings stay active. Expected dup ≈ 77 (vs 80). Verify counts and report.

View File

@@ -56,7 +56,7 @@ The nightly import is triggered by its own Hermes cron job — `bike-tinder-impo
- The importer pulls each new listing's card image once into `data/img/` (~100 KB avg; ≈64 MB the first day, then ~20–30 MB/month). Cards reference the local mirror; a missing file falls back to a 302 hotlink of the source CDN (verified token-free and size-rewritable).
- Why: ad removal eventually kills CDN URLs (unverifiable until it happens — the mirror makes it a non-issue), phone-side speed, and "gone" cards stay viewable.
- Bootstrap: today's state carries `img` on only ~3% of rows — the first import does a one-time headless re-sweep (8 pages/province via the vendored parser — 16 fetches, not 640 detail pages) to harvest card images. A placeholder renders for any listing still without a photo; it never holds up the deck. Mirrored files for rows gone >30 days are pruned.
- Bootstrap (frozen 2026-11-10, P2): image URLs are enriched into the hunt state by the hunt toolchain's `motorcycle-hunt-img.py` — one detail-page fetch per listing, polite pacing; rescans keep curated imgs — then the importer carries them into the DB. The app mirrors files via `app/imgfetch.py` → `data/img/{id}.webp`; `/img/{id}` serves the file, else 302 to the source CDN, else the UI placeholder. Backfill scope at freeze: all 620 Kijiji rows missing an image. A placeholder renders for any listing still without a photo; it never holds up the deck. Mirrored files for rows gone >30 days are pruned.
## Frontend
@@ -93,7 +93,7 @@ Single user; **no auth in v1** — the app is reached only through the stack's o
## Repo layout (target)
```
app/ importer.py · api.py · static/{index.html,app.js,app.css,manifest.json,sw.js,icons}
app/ importer.py · api.py · imgfetch.py · static/{index.html (single-file app), manifest.json, sw.js, icons/}
deploy/ docker-compose.yml (app + Tailscale sidecar) · Dockerfile · podman-stack-bike-tinder.service
reference/ copies of the hunt modules the app vendors from
data/ runtime state (gitignored)

View File

@@ -34,10 +34,12 @@ A single-source, single-user, tailnet-only Tinder-style PWA on the Rotarran: nig
## Phase 2 — The swipe app (core)
- 2–3 UI mock variants → Glenn picks one
- API (queue, swipe, edits, filters) + PWA frontend: card stack, drag physics, Like / Nope / Maybe, match moment
- Full list view with inline edit; basic filters
- Proof: runs locally, exercised on Glenn's phone over the tailnet dev port
**Status: in build (Torres, 2026-11-10) on `dev`.** Contract frozen: `UI-SPEC.md` v2 + `reference/mockup-001-fidelity.html` (Variant A "Pure", approved by Glenn; 5-deep live stack, no-lock commit, dossier sheet). Image backfill underway at freeze (hunt `motorcycle-hunt-img.py`, 620 listings) + app-side mirror (`app/imgfetch.py`).
- [x] 2–3 UI mock variants → Glenn picked **Variant A "Pure"** (approved 2026-11-10 after two fix rounds)
- [ ] API (deck cursor, swipe upsert/clear, list + filters, stats, media, import) + PWA frontend (faithful mockup port: stack engine, drag, Like / Nope / Maybe, dossier, empty/undo) — no match overlay (removed by Glenn's call)
- [ ] Full list view with inline edit; basic filters (decision + sort + search)
- [ ] Proof: runs locally on :9453; exercised on Glenn's phone over the tailnet
- **Exit criteria:** Glenn can swipe a real deck on his phone and edit results in the list.
## Phase 3 — Personalization

View File

@@ -1,46 +1,112 @@
# UI-SPEC — Bike-Tinder
Fidelity goal (Glenn's words): "as close to Tinder as possible." Photo-first, dark, big gestures. Everything below is the v1 spec; 2–3 visual mock variants get built at Phase 2 start and Glenn picks one before wiring proceeds.
**FROZEN v2 · 2026-11-10.** The post-mockup contract for the Phase 2 production build. Two normative artifacts, in tandem:
## Screens
- **`reference/mockup-001-fidelity.html`** — Variant A "Pure", chosen and iterated to approval by Glenn (2026-11-10). **Normative for FEEL.** The production frontend is a faithful port: keep its DOM, CSS, and motion code verbatim; replace data sources and add only the production deltas (§7).
- **This document** — normative for **metrics, behaviors, invariants**, and the production deltas.
### 1. Deck (default)
- Full-height card stack: the top card is draggable; the next card peeks, scaled (~95%) behind it.
- Scope (decided 2026-11-10): every active bike appears in the deck eventually — ranking reorders, never hides.
- Card anatomy: photo fills the card; bottom gradient overlay carries: year + make + model (title), price (large), sub-line: km · place · first seen. Small chips row when signals exist (e.g. "MVI", "whitewalls", "bags") — only derived from real listing data, never invented.
- Stamps: during drag, "LIKE" (green) / "NOPE" (red) / "MAYBE" (amber) stamps fade in over the card proportional to horizontal drag; rotation up to ±12° follows the finger.
- Commit: release past ~35% width (or a flick with velocity) → the card flies off; otherwise it springs back.
- Buttons (bottom, thumb zone): ↺ undo · ✕ nope · ★ maybe · ♥ like — 56px circles; the like button carries the accent gradient.
- Match moment: committing a Like plays a brief "It's a match!" overlay (bike thumbnail + flame accent, ~1.2s), then advances. No sound in v1 (a muteable chime could come later).
- Tap (not drag) on the card = detail sheet: full photo, all fields, description snippet (if crawled), "Open listing" link (source URL), and the three action buttons.
- Empty state: "You're all caught up — new bikes arrive nightly" (with the next import time). A fallback image is shown for listings without a photo — no card is ever blank.
(Fidelity goal, Glenn's words: "as close to Tinder as possible." Photo-first, dark, big gestures — and decisively: **speed; nothing may make the user wait.**)
### 2. List (all bikes)
- The full catalog. Each row: thumbnail, title, price, km, place, and a 4-state control: ♥ / ★ / ✕ / — (no call yet). Tapping sets or edits the call — edits are first-class (Glenn asked for exactly this).
- Filters (persisted): price min/max, km max, year min, text search; clear-all chip. Sort: score | price ↑↓ | newest | km.
- Context line: "n new since your last visit".
## 1. Globals
### 3. Matches (liked)
- Two segments: Liked ("matches") and Maybe. Same row style as List; opens the detail sheet. This is the actual shortlist for buying.
- Palette: `--bg:#0b0b0e; --card:#141419; --text:#fff; --mut:rgba(255,255,255,.62); --accent1:#ff7854; --accent2:#fd267d; --like:#46d369; --nope:#fd4d5c; --maybe:#ffb43a`
- Type: system stack (`-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif`).
- Frame: full-viewport flex column, `body{overflow:hidden}` — header 50px, stage flex:1, actions 86px, nav 68px.
- Desktop: `.app` max-width 520px, centered. Safe areas: header `padding-top:env(safe-area-inset-top)`; nav `padding-bottom:calc(6px + env(safe-area-inset-bottom))`; dossier actions keep their existing safe-area padding.
- **Z-order law (global):** cards 6–10 (local, inside the stage) < flight 12 < list/stub 50 < nav 60 < dback 69 < detail 70. Cards must NEVER paint above list/stub/nav — a regression here freezes every other screen.
### 4. Trends (Phase 3)
- Swipe stats (counts, like rate), learned lean summary ("you lean: V-twins · <$6k · low-km · NS"), retrain status, and the current top model weights rendered as plain language.
## 2. Deck screen (default)
## Visual language
- Dark theme, near-black background, one accent gradient (flame → red/pink), white primary text, ~60% secondary.
- Cards: ~16px radius, full-bleed photo, subtle shadow; stamps are 3px outline uppercase, rotated ∓12°.
- Type: system stack (SF on iPhone); title 22–24px bold; price 20px semibold.
- Bottom tab bar: Deck · List · Matches · Trends (icons + labels), safe-area aware.
### 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}`
- 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)).
- 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.
- 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.
## PWA specifics
- `display: standalone`; an install hint covers iOS "Add to Home Screen".
- Service worker: cache the app shell only; API always live (tailnet). No offline deck in v1.
- Icons 192/512 + maskable; theme color = dark.
### Stack engine (5-deep live pile — normative)
- Constants: `DEPTH=5, SC=.026, TY=16, DIM=.08`.
- Depth transform: `translateY(TY*d) scale(1-SC*d)`, `filter:brightness(1-DIM*d)`, `z-index:10-d`, `pointer-events:auto` iff `d===0`.
- `.topcard` marks the d=0 card. **Invariant: exactly ONE `.topcard` in the DOM at any instant** — the flying card sheds it the moment it commits.
- Depth transitions: `.22s cubic-bezier(.2,.8,.3,1)` on transform/filter/opacity — this IS the cascade; it must be visible on every commit.
- New cards enter from two depths deeper (`translateY(TY*(d+2)) scale(1-SC*(d+2))`, opacity 0), then settle to their depth after one reflow.
- Every card is a live object with its own pointer listeners, and carries its **full face at all times** (chips/title/price/sub present on every card, no late text pop).
## Interaction invariants
- One-hand usable: all primary actions live in the bottom third of the screen.
- Animation lock during commit (no double-fire); undo stack ≥ 10 deep.
- Every card shows: photo, price, title, place.
### Drag (top card only)
- pointerdown on the top card → capture pointer, transition off. Move: `translate(dx,dy) rotate(clamp(±12°, dx/12))`.
- Stamps (3px outline, 26px/800, letter-spacing 2px, radius 10px):
- LIKE — green, top-left, rotate(-14°), opacity `clamp(0..1, dx/120)`
- NOPE — red, top-right, rotate(14°), opacity `clamp(0..1, -dx/120)`
- MAYBE — amber, centered, bottom:130px, opacity `dy<-20 ? clamp(0..1, -dy/130) : 0`
- Release: `dx > stage.clientWidth*0.35` → LIKE · `dx < -w*0.35` → NOPE · `dy < -140` → MAYBE · else spring back (`.3s cubic-bezier(.2,.8,.3,1)` + stamps cleared).
- Tap (≤6px movement, pointerup) on the top card → dossier.
## Acceptance (Phase 2)
Glenn can open the PWA from his iPhone home screen, swipe a real deck one-handed, and fix any mis-swipe in the List view — without touching a desktop.
### Commit — no lock (THE point)
- `commit(dir)`: only the current top commits. Instantly removed from the pile's books; flies out; pile refills to 5 from the buffer; all remaining cards re-layer to their new depth.
- Flight: like `translate(620px,40px) rotate(26°)` · nope `translate(-620px,40px) rotate(-26°)` · maybe `translate(0,-780px)`. `transition:transform .3s ease-in, opacity .3s`; card opacity → .15; node removed ~420ms. Flying card: `.topcard` dropped, `z-index:12`, `pointer-events:none` immediately.
- **Input never locks.** The next card is grabbable the same instant — even with two or three cards still in flight. Proven: 3 commits at 70ms intervals, zero lockout. Nothing may reintroduce a busy/lock state.
- Buttons row (86px, gap 16px; all act on the current top): ↺ undo 42px (#1d1d24, #f5c451) · ✕ nope 58px (white bg, #fd5068) · ★ maybe 58px (#1d1d24, #2a2a33 border, --maybe) · ♥ like 62px (accent gradient, glow). Buttons trigger the same flight as gestures.
- Undo: pops history → the card is rebuilt and re-inserted on top (enters from `translateY(-46px) scale(1.02)`, opacity 0), pile trimmed back to DEPTH (excess returns to the buffer front). Clears the 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.
## 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.
- Grabber: 26px strip, 42×5px bar (rgba(255,255,255,.3)). Close button: 36px circle, top:38px left:14px.
- Hero: 34% height (min 190px), photo (`object-fit:cover`); no photo → dimmed empty state.
- Body (scrollable, padding 14px 18px 20px): title (20px/800) → price (19px/700) + sub → chips → section "The machine" → spec table → section "Seller's notes" → "Open on Kijiji ↗" link (target=_blank).
- Spec table rows (9, in order): **Year · Odometer · Engine · Family · Category · Location · Seller · Source · First seen.** Missing values render "—" in dim style (#5a5a66, weight 500).
- Data mapping: Year=listings.year | Odometer=km formatted ("24,800 km") | Engine=cc found in title/desc ("1,250 cc") | Family=family | Category=from URL slug (`/v-<slug>/` → hyphens to spaces, title-case) | Location=`{location}, {NS|NB}` | Seller=`dealer|private` | Source="Kijiji" | First seen=relative ("Today", "N days ago") from first_seen.
- Seller's notes = desc quarantined as a quote block (13.5px, #b9b9c2); never merged into specs.
- Bottom actions: ✕ Nope / ★ Maybe / ♥ Like (48px, radius 14; like = gradient). From the deck they commit the current top (flight); from the list they set the decision.
- Pull-to-close: drag from grabber or hero; `dy > 110px` → close; else spring back (`.25s`).
## 4. List screen
- Header: "All bikes" (20px/800) + sub "{live} active · {new} new since your last visit" (new bold white when >0).
- Search input: compact, filters title (case-insensitive, server `q`).
- Filter chips row 1 (decision, single-select): All · Liked · Maybe · Noped · Undecided.
- Sort chips row 2 (single-select): Score · Price ↕ (second tap toggles ↑/↓) · Newest · km.
- Chip style as mockup: 12px, #1b1b21 bg, #26262e border; active = white bg / dark text / 600.
- Rows: thumb 56px radius 12 (or ◍ placeholder), title (14.5px/650, 1-line ellipsis), sub "{location}, {REGION}" (+ " · dealer|private" when known), price right (14px/700), decision buttons ♥ ★ ✕ (26px circles) — current decision lit in its color; tapping the lit button clears it (delete).
- Row tap → dossier (same sheet as §3). Scroll under nav (padding-bottom 68px). Empty: "No bikes match."
## 5. Matches screen (production: REAL)
- "Your shortlist": section "♥ Liked (n)", then "★ Maybe (n)". Same row component, edits included; row tap → dossier.
- Empty: "No likes yet — swipe right on the deck."
## 6. Trends
- Stub (Phase 3): "Trends — Phase 3" / "The deck learns your taste as you swipe."
## 7. Production deltas (the ONLY changes from the mockup)
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.
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.
4. **List filters + search** per §4 (mockup had placeholder chips only).
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.
7. **PWA.** `manifest.json` (name "Bike-Tinder", display standalone, theme/background #0b0b0e, icons 192/512 + maskable), `apple-touch-icon.png` (180px), iOS metas (`apple-mobile-web-app-capable`, `black-translucent`), `sw.js` (precache shell; API network-first; offline → serve shell + toast "Offline — retrying"). Register the SW in index.html. Serve index.html + sw.js with `Cache-Control: no-cache`.
8. **Safe areas + desktop centering** per §1.
## 8. Static files
`app/static/index.html` (single-file app, no build) · `manifest.json` · `sw.js` · `icons/` (192, 512, maskable, apple-touch-icon 180).
## 9. Acceptance (Phase 2)
Machine-checkable:
- Server runs on 127.0.0.1:9453; `/` serves the app; `/api/stats` live count = DB active count (560 at freeze).
- Deck page 1 starts at the top-scored row (Indian Scout family, score 91); ordering monotonic (score DESC, first_seen DESC, id DESC); page 2 zero overlap.
- Swipe like → appears in `/api/list?decision=like`; re-swipe edits; delete clears; server restart → decision persists.
- Undo from empty restores the last card.
- Fast-chain intact: commits faster than the flight animation, no lockout, exactly one `.topcard`.
- `/img/{id}`: 200 mirrored / 302 fallback / 404 → placeholder.
- Selfcheck: all P1 checks green + new API checks green.
Human (parent-run after build): Glenn swipes a real deck from his phone and edits results in the list — the Phase 2 exit criterion.