[object Object]

← back to Wallco Ai

feat(colorways): promote saved colorway to a real spoon_all_designs row

b6425eb3496edf8f83d19163319637dd89edf922 · 2026-05-29 14:19:57 -0700 · Steve Abrams

When the user clicks 💾 Save colorway, the client now sends the baked
PNG (rendered from the recolor ImageData buffer) alongside the ink_map.
Server's POST /api/colorways/save accepts an optional data_url; if a
valid PNG arrives it writes the bytes to data/generated/ and INSERTs a
new spoon_all_designs row (parent_design_id=src, generator='colorway-
promote', is_published=FALSE) so the colorway becomes a true product.

The new id is stored on wallco_user_colorways.new_design_id (column
added via idempotent ALTER) and returned in the save response. Client
auto-navigates to /design/<new_id> after a brief toast. Chip links
prefer the promoted clean URL over the ?cw= overlay path; older
colorways without a promote still fall back to ?cw= correctly.

Files touched

Diff

commit b6425eb3496edf8f83d19163319637dd89edf922
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 14:19:57 2026 -0700

    feat(colorways): promote saved colorway to a real spoon_all_designs row
    
    When the user clicks 💾 Save colorway, the client now sends the baked
    PNG (rendered from the recolor ImageData buffer) alongside the ink_map.
    Server's POST /api/colorways/save accepts an optional data_url; if a
    valid PNG arrives it writes the bytes to data/generated/ and INSERTs a
    new spoon_all_designs row (parent_design_id=src, generator='colorway-
    promote', is_published=FALSE) so the colorway becomes a true product.
    
    The new id is stored on wallco_user_colorways.new_design_id (column
    added via idempotent ALTER) and returned in the save response. Client
    auto-navigates to /design/<new_id> after a brief toast. Chip links
    prefer the promoted clean URL over the ?cw= overlay path; older
    colorways without a promote still fall back to ?cw= correctly.
---
 public/js/color-dots.js |  44 +++++++++++++++++--
 src/colorways.js        | 109 +++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 143 insertions(+), 10 deletions(-)

diff --git a/public/js/color-dots.js b/public/js/color-dots.js
index 57c68e8..ec14bf6 100644
--- a/public/js/color-dots.js
+++ b/public/js/color-dots.js
@@ -207,8 +207,14 @@
       // Plain click stays in-page (instant preview + URL replace, no reload).
       // Modifier/middle clicks fall through to browser-native open-in-tab.
       var b = document.createElement('a');
-      b.href = '/design/' + did + '?cw=' + cw.id;
-      b.title = (cw.name || ('Version ' + cw.version)) + ' · ' + new Date(cw.created_at).toLocaleString() + ' · open as product';
+      // PREFERRED — promoted colorway has its own spoon_all_designs row
+      // (server baked the PNG at save time). Clean product URL.
+      // FALLBACK — older colorways with no promote: ?cw= overlay on src.
+      b.href = cw.new_design_id
+        ? ('/design/' + cw.new_design_id)
+        : ('/design/' + did + '?cw=' + cw.id);
+      b.title = (cw.name || ('Version ' + cw.version)) + ' · ' + new Date(cw.created_at).toLocaleString() +
+        (cw.new_design_id ? ' · open product #' + cw.new_design_id : ' · open as product');
       var stops = (cw.ink_map || []).map(function (m) { return m.to; });
       // Match the original button's exact visual: 32×22 chip with the v-label
       // floated as a block element inside (which is how the button rendered it
@@ -252,18 +258,48 @@
         })
         .catch(function () {});
     }
+    // Capture the FULL recolored tile as a PNG data URL for the promote step.
+    // recolor() always writes the result to img.src as a data URL, but that
+    // image may have been downscaled to MAXW for live preview. Re-render here
+    // at the same dimensions used for preview — that's what the user actually
+    // recolored and saw. Returns null if state is missing.
+    function bakeCurrentPng() {
+      var st = state.get(img); if (!st) return null;
+      var c = document.createElement('canvas'); c.width = st.w; c.height = st.h;
+      var out = new ImageData(new Uint8ClampedArray(st.base.data), st.w, st.h);
+      var bd = st.base.data, od = out.data, idx = st.idx, inks = st.inks, cur = st.cur;
+      for (var p = 0, q = 0; p < bd.length; p += 4, q++) {
+        var k = idx[q];
+        od[p]     = bd[p]     + (cur[k][0] - inks[k][0]);
+        od[p + 1] = bd[p + 1] + (cur[k][1] - inks[k][1]);
+        od[p + 2] = bd[p + 2] + (cur[k][2] - inks[k][2]);
+      }
+      c.getContext('2d').putImageData(out, 0, 0);
+      try { return c.toDataURL('image/png'); } catch (e) { return null; }
+    }
     save.addEventListener('click', function () {
       var st = state.get(img); if (!st) return;
       var changed = currentInkMap(img).filter(function (m) { return m.from !== m.to; });
       if (!changed.length) { toast(host, 'Recolor a dot first'); return; }
       save.disabled = true; save.textContent = 'Saving…';
+      var dataUrl = bakeCurrentPng();   // omit on failure; server will skip promote
+      var body = { design_id: did, ink_map: currentInkMap(img) };
+      if (dataUrl) body.data_url = dataUrl;
       fetch('/api/colorways/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
-        body: JSON.stringify({ design_id: did, ink_map: currentInkMap(img) }) })
+        body: JSON.stringify(body) })
         .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })
         .then(function (o) {
           save.disabled = false; save.textContent = '💾 Save colorway';
           if (o.s === 401) { toast(host, 'Sign in to save your colorway'); return; }
-          if (o.j && o.j.ok) { toast(host, 'Saved as v' + o.j.version); load(); }
+          if (o.j && o.j.ok) {
+            if (o.j.new_design_id) {
+              toast(host, 'Saved as v' + o.j.version + ' → opening as new product…');
+              setTimeout(function () { location.href = '/design/' + o.j.new_design_id; }, 700);
+            } else {
+              toast(host, 'Saved as v' + o.j.version);
+              load();
+            }
+          }
           else { toast(host, (o.j && o.j.error) || 'Save failed'); }
         })
         .catch(function () { save.disabled = false; save.textContent = '💾 Save colorway'; toast(host, 'Save failed'); });
diff --git a/src/colorways.js b/src/colorways.js
index 690bdfd..2f27789 100644
--- a/src/colorways.js
+++ b/src/colorways.js
@@ -5,16 +5,26 @@
  * version; the PDP lists them and can jump to any version. Mounted as a module
  * so the 863KB drifted server.js stays untouched beyond one mount line.
  *
- *   POST /api/colorways/save  { design_id, ink_map:[{from,to}...], name? }  (auth)
+ *   POST /api/colorways/save  { design_id, ink_map, name?, data_url? }      (auth)
  *   GET  /api/colorways?design_id=N                                          (auth)
  *   DELETE /api/colorways/:id                                                (auth)
  *
  * ink_map is the per-ink remap [{from:'#rrggbb', to:'#rrggbb'}]. Stored JSONB.
- * Rows are private to user_id (exclusive). mount(app, { getTradeUser, psql, pgEsc }).
+ * Rows are private to user_id (exclusive).
+ *
+ * NEW (2026-05-29): if save includes data_url (base64 PNG of the recolored
+ * tile from the browser canvas), the endpoint promotes the colorway to a real
+ * spoon_all_designs row (parent_design_id=src, generator='colorway-promote',
+ * is_published=FALSE). The new id is stored on new_design_id and returned, so
+ * the chip's link can navigate to /design/<new_id> like a regular product.
+ *
+ * mount(app, { getTradeUser, psql, pgEsc }).
  */
 'use strict';
+const path = require('path');
+const fs = require('fs');
 const { psqlQuery: psql, pgEsc } = require('../lib/db');
-const expressJson = require('express').json({ limit: '64kb' });
+const expressJson = require('express').json({ limit: '32mb' });   // raised — data_url PNGs
 
 function mount(app, deps) {
   const { getTradeUser } = deps || {};
@@ -33,6 +43,12 @@ function mount(app, deps) {
     );`);
     psql(`CREATE INDEX IF NOT EXISTS wallco_user_colorways_ud_idx
              ON wallco_user_colorways(user_id, design_id, created_at ASC);`);
+    // 2026-05-29: column carries the spoon_all_designs id of the promoted
+    // colorway (NULL until promote-on-save). The chip URL prefers this when
+    // present so the colorway behaves like a real product page (Steve "click
+    // into it as a new product"). Idempotent — ADD COLUMN IF NOT EXISTS.
+    psql(`ALTER TABLE wallco_user_colorways
+             ADD COLUMN IF NOT EXISTS new_design_id INTEGER;`);
     console.log('  Colorways table ready (wallco_user_colorways)');
   } catch (e) { console.error('[colorways] migration failed:', e.message); }
 
@@ -51,20 +67,98 @@ function mount(app, deps) {
       .map(m => ({ from: m.from.toLowerCase(), to: m.to.toLowerCase() }));
     if (!inkMap.length) return res.status(400).json({ ok: false, error: 'ink_map had no valid hex pairs.' });
     const name = (typeof b.name === 'string' ? b.name : '').slice(0, 80) || null;
+    // Optional baked-PNG of the recolored tile → promote to real product row.
+    // We accept anything that decodes to a sane-sized PNG; oversize is rejected
+    // at express.json's 32mb limit. NULL on bad/missing → ink_map-only save.
+    let promoteBuf = null;
+    const dataUrl = typeof b.data_url === 'string' ? b.data_url : '';
+    if (dataUrl.startsWith('data:image/png;base64,')) {
+      try {
+        const buf = Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64');
+        if (buf.length >= 1024 && buf.slice(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) {
+          promoteBuf = buf;
+        }
+      } catch (e) { /* ignore — fall through to ink_map-only save */ }
+    }
+
     try {
       const jsonStr = JSON.stringify(inkMap);
       const id = psql(`INSERT INTO wallco_user_colorways (user_id, design_id, name, ink_map)
         VALUES (${parseInt(u.id, 10)}, ${designId}, ${name ? pgEsc(name) : 'NULL'}, ${pgEsc(jsonStr)}::jsonb)
         RETURNING id;`).trim();
+      const colorwayId = parseInt(id, 10);
       const version = parseInt(psql(`SELECT count(*) FROM wallco_user_colorways
         WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId};`).trim(), 10) || 1;
-      res.json({ ok: true, id: parseInt(id, 10), version, design_id: designId });
+
+      // Optional promote step — only if a usable PNG arrived. Failures here do
+      // NOT fail the save (ink_map-only save still succeeded above).
+      let newDesignId = null;
+      if (promoteBuf) {
+        try {
+          newDesignId = promoteToSpoonRow({ srcId: designId, userId: u.id, colorwayId, version, buf: promoteBuf, palette: inkMap.map(m => m.to) });
+          if (newDesignId) {
+            psql(`UPDATE wallco_user_colorways SET new_design_id=${newDesignId} WHERE id=${colorwayId};`);
+          }
+        } catch (e) { console.error('[colorways/promote]', e.message); }
+      }
+
+      res.json({ ok: true, id: colorwayId, version, design_id: designId, new_design_id: newDesignId });
     } catch (e) {
       console.error('[colorways/save] failed:', e.message);
       res.status(500).json({ ok: false, error: 'Save failed.' });
     }
   });
 
+  // Promote helper: bake recolored PNG to disk, INSERT spoon_all_designs row
+  // mirroring the source design's identity. is_published=FALSE so it doesn't
+  // hit the catalog grid; viewable directly via /design/<new_id> (per the
+  // existing PDP fallback for unpublished). Returns new id, or null on failure.
+  function promoteToSpoonRow({ srcId, userId, colorwayId, version, buf, palette }) {
+    const srcRaw = psql(`SELECT row_to_json(t) FROM (SELECT id, kind, brand, width_in, height_in,
+      panels, category, dominant_hex, motifs, tags, product_line, generator
+      FROM spoon_all_designs WHERE id=${parseInt(srcId, 10)}) t;`);
+    if (!srcRaw) return null;
+    const src = JSON.parse(srcRaw);
+    const ts = Date.now();
+    const seed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `colorway_${srcId}_cw${colorwayId}_${ts}.png`;
+    const outPath = path.join(__dirname, '..', 'data', 'generated', filename);
+    try { fs.mkdirSync(path.dirname(outPath), { recursive: true }); } catch (e) {}
+    fs.writeFileSync(outPath, buf);
+
+    const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const arrLit = (a) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::text[]`;
+    // Tags carry provenance — 'colorway' marks the lineage; 'curator-loved' the user verdict.
+    const newTags = (Array.isArray(src.tags) ? src.tags.slice() : []).concat(['colorway', `colorway-of-${srcId}`]);
+    const newPalette = JSON.stringify((palette || []).map(hex => ({ hex })));
+    const newId = psql(`
+INSERT INTO spoon_all_designs
+  (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+   image_url, local_path, dominant_hex, palette, motifs, tags, category,
+   is_published, parent_design_id, product_line)
+VALUES
+  (${esc(src.kind || 'seamless_tile')}, ${esc(src.brand || 'wallco.ai')},
+   ${src.width_in || 'NULL'}, ${src.height_in || 'NULL'}, ${src.panels || 'NULL'},
+   'colorway-promote',
+   ${esc(`Colorway promote of #${srcId} (user ${userId}, v${version})`)},
+   ${seed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc((palette && palette[0]) || src.dominant_hex || '#888888')},
+   ${pgEsc(newPalette)}::jsonb,
+   ${arrLit(src.motifs)},
+   ${arrLit(newTags)},
+   ${esc(src.category || 'mixed')},
+   FALSE,
+   ${parseInt(srcId, 10)},
+   ${esc(src.product_line || null)})
+RETURNING id;`).trim();
+    const idNum = parseInt(newId, 10);
+    if (!idNum) return null;
+    psql(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${idNum}' WHERE id=${idNum};`);
+    return idNum;
+  }
+
   // GET /api/colorways?design_id=N — current user's exclusive colorways for a design.
   app.get('/api/colorways', (req, res) => {
     const u = getTradeUser(req);
@@ -73,11 +167,14 @@ function mount(app, deps) {
     if (!Number.isFinite(designId) || designId <= 0) return res.status(400).json({ ok: false, error: 'design_id required.' });
     try {
       const raw = psql(`SELECT COALESCE(json_agg(t ORDER BY t.created_at ASC), '[]'::json) FROM (
-        SELECT id, name, ink_map, created_at FROM wallco_user_colorways
+        SELECT id, name, ink_map, created_at, new_design_id FROM wallco_user_colorways
         WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId}) t;`);
       let rows = [];
       try { rows = JSON.parse(raw || '[]'); } catch (e) { rows = []; }
-      const colorways = rows.map((r, i) => ({ id: r.id, name: r.name, ink_map: r.ink_map, created_at: r.created_at, version: i + 1 }));
+      const colorways = rows.map((r, i) => ({
+        id: r.id, name: r.name, ink_map: r.ink_map, created_at: r.created_at,
+        new_design_id: r.new_design_id, version: i + 1
+      }));
       res.json({ ok: true, authenticated: true, colorways });
     } catch (e) {
       console.error('[colorways/list] failed:', e.message);

← 838401d Cactus curator: click a card's color dot to recolor via nati  ·  back to Wallco Ai  ·  feat(colorways): admin user fallback for save/list/delete 18b423b →