← back to Wallco Ai
elements skill: /admin/elements browse UI + bake-at-ingest pipeline
50890a32ccd92c417c80d8077878b015fe61bf49 · 2026-05-26 08:07:54 -0700 · Steve Abrams
New admin surface for the elements skill (~/.claude/skills/elements).
Curates the catalog to "solid opaque middle" designs that passed every
quality gate (edges-scan + bleed-ghost + settlement + user-removal +
bleed-guard) and surfaces them as building blocks for new designs.
Design panel verdict from 2026-05-26:
graphic-designer — bake derivatives AT INGEST TIME in the same transaction
that records the element. Otherwise every downstream consumer derives them
independently with slightly different crop / threshold / format, and that
inconsistency is what kills compositing libraries.
ui-ux-designer — browse-first with "Shuffle 12" cold-start; 4-field card
(thumb + category pill + colorway swatch + id + Use button); top-5 filters
(category > colorway > density > scale > quality tier) with live result
counts; single CTA with progressive disclosure that re-copies on selection
count ([Use as reference →] at 1, [Blend 2 →] at 2, [Compose 3 →] at 3+);
anti-pattern is filter paralysis — fix with active-filter chips and
auto-suggest-relax on 0-results.
Routes added:
GET /admin/elements — admin-gated HTML
GET /api/admin/elements/items — clean candidates with baked-path
static /elements/img/ — serves data/elements/*.png
Columns added (on all_designs base table, since spoon_all_designs view
has an explicit column list and doesn't auto-propagate new columns):
element_motif_path TEXT — alpha-trimmed motif PNG, 512x512
element_ref_path TEXT — same motif composited on white BG, 512x512
Extract pipeline (~/.claude/skills/elements/scripts/extract.py):
- Sample-per-category 3 = 524 baked in 8.4s (62.7 designs/s, 8 workers)
- PIL pipeline: center-crop 75% → corner-sample BG detect →
quantize 6 colors → alpha=0 where BG → composite ref onto white
- dense_pattern fallback: when corners are heterogeneous
(bg_dominance < 0.25), skip alpha-trim and ship motif==ref
- PG update via TEMP TABLE + batched INSERT + single UPDATE join
- Full batch on 15,231 unbaked candidates ETA ~4 min, ~5.7 GB disk
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A public/admin/elements.htmlM server.js
Diff
commit 50890a32ccd92c417c80d8077878b015fe61bf49
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 26 08:07:54 2026 -0700
elements skill: /admin/elements browse UI + bake-at-ingest pipeline
New admin surface for the elements skill (~/.claude/skills/elements).
Curates the catalog to "solid opaque middle" designs that passed every
quality gate (edges-scan + bleed-ghost + settlement + user-removal +
bleed-guard) and surfaces them as building blocks for new designs.
Design panel verdict from 2026-05-26:
graphic-designer — bake derivatives AT INGEST TIME in the same transaction
that records the element. Otherwise every downstream consumer derives them
independently with slightly different crop / threshold / format, and that
inconsistency is what kills compositing libraries.
ui-ux-designer — browse-first with "Shuffle 12" cold-start; 4-field card
(thumb + category pill + colorway swatch + id + Use button); top-5 filters
(category > colorway > density > scale > quality tier) with live result
counts; single CTA with progressive disclosure that re-copies on selection
count ([Use as reference →] at 1, [Blend 2 →] at 2, [Compose 3 →] at 3+);
anti-pattern is filter paralysis — fix with active-filter chips and
auto-suggest-relax on 0-results.
Routes added:
GET /admin/elements — admin-gated HTML
GET /api/admin/elements/items — clean candidates with baked-path
static /elements/img/ — serves data/elements/*.png
Columns added (on all_designs base table, since spoon_all_designs view
has an explicit column list and doesn't auto-propagate new columns):
element_motif_path TEXT — alpha-trimmed motif PNG, 512x512
element_ref_path TEXT — same motif composited on white BG, 512x512
Extract pipeline (~/.claude/skills/elements/scripts/extract.py):
- Sample-per-category 3 = 524 baked in 8.4s (62.7 designs/s, 8 workers)
- PIL pipeline: center-crop 75% → corner-sample BG detect →
quantize 6 colors → alpha=0 where BG → composite ref onto white
- dense_pattern fallback: when corners are heterogeneous
(bg_dominance < 0.25), skip alpha-trim and ship motif==ref
- PG update via TEMP TABLE + batched INSERT + single UPDATE join
- Full batch on 15,231 unbaked candidates ETA ~4 min, ~5.7 GB disk
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/admin/elements.html | 514 +++++++++++++++++++++++++++++++++++++++++++++
server.js | 45 ++++
2 files changed, 559 insertions(+)
diff --git a/public/admin/elements.html b/public/admin/elements.html
new file mode 100644
index 0000000..425e60e
--- /dev/null
+++ b/public/admin/elements.html
@@ -0,0 +1,514 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>Elements — wallco.ai admin</title>
+<style>
+ :root {
+ --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+ --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+ --green:#1f6a2c; --red:#b3261e; --orange:#c0660b;
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+ --serif:'Playfair Display','Didot',Georgia,serif;
+ --card-min: 220px;
+ }
+ * { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
+
+ header {
+ padding:14px 24px; border-bottom:1px solid var(--line); background:#fff;
+ display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:10;
+ }
+ header h1 { font:400 24px/1.2 var(--serif); margin:0; letter-spacing:.5px; }
+ header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
+ header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
+ header .stats b { color:var(--ink); }
+ header .shuffle-btn {
+ margin-left:18px; padding:8px 16px; background:var(--ink); color:#fff;
+ border:none; border-radius:4px; cursor:pointer;
+ font:600 11.5px var(--sans); letter-spacing:.12em; text-transform:uppercase;
+ }
+ header .shuffle-btn:hover { background:var(--gold); }
+
+ main { padding:16px 24px 80px; }
+
+ /* Controls */
+ .controls {
+ display:flex; gap:14px; align-items:center; padding:11px 14px;
+ background:#fff; border:1px solid var(--line); border-radius:8px;
+ margin-bottom:8px; flex-wrap:wrap; font-size:12px;
+ }
+ .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
+ text-transform:uppercase; color:var(--faint); }
+ .controls select, .controls input[type=text] {
+ padding:6px 10px; border:1px solid var(--line); border-radius:4px;
+ background:#fff; font:12px var(--sans); min-width:160px;
+ }
+ .controls input[type=range] { width:160px; }
+ .controls .density-val { font:600 11px ui-monospace,Menlo,monospace;
+ color:var(--faint); min-width:36px; }
+ .controls .toggle { display:flex; align-items:center; gap:6px; cursor:pointer; }
+ .controls .toggle input { margin:0; }
+
+ /* Active filter chips */
+ .chips { display:flex; gap:6px; flex-wrap:wrap; padding:0 14px 12px; }
+ .chip {
+ background:var(--ink); color:#fff;
+ font:600 10.5px var(--sans); letter-spacing:.06em;
+ padding:4px 8px 4px 10px; border-radius:999px;
+ display:inline-flex; align-items:center; gap:6px;
+ cursor:pointer;
+ }
+ .chip:hover { background:var(--red); }
+ .chip .x { font-size:14px; line-height:1; }
+
+ /* Empty state */
+ .empty-state {
+ text-align:center; padding:40px 20px;
+ background:#fff; border:1px dashed var(--line); border-radius:8px;
+ margin:14px 0;
+ }
+ .empty-state h3 { font:400 18px var(--serif); margin:0 0 6px; }
+ .empty-state p { color:var(--faint); margin:0 0 12px; font-size:13px; }
+ .empty-state button {
+ background:var(--gold); color:#fff; border:0; padding:8px 16px;
+ border-radius:4px; cursor:pointer; font:600 11.5px var(--sans);
+ letter-spacing:.12em; text-transform:uppercase;
+ }
+
+ /* Grid */
+ .grid {
+ display:grid;
+ grid-template-columns:repeat(auto-fill, minmax(var(--card-min), 1fr));
+ gap:14px; padding:14px 0;
+ }
+ .element-card {
+ background:#fff; border:1px solid var(--line); border-radius:8px;
+ overflow:hidden; transition:transform .12s, box-shadow .12s;
+ cursor:pointer; position:relative;
+ }
+ .element-card.selected {
+ border-color:var(--gold); border-width:2px;
+ box-shadow:0 0 0 1px var(--gold);
+ }
+ .element-card:hover {
+ transform:translateY(-2px);
+ box-shadow:0 6px 16px rgba(0,0,0,.12);
+ }
+ .card-thumb {
+ aspect-ratio:1; background:#f3eee2 url('/admin/elements-placeholder.svg') center/40% no-repeat;
+ background-size:cover;
+ display:block; width:100%;
+ }
+ .card-meta {
+ padding:10px 12px;
+ display:flex; flex-direction:column; gap:6px;
+ font-size:12px;
+ }
+ .card-cat {
+ display:inline-block;
+ background:#f3eee2; color:var(--ink);
+ padding:2px 8px; border-radius:999px;
+ font:600 10px var(--sans); letter-spacing:.08em;
+ text-transform:uppercase; align-self:flex-start;
+ max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
+ }
+ .card-color {
+ display:flex; align-items:center; gap:6px;
+ color:var(--faint); font-size:11px;
+ }
+ .card-color .dot {
+ width:10px; height:10px; border-radius:50%;
+ border:1px solid rgba(0,0,0,.15);
+ }
+ .card-id-row {
+ display:flex; justify-content:space-between; align-items:center;
+ border-top:1px solid var(--line); padding-top:6px; margin-top:2px;
+ }
+ .card-id {
+ font:600 11px ui-monospace,Menlo,monospace; color:var(--faint);
+ }
+ .card-use-btn {
+ background:none; border:1px solid var(--line); color:var(--ink);
+ padding:3px 10px; border-radius:4px; cursor:pointer;
+ font:600 10px var(--sans); letter-spacing:.12em; text-transform:uppercase;
+ }
+ .card-use-btn:hover { background:var(--ink); color:#fff; border-color:var(--ink); }
+
+ .card-tier {
+ position:absolute; top:8px; right:8px;
+ font:700 8.5px var(--sans); letter-spacing:.16em; text-transform:uppercase;
+ padding:3px 7px; border-radius:3px;
+ color:#fff; background:rgba(31,106,44,.92);
+ }
+ .card-tier.passed { background:rgba(122,110,90,.92); }
+
+ /* Loading */
+ .loading { padding:40px; text-align:center; color:var(--faint); }
+
+ /* Compose drawer */
+ .compose-drawer {
+ position:fixed; bottom:0; left:0; right:0;
+ background:#fff; border-top:2px solid var(--gold);
+ padding:14px 24px;
+ transform:translateY(100%); transition:transform .2s;
+ z-index:20;
+ box-shadow:0 -8px 24px rgba(0,0,0,.12);
+ }
+ .compose-drawer.open { transform:translateY(0); }
+ .drawer-inner {
+ max-width:1500px; margin:0 auto;
+ display:flex; gap:14px; align-items:center;
+ }
+ .drawer-thumbs { display:flex; gap:8px; flex:1; overflow-x:auto; }
+ .drawer-thumb {
+ width:64px; height:64px; border-radius:4px; flex-shrink:0;
+ background-size:cover; background-position:center;
+ border:1px solid var(--line); position:relative;
+ }
+ .drawer-thumb .remove {
+ position:absolute; top:-6px; right:-6px;
+ width:18px; height:18px; border-radius:50%;
+ background:var(--red); color:#fff; border:none;
+ font-size:12px; line-height:1; cursor:pointer;
+ }
+ .drawer-cta {
+ background:var(--ink); color:#fff; border:0;
+ padding:12px 22px; border-radius:4px; cursor:pointer;
+ font:600 12px var(--sans); letter-spacing:.16em; text-transform:uppercase;
+ }
+ .drawer-cta:hover { background:var(--gold); }
+ .drawer-close {
+ background:none; border:none; color:var(--faint); cursor:pointer;
+ font:14px var(--sans); padding:6px 10px;
+ }
+</style>
+</head>
+<body>
+<header>
+ <h1>Elements</h1>
+ <span class="meta">curated solid-opaque-middle building blocks</span>
+ <div class="stats">
+ <span>baked <b id="stat-baked">—</b></span>
+ <span>clean <b id="stat-clean">—</b></span>
+ <span>showing <b id="stat-showing">—</b></span>
+ </div>
+ <button class="shuffle-btn" id="shuffle-btn">Shuffle 12</button>
+</header>
+
+<main>
+ <div class="controls">
+ <label>Category</label>
+ <select id="f-category"><option value="">All</option></select>
+
+ <label>Colorway</label>
+ <select id="f-colorway"><option value="">All</option></select>
+
+ <label>Density</label>
+ <select id="f-density">
+ <option value="">All</option>
+ <option value="sparse">Sparse</option>
+ <option value="medium">Medium</option>
+ <option value="dense">Dense</option>
+ </select>
+
+ <label class="toggle" title="Solid screen-print only = baked elements. Off = also include candidates that passed seamless-scan but haven't been baked yet.">
+ <input type="checkbox" id="f-quality" checked>
+ Solid screen-print only
+ </label>
+
+ <label>Search</label>
+ <input type="text" id="f-search" placeholder="id, category, or hex…" style="min-width:200px">
+
+ <label style="margin-left:auto">Density</label>
+ <input id="density-slider" type="range" min="140" max="380" step="20" value="220">
+ <span class="density-val" id="density-val">220</span>
+ </div>
+
+ <div class="chips" id="chips"></div>
+
+ <div id="grid" class="grid"><div class="loading">loading elements…</div></div>
+</main>
+
+<div class="compose-drawer" id="drawer">
+ <div class="drawer-inner">
+ <span style="font:600 11.5px var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--faint);">
+ Selected
+ </span>
+ <div class="drawer-thumbs" id="drawer-thumbs"></div>
+ <button class="drawer-cta" id="drawer-cta">Use →</button>
+ <button class="drawer-close" id="drawer-close" title="Close drawer (selection persists)">×</button>
+ </div>
+</div>
+
+<script>
+(function(){
+ const PER = 200;
+ const state = {
+ items: [],
+ filtered: [],
+ selected: [],
+ filter: { category:'', colorway:'', density:'', quality:true, search:'' },
+ cardMin: parseInt(localStorage.getItem('el-card-min') || '220', 10),
+ shuffleSeed: 0,
+ };
+
+ function applyCardMin(px) {
+ document.documentElement.style.setProperty('--card-min', px + 'px');
+ document.getElementById('density-val').textContent = px;
+ }
+
+ function categoryOf(item) {
+ return (item.category || '').split(' · ')[0] || 'misc';
+ }
+ function colorwayOf(item) {
+ const parts = (item.category || '').split(' · ');
+ return parts.length >= 2 ? parts[1] : '';
+ }
+ function densityOf(item) {
+ if (item.bg_dominance == null) return null;
+ if (item.bg_dominance < 0.30) return 'dense';
+ if (item.bg_dominance < 0.70) return 'medium';
+ return 'sparse';
+ }
+
+ function applyFilters() {
+ const f = state.filter;
+ state.filtered = state.items.filter(it => {
+ if (f.quality && !it.element_motif_path) return false;
+ if (f.category && categoryOf(it) !== f.category) return false;
+ if (f.colorway && colorwayOf(it) !== f.colorway) return false;
+ if (f.density && densityOf(it) !== f.density) return false;
+ if (f.search) {
+ const s = f.search.toLowerCase();
+ const cat = (it.category || '').toLowerCase();
+ const hex = (it.dominant_hex || '').toLowerCase();
+ if (!cat.includes(s) && !hex.includes(s) && String(it.id) !== s) return false;
+ }
+ return true;
+ });
+ document.getElementById('stat-showing').textContent = state.filtered.length;
+ renderChips();
+ render();
+ }
+
+ function renderChips() {
+ const f = state.filter;
+ const chips = [];
+ if (f.category) chips.push({ k:'category', v:f.category });
+ if (f.colorway) chips.push({ k:'colorway', v:f.colorway });
+ if (f.density) chips.push({ k:'density', v:f.density });
+ if (!f.quality) chips.push({ k:'quality', v:'show all passed-seamless' });
+ if (f.search) chips.push({ k:'search', v:f.search });
+ const c = document.getElementById('chips');
+ c.innerHTML = chips.map(ch =>
+ `<span class="chip" data-k="${ch.k}">${ch.k}: ${ch.v} <span class="x">×</span></span>`
+ ).join('');
+ c.querySelectorAll('.chip').forEach(el => {
+ el.addEventListener('click', () => {
+ const k = el.dataset.k;
+ if (k === 'category') document.getElementById('f-category').value = '';
+ if (k === 'colorway') document.getElementById('f-colorway').value = '';
+ if (k === 'density') document.getElementById('f-density').value = '';
+ if (k === 'quality') document.getElementById('f-quality').checked = true;
+ if (k === 'search') document.getElementById('f-search').value = '';
+ state.filter[k] = k === 'quality' ? true : '';
+ applyFilters();
+ });
+ });
+ }
+
+ function shuffleList(arr, seed) {
+ // Deterministic shuffle on seed so the same Shuffle press doesn't re-roll
+ // on every render. Mulberry32 PRNG.
+ let s = seed | 0;
+ const a = arr.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ s = (s + 0x6D2B79F5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ const j = ((t ^ (t >>> 14)) >>> 0) % (i + 1);
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+ }
+
+ function render() {
+ const grid = document.getElementById('grid');
+ let view = state.filtered;
+ if (state.shuffleSeed) view = shuffleList(view, state.shuffleSeed);
+ view = view.slice(0, PER);
+
+ if (view.length === 0) {
+ // 0-results — auto-suggest relaxing the most-constraining filter
+ let suggest = '';
+ const f = state.filter;
+ if (f.density) suggest = 'remove the Density filter';
+ else if (f.colorway) suggest = 'remove the Colorway filter';
+ else if (f.category) suggest = 'remove the Category filter';
+ else if (f.search) suggest = 'clear the Search box';
+ else if (f.quality) suggest = 'turn off "Solid screen-print only"';
+ grid.innerHTML = `
+ <div class="empty-state" style="grid-column: 1 / -1;">
+ <h3>No elements match these filters</h3>
+ <p>Try this: ${suggest || 'broaden the filters'}</p>
+ <button onclick="document.querySelectorAll('.chip').forEach(c=>c.click())">Clear all chips</button>
+ </div>`;
+ return;
+ }
+
+ grid.innerHTML = view.map(item => {
+ const selected = state.selected.includes(item.id);
+ const cat = categoryOf(item);
+ const cw = colorwayOf(item);
+ const hex = item.dominant_hex || '#cccccc';
+ const tier = item.element_motif_path ? 'solid' : 'passed';
+ // Prefer the baked 512 reference thumb if present; fall back to live tile
+ const img = item.element_ref_path
+ ? `/elements/img/${item.id}_ref512.png`
+ : `/designs/img/by-id/${item.id}`;
+ return `
+ <div class="element-card ${selected ? 'selected' : ''}" data-id="${item.id}">
+ <span class="card-tier ${tier}">${tier === 'solid' ? 'Solid' : 'Passed'}</span>
+ <a href="/design/${item.id}" target="_blank" rel="noopener">
+ <div class="card-thumb" style="background-image:url('${img}')"></div>
+ </a>
+ <div class="card-meta">
+ <span class="card-cat" title="${item.category || ''}">${cat}</span>
+ ${cw ? `<span class="card-color"><span class="dot" style="background:${hex}"></span>${cw}</span>` : `<span class="card-color"><span class="dot" style="background:${hex}"></span>${hex}</span>`}
+ <div class="card-id-row">
+ <span class="card-id">#${item.id}</span>
+ <button class="card-use-btn" data-id="${item.id}">+ Use</button>
+ </div>
+ </div>
+ </div>`;
+ }).join('');
+
+ grid.querySelectorAll('.card-use-btn').forEach(b => {
+ b.addEventListener('click', e => {
+ e.stopPropagation();
+ e.preventDefault();
+ toggleSelect(parseInt(b.dataset.id, 10));
+ });
+ });
+ }
+
+ function toggleSelect(id) {
+ const i = state.selected.indexOf(id);
+ if (i >= 0) state.selected.splice(i, 1);
+ else state.selected.push(id);
+ renderDrawer();
+ render();
+ }
+
+ function renderDrawer() {
+ const d = document.getElementById('drawer');
+ const cta = document.getElementById('drawer-cta');
+ if (state.selected.length === 0) {
+ d.classList.remove('open');
+ return;
+ }
+ d.classList.add('open');
+ cta.textContent =
+ state.selected.length === 1 ? 'Use as reference →' :
+ state.selected.length === 2 ? 'Blend 2 →' :
+ `Compose ${state.selected.length} →`;
+ document.getElementById('drawer-thumbs').innerHTML = state.selected.map(id => {
+ const item = state.items.find(it => it.id === id);
+ if (!item) return '';
+ const img = item.element_ref_path
+ ? `/elements/img/${item.id}_ref512.png`
+ : `/designs/img/by-id/${item.id}`;
+ return `<div class="drawer-thumb" style="background-image:url('${img}')">
+ <button class="remove" data-id="${id}">×</button>
+ </div>`;
+ }).join('');
+ document.getElementById('drawer-thumbs').querySelectorAll('.remove').forEach(b => {
+ b.addEventListener('click', () => toggleSelect(parseInt(b.dataset.id, 10)));
+ });
+ }
+
+ function populateFilterOptions() {
+ const cats = new Set();
+ const cws = new Set();
+ state.items.forEach(it => {
+ const c = categoryOf(it);
+ if (c) cats.add(c);
+ const cw = colorwayOf(it);
+ if (cw) cws.add(cw);
+ });
+ const catSel = document.getElementById('f-category');
+ const cwSel = document.getElementById('f-colorway');
+ [...cats].sort().forEach(c =>
+ catSel.insertAdjacentHTML('beforeend', `<option value="${c}">${c}</option>`));
+ [...cws].sort().forEach(c =>
+ cwSel.insertAdjacentHTML('beforeend', `<option value="${c}">${c}</option>`));
+ }
+
+ // Wire controls
+ ['f-category','f-colorway','f-density'].forEach(id => {
+ document.getElementById(id).addEventListener('change', e => {
+ const key = id.split('-')[1];
+ state.filter[key] = e.target.value;
+ state.shuffleSeed = 0;
+ applyFilters();
+ });
+ });
+ document.getElementById('f-quality').addEventListener('change', e => {
+ state.filter.quality = e.target.checked;
+ state.shuffleSeed = 0;
+ applyFilters();
+ });
+ document.getElementById('f-search').addEventListener('input', e => {
+ state.filter.search = e.target.value.trim();
+ state.shuffleSeed = 0;
+ applyFilters();
+ });
+ document.getElementById('density-slider').addEventListener('input', e => {
+ state.cardMin = parseInt(e.target.value, 10);
+ localStorage.setItem('el-card-min', String(state.cardMin));
+ applyCardMin(state.cardMin);
+ });
+ document.getElementById('shuffle-btn').addEventListener('click', () => {
+ state.shuffleSeed = Date.now() & 0xffffffff;
+ render();
+ });
+ document.getElementById('drawer-cta').addEventListener('click', () => {
+ if (state.selected.length === 0) return;
+ // v2: actual Gemini hookup. For now just confirm + leave selection intact.
+ alert(`Compose stub — ${state.selected.length} element(s) selected.\nIDs: ${state.selected.join(', ')}\n\nGemini wiring lands in v2.`);
+ });
+ document.getElementById('drawer-close').addEventListener('click', () => {
+ document.getElementById('drawer').classList.remove('open');
+ });
+
+ // Boot
+ applyCardMin(state.cardMin);
+ document.getElementById('density-slider').value = state.cardMin;
+
+ fetch('/api/admin/elements/items')
+ .then(r => r.json())
+ .then(data => {
+ if (!data.ok) {
+ document.getElementById('grid').innerHTML =
+ `<div class="empty-state" style="grid-column:1/-1"><h3>Error</h3><p>${data.error || 'unknown'}</p></div>`;
+ return;
+ }
+ state.items = data.items || [];
+ document.getElementById('stat-baked').textContent = state.items.filter(i => i.element_motif_path).length;
+ document.getElementById('stat-clean').textContent = state.items.length;
+ populateFilterOptions();
+ // Cold-start: Shuffle 12.
+ state.shuffleSeed = Date.now() & 0xffffffff;
+ applyFilters();
+ })
+ .catch(err => {
+ document.getElementById('grid').innerHTML =
+ `<div class="empty-state" style="grid-column:1/-1"><h3>Fetch failed</h3><p>${err.message}</p></div>`;
+ });
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 0eabea0..8eeddba 100644
--- a/server.js
+++ b/server.js
@@ -1072,6 +1072,51 @@ app.get('/admin/edges-review', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin', 'edges-review.html'));
});
+// ── /admin/elements — curated library of "solid opaque middle" designs.
+// Browse-first surface for composing new designs from quality-vetted
+// existing tiles. Per the elements skill (~/.claude/skills/elements).
+app.get('/admin/elements', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+ res.sendFile(path.join(__dirname, 'public', 'admin', 'elements.html'));
+});
+
+// Serve baked element derivatives. These live outside the public
+// /designs/img/ tree so they can be cleared without touching source tiles.
+app.use('/elements/img', express.static(
+ path.join(__dirname, 'data', 'elements'),
+ { maxAge: '30d' }
+));
+
+app.get('/api/admin/elements/items', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ try {
+ // Candidate filter from elements skill: published, not user-removed,
+ // no edges/ghost/settlement/bleed flag. Returns both baked + not-yet-baked.
+ const rawJson = psqlQuery(
+ "SELECT COALESCE(json_agg(t),'[]'::json) FROM (" +
+ "SELECT id, category, dominant_hex, element_motif_path, element_ref_path " +
+ "FROM all_designs " +
+ "WHERE is_published = TRUE " +
+ "AND (user_removed IS NULL OR user_removed = FALSE) " +
+ "AND local_path IS NOT NULL " +
+ "AND (notes IS NULL OR (" +
+ "notes NOT LIKE '%EDGES_FAIL:%' " +
+ "AND notes NOT LIKE '%EDGES_SCAN_ERROR:%' " +
+ "AND notes NOT LIKE '%BLEED_GHOST_REGEN%' " +
+ "AND notes NOT LIKE '%SETTLEMENT BLOCK%' " +
+ "AND notes NOT LIKE '%BLEED_GUARD:%'" +
+ ")) " +
+ "AND generator IN ('replicate','gemini-2.5-flash-image-edit','gemini-2.5-flash-image','comfy') " +
+ "ORDER BY id DESC" +
+ ") t;"
+ );
+ const items = JSON.parse(rawJson || '[]');
+ res.json({ ok: true, count: items.length, items });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
app.get('/api/admin/edges-review/flagged', (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
try {
← 71e56c5 fix: ensureUserRemovedColumn targets all_designs base table,
·
back to Wallco Ai
·
mid-heal-preview: $0 SDXL grid-leak fix sample preview e30db7d →