← back to Corvette Dashboard Viewer

public/index.html

428 lines

<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>'67 Corvette Catalog Console — TK-135</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230e1116'/%3E%3Ccircle cx='8' cy='8' r='4.5' fill='none' stroke='%23FFB300' stroke-width='1.4'/%3E%3Cline x1='8' y1='8' x2='11.3' y2='4.7' stroke='%23ff2d4d' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1' fill='%23e9edf2'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Racing+Sans+One&family=Oswald:wght@300;500;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root{
  --red:#C8102E; --red-lit:#ff2d4d; --amber:#FFB300; --amber-dim:#8a5e00;
  --chrome1:#f7f7f9; --chrome2:#b8bcc4; --chrome3:#6e7178; --chrome4:#3a3c42;
  --vinyl:#141414; --vinyl2:#0a0a0a; --cream:#e9e4d6; --ink:#111;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
  font-family:'Oswald',sans-serif; color:var(--cream);
  background:
    radial-gradient(120% 80% at 50% -10%, #2a2b2f 0%, #141416 45%, #050506 100%),
    var(--vinyl2);
  min-height:100vh; padding:24px 16px 60px;
  -webkit-font-smoothing:antialiased;
}
/* black vinyl dash texture */
.dash{
  max-width:1180px; margin:0 auto;
  background:
    repeating-linear-gradient(90deg, rgba(255,255,255,.015) 0 2px, transparent 2px 5px),
    linear-gradient(180deg,#1c1c1e 0%, #131314 60%, #0c0c0d 100%);
  border-radius:26px;
  padding:22px 26px 30px;
  box-shadow:
    inset 0 2px 0 rgba(255,255,255,.06),
    inset 0 -30px 60px rgba(0,0,0,.7),
    0 30px 80px rgba(0,0,0,.8);
  border:1px solid #2a2a2d;
  position:relative;
}
/* red stitch line */
.stitch{border-top:2px dashed rgba(200,16,46,.55); margin:16px 0; opacity:.8}

header{display:flex; align-items:center; justify-content:center; gap:16px; margin-bottom:8px}
.emblem{font-size:26px; filter:drop-shadow(0 0 6px rgba(200,16,46,.6))}
.wordmark{font-family:'Racing Sans One',cursive; letter-spacing:2px; font-size:30px;
  background:linear-gradient(180deg,var(--chrome1),var(--chrome2) 45%,var(--chrome3) 55%,#e9ecf1);
  -webkit-background-clip:text; background-clip:text; color:transparent;
  text-shadow:0 1px 0 rgba(0,0,0,.4);}
.script{font-family:'Racing Sans One',cursive; color:var(--red-lit); font-size:16px; letter-spacing:1px;
  text-shadow:0 0 10px rgba(255,45,77,.6)}
.sub{text-align:center; font-size:11px; letter-spacing:5px; color:#8b8f98; margin-top:2px}

/* ===== instrument cluster ===== */
.cluster{display:grid; grid-template-columns:1fr 1.15fr 1fr; gap:18px; align-items:center; margin-top:14px}
.gauge-wrap{display:flex; flex-direction:column; align-items:center}
.gauge{position:relative; width:230px; height:230px}
.gauge.big{width:260px; height:260px}
.bezel{position:absolute; inset:0; border-radius:50%;
  background:conic-gradient(from 210deg,#e9edf2,#8b9099,#5a5e66,#c8ccd3,#9a9ea6,#e9edf2);
  box-shadow:0 8px 20px rgba(0,0,0,.7), inset 0 0 8px rgba(0,0,0,.5);}
.face{position:absolute; inset:14px; border-radius:50%;
  background:radial-gradient(circle at 50% 38%, #26262a 0%, #131315 70%, #0a0a0b 100%);
  box-shadow:inset 0 0 30px rgba(0,0,0,.9), inset 0 0 0 2px #303035;}
.face::after{content:''; position:absolute; inset:0; border-radius:50%;
  background:radial-gradient(circle at 50% 30%, rgba(255,179,0,.10), transparent 60%);}
.g-label{position:absolute; top:60%; left:50%; transform:translateX(-50%); text-align:center; width:80%}
.g-title{font-size:11px; letter-spacing:2px; color:#9aa0aa}
.g-value{font-family:'Share Tech Mono',monospace; font-size:30px; color:var(--amber);
  text-shadow:0 0 12px rgba(255,179,0,.55); line-height:1}
.g-unit{font-size:9px; letter-spacing:3px; color:#7d818b}
svg .tick{stroke:#c9cdd4; stroke-width:2}
svg .tick.major{stroke:#fff; stroke-width:3}
svg .num{fill:#cfd3da; font-family:'Share Tech Mono',monospace; font-size:11px; text-anchor:middle}
svg .arc-red{stroke:var(--red); stroke-width:5; fill:none; opacity:.85}
.needle{transform-origin:50% 50%; transition:transform 1.8s cubic-bezier(.16,1,.3,1)}
.needle line{stroke:var(--red-lit); stroke-width:4; filter:drop-shadow(0 0 4px rgba(255,45,77,.8))}
.hub{fill:#e9edf2; stroke:#5a5e66; stroke-width:2}

/* center stack */
.stack{background:linear-gradient(180deg,#202024,#111113); border-radius:14px; padding:14px;
  border:1px solid #34343a; box-shadow:inset 0 2px 6px rgba(0,0,0,.6)}
.mini-row{display:grid; grid-template-columns:1fr 1fr; gap:10px}
.mini{background:#0c0c0d; border-radius:10px; padding:8px 10px; border:1px solid #2b2b30; text-align:center}
.mini .m-t{font-size:9px; letter-spacing:2px; color:#8b8f98}
.mini .m-v{font-family:'Share Tech Mono',monospace; font-size:20px; color:var(--cream)}
/* ===== piston bank (small live numbers as engine cylinders) ===== */
.pistons{display:flex; justify-content:space-around; align-items:flex-end; gap:6px; padding:4px 2px}
.cyl{flex:1; display:flex; flex-direction:column; align-items:center; gap:4px}
.cyl .num{font-family:'Share Tech Mono',monospace; font-size:13px; color:var(--amber); text-shadow:0 0 8px rgba(255,179,0,.4)}
.cyl .lbl{font-size:8px; letter-spacing:1px; color:#9aa0aa; text-align:center}
.bore{position:relative; width:32px; height:72px; border-radius:7px 7px 4px 4px; overflow:hidden;
  background:linear-gradient(90deg,#08080a,#1c1c20 28%,#2b2b32 50%,#1c1c20 72%,#08080a);
  border:1px solid #3a3a42; box-shadow:inset 0 5px 9px rgba(0,0,0,.85), inset 0 -2px 4px rgba(255,255,255,.05)}
.bore::before{content:''; position:absolute; inset:0; border-radius:inherit;
  background:repeating-linear-gradient(0deg,transparent 0 9px,rgba(255,255,255,.03) 9px 10px)} /* cylinder rings */
.piston{position:absolute; left:3px; right:3px; height:24px; border-radius:4px;
  background:linear-gradient(180deg,#f4f6f9,#b2b6be 42%,#7d818a 56%,#cbccd3);
  box-shadow:0 3px 6px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.7), inset 0 -2px 3px rgba(0,0,0,.4);
  bottom:calc(var(--p,0) * 44px);
  transition:bottom 1.1s cubic-bezier(.34,1.3,.5,1);
  animation:pump .9s ease-in-out infinite}
.piston::after{content:''; position:absolute; left:50%; top:100%; width:5px; height:60px; transform:translateX(-50%);
  background:linear-gradient(90deg,#4a4e56,#9a9ea6,#4a4e56)}  /* connecting rod down to crank */
.cyl:nth-child(2) .piston{animation-delay:-.45s}
.cyl:nth-child(3) .piston{animation-delay:-.22s}
.cyl:nth-child(4) .piston{animation-delay:-.67s}
@keyframes pump{0%,100%{transform:translateY(0)}50%{transform:translateY(4px)}}
.cyl.full .piston{background:linear-gradient(180deg,#ffe291,#FFB300 50%,#a06f00);
  box-shadow:0 0 10px rgba(255,179,0,.5),inset 0 1px 0 rgba(255,255,255,.7); animation-duration:.5s}
.cyl.full .num{color:var(--green,#39d353);text-shadow:0 0 8px rgba(57,211,83,.5)}
.switches{display:flex; justify-content:space-around; margin-top:14px; gap:6px}
.sw{display:flex; flex-direction:column; align-items:center; gap:5px; cursor:pointer; user-select:none}
.sw .toggle{width:26px; height:44px; border-radius:6px;
  background:linear-gradient(180deg,#2a2a2e,#0d0d0e); border:1px solid #3a3a40; position:relative;
  box-shadow:inset 0 0 6px rgba(0,0,0,.7)}
.sw .knob{position:absolute; left:3px; right:3px; height:18px; border-radius:4px; top:22px;
  background:linear-gradient(180deg,#e9edf2,#8b9099); transition:top .18s; box-shadow:0 2px 4px rgba(0,0,0,.6)}
.sw.on .knob{top:4px; background:linear-gradient(180deg,#ffd36b,var(--amber))}
.sw.on .toggle{box-shadow:inset 0 0 10px rgba(255,179,0,.4)}
.sw .lbl{font-size:9px; letter-spacing:1px; color:#9aa0aa}
.sw.on .lbl{color:var(--amber)}
.trip{margin-top:12px; text-align:center; font-family:'Share Tech Mono',monospace; font-size:11px;
  letter-spacing:1px; color:var(--amber); background:#0a0a0b; border:1px solid #2b2b30; border-radius:8px;
  padding:6px 8px; box-shadow:inset 0 0 8px rgba(255,179,0,.08); text-shadow:0 0 6px rgba(255,179,0,.3)}

/* ===== glovebox feed ===== */
.feed-head{display:flex; align-items:center; justify-content:space-between; margin:6px 2px 12px}
.feed-head h2{font-family:'Racing Sans One',cursive; font-size:15px; letter-spacing:2px; color:#cfd3da}
.feed-head .cnt{font-family:'Share Tech Mono',monospace; color:var(--amber); font-size:13px}
.feed{display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:12px}
.card{background:linear-gradient(180deg,#1a1a1c,#101011); border:1px solid #2c2c31; border-radius:12px;
  padding:12px; box-shadow:0 6px 16px rgba(0,0,0,.5); transition:transform .15s, box-shadow .15s}
.card:hover{transform:translateY(-3px); box-shadow:0 10px 24px rgba(0,0,0,.7), 0 0 0 1px rgba(255,179,0,.3)}
.card .t{font-size:13px; font-weight:500; color:var(--cream); line-height:1.2; min-height:32px}
.card .v{font-size:10px; letter-spacing:1px; color:#8b8f98; margin:3px 0 8px; text-transform:uppercase}
.pal{display:flex; height:34px; border-radius:6px; overflow:hidden; border:1px solid #000;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)}
.pal .sw2{position:relative; display:flex; align-items:flex-end; justify-content:center; padding-bottom:2px}
.pal .sw2 span{font-family:'Share Tech Mono',monospace; font-size:8px; color:rgba(255,255,255,.85);
  text-shadow:0 1px 2px #000; opacity:0; transition:opacity .15s}
.pal:hover .sw2 span{opacity:1}
.chips{display:flex; flex-wrap:wrap; gap:4px; margin-top:8px}
.chip{font-size:9px; letter-spacing:.5px; padding:2px 7px; border-radius:20px;
  background:#26262b; border:1px solid #37373d; color:#c3c7cf}
.chip.color{background:rgba(200,16,46,.15); border-color:rgba(200,16,46,.5); color:#ff8fa3}
.odo{font-family:'Share Tech Mono',monospace; font-size:9px; color:#5f636c; margin-top:6px; letter-spacing:1px}
.card .sku{font-family:'Share Tech Mono',monospace; color:var(--amber); letter-spacing:.5px;
  text-shadow:0 0 8px rgba(255,179,0,.35)}
/* ===== live SKU ticker (below the engine) ===== */
.live-head{display:flex; align-items:center; gap:10px; margin:4px 2px 10px}
.live-head h2{font-family:'Racing Sans One',cursive; font-size:14px; letter-spacing:2px; color:#cfd3da; margin:0}
.live-dot{width:9px; height:9px; border-radius:50%; background:#3fbf6f; box-shadow:0 0 8px #3fbf6f; animation:pulse 1.3s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1; transform:scale(1)} 50%{opacity:.3; transform:scale(.6)}}
.live-cnt{font-family:'Share Tech Mono',monospace; color:var(--amber); font-size:12px; margin-left:auto}
.live-list{display:flex; flex-direction:column; gap:6px; min-height:56px}
.live-row{display:flex; align-items:center; gap:10px; background:linear-gradient(180deg,#17171a,#0e0e10);
  border:1px solid #2b2b31; border-left:3px solid var(--amber); border-radius:9px; padding:6px 10px;
  animation:slidein .5s cubic-bezier(.16,1,.3,1)}
@keyframes slidein{from{opacity:0; transform:translateY(-16px)} to{opacity:1; transform:translateY(0)}}
.live-row img{width:38px; height:38px; border-radius:6px; object-fit:cover; background:#000; flex:none; border:1px solid #333}
.live-row .lr-sku{font-family:'Share Tech Mono',monospace; color:var(--amber); font-size:13px; flex:none; min-width:108px; text-shadow:0 0 8px rgba(255,179,0,.4)}
.live-row .lr-t{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; color:var(--cream)}
.live-row .lr-t small{color:#8b8f98; display:block; font-size:10px; letter-spacing:.5px}
.live-row .lr-pal{display:flex; height:22px; width:92px; border-radius:4px; overflow:hidden; flex:none; border:1px solid #000}
.live-row .lr-pal span{flex:1}
/* collapsible glovebox */
.feed-head{cursor:pointer; user-select:none}
.feed-head .car{display:inline-block; color:var(--amber)}
.feed.collapsed{display:none}
footer{text-align:center; margin-top:22px; font-size:10px; letter-spacing:3px; color:#5f636c}
</style>
</head>
<body>
<div class="dash">
  <header>
    <span class="emblem">🏁</span>
    <div style="text-align:center">
      <div class="wordmark">CORVETTE</div>
      <div class="script">Sting Ray · Catalog Console</div>
    </div>
    <span class="emblem">🏁</span>
  </header>
  <div class="sub">1 9 6 7 &nbsp;·&nbsp; T K - 1 3 5 &nbsp;·&nbsp; C O L O R &nbsp; &amp; &nbsp; D E S C R I P T O R</div>

  <div class="stitch"></div>

  <div class="cluster">
    <!-- LEFT: tach = distinct colors -->
    <div class="gauge-wrap">
      <div class="gauge" id="gaugeTach"></div>
    </div>
    <!-- CENTER: mini gauges + switches -->
    <div class="stack">
      <div class="pistons">
        <div class="cyl" id="cStyle" style="--p:0"><div class="num" id="mStyle">–</div><div class="bore"><div class="piston"></div></div><div class="lbl">STYLE</div></div>
        <div class="cyl" id="cMat" style="--p:0"><div class="num" id="mMat">–</div><div class="bore"><div class="piston"></div></div><div class="lbl">MATERIAL</div></div>
        <div class="cyl" id="cCol" style="--p:0"><div class="num" id="mVen">–</div><div class="bore"><div class="piston"></div></div><div class="lbl">COLLECTION</div></div>
        <div class="cyl" id="cLab" style="--p:0"><div class="num" id="mAvg">–</div><div class="bore"><div class="piston"></div></div><div class="lbl">LABEL→BARE</div></div>
      </div>
      <div class="switches" id="switches"></div>
      <div class="trip" id="trip">TRIP · — /min · ETA —</div>
    </div>
    <!-- RIGHT: speedo = SKUs -->
    <div class="gauge-wrap">
      <div class="gauge big" id="gaugeSpeedo"></div>
    </div>
  </div>

  <div class="stitch"></div>

  <div class="live-head">
    <span class="live-dot"></span>
    <h2>LIVE · SKUS UPDATING</h2>
    <span class="live-cnt" id="liveCnt">connecting…</span>
  </div>
  <div class="live-list" id="liveList"></div>

  <div class="stitch"></div>

  <div class="feed-head" id="feedHead">
    <h2><span class="car" id="feedCar">▸</span> GLOVEBOX FEED</h2>
    <span class="cnt" id="feedCnt"></span>
  </div>
  <div class="feed collapsed" id="feed"></div>

  <footer>DESIGNER WALLCOVERINGS · TK-135 · POWERED PER SINGLE ROLL</footer>
</div>

<script>
// ---- build an SVG gauge ----
function gauge(el, {min=0,max=100,value=0,title='',unit='',redFrom=0.82,big=false}){
  const R=big?130:115, cx=R, cy=R, r=R-24, a0=210, a1=-30; // sweep 240deg
  const val2ang=v=>{const t=Math.max(0,Math.min(1,(v-min)/(max-min)));return a0+(a1-a0)*t;};
  const pol=(ang,rad)=>[cx+rad*Math.cos(ang*Math.PI/180), cy-rad*Math.sin(ang*Math.PI/180)];
  let ticks='',nums='';
  const N=10;
  for(let i=0;i<=N;i++){
    const ang=a0+(a1-a0)*(i/N), maj=i%2===0;
    const [x1,y1]=pol(ang,r), [x2,y2]=pol(ang,r-(maj?14:8));
    ticks+=`<line class="tick ${maj?'major':''}" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`;
    if(maj){const [tx,ty]=pol(ang,r-26); nums+=`<text class="num" x="${tx}" y="${ty+4}">${Math.round(min+(max-min)*i/N)}</text>`;}
  }
  // redline arc
  const aR0=val2ang(min+(max-min)*redFrom), aR1=val2ang(max);
  const [rx0,ry0]=pol(aR0,r), [rx1,ry1]=pol(aR1,r);
  const large=Math.abs(aR1-aR0)>180?1:0;
  const arc=`<path class="arc-red" d="M ${rx0} ${ry0} A ${r} ${r} 0 ${large} 1 ${rx1} ${ry1}"/>`;
  el.innerHTML=`<div class="bezel"></div><div class="face"></div>
    <svg viewBox="0 0 ${2*R} ${2*R}" style="position:absolute;inset:0">
      ${ticks}${nums}${arc}
      <g class="needle" id="${el.id}-ndl" transform="rotate(0 ${cx} ${cy})">
        <line x1="${cx}" y1="${cy}" x2="${pol(a0,r-6)[0]}" y2="${pol(a0,r-6)[1]}"/>
      </g>
      <circle class="hub" cx="${cx}" cy="${cy}" r="9"/>
    </svg>
    <div class="g-label"><div class="g-title">${title}</div>
      <div class="g-value" id="${el.id}-val">0</div><div class="g-unit">${unit}</div></div>`;
  // Store a RE-DRIVABLE updater on the element so the gauge is a LIVE instrument:
  // each poll calls el.__drive(newValue) and the needle sweeps + the readout counts
  // from the previous value to the new one. Needle pins at max if value exceeds it.
  const ndlId=el.id+'-ndl', valId=el.id+'-val';
  el.__cur=0;
  el.__drive=function(v){
    const ndl=document.getElementById(ndlId), vEl=document.getElementById(valId);
    if(!ndl||!vEl) return;
    const target=Math.max(min,Math.min(max,v));
    ndl.style.transform=`rotate(${-(val2ang(target)-a0)}deg)`;
    const from=el.__cur||0; let s=null;
    function step(ts){if(!s)s=ts;const pr=Math.min(1,(ts-s)/1400);vEl.textContent=Math.round(from+(v-from)*pr).toLocaleString();if(pr<1)requestAnimationFrame(step);}
    requestAnimationFrame(step); el.__cur=v;
  };
  requestAnimationFrame(()=>el.__drive(value));
}

let DATA=[], TOTALS=null, activeFilter=null;
// Live catalog first (/api/data → {records,totals}); fall back to the frozen snapshot
// if the DB/endpoint is unreachable so the console always renders.
fetch('/api/data').then(r=>r.json()).then(d=>{
  if(Array.isArray(d)){DATA=d;TOTALS=null;} else {DATA=d.records||[];TOTALS=d.totals||null;}
  init();
}).catch(()=>fetch('data.json').then(r=>r.json()).then(d=>{DATA=d;TOTALS=null;init();}));

function init(){
  // Gauges reflect the TRUE catalog totals when the live endpoint supplied them;
  // the glovebox feed below still renders the newest-N sample held in DATA.
  const skus=TOTALS?TOTALS.skus:DATA.length;
  const colors=TOTALS?TOTALS.colors:DATA.reduce((a,p)=>a+p.palette.length,0);
  const avg=skus?(colors/skus).toFixed(1):'0';
  const vendors=TOTALS?TOTALS.vendors:new Set(DATA.map(p=>p.vendor)).size;
  // LIVE gauges: speedo = SKUs color-normalized (climbs to 81,102 as the job runs),
  // tach = descriptor tags written (climbs to ~66k). Both re-driven by pollStats().
  gauge(document.getElementById('gaugeSpeedo'),{min:0,max:81102,value:0,title:'SKUS NORMALIZED',unit:'/ 81,102',big:true,redFrom:0.9});
  gauge(document.getElementById('gaugeTach'),{min:0,max:66193,value:0,title:'DESCRIPTOR TAGS',unit:'WRITTEN',redFrom:0.9});
  pollStats(); setInterval(pollStats,3000);
  // switches = top color buckets as filters
  const buckets={}; DATA.forEach(p=>buckets[p.color]=(buckets[p.color]||0)+1);
  const top=Object.entries(buckets).sort((a,b)=>b[1]-a[1]).slice(0,5).map(x=>x[0]);
  const sw=document.getElementById('switches');
  ['ALL',...top].forEach(name=>{
    const el=document.createElement('div'); el.className='sw'+(name==='ALL'?' on':'');
    el.innerHTML=`<div class="toggle"><div class="knob"></div></div><div class="lbl">${name.slice(0,8)}</div>`;
    el.onclick=()=>{document.querySelectorAll('.sw').forEach(s=>s.classList.remove('on'));el.classList.add('on');activeFilter=name==='ALL'?null:name;render();};
    sw.appendChild(el);
  });
  render();
}
function render(){
  const feed=document.getElementById('feed'); feed.innerHTML='';
  const rows=DATA.filter(p=>!activeFilter||p.color===activeFilter);
  const shown=Math.min(rows.length,120);
  document.getElementById('feedCnt').textContent='showing '+shown+' / '+rows.length+' SKU';
  rows.slice(0,120).forEach(p=>{
    const total=p.palette.reduce((a,c)=>a+c.pct,0)||1;
    const pal=p.palette.map(c=>`<div class="sw2" style="flex:${c.pct};background:${c.hex}"><span>${c.pct}%</span></div>`).join('');
    const card=document.createElement('div'); card.className='card';
    card.innerHTML=`<div class="t">${p.title||'(untitled)'}</div>
      <div class="v">${p.vendor||'—'} · <span class="sku">${p.sku||'—'}</span></div>
      <div class="pal">${pal}</div>
      <div class="chips"><span class="chip color">${p.color}</span>
        ${p.palette.slice(0,4).map(c=>`<span class="chip">${c.hex}</span>`).join('')}</div>
      <div class="odo">◱ ${p.sku||p.gid} · ${p.palette.length} COLORS</div>`;
    feed.appendChild(card);
  });
}

// ---- LIVE gauge driver: re-reads /api/stats every 3s so the twin gauges climb in
// real time as the TK-135 jobs write. Speedo=SKUs normalized, tach=descriptor tags,
// mini-stack=per-job counts. All numbers CHANGE as work lands. ----
async function pollStats(){
  try{
    const r=await fetch('/api/stats',{cache:'no-store'}); if(!r.ok)return;
    const s=await r.json();
    const sp=document.getElementById('gaugeSpeedo'), ta=document.getElementById('gaugeTach');
    if(sp&&sp.__drive) sp.__drive(s.color||0);
    if(ta&&ta.__drive) ta.__drive(s.descriptors||0);
    // pistons: height = count/target, glows gold + goes green when a job hits 100%
    const piston=(cyl,numId,count,target)=>{
      const c=document.getElementById(cyl); const p=Math.min(1,(count||0)/target);
      if(c){c.style.setProperty('--p',p.toFixed(3)); c.classList.toggle('full',p>=0.999);}
      const n=document.getElementById(numId); if(n)n.textContent=(count||0).toLocaleString();
    };
    piston('cStyle','mStyle',s.style,37566);
    piston('cMat','mMat',s.material,24581);
    piston('cCol','mVen',s.collection,3891);
    piston('cLab','mAvg',s.label,7155);
    // trip computer: processing RATE (SKUs/min) + ETA to finish the color normalize (81,102)
    const now=Date.now();
    if(pollStats._t){
      const dt=(now-pollStats._t)/60000, dc=(s.color-pollStats._c);
      if(dt>0 && dc>=0){
        const rate=dc/dt; // could be 0 between writes
        pollStats._r = pollStats._r ? pollStats._r*0.6 + rate*0.4 : rate; // smooth
        const left=81102-s.color, mins=pollStats._r>0.5?left/pollStats._r:null;
        const eta=mins==null?'—':(mins>90?(mins/60).toFixed(1)+'h':Math.round(mins)+'m');
        const t=document.getElementById('trip');
        if(t) t.textContent=`TRIP · ${Math.round(pollStats._r)}/min · ETA ${eta} · ${(100*s.color/81102).toFixed(1)}%`;
      }
    }
    pollStats._t=now; pollStats._c=s.color;
  }catch(e){/* transient; next tick retries */}
}

// ---- collapsible glovebox (starts collapsed) ----
document.getElementById('feedHead').onclick=()=>{
  const f=document.getElementById('feed'), c=document.getElementById('feedCar');
  const open=f.classList.toggle('collapsed')===false;   // toggle returns true when class NOW present (collapsed)
  c.textContent=open?'▾':'▸';
};

// ---- LIVE ticker: SKUs as the TK-135 apply-job updates them ----
// Polls /api/new-items (progress-log tail). Prepends genuinely-new SKUs with a
// slide-in so the console visibly MOVES; the "updated" counter ticks up in real time.
const seenLive=new Set();
async function pollLive(){
  try{
    const r=await fetch('/api/new-items?limit=12'); if(!r.ok)return;
    const d=await r.json();
    const cnt=document.getElementById('liveCnt');
    if(typeof d.processed==='number') cnt.textContent=d.processed.toLocaleString()+' updated';
    const list=document.getElementById('liveList');
    // records are newest-first; iterate oldest→newest so the newest ends up on top
    for(const rec of [...d.records].reverse()){
      if(seenLive.has(rec.gid))continue;
      seenLive.add(rec.gid);
      const pal=(rec.palette||[]).map(c=>`<span style="background:${c.hex}"></span>`).join('')||'<span style="background:#333"></span>';
      // Route through the same-origin /img proxy: dead Shopify assets return a
      // transparent placeholder (200) so the browser never logs a 404 console error.
      const img=`<img src="/img?u=${encodeURIComponent(rec.image||'')}" loading="lazy">`;
      const row=document.createElement('div'); row.className='live-row';
      row.innerHTML=`${img}<span class="lr-sku">${rec.sku||rec.gid}</span>`+
        `<span class="lr-t">${rec.title||''}<small>${rec.vendor||''}</small></span>`+
        `<span class="lr-pal">${pal}</span>`;
      if(rec.href){row.style.cursor='pointer';row.onclick=()=>window.open(rec.href,'_blank','noopener');}
      list.insertBefore(row,list.firstChild);
      while(list.children.length>10) list.removeChild(list.lastChild);
    }
  }catch(e){/* transient; next tick retries */}
}
setInterval(pollLive,2500);
pollLive();
</script>
</body>
</html>