[object Object]

← 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

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 →