← 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
M CLAUDE.mdA public/admin/needs-tif.htmlM server.js
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 <id></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 →