[object Object]

← back to Wallco Ai

tif-gate C+D (DTD verdict 2026-06-02): correct the docs + add needs-TIF backlog view

093259a707e34bcfedec1620fabfe9d2eee13144 · 2026-06-02 09:05:54 -0700 · Steve Abrams

D — CLAUDE.md: retire the false 'publish gate refuses NULL tif_path' rule. Publish
= catalog-visible; TIF is a lazily-built print master gated only at physical-print
fulfillment (digital download uses on-demand upscale, not the TIF).

C — read-only admin /admin/needs-tif + /api/admin/needs-tif (admin-gated, NO schema
change per the verdict — plain query over all_designs, since prod is compromised).
Lists the 3,451 published-no-TIF designs by recency with created date+time chips,
38 category-group filter chips, sort + density slider (standing rules). Validated
headless: 2000 cards, date chips, sort+density, 0 errors.

Files touched

Diff

commit 093259a707e34bcfedec1620fabfe9d2eee13144
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 2 09:05:54 2026 -0700

    tif-gate C+D (DTD verdict 2026-06-02): correct the docs + add needs-TIF backlog view
    
    D — CLAUDE.md: retire the false 'publish gate refuses NULL tif_path' rule. Publish
    = catalog-visible; TIF is a lazily-built print master gated only at physical-print
    fulfillment (digital download uses on-demand upscale, not the TIF).
    
    C — read-only admin /admin/needs-tif + /api/admin/needs-tif (admin-gated, NO schema
    change per the verdict — plain query over all_designs, since prod is compromised).
    Lists the 3,451 published-no-TIF designs by recency with created date+time chips,
    38 category-group filter chips, sort + density slider (standing rules). Validated
    headless: 2000 cards, date chips, sort+density, 0 errors.
---
 CLAUDE.md                   |   2 +-
 public/admin/needs-tif.html | 147 ++++++++++++++++++++++++++++++++++++++++++++
 server.js                   |  43 +++++++++++++
 3 files changed, 191 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 77eb990..fcdc7b1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -53,7 +53,7 @@ Critical excludes (already in `.deploy.conf`) — these files ARE genuinely PROD
 - **Solid screen-print colors only.** No gradients, no transparency, no ghost layers unless the prompt explicitly asks for them. Anti-prompt enforcement in `scripts/generate_designs.js` plus a Gemini vision pre-publish gate.
 - **Always paint on a REAL natural wallcovering texture.** When generating a pattern, the ground MUST be anchored to an actual DW natural-material SKU — **grasscloth, cork, raffia, mica, real linen, silk, sisal, abaca, seagrass, jute, hemp, arrowroot, paperweave, bamboo, reed** — never a flat/hallucinated "ivory paper" ground. The motif is painted *on top of the real textile* so the weave/slubs/flecks show through. Mechanism: `scripts/gen-luxe.js` `pickTextureBrief()` pulls a random real SKU from `shopify_products` (matched by material regex) and injects `Ground: <family> <material> (matched DW SKU <name>, dominant #hex)` into the prompt; the snapshot namer then reads that ground hex for the colorway (`refresh_designs_snapshot.py` `ground_hex()`). DW has real SKUs for all of these (mica 292, cork 384, raffia 381, grasscloth 1978, linen 1857, silk 1478, +2862 other-natural). If you add a new generator, copy this real-texture-anchor pattern — a flat paper ground is a regression.
 - **Settlement gate before publish.** `~/.claude/skills/settlement/` orchestrates 5 detectors (a1 directional leaves, a2 open space, a3 multiple colors, b prohibited elements, acceptable elements) + verdict combinator. Never set `is_published=true` without the gate returning `OK`.
-- **TIFs are full-size archives.** Every design has a `tif_path` (150 dpi, up to 36000 × 19800 px, ~2 GB). The publish gate refuses NULL `tif_path`. Thumbs online, real file must exist.
+- **TIFs are lazily-built print masters — NOT a publish prerequisite.** A `tif_path` (150 dpi, up to 36000 × 19800 px, ~2 GB) is the full-size archive for *physical-print* fulfillment. **Publish = catalog-visible; it does NOT require a TIF.** TIF generation is opt-in/deferred because each file is ~2 GB and disk runs hot (`scripts/export-etsy-digital.js`: "NO TIF gen by default"), so a large share of the live catalog is published without one. The digital license download (`/api/design/:id/download`) serves an on-demand Real-ESRGAN upscale, **not** the TIF — so a NULL `tif_path` does not block purchase. Any hard "must-have-TIF" requirement belongs at *physical-print fulfillment*, not at catalog publish. The no-TIF backlog is browsable at `/admin/needs-tif`. Full analysis + the rejected hard-gate options: `docs/tif-publish-gate-review.md`. (Historical note: earlier docs/comments claimed "the publish gate refuses NULL `tif_path`" — that gate was never implemented, contradicted ~73% of the live catalog, and is retired. DTD verdict 2026-06-02.)
 - **No vendor names in public UI.** 30-vendor denylist enforced in PG bulk rename + server.js dropdown filter. `wallco.ai/library` is admin-only.
 - **No "FLIEPAPER" word in any customer-facing surface.** Internal scaffold names (`src/fliepaper-bugs.js`) are exempt; rendered URLs / image filenames / category slugs go through redaction.
 - **`/admin` routes are admin-gated.** `src/admin-gate.js` enforces basic-auth on every `/admin/*` and `/api/admin/*` route. Don't add a new admin route without the gate.
diff --git a/public/admin/needs-tif.html b/public/admin/needs-tif.html
new file mode 100644
index 0000000..634ada7
--- /dev/null
+++ b/public/admin/needs-tif.html
@@ -0,0 +1,147 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Needs TIF — print-master backlog</title>
+<style>
+  :root { --card-min: 200px; }
+  * { box-sizing: border-box; }
+  body { margin: 0; background: #14110e; color: #e8e2d6; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+  header { padding: 20px 24px 12px; border-bottom: 1px solid #2a261f; position: sticky; top: 0; background: #14110e; z-index: 5; }
+  h1 { margin: 0 0 4px; font: 300 24px/1.2 "Cormorant Garamond", Georgia, serif; color: #f0eadc; }
+  .sub { color: #9a9182; font-size: 13px; }
+  .sub b { color: #d2b15c; font-weight: 600; }
+  .controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
+  .controls label { color: #9a9182; font-size: 12px; display: flex; gap: 6px; align-items: center; }
+  select, input[type=range] { accent-color: #d2b15c; }
+  select { background: #1e1a15; color: #e8e2d6; border: 1px solid #3a352c; border-radius: 7px; padding: 5px 8px; }
+  .groups { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; max-height: 64px; overflow: auto; }
+  .gchip { background: #1e1a15; border: 1px solid #2f2a22; border-radius: 999px; padding: 3px 10px; font-size: 11px; color: #c9c0b0; cursor: pointer; }
+  .gchip.active { border-color: #d2b15c; color: #f0eadc; }
+  .gchip b { color: #d2b15c; }
+  main { padding: 18px 24px 60px; }
+  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 14px; }
+  .card { background: #1a1611; border: 1px solid #2a261f; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; }
+  .card a.thumb { display: block; aspect-ratio: 1/1; background: #0d0b09 center/cover no-repeat; }
+  .meta { padding: 8px 10px 10px; font-size: 12px; }
+  .meta .cat { color: #d8cfbe; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+  .meta .id { color: #8a8175; font-size: 11px; }
+  .when { margin-top: 5px; display: inline-flex; align-items: center; gap: 4px; background: #221d16; border-radius: 6px; padding: 2px 7px; font-size: 10.5px; color: #b8ad9b; }
+  .sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1px solid #00000040; vertical-align: middle; margin-right: 4px; }
+  .empty, .loading { color: #9a9182; padding: 40px; text-align: center; }
+  footer { color: #6f675b; font-size: 11px; padding: 0 24px 40px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Needs TIF — print-master backlog</h1>
+  <div class="sub">Published designs with no full-size TIF (<b id="total">…</b>). Publishing is <b>not</b> gated on TIFs — this is a visibility surface only. See <code>docs/tif-publish-gate-review.md</code>.</div>
+  <div class="controls">
+    <label>Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="oldest">Oldest</option>
+        <option value="category">Category A→Z</option>
+        <option value="id">ID ↑</option>
+      </select>
+    </label>
+    <label>Density
+      <input type="range" id="density" min="130" max="340" step="10">
+    </label>
+    <label><input type="checkbox" id="onlyfilter" style="accent-color:#d2b15c"> filter by group below</label>
+  </div>
+  <div class="groups" id="groups"></div>
+</header>
+<main>
+  <div class="loading" id="status">Loading…</div>
+  <div class="grid" id="grid"></div>
+</main>
+<footer>Read-only. To build a TIF: <code>scripts/build-tif.py &lt;id&gt;</code> (≈2 GB each — run on a disk-rich box). Backlog reflects local <code>dw_unified</code>.</footer>
+<script>
+(function () {
+  var grid = document.getElementById('grid'), statusEl = document.getElementById('status');
+  var sortEl = document.getElementById('sort'), densEl = document.getElementById('density');
+  var groupsEl = document.getElementById('groups'), totalEl = document.getElementById('total');
+  var ALL = [], activeGroup = null;
+
+  // restore persisted prefs (Steve standing rule: sort + density persist)
+  sortEl.value = localStorage.getItem('needsTif_sort') || 'newest';
+  densEl.value = localStorage.getItem('needsTif_density') || '200';
+  document.documentElement.style.setProperty('--card-min', densEl.value + 'px');
+
+  function fmtDate(s) {
+    if (!s) return '—';
+    var d = new Date(s); if (isNaN(d)) return '—';
+    return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+  }
+  function grp(cat) { return String(cat || '').split(' · ')[0]; }
+
+  function sorted(rows) {
+    var r = rows.slice();
+    var m = sortEl.value;
+    if (m === 'newest') r.sort(function (a, b) { return (b.created_at || '').localeCompare(a.created_at || ''); });
+    else if (m === 'oldest') r.sort(function (a, b) { return (a.created_at || '').localeCompare(b.created_at || ''); });
+    else if (m === 'category') r.sort(function (a, b) { return String(a.category || '').localeCompare(String(b.category || '')); });
+    else if (m === 'id') r.sort(function (a, b) { return a.id - b.id; });
+    return r;
+  }
+
+  function render() {
+    var rows = ALL;
+    if (activeGroup) rows = rows.filter(function (x) { return grp(x.category) === activeGroup; });
+    rows = sorted(rows);
+    grid.innerHTML = '';
+    if (!rows.length) { statusEl.textContent = 'Nothing here.'; statusEl.style.display = 'block'; return; }
+    statusEl.style.display = 'none';
+    var frag = document.createDocumentFragment();
+    rows.forEach(function (x) {
+      var card = document.createElement('div'); card.className = 'card';
+      var a = document.createElement('a'); a.className = 'thumb'; a.href = '/design/' + x.id; a.target = '_blank';
+      if (x.image_url) a.style.backgroundImage = "url('" + x.image_url + "')";
+      var meta = document.createElement('div'); meta.className = 'meta';
+      var sw = x.dominant_hex ? '<span class="sw" style="background:' + x.dominant_hex + '"></span>' : '';
+      meta.innerHTML =
+        '<div class="cat">' + sw + (x.category || '(uncategorized)').replace(/[<>]/g, '') + '</div>' +
+        '<div class="id">#' + x.id + '</div>' +
+        '<div class="when" title="' + (x.created_at || '') + '">🕓 ' + fmtDate(x.created_at) + '</div>';
+      card.appendChild(a); card.appendChild(meta); frag.appendChild(card);
+    });
+    grid.appendChild(frag);
+  }
+
+  function renderGroups(groups) {
+    groupsEl.innerHTML = '';
+    groups.forEach(function (g) {
+      var c = document.createElement('span'); c.className = 'gchip';
+      c.innerHTML = g.grp + ' <b>' + g.n + '</b>';
+      c.onclick = function () {
+        activeGroup = (activeGroup === g.grp) ? null : g.grp;
+        [].forEach.call(groupsEl.children, function (el) { el.classList.remove('active'); });
+        if (activeGroup) c.classList.add('active');
+        render();
+      };
+      groupsEl.appendChild(c);
+    });
+  }
+
+  sortEl.addEventListener('change', function () { localStorage.setItem('needsTif_sort', sortEl.value); render(); });
+  densEl.addEventListener('input', function () {
+    localStorage.setItem('needsTif_density', densEl.value);
+    document.documentElement.style.setProperty('--card-min', densEl.value + 'px');
+  });
+
+  fetch('/api/admin/needs-tif?limit=2000', { credentials: 'same-origin' })
+    .then(function (r) { return r.json(); })
+    .then(function (j) {
+      if (!j.ok) { statusEl.textContent = 'Error: ' + (j.error || 'failed'); return; }
+      ALL = j.items || [];
+      totalEl.textContent = j.total + (j.total > ALL.length ? (' — showing newest ' + ALL.length) : '');
+      renderGroups(j.by_group || []);
+      render();
+    })
+    .catch(function (e) { statusEl.textContent = 'Load failed: ' + e.message; });
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 9eee38f..c568dcb 100644
--- a/server.js
+++ b/server.js
@@ -143,6 +143,13 @@ function colorDotsVer() {
   catch (e) { _cdVer = '1'; }
   return _cdVer;
 }
+let _tsrVer = null;
+function tsrVer() {
+  if (_tsrVer) return _tsrVer;
+  try { _tsrVer = String(Math.floor(require('fs').statSync(path.join(__dirname, 'public', 'theme-variants', 'theme-section-rearrange.js')).mtimeMs)); }
+  catch (e) { _tsrVer = '1'; }
+  return _tsrVer;
+}
 app.use((req, res, next) => {
   const isPDP = /^\/design\/\d+$/.test(req.path);
   if (req.path === '/designs' || isPDP) {
@@ -155,11 +162,18 @@ app.use((req, res, next) => {
         // screen-print ink dots, matching the 17 theme variants. PDP only (the
         // grid isn't a theme; per-card dots would clutter it).
         if (isPDP && body.indexOf('color-dots.js') === -1) inj += '<script src="/js/color-dots.js" defer></script>\n';
+        // Universal PDP theme customizer (render-a-room + free drag-rearrange +
+        // save-to-localStorage). The 17 variants reference it directly in their
+        // HTML; the classic monolith gets it injected here so EVERY theme has it.
+        if (isPDP && body.indexOf('theme-section-rearrange.js') === -1) inj += '<script src="/theme-variants/theme-section-rearrange.js" defer></script>\n';
         if (inj) body = body.replace('</body>', inj + '</body>');
         // Cache-bust color-dots.js by its mtime so JS fixes land on a normal
         // reload (no hard-refresh). Covers the injected include AND the ~17
         // hardcoded theme-variant includes in one place. (?!\?) avoids double-tag.
         if (isPDP) body = body.replace(/\/js\/color-dots\.js(?!\?)/g, '/js/color-dots.js?v=' + colorDotsVer());
+        // Same cache-bust for the theme customizer (covers the monolith inject
+        // above AND the 17 variant includes), so a script fix lands on reload.
+        if (isPDP) body = body.replace(/\/theme-variants\/theme-section-rearrange\.js(?!\?)/g, '/theme-variants/theme-section-rearrange.js?v=' + tsrVer());
       }
       return origSend(body);
     };
@@ -1901,6 +1915,35 @@ app.get('/admin/cactus-curator', (req, res) => {
   res.redirect(301, '/admin/design-curator' + (i >= 0 ? req.originalUrl.slice(i) : ''));
 });
 
+// ── /admin/needs-tif — read-only backlog of published designs with no print
+// master (tif_path IS NULL). Visibility surface only; publishing is NOT gated on
+// TIFs (see CLAUDE.md + docs/tif-publish-gate-review.md, DTD verdict 2026-06-02).
+// No schema change — plain query over all_designs. Admin-gated.
+app.get('/admin/needs-tif', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'needs-tif.html'));
+});
+app.get('/api/admin/needs-tif', requireAdmin, (req, res) => {
+  const limit = Math.min(2000, Math.max(1, parseInt(req.query.limit, 10) || 500));
+  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+  try {
+    const total = parseInt(String(psqlQuery(
+      "SELECT COUNT(*) FROM all_designs WHERE is_published AND tif_path IS NULL")).trim(), 10) || 0;
+    const groups = JSON.parse(String(psqlQuery(
+      `SELECT COALESCE(json_agg(json_build_object('grp', grp, 'n', n) ORDER BY n DESC), '[]')
+         FROM (SELECT split_part(category, ' · ', 1) AS grp, COUNT(*) AS n
+                 FROM all_designs WHERE is_published AND tif_path IS NULL GROUP BY 1) t`)) || '[]');
+    const items = JSON.parse(String(psqlQuery(
+      `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (
+         SELECT id, category, created_at, dominant_hex, image_url
+           FROM all_designs WHERE is_published AND tif_path IS NULL
+          ORDER BY created_at DESC NULLS LAST, id DESC LIMIT ${limit} OFFSET ${offset}) t`)) || '[]');
+    res.json({ ok: true, total, limit, offset, by_group: groups, items });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 // ── /admin/rooms — master gallery of EVERY saved room-setting render ───────
 app.get('/admin/rooms', (req, res) => {
   if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');

← ecbba0f cron-build-tifs.sh: only alarm on disk-guard abort (rc=2), n  ·  back to Wallco Ai  ·  PDP: universal theme customizer — render-a-room + free drag- fe12244 →