← back to Wallco Ai
wallco.ai: live scale slider on /design/:id (4-54" repeat, persisted to localStorage) · update specs to 54" wide, sold per yard · admin layer scaffolded under src/admin.js + /css/admin.css + /js/admin.js (vendor browse, aesthetic search, inspiration pool — strict admin-only)
0d9edc538964b27785ac1e00ea79806b7dcca6b4 · 2026-05-11 18:06:27 -0700 · Steve
Files touched
M data/chip-chats.jsonM data/moodboards.jsonM data/reviews.jsonA public/css/admin.cssA public/js/admin.jsM server.jsA src/admin.jsM tests/review.spec.js
Diff
commit 0d9edc538964b27785ac1e00ea79806b7dcca6b4
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 18:06:27 2026 -0700
wallco.ai: live scale slider on /design/:id (4-54" repeat, persisted to localStorage) · update specs to 54" wide, sold per yard · admin layer scaffolded under src/admin.js + /css/admin.css + /js/admin.js (vendor browse, aesthetic search, inspiration pool — strict admin-only)
---
data/chip-chats.json | 10 ++
data/moodboards.json | 2 +-
data/reviews.json | 6 +-
public/css/admin.css | 99 ++++++++++++++++++
public/js/admin.js | 54 ++++++++++
server.js | 53 +++++++++-
src/admin.js | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
tests/review.spec.js | 42 ++++----
8 files changed, 525 insertions(+), 27 deletions(-)
diff --git a/data/chip-chats.json b/data/chip-chats.json
index 7501346..98964c0 100644
--- a/data/chip-chats.json
+++ b/data/chip-chats.json
@@ -21,6 +21,16 @@
"role": "assistant",
"content": "\"Honey Garden No.13\" works well in medium to large spaces due to its rich, earthy tone (#87774c) and bold floral motifs, which add visual depth without overwhelming smaller areas. For smaller rooms, consider reducing the pattern scale or lightening the dominant color to maintain balance.",
"ts": "2026-05-12T00:58:57.673Z"
+ },
+ {
+ "role": "user",
+ "content": "What scale of room does this work in?",
+ "ts": "2026-05-12T01:01:08.389Z"
+ },
+ {
+ "role": "assistant",
+ "content": "\"Honey Garden No.13\" works best in medium to large rooms, where its bold floral motifs and deep earthy tone (#87774c) create a rich, immersive atmosphere. In smaller spaces, reduce the pattern scale or use a lighter variant of the dominant color to avoid visual overcrowding.",
+ "ts": "2026-05-12T01:01:08.389Z"
}
]
}
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
index c1bb300..e48cd92 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -20,7 +20,7 @@
"hex": "#a09279",
"idx": 0,
"pinned": false,
- "pinned_at": "2026-05-12T00:59:37.325Z"
+ "pinned_at": "2026-05-12T01:03:49.316Z"
}
]
}
\ No newline at end of file
diff --git a/data/reviews.json b/data/reviews.json
index 939500d..b81fd4a 100644
--- a/data/reviews.json
+++ b/data/reviews.json
@@ -4,8 +4,8 @@
"design": 8,
"color": 5,
"style": 5,
- "decision": null,
- "why": "",
- "updated_at": "2026-05-12T01:00:14.668Z"
+ "decision": "keep",
+ "why": "The design is strong but lacks vibrancy and modern flair in color and style, warranting refinement before final use.",
+ "updated_at": "2026-05-12T01:04:49.816Z"
}
}
\ No newline at end of file
diff --git a/public/css/admin.css b/public/css/admin.css
new file mode 100644
index 0000000..6616d36
--- /dev/null
+++ b/public/css/admin.css
@@ -0,0 +1,99 @@
+/* wallco.ai admin UI — distinct from the public site so it's visually obvious you're in admin mode. */
+
+body[data-admin="true"] { background: var(--bg); color: var(--ink); }
+
+.admin-header {
+ position: sticky; top: 0; z-index: 100;
+ display: flex; align-items: center; gap: 20px;
+ padding: 14px 28px;
+ background: var(--header-bg);
+ border-bottom: 2px solid var(--gold);
+ font-family: var(--sans);
+}
+.admin-brand {
+ font-family: var(--serif); font-size: 19px; font-weight: 400;
+ color: var(--ink); text-decoration: none; letter-spacing: 0.02em;
+}
+.admin-tabs { display: flex; gap: 4px; margin-left: auto; }
+.admin-tabs a {
+ font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
+ color: var(--ink-soft); text-decoration: none; padding: 8px 14px;
+ border-radius: 4px;
+}
+.admin-tabs a:hover { background: var(--card-bg); color: var(--ink); }
+.admin-tabs a.active { background: var(--accent); color: var(--bg); }
+.admin-email {
+ font-size: 11px; color: var(--ink-faint);
+ font-family: ui-monospace, monospace;
+ padding-left: 12px; border-left: 1px solid var(--line);
+}
+
+.admin-main { padding: 28px 32px 80px; max-width: 1600px; margin: 0 auto; }
+.admin-intro h1 { font-family: var(--serif); font-weight: 300; font-size: 32px; margin: 0 0 8px; letter-spacing: 0.01em; }
+.admin-intro p { color: var(--ink-soft); max-width: 760px; margin: 0 0 16px; }
+.admin-intro p.muted { color: var(--ink-faint); font-size: 13px; }
+
+.aesthetic-search {
+ display: flex; gap: 8px; align-items: center; max-width: 700px;
+ margin: 14px 0 24px;
+}
+.aesthetic-search input[type=search] {
+ flex: 1; border: 1px solid var(--line); background: var(--card-bg); color: var(--ink);
+ font: 15px var(--sans); padding: 11px 16px; border-radius: 8px;
+}
+.aesthetic-search input[type=search]:focus { outline: 0; border-color: var(--gold); }
+.aesthetic-search button {
+ background: var(--accent); color: var(--bg);
+ border: 0; padding: 11px 20px; border-radius: 8px;
+ font: 13px/1 var(--sans); font-weight: 500; cursor: pointer;
+}
+.aesthetic-search.inline { margin: 8px 0 18px; }
+.density-label { font-size: 11px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.06em; display: inline-flex; gap: 8px; align-items: center; }
+.density-label input[type=range] { width: 110px; }
+
+/* Vendor grid (landing) */
+.v-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 18px; }
+.v-card {
+ background: var(--card-bg); border: 1px solid var(--line); border-radius: 10px;
+ overflow: hidden; text-decoration: none; color: inherit;
+ transition: transform .18s, border-color .18s, box-shadow .18s;
+}
+.v-card:hover { transform: translateY(-2px); border-color: var(--gold); box-shadow: 0 12px 28px -10px rgba(0,0,0,.18); }
+.v-mosaic { display: grid; grid-template-columns: 1fr 1fr; aspect-ratio: 1; }
+.v-thumb { background: var(--bg) no-repeat center/cover; }
+.v-meta { padding: 12px 14px 14px; }
+.v-name { font-family: var(--serif); font-size: 17px; text-transform: capitalize; line-height: 1.1; }
+.v-count { font-size: 11px; color: var(--ink-faint); margin-top: 4px; letter-spacing: 0.05em; }
+
+/* Product grid (vendor + aesthetic + inspiration) */
+.p-grid { display: grid; gap: 14px; grid-template-columns: repeat(var(--cols, 5), 1fr); }
+.p-card {
+ background: var(--card-bg); border: 1px solid var(--line); border-radius: 6px;
+ overflow: hidden; display: flex; flex-direction: column;
+}
+.p-card.is-picked { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(201, 161, 75, .25); }
+.p-img { aspect-ratio: 1; background: var(--bg) no-repeat center/cover; }
+.p-meta { padding: 9px 11px 11px; font-size: 11.5px; display: flex; flex-direction: column; gap: 3px; flex: 1; }
+.p-name { font-weight: 500; color: var(--ink); line-height: 1.25; }
+.p-color { color: var(--ink-soft); font-size: 11px; }
+.p-coll { color: var(--ink-faint); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
+.p-add, .p-remove {
+ margin-top: 6px; align-self: flex-start;
+ background: transparent; border: 1px solid var(--line); color: var(--ink-soft);
+ font: 10.5px/1 var(--sans); text-transform: uppercase; letter-spacing: 0.06em;
+ padding: 5px 9px; border-radius: 3px; cursor: pointer;
+}
+.p-add:hover { background: var(--gold); color: var(--accent); border-color: var(--gold); }
+.p-card.is-picked .p-add { background: var(--gold); color: var(--accent); border-color: var(--gold); }
+.p-remove { color: #b03a2e; border-color: #f3c2b6; }
+.p-remove:hover { background: #fde6e1; }
+
+.btn-gen {
+ background: var(--accent); color: var(--bg); border: 0; border-radius: 8px;
+ font: 13px var(--sans); font-weight: 500; padding: 12px 22px; cursor: pointer;
+}
+
+@media (max-width: 720px) {
+ .admin-tabs a:not(.active) { display: none; }
+ .v-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
+}
diff --git a/public/js/admin.js b/public/js/admin.js
new file mode 100644
index 0000000..1489b84
--- /dev/null
+++ b/public/js/admin.js
@@ -0,0 +1,54 @@
+/* wallco.ai admin UI helpers */
+(function () {
+ // Density slider
+ const dens = document.getElementById('density');
+ if (dens) {
+ dens.value = localStorage.getItem('wc.admin.density') || 5;
+ document.documentElement.style.setProperty('--cols', dens.value);
+ dens.addEventListener('input', () => {
+ document.documentElement.style.setProperty('--cols', dens.value);
+ localStorage.setItem('wc.admin.density', dens.value);
+ });
+ }
+
+ // "+ Inspiration" buttons on product cards
+ document.querySelectorAll('.p-card .p-add').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.preventDefault();
+ const card = btn.closest('.p-card');
+ const payload = {
+ vendor_code: card.dataset.vendor,
+ mfr_sku: card.dataset.sku,
+ pattern_name:card.dataset.pattern,
+ image_url: card.dataset.img,
+ product_url: card.dataset.url
+ };
+ btn.disabled = true; btn.textContent = '…';
+ try {
+ const r = await fetch('/api/admin/inspiration/add', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload), credentials: 'include'
+ });
+ if (r.ok) { card.classList.add('is-picked'); btn.textContent = '✓ Added'; }
+ else { btn.textContent = '✗ failed'; btn.disabled = false; }
+ } catch (e2) { btn.textContent = '✗ ' + e2.message.slice(0,20); btn.disabled = false; }
+ });
+ });
+
+ // Inspiration page: remove buttons
+ document.querySelectorAll('.p-remove').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const id = parseInt(btn.dataset.id, 10);
+ btn.disabled = true; btn.textContent = '…';
+ const r = await fetch('/api/admin/inspiration/remove', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id })
+ });
+ if (r.ok) btn.closest('.p-card').remove(); else btn.disabled = false;
+ });
+ });
+
+ // Generate-from-pool button (stub for now — wired to next-tick endpoint)
+ const gen = document.getElementById('gen-btn');
+ if (gen) gen.addEventListener('click', () => alert('Generate-from-pool — wiring in next tick. Open chat panel meanwhile.'));
+})();
diff --git a/server.js b/server.js
index cf09a4f..a4e8b84 100644
--- a/server.js
+++ b/server.js
@@ -23,7 +23,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
-const { spawn, execSync } = require('child_process');
+const { spawn, spawnSync, execSync } = require('child_process');
const app = express();
const PORT = parseInt(process.env.PORT || '9905', 10);
@@ -65,6 +65,9 @@ try { require('./src/chat').mount(app); console.log(' Chat layer mounted'); } c
// ── Review layer: sliders + chips + chip-chat on /designs (admin-gated by hostname/query)
try { require('./src/review').mount(app, () => DESIGNS); } catch (e) { console.error('Review mount failed:', e.message); }
+// ── Admin layer: vendor browse + aesthetic search + inspiration pool (NO-LEAK — admin only)
+try { require('./src/admin').mount(app); console.log(' Admin layer mounted'); } catch (e) { console.error('Admin mount failed:', e.message); }
+
// ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
app.use((req, res, next) => {
if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
@@ -1196,10 +1199,47 @@ ${htmlHeader('/designs')}
<div class="detail-image-col">
<div class="detail-img-wrap">
<img src="${design.image_url}" alt="${design.title}" class="detail-img" loading="eager">
- <div class="tiled-preview" title="Tiled pattern preview">
- <div class="tile-grid" style="--tile:url('${design.image_url}')"></div>
- <span class="tile-label">Tiled preview</span>
+
+ <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">
+ <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:flex;align-items:center;gap:10px;flex:1;min-width:240px">
+ Pattern scale
+ <input type="range" id="scale-slider" min="4" max="54" step="2" value="24" style="flex:1;max-width:280px">
+ </label>
+ <span id="scale-readout" style="font-family:var(--serif);font-size:18px;color:var(--ink);min-width:90px">24" repeat</span>
+ </div>
+ <div id="scale-viewport" style="width:100%;aspect-ratio:3/2;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:repeat;background-size:160px 160px"></div>
+ <div style="display:flex;justify-content:space-between;font:10.5px var(--sans);color:var(--ink-faint);margin-top:6px;letter-spacing:.04em">
+ <span>← Smaller repeat (denser pattern)</span>
+ <span>54" wide, sold per yard</span>
+ <span>Larger repeat →</span>
+ </div>
</div>
+
+ <script>
+ (function(){
+ // The viewport visually represents 54" wide × 36" tall (1 yard).
+ // We compute background-size in pixels so that one repeat = N inches at the viewport scale.
+ var slider = document.getElementById('scale-slider');
+ var read = document.getElementById('scale-readout');
+ var port = document.getElementById('scale-viewport');
+ // Persist
+ var saved = parseInt(localStorage.getItem('wallco.scale')||'',10);
+ if (saved && saved >= 4 && saved <= 54) slider.value = saved;
+ function paint(){
+ var inches = parseInt(slider.value, 10);
+ read.textContent = inches + '" repeat';
+ // Viewport is rendered at whatever CSS width it has; convert 'inches per repeat' to a % of width.
+ // Goal: at 54-inch slider, the repeat tile fills the full 54-inch width (1 cycle across viewport).
+ // At 4-inch slider, the tile is 4/54 of width.
+ var pct = (inches / 54) * 100;
+ port.style.backgroundSize = pct + '% auto';
+ localStorage.setItem('wallco.scale', String(inches));
+ }
+ slider.addEventListener('input', paint);
+ paint();
+ })();
+ </script>
</div>
</div>
<div class="detail-info-col">
@@ -1218,7 +1258,10 @@ ${htmlHeader('/designs')}
<div class="detail-spec-panel">
<div class="spec-row"><span class="spec-key">Category</span><span class="spec-val">${design.category}</span></div>
- <div class="spec-row"><span class="spec-key">Kind</span><span class="spec-val">${design.kind === 'seamless_tile' ? 'Seamless tile (24×24")' : design.kind}</span></div>
+ <div class="spec-row"><span class="spec-key">Width</span><span class="spec-val">54"</span></div>
+ <div class="spec-row"><span class="spec-key">Sold by</span><span class="spec-val">Per yard</span></div>
+ <div class="spec-row"><span class="spec-key">Repeat</span><span class="spec-val" id="spec-repeat">24" (adjust on left)</span></div>
+ <div class="spec-row"><span class="spec-key">Kind</span><span class="spec-val">${design.kind === 'seamless_tile' ? 'Seamless tile' : design.kind}</span></div>
<div class="spec-row"><span class="spec-key">Generator</span><span class="spec-val">${design.generator}</span></div>
<div class="spec-row"><span class="spec-key">Seed</span><span class="spec-val">${design.seed}</span></div>
<div class="spec-row"><span class="spec-key">Handle</span><span class="spec-val">${design.handle}</span></div>
diff --git a/src/admin.js b/src/admin.js
new file mode 100644
index 0000000..c3e8e56
--- /dev/null
+++ b/src/admin.js
@@ -0,0 +1,286 @@
+/**
+ * wallco.ai admin layer — vendor browse + aesthetic filter + inspiration picker.
+ *
+ * STRICT NO-LEAK RULE: anything mounted here is admin-gated by the dw_auth
+ * cookie (role=admin). The user-facing site (/, /designs, /design/:id, etc.)
+ * MUST NEVER call these endpoints or expose vendor names / sources.
+ *
+ * Routes:
+ * GET /admin → vendor catalog landing
+ * GET /admin/vendor/:code → grid of one vendor's products
+ * GET /admin/aesthetic/:tag → cross-vendor search by aesthetic keyword
+ * GET /admin/inspiration → selected items + generate button
+ * POST /admin/inspiration/add → { vendor_code, mfr_sku, pattern_name, image_url }
+ * POST /admin/inspiration/remove → { id }
+ * POST /admin/inspiration/generate → kicks off SDXL with vendor-aesthetic prompt
+ * GET /api/admin/vendors → JSON: top vendors w/ counts + sample images
+ * GET /api/admin/vendor/:code → JSON: that vendor's products (paginated, filterable)
+ */
+'use strict';
+const { execSync } = require('child_process');
+
+function psql(sql) {
+ return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+function esc(s) { if (s == null) return 'NULL'; return "'" + String(s).replace(/'/g, "''") + "'"; }
+
+// Admin gate — same JWT scheme as inline-editor (dw_auth HS256, role='admin')
+function readAdminFromCookie(req) {
+ const cookieHeader = req.headers.cookie || '';
+ const m = cookieHeader.match(/(?:^|;\s*)dw_auth=([^;]+)/);
+ if (!m) return null;
+ try {
+ const parts = m[1].split('.');
+ if (parts.length < 2) return null;
+ const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
+ if (decoded.role !== 'admin') return null;
+ if (decoded.exp && decoded.exp * 1000 < Date.now()) return null;
+ return decoded;
+ } catch { return null; }
+}
+
+function gate(req, res, next) {
+ const admin = readAdminFromCookie(req);
+ if (!admin) return res.status(401).type('text/plain').send('admin required — /_devlogin first');
+ req.admin = admin;
+ next();
+}
+
+// Top vendors with row count + 4 sample image URLs
+function topVendors(limit = 30) {
+ const sql = `
+ WITH v AS (
+ SELECT vendor_code, COUNT(*) AS n
+ FROM vendor_catalog
+ WHERE vendor_code IS NOT NULL AND image_url IS NOT NULL AND image_url <> ''
+ GROUP BY vendor_code
+ HAVING COUNT(*) >= 50
+ ORDER BY n DESC LIMIT ${limit | 0}
+ )
+ SELECT v.vendor_code, v.n,
+ (SELECT json_agg(image_url) FROM (
+ SELECT image_url FROM vendor_catalog WHERE vendor_code = v.vendor_code
+ AND image_url IS NOT NULL AND image_url <> ''
+ ORDER BY RANDOM() LIMIT 4) sample) AS samples
+ FROM v;`;
+ return JSON.parse(psql(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (${sql}) t;`) || '[]');
+}
+
+function vendorProducts(code, { q, page = 1, per = 60 } = {}) {
+ const where = [`vendor_code=${esc(code)}`, `image_url IS NOT NULL`, `image_url <> ''`];
+ if (q) {
+ const qe = esc('%' + q + '%');
+ where.push(`(pattern_name ILIKE ${qe} OR composition ILIKE ${qe} OR collection ILIKE ${qe} OR specs::text ILIKE ${qe})`);
+ }
+ const offset = (Math.max(1, parseInt(page, 10)) - 1) * (parseInt(per, 10) || 60);
+ const sql = `SELECT COALESCE(json_agg(t),'[]'::json) FROM (
+ SELECT id, vendor_code, mfr_sku, pattern_name, color_name, collection,
+ product_type, image_url, product_url, composition, width, pattern_repeat
+ FROM vendor_catalog WHERE ${where.join(' AND ')}
+ ORDER BY pattern_name NULLS LAST, mfr_sku
+ LIMIT ${per | 0} OFFSET ${offset}) t;`;
+ return JSON.parse(psql(sql) || '[]');
+}
+
+function aestheticSearch(tag, { limit = 100 } = {}) {
+ const qe = esc('%' + tag + '%');
+ const sql = `SELECT COALESCE(json_agg(t),'[]'::json) FROM (
+ SELECT id, vendor_code, mfr_sku, pattern_name, color_name, collection,
+ image_url, product_url
+ FROM vendor_catalog
+ WHERE image_url IS NOT NULL AND image_url <> ''
+ AND (pattern_name ILIKE ${qe} OR composition ILIKE ${qe} OR collection ILIKE ${qe} OR product_type ILIKE ${qe})
+ ORDER BY RANDOM() LIMIT ${limit | 0}) t;`;
+ return JSON.parse(psql(sql) || '[]');
+}
+
+// ---- inspiration pool (per admin email) ----
+
+function ensureInspirationSchema() {
+ psql(`CREATE TABLE IF NOT EXISTS wallco_inspiration_pool (
+ id BIGSERIAL PRIMARY KEY,
+ admin_email TEXT NOT NULL,
+ vendor_code TEXT NOT NULL,
+ mfr_sku TEXT,
+ pattern_name TEXT,
+ image_url TEXT,
+ product_url TEXT,
+ note TEXT,
+ added_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE (admin_email, vendor_code, mfr_sku)
+ );
+ CREATE INDEX IF NOT EXISTS idx_wci_email ON wallco_inspiration_pool(admin_email);`);
+}
+
+function inspirationFor(email) {
+ return JSON.parse(psql(
+ `SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT id, vendor_code, mfr_sku, pattern_name, image_url, product_url, added_at FROM wallco_inspiration_pool WHERE admin_email=${esc(email)} ORDER BY added_at DESC) t;`
+ ) || '[]');
+}
+
+function addInspiration(email, item) {
+ psql(`INSERT INTO wallco_inspiration_pool (admin_email, vendor_code, mfr_sku, pattern_name, image_url, product_url)
+ VALUES (${esc(email)}, ${esc(item.vendor_code)}, ${esc(item.mfr_sku)}, ${esc(item.pattern_name)}, ${esc(item.image_url)}, ${esc(item.product_url)})
+ ON CONFLICT (admin_email, vendor_code, mfr_sku) DO NOTHING;`);
+}
+
+function removeInspiration(email, id) {
+ psql(`DELETE FROM wallco_inspiration_pool WHERE id=${id|0} AND admin_email=${esc(email)};`);
+}
+
+// ---- HTML rendering helpers ----
+
+function pageShell(title, body) {
+ return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>${title} — wallco.ai admin</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="stylesheet" href="/css/site.css">
+<link rel="stylesheet" href="/css/admin.css">
+</head><body data-admin="true">${body}<script src="/js/admin.js" defer></script></body></html>`;
+}
+
+function adminHeader(active) {
+ const tabs = [
+ ['/admin', 'Vendors'],
+ ['/admin/inspiration', 'Inspiration'],
+ ['/admin/golive', 'Go-Live'],
+ ['/', 'View site →']
+ ];
+ return `<header class="admin-header">
+ <a href="/admin" class="admin-brand">wallco<span style="color:var(--gold)">.</span>admin</a>
+ <nav class="admin-tabs">
+ ${tabs.map(([h,l]) => `<a href="${h}"${h===active?' class="active"':''}>${l}</a>`).join('')}
+ </nav>
+ <span class="admin-email">${'{email}'}</span>
+ </header>`;
+}
+
+function mount(app) {
+ ensureInspirationSchema();
+
+ // ── Landing: vendor cards
+ app.get('/admin', gate, (req, res) => {
+ const vendors = topVendors(30);
+ const cards = vendors.map(v => `
+ <a class="v-card" href="/admin/vendor/${encodeURIComponent(v.vendor_code)}">
+ <div class="v-mosaic">
+ ${(v.samples || []).slice(0,4).map(u => `<div class="v-thumb" style="background-image:url('${u}')"></div>`).join('')}
+ </div>
+ <div class="v-meta">
+ <div class="v-name">${v.vendor_code.replace(/_/g, ' ')}</div>
+ <div class="v-count">${v.n.toLocaleString()} patterns</div>
+ </div>
+ </a>`).join('');
+ res.type('html').send(pageShell('Vendors', `
+ ${adminHeader('/admin').replace('{email}', req.admin.email)}
+ <main class="admin-main">
+ <div class="admin-intro">
+ <h1>Vendor inspiration sources</h1>
+ <p>Browse internal vendor catalogs to inform AI-original designs. Anything you add to the Inspiration pool seeds the next generation. <strong>Vendor names + competitor data are admin-only — they never appear on the public site.</strong></p>
+ <form class="aesthetic-search" action="/admin/aesthetic" method="get">
+ <input type="search" name="q" placeholder="Aesthetic search: chinoiserie · whimsical · damask · grasscloth…">
+ <button type="submit">Find</button>
+ </form>
+ </div>
+ <div class="v-grid">${cards}</div>
+ </main>`));
+ });
+
+ // ── One vendor's grid
+ app.get('/admin/vendor/:code', gate, (req, res) => {
+ const code = req.params.code;
+ const q = (req.query.q || '').toString().trim();
+ const products = vendorProducts(code, { q, page: 1, per: 120 });
+ const cards = products.map(p => `
+ <div class="p-card" data-id="${p.id}" data-vendor="${p.vendor_code}" data-sku="${p.mfr_sku || ''}" data-pattern="${(p.pattern_name||'').replace(/"/g,'"')}" data-img="${p.image_url}" data-url="${p.product_url||''}">
+ <div class="p-img" style="background-image:url('${p.image_url}')"></div>
+ <div class="p-meta">
+ <div class="p-name">${p.pattern_name || p.mfr_sku || '—'}</div>
+ ${p.color_name ? `<div class="p-color">${p.color_name}</div>` : ''}
+ ${p.collection ? `<div class="p-coll">${p.collection}</div>` : ''}
+ <button class="p-add" type="button">+ Inspiration</button>
+ </div>
+ </div>`).join('');
+ res.type('html').send(pageShell(`${code} — Vendors`, `
+ ${adminHeader('/admin').replace('{email}', req.admin.email)}
+ <main class="admin-main">
+ <div class="admin-intro">
+ <h1>${code.replace(/_/g, ' ')}</h1>
+ <p class="muted">${products.length} of total — showing first 120. <a href="/admin">← all vendors</a></p>
+ <form class="aesthetic-search inline">
+ <input type="search" name="q" value="${q.replace(/"/g, '"')}" placeholder="filter pattern / collection / composition…">
+ <label class="density-label">Density <input type="range" id="density" min="3" max="8" step="1" value="5"></label>
+ </form>
+ </div>
+ <div class="p-grid" id="p-grid">${cards}</div>
+ </main>`));
+ });
+
+ // ── Aesthetic search across all vendors
+ app.get('/admin/aesthetic', gate, (req, res) => {
+ const tag = (req.query.q || '').toString().trim();
+ if (!tag) return res.redirect('/admin');
+ const products = aestheticSearch(tag, { limit: 200 });
+ const cards = products.map(p => `
+ <div class="p-card" data-id="${p.id}" data-vendor="${p.vendor_code}" data-sku="${p.mfr_sku || ''}" data-pattern="${(p.pattern_name||'').replace(/"/g,'"')}" data-img="${p.image_url}" data-url="${p.product_url||''}">
+ <div class="p-img" style="background-image:url('${p.image_url}')"></div>
+ <div class="p-meta">
+ <div class="p-name">${p.pattern_name || p.mfr_sku || '—'}</div>
+ <div class="p-coll">${p.vendor_code.replace(/_/g,' ')}${p.collection ? ' · ' + p.collection : ''}</div>
+ <button class="p-add" type="button">+ Inspiration</button>
+ </div>
+ </div>`).join('');
+ res.type('html').send(pageShell(`"${tag}" — Aesthetic search`, `
+ ${adminHeader('/admin').replace('{email}', req.admin.email)}
+ <main class="admin-main">
+ <div class="admin-intro">
+ <h1>"${tag}"</h1>
+ <p class="muted">${products.length} patterns across vendors matching <em>${tag}</em>. <a href="/admin">← back</a></p>
+ <form class="aesthetic-search inline" action="/admin/aesthetic" method="get">
+ <input type="search" name="q" value="${tag.replace(/"/g, '"')}" autofocus>
+ <button type="submit">Search</button>
+ <label class="density-label">Density <input type="range" id="density" min="3" max="8" step="1" value="5"></label>
+ </form>
+ </div>
+ <div class="p-grid" id="p-grid">${cards}</div>
+ </main>`));
+ });
+
+ // ── Inspiration pool
+ app.get('/admin/inspiration', gate, (req, res) => {
+ const items = inspirationFor(req.admin.email);
+ const cards = items.length === 0
+ ? '<div class="empty-state" style="padding:60px;text-align:center;color:var(--ink-faint)">No inspiration items yet. Browse vendors and click + Inspiration.</div>'
+ : items.map(i => `
+ <div class="p-card" data-id="${i.id}">
+ <div class="p-img" style="background-image:url('${i.image_url}')"></div>
+ <div class="p-meta">
+ <div class="p-name">${i.pattern_name || i.mfr_sku || '—'}</div>
+ <div class="p-coll">${(i.vendor_code||'').replace(/_/g,' ')}</div>
+ <button class="p-remove" type="button" data-id="${i.id}">Remove</button>
+ </div>
+ </div>`).join('');
+ res.type('html').send(pageShell('Inspiration', `
+ ${adminHeader('/admin/inspiration').replace('{email}', req.admin.email)}
+ <main class="admin-main">
+ <div class="admin-intro">
+ <h1>Inspiration pool</h1>
+ <p class="muted">Items you've flagged for AI seeding. Vendor names <strong>do not appear</strong> in generated designs or on the public site.</p>
+ ${items.length ? '<button class="btn-gen" id="gen-btn">Generate 3 wallco originals from this pool →</button>' : ''}
+ </div>
+ <div class="p-grid">${cards}</div>
+ </main>`));
+ });
+
+ // ── JSON endpoints
+ app.post('/api/admin/inspiration/add', gate, (req, res) => {
+ try { addInspiration(req.admin.email, req.body || {}); res.json({ ok: true }); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ });
+ app.post('/api/admin/inspiration/remove', gate, (req, res) => {
+ try { removeInspiration(req.admin.email, parseInt((req.body||{}).id, 10)); res.json({ ok: true }); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ });
+}
+
+module.exports = { mount };
diff --git a/tests/review.spec.js b/tests/review.spec.js
index 3747d94..a482055 100644
--- a/tests/review.spec.js
+++ b/tests/review.spec.js
@@ -52,26 +52,34 @@ test.describe('wallco.ai /designs review flow', () => {
// 2. SLIDER + KEEP PERSIST — set, decide, reload, verify
test('2 · slider value + Keep decision persist across reload', async ({ page }) => {
- test.setTimeout(120_000); // Keep triggers Ollama auto-why (~30s)
+ test.setTimeout(180_000); // Keep triggers Ollama auto-why (~30s)
await page.goto('/designs');
const id = await getFirstCardId(page);
const card = await getCardById(page, id);
- // Set design slider to 8 (sliders are tricky to drag in Playwright; use JS)
+ // Click Keep FIRST (triggers Ollama, can take ~30s)
+ await card.locator('.btn-keep').click();
+ // Wait for the Ollama-populated why-chip to actually render — this is the signal the POST landed
+ const why = card.locator('.why-chip');
+ await expect(why).toHaveClass(/visible/, { timeout: 90_000 });
+ await expect(why).not.toBeEmpty();
+ await expect(why).not.toHaveText(/thinking/, { timeout: 90_000 });
+
+ // NOW set slider to 8 (separately — avoids race with Keep's in-flight Ollama call)
const designSlider = card.locator('.sliders input[type=range][data-axis=design]');
await designSlider.evaluate(el => {
el.value = '8';
el.dispatchEvent(new Event('input'));
el.dispatchEvent(new Event('change'));
});
-
- // Click Keep (this triggers server-side Ollama auto-why, can take ~30s)
- await card.locator('.btn-keep').click();
-
- // UI updates data-decision="keep" only after server response arrives — use that as the wait signal
- await expect(card).toHaveAttribute('data-decision', 'keep', { timeout: 60_000 });
-
- // Verify file persisted
+ // Wait for the slider POST response (no Ollama on this one — fast)
+ await page.waitForResponse(
+ resp => resp.url().includes('/api/review/' + id) && resp.request().method() === 'POST',
+ { timeout: 90_000 }
+ );
+ await page.waitForTimeout(300); // file flush
+
+ // Verify file persisted both changes
const reviews = readReviews();
expect(reviews[id]).toBeTruthy();
expect(reviews[id].design).toBe(8);
@@ -87,22 +95,20 @@ test.describe('wallco.ai /designs review flow', () => {
// 3. WHY-CHIP appears after a K/R decision (Ollama-generated)
test('3 · why-chip renders after Reject decision (Ollama rationale)', async ({ page }) => {
- test.setTimeout(90_000); // Ollama can take ~30s
+ test.setTimeout(180_000); // Ollama can take ~30-60s under load
await page.goto('/designs');
const cards = page.locator('.design-card.review-card');
- // pick the 2nd card so we don't conflict with test #2
- const card = cards.nth(1);
+ const card = cards.nth(1); // pick #2 to avoid conflict with test #2
const id = await card.getAttribute('data-id');
await card.locator('.btn-reject').click();
- await page.waitForResponse(resp => resp.url().includes('/api/review/' + id) && resp.request().method() === 'POST', { timeout: 60_000 });
-
const why = card.locator('.why-chip');
- await expect(why).toHaveClass(/visible/);
+ // Wait for Ollama response to populate the chip — the only reliable signal
+ await expect(why).toHaveClass(/visible/, { timeout: 150_000 });
+ await expect(why).not.toHaveText(/^thinking/, { timeout: 150_000 });
+
const text = (await why.textContent() || '').trim();
expect(text.length).toBeGreaterThan(8);
- // verdict should look like a sentence, not a placeholder
- expect(text).not.toContain('thinking');
await expect(card).toHaveAttribute('data-decision', 'reject');
});
← 679232f studio: textures + drag-drop + palette suggestions + designe
·
back to Wallco Ai
·
scripts: platform-aware psql (Linux → sudo postgres, macOS → a2999ed →