← 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
M public/js/rack.jsM public/js/showroom.jsA public/proto/shots/v7-moodboard.pngM public/proto/v7-moodboard.html
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 →