§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.
21 KiB
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:
.appmax-width 520px, centered. Safe areas: headerpadding-top:env(safe-area-inset-top); navpadding-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:autoiffd===0. .topcardmarks the d=0 card. Invariant: exactly ONE.topcardin 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
- LIKE — green, top-left, rotate(-14°), opacity
- 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°)· nopetranslate(-620px,40px) rotate(-26°)· maybetranslate(0,-780px).transition:transform .3s ease-in, opacity .3s; card opacity → .15; node removed ~420ms. Flying card:.topcarddropped,z-index:12,pointer-events:noneimmediately. - 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.
- 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 (
- 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)
- Data wiring. Deck buffer from
GET /api/deck(limit 40, opaque cursor); refill when buffer <5 and a cursor exists. List fromGET /api/list. Dossier from the row/card payload. Header fromGET /api/stats. - Swipe persistence. Commits fire
POST /api/swipeoptimistically — 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 firesDELETE /api/swipe/{id}through the same queue. - Badge.
POST /api/badge/seenonce per session when the deck first renders. Header pill shows{live} live · {fresh} freshalways, and· {new} newonly when new > 0.fresh= active listings with no swipe (the deck's new-pool predicate) — v1.1. - List filters + search per §4 (mockup had placeholder chips only).
- Matches real (§5); Trends stays stub.
- Images. Every card/thumb references
/img/{id}(server: cached file → 302 CDN → 404 → placeholder). The client never hotlinks directly. - 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 withCache-Control: no-cache. - 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/statslive 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
--accent1at 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,
.topcardinvariant, 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: 0the 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).
- Forge mode
- 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; bgrgba(255,180,58,.14); border1px solid rgba(255,180,58,.35). - SECOND LOOK — you {liked|maybed|noped} this — color
#c4b5fd; bgrgba(124,58,237,.22); border1px solid rgba(167,139,250,.65); font-weight 800. Wildcard stays amber (#ffb43afamily values unchanged) for contrast. The same band renders in the dossier ("info view") when the listing's slot isreturnorwildcard(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 easeon 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:
- Your read — lead line (18px/700) + "updated {rel}" muted; cold start copy: "Swipe ~10 more and the read sharpens."
- Leanings — up to 8 bullets (14px).
- Stats — three mini blocks: "Hit rate" ({likes}/{decided}) · "Returns re-struck" ({n}) · "Wildcards hit" ({n}).
- Since last read — one muted line (hidden when null).
- 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 thesw.jscache 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'simg/img_url). - Every gallery image is mirrored locally (
data/img/{id}_{n}.webp) and served by the app (GET /img/{id}?i={n},idefault 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 attop: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=nanswers 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.