← 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&nbsp;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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>