[object Object]

← back to Quadrille Showroom

auto-save: 2026-06-28T08:13:35 (4 files) — proto/shots/v10-concierge.png proto/v10-concierge.html proto/verify-v10.cjs public/js/showroom.js

6241da89e9d7ec3ec118b653a942f1e46442696b · 2026-06-28 08:13:37 -0700 · Steve Abrams

Files touched

Diff

commit 6241da89e9d7ec3ec118b653a942f1e46442696b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 28 08:13:37 2026 -0700

    auto-save: 2026-06-28T08:13:35 (4 files) — proto/shots/v10-concierge.png proto/v10-concierge.html proto/verify-v10.cjs public/js/showroom.js
---
 proto/shots/v10-concierge.png | Bin 724853 -> 559543 bytes
 proto/v10-concierge.html      | 113 +++++++++++++++++++++++++++++++++++++++---
 proto/verify-v10.cjs          |  27 +++++++++-
 public/js/showroom.js         |  12 +++++
 4 files changed, 143 insertions(+), 9 deletions(-)

diff --git a/proto/shots/v10-concierge.png b/proto/shots/v10-concierge.png
index f919725..1d96b7d 100644
Binary files a/proto/shots/v10-concierge.png and b/proto/shots/v10-concierge.png differ
diff --git a/proto/v10-concierge.html b/proto/v10-concierge.html
index 7ff8a57..985a08d 100644
--- a/proto/v10-concierge.html
+++ b/proto/v10-concierge.html
@@ -202,6 +202,32 @@
   .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);
@@ -237,6 +263,15 @@
     <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">
@@ -397,28 +432,62 @@ function scoreProduct(p, intent){
   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 intent = interpret(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);
-
-  // If query was a "like family but darker" and family match yielded few, broaden to bucket of that family
   let results = scored.map(x=>x.p);
 
-  // Fallback: if nothing matched but they typed *something*, do a loose substring across name
+  // Fallback: if nothing matched but they typed *something*, loose substring across name
   if(!results.length){
-    const toks=tokenize(query);
+    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);
 
-  return { results, line: concierge(query,intent,results) };
+  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){
+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>.`;
   }
@@ -438,6 +507,10 @@ function concierge(query,intent,results){
   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.`;
 }
@@ -460,6 +533,7 @@ function presentBoards(results){
     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>
@@ -468,11 +542,36 @@ function presentBoards(results){
         <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();
diff --git a/proto/verify-v10.cjs b/proto/verify-v10.cjs
index 2da3949..f917385 100644
--- a/proto/verify-v10.cjs
+++ b/proto/verify-v10.cjs
@@ -61,8 +61,31 @@ const fs = require('fs');
   const boardsRendered = await page.evaluate(() => document.querySelectorAll('.board').length);
   console.log('Warm neutrals chip -> boards in DOM:', boardsRendered);
 
-  // type into the box for the screenshot beauty shot
-  await page.fill('#askInput', 'like Aga but darker');
+  // ---- Conversational refinement: "show me greens" then "now in blue" ----
+  const conv = await page.evaluate(() => {
+    const a = window.runQuery('show me greens');
+    const greens = a.results.length;
+    const b = window.runQuery('now in blue');   // refinement carries context, swaps color axis
+    const allBlue = b.results.every(p => /aqua|blue|navy|turquoise|capri/i.test(p.pattern_name));
+    return { greens, blueN: b.results.length, allBlue, line: b.line };
+  });
+  console.log('Refinement "show greens" -> "now in blue":', conv.greens, '->', conv.blueN,
+              '| all blue:', conv.allBlue);
+  console.log('   concierge said:', conv.line.replace(/<[^>]+>/g,''));
+
+  // ---- Drive UI for beauty shot: greens, then set aside two, then "now in blue" ----
+  await page.fill('#askInput', 'fresh greens');
+  await page.click('.ask button');
+  await page.waitForTimeout(900);
+  // set aside the first three boards (dispatch via JS — they may be below the fold)
+  await page.evaluate(() => {
+    document.querySelectorAll('.board .setaside').forEach((b,i)=>{ if(i<3) b.click(); });
+  });
+  await page.waitForTimeout(300);
+  const trayN = await page.evaluate(() => document.querySelectorAll('.trayItem').length);
+  console.log('Set-aside tray items:', trayN);
+
+  await page.fill('#askInput', 'now show me those in blue');
   await page.click('.ask button');
   await page.waitForTimeout(1000);
   const finalBoards = await page.evaluate(() => document.querySelectorAll('.board').length);
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 691b598..a941940 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -2268,6 +2268,18 @@ function enterGuidedDefault() {
   restoreBoards();
   focusOnWing(wingBoards[mid]);
   guidedReady = true;
+  // PREFETCH the immediate neighbours' designs during the boot idle so the FIRST
+  // Next/Prev flip doesn't pay the texture-decode stall on the click frame. Staggered
+  // off the main thread via rAF so it never competes with the boot camera fly.
+  [mid + 1, mid - 1, mid + 2, mid - 2].forEach((idx, k) => {
+    const p = wingBoards[idx];
+    if (!p || p.userData.imageLoaded) return;
+    setTimeout(() => {
+      if (p.userData.imageLoaded) return;
+      p.userData.imageLoaded = true;
+      requestAnimationFrame(() => loadWingBook(p));
+    }, 600 + k * 220);
+  });
 }
 
 // ALL DESIGNS — big-thumbnail grid overlay. Builds from the live window products;

← 597fb5b proto: COLOR RIVER — shop China Seas by hue as a flowing Thr  ·  back to Quadrille Showroom  ·  Data enrichment: hue/color_bucket (878/883) + patternBase/ro e4cdbac →