[object Object]

← back to Quadrille Showroom

auto-save: 2026-06-28T09:13:51 (4 files) — public/js/rack.js public/js/showroom.js public/proto/v7-moodboard.html public/proto/shots/v7-moodboard.png

206ff85e8c5d24864a34fe870df4531f5307a001 · 2026-06-28 09:13:53 -0700 · Steve Abrams

Files touched

Diff

commit 206ff85e8c5d24864a34fe870df4531f5307a001
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 28 09:13:53 2026 -0700

    auto-save: 2026-06-28T09:13:51 (4 files) — public/js/rack.js public/js/showroom.js public/proto/v7-moodboard.html public/proto/shots/v7-moodboard.png
---
 public/js/rack.js                   |   7 +-
 public/js/showroom.js               | 131 +++++++++++++++++++++++++--
 public/proto/shots/v7-moodboard.png | Bin 0 -> 942712 bytes
 public/proto/v7-moodboard.html      | 170 +++++++++++++++++++++++++++++++++---
 4 files changed, 288 insertions(+), 20 deletions(-)

diff --git a/public/js/rack.js b/public/js/rack.js
index 70e6b3f..999dfe2 100644
--- a/public/js/rack.js
+++ b/public/js/rack.js
@@ -28,9 +28,10 @@
   // Range chosen so the default (~62) lands on the photo's medium-thin slivers.
   function revealToRake(reveal) {
     const r = Math.max(0, Math.min(100, reveal));
-    // 38°..80° — at the default (~68) boards rake ~67° off dead-on so packed neighbours
-    // occlude all but a thin leading SLIVER, like the PJ fanned sample deck.
-    return (38 + (r / 100) * 42) * Math.PI / 180;
+    // 42°..84° — pushed steeper toward the PJ Dallas photo's RAZOR-THIN slivers. At the
+    // default (~74) boards rake ~73° off dead-on so packed neighbours occlude all but a
+    // hairline leading SLIVER — the dense fanned-deck read of the reference.
+    return (42 + (r / 100) * 42) * Math.PI / 180;
   }
 
   // Centre angle (radians, around the arc centre) of slot i of n boards.
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 3a5acc8..13cb69b 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -38,7 +38,7 @@ let controlsLocked = false, lockedCamPos = null, lockedCamTarget = null, previou
 let cameraAnim = null; // { startPos, endPos, startTarget, endTarget, progress, duration, onComplete }
 
 // Window paging across the full catalog (50 live wings at a time)
-let windowOffset = 0, windowTotal = 0, windowSize = 18;  // boards in the packed arc at once (FPS-safe)
+let windowOffset = 0, windowTotal = 0, windowSize = 24;  // boards in the packed arc at once — denser razor-thin slivers (PJ photo), still FPS-safe
 let currentBrand = 'all', currentSort = 'natural', currentWallGroup = null;
 // Peruse (endless auto-tour) state
 let peruseActive = false, peruseIndex = 0, peruseTimer = null;
@@ -68,7 +68,7 @@ let VIEW_OPEN = (function () {
 // more boards in view). Persisted to localStorage like the Wing°/Boards sliders.
 let REVEAL = (function () {
   const v = parseFloat(localStorage.getItem('qh_reveal'));
-  return (v >= 0 && v <= 100) ? v : 70;   // default ≈ the photo's thin slivers (steep rake)
+  return (v >= 0 && v <= 100) ? v : 74;   // default ≈ the photo's RAZOR-THIN slivers (steep rake)
 })();
 
 // SHARED materials — key perf optimization
@@ -1534,6 +1534,121 @@ function loadWingBook(pivot, onDone) {
   img.src = src;
 }
 
+// ============================================================
+// CLOSED-SLIVER REAL THUMBNAILS (Image #6 gap closer)
+// ------------------------------------------------------------
+// In the PJ Dallas photo you see a thin vertical strip of EVERY real design packed
+// in the arc. Previously the closed boards all shared one synthetic pastel pool
+// pattern; only the open hero showed the real China Seas wallcovering. This loads a
+// REAL but LOW-RES thumbnail onto every closed board's face so the slivers read as
+// the actual catalogue — within the texture budget:
+//   • LOW-RES: remote Shopify URLs get `?width=THUMB_PX` (server-side downscale →
+//     ~7 KB vs ~125 KB; 17× lighter). Local gen-tiles already small → used as-is.
+//   • CAPPED CONCURRENCY: at most SLIVER_MAX in flight; a stagger interval keeps the
+//     initial burst from tanking FPS. Decode is OFF-thread (createImageBitmap).
+//   • CACHED per-URL in sliverTexCache (separate from the full-res imageTexCache so a
+//     later focus still upgrades the SAME board to full-res via loadWingBook).
+//   • The thumbnail keeps the board's tiling (repeat) so the sliver shows real motif,
+//     not a stretched single image.
+// ============================================================
+const sliverTexCache = {};
+let _sliverInFlight = 0;
+const SLIVER_MAX = 3;          // concurrent thumbnail fetches (budget-safe)
+const SLIVER_THUMB_PX = 120;   // downscaled width for remote Shopify images
+let _sliverPumpAt = 0;
+const SLIVER_PUMP_MS = 90;     // min gap between kicking off new thumbnail loads
+
+// Build the low-res source URL for a board's pattern. Remote Shopify images get a
+// width param so the CDN downscales server-side; local /gen-tiles are already small.
+function sliverThumbSrc(imageUrl) {
+  if (!imageUrl) return null;
+  if (imageUrl.charAt(0) === '/') return imageUrl;                       // local tile/asset — already small
+  // Append Shopify's width= param INSIDE the proxied URL so the CDN returns a tiny JPEG.
+  let thumbUrl = imageUrl;
+  if (imageUrl.indexOf('cdn.shopify.com') !== -1) {
+    thumbUrl += (imageUrl.indexOf('?') === -1 ? '?' : '&') + 'width=' + SLIVER_THUMB_PX;
+  }
+  return '/api/proxy/image?url=' + encodeURIComponent(thumbUrl);
+}
+
+// Apply a (real, low-res) thumbnail texture to a CLOSED board's face. Uses a cheap
+// Lambert material (closed slivers don't need PBR sheen — the open hero gets that).
+function loadSliverThumb(pivot, onDone) {
+  const ud = pivot.userData;
+  const face = ud.face || ud.faceL;
+  const imageUrl = ud.pendingImage;
+  // Skip if no image, already showing the real thumb, or already upgraded to full-res hero.
+  if (!face || !imageUrl || ud.sliverLoaded || ud.imageLoaded) { if (onDone) onDone(); return; }
+  const product = ud.product;
+  const wingW = ud.boardWidth || 0.12;
+  const maxAniso = (renderer.capabilities && renderer.capabilities.getMaxAnisotropy) ? renderer.capabilities.getMaxAnisotropy() : 1;
+
+  const apply = (cached) => {
+    // Only paint the thumbnail if the board hasn't been opened to full-res in the meantime.
+    if (!ud.imageLoaded) {
+      face.material = new THREE.MeshLambertMaterial({ map: cached.texture });
+    }
+    ud.sliverLoaded = true;
+    if (onDone) onDone();
+  };
+
+  if (sliverTexCache[imageUrl] && sliverTexCache[imageUrl].texture) { apply(sliverTexCache[imageUrl]); return; }
+
+  const buildFrom = (source) => {
+    const isFD = (product && product.isSeamlessTile) ? true : true; // closed sliver always tiles for real motif
+    const t = (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap)
+      ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
+    t.image = source;
+    t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
+    t.anisotropy = Math.min(4, maxAniso); srgb(t);     // capped aniso — these are tiny + many
+    const { rx, ry } = computeRepeat(wingW, product, source);
+    t.wrapS = t.wrapT = THREE.RepeatWrapping;
+    t.repeat.set(Math.max(1, rx), ry);
+    t.needsUpdate = true;
+    const cached = { texture: t };
+    sliverTexCache[imageUrl] = cached;
+    apply(cached);
+  };
+
+  const src = sliverThumbSrc(imageUrl);
+  if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
+    fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
+      .then(bmp => buildFrom(bmp))
+      .catch(() => {
+        const img = new Image(); img.decoding = 'async';
+        img.onload = () => buildFrom(img); img.onerror = () => { if (onDone) onDone(); };
+        img.src = src;
+      });
+    return;
+  }
+  const img = new Image(); img.decoding = 'async';
+  img.onload = () => buildFrom(img); img.onerror = () => { if (onDone) onDone(); };
+  img.src = src;
+}
+
+// Pump the closed-sliver thumbnails: nearest-first, capped concurrency, staggered so
+// the initial wall of real designs streams in without an FPS cliff. Called from animate.
+const _sliverWP = new THREE.Vector3();
+function pumpSliverThumbs() {
+  if (_sliverInFlight >= SLIVER_MAX) return;
+  const now = performance.now();
+  if (now - _sliverPumpAt < SLIVER_PUMP_MS) return;
+  // Pick the nearest not-yet-thumbed board to the camera so the front of the arc fills first.
+  let target = null, best = Infinity;
+  const cx = camera.position.x, cz = camera.position.z;
+  for (let i = 0; i < wingBoards.length; i++) {
+    const ud = wingBoards[i].userData;
+    if (ud.sliverLoaded || ud.imageLoaded || !ud.pendingImage) continue;
+    wingBoards[i].getWorldPosition(_sliverWP);
+    const d = (_sliverWP.x - cx) * (_sliverWP.x - cx) + (_sliverWP.z - cz) * (_sliverWP.z - cz);
+    if (d < best) { best = d; target = wingBoards[i]; }
+  }
+  if (!target) return;
+  _sliverPumpAt = now;
+  _sliverInFlight++;
+  loadSliverThumb(target, () => { _sliverInFlight--; });
+}
+
 // ============================================================
 // WING INTERACTION — Two-step: click to move close, buttons to open
 // ============================================================
@@ -2074,10 +2189,14 @@ function animate() {
   // Proximity book-match — nearest wing opens its two leaves when you're within 4 ft
   updateBooks(dt);
 
-  // Lazy texture loading — check every 30 frames for nearby racks
-  if (frameCount % 30 === 0) {
-    lazyLoadNearbyTextures();
-  }
+  // Closed-sliver REAL thumbnails — stream a low-res real design onto every closed
+  // board so the packed arc reads as the actual catalogue (PJ photo), not one synthetic
+  // pattern. Capped concurrency + own stagger inside; cheap to poll each frame.
+  // This SUPERSEDES the old lazyLoadNearbyTextures() full-res preload of closed boards
+  // (which uploaded 125 KB heroes onto slivers you only see 4" of — wasteful, and it
+  // raced the thumb loader by pre-setting imageLoaded). Full-res now loads ONLY when a
+  // board actually opens (focusOnWing / updateBooks). Net: lighter + the slivers are real.
+  pumpSliverThumbs();
 
   // Update minimap every 10 frames (cheap but no need every frame)
   if (frameCount % 10 === 0) updateMinimap();
diff --git a/public/proto/shots/v7-moodboard.png b/public/proto/shots/v7-moodboard.png
new file mode 100644
index 0000000..e55527c
Binary files /dev/null and b/public/proto/shots/v7-moodboard.png differ
diff --git a/public/proto/v7-moodboard.html b/public/proto/v7-moodboard.html
index 3ef609d..0c9fdc5 100644
--- a/public/proto/v7-moodboard.html
+++ b/public/proto/v7-moodboard.html
@@ -260,6 +260,8 @@
 
 <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 ---------- */
@@ -290,21 +292,127 @@ const COLOR_WORDS = {
 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]; }
+// 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;
+}
 
-// derive [bucket,hex,word] for the *motif* color of a swatch from its 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();
-  // 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};
+      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:""};
+  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]||"";
@@ -314,11 +422,41 @@ function groundColor(p){
 
 /* ---------- 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){
@@ -499,15 +637,25 @@ function refreshPalette(){
     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
+  // 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; 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 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])=>
-     `<div class="pchip" style="background:${BUCKET_HEX[b]||o.hex}"><span>${b}</span></div>`).join("")}</div>`;
+  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="";

← f541de6 Sample Table: add-sample drops the square swatch onto the co  ·  back to Quadrille Showroom  ·  V8 Swipe Tower: keep biases the queue via enrichment taste m 7f3989c →