Files
Bike-Tinder/docs/UI-SPEC.md

113 lines
10 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)).
- 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.
## 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.