Files
Bike-Tinder/docs/UI-SPEC.md
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

232 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI-SPEC — Bike-Tinder
**FROZEN v2 · 2026-11-10.** The post-mockup contract for the Phase 2 production build. Two normative artifacts, in tandem:
- **`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.
(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.**)
## 1. Globals
- 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.
## 2. Deck screen (default)
### 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)).
- 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".
- ⓘ 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.
### 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).
### 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.
### 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.
§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
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
- **Real (P3):** see §10.6.
## 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 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).
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.
## 10. P3 additions (v3) — Personalization UI
**Frozen 2026-11-10 (Glenn approved).** Everything in §1–§7 remains in force; all of this is additive. The card face gains only the two system bands (§10.3); everything else lives in the list, the report screen, the dossier, and Trends.
### 10.1 Sections — the deck becomes a finite batch
- 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).
- 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.
### 10.2 Section report + forge
- When no undecided item remains, the deck shows the report card (replaces the §2 empty state when a section exists; same visual language):
- Title: **"Section complete."**
- Counts row: "♥ {likes} · ★ {maybes} · ✕ {nopes}"
- Sub: "The file is digesting your strikes."
- Line 2 (only when >0): "Second looks re-struck: {n} · Wildcards that hit: {n}"
- Actions:
- **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."
- 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).
### 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.
- **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 `#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.
### 10.4 Tank gauge (match points, non-numeric)
- Small fuel-tank glyph; fill = match points (0–100). List rows: 18×22px in the trailing cluster, immediately left of the price, vertically centered. Dossier: 28×34px.
- Fill band colors: `<30` → `--nope`; `30–70` → `--maybe`; `>70` → `--like`. Track: `rgba(255,255,255,.12)`, radius 4px. Fill animates `.25s ease` on update.
- The List view NEVER shows raw numbers.
### 10.5 Dossier — "Why this rank"
- New block after chips, before "The machine":
- Gauge + "{points}%" (17px/700).
- 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).
- 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).
### 10.6 Trends tab (real)
Top→bottom:
1. **Your read** — lead line (18px/700) + "updated {rel}" muted; cold start copy: "Swipe ~10 more and the read sharpens."
2. **Leanings** — up to 8 bullets (14px).
3. **Stats** — three mini blocks: "Hit rate" ({likes}/{decided}) · "Returns re-struck" ({n}) · "Wildcards hit" ({n}).
4. **Since last read** — one muted line (hidden when null).
5. **Actions** — "Run agent now" (POST /api/agent/run; inline status) · forge-mode toggle · muted "Last read {time} · {model}".
- No LLM call at view time; the tab renders the stored profile only.
### 10.7 Wiring deltas
- Deck buffer source becomes `GET /api/section` (pinned order). All P2 wiring (optimistic swipe + retry queue, badge, SW) unchanged; bump the `sw.js` cache version.
- Card payload additions the FE reads: `slot` ('new'|'return'|'wildcard'), `points`, `tank` {value, band}, `round_next` (1|2, for the SECOND LOOK band copy when needed).
## 11. Photo experience v2 (B1) — galleries, photo flip, fullscreen viewer
**Frozen 2026-11-10 (Glenn approved: gesture map + repeat rotation picked by him in chat).** Additive; §1–§10 remain in force except where explicitly superseded here (§2's "Tap … → dossier" and §3's "(or tap)" are retired — §11.4).
### 11.1 Galleries
- Every listing carries its full photo gallery: `imgs[]` (page order, deduped by image id; entry 0 = the established primary = today's `img`/`img_url`).
- Every gallery image is mirrored locally (`data/img/{id}_{n}.webp`) and served by the app (`GET /img/{id}?i={n}`, `i` default 0; falls back into the §7.6 chain). The client never hotlinks directly.
- Zero photos → §2 placeholder; one photo → single photo, no segments, no flip.
### 11.2 Photo flip (deck card — top card only)
- Tap LEFT third → previous photo; tap RIGHT third → next photo. **Clamp at the ends** (no wrap). Tap test = §2's (≤6px movement on pointerup); a tap inside the ⓘ button hits ⓘ, not the zones.
- Segment indicator bars: one segment per photo along the top edge of the photo, centered, ~3px tall; current = solid white, others `rgba(255,255,255,.35)`; Tinder-style. (§10.3 amendment: when segments are present, band chips render at `top:22px`; no other change to §10.3.)
- Flip is instant, commits nothing, locks nothing (§2 THE law). A drag that starts anywhere still drags for LIKE/NOPE/MAYBE — the zones apply to taps only.
- Photo position is per-card-session state; a card re-shown fresh returns to §11.5's lead photo.
### 11.3 Fullscreen viewer
- 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.
- 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)
- ⓘ (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).
- 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
- A listing presented again (second look / re-show) opens on a different photo than the last one viewed: lead = `(last_photo_idx + 1) % n`; no stored index → photo 0.
- The index is persisted server-side at swipe-commit time (survives sessions and devices; works with §7.2's optimistic retry queue).
- List thumbnails and the dossier hero keep photo 0 (the canonical shot) — rotation is deck-only.
### 11.6 Performance & invariants
- Preload the top card's next photo and the next card's lead photo; decode off the critical path; **no network on the swipe path** (all mirrored locally).
- Stack metrics, `.topcard`, z-order law, stamps, undo, no-lock — all §2 laws untouched. Bands and segments coexist per §11.2.
### 11.7 Acceptance (B1)
Machine-checkable:
- Gallery capture ≥1 photo for every listing with a page image; multi-photo listings show N segments = N photos; the mirror holds all gallery files; `/img/{id}?i=n` answers for every n.
- Flip: left/right taps advance and clamp; a tap never commits (no stamp flash, no flight); drag-commit regression passes (fast-chain, no-lock, one `.topcard`).
- Viewer: opens from center tap and dossier hero; swipe/zoom/close work; close restores the card and photo position.
- Rotation: a re-shown card leads at `last+1`; persisted across page reload (server-side).
- Fallbacks: 0-photo placeholder, 1-photo (no segments), broken file → §7.6 chain.
- All §9/§10 acceptance re-run green.
Human: Glenn flips photos on his phone, opens fullscreen, and sees a fresh photo on a repeat — the B1 exit criterion.