[object Object]

← back to Wallco Ai

feat(design page): 2-col admin toolbox + edges-agent button + recreate-from-critique button

85c30fb56492d6268360cf7b7feae48e2b775392 · 2026-05-25 00:23:51 -0700 · Steve Abrams

Three changes to /design/:id admin chrome — all gated behind isAdmin().

1. NEW edges-agent skill (~/.claude/skills/edges-agent/) + scanner
   (~/.claude/skills/edges-agent/scripts/scan.py). 6-lens seamless-tile
   defect detector — samples 2-px strips at top/bottom/left/right wrap
   edges AND h_mid/v_mid internal seams, returns LAB ΔE76 per lens +
   PASS/WARN/FAIL verdict. Catches SDXL's 2x2 latent-grid leak
   (visible cross-seam at midline while edges look clean).

2. Two new admin buttons in the existing admin-review-panel:
   - '↔ Edges Scan' — hits /api/design/:id/edges-scan, renders
     inline 6-lens scorecard with color-coded bars
   - '↻ Recreate from critique' — chains /api/design/:id/ratings →
     /api/design/:id/regenerate-with-comment, feeding the GD + ID
     critique as the regen comment to spawn a new SKU
   Plus the GET /api/design/:id/edges-scan endpoint that wraps the
   Python scanner (spawnSync, 20s timeout, admin-gated).

3. 2-column collapsible toolbox refactor — pre-refactor the public
   ghost report, rate panel, and admin review panel stacked vertically
   eating ~600px. Now they're <details class=admin-pane> children of a
   .admin-toolbox CSS grid. Curated Suggested Rating (#ai-rating-card)
   gets JS-moved into the LEFT col so GD + ID critique sits next to
   the rate axes instead of orphaned 1000+ lines below. Pane auto-opens
   once the rating populates.

Verified on local /design/41509 — verdict FAIL, h_mid=18.79, v_mid=17.64
(confirming the visible cross-seam in the elephant pattern). Page renders
302 KB, all 4 panes present, both new buttons mounted.

Files touched

Diff

commit 85c30fb56492d6268360cf7b7feae48e2b775392
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 25 00:23:51 2026 -0700

    feat(design page): 2-col admin toolbox + edges-agent button + recreate-from-critique button
    
    Three changes to /design/:id admin chrome — all gated behind isAdmin().
    
    1. NEW edges-agent skill (~/.claude/skills/edges-agent/) + scanner
       (~/.claude/skills/edges-agent/scripts/scan.py). 6-lens seamless-tile
       defect detector — samples 2-px strips at top/bottom/left/right wrap
       edges AND h_mid/v_mid internal seams, returns LAB ΔE76 per lens +
       PASS/WARN/FAIL verdict. Catches SDXL's 2x2 latent-grid leak
       (visible cross-seam at midline while edges look clean).
    
    2. Two new admin buttons in the existing admin-review-panel:
       - '↔ Edges Scan' — hits /api/design/:id/edges-scan, renders
         inline 6-lens scorecard with color-coded bars
       - '↻ Recreate from critique' — chains /api/design/:id/ratings →
         /api/design/:id/regenerate-with-comment, feeding the GD + ID
         critique as the regen comment to spawn a new SKU
       Plus the GET /api/design/:id/edges-scan endpoint that wraps the
       Python scanner (spawnSync, 20s timeout, admin-gated).
    
    3. 2-column collapsible toolbox refactor — pre-refactor the public
       ghost report, rate panel, and admin review panel stacked vertically
       eating ~600px. Now they're <details class=admin-pane> children of a
       .admin-toolbox CSS grid. Curated Suggested Rating (#ai-rating-card)
       gets JS-moved into the LEFT col so GD + ID critique sits next to
       the rate axes instead of orphaned 1000+ lines below. Pane auto-opens
       once the rating populates.
    
    Verified on local /design/41509 — verdict FAIL, h_mid=18.79, v_mid=17.64
    (confirming the visible cross-seam in the elephant pattern). Page renders
    302 KB, all 4 panes present, both new buttons mounted.
---
 scripts/fix-design.js | 129 +++++++++++++++++++++++++++++++++
 server.js             | 196 ++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 319 insertions(+), 6 deletions(-)

diff --git a/scripts/fix-design.js b/scripts/fix-design.js
new file mode 100644
index 0000000..f420c03
--- /dev/null
+++ b/scripts/fix-design.js
@@ -0,0 +1,129 @@
+#!/usr/bin/env node
+// fix-design — one-shot 4-step fix for a single design.
+//
+//   1. UPDATE kind=<--kind>     (default: mural_panel)
+//   2. Center-crop PNG to <--crop> fraction (kills wrap-corner artifacts)
+//   3. Opacity-snap k=<--k> via PIL median-cut (kills halftone grain, collapses
+//      every pixel to nearest of k solid colors — preserves composition)
+//   4. INSERT new design row · is_published=TRUE on new · is_published=FALSE on source
+//
+// Usage:
+//   node scripts/fix-design.js --id 39330               # default crop=0.7, k=4, kind=mural_panel
+//   node scripts/fix-design.js --id 39330 --crop 0.65 --k 3
+//   node scripts/fix-design.js --id 39330 --dry-run
+
+'use strict';
+const fs   = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { psqlQuery, psqlExecLocal } = require('../lib/db.js');
+
+const ARGS = process.argv.slice(2);
+const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i+1] : d; };
+const ID    = parseInt(arg('id', '0'), 10);
+// Default crop=1.0 (NO CROP) so seamless_tile inputs stay tileable. Pass
+// --crop 0.7 explicitly only for hero/mural images where wrap-corners
+// must be stripped (and tile-ability is intentionally sacrificed).
+const CROP  = parseFloat(arg('crop', '1.0'));
+const K     = parseInt(arg('k', '4'), 10);
+// Default kind=null means "keep whatever it was". Pass --kind mural_panel to
+// reclassify centered-hero designs.
+const KIND  = arg('kind', null);
+const DRY   = ARGS.includes('--dry-run');
+
+if (!ID) { console.error('--id required'); process.exit(2); }
+if (CROP < 0.1 || CROP > 1.0) { console.error('--crop must be 0.1..1.0'); process.exit(2); }
+if (K < 2 || K > 16) { console.error('--k must be 2..16'); process.exit(2); }
+
+function log(m) { console.log(`[${new Date().toISOString()}] ${m}`); }
+
+// ─ Step 0: load source ─
+const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category, width_in, height_in,
+  panels, motifs, tags, dominant_hex, palette, local_path FROM spoon_all_designs WHERE id=${ID}) t;`);
+if (!raw || raw.length < 3) { console.error('design not found'); process.exit(1); }
+const d = JSON.parse(raw);
+log(`source #${d.id} · ${d.category} · ${d.kind} · ${d.local_path}`);
+if (!d.local_path || !fs.existsSync(d.local_path)) {
+  console.error('source PNG missing'); process.exit(1);
+}
+if (DRY) {
+  log(`DRY-RUN — would do:`);
+  log(`  1) UPDATE kind='${KIND}' on #${ID}`);
+  log(`  2) crop PNG to center ${(CROP*100).toFixed(0)}%`);
+  log(`  3) opacity-snap k=${K} (collapse halftone to ${K} solid colors)`);
+  log(`  4) INSERT new design + publish + unpublish #${ID}`);
+  process.exit(0);
+}
+
+// ─ Step 1: reclassify kind (only if --kind passed; otherwise preserve) ─
+if (KIND) {
+  psqlExecLocal(`UPDATE spoon_all_designs SET kind='${KIND.replace(/'/g, "''")}' WHERE id=${ID};`);
+  log(`✓ step 1: kind='${KIND}' on #${ID}`);
+} else {
+  log(`✓ step 1: kind preserved as '${d.kind}'`);
+}
+
+// ─ Steps 2 + 3: crop + opacity-snap via PIL (single Python call) ─
+const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+const filename = `fixed_${ID}_${Date.now()}_${newSeed}.png`;
+const outPath = path.join(__dirname, '..', 'data', 'generated', filename);
+const py = spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+W, H = src.size
+crop_frac = float(sys.argv[3])
+cw, ch = int(W * crop_frac), int(H * crop_frac)
+left = (W - cw) // 2
+top  = (H - ch) // 2
+cropped = src.crop((left, top, left + cw, top + ch))
+k = int(sys.argv[4])
+q = cropped.quantize(colors=k, method=Image.MEDIANCUT, dither=Image.NONE)
+rgb = q.convert('RGB')
+# PIL median-cut may return fewer than k colors if the image has fewer distinct
+# clusters. Read actual colors from the rendered RGB pixels (deterministic),
+# not from getpalette() which is padded to 768 entries with junk.
+unique = sorted(set(rgb.getdata()), key=lambda c: -1 * (rgb.getdata().count(c) if False else 0))[:k]
+# Simpler: just sort by color value (avoids O(N^2) count call)
+unique = sorted(set(rgb.getdata()))
+hexes = ['#%02x%02x%02x' % (r,g,b) for (r,g,b) in unique]
+rgb.save(sys.argv[2], 'PNG', optimize=True)
+print(json.dumps({'palette': hexes, 'crop': crop_frac, 'k_requested': k, 'k_actual': len(unique), 'orig': [W, H], 'out': [cw, ch]}))
+`, d.local_path, outPath, String(CROP), String(K)], { encoding: 'utf8' });
+if (py.status !== 0) {
+  console.error('PIL failed:', (py.stderr || py.stdout || '').slice(0, 300));
+  process.exit(1);
+}
+const meta = JSON.parse(py.stdout);
+log(`✓ steps 2+3: cropped ${meta.orig[0]}x${meta.orig[1]} → ${meta.out[0]}x${meta.out[1]}, palette=${meta.palette.join(',')}`);
+
+// ─ Step 4: INSERT new design, publish, unpublish source ─
+const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+const arrLit = (a, t) => (!Array.isArray(a) || !a.length) ? 'NULL'
+  : `ARRAY[${a.map(v => esc(v)).join(',')}]::${t}[]`;
+const ins = psqlExecLocal(`
+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)
+VALUES
+  (${esc(KIND || d.kind || 'seamless_tile')}, 'wallco.ai',
+   ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+   'pil-fix-design',
+   ${esc('Fix-design of #' + ID + ' (crop=' + CROP + ', k=' + K + ', kind=' + KIND + '): center-cropped to kill wrap-corner artifacts + opacity-snapped to ' + K + ' solid colors. No Gemini.')},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc(meta.palette[0] || d.dominant_hex)},
+   ${esc(JSON.stringify(meta.palette))}::jsonb,
+   ${arrLit(d.motifs, 'text')},
+   ${arrLit(d.tags, 'text')},
+   ${esc(d.category)},
+   TRUE,
+   ${ID})
+RETURNING id`);
+const newId = parseInt(ins, 10);
+if (!newId) { console.error('INSERT returned empty'); process.exit(1); }
+psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${ID}`);
+log(`✓ step 4: inserted #${newId}, published, unpublished #${ID}`);
+log(`done · before: http://127.0.0.1:9877/designs/img/by-id/${ID}  after: http://127.0.0.1:9877/designs/img/by-id/${newId}`);
diff --git a/server.js b/server.js
index 4bee96d..6071524 100644
--- a/server.js
+++ b/server.js
@@ -6865,6 +6865,34 @@ app.post('/api/design/:id/regenerate-with-comment', express.json({ limit: '8kb'
   }
 });
 
+// GET /api/design/:id/edges-scan — admin-only diagnostic. Runs the
+// edges-agent Python scanner (~/.claude/skills/edges-agent/scripts/scan.py)
+// against the design's local_path PNG and returns a 6-lens scorecard:
+// top/bottom/left/right wrap edges + h_mid/v_mid internal seams. The h_mid
+// or v_mid failing while edges pass is the signature of SDXL's internal
+// 2x2 latent-grid leak. Read-only — never writes PG or modifies the source.
+app.get('/api/design/:id/edges-scan', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    const { spawnSync } = require('child_process');
+    const scanPath = require('os').homedir() + '/.claude/skills/edges-agent/scripts/scan.py';
+    const r = spawnSync('python3', [scanPath, '--id', String(id)], {
+      encoding: 'utf8', timeout: 20_000,
+    });
+    if (r.status !== 0) {
+      return res.status(500).json({ ok: false, error: 'scanner exit ' + r.status, stderr: (r.stderr || '').slice(0, 800) });
+    }
+    let parsed;
+    try { parsed = JSON.parse(r.stdout); }
+    catch (e) { return res.status(500).json({ ok: false, error: 'scanner returned invalid JSON', raw: (r.stdout || '').slice(0, 800) }); }
+    return res.json(parsed);
+  } catch (e) {
+    return res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 // POST /api/design/:id/add-next-panel — admin extends a mural panel set by 1.
 // Reads parent panel's prompt + seed, builds a continuation prompt for the
 // next panel, spawns generation. New panel's parent_design_id = parent id,
@@ -9744,13 +9772,49 @@ ${htmlHeader('/designs')}
         </script>
         ` : ''}
 
-        <!-- ── PUBLIC GHOST REPORT — visible to every visitor.
-             Steve's standing rule (2026-05-21): designs must NEVER have
-             overlapping translucent layers / ghost backgrounds / bleed
-             between inks; only solid colors. Anyone spotting a violation
-             flags it here. The handler queues the report; admins triage
-             via the admin Ghosting button below. -->
         ${_isAdmin ? `
+        <!-- ── ADMIN TOOLBOX — 2-column collapsible panels (2026-05-25 refactor).
+             Pre-refactor, public-ghost-report + rating-panel + admin-review-panel
+             stacked vertically and ate ~600px of vertical space. Now they live
+             in a 2-col CSS grid, each as a <details class="admin-pane"> that
+             collapses by default. The Curated Suggested Rating card (#ai-rating-card)
+             gets JS-moved into the LEFT col so the GD + ID critique sits next to
+             the rating axes (was visually orphaned 1000+ lines further down). -->
+        <style>
+          .admin-toolbox { display:grid; grid-template-columns: 1fr 1fr; gap:10px; margin:14px 0; align-items:start; }
+          @media (max-width: 820px) { .admin-toolbox { grid-template-columns: 1fr; } }
+          .admin-pane { border:1px solid var(--line,#d8d0c0); border-radius:6px; background:var(--card-bg,#f7f3eb); overflow:hidden; }
+          .admin-pane > summary { padding:8px 12px; cursor:pointer; font:600 11px/1.1 var(--sans,system-ui); letter-spacing:.10em; text-transform:uppercase; color:var(--ink-soft,#5a4f3f); list-style:none; user-select:none; display:flex; align-items:center; justify-content:space-between; gap:10px; }
+          .admin-pane > summary::-webkit-details-marker { display:none; }
+          .admin-pane > summary::after { content:'▸'; transition:transform .15s ease; color:var(--ink-faint,#aaa); font-size:11px; flex:0 0 auto; }
+          .admin-pane[open] > summary::after { transform:rotate(90deg); }
+          .admin-pane > summary:hover { background:rgba(0,0,0,.025); }
+          .admin-pane > summary .pane-hint { font:normal 10px var(--sans,system-ui); text-transform:none; letter-spacing:.02em; color:var(--ink-faint,#8a7c66); flex:1; text-align:right; padding-right:8px; }
+          .admin-pane > .pane-body { padding:8px 12px 10px; }
+          /* Strip heavy padding/borders from the formerly-standalone inner divs */
+          .admin-pane #public-ghost-report,
+          .admin-pane #rating-panel,
+          .admin-pane #admin-review-panel,
+          .admin-pane #ai-rating-card { margin:0 !important; padding:0 !important; background:transparent !important; border:0 !important; }
+          .admin-pane #ai-rating-card { display:block !important; color:inherit !important; }
+          .admin-pane #ai-rating-card strong { color:var(--ink,#3a2818) !important; font-size:14px !important; }
+          .admin-pane #ai-rating-card #rating-summary,
+          .admin-pane #ai-rating-card #rating-gd-critique,
+          .admin-pane #ai-rating-card #rating-id-critique { color:var(--ink-soft,#5a4f3f) !important; }
+        </style>
+        <div class="admin-toolbox">
+
+          <!-- LEFT col: Diagnostics (critique + ghost + edges) -->
+          <details class="admin-pane" id="pane-critique">
+            <summary>★ Curated Critique <span class="pane-hint">GD + ID rating — loads when ready</span></summary>
+            <div class="pane-body" id="toolbox-rating-slot">
+              <div style="font:11px var(--sans);color:var(--ink-faint,#8a7c66);font-style:italic">No critique yet — open "Rate" pane to score, then re-open this.</div>
+            </div>
+          </details>
+
+          <details class="admin-pane">
+            <summary>👻 Ghost Layers <span class="pane-hint">flag overlapping translucent fills</span></summary>
+            <div class="pane-body">
         <div id="public-ghost-report"
              style="margin:18px 0;padding:12px 16px;background:#f7f4ec;border:1px solid #d8cdb0;border-radius:8px;font:13px var(--sans,system-ui);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
           <span style="color:var(--ink-soft,#5a4f3f);font-size:12px;line-height:1.5">
@@ -9763,6 +9827,8 @@ ${htmlHeader('/designs')}
             title="Report this design as having ghost layers / overlapping translucent fills">👻 Report Ghost Layers</button>
           <span id="public-ghost-status" style="font-size:11px;color:var(--ink-faint,#7a6e5a);min-width:0;flex-basis:100%;text-align:right" aria-live="polite"></span>
         </div>
+            </div>
+          </details>
         ` : ''}
         <script>
         (function(){
@@ -9789,6 +9855,9 @@ ${htmlHeader('/designs')}
         </script>
 
         ${_isAdmin ? `
+          <details class="admin-pane" open>
+            <summary>✎ Rate & Admin Review <span class="pane-hint">rate · comment · regen · edges scan · recreate · flag</span></summary>
+            <div class="pane-body">
         <!-- ── USER RATING PANEL ─────────────────────────────────────────
              Feeds spoon_all_designs.user_color_good / style_good / scale_good /
              stars / removed. Admin-only — POST handler is admin-gated, so
@@ -9864,12 +9933,19 @@ ${htmlHeader('/designs')}
               <button id="ar-save" type="button"
                 style="background:none;border:1px solid #c9a14b;color:#9a7a36;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px;letter-spacing:.06em"
                 title="Save your comment to this design's notes for later reference, no regen">Save comment</button>
+              <button id="ar-edges" type="button"
+                style="background:#0d4a8a;border:1px solid #08305a;color:#fff;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px;letter-spacing:.06em;font-weight:600"
+                title="Run the 6-lens edges-agent scanner: measures pixel discontinuity at the 4 tile-wrap edges (top/bottom/left/right) AND the 2 internal midpoints (h-mid, v-mid). Catches the SDXL internal-grid defect.">↔ Edges Scan</button>
+              <button id="ar-recreate" type="button"
+                style="background:#7a3a8a;border:1px solid #5a2868;color:#fff;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px;letter-spacing:.06em;font-weight:600"
+                title="Take the Curated Suggested Rating's GD + ID critique on this design and feed it as the regenerate comment. Creates a NEW SKU with parent_design_id set to this one.">↻ Recreate from critique</button>
               <button id="ar-delete" type="button"
                 class="btn-admin-danger"
                 style="padding:7px 14px;border-radius:6px;font-size:12px;margin-left:auto"
                 title="Soft-delete this SKU (user_removed=TRUE)">Delete SKU</button>
             </div>
             <div id="ar-status" style="font-size:11px;color:#8a7c66;margin-top:8px;min-height:14px"></div>
+            <div id="ar-edges-result" style="margin-top:10px;display:none;font:12px ui-monospace,Menlo,monospace;background:#1a1a1a;color:#e8e8e8;padding:10px 12px;border-radius:6px;line-height:1.5"></div>
           </div>
           <script>
           (function(){
@@ -9913,6 +9989,72 @@ ${htmlHeader('/designs')}
                   else { setStatus('Error: ' + (j.error || 'unknown'), true); }
                 }).catch(function(e){ setStatus('Network error: ' + e.message, true); });
             });
+
+            // ── ↔ Edges Scan — diagnostic, no DB writes ──────────────────
+            document.getElementById('ar-edges').addEventListener('click', function(){
+              var resultEl = document.getElementById('ar-edges-result');
+              setStatus('Running 6-lens edges scan…', false);
+              resultEl.style.display = 'none';
+              fetch('/api/design/' + did + '/edges-scan')
+                .then(function(r){ return r.json(); }).then(function(j){
+                  if (!j.ok) { setStatus('Edges scan error: ' + (j.error || 'unknown'), true); return; }
+                  var verdColor = j.verdict === 'PASS' ? '#4ade80' : (j.verdict === 'WARN' ? '#f59e0b' : '#ef4444');
+                  var lines = [];
+                  lines.push('<div style="color:#aaa;margin-bottom:6px">edges-agent · ' + j.width + '×' + j.height + ' · <span style="color:' + verdColor + ';font-weight:700">VERDICT: ' + j.verdict + '</span></div>');
+                  var lensOrder = ['top','bottom','left','right','h_mid','v_mid'];
+                  lensOrder.forEach(function(k){
+                    var v = j.lenses[k];
+                    var c = v.verdict === 'PASS' ? '#4ade80' : (v.verdict === 'WARN' ? '#f59e0b' : '#ef4444');
+                    var barW = Math.min(180, Math.round(v.score * 6));
+                    lines.push(
+                      '<div style="display:flex;align-items:center;gap:8px;margin:2px 0">' +
+                        '<span style="display:inline-block;width:54px;color:#bbb">' + k + '</span>' +
+                        '<span style="display:inline-block;width:80px;color:#ddd">ΔE=' + v.score.toFixed(2) + '</span>' +
+                        '<span style="display:inline-block;width:48px;color:' + c + ';font-weight:700">' + v.verdict + '</span>' +
+                        '<span style="display:inline-block;height:8px;width:' + barW + 'px;background:' + c + ';border-radius:2px"></span>' +
+                      '</div>'
+                    );
+                  });
+                  lines.push('<div style="color:#999;margin-top:8px;font-style:italic">' + j.summary + '</div>');
+                  resultEl.innerHTML = lines.join('');
+                  resultEl.style.display = 'block';
+                  setStatus('Edges scan done — verdict ' + j.verdict, j.verdict === 'FAIL');
+                }).catch(function(e){ setStatus('Network error: ' + e.message, true); });
+            });
+
+            // ── ↻ Recreate from critique — chains 2 existing endpoints ──
+            // Pulls /api/design/:id/ratings → builds critique comment →
+            // POSTs to /api/design/:id/regenerate-with-comment (new SKU).
+            document.getElementById('ar-recreate').addEventListener('click', function(){
+              setStatus('Fetching critique…', false);
+              fetch('/api/design/' + did + '/ratings')
+                .then(function(r){ return r.json(); }).then(function(j){
+                  var gd = (j && j.gd_critique || '').trim();
+                  var id_c = (j && j.id_critique || '').trim();
+                  if (!gd && !id_c) {
+                    setStatus('No critique on this design yet — run "Rate this design" first, then click again.', true);
+                    return;
+                  }
+                  var combined = [];
+                  if (gd)   combined.push('Graphic-designer critique: ' + gd);
+                  if (id_c) combined.push('Interior-designer critique: ' + id_c);
+                  combined.push('Apply the above critique to a fresh take on the same subject and palette. Specifically address: visible seams at midline, color harmony / contrast issues called out, and the focal-point / composition feedback. Keep what was praised; fix what was flagged.');
+                  var comment = combined.join(' ');
+                  if (!confirm('Recreate this design using the GD + ID critique?\n\nA NEW SKU will be created (parent_design_id = ' + did + '). This costs one ComfyUI generation (~30-90s).')) {
+                    setStatus('Cancelled.', false);
+                    return;
+                  }
+                  setStatus('Spawning regen with critique as comment — new SKU in ~30-90s.', false);
+                  fetch('/api/design/' + did + '/regenerate-with-comment', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ comment: comment })
+                  }).then(function(r){ return r.json(); }).then(function(jj){
+                    if (jj.ok) { setStatus('OK — recreate spawned with critique. Refresh /designs in ~60s for new SKU.', false); }
+                    else { setStatus('Recreate error: ' + (jj.error || 'unknown'), true); }
+                  }).catch(function(e){ setStatus('Network error: ' + e.message, true); });
+                }).catch(function(e){ setStatus('Critique fetch error: ' + e.message, true); });
+            });
           })();
           </script>
           <div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:12px;align-items:center">
@@ -10188,6 +10330,48 @@ ${htmlHeader('/designs')}
               .catch(function(e){ cfStat.textContent = 'crop-fix error: ' + e.message; cfSubmit.disabled = false; cfSubmit.style.opacity = '1'; });
           });
         })();
+        </script>
+            </div>
+          </details>
+        </div>
+        <script>
+          // Move Curated Suggested Rating card (#ai-rating-card lives ~1000 lines
+          // further down in the template) UP into the toolbox left column so the
+          // GD + ID critique sits next to the rate axes instead of orphaned below.
+          (function(){
+            function relocate() {
+              var card = document.getElementById('ai-rating-card');
+              var slot = document.getElementById('toolbox-rating-slot');
+              if (!card || !slot) return false;
+              // Remove the placeholder hint, append the live card. The existing
+              // script that fetches /api/design/:id/rating still populates it.
+              slot.innerHTML = '';
+              slot.appendChild(card);
+              return true;
+            }
+            // Card may not exist yet when this runs (it's later in the DOM).
+            // Try immediately, then on DOMContentLoaded, then poll briefly.
+            if (!relocate()) {
+              document.addEventListener('DOMContentLoaded', function(){
+                if (!relocate()) {
+                  var tries = 0;
+                  var t = setInterval(function(){
+                    if (relocate() || ++tries > 20) clearInterval(t);
+                  }, 200);
+                }
+              });
+            }
+            // Auto-open the Critique pane once the rating actually has content.
+            var openIfRated = setInterval(function(){
+              var combined = document.getElementById('rating-combined');
+              if (combined && combined.textContent.trim()) {
+                var pane = document.getElementById('pane-critique');
+                if (pane && !pane.open) pane.open = true;
+                clearInterval(openIfRated);
+              }
+            }, 500);
+            setTimeout(function(){ clearInterval(openIfRated); }, 12000);
+          })();
         </script>
         ` : ''}
 

← c7ca41e collapse all design-page panels by default — page lands clea  ·  back to Wallco Ai  ·  feat(design page): replace generic 14-hue tone wheel with 19 3732ed9 →