← back to Quadrille Showroom
public/proto/v7-moodboard.html
716 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="/api/showroom/products?limit=120";
const ENRICH_URL="./enrichment.json"; // REAL hue / color_bucket / first-word family per sku
let ENRICH={}; // sku -> { hue(0-360), color_bucket, family }
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"};
// CONTRARIAN FIX — family now comes from enrichment's first-word family (already
// de-duped to 121 families upstream) instead of re-splitting the pattern name,
// so "pairs-well-with" groups correct colorway families.
function family(p){
const en=ENRICH[p.sku];
if(en && en.family) return String(en.family).toLowerCase();
return (p.pattern_name||"").trim().split(/\s+on\s+/i)[0].split(/\s+/)[0].toLowerCase();
}
function familyLabel(p){
const en=ENRICH[p.sku];
if(en && en.patternBase) return en.patternBase;
return (p.pattern_name||"").trim().split(/\s+on\s+/i)[0];
}
// hsl (h 0-360, s/l 0-1) → display hex
function hueToHex(h, s, l){
s=s==null?0.55:s; l=l==null?0.5:l;
h=((h%360)+360)%360/360;
function f(n){ const k=(n+h*12)%12; const a=s*Math.min(l,1-l);
return l - a*Math.max(-1,Math.min(k-3,9-k,1)); }
const to=v=>('0'+Math.round(Math.max(0,Math.min(1,v))*255).toString(16)).slice(-2);
return '#'+to(f(0))+to(f(8))+to(f(4));
}
// enrichment color_bucket → tasteful representative hex for the palette strip
const ENRICH_BUCKET_HEX={ red:"#b8403a", orange:"#d07a32", yellow:"#cBa83c",
green:"#5a8f4a", teal:"#2f8f8a", blue:"#3a5a9c", purple:"#7d4f93",
magenta:"#bf3f78", neutral:"#a39a88", grey:"#9a948b" };
// CONTRARIAN FIX — interior-design colorway lexicon so a precise hue reads as a
// designerly name (Citron / Sage / Delft) on the chip, not the coarse 8-bucket
// token. Indexed by hue band; muted/dark variants chosen by sampled S/L.
function colorwayName(h, s, l){
h=((h%360)+360)%360;
if(s<0.12){ return l>0.78?"Alabaster": l>0.6?"Oatmeal": l>0.4?"Greige": l>0.22?"Pewter":"Charcoal"; }
const bands=[
[12,"Coral"],[24,"Terracotta"],[40,"Ochre"],[52,"Saffron"],[66,"Citron"],
[80,"Chartreuse"],[100,"Fern"],[140,"Sage"],[165,"Viridian"],[185,"Celadon"],
[200,"Teal"],[220,"Delft"],[240,"Indigo"],[258,"Cobalt"],[275,"Periwinkle"],
[292,"Amethyst"],[312,"Mulberry"],[330,"Magenta"],[345,"Raspberry"],[360,"Coral"]
];
let name="Coral"; for(const [hi,n] of bands){ if(h<hi){ name=n; break; } }
if(l<0.32 && (name==="Delft"||name==="Indigo"||name==="Cobalt")) name="Navy";
return name;
}
// pixel hue → enrichment-style bucket (for ungrounded skus so pairs still group)
function hueBucket(h,s){
if(s<0.12) return "neutral";
h=((h%360)+360)%360;
if(h<18||h>=345) return "red"; if(h<45) return "orange"; if(h<70) return "yellow";
if(h<160) return "green"; if(h<195) return "teal"; if(h<255) return "blue";
if(h<290) return "purple"; return "magenta";
}
// PIXEL sampler — average the swatch image's CHROMATIC pixels into real h/s/l so
// the chip is a true eyedropper sample, not a fixed-chroma slice. Cached per sku.
const _pxCanvas=document.createElement("canvas"); _pxCanvas.width=_pxCanvas.height=16;
const _pxCtx=_pxCanvas.getContext("2d",{willReadFrequently:true});
function rgb2hsl(r,g,b){ r/=255;g/=255;b/=255;
const mx=Math.max(r,g,b),mn=Math.min(r,g,b); let h=0,s=0; const l=(mx+mn)/2;
if(mx!==mn){ const d=mx-mn; s=l>0.5?d/(2-mx-mn):d/(mx+mn);
switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;default:h=(r-g)/d+4;} h/=6; }
return [h*360,s,l]; }
const _pxCache={};
function samplePixelColor(sku, imgUrl, enHue){
return new Promise(res=>{
if(_pxCache[sku]) return res(_pxCache[sku]);
const img=new Image(); img.crossOrigin="anonymous";
img.onload=()=>{
try{
_pxCtx.clearRect(0,0,16,16); _pxCtx.drawImage(img,0,0,16,16);
const d=_pxCtx.getImageData(0,0,16,16).data;
let sS=0,sL=0,wS=0, sLn=0,nN=0, hx=0,hy=0; // hue averaged on the circle
for(let i=0;i<d.length;i+=4){ const [h,s,l]=rgb2hsl(d[i],d[i+1],d[i+2]);
if(s<0.12){ sLn+=l; nN++; continue; }
const w=s; sS+=s*w; sL+=l*w; wS+=w;
const rad=h*Math.PI/180; hx+=Math.cos(rad)*w; hy+=Math.sin(rad)*w; }
const out = wS>0.05
? {sat:Math.min(0.85,sS/wS*1.15), light:Math.max(0.30,Math.min(0.62,sL/wS)),
hue:((Math.atan2(hy,hx)*180/Math.PI)+360)%360}
: {sat:0.06, light:nN?sLn/nN:0.6}; // genuinely neutral swatch
_pxCache[sku]=out; res(out);
}catch(e){ res(_pxCache[sku]={sat:null,light:null}); }
};
img.onerror=()=>res(_pxCache[sku]={sat:null,light:null});
img.src=/cdn\.shopify\.com/.test(imgUrl)?imgUrl:"/api/proxy/image?url="+encodeURIComponent(imgUrl);
});
}
// CONTRARIAN FIX — the swatch's motif color is now PIXEL-TRUE via enrichment hue,
// not a guess from a color word in the name. Falls back to the name-word table
// only when a sku has no enrichment record.
function swatchColor(p){
const en=ENRICH[p.sku];
if(en && typeof en.hue==="number"){
const bucket=en.color_bucket||"neutral";
// PIXEL-TRUE: sampled saturation/lightness from the image (set during load).
// Fall back to bucket-tuned chroma only if sampling hasn't completed.
const isMuted = bucket==="neutral"||bucket==="grey";
const s = (typeof p._sat==="number") ? p._sat : (isMuted?0.10:0.52);
const l = (typeof p._light==="number") ? p._light : 0.50;
const hex=hueToHex(en.hue, s, l);
return { bucket, hex, word: colorwayName(en.hue, s, l), hue:en.hue, sat:s, light:l, _grounded:true };
}
// No enrichment record — use the PIXEL-sampled hue if we have it (still pixel-true,
// just self-derived), and only fall back to the name-word table as a last resort.
if(typeof p._hue==="number"){
const s=(typeof p._sat==="number")?p._sat:0.5, l=(typeof p._light==="number")?p._light:0.5;
return { bucket:hueBucket(p._hue,s), hex:hueToHex(p._hue,s,l), word:colorwayName(p._hue,s,l),
hue:p._hue, sat:s, light:l, _grounded:false };
}
const name=(p.pattern_name||"").toLowerCase();
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, _grounded:false};
}
}
return {bucket:"neutral", hex:"#b3a487", word:"", _grounded:false};
}
// the paper/ground colour — still a useful light accent; keep the name parse for it
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(){
// load REAL enrichment first so color + family are grounded, not name-guessed
try { const er=await fetch(ENRICH_URL); const ej=await er.json();
ENRICH=(ej&&ej.enrich)?ej.enrich:(ej||{}); } catch(e){ ENRICH={}; }
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);
samplePixels(); // upgrade dots/strip from hue-true to pixel-true in the background
}
// Pixel-sample every swatch's true saturation/lightness, then re-derive its color
// and repaint the dot in place so the chip matches the image (eyedropper-true).
async function samplePixels(){
const POOL=8; let cur=0;
async function worker(){
while(cur<PRODUCTS.length){
const p=PRODUCTS[cur++];
const en=ENRICH[p.sku];
const s=await samplePixelColor(p.sku, p.image, en&&en.hue);
if(s && typeof s.sat==="number"){
p._sat=s.sat; p._light=s.light;
if(typeof s.hue==="number") p._hue=s.hue; // pixel hue for ungrounded skus
p._c=swatchColor(p); paintDot(p);
}
}
}
await Promise.all(Array.from({length:POOL},worker));
if(placed.length) refreshPalette(); // strip now reflects true chroma
}
function paintDot(p){
const el=document.querySelector(`.swatch[data-i="${p._i}"]`);
if(!el) return;
const dot=el.querySelector(".dot"); if(dot) dot.style.background=p._c.hex;
const cwn=el.querySelector(".cwn"); if(cwn) cwn.textContent = p._c.word? cap(p._c.word):p._fam;
}
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;
}
// CONTRARIAN FIX — palette strip is now built from the PIXEL-TRUE enrichment hue
// of each placed swatch (averaged within a bucket) instead of a fixed name-word
// hex, so the strip mirrors the actual colours on the table.
const tally={};
placed.forEach(c=>{
const b=c.p._c.bucket||"neutral";
const t=tally[b]=(tally[b]||{n:0,hueSum:0,satSum:0,lSum:0,hueN:0,fallback:c.p._c.hex});
t.n++;
if(typeof c.p._c.hue==="number"){ t.hueSum+=c.p._c.hue;
t.satSum+=(c.p._c.sat!=null?c.p._c.sat:0.5); t.lSum+=(c.p._c.light!=null?c.p._c.light:0.5); t.hueN++; }
if(c.p._ground){ tally["light"]=tally["light"]||{n:0,hueSum:0,satSum:0,lSum:0,hueN:0,fallback: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])=>{
// pixel-true averaged chroma → designerly colorway label on each chip
const hue=o.hueN?o.hueSum/o.hueN:0, sat=o.hueN?o.satSum/o.hueN:0.1, lt=o.hueN?o.lSum/o.hueN:0.6;
const hex = o.hueN ? hueToHex(hue, sat, lt) : (ENRICH_BUCKET_HEX[b]||BUCKET_HEX[b]||o.fallback);
const label = o.hueN ? colorwayName(hue,sat,lt) : b;
return `<div class="pchip" style="background:${hex}"><span>${label}</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>
<script>window.PROTO_META={"key":"V7","label":"V7 — Mood-Board Table","elements":[{"n":1,"label":"Drag swatches onto the design table"},{"n":2,"label":"Build a palette from the dropped swatches"},{"n":3,"label":"“Pairs-well-with” suggestions by color family"},{"n":4,"label":"Swatch tray / collection rail to drag from"},{"n":5,"label":"Live palette read-out (the chosen colors)"}]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>