← back to Quadrille Showroom
proto/v10-concierge.html
661 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Quadrille House — Concierge Nook</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=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Jost:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root{
/* Art-direction palette */
--plaster:#e7e1d6;
--stone:#cfc7b8;
--greige:#b8afa0;
--oak:#9a7a52;
--oak-dark:#6f5536;
--brass:#b9933f;
--brass-lt:#c9a96e;
--ink:#1a1206;
--ink-soft:#473c2c;
--cream:#f0e6cf;
--charcoal:#1c1c22;
--felt:#3a352c; /* table felt */
--pane:#f5f0e7;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:'Jost',sans-serif;
color:var(--ink);
background:
radial-gradient(120% 90% at 50% -10%, #2c2820 0%, #211d16 45%, #16130d 100%);
overflow:hidden;
-webkit-font-smoothing:antialiased;
}
/* ===== Room shell: POV sitting at the consultation table ===== */
.room{
position:fixed; inset:0;
display:grid;
grid-template-rows: 1fr auto;
perspective:1400px;
perspective-origin:50% 38%;
}
/* Back wall — limewash plaster with a brass picture rail + soft picture-light glow */
.backwall{
position:absolute; inset:0 0 28% 0;
background:
radial-gradient(80% 55% at 50% 4%, rgba(201,169,110,.18), transparent 60%),
linear-gradient(180deg, #efe9dd 0%, #e3dccf 55%, #d6cdbc 100%);
box-shadow: inset 0 -40px 80px rgba(28,28,34,.18);
}
.backwall::after{ /* faint plaster mottle */
content:""; position:absolute; inset:0;
background:
radial-gradient(60% 40% at 22% 30%, rgba(0,0,0,.03), transparent 60%),
radial-gradient(50% 38% at 78% 22%, rgba(0,0,0,.025), transparent 60%);
mix-blend-mode:multiply;
}
.rail{ position:absolute; top:13%; left:0; right:0; height:5px;
background:linear-gradient(180deg,var(--brass-lt),var(--brass) 60%,#7e6526);
box-shadow:0 3px 9px rgba(0,0,0,.28), 0 1px 0 rgba(255,255,255,.35) inset;}
/* The consultation TABLE — a tilted oak surface receding from the viewer (the POV) */
.table-zone{ position:absolute; inset:0; overflow:hidden; }
.table{
position:absolute; left:50%; bottom:-6%;
width:175%; height:122%;
transform:translateX(-50%) rotateX(58deg);
transform-origin:50% 100%;
background:
linear-gradient(115deg, rgba(255,255,255,.05) 0%, transparent 30%),
repeating-linear-gradient(91deg, #a07d50 0 60px, #98774b 60px 122px),
linear-gradient(180deg,#a98a5c,#7c6038 70%,#5f4a2c);
border-radius:14px 14px 0 0;
box-shadow: 0 -2px 0 rgba(255,255,255,.18) inset, 0 40px 120px rgba(0,0,0,.55);
}
.table::before{ /* warm sheen / picture-light grazing the felt runner */
content:""; position:absolute; left:50%; top:6%; transform:translateX(-50%);
width:62%; height:70%;
background:
radial-gradient(60% 60% at 50% 0%, rgba(231,225,214,.14), transparent 70%);
border-radius:8px;
background-color:var(--felt);
box-shadow:inset 0 0 60px rgba(0,0,0,.5), 0 0 0 1px rgba(201,169,110,.25);
opacity:.9;
}
/* ===== Concierge assist panel (left, fixed) ===== */
.concierge{
position:fixed; top:0; left:0; bottom:0; width:380px;
background:linear-gradient(180deg, rgba(245,240,231,.97), rgba(238,232,221,.98));
border-right:1px solid rgba(185,147,63,.35);
box-shadow: 8px 0 40px rgba(0,0,0,.4);
display:flex; flex-direction:column;
z-index:20;
}
.c-head{ padding:26px 26px 16px; border-bottom:1px solid rgba(185,147,63,.25); }
.eyebrow{ font-size:11px; letter-spacing:2.4px; text-transform:uppercase; color:var(--brass);
font-weight:500; }
.wordmark{ font-family:'Cormorant Garamond',serif; font-weight:600; font-size:30px;
line-height:1.05; color:var(--ink); margin-top:4px; }
.wordmark em{ font-style:italic; color:var(--oak-dark); }
.seat{ font-size:12.5px; color:var(--ink-soft); margin-top:9px; line-height:1.5; }
/* conversation transcript */
.convo{ flex:1; overflow-y:auto; padding:18px 22px; display:flex; flex-direction:column; gap:14px;}
.convo::-webkit-scrollbar{width:7px} .convo::-webkit-scrollbar-thumb{background:rgba(185,147,63,.35);border-radius:4px}
.msg{ max-width:92%; font-size:14px; line-height:1.55; }
.msg.assist{ align-self:flex-start; }
.msg.assist .who{ font-size:10px; letter-spacing:2px; text-transform:uppercase; color:var(--brass); margin-bottom:4px; }
.msg.assist .body{ background:#fff; border:1px solid rgba(185,147,63,.22); border-radius:3px 14px 14px 14px;
padding:11px 14px; color:var(--ink); box-shadow:0 2px 10px rgba(0,0,0,.06); }
.msg.you{ align-self:flex-end; }
.msg.you .body{ background:var(--oak-dark); color:var(--cream); border-radius:14px 3px 14px 14px;
padding:9px 14px; font-family:'Cormorant Garamond',serif; font-size:17px; }
.msg .body em{ color:var(--oak-dark); font-style:italic; }
.msg.you .body em{ color:var(--brass-lt); }
/* chip palette */
.chips-wrap{ padding:14px 20px 8px; border-top:1px solid rgba(185,147,63,.25); }
.chips-label{ font-size:10px; letter-spacing:2px; text-transform:uppercase; color:var(--ink-soft); margin-bottom:10px; }
.chips{ display:flex; flex-wrap:wrap; gap:8px; }
.chip{
font-family:'Cormorant Garamond',serif; font-size:17px;
padding:9px 16px; border-radius:30px; cursor:pointer;
background:#fff; color:var(--ink);
border:1px solid rgba(185,147,63,.4);
transition:.18s; user-select:none; line-height:1;
}
.chip:hover{ background:var(--brass); color:#fff; border-color:var(--brass); transform:translateY(-1px); }
.chip:active{ transform:translateY(0); }
.chip .sw{ display:inline-block; width:11px; height:11px; border-radius:50%; margin-right:7px;
vertical-align:-1px; border:1px solid rgba(0,0,0,.15); }
/* text input bar */
.ask{ padding:16px 20px 20px; border-top:1px solid rgba(185,147,63,.25); }
.ask form{ display:flex; gap:8px; }
.ask input{
flex:1; font-family:'Cormorant Garamond',serif; font-size:18px;
padding:12px 15px; border-radius:30px; border:1px solid rgba(185,147,63,.45);
background:#fff; color:var(--ink); outline:none;
}
.ask input::placeholder{ color:#a99; font-style:italic; }
.ask input:focus{ border-color:var(--brass); box-shadow:0 0 0 3px rgba(185,147,63,.15); }
.ask button{ border:none; background:var(--oak-dark); color:var(--cream); cursor:pointer;
border-radius:50%; width:46px; height:46px; font-size:18px; flex:0 0 auto; transition:.18s; }
.ask button:hover{ background:var(--brass); }
/* ===== Boards gliding onto the table ===== */
.stage{ position:fixed; left:380px; right:0; top:0; bottom:0; z-index:10;
display:flex; align-items:center; justify-content:center; pointer-events:none; }
.boards{
pointer-events:auto;
display:flex; gap:30px; flex-wrap:wrap; justify-content:center; align-content:center;
max-width:1100px; max-height:88vh; overflow-y:auto; padding:40px 30px 20px;
perspective:1200px;
}
.boards::-webkit-scrollbar{width:8px} .boards::-webkit-scrollbar-thumb{background:rgba(201,169,110,.4);border-radius:4px}
.board{
width:210px; flex:0 0 auto;
transform-origin:50% 100%;
animation:glide .7s cubic-bezier(.18,.8,.28,1) backwards;
cursor:pointer;
}
@keyframes glide{
0%{ opacity:0; transform:translateY(70px) rotateX(28deg) scale(.86); }
100%{ opacity:1; transform:translateY(0) rotateX(0) scale(1); }
}
/* brass-framed sample board with gallery placard */
.board .frame{
position:relative; border-radius:4px;
padding:9px; background:linear-gradient(155deg,#d8c79c,var(--brass) 55%,#7e6526);
box-shadow:0 22px 44px rgba(0,0,0,.5), 0 2px 0 rgba(255,255,255,.3) inset;
transition:transform .35s cubic-bezier(.2,.7,.3,1), box-shadow .35s;
}
.board:hover .frame{ transform:translateY(-10px) rotateX(-4deg); box-shadow:0 34px 60px rgba(0,0,0,.6); }
.board .swatch{
width:100%; aspect-ratio:1/1; border-radius:2px; object-fit:cover;
background:var(--stone); display:block; box-shadow:0 0 0 1px rgba(0,0,0,.25) inset;
}
/* engraved-look placard */
.placard{ margin-top:9px; background:linear-gradient(180deg,#23201a,#171410);
border:1px solid var(--brass); border-radius:3px; padding:9px 11px 10px;
box-shadow:0 5px 14px rgba(0,0,0,.4); }
.placard .pname{ font-family:'Cormorant Garamond',serif; font-size:16px; color:var(--cream);
line-height:1.15; }
.placard .pmeta{ font-size:10px; letter-spacing:1.4px; text-transform:uppercase; color:var(--brass-lt);
margin-top:4px; display:flex; gap:6px; align-items:center; }
.placard .pmeta .dot{ width:9px;height:9px;border-radius:50%;border:1px solid rgba(255,255,255,.3); }
.placard a{ color:var(--brass-lt); text-decoration:none; font-size:10px; letter-spacing:1px;
text-transform:uppercase; display:inline-block; margin-top:7px; border-bottom:1px solid rgba(201,169,110,.4); }
.placard a:hover{ color:#fff; }
/* empty / welcome state floating over the table */
.welcome{ position:absolute; left:50%; top:34%; transform:translateX(-50%);
text-align:center; color:var(--cream); max-width:420px; pointer-events:none; }
.welcome .wm{ font-family:'Cormorant Garamond',serif; font-size:34px; font-weight:500; line-height:1.2;
text-shadow:0 4px 24px rgba(0,0,0,.6); }
.welcome .wm em{ font-style:italic; color:var(--brass-lt); }
.welcome .sub{ font-size:13px; letter-spacing:.4px; margin-top:12px; opacity:.78; line-height:1.6; }
/* set-aside button on each board */
.setaside{ position:absolute; top:14px; right:14px; z-index:3;
width:30px; height:30px; border-radius:50%; cursor:pointer;
border:1px solid var(--brass); background:rgba(28,28,34,.72); color:var(--brass-lt);
font-size:17px; line-height:1; display:flex; align-items:center; justify-content:center;
opacity:0; transform:scale(.8); transition:.2s; backdrop-filter:blur(3px); }
.board:hover .setaside{ opacity:1; transform:scale(1); }
.setaside:hover{ background:var(--brass); color:#fff; }
/* set-aside tray */
.tray{ position:fixed; left:380px; right:0; bottom:0; z-index:18; display:none;
flex-direction:column; gap:8px; padding:12px 26px 16px;
background:linear-gradient(0deg, rgba(22,19,13,.96), rgba(22,19,13,.78) 70%, transparent);
border-top:1px solid rgba(201,169,110,.35); }
.trayHead{ display:flex; align-items:center; gap:10px; }
.trayLabel{ font-size:10px; letter-spacing:2.2px; text-transform:uppercase; color:var(--brass-lt); }
.trayPill{ background:var(--brass); color:#fff; border-radius:20px; padding:1px 9px; font-size:11px; }
.trayItems{ display:flex; gap:10px; overflow-x:auto; padding-bottom:2px; }
.trayItem{ position:relative; flex:0 0 auto; }
.trayItem img{ width:54px; height:54px; object-fit:cover; border-radius:3px;
border:2px solid var(--brass); box-shadow:0 4px 12px rgba(0,0,0,.5); }
.trayX{ position:absolute; top:-6px; right:-6px; width:18px; height:18px; border-radius:50%;
border:none; background:var(--oak-dark); color:var(--cream); font-size:12px; cursor:pointer;
line-height:1; display:flex; align-items:center; justify-content:center; }
.trayX:hover{ background:#a4322b; }
/* result count ribbon */
.ribbon{ position:fixed; top:20px; right:30px; z-index:15;
background:rgba(28,28,34,.82); color:var(--cream); border:1px solid rgba(201,169,110,.4);
border-radius:30px; padding:8px 18px; font-size:11px; letter-spacing:2px; text-transform:uppercase;
backdrop-filter:blur(8px); display:none; }
.ribbon b{ color:var(--brass-lt); font-weight:500; }
@media (max-width:920px){
.concierge{ width:300px; } .stage{ left:300px; }
.board{ width:160px; }
}
</style>
</head>
<body>
<!-- POV room -->
<div class="room">
<div class="table-zone">
<div class="backwall"></div>
<div class="rail"></div>
<div class="table"></div>
</div>
</div>
<div class="ribbon" id="ribbon"></div>
<!-- Boards stage -->
<div class="stage">
<div class="welcome" id="welcome">
<div class="wm">Take a seat at the <em>consultation table.</em></div>
<div class="sub">Tell me what you're after — a colour, a feeling, a pattern you already love —
and I'll pull the boards for you, one at a time, right here in front of you.</div>
</div>
<div class="boards" id="boards"></div>
</div>
<!-- "Set aside" tray: favourites pulled toward you -->
<div class="tray" id="tray">
<div class="trayHead">
<span class="trayLabel">Set aside for you</span>
<span class="trayPill"><b id="trayCount">0</b></span>
</div>
<div class="trayItems" id="trayItems"></div>
</div>
<!-- Concierge assist panel -->
<aside class="concierge">
<div class="c-head">
<div class="eyebrow">The Quadrille House</div>
<div class="wordmark">Concierge <em>Nook</em></div>
<div class="seat">Your private appointment. I'm pulling from the China Seas atelier —
just say the word.</div>
</div>
<div class="convo" id="convo"></div>
<div class="chips-wrap">
<div class="chips-label">A few things people often ask for</div>
<div class="chips" id="chips"></div>
</div>
<div class="ask">
<form id="askForm" autocomplete="off">
<input id="askInput" type="text" placeholder="e.g. show me blues… or “like Aga but darker”" aria-label="Ask the concierge">
<button type="submit" title="Ask">→</button>
</form>
</div>
</aside>
<script>
/* ============================================================
CONCIERGE NOOK — client-side matching engine (no LLM)
Color/hue/bucket fields are null in the feed, so all signal
is derived from pattern_name + collection text.
============================================================ */
/* Relative path so it's same-origin whether served behind the proto proxy
(proto/proxy-server.cjs :7699 → backend :7690) or later from the app's public/.
Falls back to the absolute backend URL if same-origin fetch fails. */
const API = '/api/showroom/products?limit=200';
const API_FALLBACK = 'http://127.0.0.1:7690/api/showroom/products?limit=200';
let PRODUCTS = [];
/* ---- Color lexicon: word -> {bucket, hex (for the chip swatch)} ---- */
const COLOR_WORDS = {
// blues
navy:['blue','#1f2d52'], blue:['blue','#3a5fa0'], blues:['blue','#3a5fa0'],
aqua:['blue','#5fb8c4'], turquoise:['blue','#2fb5b0'], capri:['blue','#3aa0c8'],
// greens
green:['green','#4f7a3a'], greens:['green','#4f7a3a'], jungle:['green','#2f5d33'],
apple:['green','#7aa340'], lime:['green','#9bc23a'], pistachio:['green','#bcd49a'],
// warm / neutrals
cream:['neutral','#efe7d2'], white:['neutral','#f3eee2'], 'off-white':['neutral','#ece5d4'],
beige:['neutral','#d8ccb0'], gold:['warm','#c9a44a'], metallic:['warm','#c9b072'],
cognac:['warm','#9a5a2c'], brown:['warm','#6f4a2c'], burnt:['warm','#8a4326'],
shrimp:['warm','#e08e72'], salmon:['warm','#e89274'], coral:['warm','#e26d52'],
orange:['warm','#d2752c'], yellow:['warm','#e0bb46'],
// reds / pinks / purples
red:['red','#a4322b'], reds:['red','#a4322b'], watermelon:['red','#c8455a'],
magenta:['purple','#a63072'], pink:['pink','#dd86a4'], lilac:['purple','#b79ec8'],
purple:['purple','#6b4a8a'], plum:['purple','#5a2f52'],
// dark / black
black:['dark','#1c1a16'], charcoal:['dark','#2b2926']
};
/* ---- Style / pattern keywords (matched against pattern_name) ---- */
const STYLE_WORDS = {
stripe:['stripe','striped','bali','batik'],
trellis:['trellis','diamond','lattice'],
floral:['floral','flower','bouquet','arbre','rivage'],
bird:['bird','birds','aviary'],
geometric:['geometric','diamond','batik','isle']
};
/* ---- bucket -> warm description for the concierge to speak ---- */
const BUCKET_PHRASE = {
blue:'cool blues — from soft aqua to deep navy',
green:'fresh greens — jungle, apple, pistachio',
warm:'warm neutrals and earthy tones',
neutral:'quiet creams and whites',
red:'rich reds and watermelons',
pink:'soft pinks',
purple:'purples and lilacs',
dark:'darker, more dramatic grounds'
};
/* ---- family = first phrase of pattern_name (before " on ") ---- */
function familyOf(p){
const base = (p.pattern_name||'').split(/ on /i)[0];
// family root = first 1–2 words (e.g. "Aga", "Bali Hai", "Arbre de Matisse")
return base.replace(/\s*(screen printed|reverse|new).*/i,'').trim();
}
function familyRoot(p){ return (familyOf(p).split(/\s+/)[0]||'').toLowerCase(); }
/* lightness heuristic from ground/color words (for "darker"/"lighter") */
function lightness(p){
const n=(p.pattern_name||'').toLowerCase();
let score=0.5;
if(/white|cream|almost white|off white|pale|soft|light|pistachio|lilac/.test(n)) score+=0.32;
if(/navy|black|charcoal|jungle|brown|burnt|cognac|plum|dark|deep/.test(n)) score-=0.34;
if(/aqua|apple|lime|pink|shrimp|gold|yellow|salmon/.test(n)) score+=0.1;
return Math.max(0,Math.min(1,score));
}
/* ---- normalize a query into match intent ---- */
function tokenize(q){ return q.toLowerCase().replace(/[^a-z' ]/g,' ').split(/\s+/).filter(Boolean); }
function interpret(query){
const toks = tokenize(query);
const intent = { colors:new Set(), buckets:new Set(), styles:new Set(),
families:new Set(), darker:false, lighter:false, raw:query };
// direction words
if(toks.some(t=>/dark|deep|moody|richer|bolder/.test(t))) intent.darker=true;
if(toks.some(t=>/light|pale|soft|airy|brighter|paler/.test(t))) intent.lighter=true;
// color & bucket words
for(const t of toks){
if(COLOR_WORDS[t]){ intent.colors.add(t); intent.buckets.add(COLOR_WORDS[t][0]); }
if(t==='neutral'||t==='neutrals') intent.buckets.add('neutral').add('warm');
if(t==='warm') intent.buckets.add('warm');
if(t==='cool') intent.buckets.add('blue');
}
// style words (tolerate plurals: "stripes"->"stripe", "florals"->"floral")
const stem=t=>t.replace(/s$/,'');
for(const [style,words] of Object.entries(STYLE_WORDS)){
const want=new Set([style,...words].map(stem));
if(toks.some(t=>want.has(stem(t)))) intent.styles.add(style);
}
// "like X" family references — match any token against known family roots
const famRoots = new Set(PRODUCTS.map(familyRoot));
for(const t of toks){ if(famRoots.has(t)) intent.families.add(t); }
return intent;
}
/* ---- score a product against an intent ---- */
function scoreProduct(p, intent){
const n=(p.pattern_name||'').toLowerCase();
let s=0, hits=0;
// color words present in name
for(const c of intent.colors){ if(n.includes(c)){ s+=6; hits++; } }
// bucket: any color word in name that maps to a wanted bucket
if(intent.buckets.size){
for(const [w,[bk]] of Object.entries(COLOR_WORDS)){
if(intent.buckets.has(bk) && n.includes(w)){ s+=3; hits++; }
}
}
// style
for(const st of intent.styles){
if(STYLE_WORDS[st].some(w=>n.includes(w))){ s+=4; hits++; }
}
// family ("like Aga")
if(intent.families.size){
const fr=familyRoot(p);
if(intent.families.has(fr)){ s+=5; hits++; }
}
if(hits===0) return -1; // no positive signal at all -> exclude
// darker/lighter nudge
const L=lightness(p);
if(intent.darker) s += (1-L)*5;
if(intent.lighter) s += L*5;
// "like X but darker": family match + direction => big boost when both
if(intent.families.size && (intent.darker||intent.lighter)) s+=2;
return s;
}
/* ---- conversational memory: the appointment remembers what we've narrowed to ---- */
let LAST_INTENT = null;
function intentEmpty(i){
return !i.colors.size && !i.buckets.size && !i.styles.size && !i.families.size && !i.darker && !i.lighter;
}
/* a query is a REFINEMENT (carry prior context) when it starts with a connective
like "now", "but", "and", "those", "make them", or is just a bare modifier */
function isRefinement(query){
return /^(now|but|and|those|these|make (them|it)|in |something |a (bit|little) |more |less |warmer|cooler|darker|lighter|narrower)/i.test(query.trim());
}
function mergeIntents(prev, cur){
// start from prior; layer new signal on top. Direction words REPLACE, color/style ADD or REPLACE-color.
const m = { colors:new Set(prev.colors), buckets:new Set(prev.buckets), styles:new Set(prev.styles),
families:new Set(prev.families), darker:prev.darker, lighter:prev.lighter, raw:cur.raw };
// if the refinement names a NEW color/bucket, swap the color axis (e.g. "now in blue")
if(cur.colors.size || cur.buckets.size){ m.colors=new Set(cur.colors); m.buckets=new Set(cur.buckets); }
if(cur.styles.size){ cur.styles.forEach(s=>m.styles.add(s)); }
if(cur.families.size){ m.families=new Set(cur.families); }
if(cur.darker){ m.darker=true; m.lighter=false; }
if(cur.lighter){ m.lighter=true; m.darker=false; }
return m;
}
/* ---- run a query: return sorted matches + a spoken concierge line ---- */
function runQuery(query){
const q=query.trim();
// "start over" / "clear" resets the appointment
if(/^(start over|reset|clear|never mind|forget (that|it))/i.test(q)){
LAST_INTENT=null;
return { results:[], line:`Of course — clean slate. What shall we look at?`, reset:true };
}
let intent = interpret(q);
let refined=false;
if(LAST_INTENT && !intentEmpty(LAST_INTENT) && isRefinement(q)){
intent = mergeIntents(LAST_INTENT, intent); refined=true;
}
let scored = PRODUCTS.map(p=>({p,score:scoreProduct(p,intent)}))
.filter(x=>x.score>=0)
.sort((a,b)=>b.score-a.score);
let results = scored.map(x=>x.p);
// Fallback: if nothing matched but they typed *something*, loose substring across name
if(!results.length){
const toks=tokenize(q);
results = PRODUCTS.filter(p=>{const n=(p.pattern_name||'').toLowerCase();return toks.some(t=>t.length>2&&n.includes(t));});
}
results = results.slice(0,24);
if(results.length) LAST_INTENT = intent; // remember the active selection for the next turn
return { results, line: concierge(q,intent,results,refined) };
}
/* ---- concierge's warm spoken response ---- */
function concierge(query,intent,results,refined){
if(!results.length){
return `Hm — I don't have anything quite like “${esc(query)}” on the floor today. Try a colour like <em>blues</em> or <em>warm neutrals</em>, or name a pattern such as <em>Aga</em> or <em>Bali Hai</em>.`;
}
const bits=[];
if(intent.families.size){
const fam=[...intent.families][0];
if(intent.darker) bits.push(`the <em>${cap(fam)}</em> family, leaning to the darker, moodier grounds`);
else if(intent.lighter) bits.push(`the <em>${cap(fam)}</em> family in its lighter, airier colourways`);
else bits.push(`everything in the <em>${cap(fam)}</em> family`);
} else if(intent.buckets.size){
const ph=[...intent.buckets].map(b=>BUCKET_PHRASE[b]).filter(Boolean)[0];
bits.push(ph?`some lovely <em>${ph}</em>`:`a few in that palette`);
} else if(intent.styles.size){
bits.push(`pieces with a <em>${[...intent.styles][0]}</em> feel`);
} else if(intent.darker) bits.push(`the darker, more dramatic boards`);
else if(intent.lighter) bits.push(`the softest, lightest grounds`);
else bits.push(`a curated handful for you`);
const n=results.length;
if(refined){
const lead=['Adjusting now.','Refining for you.','Narrowing those.','Reworking the table.'][Math.floor(Math.random()*4)];
return `${lead} Re-laying the table with ${bits.join(', ')} — ${n} board${n>1?'s':''} now in front of you.`;
}
const lead=['Of course.','Lovely choice.','With pleasure.','Beautifully said.','Right this way.'][Math.floor(Math.random()*5)];
return `${lead} I'm bringing out ${bits.join(', ')} — ${n} board${n>1?'s':''} gliding onto the table now.`;
}
/* ---- render boards onto the table ---- */
function presentBoards(results){
const wrap=document.getElementById('boards');
const welcome=document.getElementById('welcome');
const ribbon=document.getElementById('ribbon');
welcome.style.display = results.length ? 'none':'block';
ribbon.style.display = results.length ? 'block':'none';
ribbon.innerHTML = `<b>${results.length}</b> boards on the table`;
wrap.innerHTML='';
results.forEach((p,i)=>{
const fam=familyOf(p);
const ground=(p.pattern_name||'').split(/ on /i)[1]||'';
const dot=swatchHex(p);
const el=document.createElement('div');
el.className='board';
el.style.animationDelay=(i*70)+'ms';
el.innerHTML=`
<div class="frame">
<button class="setaside" title="Set this one aside" data-sku="${esc(p.sku)}">+</button>
<img class="swatch" loading="lazy" src="${p.image}" alt="${esc(p.pattern_name)}"
onerror="this.style.opacity=.4;this.alt='image unavailable'">
</div>
<div class="placard">
<div class="pname">${esc(fam)}</div>
<div class="pmeta"><span class="dot" style="background:${dot}"></span>${esc((ground||p.vendor||'China Seas').trim())}</div>
<a href="${p.store_url||'#'}" target="_blank" rel="noopener">View this design →</a>
</div>`;
el.querySelector('.setaside').addEventListener('click',ev=>{ev.stopPropagation();setAside(p);});
wrap.appendChild(el);
});
wrap.scrollTop=0;
}
/* ---- "set aside" tray: the favorites you pull toward you at the table ---- */
const ASIDE=new Map();
function setAside(p){
if(ASIDE.has(p.sku)) return;
ASIDE.set(p.sku,p);
renderTray();
addMsg('assist', `I've set <em>${esc(familyOf(p))}</em> aside for you. ${ASIDE.size===1?'A lovely start.':'That makes '+ASIDE.size+'.'}`);
}
function removeAside(sku){ ASIDE.delete(sku); renderTray(); }
function renderTray(){
const tray=document.getElementById('tray');
const items=document.getElementById('trayItems');
tray.style.display = ASIDE.size ? 'flex':'none';
document.getElementById('trayCount').textContent = ASIDE.size;
items.innerHTML='';
[...ASIDE.values()].forEach(p=>{
const d=document.createElement('div'); d.className='trayItem';
d.innerHTML=`<img src="${p.image}" alt="${esc(p.pattern_name)}" title="${esc(familyOf(p))}">
<button class="trayX" title="Remove">×</button>`;
d.querySelector('.trayX').onclick=()=>removeAside(p.sku);
items.appendChild(d);
});
}
/* pick a representative swatch hex for a product from its color words */
function swatchHex(p){
const n=(p.pattern_name||'').toLowerCase();
for(const [w,[,hex]] of Object.entries(COLOR_WORDS)){ if(n.includes(w)) return hex; }
return '#cfc7b8';
}
/* ---- transcript ---- */
function addMsg(role,html){
const c=document.getElementById('convo');
const m=document.createElement('div'); m.className='msg '+role;
if(role==='assist') m.innerHTML=`<div class="who">Concierge</div><div class="body">${html}</div>`;
else m.innerHTML=`<div class="body">${html}</div>`;
c.appendChild(m); c.scrollTop=c.scrollHeight;
}
/* ---- handle an ask ---- */
function ask(query){
query=(query||'').trim(); if(!query) return;
addMsg('you', esc(query));
// tiny "pulling boards" beat
setTimeout(()=>{
const {results,line}=runQuery(query);
addMsg('assist', line);
presentBoards(results);
}, 260);
}
/* ---- chips ---- */
const CHIPS=[
{label:'Show me blues', q:'show me blues', c:'#3a5fa0'},
{label:'Fresh greens', q:'fresh greens', c:'#4f7a3a'},
{label:'Warm neutrals', q:'warm neutrals', c:'#d8ccb0'},
{label:'Something with stripes', q:'something with stripes', c:'#9a7a52'},
{label:'Soft & pale', q:'soft pale and light', c:'#efe7d2'},
{label:'Dark & dramatic', q:'dark and dramatic', c:'#2b2926'},
{label:'Like Aga, but darker', q:'like Aga but darker', c:'#2f5d33'},
{label:'Pinks & corals', q:'pink coral and shrimp', c:'#e08e72'},
{label:'Bali Hai', q:'Bali Hai', c:'#5fb8c4'}
];
function buildChips(){
const wrap=document.getElementById('chips');
CHIPS.forEach(ch=>{
const b=document.createElement('div'); b.className='chip';
b.innerHTML=`<span class="sw" style="background:${ch.c}"></span>${ch.label}`;
b.onclick=()=>ask(ch.q);
wrap.appendChild(b);
});
}
/* ---- utils ---- */
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function cap(s){return (s||'').charAt(0).toUpperCase()+(s||'').slice(1);}
/* ---- boot ---- */
async function boot(){
buildChips();
addMsg('assist', `Welcome — make yourself comfortable. I'm your design concierge for the
<em>China Seas</em> atelier. Tell me a colour, a mood, or a pattern you love, and I'll pull the boards.`);
try{
let r;
try{ r=await fetch(API); if(!r.ok) throw new Error(r.status); }
catch(_){ r=await fetch(API_FALLBACK); } // dev fallback when not behind the proxy
const d=await r.json();
PRODUCTS=(d.products||d).filter(p=>p&&p.image);
addMsg('assist', `I've ${PRODUCTS.length} boards ready behind me. Where shall we begin?`);
}catch(e){
addMsg('assist', `I'm having trouble reaching the sample room just now — please try again in a moment.`);
console.error('feed error',e);
}
}
document.getElementById('askForm').addEventListener('submit',e=>{
e.preventDefault();
const inp=document.getElementById('askInput');
ask(inp.value); inp.value='';
});
/* expose for verification harness */
window.runQuery=runQuery; window.interpret=interpret;
Object.defineProperty(window,'PRODUCTS',{get:()=>PRODUCTS});
boot();
</script>
</body>
</html>