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