← 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
M proto/shots/v10-concierge.pngM proto/v10-concierge.htmlM proto/verify-v10.cjsM public/js/showroom.js
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 →