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

10 KiB
Raw Blame History

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."
  • 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.