← 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
A proto/shots/v7-moodboard.pngA proto/v7-moodboard.htmlA proto/verify-v7.cjs
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 & China Seas</h2>
+ <input class="search" id="search" placeholder="search pattern or colorway…">
+ </div>
+ <div class="tray-scroll"><div class="tray-grid" id="trayGrid"></div></div>
+ </aside>
+
+ <!-- BOARD -->
+ <main class="board-wrap" id="boardWrap">
+ <div class="board" id="board">
+ <div class="vignette"></div>
+ <div class="hint">
+ <div class="big">Pull swatches onto the table.</div>
+ <div class="small">drag from the left · click a placed swatch to find its companions</div>
+ </div>
+ </div>
+ </main>
+
+ <!-- PALETTE RAIL -->
+ <aside class="rail">
+ <div class="rail-hd">
+ <div class="lbl">Working Palette</div>
+ <h2>Your Scheme</h2>
+ </div>
+ <div id="paletteHost"><div class="palette-empty">your palette builds as you place swatches</div></div>
+ <div class="rail-body">
+ <div class="rsec" id="onboardSec" style="display:none">On the table</div>
+ <div id="onboardList"></div>
+ </div>
+ <div class="actions">
+ <button id="clearBtn">Clear table</button>
+ <button id="copyBtn" class="solid">Copy scheme</button>
+ </div>
+ </aside>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script>
+const API="http://127.0.0.1:7690/api/showroom/products?limit=120";
+let PRODUCTS=[], FAMILIES={}, placed=[], selId=null, pairsEl=null, zTop=10;
+
+/* ---------- color vocabulary: derive a swatch's hue from its pattern name ---------- */
+const COLOR_WORDS = {
+ // word -> [bucket, hex]
+ apple:["green","#7aa83f"], green:["green","#4f8f4a"], jungle:["green","#2f6b3a"], pistachio:["green","#bcd09b"],
+ emerald:["green","#1f7a52"], leaf:["green","#5a8f3c"], lettuce:["green","#9cc46a"], moss:["green","#6b7a3a"],
+ turquoise:["aqua","#3fb6b0"], aqua:["aqua","#5cc0c8"], teal:["aqua","#2c8d92"], peacock:["aqua","#1f6f78"],
+ navy:["blue","#22335e"], blue:["blue","#3a5a9c"], denim:["blue","#4a6fa0"], delft:["blue","#2d4a86"],
+ cobalt:["blue","#2747a0"], sky:["blue","#8fb4dd"], periwinkle:["blue","#8e9fd6"], slate:["blue","#5a6e86"],
+ purple:["purple","#7d4f93"], lilac:["purple","#b89dc9"], lavender:["purple","#b6a7d6"], plum:["purple","#6b3a5e"],
+ aubergine:["purple","#4a2a44"], orchid:["purple","#a564a8"], grape:["purple","#5e3a6e"],
+ watermelon:["pink","#e0607a"], pink:["pink","#e08aa0"], rose:["pink","#d8758a"], magenta:["pink","#c43a78"],
+ fuchsia:["pink","#cf3f86"], coral:["pink","#e87a64"], blush:["pink","#e6b5bd"], raspberry:["pink","#b03060"],
+ red:["red","#b8403a"], crimson:["red","#9e2a2a"], cherry:["red","#c0392b"], brick:["red","#a04a38"],
+ orange:["orange","#e08a3a"], tangerine:["orange","#e57733"], rust:["orange","#a85a30"], terracotta:["orange","#b56a47"],
+ gold:["gold","#c9a23f"], yellow:["gold","#e0c14a"], ochre:["gold","#bf9a3a"], straw:["gold","#d8c87a"],
+ mustard:["gold","#c99a2a"], sand:["neutral","#cbb893"], camel:["neutral","#b89a6a"], khaki:["neutral","#9a8a5e"],
+ brown:["brown","#6b4a32"], chocolate:["brown","#4a3324"], coffee:["brown","#5a4030"], taupe:["neutral","#9a8a78"],
+ tobacco:["brown","#6e4a2a"], walnut:["brown","#5a3e28"], chestnut:["brown","#6a3f2a"],
+ black:["dark","#26211b"], charcoal:["dark","#3a3733"], ebony:["dark","#22201d"], onyx:["dark","#2a2724"],
+ grey:["neutral","#9a948b"], gray:["neutral","#9a948b"], dove:["neutral","#bdb6ab"], pewter:["neutral","#8a857c"],
+ silver:["neutral","#c3c0ba"], stone:["neutral","#b0a896"],
+ white:["light","#f3efe6"], cream:["light","#efe7d4"], ivory:["light","#f1e9d6"], oyster:["light","#e7ddc9"],
+ beige:["light","#e3d8bf"], natural:["light","#e8dfc8"], linen:["light","#ece3cf"], chalk:["light","#f0ead9"],
+ almost:["light","#eee6d6"], off:["light","#eee6d6"], celadon:["aqua","#bcd0c0"]
+};
+const BUCKET_HEX={green:"#5a8f4a",aqua:"#3fb6b0",blue:"#3a5a9c",purple:"#7d4f93",pink:"#d8758a",
+ red:"#b8403a",orange:"#e08a3a",gold:"#c9a23f",brown:"#5a4030",dark:"#2a2622",neutral:"#a39a88",light:"#eadfca"};
+
+function family(p){ return (p.pattern_name||"").trim().split(/\s+on\s+/i)[0].split(/\s+/)[0].toLowerCase(); }
+function familyLabel(p){ return (p.pattern_name||"").trim().split(/\s+on\s+/i)[0]; }
+
+// derive [bucket,hex,word] for the *motif* color of a swatch from its name
+function swatchColor(p){
+ const name=(p.pattern_name||"").toLowerCase();
+ // prefer color word appearing BEFORE " on " (the ink/motif), else anywhere
+ const beforeOn = name.split(/\s+on\s+/i)[0];
+ for(const seg of [beforeOn, name]){
+ for(const w of seg.split(/[^a-z]+/)){
+ if(COLOR_WORDS[w]) return {bucket:COLOR_WORDS[w][0], hex:COLOR_WORDS[w][1], word:w};
+ }
+ }
+ return {bucket:"neutral", hex:"#b3a487", word:""};
+}
+function groundColor(p){
+ const name=(p.pattern_name||"").toLowerCase();
+ const afterOn = name.split(/\s+on\s+/i)[1]||"";
+ for(const w of afterOn.split(/[^a-z]+/)){ if(COLOR_WORDS[w]) return COLOR_WORDS[w][1]; }
+ return null;
+}
+
+/* ---------- load ---------- */
+async function load(){
+ const r=await fetch(API); const d=await r.json();
+ PRODUCTS=d.products.map((p,i)=>{const c=swatchColor(p);return {...p,_i:i,_fam:family(p),_c:c,_ground:groundColor(p)};});
+ document.getElementById("brandCount").textContent=`${PRODUCTS.length} of ${d.total} patterns`;
+ FAMILIES={}; PRODUCTS.forEach(p=>{(FAMILIES[p._fam]=FAMILIES[p._fam]||[]).push(p);});
+ renderTray(PRODUCTS);
+}
+
+function renderTray(list){
+ const g=document.getElementById("trayGrid"); g.innerHTML="";
+ list.forEach(p=>{
+ const el=document.createElement("div"); el.className="swatch"; el.dataset.i=p._i;
+ el.innerHTML=`
+ <div class="img" style="background-image:url('${p.image}')"></div>
+ <div class="meta">
+ <div class="pn">${esc(p.pattern_name)}</div>
+ <div class="cw"><span class="dot" style="background:${p._c.hex}"></span>
+ <span class="cwn">${p._c.word? cap(p._c.word):p._fam}</span></div>
+ </div>`;
+ el.addEventListener("pointerdown",e=>startTrayDrag(e,p));
+ g.appendChild(el);
+ });
+}
+
+document.getElementById("search").addEventListener("input",e=>{
+ const q=e.target.value.toLowerCase().trim();
+ renderTray(!q?PRODUCTS:PRODUCTS.filter(p=>p.pattern_name.toLowerCase().includes(q)||p._fam.includes(q)||p._c.word.includes(q)));
+});
+
+/* ---------- drag from tray onto the board ---------- */
+let ghost=null, dragP=null;
+function startTrayDrag(e,p){
+ e.preventDefault();
+ dragP=p;
+ ghost=document.createElement("div"); ghost.className="drag-ghost";
+ ghost.innerHTML=`<img src="${p.image}">`;
+ document.body.appendChild(ghost);
+ moveGhost(e);
+ document.getElementById("boardWrap").classList.add("drop-active");
+ window.addEventListener("pointermove",moveGhost);
+ window.addEventListener("pointerup",dropTray,{once:true});
+}
+function moveGhost(e){ if(ghost){ghost.style.left=(e.clientX-75)+"px";ghost.style.top=(e.clientY-60)+"px";} }
+function dropTray(e){
+ window.removeEventListener("pointermove",moveGhost);
+ document.getElementById("boardWrap").classList.remove("drop-active");
+ const bw=document.getElementById("boardWrap").getBoundingClientRect();
+ if(ghost){ghost.remove();ghost=null;}
+ if(dragP && e.clientX>=bw.left && e.clientX<=bw.right && e.clientY>=bw.top && e.clientY<=bw.bottom){
+ placeCard(dragP, e.clientX-bw.left-89, e.clientY-bw.top-100);
+ }
+ dragP=null;
+}
+
+/* ---------- placed cards ---------- */
+function placeCard(p,x,y){
+ const bw=document.getElementById("boardWrap").getBoundingClientRect();
+ x=Math.max(8,Math.min(x, bw.width-186)); y=Math.max(8,Math.min(y, bw.height-220));
+ const rot=(Math.random()*9-4.5).toFixed(2);
+ const id="c"+(Date.now())+Math.floor(Math.random()*999);
+ const card={id,p,x,y,rot};
+ placed.push(card);
+ const el=document.createElement("div"); el.className="placed"; el.dataset.id=id;
+ el.style.left=x+"px"; el.style.top=y+"px"; el.style.zIndex=++zTop;
+ el.style.transform=`rotate(${rot}deg)`;
+ el.innerHTML=`
+ <img class="pimg" src="${p.image}" draggable="false">
+ <div class="plabel">${esc(p.pattern_name)}</div>
+ <div class="pclose" data-close="${id}">×</div>
+ ${p.room?`<div class="proom" title="see it in a room" style="background-image:url('${p.room}')" data-room="${id}"></div>`:""}`;
+ card.el=el;
+ document.getElementById("board").appendChild(el);
+ document.getElementById("board").classList.add("has-cards");
+ el.addEventListener("pointerdown",e=>startCardDrag(e,card));
+ el.querySelector(".pclose").addEventListener("pointerdown",e=>{e.stopPropagation();removeCard(id);});
+ const rm=el.querySelector(".proom"); if(rm) rm.addEventListener("pointerdown",e=>{e.stopPropagation();showRoom(p);});
+ refreshPalette();
+ selectCard(id);
+}
+
+function startCardDrag(e,card){
+ if(e.target.dataset.close||e.target.dataset.room) return;
+ e.preventDefault();
+ selectCard(card.id);
+ const el=card.el; el.classList.add("dragging"); el.style.zIndex=++zTop;
+ const bw=document.getElementById("boardWrap").getBoundingClientRect();
+ const ox=e.clientX-bw.left-card.x, oy=e.clientY-bw.top-card.y;
+ let moved=false;
+ function mv(ev){
+ moved=true;
+ let nx=ev.clientX-bw.left-ox, ny=ev.clientY-bw.top-oy;
+ nx=Math.max(-40,Math.min(nx,bw.width-140)); ny=Math.max(-10,Math.min(ny,bw.height-120));
+ card.x=nx; card.y=ny; el.style.left=nx+"px"; el.style.top=ny+"px";
+ if(pairsEl) positionPairs(card);
+ }
+ function up(){
+ window.removeEventListener("pointermove",mv); el.classList.remove("dragging");
+ if(!moved) selectCard(card.id); // pure click = (re)select & show pairs
+ }
+ window.addEventListener("pointermove",mv);
+ window.addEventListener("pointerup",up,{once:true});
+}
+
+function removeCard(id){
+ const i=placed.findIndex(c=>c.id===id); if(i<0)return;
+ placed[i].el.remove(); placed.splice(i,1);
+ if(selId===id){selId=null;closePairs();}
+ if(!placed.length) document.getElementById("board").classList.remove("has-cards");
+ refreshPalette();
+}
+
+function selectCard(id){
+ selId=id;
+ placed.forEach(c=>c.el.classList.toggle("selected",c.id===id));
+ const card=placed.find(c=>c.id===id);
+ if(card) showPairs(card);
+}
+
+/* ---------- PAIRS WELL WITH ---------- */
+function pairsFor(p){
+ // 1) same family, different colorway 2) same color bucket, different family
+ const sameFam=PRODUCTS.filter(x=>x._fam===p._fam && x.sku!==p.sku)
+ .map(x=>({x,why:"Same pattern",rank:0}));
+ const seenFam=new Set(sameFam.map(o=>o.x._fam));
+ const sameColor=PRODUCTS.filter(x=>x._c.bucket===p._c.bucket && x._fam!==p._fam)
+ .map(x=>({x,why:cap(p._c.bucket)+" palette",rank:1}));
+ // de-dupe by sku, prefer family matches, cap at 6, diversify families on the color side
+ const out=[]; const skus=new Set([p.sku]); const colorFams=new Set();
+ for(const o of sameFam){ if(skus.has(o.x.sku))continue; skus.add(o.x.sku); out.push(o); if(out.length>=4)break; }
+ for(const o of sameColor){ if(out.length>=6)break; if(skus.has(o.x.sku))continue;
+ if(colorFams.has(o.x._fam))continue; colorFams.add(o.x._fam); skus.add(o.x.sku); out.push(o); }
+ // top up with any color match if still short
+ for(const o of sameColor){ if(out.length>=6)break; if(skus.has(o.x.sku))continue; skus.add(o.x.sku); out.push(o); }
+ return out.slice(0,6);
+}
+
+function showPairs(card){
+ closePairs();
+ const p=card.p; const list=pairsFor(p);
+ const el=document.createElement("div"); el.className="pairs"; pairsEl=el; pairsEl._for=card.id;
+ el.innerHTML=`<span class="pclose-x" id="pairsClose">×</span>
+ <h3>Pairs well with</h3>
+ <div class="sub">${esc(familyLabel(p))} · ${cap(p._c.word||p._c.bucket)}</div>
+ <div id="pairsRows"></div>`;
+ document.getElementById("boardWrap").appendChild(el);
+ const rows=el.querySelector("#pairsRows");
+ if(!list.length){ rows.innerHTML=`<div class="empty">A standout — no close companions in this set. It anchors the room on its own.</div>`; }
+ list.forEach(o=>{
+ const r=document.createElement("div"); r.className="prow";
+ r.innerHTML=`<div class="thumb" style="background-image:url('${o.x.image}')"></div>
+ <div><div class="pn">${esc(o.x.pattern_name)}</div><div class="why">${o.why}</div></div>`;
+ r.addEventListener("pointerdown",e=>{ e.preventDefault(); startPairDrag(e,o.x); });
+ rows.appendChild(r);
+ });
+ el.querySelector("#pairsClose").addEventListener("pointerdown",e=>{e.stopPropagation();closePairs();selId=null;placed.forEach(c=>c.el.classList.remove("selected"));});
+ positionPairs(card);
+}
+function positionPairs(card){
+ if(!pairsEl)return;
+ const bw=document.getElementById("boardWrap").getBoundingClientRect();
+ let x=card.x+196, y=card.y;
+ if(x+216>bw.width) x=card.x-216;
+ if(x<6) x=6;
+ y=Math.max(6,Math.min(y,bw.height-260));
+ pairsEl.style.left=x+"px"; pairsEl.style.top=y+"px";
+}
+function closePairs(){ if(pairsEl){pairsEl.remove();pairsEl=null;} }
+
+// drag a pairs suggestion straight onto the table
+function startPairDrag(e,p){
+ ghost=document.createElement("div"); ghost.className="drag-ghost";
+ ghost.innerHTML=`<img src="${p.image}">`; document.body.appendChild(ghost); moveGhost(e);
+ dragP=p; document.getElementById("boardWrap").classList.add("drop-active");
+ window.addEventListener("pointermove",moveGhost);
+ window.addEventListener("pointerup",dropTray,{once:true});
+}
+
+/* ---------- palette rail ---------- */
+function refreshPalette(){
+ const host=document.getElementById("paletteHost");
+ const onboardSec=document.getElementById("onboardSec");
+ const onboardList=document.getElementById("onboardList");
+ if(!placed.length){
+ host.innerHTML=`<div class="palette-empty">your palette builds as you place swatches</div>`;
+ onboardSec.style.display="none"; onboardList.innerHTML=""; return;
+ }
+ // build palette: count buckets across motif + ground colors of placed swatches
+ const tally={};
+ placed.forEach(c=>{
+ const b=c.p._c.bucket; tally[b]=(tally[b]||{n:0,hex:c.p._c.hex}); tally[b].n++;
+ if(c.p._ground){ const lb="light"; tally[lb]=tally[lb]||{n:0,hex:c.p._ground}; }
+ });
+ const chips=Object.entries(tally).sort((a,b)=>b[1].n-a[1].n).slice(0,6);
+ host.innerHTML=`<div class="palette-strip">${chips.map(([b,o])=>
+ `<div class="pchip" style="background:${BUCKET_HEX[b]||o.hex}"><span>${b}</span></div>`).join("")}</div>`;
+ // on-table list
+ onboardSec.style.display="block";
+ onboardList.innerHTML="";
+ [...placed].reverse().forEach(c=>{
+ const it=document.createElement("div"); it.className="onboard-item";
+ it.innerHTML=`<div class="t" style="background-image:url('${c.p.image}')"></div>
+ <div><div class="n">${esc(c.p.pattern_name)}</div><div class="fam">${esc(familyLabel(c.p))} · ${cap(c.p._c.word||c.p._c.bucket)}</div></div>`;
+ it.addEventListener("click",()=>{selectCard(c.id); c.el.scrollIntoView&&0;});
+ onboardList.appendChild(it);
+ });
+}
+
+/* ---------- room peek ---------- */
+function showRoom(p){
+ closePairs();
+ const ov=document.createElement("div");
+ ov.style.cssText="position:fixed;inset:0;z-index:7000;background:rgba(10,8,5,.78);display:flex;align-items:center;justify-content:center;cursor:zoom-out";
+ ov.innerHTML=`<figure style="margin:0;max-width:78vw;max-height:82vh;text-align:center">
+ <img src="${p.room}" style="max-width:78vw;max-height:74vh;border:10px solid #fff;box-shadow:0 30px 80px rgba(0,0,0,.6);border-radius:2px">
+ <figcaption style="font-family:var(--serif);color:#f4efe6;font-style:italic;margin-top:14px;font-size:14px">${esc(p.pattern_name)}</figcaption></figure>`;
+ ov.addEventListener("pointerdown",()=>ov.remove());
+ document.body.appendChild(ov);
+}
+
+/* ---------- actions ---------- */
+document.getElementById("clearBtn").addEventListener("click",()=>{
+ [...placed].forEach(c=>c.el.remove()); placed=[]; selId=null; closePairs();
+ document.getElementById("board").classList.remove("has-cards"); refreshPalette();
+});
+document.getElementById("copyBtn").addEventListener("click",()=>{
+ if(!placed.length){toast("Place a few swatches first.");return;}
+ const lines=placed.map(c=>`• ${c.p.pattern_name} [${c.p.sku}]`).join("\n");
+ const txt=`Design scheme — Quadrille / China Seas\n\n${lines}\n`;
+ (navigator.clipboard?.writeText(txt)||Promise.reject()).then(()=>toast("Scheme copied to clipboard")).catch(()=>toast("Scheme ready (clipboard blocked)"));
+});
+
+/* deselect on empty board click */
+document.getElementById("board").addEventListener("pointerdown",e=>{
+ if(e.target.id==="board"||e.target.classList.contains("vignette")){
+ selId=null; placed.forEach(c=>c.el.classList.remove("selected")); closePairs();
+ }
+});
+
+/* ---------- util ---------- */
+function esc(s){return (s||"").replace(/[&<>"]/g,m=>({"&":"&","<":"<",">":">",'"':"""}[m]));}
+function cap(s){return s?s.charAt(0).toUpperCase()+s.slice(1):s;}
+let toastT;
+function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("show");clearTimeout(toastT);toastT=setTimeout(()=>t.classList.remove("show"),2200);}
+
+load().catch(e=>{document.getElementById("brandCount").textContent="failed to load API";console.error(e);});
+window.__moodboard={placeByIndex:(i,x,y)=>{const p=PRODUCTS[i];if(p)placeCard(p,x,y);},get placed(){return placed;}};
+</script>
+</body>
+</html>
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 →