[object Object]

← back to Quadrille Showroom

proto v7: mood-board table — drag swatches, build palette, pairs-well-with

72bd4765e5a90074499ab4cc7fac5af16bbb7ba7 · 2026-06-28 08:07:42 -0700 · Steve

Standalone proto/v7-moodboard.html: designer's felt table where swatch cards
drag from a tray onto the board (free-position + rotation + overlap), emit an
aggregated color palette, and surface PAIRS WELL WITH (same pattern-family
colorways + same color bucket) that are themselves draggable onto the table.
Pulls from /api/showroom/products?limit=120. Verified headless: 120 swatches,
2-card pointer-drag, pairs render, palette aggregates, 0 console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 72bd4765e5a90074499ab4cc7fac5af16bbb7ba7
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 08:07:42 2026 -0700

    proto v7: mood-board table — drag swatches, build palette, pairs-well-with
    
    Standalone proto/v7-moodboard.html: designer's felt table where swatch cards
    drag from a tray onto the board (free-position + rotation + overlap), emit an
    aggregated color palette, and surface PAIRS WELL WITH (same pattern-family
    colorways + same color bucket) that are themselves draggable onto the table.
    Pulls from /api/showroom/products?limit=120. Verified headless: 120 swatches,
    2-card pointer-drag, pairs render, palette aggregates, 0 console errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 proto/shots/v7-moodboard.png | Bin 0 -> 923926 bytes
 proto/v7-moodboard.html      | 564 +++++++++++++++++++++++++++++++++++++++++++
 proto/verify-v7.cjs          |  74 ++++++
 3 files changed, 638 insertions(+)

diff --git a/proto/shots/v7-moodboard.png b/proto/shots/v7-moodboard.png
new file mode 100644
index 0000000..8978632
Binary files /dev/null and b/proto/shots/v7-moodboard.png differ
diff --git a/proto/v7-moodboard.html b/proto/v7-moodboard.html
new file mode 100644
index 0000000..47617a6
--- /dev/null
+++ b/proto/v7-moodboard.html
@@ -0,0 +1,564 @@
+<!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="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=>({"&":"&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>
+</body>
+</html>
diff --git a/proto/verify-v7.cjs b/proto/verify-v7.cjs
new file mode 100644
index 0000000..90b73e3
--- /dev/null
+++ b/proto/verify-v7.cjs
@@ -0,0 +1,74 @@
+// Verify v7-moodboard: load it AT the API origin (so fetch is same-origin),
+// drag two swatches onto the table, confirm pairs-well-with renders, 0 console errors.
+const { chromium } = require('playwright');
+const fs = require('fs');
+const path = require('path');
+
+const HTML = fs.readFileSync(path.join(__dirname, 'v7-moodboard.html'), 'utf8');
+const OUT = path.join(__dirname, 'shots', 'v7-moodboard.png');
+
+(async () => {
+  const browser = await chromium.launch();
+  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+
+  const errors = [];
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push('pageerror: ' + e.message));
+
+  // Serve our HTML from the API's own origin so the fetch() is same-origin.
+  await page.route('http://127.0.0.1:7690/__proto.html', r =>
+    r.fulfill({ status: 200, contentType: 'text/html', body: HTML }));
+
+  await page.goto('http://127.0.0.1:7690/__proto.html', { waitUntil: 'networkidle' });
+  await page.waitForFunction(() => window.__moodboard && window.__moodboard.placed, null, { timeout: 8000 });
+  await page.waitForSelector('.swatch', { timeout: 8000 });
+
+  const loaded = await page.evaluate(() => document.querySelectorAll('.swatch').length);
+  console.log('tray swatches loaded:', loaded);
+
+  // --- drag swatch 1 (real pointer drag from tray onto the board) ---
+  async function dragNth(n, tx, ty) {
+    const sw = page.locator('.swatch').nth(n);
+    const box = await sw.boundingBox();
+    await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+    await page.mouse.down();
+    await page.mouse.move(box.x + 200, box.y + 50, { steps: 6 });
+    await page.mouse.move(tx, ty, { steps: 10 });
+    await page.mouse.up();
+    await page.waitForTimeout(250);
+  }
+  // index 0 = "Aga Apple", index 2 = "Aga Jungle" → same family, must surface as pairs
+  await dragNth(0, 720, 380);
+  await dragNth(2, 980, 520);
+
+  const placedCount = await page.evaluate(() => window.__moodboard.placed.length);
+  console.log('placed on table:', placedCount);
+
+  // click the first placed card to open Pairs Well With
+  await page.locator('.placed').first().click({ position: { x: 80, y: 80 } });
+  await page.waitForTimeout(400);
+
+  const pairs = await page.evaluate(() => {
+    const el = document.querySelector('.pairs');
+    if (!el) return null;
+    return {
+      title: el.querySelector('h3') ? el.querySelector('h3').textContent : '',
+      rows: [...el.querySelectorAll('.prow .pn')].map(n => n.textContent.trim()),
+      whys: [...el.querySelectorAll('.prow .why')].map(n => n.textContent.trim()),
+    };
+  });
+  console.log('pairs panel:', JSON.stringify(pairs, null, 2));
+
+  const paletteChips = await page.evaluate(() => document.querySelectorAll('.palette-strip .pchip').length);
+  console.log('palette chips:', paletteChips);
+
+  await page.screenshot({ path: OUT });
+  console.log('screenshot:', OUT);
+  console.log('CONSOLE ERRORS:', errors.length, errors);
+
+  await browser.close();
+
+  const ok = loaded > 0 && placedCount === 2 && pairs && pairs.rows.length > 0 && errors.length === 0;
+  console.log(ok ? '\nVERDICT: PASS' : '\nVERDICT: FAIL');
+  process.exit(ok ? 0 : 1);
+})();

← 0ecda36 Thin slivers (reveal 70 / steeper rake) + walls stay cream p  ·  back to Quadrille Showroom  ·  Tighter arc (86deg span, ~75% overlap = thinner slivers) + w cbd8fb9 →