diff --git a/app/static/index.html b/app/static/index.html
index c4ad187..c677791 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -181,6 +181,21 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
.trow span{cursor:pointer}
.trow i{color:#4a4a55;font-style:normal;cursor:default}
.tmuted{font-size:12px;color:#6f6f7a;margin:10px 18px 20px}
+/* B1 §11 — photo galleries: segments, tap zones, fullscreen viewer */
+.segments{position:absolute;top:8px;left:50%;transform:translateX(-50%);display:flex;gap:4px;z-index:4;pointer-events:none}
+.segments i{width:22px;height:3px;border-radius:2px;background:rgba(255,255,255,.35);transition:background .15s}
+.segments i.on{background:#fff}
+.card.hasSegs .band{top:22px} /* §11.2 amendment: bands drop below the bars */
+.vback{position:absolute;inset:0;z-index:71;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .28s}
+.vback.on{opacity:1;pointer-events:auto}
+.viewer{position:absolute;top:26px;left:0;right:0;bottom:0;z-index:72;background:#0b0b0f;display:flex;flex-direction:column;border-radius:18px 18px 0 0;box-shadow:0 -14px 40px rgba(0,0,0,.5);transform:translateY(103%);transition:transform .3s cubic-bezier(.2,.8,.3,1);overflow:hidden}
+.viewer.on{transform:translateY(0)}
+.vgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none}
+.vgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block}
+.vclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:16px;z-index:6;cursor:pointer;display:flex;align-items:center;justify-content:center}
+.vphoto{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none}
+.vimg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transform-origin:center center}
+.viewer .segments{top:34px}
@@ -253,6 +268,14 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items
+
@@ -332,12 +355,18 @@ async function getJSON(url){
/* swipe persistence: optimistic, never waits; failures retry 2s/5s/10s then
every 15s while the tab lives. Last write per listing wins; the queue runs
serially so reorderings can't land out of sequence. */
-const pend=new Map(); // listing_id -> {act, secId, fails, warned}
+const pend=new Map(); // listing_id -> {act, secId, photoIdx, fails, warned}
let qRunning=false, qTimer=null, qWarned=false;
-function enqueue(listingId, act, secId=null){ // act: 'like'|'nope'|'maybe'|null (delete)
- pend.set(listingId,{act,secId,fails:0});
+function enqueue(listingId, act, secId=null, photoIdx=null){ // act: 'like'|'nope'|'maybe'|null (delete)
+ pend.set(listingId,{act,secId,photoIdx,fails:0});
scheduleQueue();
}
+function swipeBody(id, act, secId, photoIdx){
+ const b={listing_id:id,action:act};
+ if(secId!=null) b.section_id=secId;
+ if(photoIdx!=null) b.photo_idx=photoIdx; // B1 §11.5: photo on screen at strike
+ return b;
+}
function queDelay(op){ return op.fails===1?2000:op.fails===2?5000:op.fails===3?10000:15000; }
function scheduleQueue(){
if(qRunning||!pend.size||qTimer) return;
@@ -355,7 +384,7 @@ async function runQueue(){
try{
const res= p.act==null
? await fetch('/api/swipe/'+id,{method:'DELETE'})
- : await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p.secId?{listing_id:id,action:p.act,section_id:p.secId}:{listing_id:id,action:p.act})});
+ : await fetch('/api/swipe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(swipeBody(id,p.act,p.secId,p.photoIdx))});
if(res.ok){ pend.delete(id); }
else { p.fails++; if(!qWarned){ qWarned=true; toast("Couldn't save — retrying"); } minDelay=Math.max(minDelay,queDelay(p)); }
}catch(e){
@@ -371,10 +400,10 @@ async function runQueue(){
progress stays correct while the persistence queue is mid-flight. */
const localDecisions=new Map(); // listing_id -> action|null
let lastResyncAt=0;
-function setDecision(id, act, secId=null){
+function setDecision(id, act, secId=null, photoIdx=null){
localDecisions.set(id, act);
updateRowDecision(id, act); // optimistic paint
- enqueue(id, act, secId);
+ enqueue(id, act, secId, photoIdx);
}
/* ---------- payload -> display helpers ---------- */
@@ -458,12 +487,75 @@ function bandHTML(item){ // P3 system bands — the ONLY card-fa
}
return '';
}
+/* ---------- B1 §11: photo galleries ---------- */
+function nPhotos(it){ // payload n_photos; tolerant of old APIs
+ const n= it&&typeof it.n_photos==='number'?it.n_photos:(it&&it.img?1:0);
+ return Math.max(0, Math.trunc(n));
+}
+function leadFor(it){ // §11.5 repeat rotation: (last+1) % n
+ const n=nPhotos(it);
+ if(n<2) return 0;
+ const li=it.last_photo_idx;
+ if(!Number.isInteger(li)||li<0) return 0;
+ return (li+1)%n;
+}
+function photoSrc(it,i){ if(!it||!it.img) return null; return i>0?('/img/'+it.id+'?i='+i):it.img; }
+function segHTML(n,cur){
+ if(!n||n<2) return '';
+ let h='
';
+ for(let k=0;k`;
+ return h+'
';
+}
+function phwrapHTML(src){
+ return `
${src?`

`:`
Photo pending — thumbnail backfill queued
`}
`;
+}
+function attachPhError(entry){
+ const wrap=entry.el.querySelector('.phwrap');
+ const im=wrap.querySelector('img');
+ if(im) im.addEventListener('error',()=>{ wrap.innerHTML='
Photo pending — thumbnail backfill queued
'; });
+}
+function paintSegs(entry){
+ const segs=entry.el.querySelector('.segments');
+ if(segs) [...segs.children].forEach((x,k)=>x.classList.toggle('on',k===(entry.cur||0)));
+}
+/* §11.2 flip — instant, clamped at the ends (no wrap), commits nothing */
+function flipCard(entry,dir){
+ const n=nPhotos(entry.d);
+ if(n<2) return;
+ const cur=(entry.cur||0)+dir;
+ if(cur<0||cur>=n) return;
+ entry.cur=cur;
+ const wrap=entry.el.querySelector('.phwrap');
+ wrap.innerHTML= phwrapHTML(photoSrc(entry.d,cur));
+ attachPhError(entry);
+ paintSegs(entry);
+ preloadPhotos();
+}
+/* §11.6 preload: top card's next photo + next card's lead photo, off-path */
+function preloadPhotos(){
+ pile.slice(0,2).forEach((entry,pos)=>{
+ const d=entry.d;
+ if(!d) return;
+ const targets= pos===0
+ ? ((()=>{ const n=nPhotos(d); const nx=(entry.cur||0)+1;
+ return (n>1&&nx
`:`Photo pending — thumbnail backfill queued
`}
+ const n=nPhotos(item);
+ const lead=leadFor(item);
+ if(n>=2) el.classList.add('hasSegs');
+ el.innerHTML=`${phwrapHTML(photoSrc(item,lead))}
${bandHTML(item)}
+ ${segHTML(n,lead)}
LIKE
NOPE
MAYBE
@@ -474,11 +566,8 @@ function cardNode(item){
info.querySelector('.title').textContent=item.title||'';
info.querySelector('.price').textContent=priceFor(item);
info.querySelector('.sub').textContent=subFor(item);
- const ph=el.querySelector('.photo');
- if(ph) ph.addEventListener('error',()=>{
- ph.remove();
- el.insertAdjacentHTML('afterbegin','Photo pending — thumbnail backfill queued
');
- });
+ const entry={d:item,el,cur:lead};
+ attachPhError(entry);
el.addEventListener('pointerdown',down);
el.addEventListener('pointermove',move);
el.addEventListener('pointerup',upH);
@@ -486,7 +575,7 @@ function cardNode(item){
const ib=el.querySelector('.ibtn');
ib.addEventListener('pointerdown',e=>e.stopPropagation());
ib.addEventListener('click',e=>{ e.stopPropagation(); if(isTop(el)) openDetail(item,{type:'deck'}); });
- return {d:item,el};
+ return entry;
}
function layer(el,d,anim){
el.classList.toggle('topcard',d===0);
@@ -511,6 +600,7 @@ function fillPile(){
layer(entry.el,d,true);
}
relayer();
+ preloadPhotos();
}
/* deck feed: P3 sections (pinned order) with a legacy /api/deck fallback */
async function loadDeck(){ // legacy cursor feed (pre-P3 API)
@@ -586,7 +676,8 @@ function commit(el,dir){
const t=pile.shift();
hist.push(t.d);
const act= dir==='maybe'?'maybe':dir;
- setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null);
+ setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null,
+ nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike
sectDecided++;
paintSect();
flyOut(el,dir);
@@ -620,6 +711,7 @@ function undo(){
if(pile.length>DEPTH){ const drop=pile.pop(); drop.el.remove(); buffer.unshift(drop.d); }
void entry.el.offsetWidth;
relayer();
+ preloadPhotos();
$('empty').classList.remove('on');
$('report').classList.remove('on');
}
@@ -654,13 +746,25 @@ function upH(e){
if(dx>w*0.35) commit(el,'like');
else if(dx<-w*0.35) commit(el,'nope');
else if(dy<-140) commit(el,'maybe');
- else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) openDetail(pile[0].d,{type:'deck'}); }
+ else { el.style.transform=''; setStamps(el,0,0,0); if(!moved && e.type==='pointerup' && isTop(el)) tapZone(pile[0],e); }
+}
+/* §11.2/§11.3/§11.4 tap zones: sides flip (clamped), center = fullscreen.
+ The old tap-anywhere -> dossier gesture is retired (ⓘ stays the door). */
+function tapZone(entry,e){
+ const n=nPhotos(entry.d);
+ if(n<1 || !entry.d.img) return;
+ const r=stage.getBoundingClientRect();
+ const rel=e.clientX-r.left, w=r.width||360;
+ if(rel>=w/3 && rel<=w*2/3) openViewer(entry.d, entry.cur||0, {type:'card',entry});
+ else flipCard(entry, rel viewer)
function openDetail(data,ctx){
dCtx=ctx||null;
+ dData=data;
const ph=$('dphoto');
if(data.img){ ph.src=data.img; ph.classList.remove('ph'); }
else { ph.removeAttribute('src'); ph.classList.add('ph'); }
@@ -721,7 +825,12 @@ function dStart(e){ dDragY=e.clientY; $('detail').style.transition='none'; try{e
function dMoveH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); $('detail').style.transform=`translateY(${dy}px)`; }
function dEndH(e){ if(dDragY===null) return; const dy=Math.max(0,e.clientY-dDragY); dDragY=null;
if(dy>110){ closeDetail(); }
- else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform=''; } }
+ else { const d=$('detail'); d.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; d.style.transform='';
+ /* B1 §11.3: a tap on the dossier HERO opens the fullscreen viewer at photo 0 */
+ if(e.type==='pointerup' && dy<=6 && e.currentTarget&&e.currentTarget.classList
+ && e.currentTarget.classList.contains('dhero') && dData&&dData.img){
+ openViewer(dData,0,{type:'dossier'});
+ } } }
[document.querySelector('.dgrab'),document.querySelector('.dhero')].forEach(el=>{ if(!el) return; el.addEventListener('pointerdown',dStart); el.addEventListener('pointermove',dMoveH); el.addEventListener('pointerup',dEndH); el.addEventListener('pointercancel',dEndH); });
function dAction(a){
const ctx=dCtx; closeDetail();
@@ -736,6 +845,113 @@ function dAction(a){
}
$('dNo').onclick=()=>dAction('nope'); $('dMb').onclick=()=>dAction('maybe'); $('dLk').onclick=()=>dAction('like');
+/* ---------- B1 §11.3: fullscreen viewer (swipe nav, pinch/double-tap zoom,
+ ✕ + pull-down close) — returns to the same card & photo position ---------- */
+let vCtx=null; // {item, idx, back} — deck cards untouched
+let vScale=1, vTx=0, vTy=0;
+let vDrag=null; // single-pointer gesture state
+const vPtrs=new Map(); // pointerId -> {x,y}
+let vPinch=null; // {d0, scale0}
+let vLastTap=0;
+function vCount(){ return vCtx&&vCtx.item?nPhotos(vCtx.item):0; }
+function vApply(){ if(!vCtx) return; if(vScale<=1){ vTx=0; vTy=0; } $('vimg').style.transform=`translate(${vTx}px,${vTy}px) scale(${vScale})`; }
+function vPaint(){
+ const item=vCtx.item, i=vCtx.idx;
+ $('vsegs').innerHTML=segHTML(vCount(),i);
+ const im=$('vimg'), s=photoSrc(item,i);
+ if(s){ im.src=s; im.style.opacity=1; }
+ else { im.removeAttribute('src'); im.style.opacity=.25; }
+ im.onerror=()=>{ im.style.opacity=.25; };
+ vApply();
+}
+function openViewer(item, idx, back){
+ const n=nPhotos(item);
+ if(!n||!item||!item.img) return;
+ vCtx={item, idx:Math.max(0,Math.min(idx||0,n-1)), back:back||null};
+ vScale=1; vTx=0; vTy=0;
+ vPaint();
+ $('viewer').classList.add('on'); $('vback').classList.add('on');
+}
+function closeViewer(){
+ const v=$('viewer');
+ v.classList.remove('on'); $('vback').classList.remove('on');
+ v.style.transition=''; v.style.transform='';
+ vCtx=null; vPtrs.clear(); vPinch=null; vDrag=null;
+}
+$('vback').addEventListener('click',closeViewer);
+$('vclose').onclick=closeViewer;
+$('vclose').addEventListener('pointerdown',e=>e.stopPropagation());
+const vph=$('vphoto');
+function vDist(){
+ const pts=[...vPtrs.values()];
+ return pts.length>=2?Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y):null;
+}
+vph.addEventListener('pointerdown',e=>{
+ if(!vCtx) return;
+ vPtrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
+ try{ vph.setPointerCapture(e.pointerId); }catch(_){}
+ if(vPtrs.size===1){ vDrag={sx:e.clientX,sy:e.clientY,px:vTx,py:vTy,scale0:vScale,mode:null}; }
+ else if(vPtrs.size===2){ vDrag=null; vPinch={d0:vDist()||1,scale0:vScale}; }
+});
+vph.addEventListener('pointermove',e=>{
+ if(!vCtx) return;
+ const pt=vPtrs.get(e.pointerId); if(pt){ pt.x=e.clientX; pt.y=e.clientY; }
+ if(vPtrs.size>=2&&vPinch){
+ const dd=vDist(); if(!dd) return;
+ vScale=Math.max(1,Math.min(4,vPinch.scale0*dd/vPinch.d0)); // 1×–4×
+ vApply(); return;
+ }
+ if(!vDrag) return;
+ const dx=e.clientX-vDrag.sx, dy=e.clientY-vDrag.sy;
+ if(!vDrag.mode){
+ if(Math.abs(dx)<=6&&Math.abs(dy)<=6) return;
+ vDrag.mode= vScale>1?'pan':(Math.abs(dx)>=Math.abs(dy)?'nav':'pull');
+ }
+ if(vDrag.mode==='pull'){
+ const v=$('viewer'); v.style.transition='none';
+ v.style.transform=`translateY(${Math.max(0,dy)}px)`;
+ } else if(vDrag.mode==='nav'){
+ vTx=Math.max(-70,Math.min(70,dx)); vApply(); // live edge-feedback
+ } else { vTx=vDrag.px+dx; vTy=vDrag.py+dy; vApply(); } // pan while zoomed
+});
+function vUp(e){
+ const wasDrag=vDrag, dx=wasDrag?e.clientX-wasDrag.sx:0, dy=wasDrag?e.clientY-wasDrag.sy:0;
+ if(wasDrag&&vPtrs.size<=1){
+ const mode=wasDrag.mode;
+ if(mode==='pull'){
+ if(dy>110){ closeViewer(); }
+ else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; }
+ } else if(mode==='nav'){
+ if(Math.abs(dx)>=50&&vCtx){
+ const nx=vCtx.idx+(dx>0?-1:1); // clamp at the ends
+ if(nx>=0&&nx double-tap zoom toggle
+ const now=Date.now();
+ if(now-vLastTap<300){ vLastTap=0; vScale= vScale>1?1:2.5; vApply(); }
+ else vLastTap=now;
+ }
+ vDrag=null;
+ }
+ vPtrs.delete(e.pointerId); vPinch=null;
+}
+vph.addEventListener('pointerup',vUp);
+vph.addEventListener('pointercancel',vUp);
+/* pull-down close from the grabber (the §3 idiom) */
+let vgY=null;
+function vgStart(e){ vgY=e.clientY; $('viewer').style.transition='none'; try{e.currentTarget.setPointerCapture(e.pointerId)}catch(_){} }
+function vgMove(e){ if(vgY===null) return; $('viewer').style.transform=`translateY(${Math.max(0,e.clientY-vgY)}px)`; }
+function vgEnd(e){ if(vgY===null) return; const dy=Math.max(0,e.clientY-vgY); vgY=null;
+ if(dy>110){ closeViewer(); }
+ else { const v=$('viewer'); v.style.transition='transform .25s cubic-bezier(.2,.8,.3,1)'; v.style.transform=''; } }
+$('vgrab').addEventListener('pointerdown',vgStart);
+$('vgrab').addEventListener('pointermove',vgMove);
+$('vgrab').addEventListener('pointerup',vgEnd);
+$('vgrab').addEventListener('pointercancel',vgEnd);
+
/* ---------- list screen (real filters + search) ---------- */
let listItems=[];
const listCtl={decision:'all',sort:'score',q:'',priceDir:1};
@@ -1043,7 +1259,7 @@ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
/* ---------- desktop keyboard arrows (DESIGN.md: v1 frontend) ---------- */
document.addEventListener('keydown',e=>{
- if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')) return;
+ if(e.target.tagName==='INPUT'||$('detail').classList.contains('on')||$('viewer').classList.contains('on')) return;
if(e.key==='ArrowRight'){ if(pile[0]) commit(pile[0].el,'like'); }
else if(e.key==='ArrowLeft'){ if(pile[0]) commit(pile[0].el,'nope'); }
else if(e.key==='ArrowUp'){ if(pile[0]) commit(pile[0].el,'maybe'); }
diff --git a/app/static/sw.js b/app/static/sw.js
index 3c26a6c..10c252c 100644
--- a/app/static/sw.js
+++ b/app/static/sw.js
@@ -6,7 +6,7 @@
offline once seen).
- Everything else (shell/navigation): network-first, fall back to cache.
*/
-const VERSION = 'bike-tinder-v2';
+const VERSION = 'bike-tinder-v3';
const SHELL = [
'/',
'/manifest.json',