[object Object]

← back to 4square Agentabrams

v2 layout — site nav rail + adjustable-width product browser + collapsible moodboard

2c34d92c4218637bb817cb0500c5391eeb35e849 · 2026-05-14 09:18:26 -0700 · Steve

LEFT rail (resizable): 104 of Steve's live, keep-flagged domains from CNCP,
in headliner order then alphabetical. TSD is wired; rest show 'Catalog
pending'. Filter input narrows the list as you type. Selection persists
in localStorage.

CENTER splitter (6px) drags to resize the rail (180-600px). Width persists.

RIGHT main panel: search input pinned at top ('Type anything to bring a
product up…') with debounced live filter through the wired site's catalog
API. Dense grid (auto-fill, 180px+ cards). Source pill + result count
visible. Empty states for no-selection / no-match / pending-catalog.

BOTTOM moodboard dock: the 4-square design board with collapse toggle.
Drag from the main grid into a slot, or double-click a card to place
into first empty slot. Slot↔slot swap still works. All persists.

Verified: 104 rail items, splitter drag, search ('vermeer' → 2/2),
rail filter ('wallpaper' → 20), drop → slot, reload persistence, mobile
stack. Zero console errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2c34d92c4218637bb817cb0500c5391eeb35e849
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:18:26 2026 -0700

    v2 layout — site nav rail + adjustable-width product browser + collapsible moodboard
    
    LEFT rail (resizable): 104 of Steve's live, keep-flagged domains from CNCP,
    in headliner order then alphabetical. TSD is wired; rest show 'Catalog
    pending'. Filter input narrows the list as you type. Selection persists
    in localStorage.
    
    CENTER splitter (6px) drags to resize the rail (180-600px). Width persists.
    
    RIGHT main panel: search input pinned at top ('Type anything to bring a
    product up…') with debounced live filter through the wired site's catalog
    API. Dense grid (auto-fill, 180px+ cards). Source pill + result count
    visible. Empty states for no-selection / no-match / pending-catalog.
    
    BOTTOM moodboard dock: the 4-square design board with collapse toggle.
    Drag from the main grid into a slot, or double-click a card to place
    into first empty slot. Slot↔slot swap still works. All persists.
    
    Verified: 104 rail items, splitter drag, search ('vermeer' → 2/2),
    rail filter ('wallpaper' → 20), drop → slot, reload persistence, mobile
    stack. Zero console errors.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/data/sites.json | 631 +++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html      | 574 +++++++++++++++++++++++++++++---------------
 2 files changed, 1013 insertions(+), 192 deletions(-)

diff --git a/public/data/sites.json b/public/data/sites.json
new file mode 100644
index 0000000..1bc5ba8
--- /dev/null
+++ b/public/data/sites.json
@@ -0,0 +1,631 @@
+{
+  "sites": [
+    {
+      "domain": "designerwallcoverings.com",
+      "name": "designerwallcoverings.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "thesetdecorator.com",
+      "name": "thesetdecorator.com",
+      "kind": "film",
+      "source": {
+        "kind": "tsd-catalog",
+        "endpoint": "/api/catalog/search"
+      }
+    },
+    {
+      "domain": "agentabrams.com",
+      "name": "agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "4square.agentabrams.com",
+      "name": "4square.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "butlr.agentabrams.com",
+      "name": "butlr.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "sdccanalysis.agentabrams.com",
+      "name": "sdccanalysis.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "lawyers.agentabrams.com",
+      "name": "lawyers.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "venturablvd.agentabrams.com",
+      "name": "venturablvd.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "philipperomano.com",
+      "name": "philipperomano.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "novasuede.com",
+      "name": "novasuede.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "flockedwallpaper.com",
+      "name": "flockedwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "grassclothwallpaper.com",
+      "name": "grassclothwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "corkwallcovering.com",
+      "name": "corkwallcovering.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "silkwallpaper.com",
+      "name": "silkwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "linenwallpaper.com",
+      "name": "linenwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "chinoiseriewallpaper.com",
+      "name": "chinoiseriewallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "glassbeadedwallpaper.com",
+      "name": "glassbeadedwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "metallicwallpaper.com",
+      "name": "metallicwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "90210rentals.com",
+      "name": "90210rentals.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "agedwallpaper.com",
+      "name": "agedwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "apartmentwallpaper.com",
+      "name": "apartmentwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "architecturalwallcoverings.com",
+      "name": "architecturalwallcoverings.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "askgoodquestions.ai",
+      "name": "askgoodquestions.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "asknonna.ai",
+      "name": "asknonna.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "beverlyandhills.com",
+      "name": "beverlyandhills.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "beverlyhillspaints.com",
+      "name": "beverlyhillspaints.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "blockprintedwallpaper.com",
+      "name": "blockprintedwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "boomercalc.com",
+      "name": "boomercalc.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "borninbh.com",
+      "name": "borninbh.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "borrowerbills.org",
+      "name": "borrowerbills.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "bubbe.ai",
+      "name": "bubbe.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "bubbeleh.ai",
+      "name": "bubbeleh.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "bubbesblock.com",
+      "name": "bubbesblock.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "bubbie.ai",
+      "name": "bubbie.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "carmelpaints.com",
+      "name": "carmelpaints.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "carmelriverwater.com",
+      "name": "carmelriverwater.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "carmelvalleywater.com",
+      "name": "carmelvalleywater.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "carmelwallpapers.com",
+      "name": "carmelwallpapers.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "cfafabrics.com",
+      "name": "cfafabrics.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "claimmyaddress.com",
+      "name": "claimmyaddress.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "commercialsalesreps.com",
+      "name": "commercialsalesreps.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "customdigitalwalls.com",
+      "name": "customdigitalwalls.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "cypresawards.com",
+      "name": "cypresawards.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designerlaboratory.com",
+      "name": "designerlaboratory.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designersalesreps.com",
+      "name": "designersalesreps.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designerschat.com",
+      "name": "designerschat.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designerwallcover.com",
+      "name": "designerwallcover.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designerwallcovering.com",
+      "name": "designerwallcovering.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "designerwallcoverings.ai",
+      "name": "designerwallcoverings.ai",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "designerwallpaints.com",
+      "name": "designerwallpaints.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designerwalltiles.com",
+      "name": "designerwalltiles.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "designtradereps.com",
+      "name": "designtradereps.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "dsgn.tv",
+      "name": "dsgn.tv",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "dwlosangeles.com",
+      "name": "dwlosangeles.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "edgrants.org",
+      "name": "edgrants.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "fabricwallpaper.com",
+      "name": "fabricwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "fentucci.com",
+      "name": "fentucci.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "forgivestudentloandebt.com",
+      "name": "forgivestudentloandebt.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "forza.agentabrams.com",
+      "name": "forza.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "freethedegree.org",
+      "name": "freethedegree.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "gmail.agentabrams.com",
+      "name": "gmail.agentabrams.com",
+      "kind": "agentabrams",
+      "source": null
+    },
+    {
+      "domain": "goodquestion.ai",
+      "name": "goodquestion.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "grassclothwallcovering.com",
+      "name": "grassclothwallcovering.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "grassclothwallcoverings.com",
+      "name": "grassclothwallcoverings.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "greenwallcoverings.com",
+      "name": "greenwallcoverings.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "hospitalitysalesagency.com",
+      "name": "hospitalitysalesagency.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "hospitalitysalesagents.com",
+      "name": "hospitalitysalesagents.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "hospitalitysalesreps.com",
+      "name": "hospitalitysalesreps.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "interiordesignreps.com",
+      "name": "interiordesignreps.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "joshdultz.com",
+      "name": "joshdultz.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "latke.ai",
+      "name": "latke.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "museumwallpaper.com",
+      "name": "museumwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "mylarwallpaper.com",
+      "name": "mylarwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "nataliaabrams.com",
+      "name": "nataliaabrams.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "nationalpaperhangers.com",
+      "name": "nationalpaperhangers.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "naturaltextilewallpaper.com",
+      "name": "naturaltextilewallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "pastelwallpaper.com",
+      "name": "pastelwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "patterndesignlab.com",
+      "name": "patterndesignlab.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "philliperomano.com",
+      "name": "philliperomano.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "saloonwallpaper.com",
+      "name": "saloonwallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "steveabramsdesigns.com",
+      "name": "steveabramsdesigns.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentdebtattorney.com",
+      "name": "studentdebtattorney.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentdebtcrisis.com",
+      "name": "studentdebtcrisis.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentdebtcrisis.org",
+      "name": "studentdebtcrisis.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentdebtlawyers.com",
+      "name": "studentdebtlawyers.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentdebtlawyers.org",
+      "name": "studentdebtlawyers.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentloanvoter.com",
+      "name": "studentloanvoter.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "studentstrikes.org",
+      "name": "studentstrikes.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "summergamesrentals.com",
+      "name": "summergamesrentals.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "textilewallpaper.com",
+      "name": "textilewallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "theatrewallpaper.com",
+      "name": "theatrewallpaper.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "thecannabisnews.com",
+      "name": "thecannabisnews.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "thedesignerlaboratory.com",
+      "name": "thedesignerlaboratory.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "thedesignerlibrary.com",
+      "name": "thedesignerlibrary.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "thesalesreps.com",
+      "name": "thesalesreps.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "traditionalwhimsy.com",
+      "name": "traditionalwhimsy.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "vintagebh.com",
+      "name": "vintagebh.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "wallco.ai",
+      "name": "wallco.ai",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "wallpapersback.com",
+      "name": "wallpapersback.com",
+      "kind": "wallcovering",
+      "source": null
+    },
+    {
+      "domain": "wholivedthere.com",
+      "name": "wholivedthere.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "wintergamesrentals.com",
+      "name": "wintergamesrentals.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "withoutstudentdebt.com",
+      "name": "withoutstudentdebt.com",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "withoutstudentdebt.org",
+      "name": "withoutstudentdebt.org",
+      "kind": "other",
+      "source": null
+    },
+    {
+      "domain": "zayde.ai",
+      "name": "zayde.ai",
+      "kind": "other",
+      "source": null
+    }
+  ]
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 480b5c0..2defb25 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,181 +8,339 @@
   <style>
     :root {
       --bg: #0e0d0b;
+      --panel: #14130f;
       --card: #19181640;
       --rule: #2a2825;
+      --rule-strong: #3a3631;
       --fg: #f3ecdd;
       --muted: #9d9586;
+      --muted-dim: #6e665a;
       --accent: #d9b14e;
+      --accent-dim: #8a6f30;
+      --selected: #2a2415;
       --font-display: 'Playfair Display', serif;
       --font-ui: 'Inter', system-ui, sans-serif;
-      --rad: 14px;
-      --slot-gap: 18px;
+      --rad: 12px;
+      --rail-w: 240px;
+      --moodboard-h: 200px;
     }
     * { box-sizing: border-box; }
-    html, body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--font-ui); }
-    body { min-height: 100vh; }
+    html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font-ui); }
+    body { display: flex; flex-direction: column; min-height: 100vh; }
+
+    /* ─ Header ─ */
     header.top {
       display: flex; align-items: center; justify-content: space-between;
-      padding: 14px 22px; border-bottom: 1px solid var(--rule);
+      padding: 12px 20px; border-bottom: 1px solid var(--rule);
       background: linear-gradient(180deg, #1c1a17 0%, #14130f 100%);
+      flex: 0 0 auto;
     }
-    header.top h1 {
-      font-family: var(--font-display); margin: 0;
-      font-size: 1.45rem; letter-spacing: 0.01em;
-      color: var(--accent);
-    }
-    header.top .sub { color: var(--muted); font-size: 0.82rem; letter-spacing: 0.06em; text-transform: uppercase; }
+    header.top h1 { font-family: var(--font-display); margin: 0; font-size: 1.4rem; color: var(--accent); }
+    header.top .sub { color: var(--muted); font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; }
     header .save-status { color: var(--muted); font-size: 0.75rem; }
     header .save-status.flash { color: var(--accent); }
+    .action-btn {
+      background: transparent; color: var(--fg); border: 1px solid var(--rule);
+      padding: 6px 12px; border-radius: 8px; cursor: pointer; font: 600 0.75rem var(--font-ui);
+      letter-spacing: 0.05em;
+    }
+    .action-btn:hover { border-color: var(--accent); color: var(--accent); }
+
+    /* ─ Main 3-pane (left rail + splitter + main + bottom moodboard) ─ */
+    .workspace { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
+    .panes { display: flex; flex: 1 1 auto; min-height: 0; }
+
+    /* Left rail — site nav */
+    .rail {
+      width: var(--rail-w); min-width: 180px; max-width: 50vw;
+      background: var(--panel); border-right: 1px solid var(--rule);
+      display: flex; flex-direction: column; flex: 0 0 auto; min-height: 0;
+    }
+    .rail h2 {
+      font-family: var(--font-display); margin: 0; font-size: 0.95rem;
+      letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted);
+      padding: 14px 16px 8px;
+    }
+    .rail .site-filter {
+      margin: 0 12px 8px; padding: 7px 10px; background: #100f0d; color: var(--fg);
+      border: 1px solid var(--rule); border-radius: 8px; font: 0.84rem var(--font-ui);
+    }
+    .rail .site-filter:focus { outline: 0; border-color: var(--accent-dim); }
+    .rail .meta { color: var(--muted-dim); font-size: 0.7rem; padding: 0 16px 6px; }
+    .site-list { overflow-y: auto; flex: 1 1 auto; padding: 0 6px 12px; }
+    .site-item {
+      display: flex; align-items: center; gap: 8px;
+      padding: 7px 10px; border-radius: 8px; cursor: pointer;
+      font: 0.84rem var(--font-ui); color: var(--fg);
+      border-left: 2px solid transparent;
+      margin: 1px 0;
+    }
+    .site-item:hover { background: #1a1815; }
+    .site-item.active { background: var(--selected); border-left-color: var(--accent); }
+    .site-item .dot {
+      width: 7px; height: 7px; border-radius: 999px;
+      background: var(--rule-strong); flex: 0 0 7px;
+    }
+    .site-item.wired .dot { background: var(--accent); }
+    .site-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; }
+    .site-item .kind {
+      font-size: 0.62rem; color: var(--muted-dim);
+      letter-spacing: 0.06em; text-transform: uppercase;
+    }
+
+    /* Splitter — drag to resize left rail */
+    .splitter {
+      width: 6px; flex: 0 0 6px; cursor: col-resize;
+      background: var(--bg); border-right: 1px solid var(--rule);
+      transition: background 0.12s ease;
+    }
+    .splitter:hover, .splitter.dragging { background: var(--accent-dim); }
 
-    .app { display: grid; grid-template-columns: 320px 1fr; gap: 18px; padding: 18px; min-height: calc(100vh - 60px); }
+    /* Main — search + grid */
+    .main {
+      flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column;
+      background: var(--bg);
+    }
+    .search-bar {
+      display: flex; align-items: center; gap: 12px;
+      padding: 14px 22px; border-bottom: 1px solid var(--rule);
+      background: var(--panel);
+    }
+    .search-bar .search-icon { color: var(--muted); font-size: 1.1rem; }
+    .search-bar input[type=search] {
+      flex: 1 1 auto; background: #100f0d; color: var(--fg);
+      border: 1px solid var(--rule); border-radius: 10px;
+      padding: 10px 14px; font: 0.95rem var(--font-ui);
+    }
+    .search-bar input[type=search]:focus { outline: 0; border-color: var(--accent-dim); }
+    .search-bar .count { color: var(--muted); font-size: 0.78rem; min-width: 110px; text-align: right; }
+    .search-bar .source-label {
+      color: var(--accent); font: 600 0.78rem var(--font-ui); letter-spacing: 0.05em;
+      text-transform: uppercase; padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
+      max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+    }
 
-    /* Rail — draggable tile catalog */
-    aside.rail {
-      background: var(--card); border: 1px solid var(--rule); border-radius: var(--rad);
-      padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0;
-    }
-    aside.rail h2 { font-family: var(--font-display); margin: 0 0 4px; font-size: 1.05rem; }
-    aside.rail .controls { display: flex; flex-direction: column; gap: 6px; }
-    aside.rail input, aside.rail select {
-      background: #100f0d; color: var(--fg); border: 1px solid var(--rule); border-radius: 8px;
-      padding: 7px 10px; font: 0.85rem var(--font-ui);
-    }
-    aside.rail .meta { color: var(--muted); font-size: 0.72rem; }
-    .tile-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; overflow-y: auto; padding-right: 4px; flex: 1 1 auto; }
-    .tile {
-      aspect-ratio: 1/1.15; background: #100f0d; border: 1px solid var(--rule); border-radius: 10px;
-      overflow: hidden; cursor: grab; position: relative; user-select: none;
+    .grid-scroll { flex: 1 1 auto; overflow-y: auto; padding: 18px 22px 22px; }
+    .grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+      gap: 14px;
+    }
+    .card {
+      aspect-ratio: 1/1.15; background: #100f0d; border: 1px solid var(--rule);
+      border-radius: var(--rad); overflow: hidden; position: relative; cursor: grab;
       transition: border-color 0.15s ease, transform 0.15s ease;
     }
-    .tile:active { cursor: grabbing; }
-    .tile.dragging { opacity: 0.4; }
-    .tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
-    .tile .tile-label {
-      position: absolute; left: 0; right: 0; bottom: 0; padding: 5px 7px;
-      font: 600 0.65rem var(--font-ui); color: #fff; line-height: 1.1;
+    .card:active { cursor: grabbing; }
+    .card:hover { border-color: var(--accent-dim); transform: translateY(-1px); }
+    .card.dragging { opacity: 0.4; }
+    .card img { width: 100%; height: 100%; object-fit: cover; display: block; }
+    .card .card-label {
+      position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 9px;
+      font: 600 0.7rem var(--font-ui); color: #fff;
       background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
-      letter-spacing: 0.02em; text-transform: uppercase;
+      letter-spacing: 0.02em;
+    }
+    .card .card-label .by { color: rgba(255,255,255,0.65); font-weight: 400; font-size: 0.62rem; display: block; margin-top: 1px; }
+
+    .empty-state {
+      padding: 60px 30px; text-align: center; color: var(--muted);
+      max-width: 480px; margin: 0 auto;
     }
+    .empty-state h3 { font-family: var(--font-display); color: var(--fg); margin: 0 0 8px; }
+    .empty-state .lead { font-size: 0.92rem; line-height: 1.5; }
+    .empty-state .domain { color: var(--accent); font-weight: 600; }
+
+    /* Moodboard dock at bottom */
+    .moodboard {
+      flex: 0 0 auto; min-height: var(--moodboard-h);
+      border-top: 1px solid var(--rule); background: var(--panel);
+      display: flex; flex-direction: column;
+    }
+    .moodboard.collapsed { min-height: 38px; }
+    .moodboard-header {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 8px 18px; border-bottom: 1px solid var(--rule);
+      cursor: pointer; user-select: none;
+    }
+    .moodboard-header h2 {
+      font-family: var(--font-display); margin: 0; font-size: 0.95rem;
+      letter-spacing: 0.04em; color: var(--accent);
+    }
+    .moodboard-header .toggle {
+      color: var(--muted); font-size: 0.8rem; transition: transform 0.18s ease;
+    }
+    .moodboard.collapsed .toggle { transform: rotate(180deg); }
+    .moodboard-body {
+      display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
+      padding: 12px 18px 14px; flex: 1 1 auto;
+    }
+    .moodboard.collapsed .moodboard-body { display: none; }
 
-    /* 2×2 board */
-    main.board { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: var(--slot-gap); min-height: 0; }
     .slot {
-      position: relative; background: #131210; border: 2px dashed var(--rule); border-radius: var(--rad);
-      overflow: hidden; min-height: 240px; transition: border-color 0.15s ease, background 0.15s ease;
+      position: relative; background: #131210; border: 2px dashed var(--rule); border-radius: 10px;
+      overflow: hidden; min-height: 150px;
+      transition: border-color 0.15s ease;
     }
     .slot.empty::after {
       content: attr(data-empty-label); position: absolute; inset: 0;
       display: flex; align-items: center; justify-content: center;
-      color: var(--muted); font: 600 0.85rem var(--font-ui); letter-spacing: 0.16em; text-transform: uppercase;
+      color: var(--muted-dim); font: 700 1.4rem var(--font-display);
       pointer-events: none;
     }
-    .slot.over { border-color: var(--accent); background: #1a1714; }
+    .slot.over { border-color: var(--accent); }
     .slot.filled { border-style: solid; border-color: var(--rule); cursor: grab; }
     .slot.filled:active { cursor: grabbing; }
     .slot.dragging-source { opacity: 0.5; }
     .slot img { width: 100%; height: 100%; object-fit: cover; display: block; }
     .slot .badge {
-      position: absolute; top: 8px; left: 8px;
-      background: rgba(20, 18, 15, 0.85); color: var(--accent);
-      font: 700 0.65rem var(--font-ui); letter-spacing: 0.1em;
-      padding: 3px 8px; border-radius: 999px; backdrop-filter: blur(6px);
+      position: absolute; top: 6px; left: 6px; background: rgba(20,18,15,0.85);
+      color: var(--accent); font: 700 0.6rem var(--font-ui); letter-spacing: 0.1em;
+      padding: 2px 7px; border-radius: 999px;
     }
     .slot .info {
-      position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px;
-      background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.85) 100%);
-      font-family: var(--font-display); color: #fff; font-size: 0.95rem;
+      position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
+      font: 0.72rem var(--font-ui); color: #fff;
+      background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.82) 100%);
     }
-    .slot .info .by { font: 0.72rem var(--font-ui); color: var(--muted); margin-top: 2px; }
     .slot .clear {
-      position: absolute; top: 8px; right: 8px;
-      background: rgba(20, 18, 15, 0.85); color: var(--fg); border: 1px solid var(--rule);
-      width: 26px; height: 26px; border-radius: 999px; cursor: pointer;
-      display: none; align-items: center; justify-content: center; font-size: 1rem; line-height: 1;
+      position: absolute; top: 6px; right: 6px;
+      background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
+      width: 22px; height: 22px; border-radius: 999px; cursor: pointer;
+      display: none; align-items: center; justify-content: center; font-size: 0.9rem; line-height: 1;
     }
     .slot.filled .clear { display: flex; }
-    .slot .clear:hover { background: #d44; color: #fff; }
+    .slot .clear:hover { background: #c54; color: #fff; }
 
     @media (max-width: 880px) {
-      .app { grid-template-columns: 1fr; padding: 12px; gap: 14px; }
-      main.board { grid-template-columns: 1fr 1fr; }
-      aside.rail { max-height: 50vh; }
-    }
-
-    button.action {
-      background: transparent; color: var(--fg); border: 1px solid var(--rule);
-      padding: 7px 14px; border-radius: 8px; cursor: pointer; font: 600 0.78rem var(--font-ui);
-      letter-spacing: 0.05em;
+      .panes { flex-direction: column; }
+      .rail { width: 100% !important; max-width: none; max-height: 36vh; }
+      .splitter { display: none; }
+      .search-bar { padding: 10px 14px; }
+      .search-bar .count { display: none; }
+      .grid-scroll { padding: 12px 14px; }
+      .moodboard-body { grid-template-columns: repeat(2, 1fr); }
     }
-    button.action:hover { border-color: var(--accent); color: var(--accent); }
   </style>
 </head>
 <body>
   <header class="top">
     <div>
       <h1>4Square</h1>
-      <div class="sub">Agent Abrams · drag a piece into a square</div>
+      <div class="sub">Agent Abrams · pick a site → drag products into the moodboard</div>
     </div>
     <div style="display:flex;align-items:center;gap:14px">
-      <span class="save-status" id="save-status">layout autosaves to this browser</span>
-      <button class="action" id="reset-all" title="Clear all four squares">Reset all</button>
+      <span class="save-status" id="save-status">moodboard autosaves to this browser</span>
+      <button class="action-btn" id="reset-all" title="Clear all four moodboard squares">Reset moodboard</button>
     </div>
   </header>
 
-  <div class="app">
-    <aside class="rail">
-      <h2>Catalog</h2>
-      <div class="controls">
-        <input id="q" type="search" placeholder="Search title, artist, era…" autocomplete="off">
-        <select id="artist"><option value="">All artists</option></select>
-      </div>
-      <div class="meta" id="rail-meta">loading…</div>
-      <div class="tile-list" id="tiles"></div>
-    </aside>
+  <div class="workspace">
+    <div class="panes">
+      <aside class="rail" id="rail">
+        <h2>Sites</h2>
+        <input class="site-filter" id="site-filter" type="search" placeholder="Filter domains…" autocomplete="off">
+        <div class="meta" id="rail-meta">loading…</div>
+        <div class="site-list" id="site-list"></div>
+      </aside>
 
-    <main class="board" id="board">
-      <div class="slot empty" data-slot="1" data-empty-label="1"></div>
-      <div class="slot empty" data-slot="2" data-empty-label="2"></div>
-      <div class="slot empty" data-slot="3" data-empty-label="3"></div>
-      <div class="slot empty" data-slot="4" data-empty-label="4"></div>
-    </main>
+      <div class="splitter" id="splitter" aria-label="Drag to resize"></div>
+
+      <main class="main">
+        <div class="search-bar">
+          <span class="search-icon" aria-hidden="true">⌕</span>
+          <input id="q" type="search" placeholder="Type anything to bring a product up…" autocomplete="off">
+          <span class="source-label" id="source-label">no site selected</span>
+          <span class="count" id="result-count"></span>
+        </div>
+        <div class="grid-scroll" id="grid-scroll">
+          <div class="grid" id="grid"></div>
+          <div class="empty-state" id="empty-state" style="display:none">
+            <h3>Pick a site from the left rail.</h3>
+            <p class="lead">Wired sites have a yellow dot. Other sites are listed by name and will populate when their catalog API is connected.</p>
+          </div>
+        </div>
+      </main>
+    </div>
+
+    <section class="moodboard" id="moodboard">
+      <div class="moodboard-header" id="moodboard-header">
+        <h2>Moodboard — 4 Squares</h2>
+        <span class="toggle">▾</span>
+      </div>
+      <div class="moodboard-body">
+        <div class="slot empty" data-slot="1" data-empty-label="1"></div>
+        <div class="slot empty" data-slot="2" data-empty-label="2"></div>
+        <div class="slot empty" data-slot="3" data-empty-label="3"></div>
+        <div class="slot empty" data-slot="4" data-empty-label="4"></div>
+      </div>
+    </section>
   </div>
 
   <script>
     const STORAGE_KEY = '4square.agentabrams.layout.v1';
+    const SITES_KEY   = '4square.agentabrams.selectedSite.v1';
+    const RAIL_W_KEY  = '4square.agentabrams.railWidth.v1';
+    const MOOD_KEY    = '4square.agentabrams.moodCollapsed.v1';
 
-    // ── persisted state ──────────────────────────────────────────────
     let layout = loadLayout();
+    let sites = [];
+    let selectedSite = null;
+    let railItems = [];
+    let queryToken = 0;
 
     function loadLayout() {
       try {
         const raw = localStorage.getItem(STORAGE_KEY);
-        if (!raw) return { 1: null, 2: null, 3: null, 4: null };
-        const parsed = JSON.parse(raw);
-        return { 1: parsed[1] || null, 2: parsed[2] || null, 3: parsed[3] || null, 4: parsed[4] || null };
-      } catch { return { 1: null, 2: null, 3: null, 4: null }; }
+        if (!raw) return { 1:null, 2:null, 3:null, 4:null };
+        const p = JSON.parse(raw);
+        return { 1: p[1]||null, 2: p[2]||null, 3: p[3]||null, 4: p[4]||null };
+      } catch { return { 1:null, 2:null, 3:null, 4:null }; }
     }
     function saveLayout() {
-      try {
-        localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
-        flashSaved();
-      } catch (e) {
-        document.getElementById('save-status').textContent = 'autosave failed: ' + e.message;
-      }
+      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); flashSaved(); }
+      catch (e) { document.getElementById('save-status').textContent = 'autosave failed'; }
     }
     function flashSaved() {
       const el = document.getElementById('save-status');
-      el.textContent = 'saved ✓';
-      el.classList.add('flash');
+      el.textContent = 'saved ✓'; el.classList.add('flash');
       clearTimeout(flashSaved._t);
       flashSaved._t = setTimeout(() => {
         el.classList.remove('flash');
-        el.textContent = 'layout autosaves to this browser';
+        el.textContent = 'moodboard autosaves to this browser';
       }, 1200);
     }
+    function escapeHtml(s) {
+      return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
+    }
+
+    // ── Resizable rail ─────────────────────────────────────────────
+    (function rail() {
+      const saved = parseInt(localStorage.getItem(RAIL_W_KEY) || '240', 10);
+      if (saved >= 180 && saved <= 600) document.documentElement.style.setProperty('--rail-w', saved + 'px');
+      const split = document.getElementById('splitter');
+      let dragging = false, startX = 0, startW = 0;
+      split.addEventListener('mousedown', (e) => {
+        dragging = true; split.classList.add('dragging');
+        startX = e.clientX;
+        startW = document.getElementById('rail').getBoundingClientRect().width;
+        e.preventDefault();
+      });
+      window.addEventListener('mousemove', (e) => {
+        if (!dragging) return;
+        const newW = Math.max(180, Math.min(600, startW + (e.clientX - startX)));
+        document.documentElement.style.setProperty('--rail-w', newW + 'px');
+      });
+      window.addEventListener('mouseup', () => {
+        if (!dragging) return;
+        dragging = false; split.classList.remove('dragging');
+        const w = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--rail-w'), 10);
+        localStorage.setItem(RAIL_W_KEY, String(w));
+      });
+    })();
 
-    // ── render slots ─────────────────────────────────────────────────
-    const board = document.getElementById('board');
+    // ── Moodboard ──────────────────────────────────────────────────
+    const board = document.querySelector('.moodboard-body');
     function renderSlots() {
       board.querySelectorAll('.slot').forEach(slot => {
         const n = slot.dataset.slot;
@@ -193,18 +351,11 @@
         slot.setAttribute('draggable', item ? 'true' : 'false');
         if (item) {
           const img = document.createElement('img');
-          img.src = item.imageProxy;
-          img.alt = item.title || '';
-          img.loading = 'lazy';
-          const badge = document.createElement('span');
-          badge.className = 'badge';
-          badge.textContent = '#' + n;
-          const info = document.createElement('div');
-          info.className = 'info';
-          info.innerHTML = '<div>' + escapeHtml(item.title || 'Untitled') + '</div>' +
-            '<div class="by">' + escapeHtml(item.artist || '') + (item.year ? ' · ' + item.year : '') + '</div>';
-          const clear = document.createElement('button');
-          clear.className = 'clear'; clear.title = 'Clear this square'; clear.textContent = '×';
+          img.src = item.imageProxy; img.alt = item.title || ''; img.loading = 'lazy';
+          const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = '#'+n;
+          const info = document.createElement('div'); info.className = 'info';
+          info.innerHTML = escapeHtml(item.title || 'Untitled');
+          const clear = document.createElement('button'); clear.className='clear'; clear.title='Clear'; clear.textContent='×';
           clear.addEventListener('click', (e) => {
             e.stopPropagation();
             layout[n] = null; saveLayout(); renderSlots();
@@ -213,136 +364,175 @@
         }
       });
     }
-
-    function escapeHtml(s) {
-      return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
-    }
-
-    // ── drag/drop wiring ─────────────────────────────────────────────
-    // Tile -> slot OR slot -> slot. Data payload format: { kind: 'tile'|'slot', payload }
     function attachSlotDnD() {
       board.querySelectorAll('.slot').forEach(slot => {
         slot.addEventListener('dragstart', (e) => {
           const n = slot.dataset.slot;
           if (!layout[n]) { e.preventDefault(); return; }
           e.dataTransfer.effectAllowed = 'move';
-          e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind: 'slot', from: n }));
+          e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'slot', from:n }));
           slot.classList.add('dragging-source');
         });
         slot.addEventListener('dragend', () => slot.classList.remove('dragging-source'));
-        slot.addEventListener('dragover', (e) => {
-          e.preventDefault();
-          e.dataTransfer.dropEffect = 'move';
-          slot.classList.add('over');
-        });
+        slot.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect='move'; slot.classList.add('over'); });
         slot.addEventListener('dragleave', () => slot.classList.remove('over'));
         slot.addEventListener('drop', (e) => {
-          e.preventDefault();
-          slot.classList.remove('over');
+          e.preventDefault(); slot.classList.remove('over');
           const raw = e.dataTransfer.getData('application/x-4sq');
           if (!raw) return;
-          let payload;
-          try { payload = JSON.parse(raw); } catch { return; }
+          let payload; try { payload = JSON.parse(raw); } catch { return; }
           const target = slot.dataset.slot;
-          if (payload.kind === 'tile') {
-            layout[target] = payload.item;
-          } else if (payload.kind === 'slot') {
-            const from = payload.from;
-            if (from === target) return;
+          if (payload.kind === 'tile') layout[target] = payload.item;
+          else if (payload.kind === 'slot' && payload.from !== target) {
             const tmp = layout[target];
-            layout[target] = layout[from];
-            layout[from] = tmp;  // swap (tmp may be null = move)
+            layout[target] = layout[payload.from];
+            layout[payload.from] = tmp;
           }
-          saveLayout();
-          renderSlots();
+          saveLayout(); renderSlots();
         });
       });
     }
-    attachSlotDnD();
-    renderSlots();
+    attachSlotDnD(); renderSlots();
+
+    // Moodboard collapse toggle
+    (function moodCollapse() {
+      const mb = document.getElementById('moodboard');
+      const hdr = document.getElementById('moodboard-header');
+      if (localStorage.getItem(MOOD_KEY) === 'true') mb.classList.add('collapsed');
+      hdr.addEventListener('click', () => {
+        mb.classList.toggle('collapsed');
+        localStorage.setItem(MOOD_KEY, mb.classList.contains('collapsed') ? 'true' : 'false');
+      });
+    })();
+    document.getElementById('reset-all').addEventListener('click', () => {
+      if (!confirm('Clear all 4 moodboard squares?')) return;
+      layout = { 1:null, 2:null, 3:null, 4:null };
+      saveLayout(); renderSlots();
+    });
+
+    // ── Sites rail ─────────────────────────────────────────────────
+    const $list = document.getElementById('site-list');
+    const $railMeta = document.getElementById('rail-meta');
+    const $siteFilter = document.getElementById('site-filter');
+
+    async function loadSites() {
+      try {
+        const r = await fetch('/data/sites.json');
+        const j = await r.json();
+        sites = j.sites || [];
+        const wired = sites.filter(s => s.source).length;
+        $railMeta.textContent = `${sites.length} sites · ${wired} wired`;
+        renderSites();
+        // restore selection
+        const saved = localStorage.getItem(SITES_KEY);
+        const initial = sites.find(s => s.domain === saved) || sites.find(s => s.source) || sites[0];
+        if (initial) selectSite(initial);
+      } catch (e) {
+        $railMeta.textContent = 'failed to load sites';
+      }
+    }
 
-    // ── catalog rail ─────────────────────────────────────────────────
+    function renderSites() {
+      const filter = $siteFilter.value.toLowerCase().trim();
+      const matches = filter ? sites.filter(s => s.domain.toLowerCase().includes(filter)) : sites;
+      $list.innerHTML = '';
+      for (const s of matches) {
+        const div = document.createElement('div');
+        div.className = 'site-item' + (s.source ? ' wired' : '') + (selectedSite && selectedSite.domain === s.domain ? ' active' : '');
+        div.title = s.domain + (s.source ? ' (catalog wired)' : ' (catalog pending)');
+        div.innerHTML = `<span class="dot"></span><span class="name">${escapeHtml(s.domain)}</span><span class="kind">${escapeHtml(s.kind)}</span>`;
+        div.addEventListener('click', () => selectSite(s));
+        $list.appendChild(div);
+      }
+    }
+    $siteFilter.addEventListener('input', renderSites);
+
+    function selectSite(s) {
+      selectedSite = s;
+      localStorage.setItem(SITES_KEY, s.domain);
+      document.getElementById('source-label').textContent = s.domain;
+      renderSites();
+      fetchProducts();
+    }
+
+    // ── Product grid ───────────────────────────────────────────────
+    const $grid = document.getElementById('grid');
+    const $count = document.getElementById('result-count');
+    const $empty = document.getElementById('empty-state');
     const $q = document.getElementById('q');
-    const $artist = document.getElementById('artist');
-    const $tiles = document.getElementById('tiles');
-    const $meta = document.getElementById('rail-meta');
 
-    let railItems = [];
-    let lastQueryToken = 0;
-    async function fetchRail() {
-      const token = ++lastQueryToken;
-      $meta.textContent = 'loading…';
+    let qDebounce;
+    $q.addEventListener('input', () => {
+      clearTimeout(qDebounce);
+      qDebounce = setTimeout(fetchProducts, 200);
+    });
+
+    async function fetchProducts() {
+      if (!selectedSite) { $grid.innerHTML = ''; $empty.style.display = ''; return; }
+      $empty.style.display = 'none';
+      const src = selectedSite.source;
+      if (!src) {
+        $grid.innerHTML = '';
+        $empty.innerHTML = `<h3>Catalog connection pending</h3>
+          <p class="lead">Site <span class="domain">${escapeHtml(selectedSite.domain)}</span> is in the directory, but its product catalog isn't wired into 4Square yet. Connect its API to enable browsing here.</p>`;
+        $empty.style.display = '';
+        $count.textContent = '';
+        return;
+      }
+      const token = ++queryToken;
+      $count.textContent = 'loading…';
       const params = new URLSearchParams();
       if ($q.value) params.set('q', $q.value);
-      if ($artist.value) params.set('artist', $artist.value);
-      params.set('limit', '60');
+      params.set('limit', '120');
       try {
-        const r = await fetch('/api/catalog/search?' + params.toString());
+        const r = await fetch(src.endpoint + '?' + params.toString());
         const j = await r.json();
-        if (token !== lastQueryToken) return; // a newer query already fired
+        if (token !== queryToken) return;
         railItems = j.items || [];
-        $meta.textContent = `${railItems.length} of ${(j.total || railItems.length).toLocaleString()} pieces`;
-        renderRail();
-      } catch (e) {
-        $meta.textContent = 'load failed: ' + e.message;
-      }
+        $count.textContent = `${railItems.length} of ${(j.total || railItems.length).toLocaleString()}`;
+        renderGrid();
+      } catch (e) { $count.textContent = 'load failed'; }
     }
-    function renderRail() {
-      $tiles.innerHTML = '';
+
+    function renderGrid() {
+      $grid.innerHTML = '';
+      if (!railItems.length) {
+        $empty.innerHTML = `<h3>No matches</h3><p class="lead">Try a different search term.</p>`;
+        $empty.style.display = '';
+        return;
+      }
+      $empty.style.display = 'none';
       for (const it of railItems) {
         const div = document.createElement('div');
-        div.className = 'tile';
+        div.className = 'card';
         div.draggable = true;
         const imageProxy = '/img?path=' + encodeURIComponent(it.image_url || it.image || '');
-        const lite = {
-          id: it.id, title: it.title, artist: it.artist, year: it.year, era: it.era,
-          imageProxy,
-        };
+        const lite = { id: it.id, title: it.title, artist: it.artist, year: it.year, era: it.era, imageProxy };
         div.dataset.item = JSON.stringify(lite);
         const img = document.createElement('img');
-        img.src = imageProxy;
-        img.loading = 'lazy';
-        img.alt = it.title || '';
+        img.src = imageProxy; img.loading = 'lazy'; img.alt = it.title || '';
         const label = document.createElement('div');
-        label.className = 'tile-label';
-        label.textContent = it.artist || it.title || '—';
+        label.className = 'card-label';
+        label.innerHTML = `${escapeHtml(it.title || 'Untitled')}<span class="by">${escapeHtml(it.artist || '')}${it.year ? ' · ' + it.year : ''}</span>`;
         div.append(img, label);
         div.addEventListener('dragstart', (e) => {
           e.dataTransfer.effectAllowed = 'copy';
-          e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind: 'tile', item: lite }));
+          e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'tile', item: lite }));
           div.classList.add('dragging');
         });
         div.addEventListener('dragend', () => div.classList.remove('dragging'));
-        // Click-to-place fallback for touch / accessibility: place into first empty slot.
+        // Double-click = place into first empty slot
         div.addEventListener('dblclick', () => {
           for (const n of ['1','2','3','4']) {
             if (!layout[n]) { layout[n] = lite; saveLayout(); renderSlots(); return; }
           }
         });
-        $tiles.appendChild(div);
+        $grid.appendChild(div);
       }
     }
 
-    async function fetchArtists() {
-      try {
-        const r = await fetch('/api/catalog/artists');
-        const j = await r.json();
-        const opts = (j.artists || []).map(a => `<option value="${escapeHtml(a)}">${escapeHtml(a)}</option>`).join('');
-        $artist.insertAdjacentHTML('beforeend', opts);
-      } catch {}
-    }
-    let qDebounce;
-    $q.addEventListener('input', () => { clearTimeout(qDebounce); qDebounce = setTimeout(fetchRail, 220); });
-    $artist.addEventListener('change', fetchRail);
-
-    document.getElementById('reset-all').addEventListener('click', () => {
-      if (!confirm('Clear all 4 squares?')) return;
-      layout = { 1: null, 2: null, 3: null, 4: null };
-      saveLayout(); renderSlots();
-    });
-
-    fetchArtists();
-    fetchRail();
+    // ── boot ───────────────────────────────────────────────────────
+    loadSites();
   </script>
 </body>
 </html>

← 57645db scaffold 4square design board  ·  back to 4square Agentabrams  ·  wire 19 more DW microsites — click → real products, not 'pen a00276f →