← back to Dw Photo Capture
public/kid-simple/v15-thumb-zone.html
494 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>v15 — Thumb Zone (Shoot-First / One-Thumb Confirm)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Spectral:wght@500;600;700&family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--stage:#07070A;
--panel:#18120E;
--panel-edge:#241A13;
--line:#3A2C20;
--ink:#F6EFE4;
--muted:#A9998A;
--accent:#FF8A42;
--accent-ink:#2B1206;
--success:#5FCE8B;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:#000;font-family:'Manrope',sans-serif;color:var(--ink);}
body{display:flex; align-items:flex-start; justify-content:center; min-height:100vh; padding:20px 0;}
.phone{
width:390px; min-height:780px; height:780px; background:var(--stage); border-radius:36px;
overflow:hidden; box-shadow:0 24px 60px rgba(0,0,0,.5); display:flex; flex-direction:column;
position:relative;
}
/* ---------- STAGE (top two-thirds: camera is ALWAYS live, in every state) ---------- */
.stage{
flex:1; min-height:0; background:radial-gradient(120% 90% at 50% 0%, #101015 0%, #07070A 60%);
position:relative; display:flex; flex-direction:column; overflow:hidden;
}
.stage-topbar{
display:flex; align-items:center; justify-content:space-between; padding:16px 20px 0;
font-family:'Manrope'; font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--muted);
}
.stage-topbar .dw{ color:var(--accent); font-weight:800; }
.live-dot{ display:inline-flex; align-items:center; gap:6px; }
.live-dot span.dot{ width:7px; height:7px; border-radius:50%; background:var(--accent); box-shadow:0 0 8px var(--accent); animation:pulse 1.6s ease-in-out infinite; }
@keyframes pulse{ 0%,100%{opacity:1;} 50%{opacity:.35;} }
.stage-body{ flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:14px 26px; gap:14px; text-align:center; }
.stage-hint{ font-family:'Spectral',serif; font-weight:600; font-size:23px; line-height:1.3; color:var(--ink); max-width:300px; }
.stage-status{ font-family:'Manrope'; font-size:14px; font-weight:700; color:var(--muted); letter-spacing:.03em; }
.viewfinder{ width:230px; height:230px; position:relative; display:flex; align-items:center; justify-content:center; }
.corner{ position:absolute; width:34px; height:34px; border-color:var(--accent); }
.corner.tl{ top:0; left:0; border-top:3px solid; border-left:3px solid; border-radius:10px 0 0 0; }
.corner.tr{ top:0; right:0; border-top:3px solid; border-right:3px solid; border-radius:0 10px 0 0; }
.corner.bl{ bottom:0; left:0; border-bottom:3px solid; border-left:3px solid; border-radius:0 0 0 10px; }
.corner.br{ bottom:0; right:0; border-bottom:3px solid; border-right:3px solid; border-radius:0 0 10px 0; }
.viewfinder .glyph{ font-size:44px; opacity:.9; }
.idle-orb{
width:150px; height:150px; border-radius:50%;
background:radial-gradient(circle at 35% 30%, rgba(255,138,66,.35), rgba(255,138,66,0) 65%);
border:2px solid var(--line); display:flex; align-items:center; justify-content:center; font-size:46px;
}
.swatch-card{
width:190px; height:190px; border-radius:24px; box-shadow:0 14px 30px rgba(0,0,0,.5);
display:flex; align-items:flex-end; justify-content:center; position:relative; overflow:hidden;
}
.sw1{background:repeating-linear-gradient(45deg,#8FAE8B 0 18px,#5f7a5c 18px 36px);}
.sw2{background:repeating-linear-gradient(-45deg,#7C93B0 0 16px,#4c637f 16px 32px);}
.sw3{background:repeating-linear-gradient(90deg,#D9A441 0 14px,#a9711f 14px 28px);}
.item-caption{ margin-top:2px; }
.item-name{ font-family:'Spectral',serif; font-weight:700; font-size:20px; margin:0; color:var(--ink); }
.item-meta{ font-family:'Manrope'; font-size:13px; color:var(--muted); margin:4px 0 0; }
.big-emoji{ font-size:60px; }
/* toast, floats over the still-live stage */
.toast{
position:absolute; left:50%; bottom:14px; transform:translateX(-50%);
background:rgba(20,14,10,.94); border:2px solid var(--accent); border-radius:999px;
padding:10px 20px; font-weight:800; font-size:14px; display:flex; align-items:center; gap:8px;
box-shadow:0 10px 24px rgba(0,0,0,.4); white-space:nowrap;
}
/* ---------- THUMB CONTROLLER (bottom third: the ONE persistent panel) ---------- */
.panel{
background:linear-gradient(180deg, var(--panel-edge), var(--panel));
border-top:1px solid var(--line);
border-radius:26px 26px 0 0;
padding:16px 20px 24px;
display:flex; flex-direction:column; gap:12px;
min-height:298px;
box-shadow:0 -18px 40px rgba(0,0,0,.35);
position:relative;
}
.panel-head{ display:flex; align-items:center; gap:12px; }
.home-btn{
flex:0 0 auto; width:56px; height:56px; border-radius:16px; background:#241A13;
border:2px solid var(--line); color:var(--muted); font-size:20px; display:flex;
align-items:center; justify-content:center; cursor:pointer;
}
.home-btn:focus-visible{ outline:3px solid #6FB8FF; outline-offset:2px; }
.panel-title{ font-family:'Spectral',serif; font-weight:600; font-size:21px; line-height:1.25; color:var(--ink); flex:1; }
.panel-body{ flex:1; display:flex; flex-direction:column; gap:12px; justify-content:center; }
button{ font-family:'Manrope',sans-serif; cursor:pointer; }
.pbtn{
width:100%; min-height:60px; border-radius:16px; font-size:17px; font-weight:800;
background:#241A13; border:2px solid var(--line); color:var(--ink); padding:10px 16px;
}
.pbtn-primary{ background:var(--accent); border-color:var(--accent); color:var(--accent-ink); }
.pbtn-secondary{ background:transparent; border-color:var(--line); color:var(--muted); }
.pbtn:focus-visible, .home-btn:focus-visible{ outline:3px solid #6FB8FF; outline-offset:2px; }
.chip-row{ display:flex; gap:10px; overflow-x:auto; padding:4px 2px 8px; scroll-snap-type:x proximity; }
.chip{
flex:0 0 auto; scroll-snap-align:start; min-height:56px; padding:0 20px; border-radius:999px;
background:#241A13; border:2px solid var(--line); color:var(--ink); font-size:15px; font-weight:700;
display:flex; align-items:center; white-space:nowrap;
}
.chip:focus-visible{ outline:3px solid #6FB8FF; outline-offset:2px; }
.shutter-wrap{ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; flex:1; }
.shutter{
width:84px; height:84px; border-radius:50%; background:var(--accent); border:5px solid #4A2C16;
font-size:30px; display:flex; align-items:center; justify-content:center; box-shadow:0 0 0 6px rgba(255,138,66,.16);
}
.shutter-caption{ font-size:13px; font-weight:700; color:var(--muted); letter-spacing:.03em; }
.linktext{
background:none; border:none; color:var(--muted); font-size:15px; text-decoration:underline;
text-align:center; min-height:56px; min-width:56px; width:100%;
}
.linktext:focus-visible{ outline:3px solid #6FB8FF; outline-offset:2px; }
/* pull-up find drawer tab, lives on the LIVE screen */
.drawer-tab{
width:100%; min-height:56px; border-radius:16px; background:transparent;
border:2px dashed var(--line); color:var(--muted); font-weight:800; font-size:15px;
display:flex; align-items:center; justify-content:center; gap:8px;
}
.drawer-tab .chev{ color:var(--accent); }
/* the vertical 3-option thumb picker (post-shot job wheel) */
.picker-hint{ text-align:center; font-size:12px; font-weight:800; letter-spacing:.08em; color:var(--muted); text-transform:uppercase; }
.picker-stack{ display:flex; flex-direction:column; gap:10px; touch-action:pan-y; }
.picker-card{
min-height:64px; width:100%; border-radius:16px; background:#241A13; border:2px solid var(--line);
display:flex; align-items:center; gap:14px; padding:0 18px; font-weight:800; font-size:16px; color:var(--ink);
text-align:left;
}
.picker-card .ic{ font-size:26px; }
.picker-card.likely{ border-color:var(--accent); background:linear-gradient(135deg, rgba(255,138,66,.20), #241A13); }
.picker-card.likely::after{ content:'MOST LIKELY'; margin-left:auto; font-size:10px; letter-spacing:.06em; color:var(--accent); font-weight:800; }
.picker-card:focus-visible{ outline:3px solid #6FB8FF; outline-offset:2px; }
/* hold-to-confirm ring, replaces every yes/no screen */
.hold-wrap{ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; flex:1; }
.hold-ring{
--pct:0%;
width:104px; height:104px; border-radius:50%; padding:6px; border:none;
background:conic-gradient(var(--accent) var(--pct), #2A1F17 var(--pct));
display:flex; align-items:center; justify-content:center; transition:box-shadow .15s;
}
.hold-ring.holding{ box-shadow:0 0 0 8px rgba(255,138,66,.14); }
.hold-ring-inner{
width:100%; height:100%; border-radius:50%; background:#18120E; border:2px solid var(--line);
display:flex; align-items:center; justify-content:center; text-align:center; padding:6px;
font-size:13px; font-weight:800; color:var(--ink); line-height:1.2;
}
.hold-caption{ font-size:13px; font-weight:700; color:var(--muted); letter-spacing:.02em; text-align:center; }
/* mini persistent shutter FAB — sits on the stage/panel seam in every non-idle state */
.mini-fab{
position:absolute; top:-28px; right:22px; width:64px; height:64px; border-radius:50%;
background:var(--accent); border:4px solid #4A2C16; color:var(--accent-ink); font-size:24px;
display:flex; align-items:center; justify-content:center; box-shadow:0 8px 18px rgba(0,0,0,.4); z-index:5;
}
.mini-fab:focus-visible{ outline:3px solid #6FB8FF; outline-offset:2px; }
</style>
</head>
<body>
<div class="phone">
<div class="stage" id="stage"></div>
<div class="panel" id="panel"></div>
</div>
<script>
/* ---------------------------------------------------------------------
NEW MODEL: "shoot first, one-thumb confirm"
- Camera is ALWAYS live on the stage, in every state.
- The bottom panel is a single persistent thumb controller.
- Tap the shutter (big one on the idle screen, or the mini FAB seam
button everywhere else) from ANY state to take a shot.
- After a shot taken from idle, the panel morphs into a vertical
3-option thumb PICKER (swipe up/down or tap) offering the most
likely job first, instead of a menu screen.
- Every yes/no decision is a HOLD-TO-CONFIRM ring (0.6s) instead of
a Yes/No screen.
- Find is a pull-up search drawer from the panel, not its own flow.
--------------------------------------------------------------------- */
const MOCK_ITEMS = [
{name:'Palm Damask', color:'Sage', code:'DW-10423', sw:'sw1'},
{name:'Trellis Weave', color:'Indigo', code:'DW-88214', sw:'sw2'},
{name:'Linen Bloom', color:'Blush', code:'DW-55071', sw:'sw3'},
];
const BRANDS = ['York Wallcoverings','Brewster Home','Kravet','Cole & Son','Thibaut','Ronald Redding'];
const JOB_META = {
new: {ic:'🆕', label:'Add New Item'},
update: {ic:'🔄', label:'Update an Item'},
sample: {ic:'📦', label:'Sample Came In'},
};
const state = {
screen:'live', // live | picker | new-brand | new-confirm |
// update-confirm | update-shoot | update-save |
// find-drawer | find-result | find-detail |
// sample-confirm | sample-printing
job:null, item:null, brand:null, findIndex:0,
pickerOrder:['new','update','sample'],
toast:null, toastTimer:null,
};
function go(screen){ state.screen = screen; render(); }
function randomItem(){ return MOCK_ITEMS[Math.floor(Math.random()*MOCK_ITEMS.length)]; }
/* the ONE shoot action — captures pretend data */
function shoot(cb){ state.item = randomItem(); cb && cb(); }
/* tapping the shutter from idle (or the persistent mini-FAB anywhere else)
always leads to the picker: shoot first, decide the job second */
function mainShoot(){
shoot(()=>{
const likely = state.job || 'new';
state.pickerOrder = ['new','update','sample'];
state.pickerOrder.sort((a,b)=> (a===likely?-1:0) - (b===likely?-1:0));
go('picker');
});
}
function choosePicker(job){
state.job = job;
if(job==='new') go('new-brand');
else if(job==='update') go('update-confirm');
else if(job==='sample') go('sample-confirm');
}
function swipePicker(dir){
const o = state.pickerOrder;
if(dir>0) o.push(o.shift()); else o.unshift(o.pop());
render();
}
function chooseBrand(b){ state.brand = b; go('new-confirm'); }
function completeNew(){ state.toast = {emoji:'🎉', text:'Saved!'}; state.job=null; go('live'); }
function confirmUpdateMatch(){ go('update-shoot'); }
function shootUpdate(){ shoot(()=> go('update-save')); }
function completeUpdateSave(){ state.toast = {emoji:'✅', text:'Updated!'}; state.job=null; go('live'); }
function openFindDrawer(){ go('find-drawer'); }
function startFind(){ state.findIndex = 0; go('find-result'); }
function nextFindResult(){ state.findIndex = (state.findIndex+1) % MOCK_ITEMS.length; render(); }
function confirmFindMatch(){ go('find-detail'); }
function updateFromFind(){ go('update-shoot'); }
function confirmSampleReceived(){
go('sample-printing');
setTimeout(()=>{ state.toast = {emoji:'✅', text:'Received!'}; state.job=null; go('live'); }, 1100);
}
/* ---------- stage fragments (camera is always "live" behind everything) ---------- */
function stageTopbar(){
return `<div class="stage-topbar"><span class="dw">DW</span><span class="live-dot"><span class="dot"></span>LIVE</span></div>`;
}
function stageLive(){
return `${stageTopbar()}
<div class="stage-body">
<div class="idle-orb">📷</div>
<div class="stage-hint">Ready when you are.</div>
</div>`;
}
function stageViewfinder(hint){
return `${stageTopbar()}
<div class="stage-body">
<div class="stage-hint">${hint}</div>
<div class="viewfinder">
<div class="corner tl"></div><div class="corner tr"></div>
<div class="corner bl"></div><div class="corner br"></div>
<span class="glyph">🖼️</span>
</div>
</div>`;
}
function stageCaptured(caption){
const it = state.item || MOCK_ITEMS[0];
return `${stageTopbar()}
<div class="stage-body">
<div class="swatch-card ${it.sw}"></div>
<div class="item-caption">
<p class="item-name">${it.name} — ${it.color}</p>
<p class="item-meta">${caption}</p>
</div>
</div>`;
}
function stageMessage(emoji, text, sub){
return `${stageTopbar()}
<div class="stage-body">
<div class="big-emoji">${emoji}</div>
<div class="stage-hint">${text}</div>
${sub ? `<div class="stage-status">${sub}</div>` : ''}
</div>`;
}
function toastHTML(){
return state.toast ? `<div class="toast">${state.toast.emoji} ${state.toast.text}</div>` : '';
}
/* ---------- panel fragments ---------- */
function homeBtn(){ return `<button class="home-btn" aria-label="Home" onclick="go('live')">⌂</button>`; }
function panelHead(title){
return `<div class="panel-head">${state.screen!=='live' ? homeBtn() : ''}<div class="panel-title">${title}</div></div>`;
}
function miniFabHTML(){
return `<button class="mini-fab" aria-label="Take a new photo" onclick="mainShoot()">📸</button>`;
}
function holdRingHTML(id, label, sub){
return `<div class="hold-wrap">
<button type="button" class="hold-ring" id="${id}"><span class="hold-ring-inner">${label}</span></button>
<div class="hold-caption">${sub}</div>
</div>`;
}
function pickerCardHTML(job, isLikely){
const m = JOB_META[job];
return `<button class="picker-card ${isLikely?'likely':''}" onclick="choosePicker('${job}')">
<span class="ic">${m.ic}</span><span>${m.label}</span>
</button>`;
}
/* ---------- hold-to-confirm mechanics ---------- */
function attachHold(btnId, duration, onComplete){
const btn = document.getElementById(btnId);
if(!btn) return;
let raf=null, startTs=null, done=false;
function frame(ts){
if(startTs===null) startTs = ts;
const pct = Math.min(1, (ts-startTs)/duration);
btn.style.setProperty('--pct', (pct*100)+'%');
if(pct>=1 && !done){ done=true; setTimeout(onComplete, 100); }
else if(!done){ raf = requestAnimationFrame(frame); }
}
function start(e){ e.preventDefault(); if(done) return; startTs=null; btn.classList.add('holding'); raf=requestAnimationFrame(frame); }
function cancel(){ if(done) return; if(raf) cancelAnimationFrame(raf); startTs=null; btn.classList.remove('holding'); btn.style.setProperty('--pct','0%'); }
btn.addEventListener('pointerdown', start);
btn.addEventListener('pointerup', cancel);
btn.addEventListener('pointerleave', cancel);
btn.addEventListener('pointercancel', cancel);
}
function wirePickerSwipe(){
const el = document.querySelector('.picker-stack');
if(!el) return;
let startY = null;
el.addEventListener('touchstart', e=>{ startY = e.touches[0].clientY; }, {passive:true});
el.addEventListener('touchend', e=>{
if(startY===null) return;
const dy = e.changedTouches[0].clientY - startY;
if(Math.abs(dy) > 24) swipePicker(dy<0 ? 1 : -1);
startY = null;
}, {passive:true});
}
function wireInteractive(){
if(state.screen==='picker') wirePickerSwipe();
if(state.screen==='new-confirm') attachHold('holdNew', 600, completeNew);
if(state.screen==='update-confirm') attachHold('holdMatch', 600, confirmUpdateMatch);
if(state.screen==='update-save') attachHold('holdUpdateSave', 600, completeUpdateSave);
if(state.screen==='find-result') attachHold('holdFind', 600, confirmFindMatch);
if(state.screen==='sample-confirm') attachHold('holdSample', 600, confirmSampleReceived);
}
/* ---------- render ---------- */
function render(){
const stage = document.getElementById('stage');
const panel = document.getElementById('panel');
let s = '', p = '';
if(state.screen==='live'){
s = stageLive();
p = `${panelHead('Point & shoot')}
<div class="panel-body">
<div class="shutter-wrap">
<button class="shutter" aria-label="Take photo" onclick="mainShoot()">📸</button>
<div class="shutter-caption">Tap the shutter — we'll ask what it's for</div>
</div>
<button class="drawer-tab" onclick="openFindDrawer()">🔎 Find an item <span class="chev">▲</span></button>
</div>`;
}
else if(state.screen==='picker'){
s = stageCaptured('What was that for?');
p = `${panelHead("Got it — what's this for?")}
<div class="panel-body">
<div class="picker-hint">↕ swipe or tap to pick</div>
<div class="picker-stack">${state.pickerOrder.map((j,i)=>pickerCardHTML(j, i===0)).join('')}</div>
</div>`;
}
else if(state.screen==='new-brand'){
s = stageCaptured('New item spotted');
p = `${miniFabHTML()}${panelHead('Which brand is it?')}
<div class="panel-body">
<div class="chip-row">${BRANDS.map(b=>`<button class="chip" onclick="chooseBrand('${b}')">${b}</button>`).join('')}</div>
</div>`;
}
else if(state.screen==='new-confirm'){
s = stageCaptured(state.brand || 'Ready to save');
p = `${miniFabHTML()}${panelHead('Save this item?')}
<div class="panel-body">${holdRingHTML('holdNew', 'HOLD<br>TO SAVE', 'Press and hold 0.6s to confirm')}</div>`;
}
else if(state.screen==='update-confirm'){
s = stageCaptured('Is this the item?');
p = `${miniFabHTML()}${panelHead('Confirm the match')}
<div class="panel-body">
${holdRingHTML('holdMatch', "HOLD —<br>YES", 'Hold if this is the right item')}
<button class="linktext" onclick="mainShoot()">Not this — reshoot</button>
</div>`;
}
else if(state.screen==='update-shoot'){
s = stageViewfinder('Point at the replacement, then tap shutter.');
p = `${panelHead('Shoot the new photo')}
<div class="panel-body">
<div class="shutter-wrap">
<button class="shutter" aria-label="Take photo" onclick="shootUpdate()">📸</button>
<div class="shutter-caption">Tap the shutter to capture</div>
</div>
</div>`;
}
else if(state.screen==='update-save'){
s = stageCaptured('Ready to save the update');
p = `${miniFabHTML()}${panelHead('Save the update?')}
<div class="panel-body">${holdRingHTML('holdUpdateSave', 'HOLD<br>TO SAVE', 'Press and hold 0.6s to confirm')}</div>`;
}
else if(state.screen==='find-drawer'){
s = stageLive();
p = `${panelHead('Find an item')}
<div class="panel-body">
<button class="pbtn" onclick="startFind()">📷 Scan the Label</button>
<button class="pbtn" onclick="startFind()">🎤 Say the Name</button>
<button class="pbtn" onclick="startFind()">⌨️ Type It</button>
</div>`;
}
else if(state.screen==='find-result'){
state.item = MOCK_ITEMS[state.findIndex];
s = stageCaptured('Is this the one?');
p = `${miniFabHTML()}${panelHead('Confirm the item')}
<div class="panel-body">
${holdRingHTML('holdFind', "HOLD —<br>IT'S THIS", 'Hold to confirm this match')}
<button class="linktext" onclick="nextFindResult()">Show Next Result</button>
</div>`;
}
else if(state.screen==='find-detail'){
s = stageCaptured('Confirmed match');
p = `${panelHead(state.item.name)}
<div class="panel-body">
<button class="pbtn pbtn-primary" onclick="updateFromFind()">Update This</button>
<button class="pbtn pbtn-secondary" onclick="go('find-drawer')">Search Again</button>
</div>`;
}
else if(state.screen==='sample-confirm'){
s = stageCaptured('Mark this sample received?');
p = `${miniFabHTML()}${panelHead('Sample received?')}
<div class="panel-body">${holdRingHTML('holdSample', 'HOLD TO<br>CONFIRM', 'Hold to mark it received')}</div>`;
}
else if(state.screen==='sample-printing'){
s = stageMessage('🖨️', 'Printing your sticker…', 'One sec…');
p = `${panelHead('Printing')}<div class="panel-body"></div>`;
}
if(state.toast) s += toastHTML();
stage.innerHTML = s;
panel.innerHTML = p;
wireInteractive();
if(state.toast && !state.toastTimer){
state.toastTimer = setTimeout(()=>{ state.toast=null; state.toastTimer=null; render(); }, 1800);
}
}
render();
</script>
</body>
</html>