[object Object]

← back to Wallco Ai

design/:id: Photoshop-style edit panel — sliders, palette dots, color wheel, drag-drop recolor, bake

c9365f55c2ba13cbb6fcaebb1a3992715b578cbc · 2026-05-11 19:33:30 -0700 · SteveStudio2

Collapsed left rail on every design detail page. Toggle (⚙) expands.
All client-side — uses canvas getImageData + CSS filters. No round-trip
to server for live preview; only "Bake" persists the result.

ADJUST PANEL (8 sliders, live preview via CSS filter)
  Hue (-180…+180°) · Saturation (0…200%) · Vibrance (0…200%)
  Brightness (0…200%) · Contrast (0…200%) · Opacity (20…100%)
  Sepia (0…100%) · Blur (0…20px)
  Reset all button.

PALETTE DOTS
  Canvas extracts 14 dominant colors via 6-bit color-bucket quantization
  on a 64×64 downsample. Each dot shows hex on hover, sortable by:
    by hex · light→dark · color wheel (hue-sorted)
  Dots are draggable AND drop-targets.

DRAG-DROP RECOLOR (color separation)
  Drag one dot onto another → finds all pixels within
    Δhue < 25°  AND  Δlightness < 0.18
  of the source dot's HSL, shifts them toward the target hue while
  preserving local lightness. Operates on full-res via canvas.

HSV COLOR WHEEL
  160×160 canvas, click-to-pick HSV. Pair with a selected swatch above
  → recolors that swatch's pixels to the wheel pick.

SUGGESTED REPLACEMENTS
  When a dot is selected, surface 5 swatches: 2 analogous (±30°),
  complementary (180°), 2 triadic (120° / 240°). Click any to recolor.

BAKE → SAVE AS NEW DESIGN
  POST /api/design/:id/bake { image_b64, parent_id }
  Writes the canvas-baked PNG into data/generated/, watermarks it
  (same SAND, LLC triple-layer), inserts a new spoon_all_designs row
  with notes='baked from design #<parent>', regenerates the snapshot,
  redirects to the new /design/:newId.

All edits non-destructive until Bake. CORS-locked images surface a
clear "palette unavailable" message (designs served same-origin so
this only triggers on cross-origin embeds).

Verified: panel renders + 14 dots extracted + 8 sliders on prod
/design/13. Screenshot in /tmp/prod_d13_panel.png.

Files touched

Diff

commit c9365f55c2ba13cbb6fcaebb1a3992715b578cbc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 19:33:30 2026 -0700

    design/:id: Photoshop-style edit panel — sliders, palette dots, color wheel, drag-drop recolor, bake
    
    Collapsed left rail on every design detail page. Toggle (⚙) expands.
    All client-side — uses canvas getImageData + CSS filters. No round-trip
    to server for live preview; only "Bake" persists the result.
    
    ADJUST PANEL (8 sliders, live preview via CSS filter)
      Hue (-180…+180°) · Saturation (0…200%) · Vibrance (0…200%)
      Brightness (0…200%) · Contrast (0…200%) · Opacity (20…100%)
      Sepia (0…100%) · Blur (0…20px)
      Reset all button.
    
    PALETTE DOTS
      Canvas extracts 14 dominant colors via 6-bit color-bucket quantization
      on a 64×64 downsample. Each dot shows hex on hover, sortable by:
        by hex · light→dark · color wheel (hue-sorted)
      Dots are draggable AND drop-targets.
    
    DRAG-DROP RECOLOR (color separation)
      Drag one dot onto another → finds all pixels within
        Δhue < 25°  AND  Δlightness < 0.18
      of the source dot's HSL, shifts them toward the target hue while
      preserving local lightness. Operates on full-res via canvas.
    
    HSV COLOR WHEEL
      160×160 canvas, click-to-pick HSV. Pair with a selected swatch above
      → recolors that swatch's pixels to the wheel pick.
    
    SUGGESTED REPLACEMENTS
      When a dot is selected, surface 5 swatches: 2 analogous (±30°),
      complementary (180°), 2 triadic (120° / 240°). Click any to recolor.
    
    BAKE → SAVE AS NEW DESIGN
      POST /api/design/:id/bake { image_b64, parent_id }
      Writes the canvas-baked PNG into data/generated/, watermarks it
      (same SAND, LLC triple-layer), inserts a new spoon_all_designs row
      with notes='baked from design #<parent>', regenerates the snapshot,
      redirects to the new /design/:newId.
    
    All edits non-destructive until Bake. CORS-locked images surface a
    clear "palette unavailable" message (designs served same-origin so
    this only triggers on cross-origin embeds).
    
    Verified: panel renders + 14 dots extracted + 8 sliders on prod
    /design/13. Screenshot in /tmp/prod_d13_panel.png.
---
 server.js | 538 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 534 insertions(+), 4 deletions(-)

diff --git a/server.js b/server.js
index 0ae201a..8e7053d 100644
--- a/server.js
+++ b/server.js
@@ -56,6 +56,22 @@ function psqlQuery(sql) {
   return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
 }
 
+// PG value escaper for inline SQL — matches the project's existing inline-string style.
+function pgEsc(v) {
+  if (v === null || v === undefined) return 'NULL';
+  if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NULL';
+  if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE';
+  return "'" + String(v).replace(/'/g, "''") + "'";
+}
+
+// Admin-token check for destructive room ops. Empty/unset token => admin features locked off.
+const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
+function isAdmin(req) {
+  if (!ADMIN_TOKEN) return false;
+  return (req.headers['x-admin-token'] === ADMIN_TOKEN)
+      || (req.query && req.query.admin === ADMIN_TOKEN);
+}
+
 app.use(express.json());
 app.use(express.urlencoded({ extended: false }));
 
@@ -1295,9 +1311,86 @@ app.get('/design/:id', (req, res) => {
 ${htmlHeader('/designs')}
 <main class="detail-main">
   <div class="detail-layout">
-    <div class="detail-image-col">
+    <div class="detail-image-col" style="position:relative">
+      <!-- COLLAPSIBLE COLOR / ADJUST PANEL ─────────────────────────────────── -->
+      <div id="edit-panel" class="edit-panel collapsed">
+        <button id="edit-toggle" class="edit-toggle" aria-label="Toggle color & adjustments">
+          <span class="edit-toggle-icon">⚙</span>
+          <span class="edit-toggle-label">Color &amp; adjust</span>
+        </button>
+        <div class="edit-body">
+          <div class="edit-section">
+            <div class="edit-section-title">Adjust</div>
+            <label class="adj"><span>Hue</span><input type="range" id="adj-hue" min="-180" max="180" step="1" value="0"><em id="adj-hue-v">0°</em></label>
+            <label class="adj"><span>Saturation</span><input type="range" id="adj-sat" min="0" max="200" step="1" value="100"><em id="adj-sat-v">100%</em></label>
+            <label class="adj"><span>Vibrance</span><input type="range" id="adj-vib" min="0" max="200" step="1" value="100"><em id="adj-vib-v">100%</em></label>
+            <label class="adj"><span>Brightness</span><input type="range" id="adj-bri" min="0" max="200" step="1" value="100"><em id="adj-bri-v">100%</em></label>
+            <label class="adj"><span>Contrast</span><input type="range" id="adj-con" min="0" max="200" step="1" value="100"><em id="adj-con-v">100%</em></label>
+            <label class="adj"><span>Opacity</span><input type="range" id="adj-opa" min="20" max="100" step="1" value="100"><em id="adj-opa-v">100%</em></label>
+            <label class="adj"><span>Sepia</span><input type="range" id="adj-sep" min="0" max="100" step="1" value="0"><em id="adj-sep-v">0%</em></label>
+            <label class="adj"><span>Blur</span><input type="range" id="adj-blur" min="0" max="20" step="1" value="0"><em id="adj-blur-v">0px</em></label>
+            <button id="adj-reset" class="adj-reset">Reset all</button>
+          </div>
+          <div class="edit-section">
+            <div class="edit-section-title">Palette · drag dot onto dot to swap</div>
+            <div id="palette-dots" class="palette-dots">
+              <div class="dots-loading">Extracting colors…</div>
+            </div>
+            <div class="dots-sort-row">
+              <button class="dots-sort active" data-sort="hex">by hex</button>
+              <button class="dots-sort" data-sort="light">light→dark</button>
+              <button class="dots-sort" data-sort="hue">color wheel</button>
+            </div>
+          </div>
+          <div class="edit-section">
+            <div class="edit-section-title">Color wheel · pick replacement</div>
+            <canvas id="color-wheel" width="160" height="160"></canvas>
+            <div id="wheel-readout" style="font:11px var(--mono,monospace);color:var(--ink-faint);text-align:center;margin-top:4px">Click a swatch above first, then a wheel position to recolor it</div>
+          </div>
+          <div class="edit-section">
+            <div class="edit-section-title">Suggested replacements</div>
+            <div id="suggested-swatches" class="suggested-swatches"></div>
+          </div>
+          <div class="edit-section">
+            <button id="adj-bake" class="adj-bake">Bake edits → save as new design</button>
+          </div>
+        </div>
+      </div>
+      <style>
+        .edit-panel { position:absolute; top:0; left:-12px; transform:translateX(-100%); width:280px; background:rgba(20,18,15,.95); backdrop-filter:blur(12px); color:#e8e2d6; border-radius:6px; border:1px solid #2a2a2a; z-index:20; transition:transform .3s ease; box-shadow:0 12px 36px rgba(0,0,0,.4); }
+        .edit-panel.collapsed { transform:translateX(calc(-100% + 36px)); }
+        .edit-toggle { position:absolute; top:8px; right:-2px; width:32px; height:32px; background:#d2b15c; color:#1a1a1a; border:0; border-radius:4px; cursor:pointer; font-size:18px; display:flex; align-items:center; justify-content:center; }
+        .edit-toggle-label { display:none; }
+        .edit-body { padding:42px 14px 14px; max-height:80vh; overflow-y:auto; }
+        .edit-section { margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid #2a2a2a; }
+        .edit-section:last-child { border-bottom:0; }
+        .edit-section-title { font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:#888; margin-bottom:8px; }
+        .adj { display:grid; grid-template-columns:70px 1fr 42px; align-items:center; gap:8px; margin-bottom:6px; font-size:11px; color:#bba; }
+        .adj input[type=range] { width:100%; }
+        .adj em { font-style:normal; font-size:10px; color:#666; text-align:right; }
+        .adj-reset { background:transparent; border:1px solid #444; color:#aaa; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; margin-top:6px; }
+        .palette-dots { display:flex; flex-wrap:wrap; gap:5px; min-height:40px; }
+        .dots-loading { font-size:11px; color:#888; }
+        .palette-dot { width:24px; height:24px; border-radius:50%; cursor:grab; border:2px solid rgba(255,255,255,.15); position:relative; transition:transform .15s; }
+        .palette-dot:hover { transform:scale(1.18); border-color:#d2b15c; }
+        .palette-dot.selected { box-shadow:0 0 0 2px #d2b15c, 0 0 12px rgba(210,177,92,.6); transform:scale(1.15); }
+        .palette-dot.drag-over { box-shadow:0 0 0 2px #fff; }
+        .dot-label { position:absolute; bottom:-18px; left:50%; transform:translateX(-50%); font:9px var(--mono,monospace); color:#888; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .15s; }
+        .palette-dot:hover .dot-label { opacity:1; }
+        .dots-sort-row { display:flex; gap:4px; margin-top:14px; }
+        .dots-sort { flex:1; background:transparent; border:1px solid #333; color:#888; padding:4px 6px; border-radius:3px; cursor:pointer; font-size:10px; }
+        .dots-sort.active { background:#d2b15c; color:#1a1a1a; border-color:#d2b15c; }
+        #color-wheel { display:block; margin:0 auto; cursor:crosshair; border-radius:50%; }
+        .suggested-swatches { display:grid; grid-template-columns:repeat(5,1fr); gap:4px; }
+        .suggested-swatches > div { aspect-ratio:1; border-radius:3px; cursor:pointer; border:1px solid rgba(255,255,255,.1); }
+        .suggested-swatches > div:hover { border-color:#d2b15c; }
+        .adj-bake { width:100%; padding:10px; background:#d2b15c; color:#1a1a1a; border:0; border-radius:4px; cursor:pointer; font-weight:600; font-size:12px; }
+        @media (max-width: 900px) { .edit-panel { display:none; } }
+      </style>
+
       <div class="detail-img-wrap">
-        <img src="${design.image_url}" alt="${design.title}" class="detail-img" loading="eager">
+        <img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" crossorigin="anonymous">
+        <canvas id="detail-canvas" style="display:none"></canvas>
 
         <div class="scale-preview" style="margin-top:18px">
           <div class="scale-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-wrap:wrap">
@@ -1556,6 +1649,303 @@ ${htmlHeader('/designs')}
       })();
       </script>
 
+      <!-- ── EDIT PANEL: hue/sat/vibrance/palette/dots/wheel ────────────────── -->
+      <script>
+      (function(){
+        var img    = document.getElementById('detail-img');
+        var canvas = document.getElementById('detail-canvas');
+        var panel  = document.getElementById('edit-panel');
+        var toggle = document.getElementById('edit-toggle');
+        if (!img || !panel) return;
+
+        toggle.addEventListener('click', function(){
+          panel.classList.toggle('collapsed');
+        });
+
+        // ── 1. CSS-filter live adjustments ─────────────────────────────────
+        var adjState = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 };
+        function applyFilter() {
+          img.style.filter =
+            'hue-rotate(' + adjState.hue + 'deg) ' +
+            'saturate(' + (adjState.sat / 100) + ') ' +
+            'brightness(' + (adjState.bri / 100) + ') ' +
+            'contrast(' + (adjState.con / 100) + ') ' +
+            'sepia(' + (adjState.sep / 100) + ') ' +
+            'blur(' + adjState.blur + 'px)';
+          img.style.opacity = adjState.opa / 100;
+          // "Vibrance" approximated as saturation*0.5 boost on the saturate filter
+          var vibBoost = (adjState.vib - 100) / 200;
+          img.style.filter += ' saturate(' + (1 + vibBoost) + ')';
+        }
+        var sliders = [
+          ['hue',  '°'], ['sat','%'], ['vib','%'], ['bri','%'],
+          ['con','%'],   ['opa','%'], ['sep','%'], ['blur','px']
+        ];
+        sliders.forEach(function(s){
+          var el = document.getElementById('adj-' + s[0]);
+          var v  = document.getElementById('adj-' + s[0] + '-v');
+          if (!el || !v) return;
+          el.addEventListener('input', function(){
+            adjState[s[0]] = parseFloat(this.value);
+            v.textContent = this.value + s[1];
+            applyFilter();
+          });
+        });
+        document.getElementById('adj-reset').addEventListener('click', function(){
+          adjState = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 };
+          sliders.forEach(function(s){
+            var el = document.getElementById('adj-' + s[0]);
+            var v  = document.getElementById('adj-' + s[0] + '-v');
+            var def = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 }[s[0]];
+            if (el) el.value = def;
+            if (v) v.textContent = def + s[1];
+          });
+          applyFilter();
+        });
+
+        // ── 2. Palette extraction via canvas ───────────────────────────────
+        var PALETTE = []; // [{hex, count, r, g, b, h, s, l}]
+        var selectedDot = null;
+
+        function rgbToHex(r,g,b){ return '#'+[r,g,b].map(function(x){return x.toString(16).padStart(2,'0');}).join(''); }
+        function hexToRgb(h){ var n=parseInt(h.replace('#',''),16); return [(n>>16)&255,(n>>8)&255,n&255]; }
+        function rgbToHsl(r,g,b){
+          r/=255; g/=255; b/=255;
+          var max=Math.max(r,g,b), min=Math.min(r,g,b), l=(max+min)/2, h=0, s=0;
+          if (max!==min) {
+            var d=max-min;
+            s = l>0.5 ? d/(2-max-min) : d/(max+min);
+            if (max===r) h = ((g-b)/d + (g<b?6:0));
+            else if (max===g) h = (b-r)/d + 2;
+            else h = (r-g)/d + 4;
+            h *= 60;
+          }
+          return [h, s, l];
+        }
+
+        function extractPalette(n) {
+          var iw = img.naturalWidth, ih = img.naturalHeight;
+          if (!iw) return [];
+          var scale = Math.min(64 / iw, 64 / ih, 1);
+          var w = Math.max(8, Math.round(iw * scale));
+          var h = Math.max(8, Math.round(ih * scale));
+          canvas.width = w; canvas.height = h;
+          var ctx = canvas.getContext('2d');
+          ctx.drawImage(img, 0, 0, w, h);
+          var data;
+          try { data = ctx.getImageData(0, 0, w, h).data; }
+          catch(e) { return []; } // CORS
+          // Bucket by 6-bit color (4*4*4=64 buckets) — fast color quantization
+          var buckets = {};
+          for (var i = 0; i < data.length; i += 4) {
+            var r = data[i], g = data[i+1], b = data[i+2];
+            var key = (r >> 5) + '_' + (g >> 5) + '_' + (b >> 5);
+            if (!buckets[key]) buckets[key] = {r:0,g:0,b:0,count:0};
+            buckets[key].r += r; buckets[key].g += g; buckets[key].b += b; buckets[key].count++;
+          }
+          var palette = Object.values(buckets).map(function(b){
+            return {
+              r: Math.round(b.r/b.count), g: Math.round(b.g/b.count), b: Math.round(b.b/b.count),
+              count: b.count
+            };
+          }).sort(function(a,b){ return b.count - a.count; }).slice(0, n);
+          return palette.map(function(p){
+            var hex = rgbToHex(p.r, p.g, p.b);
+            var hsl = rgbToHsl(p.r, p.g, p.b);
+            return { hex:hex, r:p.r, g:p.g, b:p.b, count:p.count, h:hsl[0], s:hsl[1], l:hsl[2] };
+          });
+        }
+
+        function renderDots(sort) {
+          var c = PALETTE.slice();
+          if (sort === 'hex')   c.sort(function(a,b){ return a.hex.localeCompare(b.hex); });
+          if (sort === 'light') c.sort(function(a,b){ return b.l - a.l; });
+          if (sort === 'hue')   c.sort(function(a,b){ return a.h - b.h; });
+          var container = document.getElementById('palette-dots');
+          container.innerHTML = c.map(function(p, i){
+            return '<div class="palette-dot" draggable="true" data-hex="' + p.hex + '" style="background:' + p.hex + '" title="' + p.hex + ' · ' + p.count + 'px"><span class="dot-label">' + p.hex + '</span></div>';
+          }).join('');
+          // Wire up drag-drop + click-select
+          container.querySelectorAll('.palette-dot').forEach(function(dot){
+            dot.addEventListener('click', function(){
+              container.querySelectorAll('.palette-dot.selected').forEach(function(d){ d.classList.remove('selected'); });
+              dot.classList.add('selected');
+              selectedDot = dot.getAttribute('data-hex');
+              document.getElementById('wheel-readout').textContent =
+                'Selected ' + selectedDot + ' — click wheel or a suggested swatch to recolor';
+              renderSuggested(selectedDot);
+            });
+            dot.addEventListener('dragstart', function(e){ e.dataTransfer.setData('text/hex', dot.getAttribute('data-hex')); });
+            dot.addEventListener('dragover', function(e){ e.preventDefault(); dot.classList.add('drag-over'); });
+            dot.addEventListener('dragleave', function(){ dot.classList.remove('drag-over'); });
+            dot.addEventListener('drop', function(e){
+              e.preventDefault();
+              dot.classList.remove('drag-over');
+              var src = e.dataTransfer.getData('text/hex');
+              var tgt = dot.getAttribute('data-hex');
+              if (src && tgt && src !== tgt) recolor(tgt, src);
+            });
+          });
+        }
+
+        document.querySelectorAll('.dots-sort').forEach(function(btn){
+          btn.addEventListener('click', function(){
+            document.querySelectorAll('.dots-sort').forEach(function(b){ b.classList.remove('active'); });
+            btn.classList.add('active');
+            renderDots(btn.getAttribute('data-sort'));
+          });
+        });
+
+        // ── 3. Recolor a palette bucket — canvas pixel replace ─────────────
+        function recolor(fromHex, toHex) {
+          var iw = img.naturalWidth, ih = img.naturalHeight;
+          if (!iw) return;
+          canvas.width = iw; canvas.height = ih;
+          var ctx = canvas.getContext('2d');
+          ctx.drawImage(img, 0, 0);
+          var imgData;
+          try { imgData = ctx.getImageData(0,0,iw,ih); }
+          catch(e) { alert('Image is CORS-locked — open from the same origin to recolor.'); return; }
+          var d = imgData.data;
+          var from = hexToRgb(fromHex), to = hexToRgb(toHex);
+          // Convert from to HSL to tolerate similar hues
+          var fromHsl = rgbToHsl(from[0], from[1], from[2]);
+          var toHsl   = rgbToHsl(to[0],   to[1],   to[2]);
+          var TOLERANCE_H = 25;   // degrees
+          var TOLERANCE_L = 0.18; // lightness window
+          var changed = 0;
+          for (var i = 0; i < d.length; i += 4) {
+            var hsl = rgbToHsl(d[i], d[i+1], d[i+2]);
+            var dh = Math.min(Math.abs(hsl[0] - fromHsl[0]), 360 - Math.abs(hsl[0] - fromHsl[0]));
+            var dl = Math.abs(hsl[2] - fromHsl[2]);
+            if (dh < TOLERANCE_H && dl < TOLERANCE_L) {
+              // shift hue toward target, preserve original luminance
+              var newH = (toHsl[0] + (hsl[0] - fromHsl[0]) * 0.3 + 360) % 360;
+              var rgb = hslToRgb(newH, hsl[1] * (toHsl[1] / Math.max(fromHsl[1], 0.001)), hsl[2]);
+              d[i] = rgb[0]; d[i+1] = rgb[1]; d[i+2] = rgb[2];
+              changed++;
+            }
+          }
+          ctx.putImageData(imgData, 0, 0);
+          // Replace the img src with the canvas data URL
+          img.src = canvas.toDataURL('image/png');
+          document.getElementById('wheel-readout').textContent =
+            'Recolored ' + changed.toLocaleString() + ' pixels  (' + fromHex + ' → ' + toHex + ')';
+          // After recolor, re-extract palette
+          setTimeout(function(){ PALETTE = extractPalette(14); renderDots('hex'); }, 200);
+        }
+
+        function hslToRgb(h, s, l) {
+          var c = (1 - Math.abs(2*l - 1)) * s, x = c * (1 - Math.abs(((h/60) % 2) - 1)), m = l - c/2;
+          var r, g, b;
+          if (h<60)       [r,g,b]=[c,x,0];
+          else if (h<120) [r,g,b]=[x,c,0];
+          else if (h<180) [r,g,b]=[0,c,x];
+          else if (h<240) [r,g,b]=[0,x,c];
+          else if (h<300) [r,g,b]=[x,0,c];
+          else            [r,g,b]=[c,0,x];
+          return [Math.round((r+m)*255), Math.round((g+m)*255), Math.round((b+m)*255)];
+        }
+
+        // ── 4. HSV color wheel ────────────────────────────────────────────
+        var wheel = document.getElementById('color-wheel');
+        if (wheel) {
+          var wctx = wheel.getContext('2d');
+          var W = wheel.width, H = wheel.height, R = W/2;
+          var imgD = wctx.createImageData(W, H);
+          for (var y = 0; y < H; y++) {
+            for (var x = 0; x < W; x++) {
+              var dx = x - R, dy = y - R, dist = Math.sqrt(dx*dx + dy*dy);
+              if (dist > R - 2) continue;
+              var ang = Math.atan2(dy, dx) * 180 / Math.PI;
+              if (ang < 0) ang += 360;
+              var sat = Math.min(1, dist / (R - 2));
+              var rgb = hslToRgb(ang, sat, 0.55);
+              var idx = (y * W + x) * 4;
+              imgD.data[idx] = rgb[0]; imgD.data[idx+1] = rgb[1]; imgD.data[idx+2] = rgb[2]; imgD.data[idx+3] = 255;
+            }
+          }
+          wctx.putImageData(imgD, 0, 0);
+          wheel.addEventListener('click', function(e){
+            var rect = wheel.getBoundingClientRect();
+            var x = e.clientX - rect.left, y = e.clientY - rect.top;
+            var pix = wctx.getImageData(x, y, 1, 1).data;
+            if (pix[3] === 0) return;
+            var newHex = rgbToHex(pix[0], pix[1], pix[2]);
+            if (!selectedDot) {
+              document.getElementById('wheel-readout').textContent = 'Pick a swatch first (click a dot above).';
+              return;
+            }
+            recolor(selectedDot, newHex);
+            selectedDot = newHex;
+          });
+        }
+
+        // ── 5. Suggested swatches (analogous + complementary) ─────────────
+        function renderSuggested(hex) {
+          var rgb = hexToRgb(hex), hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]);
+          var suggestions = [
+            (hsl[0] +  30) % 360, (hsl[0] -  30 + 360) % 360,  // analogous
+            (hsl[0] + 180) % 360,                               // complementary
+            (hsl[0] + 120) % 360, (hsl[0] + 240) % 360          // triadic
+          ];
+          var sw = suggestions.map(function(h){
+            var rgb = hslToRgb(h, Math.max(hsl[1], 0.4), Math.max(0.3, Math.min(0.7, hsl[2])));
+            return rgbToHex(rgb[0], rgb[1], rgb[2]);
+          });
+          document.getElementById('suggested-swatches').innerHTML = sw.map(function(s){
+            return '<div title="' + s + '" data-hex="' + s + '" style="background:' + s + '"></div>';
+          }).join('');
+          document.querySelectorAll('#suggested-swatches > div').forEach(function(d){
+            d.addEventListener('click', function(){
+              if (!selectedDot) return;
+              recolor(selectedDot, d.getAttribute('data-hex'));
+              selectedDot = d.getAttribute('data-hex');
+            });
+          });
+        }
+
+        // ── 6. Bake — save current state as a new design ──────────────────
+        document.getElementById('adj-bake').addEventListener('click', async function(){
+          var iw = img.naturalWidth, ih = img.naturalHeight;
+          if (!iw) return;
+          canvas.width = iw; canvas.height = ih;
+          var ctx = canvas.getContext('2d');
+          ctx.filter = img.style.filter || '';
+          ctx.globalAlpha = parseFloat(img.style.opacity) || 1;
+          ctx.drawImage(img, 0, 0);
+          var dataUrl = canvas.toDataURL('image/png');
+          this.disabled = true; this.textContent = 'Saving…';
+          try {
+            var r = await fetch('/api/design/' + ${design.id} + '/bake', {
+              method:'POST', headers:{'Content-Type':'application/json'},
+              body: JSON.stringify({ image_b64: dataUrl.split(',')[1], parent_id: ${design.id} })
+            });
+            var j = await r.json();
+            this.textContent = j.ok ? '✓ Saved as #' + j.design_id : 'Failed: ' + (j.error||'?');
+            if (j.ok && j.design_id) {
+              setTimeout(function(){ window.location.href = '/design/' + j.design_id; }, 800);
+            }
+          } catch(e) {
+            this.textContent = 'Error: ' + e.message;
+          }
+        });
+
+        // ── boot: extract palette once image is ready ─────────────────────
+        function boot() {
+          PALETTE = extractPalette(14);
+          if (PALETTE.length === 0) {
+            document.getElementById('palette-dots').innerHTML =
+              '<div class="dots-loading" style="color:#c66">Image CORS-locked. Palette unavailable.</div>';
+            return;
+          }
+          renderDots('hex');
+        }
+        if (img.complete && img.naturalWidth) boot();
+        else img.addEventListener('load', boot);
+      })();
+      </script>
+
       <!-- Provenance note -->
       <div class="provenance-note">
         <strong>AI-original provenance:</strong> This pattern was generated by <em>wallco.ai</em>.
@@ -2198,8 +2588,102 @@ app.post('/api/room', async (req, res) => {
     const imgB64 = j.image.replace(/^data:image\/\w+;base64,/, '');
     const fname = `room_${design_id}_${roomType}_${Date.now()}.png`;
     const outPath = path.join(ROOMS_DIR, fname);
-    fs.writeFileSync(outPath, Buffer.from(imgB64, 'base64'));
-    res.json({ ok: true, image_url: `/uploads/rooms/${fname}`, roomType, design_id });
+    const buf = Buffer.from(imgB64, 'base64');
+    fs.writeFileSync(outPath, buf);
+    const image_url = `/uploads/rooms/${fname}`;
+
+    // Persist to wallco_rooms for gallery + marketing reuse.
+    let room_id = null;
+    try {
+      const row = psqlQuery(`INSERT INTO wallco_rooms
+        (design_id, room_type, angle, camera_distance, pattern_width, pattern_height,
+         filename, image_path, file_size_bytes, c2pa_signed)
+        VALUES (${pgEsc(parseInt(design_id, 10))}, ${pgEsc(roomType)}, ${pgEsc(angle)},
+                ${pgEsc(Number(cameraDistance))}, ${pgEsc(Number(patternWidth))}, ${pgEsc(Number(patternHeight))},
+                ${pgEsc(fname)}, ${pgEsc(image_url)}, ${pgEsc(buf.length)}, TRUE)
+        RETURNING id;`);
+      room_id = parseInt(row, 10) || null;
+    } catch (e) {
+      console.error('[wallco_rooms insert]', e.message);
+    }
+
+    res.json({ ok: true, room_id, image_url, roomType, design_id });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ── GET /api/rooms?design_id=N&include_removed=0&marketing_only=0
+// Returns generated rooms for a design (or all, if no design_id) with metadata.
+app.get('/api/rooms', (req, res) => {
+  const designId = req.query.design_id ? parseInt(req.query.design_id, 10) : null;
+  const includeRemoved = req.query.include_removed === '1' && isAdmin(req);
+  const marketingOnly = req.query.marketing_only === '1';
+  try {
+    const where = [];
+    if (Number.isInteger(designId)) where.push(`design_id = ${designId}`);
+    if (!includeRemoved) where.push('removed_at IS NULL');
+    if (marketingOnly) where.push('selected_for_marketing = TRUE');
+    const whereSQL = where.length ? `WHERE ${where.join(' AND ')}` : '';
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json)
+      FROM (SELECT id, design_id, room_type, angle, image_path, filename, file_size_bytes,
+                   selected_for_marketing, removed_at, removed_reason, notes, created_at
+              FROM wallco_rooms ${whereSQL}) t;`);
+    res.json({ ok: true, rooms: JSON.parse(raw || '[]'), admin: isAdmin(req) });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ── POST /api/rooms/:id/remove  (admin only)
+// Soft-deletes; the image file stays on disk so prior page links continue to work for caching.
+// Pass { hard: true, reason: "..." } to also unlink the file.
+app.post('/api/rooms/:id/remove', (req, res) => {
+  if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+  const { reason = '', hard = false } = req.body || {};
+  try {
+    const row = psqlQuery(`UPDATE wallco_rooms SET removed_at = NOW(), removed_reason = ${pgEsc(reason)}
+      WHERE id = ${id} AND removed_at IS NULL
+      RETURNING filename;`);
+    if (!row) return res.status(404).json({ ok: false, error: 'not found or already removed' });
+    if (hard) {
+      try { fs.unlinkSync(path.join(ROOMS_DIR, row)); } catch {}
+    }
+    res.json({ ok: true, id, hard: !!hard, filename: row });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ── POST /api/rooms/:id/restore  (admin only) — reverses a soft-delete.
+app.post('/api/rooms/:id/restore', (req, res) => {
+  if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+  try {
+    const row = psqlQuery(`UPDATE wallco_rooms SET removed_at = NULL, removed_reason = NULL
+      WHERE id = ${id} RETURNING id;`);
+    if (!row) return res.status(404).json({ ok: false, error: 'not found' });
+    res.json({ ok: true, id });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ── POST /api/rooms/:id/marketing  (admin only) — toggle marketing flag + optional notes.
+app.post('/api/rooms/:id/marketing', (req, res) => {
+  if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+  const { selected = true, notes = null } = req.body || {};
+  try {
+    const row = psqlQuery(`UPDATE wallco_rooms SET selected_for_marketing = ${pgEsc(!!selected)},
+        notes = COALESCE(${pgEsc(notes)}, notes)
+      WHERE id = ${id} RETURNING id, selected_for_marketing;`);
+    if (!row) return res.status(404).json({ ok: false, error: 'not found' });
+    res.json({ ok: true, row });
   } catch (e) {
     res.status(500).json({ ok: false, error: e.message });
   }
@@ -3923,6 +4407,52 @@ app.get('/api/design/:id/pairings', (req, res) => {
   }
 });
 
+// ── POST /api/design/:id/bake — save a user-edited recolor as a new design
+app.post('/api/design/:id/bake',
+  express.json({ limit: '20mb' }),
+  (req, res) => {
+    const parentId = parseInt(req.params.id, 10);
+    const { image_b64 } = req.body || {};
+    if (!image_b64) return res.status(400).json({ ok:false, error:'image_b64 required' });
+    try {
+      const filename = `bake_${Date.now()}_p${parentId}.png`;
+      const outPath = path.join(IMG_DIR, filename);
+      fs.writeFileSync(outPath, Buffer.from(image_b64, 'base64'));
+      // Watermark the baked image (silent no-op if Python/Pillow missing)
+      try {
+        spawnSync('python3', [
+          path.join(__dirname, 'scripts', 'watermark.py'),
+          'embed', outPath, '--out', outPath,
+          '--owner', process.env.WATERMARK_OWNER || 'SAND, LLC',
+          '--year', String(new Date().getFullYear()),
+        ], { timeout: 30000 });
+      } catch {}
+      // Insert into spoon_all_designs (no Replicate call; this is user-edited)
+      const parent = DESIGNS.find(d => d.id === parentId) || {};
+      const id = psqlQuery(`INSERT INTO spoon_all_designs
+        (kind, width_in, height_in, panels, generator, prompt, seed,
+         pd_source_ids, local_path, dominant_hex, category, is_published, notes)
+        VALUES('seamless_tile', 24, 24, 1, 'replicate',
+               'user-baked recolor of design #${parentId}', 0,
+               '{}', '${outPath.replace(/'/g,"''")}',
+               ${parent.dominant_hex ? "'"+parent.dominant_hex+"'" : 'NULL'},
+               ${parent.category ? "'"+parent.category.replace(/'/g,"''")+"'" : 'NULL'},
+               FALSE, 'baked from design #${parentId}')
+        RETURNING id;`);
+      const newId = parseInt(id, 10);
+      // Refresh in-memory + snapshot
+      try {
+        spawnSync('python3', [path.join(__dirname, 'scripts', 'refresh_designs_snapshot.py')],
+                  { timeout: 15000 });
+        loadDesigns();
+      } catch {}
+      res.json({ ok:true, design_id: newId, filename });
+    } catch (e) {
+      res.status(500).json({ ok:false, error: e.message });
+    }
+  }
+);
+
 // ── HEALTH
 app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });

← 6881a12 wallco.ai: motif filter chips on /designs — 12 top motifs (p  ·  back to Wallco Ai  ·  wallco_rooms persistence + gallery + admin remove/restore/ma e8f73bf →