← back to Dw Photo Capture
public/kid-simple/v3-text-buddy.html
552 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>v3 — Text Buddy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--paper:#EFEAE3;
--gold:#C9A24B;
--sage:#DCE8DA;
--text:#2B2B2B;
--muted:#6B6255;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:#0a0a0a;font-family:'Inter',sans-serif;}
.stage{
width:390px;height:780px;margin:24px auto;position:relative;
background:var(--paper);border-radius:36px;overflow:hidden;
box-shadow:0 20px 60px rgba(0,0,0,.5);border:8px solid #000;color:var(--text);
display:flex;flex-direction:column;
}
header{
display:flex;flex-direction:column;gap:8px;
padding:14px 16px 10px;border-bottom:1px solid #ded6c8;background:var(--paper);flex-shrink:0;
}
.header-top{display:flex;align-items:center;justify-content:space-between;}
header .wordmark{font-family:'Fraunces',serif;font-weight:700;font-size:18px;}
header .sam-label{font-size:14px;color:var(--muted);margin-top:2px;}
.header-actions{display:flex;gap:10px;}
.nav-btn{
flex:1;min-height:56px;background:#fff;border:2px solid #ded6c8;border-radius:14px;padding:0 10px;
font-size:17px;font-weight:600;cursor:pointer;color:var(--text);font-family:'Inter',sans-serif;
}
.nav-btn:disabled{opacity:.4;cursor:not-allowed;}
.nav-btn:focus-visible, .chip:focus-visible, .thumb-card:focus-visible, .result-card:focus-visible, input:focus-visible{outline:3px solid var(--gold);outline-offset:2px;}
.nav-btn:active, .chip:active, .result-card:active, .cam-bubble:active{transform:scale(.97);}
.demo-toggle-inline{font-size:14px;color:var(--muted);display:flex;align-items:center;gap:8px;}
.demo-toggle-inline input{width:18px;height:18px;}
#thread{
flex:1;overflow-y:auto;padding:18px 16px 24px;display:flex;flex-direction:column;gap:12px;
}
.bubble{
max-width:78%;padding:14px 16px;border-radius:16px;font-size:17px;line-height:1.4;
}
.sam{
align-self:flex-start;background:#fff;border-left:5px solid var(--gold);
border-radius:4px 16px 16px 16px;
}
.user{
align-self:flex-end;background:var(--sage);border-radius:16px 4px 16px 16px;
}
.step-note{align-self:flex-start;font-size:14px;color:var(--muted);padding:0 4px;}
.chips{display:flex;flex-wrap:wrap;gap:10px;align-self:flex-start;max-width:92%;}
.chip{
min-height:56px;padding:0 18px;border-radius:20px;background:#fff;border:2px solid var(--gold);
color:var(--text);font-size:17px;font-weight:600;cursor:pointer;
}
.cam-bubble{
align-self:flex-start;background:#fff;border:2px dashed var(--gold);border-radius:16px;
padding:18px;display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;
min-width:180px;min-height:56px;
}
.cam-bubble .cam-icon{font-size:40px;}
.cam-bubble .cam-label{font-size:17px;color:var(--muted);}
.swatch-bubble{
align-self:flex-end;width:180px;height:130px;border-radius:14px;
background:repeating-linear-gradient(45deg,#8FAE8B 0 20px,#B9CBB2 20px 40px),linear-gradient(135deg,#8FAE8B,#6f8d6c);
background-blend-mode:multiply;
}
.result-card{
align-self:flex-start;display:flex;gap:10px;background:#fff;border-radius:14px;padding:10px;
border:2px solid #ded6c8;cursor:pointer;max-width:88%;align-items:center;min-height:56px;
}
.result-card .thumb{width:56px;height:56px;border-radius:10px;flex-shrink:0;
background:repeating-linear-gradient(45deg,#C97B5B 0 12px,#e0a184 12px 24px);}
.result-card .rc-title{font-size:16px;font-weight:700;}
.result-card .rc-sub{font-size:17px;color:var(--muted);}
.full-item{
align-self:flex-start;background:#fff;border-radius:16px;padding:14px;border:2px solid #ded6c8;max-width:88%;
}
.full-item .swatch{width:100%;height:150px;border-radius:12px;margin-bottom:10px;
background:repeating-linear-gradient(45deg,#8FAE8B 0 20px,#B9CBB2 20px 40px),linear-gradient(135deg,#8FAE8B,#6f8d6c);
background-blend-mode:multiply;}
.full-item .swatch.blue{background:repeating-linear-gradient(45deg,#7C93B0 0 20px,#B3C3D6 20px 40px),linear-gradient(135deg,#7C93B0,#5f7692);background-blend-mode:multiply;}
.full-item .rc-title{font-size:18px;font-weight:700;}
.full-item .rc-sub{font-size:17px;color:var(--muted);}
.search-mock{
align-self:flex-start;background:#fff;border-radius:14px;padding:14px 16px;font-size:17px;
color:var(--muted);border:2px solid #ded6c8;min-width:220px;
}
.text-input{
width:100%;border:none;outline:none;font-size:17px;font-family:'Inter',sans-serif;
background:transparent;color:var(--text);min-height:24px;
}
.spinner-inline{display:inline-block;width:16px;height:16px;border-radius:50%;
border:3px solid rgba(0,0,0,.12);border-top-color:var(--gold);animation:spin 1s linear infinite;margin-right:8px;vertical-align:-3px;}
@keyframes spin{to{transform:rotate(360deg);}}
.toast{
position:absolute;left:16px;right:16px;bottom:16px;background:#2B2B2B;color:#fff;
border-radius:14px;padding:14px 18px;display:none;align-items:center;justify-content:space-between;
gap:12px;box-shadow:0 8px 24px rgba(0,0,0,.35);z-index:20;font-size:17px;border:2px solid var(--gold);
}
.toast.show{display:flex;}
.toast button{background:none;border:none;color:#F4D48A;font-weight:700;font-size:17px;cursor:pointer;min-height:56px;min-width:56px;padding:0 6px;}
@media (prefers-reduced-motion: reduce){
.spinner-inline{animation:none;border-top-color:var(--gold);}
*{transition:none !important;}
}
</style>
</head>
<body>
<div class="stage">
<header>
<div class="header-top">
<div>
<div class="wordmark">DW</div>
<div class="sam-label">chatting with Sam</div>
</div>
</div>
<div class="header-actions">
<button class="nav-btn" id="undoBtn" onclick="undoLast()" aria-label="Undo last">‹ Undo last</button>
<button class="nav-btn" onclick="resetChat()" aria-label="New chat">⌂ New chat</button>
</div>
<label class="demo-toggle-inline"><input type="checkbox" id="demoToggle" onchange="demoNoMatch=this.checked"> Demo: show "no match"</label>
</header>
<div id="thread" aria-live="polite"></div>
</div>
<!-- TOAST (undo) -->
<div class="toast" id="toast" role="status" aria-live="polite">
<span id="toastText">Saved</span>
<button onclick="toastUndo()">Undo</button>
</div>
<script>
const thread = document.getElementById('thread');
let demoNoMatch = false;
let stepStack = [];
let toastTimer = null;
function scrollBottom(){ thread.scrollTop = thread.scrollHeight; }
function setUndoEnabled(){
document.getElementById('undoBtn').disabled = stepStack.length === 0;
}
// Anything "awaiting a tap" (chips, camera prompt, brand picker, result list,
// type-a-code box) is tagged .pending-ui. pushStep always clears it first, so
// both a real click AND an undo-replay land on a clean, non-duplicated screen.
function clearPendingUI(){
document.querySelectorAll('#thread .pending-ui').forEach(el => el.remove());
}
function pushStep(fn){
clearPendingUI();
fn();
stepStack.push(fn);
setUndoEnabled();
}
function undoLast(){
if(stepStack.length === 0) return;
hideToast();
stepStack.pop();
const keep = stepStack;
stepStack = [];
thread.innerHTML = '';
startHome();
keep.forEach(pushStep);
setUndoEnabled();
}
function toastUndo(){ undoLast(); }
function resetChat(){
hideToast();
stepStack = [];
thread.innerHTML = '';
startHome();
setUndoEnabled();
}
function addSam(text){
const el = document.createElement('div');
el.className = 'bubble sam';
el.innerHTML = text;
thread.appendChild(el);
scrollBottom();
return el;
}
function addUser(text){
const el = document.createElement('div');
el.className = 'bubble user';
el.innerHTML = text;
thread.appendChild(el);
scrollBottom();
return el;
}
function addStepNote(text){
const el = document.createElement('div');
el.className = 'step-note';
el.textContent = text;
thread.appendChild(el);
scrollBottom();
}
function addChips(options){
const wrap = document.createElement('div');
wrap.className = 'chips pending-ui';
options.forEach(opt => {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = opt.label;
b.onclick = () => pushStep(opt.action);
wrap.appendChild(b);
});
thread.appendChild(wrap);
scrollBottom();
return wrap;
}
function addCameraBubble(onShot){
function showCameraStep(){
const el = document.createElement('div');
el.className = 'cam-bubble pending-ui';
el.setAttribute('role','button');
el.tabIndex = 0;
el.innerHTML = '<div class="cam-icon">📸</div><div class="cam-label">Tap to shoot</div>';
// shoot is a recorded step (pushStep, not a bare onclick) so tapping the
// shutter has its own undo point. Its "Looks good"/"Retake" actions never
// hold onto a closed-over DOM node — they re-query the live tree by the
// '.shot-pending' marker at click time, so a replay (which rebuilds the
// #thread DOM from scratch) never touches a node from a destroyed tree.
const shoot = () => {
const photo = addUser('<div class="swatch-bubble"></div>');
photo.classList.add('shot-pending');
const looking = addSam('<span class="spinner-inline"></span>Looking…');
setTimeout(() => {
looking.remove();
addSam("Here's what I got — good?");
addChips([
{label:'Looks good', action: () => {
const p = thread.querySelector('.shot-pending');
if(p) p.classList.remove('shot-pending');
onShot();
}},
{label:'Retake', action: () => {
const p = thread.querySelector('.shot-pending');
if(p) p.remove();
showCameraStep();
}},
]);
}, 900);
};
el.onclick = () => pushStep(shoot);
el.onkeydown = (e) => { if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); pushStep(shoot); } };
thread.appendChild(el);
scrollBottom();
}
showCameraStep();
}
function addDoneCard(opts){
const el = document.createElement('div');
el.className = 'full-item';
el.innerHTML = '<div class="swatch'+(opts.blue ? ' blue' : '')+'"></div>'+
'<div class="rc-title">'+opts.name+'</div>'+
'<div class="rc-sub">code '+opts.code+' · '+opts.brand+'</div>'+
'<div class="rc-sub" style="margin-top:6px;">'+opts.action+'</div>';
thread.appendChild(el);
scrollBottom();
}
function randomCode(){ return 'DW-' + Math.floor(10000 + Math.random() * 89999); }
function showToast(text){
document.getElementById('toastText').textContent = text;
const t = document.getElementById('toast');
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(hideToast, 5000);
}
function hideToast(){
document.getElementById('toast').classList.remove('show');
clearTimeout(toastTimer);
}
function startHome(){
thread.innerHTML = '';
addSam('Hey! What are we doing today?');
addChips([
{label:'Add New Item', action: flowAddStart},
{label:'Update an Item', action: flowUpdateStart},
{label:'Find an Item', action: flowFindStart},
{label:'A Sample Came In', action: flowSampleStart},
]);
}
// ---------- ADD NEW ITEM ----------
function flowAddStart(){
addUser('Add New Item');
addSam('Cool, snap a photo of it 📸');
addCameraBubble(() => {
addSam('Which brand?');
addBrandPicker((brand) => addAddSaved(brand));
});
}
function addBrandPicker(onPick){
const recent = ['York', 'Kravet', 'Thibaut'];
const all = ['Brewster', 'Cole & Son', 'Mulberry', 'Other / Unsure'];
const wrap = document.createElement('div');
wrap.className = 'pending-ui';
wrap.style.cssText = 'align-self:flex-start;width:100%;display:flex;flex-direction:column;gap:10px;';
wrap.innerHTML =
'<div class="search-mock" style="width:100%;"><input type="text" class="text-input" id="brandSearchInput" placeholder="Search brands…" aria-label="Search brands"></div>'+
'<div class="step-note">Recent</div>'+
'<div class="chips" id="recentChipsRow"></div>'+
'<div class="step-note">All Brands</div>'+
'<div class="chips" id="allChipsRow"></div>';
thread.appendChild(wrap);
const recentRow = wrap.querySelector('#recentChipsRow');
const allRow = wrap.querySelector('#allChipsRow');
function addBrandChip(row, name){
const b = document.createElement('button');
b.className = 'chip';
b.textContent = name;
b.onclick = () => pushStep(() => { addUser(name); onPick(name); });
row.appendChild(b);
}
recent.forEach(n => addBrandChip(recentRow, n));
all.forEach(n => addBrandChip(allRow, n));
scrollBottom();
wrap.querySelector('#brandSearchInput').addEventListener('input', function(){
const q = this.value.trim().toLowerCase();
Array.from(allRow.children).forEach(b => {
b.style.display = (q.length === 0 || b.textContent.toLowerCase().includes(q)) ? '' : 'none';
});
});
}
function addAddSaved(brand){
showToast('Saved · Undo');
addSam('Saved it as a draft! 🎉');
addDoneCard({name:'New Item', code: randomCode(), brand: brand, action:'Saved as a draft'});
addChips([
{label:'Do Another', action: () => { addUser('Do Another'); flowAddStart(); }},
{label:'Home', action: () => { addUser('Home'); resetChat(); }},
]);
}
// ---------- UPDATE AN ITEM ----------
function flowUpdateStart(){
addUser('Update an Item');
addStepNote('Step 1 of 2');
addSam('Show me the label or pattern');
addCameraBubble(() => {
if(demoNoMatch){
showNoMatch(
() => { addUser('Try again'); flowUpdateStart(); },
() => { addUser('Type the code instead'); updateTypeSearch(); }
);
return;
}
updateConfirm();
});
}
function updateConfirm(){
addSam("Found it — Palm Damask — Sage, code DW-10423. That the one?");
addChips([
{label:"Yep, that's it", action: () => {
addUser("Yep, that's it");
updateNewPhoto();
}},
{label:'Nope, let me search', action: () => {
addUser('Nope, let me search');
updateTypeSearch();
}},
]);
}
function updateTypeSearch(){
addSam('No problem — type the name or code:');
const el = document.createElement('div');
el.className = 'search-mock pending-ui';
el.innerHTML = '<input type="text" class="text-input" enterkeyhint="search" placeholder="Palm Damask…" aria-label="Type the item name or code">';
thread.appendChild(el);
scrollBottom();
const searchInput = el.querySelector('input');
const sendIt = () => {
const val = searchInput.value.trim() || 'Palm Damask';
pushStep(() => {
addUser(val);
addSam("Great — Palm Damask — Sage, code DW-10423. That the one?");
addChips([{label:"Yep, that's it", action: () => { addUser("Yep, that's it"); updateNewPhoto(); }}]);
});
};
searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); sendIt(); } });
addChips([{label:'Send', action: sendIt}]);
}
function updateNewPhoto(){
addStepNote('Step 2 of 2');
addSam('Send me the new photo');
addCameraBubble(() => {
showToast('Saved · Undo');
addSam('Updated! ✅');
addDoneCard({name:'Palm Damask — Sage', code:'DW-10423', brand:'York', action:'Updated the photo', blue:true});
addChips([
{label:'Do Another', action: () => { addUser('Do Another'); flowUpdateStart(); }},
{label:'Home', action: () => { addUser('Home'); resetChat(); }},
]);
});
}
// ---------- FIND AN ITEM ----------
function flowFindStart(){
addUser('Find an Item');
findAskMethod();
}
function findAskMethod(){
addSam('How do you want to search? 📷 label 🎤 voice ⌨️ type');
addChips([
{label:'📷 label', action: () => { addUser('📷 label'); findShowResults(); }},
{label:'🎤 voice', action: () => { addUser('🎤 voice'); findShowResults(); }},
{label:'⌨️ type', action: () => { addUser('⌨️ type'); findShowResults(); }},
]);
}
function findShowResults(){
if(demoNoMatch){
showNoMatch(
() => { addUser('Try again'); findAskMethod(); },
() => { addUser('Type the code instead'); findTypeSearch(); }
);
return;
}
addSam("Here's what I found:");
const items = [
{name:'Palm Damask — Sage', code:'DW-10423', brand:'York'},
{name:'Meadow Stripe — Gold', code:'DW-20115', brand:'Kravet'},
{name:'Coastal Weave — Blue', code:'DW-30871', brand:'Thibaut'},
];
const listWrap = document.createElement('div');
listWrap.className = 'pending-ui';
listWrap.style.cssText = 'display:flex;flex-direction:column;gap:10px;align-self:flex-start;width:100%;';
items.forEach(it => {
const card = document.createElement('div');
card.className = 'result-card';
card.tabIndex = 0;
card.setAttribute('role','button');
card.innerHTML = '<div class="thumb"></div><div><div class="rc-title">'+it.name+'</div><div class="rc-sub">code '+it.code+'</div></div>';
const pick = () => pushStep(() => { addUser(it.name); findShowDetail(it); });
card.onclick = pick;
card.onkeydown = (e) => { if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); pick(); } };
listWrap.appendChild(card);
});
thread.appendChild(listWrap);
scrollBottom();
}
function findTypeSearch(){
addSam('Type the item name or code:');
const el = document.createElement('div');
el.className = 'search-mock pending-ui';
el.innerHTML = '<input type="text" class="text-input" enterkeyhint="search" placeholder="DW-10423" aria-label="Type the item code">';
thread.appendChild(el);
scrollBottom();
const searchInput = el.querySelector('input');
const sendIt = () => {
const val = searchInput.value.trim() || 'DW-10423';
pushStep(() => {
addUser(val);
findShowDetail({name:'Palm Damask — Sage', code:'DW-10423', brand:'York'});
});
};
searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); sendIt(); } });
addChips([{label:'Send', action: sendIt}]);
}
function findShowDetail(it){
addSam('Found it —');
addDoneCard({name:it.name, code:it.code, brand:it.brand, action:'Found it'});
addChips([
{label:'Update This', action: () => {
addUser('Update This');
updateNewPhoto();
}},
{label:'Search Again', action: () => { addUser('Search Again'); findAskMethod(); }},
]);
}
// ---------- SAMPLE IN ----------
function flowSampleStart(){
addUser('A Sample Came In');
addSam('Scan the box label');
addCameraBubble(() => {
if(demoNoMatch){
showNoMatch(
() => { addUser('Try again'); flowSampleStart(); },
() => { addUser('Type the code instead'); sampleTypeSearch(); }
);
return;
}
sampleConfirm();
});
}
function sampleConfirm(){
addSam('Found it — mark it received?');
addChips([
{label:'Yes', action: () => { addUser('Yes'); sampleFinish(); }},
]);
}
function sampleTypeSearch(){
addSam('Type the item name or code:');
const el = document.createElement('div');
el.className = 'search-mock pending-ui';
el.innerHTML = '<input type="text" class="text-input" enterkeyhint="search" placeholder="DW-10423" aria-label="Type the item code">';
thread.appendChild(el);
scrollBottom();
const searchInput = el.querySelector('input');
const sendIt = () => {
const val = searchInput.value.trim() || 'DW-10423';
pushStep(() => {
addUser(val);
sampleConfirm();
});
};
searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); sendIt(); } });
addChips([{label:'Send', action: sendIt}]);
}
function sampleFinish(){
const printing = addSam('<span class="spinner-inline"></span>Printing your sticker now 🖨️');
setTimeout(() => {
printing.remove();
showToast('Saved · Undo');
addSam('All set! ✅');
addDoneCard({name:'Palm Damask — Sage', code:'DW-10423', brand:'York', action:'Marked as received'});
addChips([
{label:'Do Another', action: () => { addUser('Do Another'); flowSampleStart(); }},
{label:'Home', action: () => { addUser('Home'); resetChat(); }},
]);
}, 1200);
}
// ---------- SHARED NO-MATCH ----------
function showNoMatch(tryAgainFn, typeItFn){
addSam("Hmm, I couldn't find a match.");
addChips([
{label:'Try again', action: tryAgainFn},
{label:'Type the code instead', action: typeItFn},
]);
}
startHome();
setUndoEnabled();
</script>
</body>
</html>