← back to Quadrille Showroom
proto/v7-moodboard.html
565 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mood-Board Table — Quadrille / China Seas</title>
<style>
:root{
--ink:#1c1813;
--paper:#f4efe6;
--felt-a:#2a3b34; /* designer's table felt */
--felt-b:#1f2c27;
--rail:#efe7d8;
--line:#cdbfa6;
--accent:#9a6a3c; /* warm brass */
--accent-2:#c0894f;
--shadow:rgba(28,20,12,.28);
--tray-w:300px;
--rail-w:248px;
--card-r:3px;
--serif:"Hoefler Text",Georgia,"Times New Roman",serif;
--sans:"Helvetica Neue",Arial,system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
font-family:var(--sans);
color:var(--ink);
background:var(--paper);
overflow:hidden;
-webkit-font-smoothing:antialiased;
}
/* ---------- layout shell ---------- */
.app{display:grid;grid-template-columns:var(--tray-w) 1fr var(--rail-w);height:100vh;}
@media (max-width:1000px){ .app{grid-template-columns:220px 1fr;} .rail{display:none} }
/* ---------- masthead strip over the tray ---------- */
header.brand{
grid-column:1 / -1;
display:flex;align-items:baseline;gap:14px;
padding:14px 22px 12px;
border-bottom:1px solid var(--line);
background:linear-gradient(#fbf7ee,#f4efe6);
position:relative;z-index:5;
}
header.brand h1{
font-family:var(--serif);font-weight:500;letter-spacing:.14em;
font-size:19px;margin:0;text-transform:uppercase;
}
header.brand .sub{
font-family:var(--serif);font-style:italic;color:#7a6a52;font-size:13.5px;
}
header.brand .count{margin-left:auto;font-size:11px;letter-spacing:.18em;color:#8a7a60;text-transform:uppercase}
/* ---------- tray (left) ---------- */
.tray{
grid-row:2;grid-column:1;
background:#fbf7ee;
border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.tray .tray-hd{
padding:13px 18px 10px;border-bottom:1px solid #e7ddca;
}
.tray .tray-hd .lbl{font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;color:#9a8a6c}
.tray .tray-hd h2{font-family:var(--serif);font-size:15px;margin:3px 0 8px;font-weight:500}
.tray .search{
width:100%;padding:7px 10px;border:1px solid #ddd1ba;border-radius:2px;background:#fff;
font-family:var(--serif);font-size:13px;color:var(--ink);
}
.tray .search::placeholder{color:#b3a487;font-style:italic}
.tray-scroll{overflow-y:auto;padding:12px;flex:1;min-height:0;}
.tray-scroll::-webkit-scrollbar{width:8px}
.tray-scroll::-webkit-scrollbar-thumb{background:#d8ccb2;border-radius:4px}
.tray-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.swatch{
position:relative;cursor:grab;border-radius:var(--card-r);
background:#fff;border:1px solid #e3d8c2;overflow:hidden;
box-shadow:0 1px 2px rgba(0,0,0,.06);
transition:transform .12s ease,box-shadow .12s ease;
user-select:none;
}
.swatch:hover{transform:translateY(-2px);box-shadow:0 6px 14px rgba(0,0,0,.16);z-index:2}
.swatch:active{cursor:grabbing}
.swatch .img{aspect-ratio:1/1;background-size:cover;background-position:center;background-color:#efe7d6}
.swatch .meta{padding:5px 6px 6px;}
.swatch .pn{font-family:var(--serif);font-size:10.5px;line-height:1.15;color:#33291c;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:24px}
.swatch .cw{display:flex;align-items:center;gap:4px;margin-top:3px}
.swatch .dot{width:8px;height:8px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:none}
.swatch .cwn{font-size:9px;letter-spacing:.04em;color:#8a7a60;text-transform:uppercase;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* ---------- the table / board (center) ---------- */
.board-wrap{grid-row:2;grid-column:2;position:relative;overflow:hidden;}
.board{
position:absolute;inset:0;
background:
radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,.05), transparent 60%),
repeating-linear-gradient(135deg, rgba(0,0,0,.018) 0 2px, transparent 2px 5px),
linear-gradient(160deg,var(--felt-a),var(--felt-b));
box-shadow:inset 0 0 140px rgba(0,0,0,.45);
}
.board .vignette{position:absolute;inset:0;pointer-events:none;
box-shadow:inset 0 0 120px 30px rgba(0,0,0,.35)}
.board .hint{
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
text-align:center;color:rgba(244,239,230,.5);pointer-events:none;
font-family:var(--serif);transition:opacity .4s;width:80%;
}
.board .hint .big{font-size:21px;font-style:italic;letter-spacing:.02em}
.board .hint .small{font-size:12px;letter-spacing:.16em;text-transform:uppercase;margin-top:10px;color:rgba(244,239,230,.35)}
.board.has-cards .hint{opacity:0}
/* placed cards on the table */
.placed{
position:absolute;width:178px;cursor:grab;
border-radius:var(--card-r);background:#fff;
box-shadow:0 10px 24px var(--shadow),0 2px 4px rgba(0,0,0,.3);
border:5px solid #fff;border-bottom-width:26px; /* polaroid lip */
transition:box-shadow .15s ease;will-change:transform;
}
.placed.dragging{box-shadow:0 22px 50px rgba(0,0,0,.5);z-index:999 !important;cursor:grabbing}
.placed.selected{outline:2px solid var(--accent-2);outline-offset:3px}
.placed .pimg{display:block;width:100%;aspect-ratio:1/1;object-fit:cover;background:#eee;border-radius:1px}
.placed .plabel{
position:absolute;left:6px;right:6px;bottom:5px;height:18px;
font-family:var(--serif);font-size:9.5px;color:#3a2f20;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.placed .pclose{
position:absolute;top:-9px;right:-9px;width:20px;height:20px;border-radius:50%;
background:#1f2c27;color:#f4efe6;border:1px solid rgba(255,255,255,.5);
font-size:12px;line-height:18px;text-align:center;cursor:pointer;opacity:0;transition:opacity .12s;
box-shadow:0 2px 5px rgba(0,0,0,.4)}
.placed:hover .pclose{opacity:1}
.placed .proom{
position:absolute;left:-7px;bottom:30px;width:26px;height:26px;border-radius:50%;
border:2px solid #fff;background-size:cover;background-position:center;cursor:pointer;
box-shadow:0 2px 6px rgba(0,0,0,.4);opacity:0;transition:opacity .12s}
.placed:hover .proom{opacity:1}
/* drop-target glow when dragging from tray */
.board-wrap.drop-active .board{box-shadow:inset 0 0 0 3px rgba(192,137,79,.6),inset 0 0 140px rgba(0,0,0,.45)}
/* pairs-well-with fan (popover near selected card) */
.pairs{
position:absolute;z-index:1200;width:208px;
background:rgba(20,16,11,.93);backdrop-filter:blur(6px);
border:1px solid rgba(192,137,79,.4);border-radius:6px;
padding:11px 11px 12px;box-shadow:0 18px 44px rgba(0,0,0,.55);color:#f4efe6;
}
.pairs h3{margin:0 0 2px;font-family:var(--serif);font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;color:#d9b483}
.pairs .sub{font-size:10px;color:#b6a888;margin-bottom:9px;font-style:italic;font-family:var(--serif)}
.pairs .prow{display:flex;gap:8px;align-items:center;padding:5px 4px;border-radius:4px;cursor:grab;
transition:background .12s}
.pairs .prow:hover{background:rgba(192,137,79,.16)}
.pairs .prow .thumb{width:38px;height:38px;border-radius:3px;background-size:cover;background-position:center;flex:none;border:1px solid rgba(255,255,255,.2)}
.pairs .prow .pn{font-family:var(--serif);font-size:11px;line-height:1.2;color:#f1e8d8}
.pairs .prow .why{font-size:8.5px;letter-spacing:.1em;text-transform:uppercase;color:#cf9f63;margin-top:2px}
.pairs .pclose-x{position:absolute;top:6px;right:9px;cursor:pointer;color:#b6a888;font-size:15px;line-height:1}
.pairs .empty{font-size:11px;color:#b6a888;font-style:italic;font-family:var(--serif);padding:6px 2px}
/* ---------- palette rail (right) ---------- */
.rail{
grid-row:2;grid-column:3;background:var(--rail);border-left:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.rail .rail-hd{padding:14px 18px 8px;border-bottom:1px solid #e3d8c2}
.rail .rail-hd .lbl{font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;color:#9a8a6c}
.rail .rail-hd h2{font-family:var(--serif);font-size:16px;margin:4px 0 0;font-weight:500}
.palette-strip{display:flex;height:64px;margin:14px 16px 4px;border-radius:4px;overflow:hidden;
box-shadow:0 2px 6px rgba(0,0,0,.14);border:1px solid rgba(0,0,0,.08)}
.palette-strip .pchip{flex:1;position:relative}
.palette-strip .pchip span{position:absolute;bottom:4px;left:0;right:0;text-align:center;
font-size:8px;letter-spacing:.05em;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.7);
text-transform:uppercase;mix-blend-mode:normal}
.palette-empty{margin:14px 16px;font-family:var(--serif);font-style:italic;font-size:12px;color:#a8997c;
border:1px dashed #d3c6ac;border-radius:5px;padding:18px 12px;text-align:center;height:64px;
display:flex;align-items:center;justify-content:center}
.rail-body{overflow-y:auto;padding:6px 16px 18px;flex:1;min-height:0}
.rail-body::-webkit-scrollbar{width:8px}
.rail-body::-webkit-scrollbar-thumb{background:#d8ccb2;border-radius:4px}
.rsec{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#9a8a6c;margin:14px 0 7px}
.onboard-item{display:flex;gap:9px;align-items:center;padding:6px 4px;border-radius:4px}
.onboard-item:hover{background:#e9dfca}
.onboard-item .t{width:34px;height:34px;border-radius:3px;background-size:cover;background-position:center;flex:none;border:1px solid #d9cdb4}
.onboard-item .n{font-family:var(--serif);font-size:11px;color:#3a2f20;line-height:1.15}
.onboard-item .fam{font-size:8.5px;letter-spacing:.1em;text-transform:uppercase;color:#a08c66;margin-top:1px}
.rail .actions{padding:12px 16px;border-top:1px solid #e3d8c2;display:flex;gap:8px}
.rail .actions button{
flex:1;font-family:var(--sans);font-size:11px;letter-spacing:.1em;text-transform:uppercase;
padding:9px 6px;border:1px solid var(--accent);background:transparent;color:var(--accent);
border-radius:3px;cursor:pointer;transition:all .15s}
.rail .actions button:hover{background:var(--accent);color:#fff}
.rail .actions button.solid{background:var(--accent);color:#fff}
.rail .actions button.solid:hover{background:var(--ink);border-color:var(--ink)}
/* drag ghost */
.drag-ghost{position:fixed;z-index:5000;pointer-events:none;width:150px;opacity:.92;
border:5px solid #fff;border-bottom-width:22px;border-radius:3px;background:#fff;
box-shadow:0 16px 40px rgba(0,0,0,.5);transform:rotate(-4deg)}
.drag-ghost img{display:block;width:100%;aspect-ratio:1/1;object-fit:cover}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);
background:var(--ink);color:#f4efe6;padding:10px 18px;border-radius:30px;font-size:12px;
letter-spacing:.04em;opacity:0;transition:all .3s;z-index:6000;font-family:var(--serif)}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<div class="app">
<header class="brand">
<h1>The Design Table</h1>
<span class="sub">drag swatches · build a palette · find what pairs</span>
<span class="count" id="brandCount">loading…</span>
</header>
<!-- TRAY -->
<aside class="tray">
<div class="tray-hd">
<div class="lbl">Swatch Library</div>
<h2>Quadrille & China Seas</h2>
<input class="search" id="search" placeholder="search pattern or colorway…">
</div>
<div class="tray-scroll"><div class="tray-grid" id="trayGrid"></div></div>
</aside>
<!-- BOARD -->
<main class="board-wrap" id="boardWrap">
<div class="board" id="board">
<div class="vignette"></div>
<div class="hint">
<div class="big">Pull swatches onto the table.</div>
<div class="small">drag from the left · click a placed swatch to find its companions</div>
</div>
</div>
</main>
<!-- PALETTE RAIL -->
<aside class="rail">
<div class="rail-hd">
<div class="lbl">Working Palette</div>
<h2>Your Scheme</h2>
</div>
<div id="paletteHost"><div class="palette-empty">your palette builds as you place swatches</div></div>
<div class="rail-body">
<div class="rsec" id="onboardSec" style="display:none">On the table</div>
<div id="onboardList"></div>
</div>
<div class="actions">
<button id="clearBtn">Clear table</button>
<button id="copyBtn" class="solid">Copy scheme</button>
</div>
</aside>
</div>
<div class="toast" id="toast"></div>
<script>
const API="http://127.0.0.1:7690/api/showroom/products?limit=120";
let PRODUCTS=[], FAMILIES={}, placed=[], selId=null, pairsEl=null, zTop=10;
/* ---------- color vocabulary: derive a swatch's hue from its pattern name ---------- */
const COLOR_WORDS = {
// word -> [bucket, hex]
apple:["green","#7aa83f"], green:["green","#4f8f4a"], jungle:["green","#2f6b3a"], pistachio:["green","#bcd09b"],
emerald:["green","#1f7a52"], leaf:["green","#5a8f3c"], lettuce:["green","#9cc46a"], moss:["green","#6b7a3a"],
turquoise:["aqua","#3fb6b0"], aqua:["aqua","#5cc0c8"], teal:["aqua","#2c8d92"], peacock:["aqua","#1f6f78"],
navy:["blue","#22335e"], blue:["blue","#3a5a9c"], denim:["blue","#4a6fa0"], delft:["blue","#2d4a86"],
cobalt:["blue","#2747a0"], sky:["blue","#8fb4dd"], periwinkle:["blue","#8e9fd6"], slate:["blue","#5a6e86"],
purple:["purple","#7d4f93"], lilac:["purple","#b89dc9"], lavender:["purple","#b6a7d6"], plum:["purple","#6b3a5e"],
aubergine:["purple","#4a2a44"], orchid:["purple","#a564a8"], grape:["purple","#5e3a6e"],
watermelon:["pink","#e0607a"], pink:["pink","#e08aa0"], rose:["pink","#d8758a"], magenta:["pink","#c43a78"],
fuchsia:["pink","#cf3f86"], coral:["pink","#e87a64"], blush:["pink","#e6b5bd"], raspberry:["pink","#b03060"],
red:["red","#b8403a"], crimson:["red","#9e2a2a"], cherry:["red","#c0392b"], brick:["red","#a04a38"],
orange:["orange","#e08a3a"], tangerine:["orange","#e57733"], rust:["orange","#a85a30"], terracotta:["orange","#b56a47"],
gold:["gold","#c9a23f"], yellow:["gold","#e0c14a"], ochre:["gold","#bf9a3a"], straw:["gold","#d8c87a"],
mustard:["gold","#c99a2a"], sand:["neutral","#cbb893"], camel:["neutral","#b89a6a"], khaki:["neutral","#9a8a5e"],
brown:["brown","#6b4a32"], chocolate:["brown","#4a3324"], coffee:["brown","#5a4030"], taupe:["neutral","#9a8a78"],
tobacco:["brown","#6e4a2a"], walnut:["brown","#5a3e28"], chestnut:["brown","#6a3f2a"],
black:["dark","#26211b"], charcoal:["dark","#3a3733"], ebony:["dark","#22201d"], onyx:["dark","#2a2724"],
grey:["neutral","#9a948b"], gray:["neutral","#9a948b"], dove:["neutral","#bdb6ab"], pewter:["neutral","#8a857c"],
silver:["neutral","#c3c0ba"], stone:["neutral","#b0a896"],
white:["light","#f3efe6"], cream:["light","#efe7d4"], ivory:["light","#f1e9d6"], oyster:["light","#e7ddc9"],
beige:["light","#e3d8bf"], natural:["light","#e8dfc8"], linen:["light","#ece3cf"], chalk:["light","#f0ead9"],
almost:["light","#eee6d6"], off:["light","#eee6d6"], celadon:["aqua","#bcd0c0"]
};
const BUCKET_HEX={green:"#5a8f4a",aqua:"#3fb6b0",blue:"#3a5a9c",purple:"#7d4f93",pink:"#d8758a",
red:"#b8403a",orange:"#e08a3a",gold:"#c9a23f",brown:"#5a4030",dark:"#2a2622",neutral:"#a39a88",light:"#eadfca"};
function family(p){ return (p.pattern_name||"").trim().split(/\s+on\s+/i)[0].split(/\s+/)[0].toLowerCase(); }
function familyLabel(p){ return (p.pattern_name||"").trim().split(/\s+on\s+/i)[0]; }
// derive [bucket,hex,word] for the *motif* color of a swatch from its name
function swatchColor(p){
const name=(p.pattern_name||"").toLowerCase();
// prefer color word appearing BEFORE " on " (the ink/motif), else anywhere
const beforeOn = name.split(/\s+on\s+/i)[0];
for(const seg of [beforeOn, name]){
for(const w of seg.split(/[^a-z]+/)){
if(COLOR_WORDS[w]) return {bucket:COLOR_WORDS[w][0], hex:COLOR_WORDS[w][1], word:w};
}
}
return {bucket:"neutral", hex:"#b3a487", word:""};
}
function groundColor(p){
const name=(p.pattern_name||"").toLowerCase();
const afterOn = name.split(/\s+on\s+/i)[1]||"";
for(const w of afterOn.split(/[^a-z]+/)){ if(COLOR_WORDS[w]) return COLOR_WORDS[w][1]; }
return null;
}
/* ---------- load ---------- */
async function load(){
const r=await fetch(API); const d=await r.json();
PRODUCTS=d.products.map((p,i)=>{const c=swatchColor(p);return {...p,_i:i,_fam:family(p),_c:c,_ground:groundColor(p)};});
document.getElementById("brandCount").textContent=`${PRODUCTS.length} of ${d.total} patterns`;
FAMILIES={}; PRODUCTS.forEach(p=>{(FAMILIES[p._fam]=FAMILIES[p._fam]||[]).push(p);});
renderTray(PRODUCTS);
}
function renderTray(list){
const g=document.getElementById("trayGrid"); g.innerHTML="";
list.forEach(p=>{
const el=document.createElement("div"); el.className="swatch"; el.dataset.i=p._i;
el.innerHTML=`
<div class="img" style="background-image:url('${p.image}')"></div>
<div class="meta">
<div class="pn">${esc(p.pattern_name)}</div>
<div class="cw"><span class="dot" style="background:${p._c.hex}"></span>
<span class="cwn">${p._c.word? cap(p._c.word):p._fam}</span></div>
</div>`;
el.addEventListener("pointerdown",e=>startTrayDrag(e,p));
g.appendChild(el);
});
}
document.getElementById("search").addEventListener("input",e=>{
const q=e.target.value.toLowerCase().trim();
renderTray(!q?PRODUCTS:PRODUCTS.filter(p=>p.pattern_name.toLowerCase().includes(q)||p._fam.includes(q)||p._c.word.includes(q)));
});
/* ---------- drag from tray onto the board ---------- */
let ghost=null, dragP=null;
function startTrayDrag(e,p){
e.preventDefault();
dragP=p;
ghost=document.createElement("div"); ghost.className="drag-ghost";
ghost.innerHTML=`<img src="${p.image}">`;
document.body.appendChild(ghost);
moveGhost(e);
document.getElementById("boardWrap").classList.add("drop-active");
window.addEventListener("pointermove",moveGhost);
window.addEventListener("pointerup",dropTray,{once:true});
}
function moveGhost(e){ if(ghost){ghost.style.left=(e.clientX-75)+"px";ghost.style.top=(e.clientY-60)+"px";} }
function dropTray(e){
window.removeEventListener("pointermove",moveGhost);
document.getElementById("boardWrap").classList.remove("drop-active");
const bw=document.getElementById("boardWrap").getBoundingClientRect();
if(ghost){ghost.remove();ghost=null;}
if(dragP && e.clientX>=bw.left && e.clientX<=bw.right && e.clientY>=bw.top && e.clientY<=bw.bottom){
placeCard(dragP, e.clientX-bw.left-89, e.clientY-bw.top-100);
}
dragP=null;
}
/* ---------- placed cards ---------- */
function placeCard(p,x,y){
const bw=document.getElementById("boardWrap").getBoundingClientRect();
x=Math.max(8,Math.min(x, bw.width-186)); y=Math.max(8,Math.min(y, bw.height-220));
const rot=(Math.random()*9-4.5).toFixed(2);
const id="c"+(Date.now())+Math.floor(Math.random()*999);
const card={id,p,x,y,rot};
placed.push(card);
const el=document.createElement("div"); el.className="placed"; el.dataset.id=id;
el.style.left=x+"px"; el.style.top=y+"px"; el.style.zIndex=++zTop;
el.style.transform=`rotate(${rot}deg)`;
el.innerHTML=`
<img class="pimg" src="${p.image}" draggable="false">
<div class="plabel">${esc(p.pattern_name)}</div>
<div class="pclose" data-close="${id}">×</div>
${p.room?`<div class="proom" title="see it in a room" style="background-image:url('${p.room}')" data-room="${id}"></div>`:""}`;
card.el=el;
document.getElementById("board").appendChild(el);
document.getElementById("board").classList.add("has-cards");
el.addEventListener("pointerdown",e=>startCardDrag(e,card));
el.querySelector(".pclose").addEventListener("pointerdown",e=>{e.stopPropagation();removeCard(id);});
const rm=el.querySelector(".proom"); if(rm) rm.addEventListener("pointerdown",e=>{e.stopPropagation();showRoom(p);});
refreshPalette();
selectCard(id);
}
function startCardDrag(e,card){
if(e.target.dataset.close||e.target.dataset.room) return;
e.preventDefault();
selectCard(card.id);
const el=card.el; el.classList.add("dragging"); el.style.zIndex=++zTop;
const bw=document.getElementById("boardWrap").getBoundingClientRect();
const ox=e.clientX-bw.left-card.x, oy=e.clientY-bw.top-card.y;
let moved=false;
function mv(ev){
moved=true;
let nx=ev.clientX-bw.left-ox, ny=ev.clientY-bw.top-oy;
nx=Math.max(-40,Math.min(nx,bw.width-140)); ny=Math.max(-10,Math.min(ny,bw.height-120));
card.x=nx; card.y=ny; el.style.left=nx+"px"; el.style.top=ny+"px";
if(pairsEl) positionPairs(card);
}
function up(){
window.removeEventListener("pointermove",mv); el.classList.remove("dragging");
if(!moved) selectCard(card.id); // pure click = (re)select & show pairs
}
window.addEventListener("pointermove",mv);
window.addEventListener("pointerup",up,{once:true});
}
function removeCard(id){
const i=placed.findIndex(c=>c.id===id); if(i<0)return;
placed[i].el.remove(); placed.splice(i,1);
if(selId===id){selId=null;closePairs();}
if(!placed.length) document.getElementById("board").classList.remove("has-cards");
refreshPalette();
}
function selectCard(id){
selId=id;
placed.forEach(c=>c.el.classList.toggle("selected",c.id===id));
const card=placed.find(c=>c.id===id);
if(card) showPairs(card);
}
/* ---------- PAIRS WELL WITH ---------- */
function pairsFor(p){
// 1) same family, different colorway 2) same color bucket, different family
const sameFam=PRODUCTS.filter(x=>x._fam===p._fam && x.sku!==p.sku)
.map(x=>({x,why:"Same pattern",rank:0}));
const seenFam=new Set(sameFam.map(o=>o.x._fam));
const sameColor=PRODUCTS.filter(x=>x._c.bucket===p._c.bucket && x._fam!==p._fam)
.map(x=>({x,why:cap(p._c.bucket)+" palette",rank:1}));
// de-dupe by sku, prefer family matches, cap at 6, diversify families on the color side
const out=[]; const skus=new Set([p.sku]); const colorFams=new Set();
for(const o of sameFam){ if(skus.has(o.x.sku))continue; skus.add(o.x.sku); out.push(o); if(out.length>=4)break; }
for(const o of sameColor){ if(out.length>=6)break; if(skus.has(o.x.sku))continue;
if(colorFams.has(o.x._fam))continue; colorFams.add(o.x._fam); skus.add(o.x.sku); out.push(o); }
// top up with any color match if still short
for(const o of sameColor){ if(out.length>=6)break; if(skus.has(o.x.sku))continue; skus.add(o.x.sku); out.push(o); }
return out.slice(0,6);
}
function showPairs(card){
closePairs();
const p=card.p; const list=pairsFor(p);
const el=document.createElement("div"); el.className="pairs"; pairsEl=el; pairsEl._for=card.id;
el.innerHTML=`<span class="pclose-x" id="pairsClose">×</span>
<h3>Pairs well with</h3>
<div class="sub">${esc(familyLabel(p))} · ${cap(p._c.word||p._c.bucket)}</div>
<div id="pairsRows"></div>`;
document.getElementById("boardWrap").appendChild(el);
const rows=el.querySelector("#pairsRows");
if(!list.length){ rows.innerHTML=`<div class="empty">A standout — no close companions in this set. It anchors the room on its own.</div>`; }
list.forEach(o=>{
const r=document.createElement("div"); r.className="prow";
r.innerHTML=`<div class="thumb" style="background-image:url('${o.x.image}')"></div>
<div><div class="pn">${esc(o.x.pattern_name)}</div><div class="why">${o.why}</div></div>`;
r.addEventListener("pointerdown",e=>{ e.preventDefault(); startPairDrag(e,o.x); });
rows.appendChild(r);
});
el.querySelector("#pairsClose").addEventListener("pointerdown",e=>{e.stopPropagation();closePairs();selId=null;placed.forEach(c=>c.el.classList.remove("selected"));});
positionPairs(card);
}
function positionPairs(card){
if(!pairsEl)return;
const bw=document.getElementById("boardWrap").getBoundingClientRect();
let x=card.x+196, y=card.y;
if(x+216>bw.width) x=card.x-216;
if(x<6) x=6;
y=Math.max(6,Math.min(y,bw.height-260));
pairsEl.style.left=x+"px"; pairsEl.style.top=y+"px";
}
function closePairs(){ if(pairsEl){pairsEl.remove();pairsEl=null;} }
// drag a pairs suggestion straight onto the table
function startPairDrag(e,p){
ghost=document.createElement("div"); ghost.className="drag-ghost";
ghost.innerHTML=`<img src="${p.image}">`; document.body.appendChild(ghost); moveGhost(e);
dragP=p; document.getElementById("boardWrap").classList.add("drop-active");
window.addEventListener("pointermove",moveGhost);
window.addEventListener("pointerup",dropTray,{once:true});
}
/* ---------- palette rail ---------- */
function refreshPalette(){
const host=document.getElementById("paletteHost");
const onboardSec=document.getElementById("onboardSec");
const onboardList=document.getElementById("onboardList");
if(!placed.length){
host.innerHTML=`<div class="palette-empty">your palette builds as you place swatches</div>`;
onboardSec.style.display="none"; onboardList.innerHTML=""; return;
}
// build palette: count buckets across motif + ground colors of placed swatches
const tally={};
placed.forEach(c=>{
const b=c.p._c.bucket; tally[b]=(tally[b]||{n:0,hex:c.p._c.hex}); tally[b].n++;
if(c.p._ground){ const lb="light"; tally[lb]=tally[lb]||{n:0,hex:c.p._ground}; }
});
const chips=Object.entries(tally).sort((a,b)=>b[1].n-a[1].n).slice(0,6);
host.innerHTML=`<div class="palette-strip">${chips.map(([b,o])=>
`<div class="pchip" style="background:${BUCKET_HEX[b]||o.hex}"><span>${b}</span></div>`).join("")}</div>`;
// on-table list
onboardSec.style.display="block";
onboardList.innerHTML="";
[...placed].reverse().forEach(c=>{
const it=document.createElement("div"); it.className="onboard-item";
it.innerHTML=`<div class="t" style="background-image:url('${c.p.image}')"></div>
<div><div class="n">${esc(c.p.pattern_name)}</div><div class="fam">${esc(familyLabel(c.p))} · ${cap(c.p._c.word||c.p._c.bucket)}</div></div>`;
it.addEventListener("click",()=>{selectCard(c.id); c.el.scrollIntoView&&0;});
onboardList.appendChild(it);
});
}
/* ---------- room peek ---------- */
function showRoom(p){
closePairs();
const ov=document.createElement("div");
ov.style.cssText="position:fixed;inset:0;z-index:7000;background:rgba(10,8,5,.78);display:flex;align-items:center;justify-content:center;cursor:zoom-out";
ov.innerHTML=`<figure style="margin:0;max-width:78vw;max-height:82vh;text-align:center">
<img src="${p.room}" style="max-width:78vw;max-height:74vh;border:10px solid #fff;box-shadow:0 30px 80px rgba(0,0,0,.6);border-radius:2px">
<figcaption style="font-family:var(--serif);color:#f4efe6;font-style:italic;margin-top:14px;font-size:14px">${esc(p.pattern_name)}</figcaption></figure>`;
ov.addEventListener("pointerdown",()=>ov.remove());
document.body.appendChild(ov);
}
/* ---------- actions ---------- */
document.getElementById("clearBtn").addEventListener("click",()=>{
[...placed].forEach(c=>c.el.remove()); placed=[]; selId=null; closePairs();
document.getElementById("board").classList.remove("has-cards"); refreshPalette();
});
document.getElementById("copyBtn").addEventListener("click",()=>{
if(!placed.length){toast("Place a few swatches first.");return;}
const lines=placed.map(c=>`• ${c.p.pattern_name} [${c.p.sku}]`).join("\n");
const txt=`Design scheme — Quadrille / China Seas\n\n${lines}\n`;
(navigator.clipboard?.writeText(txt)||Promise.reject()).then(()=>toast("Scheme copied to clipboard")).catch(()=>toast("Scheme ready (clipboard blocked)"));
});
/* deselect on empty board click */
document.getElementById("board").addEventListener("pointerdown",e=>{
if(e.target.id==="board"||e.target.classList.contains("vignette")){
selId=null; placed.forEach(c=>c.el.classList.remove("selected")); closePairs();
}
});
/* ---------- util ---------- */
function esc(s){return (s||"").replace(/[&<>"]/g,m=>({"&":"&","<":"<",">":">",'"':"""}[m]));}
function cap(s){return s?s.charAt(0).toUpperCase()+s.slice(1):s;}
let toastT;
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("show");clearTimeout(toastT);toastT=setTimeout(()=>t.classList.remove("show"),2200);}
load().catch(e=>{document.getElementById("brandCount").textContent="failed to load API";console.error(e);});
window.__moodboard={placeByIndex:(i,x,y)=>{const p=PRODUCTS[i];if(p)placeCard(p,x,y);},get placed(){return placed;}};
</script>
</body>
</html>