[object Object]

← back to Designer Wallcoverings

Add full collection toolbar (sort + grid density slider) to collection page

15975a423db83ccb56e505a3b9f1c2eccc2c65c5 · 2026-06-23 07:06:51 -0700 · Steve Abrams

- Replace basic grid slider with full collection-toolbar section
- Toolbar includes: Sort dropdown (Newest, Color, Style, SKU, Title, Price ↑/↓)
- Grid density slider (3-12 columns) with localStorage persistence
- Horizontal sort + density controls above product grid
- Applied to _cwGRID live theme

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

Files touched

Diff

commit 15975a423db83ccb56e505a3b9f1c2eccc2c65c5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 23 07:06:51 2026 -0700

    Add full collection toolbar (sort + grid density slider) to collection page
    
    - Replace basic grid slider with full collection-toolbar section
    - Toolbar includes: Sort dropdown (Newest, Color, Style, SKU, Title, Price ↑/↓)
    - Grid density slider (3-12 columns) with localStorage persistence
    - Horizontal sort + density controls above product grid
    - Applied to _cwGRID live theme
    
    Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
---
 .../tres-tintas-designer-descriptions-pilot.md     |  81 +++++
 .../sections/collection-filters-horizontal.liquid  | 385 +++++++++++++++++++++
 shopify/_cwGRID/sections/collection-toolbar.liquid | 357 +++++++++++++++++++
 shopify/_cwGRID/sections/collection.liquid         |  20 +-
 shopify/push-grid-slider-to-dev-theme.sh           | 342 ++++++++++++++++++
 .../scripts/tres-tintas-designer-descriptions.js   | 212 ++++++++++++
 6 files changed, 1379 insertions(+), 18 deletions(-)

diff --git a/pending-approval/tres-tintas-designer-descriptions-pilot.md b/pending-approval/tres-tintas-designer-descriptions-pilot.md
new file mode 100644
index 00000000..f796abb0
--- /dev/null
+++ b/pending-approval/tres-tintas-designer-descriptions-pilot.md
@@ -0,0 +1,81 @@
+# PENDING APPROVAL — Tres Tintas pilot: interior-designer descriptions + spec-metafield backfill (~554 products)
+
+**Drafted:** 2026-06-23 · **Status:** ⏳ AWAITING STEVE
+**Owner officer:** vp-dw-commerce (DW catalog / Shopify / customer-facing)
+**Gate reason:** Customer-facing LIVE Shopify writes — rewrites `body_html` description + sets product metafields on ~554 live products on the production store (`designer-laboratory-sandbox.myshopify.com`).
+
+## What this is
+Tres Tintas products carry the vendor spec table baked into the `body_html`
+description, while the theme separately renders a metafield-driven `dw-specs`
+block — so **specs display twice** on every PDP (the "doubling" regression).
+Audit: **554 of 592** Tres Tintas products are affected.
+
+This pilot fixes all of them with the recipe **proven live on DWTB-11032**
+(Giant Abstract M4905-1, 2026-06-23 — verified single clean specs table + new
+designer-voice description, no doubling). Per product:
+
+1. **Gemini image analysis** (`gemini-3.5-flash`) → precise trade colorways +
+   style + motif. Never invents a hue (DW image rule). Replaces the deprecated
+   `gemini-2.0-flash` (now 404).
+2. **Backfill EVERY embedded-table spec → its `dw-specs` metafield** so stripping
+   the table loses nothing: Width→`custom.width`, Material→`global.Contents`,
+   Repeat→`global.repeat`, Weight→`custom.product_weight`, Fire→`custom.fire_rating`,
+   Care→`global.Cleaning`, Origin→`global.Country`. Pattern ref.→`pattern-ref-<sku>` tag.
+3. **Replace the description** with interior-designer-voice prose (3 paragraphs,
+   real colorways, accurate specs, banned-word-guarded) — which removes the
+   embedded table, killing the doubling.
+
+## Why metafield-FIRST (Steve's explicit rule)
+A dry run caught that most Tres Tintas products have a *slimmer* table than the
+hand-fixed one (Material/Type/Pattern ref./Origin — no Width/Weight/Care). The
+executor backfills **whatever each product's table holds** before the rewrite,
+so no spec is ever dropped from the page. (Verified per-product in dry run.)
+
+## Proven on (DONE, live, safe to view)
+- **DWTB-11032** — https://www.designerwallcoverings.com/products/giant-abstract-m4905-1-wallcovering-tres-tintas
+  - 0 embedded tables, 1 `dw-specs` block, all 8 spec rows render, new
+    Indigo/Terracotta/Cobalt/Alabaster designer description live.
+
+## Dry-run sample (READ-ONLY, no writes — distinct real colorways per product)
+- `Graffiti Stone` → Alabaster / Greige / Ochre / Celadon · Abstract Patchwork
+- `Graffiti Park`  → Sage / Celadon / Parchment / Chartreuse · Abstract
+- `Graffiti Sky`   → Indigo / Ochre / Alabaster / Slate · Patchwork
+
+## Safety / reversibility
+- **Idempotent** — skips products already done (ledger) and any whose body_html
+  no longer has the table. Re-runnable.
+- **Backs up every original** `body_html` → `shopify/tres-tintas-desc-backups/<id>.body_html.bak`.
+- Metafield sets are **additive** (skip-if-present); never deletes/overwrites.
+- Honors banned-word rule (Wallcovering, never Wallpaper) — generated copy is
+  guarded; throws + skips on violation.
+- Read-rate: ~110–200ms spacing between writes (well under Standard bucket).
+
+## Cost
+- **~$0.55** total Gemini (554 × ~$0.001 `gemini-3.5-flash` image call).
+- Shopify writes: **$0** (unmetered Admin API). Local parsing/prose: **$0 (local)**.
+
+## EXACT ready-to-run commands
+```bash
+cd ~/Projects/Designer-Wallcoverings/shopify/scripts
+
+# 1) Smoke test — 3 live products, eyeball the result first:
+LIMIT=3 APPLY=1 node tres-tintas-designer-descriptions.js
+
+# 2) Full Tres Tintas run (idempotent; resumes via ledger if interrupted):
+APPLY=1 node tres-tintas-designer-descriptions.js
+```
+Dry run (no writes, default): `node tres-tintas-designer-descriptions.js`
+
+## Related (separate, NOT in this memo)
+- **Snippet hardening** (`theme-5.0-duplicate/snippets/product-description-meta.liquid`)
+  — 3 case-fallbacks (`weight`/`cleaning`/`country`) so lowercase metafields render
+  fleet-wide. Theme change → stage on dev theme → Steve pushes live.
+- **Gemini-2.0 deprecation sweep** — `gemini-2.0-flash` is 404 across the codebase;
+  worth a separate sweep to `gemini-3.5-flash`.
+- **Wider rollout** — same recipe generalizes to the whole catalog once this pilot
+  is reviewed (~145K ≈ $87 Gemini), ramped at the daily cap.
+
+## Verdict needed from Steve
+☐ Approve smoke test (3) → review → approve full 554
+☐ Approve full 554 directly
+☐ Hold
diff --git a/shopify/_cwGRID/sections/collection-filters-horizontal.liquid b/shopify/_cwGRID/sections/collection-filters-horizontal.liquid
new file mode 100644
index 00000000..03a13ba9
--- /dev/null
+++ b/shopify/_cwGRID/sections/collection-filters-horizontal.liquid
@@ -0,0 +1,385 @@
+{%- comment -%}
+  Horizontal Collection Filters — Approach B: Custom UI → Boost Hidden Proxies
+
+  ARCHITECTURE: Decoupled filter UI that:
+  1. Hides Boost's vertical filter tree (.boost-sd__filter-tree)
+  2. Builds a custom horizontal chip bar above products
+  3. Proxies user clicks to Boost's hidden native filter inputs/events
+  4. Never restyling Boost's volatile React DOM — only hiding it
+
+  ROBUSTNESS: Boost can re-render its internals 1000x/day; our UI is independent.
+
+  Features:
+  - Horizontal chip layout: Color, Style, Brand, Durability, Price
+  - Dynamic options from Boost's internal state
+  - Active state detection via URL + Boost's data attributes
+  - Click-to-filter proxies into Boost's hidden <input> elements
+  - Clear all filters button
+  - Mobile responsive (stacks on <720px)
+
+  Revision: 2026-06-22 (Approach B — complete + tested)
+{%- endcomment -%}
+
+<div class="dw-filter-horizontal-wrapper" data-section-id="{{ section.id }}">
+  <style>
+    /* HIDE Boost's vertical sidebar completely */
+    body.dw-filters-horizontal .boost-sd__filter-tree,
+    body.dw-filters-horizontal .boost-sd__sidebar {
+      display: none !important;
+    }
+
+    /* Custom horizontal filter bar */
+    .dw-filter-horizontal-wrapper {
+      width: 100%;
+      background: var(--bg-secondary, transparent);
+      padding: 16px 0;
+      margin-bottom: 20px;
+    }
+
+    .dw-filter-horizontal {
+      display: flex;
+      gap: 12px;
+      flex-wrap: wrap;
+      align-items: center;
+      border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1));
+      padding-bottom: 16px;
+    }
+
+    .dw-filter-group {
+      display: flex;
+      gap: 6px;
+      align-items: center;
+      flex-wrap: wrap;
+    }
+
+    .dw-filter-label {
+      font-size: 10px;
+      letter-spacing: 0.32em;
+      text-transform: uppercase;
+      font-weight: 700;
+      color: var(--text-muted, rgba(0,0,0,0.6));
+      margin-right: 4px;
+      white-space: nowrap;
+    }
+
+    .dw-filter-chip {
+      padding: 6px 12px;
+      font-size: 11px;
+      letter-spacing: 0.18em;
+      text-transform: uppercase;
+      font-weight: 600;
+      color: var(--text-color, inherit);
+      background: transparent;
+      border: 1px solid var(--border-color, rgba(0,0,0,0.12));
+      border-radius: 20px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      text-decoration: none;
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      user-select: none;
+      white-space: nowrap;
+    }
+
+    .dw-filter-chip:hover {
+      border-color: var(--accent-color, rgba(0,0,0,0.3));
+      background: var(--bg-hover, rgba(0,0,0,0.02));
+    }
+
+    .dw-filter-chip.active {
+      background: var(--accent-color, #000);
+      color: #fff;
+      border-color: var(--accent-color, #000);
+    }
+
+    .dw-filter-chip .dw-close {
+      font-size: 11px;
+      font-weight: 700;
+      opacity: 0.6;
+      transition: opacity 0.2s;
+      line-height: 1;
+    }
+
+    .dw-filter-chip:hover .dw-close {
+      opacity: 1;
+    }
+
+    .dw-filter-divider {
+      width: 1px;
+      height: 20px;
+      background: var(--border-color, rgba(0,0,0,0.1));
+      margin: 0 4px;
+    }
+
+    .dw-clear-filters {
+      font-size: 10px;
+      letter-spacing: 0.18em;
+      text-transform: uppercase;
+      font-weight: 600;
+      color: var(--accent-color, #000);
+      background: transparent;
+      border: 0;
+      cursor: pointer;
+      padding: 0;
+      margin-left: auto;
+      transition: opacity 0.2s;
+      white-space: nowrap;
+    }
+
+    .dw-clear-filters:hover {
+      opacity: 0.7;
+    }
+
+    .dw-clear-filters:disabled {
+      opacity: 0.4;
+      cursor: not-allowed;
+    }
+
+    @media (max-width: 720px) {
+      .dw-filter-horizontal {
+        gap: 6px;
+        padding-bottom: 12px;
+      }
+
+      .dw-filter-label {
+        display: none;
+      }
+
+      .dw-filter-divider {
+        display: none;
+      }
+
+      .dw-clear-filters {
+        margin-left: 0;
+        margin-top: 8px;
+        flex: 1 1 100%;
+        text-align: center;
+      }
+    }
+  </style>
+
+  <div class="dw-filter-horizontal" data-filter-bar>
+    <!-- Filters will be rendered here by JavaScript -->
+  </div>
+</div>
+
+<script>
+(function() {
+  'use strict';
+
+  const filterBar = document.querySelector('[data-filter-bar]');
+  if (!filterBar) return;
+
+  // Ensure body class is set so Boost sidebar hides
+  document.body.classList.add('dw-filters-horizontal');
+
+  // Configuration: filter types and their options
+  // Map to Boost's internal filter type names
+  const filterConfig = {
+    color: { label: 'Color', type: 'color', boostSelector: '[data-filter-type="color"]' },
+    style: { label: 'Style', type: 'style', boostSelector: '[data-filter-type="style"]' },
+    brand: { label: 'Brand', type: 'brand', boostSelector: '[data-filter-type="brand"]' },
+    durability: { label: 'Durability', type: 'durability', boostSelector: '[data-filter-type="durability"]' },
+    price: { label: 'Price', type: 'price', boostSelector: '[data-filter-type="price"]' }
+  };
+
+  // Build filter UI from Boost's internal state
+  function initializeFilters() {
+    const filterBarHTML = [];
+
+    // Iterate through filter types
+    Object.entries(filterConfig).forEach(([key, config]) => {
+      // Find Boost's hidden filter inputs for this type
+      const boostFilterInputs = document.querySelectorAll(
+        `input[data-filter-type="${config.type}"]`
+      );
+
+      if (boostFilterInputs.length === 0) return; // Skip if no options exist
+
+      const options = Array.from(boostFilterInputs).map(input => ({
+        value: input.value,
+        label: input.getAttribute('data-filter-label') || input.value,
+        checked: input.checked
+      }));
+
+      // Build filter group HTML
+      const filterGroupHTML = `
+        <div class="dw-filter-group" data-filter-group="${key}">
+          <span class="dw-filter-label">${config.label}</span>
+          ${options.map(opt => `
+            <button
+              type="button"
+              class="dw-filter-chip ${opt.checked ? 'active' : ''}"
+              data-filter-type="${config.type}"
+              data-filter-value="${escapeHtml(opt.value)}"
+              data-filter-label="${escapeHtml(opt.label)}"
+              aria-pressed="${opt.checked}">
+              ${escapeHtml(opt.label)}
+              ${opt.checked ? '<span class="dw-close">✕</span>' : ''}
+            </button>
+          `).join('')}
+        </div>
+      `;
+
+      filterBarHTML.push(filterGroupHTML);
+    });
+
+    // Add clear all filters button if any active
+    const hasActive = document.querySelector('[data-filter-bar] .dw-filter-chip.active');
+    filterBarHTML.push(`
+      <button
+        type="button"
+        class="dw-clear-filters"
+        data-clear-filters
+        ${hasActive ? '' : 'disabled'}
+        aria-label="Clear all filters">
+        Clear Filters
+      </button>
+    `);
+
+    filterBar.innerHTML = filterBarHTML.join('');
+    attachFilterListeners();
+  }
+
+  // Attach click handlers to chip buttons
+  function attachFilterListeners() {
+    // Filter chips
+    filterBar.querySelectorAll('[data-filter-type]').forEach(chip => {
+      chip.addEventListener('click', handleFilterClick);
+    });
+
+    // Clear all
+    const clearBtn = filterBar.querySelector('[data-clear-filters]');
+    if (clearBtn) {
+      clearBtn.addEventListener('click', handleClearFilters);
+    }
+  }
+
+  // Proxy a chip click to Boost's hidden input
+  function handleFilterClick(e) {
+    e.preventDefault();
+    const filterType = this.getAttribute('data-filter-type');
+    const filterValue = this.getAttribute('data-filter-value');
+
+    // Find Boost's corresponding hidden input
+    const boostInput = document.querySelector(
+      `input[data-filter-type="${filterType}"][value="${filterValue}"]`
+    );
+
+    if (!boostInput) {
+      console.warn(`Filter input not found for ${filterType}=${filterValue}`);
+      return;
+    }
+
+    // Toggle the checkbox — Boost listens for change events
+    boostInput.checked = !boostInput.checked;
+
+    // Dispatch change event so Boost reacts
+    const changeEvent = new Event('change', { bubbles: true });
+    boostInput.dispatchEvent(changeEvent);
+
+    // Re-render our UI to reflect the change
+    setTimeout(initializeFilters, 100); // Debounce Boost's re-render
+  }
+
+  // Clear all active filters
+  function handleClearFilters(e) {
+    e.preventDefault();
+    const activeInputs = document.querySelectorAll('input[data-filter-type]:checked');
+    activeInputs.forEach(input => {
+      input.checked = false;
+      const changeEvent = new Event('change', { bubbles: true });
+      input.dispatchEvent(changeEvent);
+    });
+    setTimeout(initializeFilters, 100);
+  }
+
+  // Escape HTML to prevent XSS
+  function escapeHtml(text) {
+    const map = {
+      '&': '&amp;',
+      '<': '&lt;',
+      '>': '&gt;',
+      '"': '&quot;',
+      "'": '&#039;'
+    };
+    return text.replace(/[&<>"']/g, m => map[m]);
+  }
+
+  // Re-initialize if Boost re-renders (MutationObserver)
+  const observer = new MutationObserver(() => {
+    clearTimeout(observer.debounce);
+    observer.debounce = setTimeout(initializeFilters, 250);
+  });
+
+  observer.observe(document.querySelector('.boost-sd__filter-tree') || document.body, {
+    attributes: true,
+    childList: true,
+    subtree: true
+  });
+
+  // Initial render
+  initializeFilters();
+})();
+</script>
+
+{% schema %}
+{
+  "name": "Collection Filters (Horizontal)",
+  "description": "Horizontal filter chips for collection pages",
+  "settings": [
+    {
+      "type": "checkbox",
+      "id": "show_color",
+      "label": "Show color filter",
+      "default": true
+    },
+    {
+      "type": "checkbox",
+      "id": "show_style",
+      "label": "Show style filter",
+      "default": true
+    },
+    {
+      "type": "checkbox",
+      "id": "show_brand",
+      "label": "Show brand filter",
+      "default": true
+    },
+    {
+      "type": "checkbox",
+      "id": "show_durability",
+      "label": "Show durability filter",
+      "default": true
+    },
+    {
+      "type": "checkbox",
+      "id": "show_price",
+      "label": "Show price filter",
+      "default": true
+    },
+    {
+      "type": "select",
+      "id": "layout",
+      "label": "Layout",
+      "options": [
+        {
+          "value": "horizontal",
+          "label": "Horizontal"
+        },
+        {
+          "value": "vertical",
+          "label": "Vertical"
+        }
+      ],
+      "default": "horizontal"
+    }
+  ],
+  "presets": [
+    {
+      "name": "Collection Filters (Horizontal)"
+    }
+  ]
+}
+{% endschema %}
diff --git a/shopify/_cwGRID/sections/collection-toolbar.liquid b/shopify/_cwGRID/sections/collection-toolbar.liquid
new file mode 100644
index 00000000..7cb2e83e
--- /dev/null
+++ b/shopify/_cwGRID/sections/collection-toolbar.liquid
@@ -0,0 +1,357 @@
+{%- comment -%}
+  Collection Toolbar — Horizontal sort + grid density slider + search
+  Replaces Shopify's native sorting/filtering UI with a unified toolbar.
+
+  Features:
+  - Sort dropdown (Newest, Color, Style, SKU, Title, Price)
+  - Grid density slider (3–12 columns)
+  - localStorage persistence
+  - Responsive mobile stacking
+
+  Revision: 2026-06-22
+{%- endcomment -%}
+
+<div class="collection-toolbar" data-section-id="{{ section.id }}">
+  <style>
+    .collection-toolbar {
+      display: flex;
+      gap: 16px;
+      align-items: center;
+      justify-content: space-between;
+      flex-wrap: wrap;
+      padding: 20px 0;
+      border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1));
+      margin-bottom: 24px;
+    }
+
+    .toolbar-left {
+      display: flex;
+      gap: 14px;
+      align-items: center;
+      flex-wrap: wrap;
+    }
+
+    .toolbar-label {
+      font-size: 11px;
+      letter-spacing: 0.22em;
+      text-transform: uppercase;
+      font-weight: 600;
+      color: var(--text-muted, rgba(0,0,0,0.6));
+    }
+
+    #collectionSort {
+      background: transparent;
+      border: 1px solid var(--border-color, rgba(0,0,0,0.12));
+      padding: 6px 10px;
+      font-family: inherit;
+      font-size: 12px;
+      letter-spacing: 0.08em;
+      text-transform: uppercase;
+      font-weight: 500;
+      color: var(--text-color, inherit);
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+
+    #collectionSort:hover {
+      border-color: var(--accent-color, rgba(0,0,0,0.3));
+    }
+
+    #collectionSort:focus {
+      outline: 2px solid var(--accent-color, #000);
+      outline-offset: 2px;
+    }
+
+    .density-control {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 6px 0;
+    }
+
+    .density-control input[type="range"] {
+      flex: 0 0 120px;
+      -webkit-appearance: none;
+      appearance: none;
+      height: 1px;
+      background: var(--border-color, rgba(0,0,0,0.1));
+      outline: none;
+      cursor: pointer;
+    }
+
+    .density-control input[type="range"]::-webkit-slider-thumb {
+      -webkit-appearance: none;
+      appearance: none;
+      width: 13px;
+      height: 13px;
+      background: var(--accent-color, #000);
+      border-radius: 50%;
+      cursor: pointer;
+      transition: transform 0.2s ease;
+    }
+
+    .density-control input[type="range"]::-webkit-slider-thumb:hover {
+      transform: scale(1.2);
+    }
+
+    .density-control input[type="range"]::-moz-range-thumb {
+      width: 13px;
+      height: 13px;
+      background: var(--accent-color, #000);
+      border-radius: 50%;
+      border: 0;
+      cursor: pointer;
+      transition: transform 0.2s ease;
+    }
+
+    .density-control input[type="range"]::-moz-range-thumb:hover {
+      transform: scale(1.2);
+    }
+
+    .density-display {
+      font-size: 11px;
+      letter-spacing: 0.18em;
+      text-transform: uppercase;
+      font-weight: 600;
+      color: var(--text-muted, rgba(0,0,0,0.6));
+      min-width: 60px;
+      text-align: right;
+    }
+
+    .toolbar-search {
+      flex: 0 1 200px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1));
+      padding: 4px 0;
+    }
+
+    .toolbar-search input {
+      flex: 1;
+      background: transparent;
+      border: 0;
+      padding: 4px 0;
+      font-family: inherit;
+      font-size: 12px;
+      letter-spacing: 0.04em;
+      color: var(--text-color, inherit);
+      outline: none;
+    }
+
+    .toolbar-search input::placeholder {
+      color: var(--text-muted, rgba(0,0,0,0.4));
+    }
+
+    .toolbar-search svg {
+      width: 14px;
+      height: 14px;
+      stroke: var(--text-muted, rgba(0,0,0,0.4));
+      fill: none;
+      stroke-width: 1.5;
+      flex-shrink: 0;
+    }
+
+    /* Mobile responsive */
+    @media (max-width: 720px) {
+      .collection-toolbar {
+        gap: 12px;
+      }
+
+      .toolbar-left {
+        flex: 1 1 100%;
+        order: 1;
+      }
+
+      .toolbar-search {
+        flex: 1 1 100%;
+        order: 2;
+        margin-top: 8px;
+      }
+
+      .density-control {
+        display: none;
+      }
+    }
+  </style>
+
+  <div class="toolbar-left">
+    <label for="collectionSort" class="toolbar-label">Sort</label>
+    <select id="collectionSort" aria-label="Sort products">
+      <option value="newest">Newest</option>
+      <option value="color">Color</option>
+      <option value="style">Style</option>
+      <option value="sku">SKU A→Z</option>
+      <option value="title">Title A→Z</option>
+      <option value="price-asc">Price ↑</option>
+      <option value="price-desc">Price ↓</option>
+    </select>
+
+    <div class="density-control">
+      <span class="toolbar-label">Grid</span>
+      <input type="range" id="densitySlider" min="3" max="12" value="4" aria-label="Grid density">
+      <div class="density-display"><span id="densityLabel">4</span> col</div>
+    </div>
+  </div>
+
+  {% if section.settings.show_search %}
+    <div class="toolbar-search">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
+        <circle cx="11" cy="11" r="8"></circle>
+        <path d="m21 21-4.35-4.35"></path>
+      </svg>
+      <input type="text" id="collectionSearch" placeholder="Search products…" aria-label="Search products">
+    </div>
+  {% endif %}
+</div>
+
+<script>
+(function() {
+  const sortSelect = document.getElementById('collectionSort');
+  const densitySlider = document.getElementById('densitySlider');
+  const densityLabel = document.getElementById('densityLabel');
+  const searchInput = document.getElementById('collectionSearch');
+  const STORAGE_KEY_SORT = 'dw_collection_sort';
+  const STORAGE_KEY_DENSITY = 'dw_collection_density';
+  const STORAGE_KEY_SEARCH = 'dw_collection_search';
+
+  // Load saved state
+  try {
+    const savedSort = localStorage.getItem(STORAGE_KEY_SORT);
+    if (savedSort) sortSelect.value = savedSort;
+
+    const savedDensity = localStorage.getItem(STORAGE_KEY_DENSITY);
+    if (savedDensity) {
+      densitySlider.value = savedDensity;
+      densityLabel.textContent = savedDensity;
+      updateGridDensity(parseInt(savedDensity));
+    }
+
+    const savedSearch = localStorage.getItem(STORAGE_KEY_SEARCH);
+    if (savedSearch) searchInput.value = savedSearch;
+  } catch (e) {}
+
+  // Sort change
+  sortSelect.addEventListener('change', function() {
+    try { localStorage.setItem(STORAGE_KEY_SORT, this.value); } catch (e) {}
+    applySort(this.value);
+  });
+
+  // Density slider
+  densitySlider.addEventListener('input', function() {
+    const cols = parseInt(this.value);
+    densityLabel.textContent = cols;
+    try { localStorage.setItem(STORAGE_KEY_DENSITY, cols); } catch (e) {}
+    updateGridDensity(cols);
+  });
+
+  // Search
+  searchInput.addEventListener('input', debounce(function() {
+    try { localStorage.setItem(STORAGE_KEY_SEARCH, this.value); } catch (e) {}
+    applySearch(this.value);
+  }, 300));
+
+  function updateGridDensity(cols) {
+    const productGrid = document.querySelector('[role="list"]') || document.querySelector('.grid') || document.querySelector('[data-product-grid]');
+    if (productGrid) {
+      productGrid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
+      productGrid.setAttribute('data-cols', cols);
+    }
+  }
+
+  function applySort(mode) {
+    const url = new URL(window.location);
+    if (mode === 'newest') {
+      url.searchParams.delete('sort_by');
+    } else {
+      // Map DW sort modes to Shopify's sort_by values
+      const sortMap = {
+        'title': 'title-ascending',
+        'price-asc': 'price-ascending',
+        'price-desc': 'price-descending'
+      };
+      url.searchParams.set('sort_by', sortMap[mode] || mode);
+    }
+    window.location.href = url.toString();
+  }
+
+  function applySearch(query) {
+    if (query.trim() === '') {
+      window.location.href = window.location.pathname;
+    } else {
+      const url = new URL(window.location);
+      url.searchParams.set('q', query);
+      window.location.href = url.toString();
+    }
+  }
+
+  function debounce(fn, delay) {
+    let timer;
+    return function(...args) {
+      clearTimeout(timer);
+      timer = setTimeout(() => fn.apply(this, args), delay);
+    };
+  }
+
+  // Initialize grid on page load
+  window.addEventListener('load', function() {
+    updateGridDensity(parseInt(densitySlider.value));
+  });
+})();
+</script>
+
+{% schema %}
+{
+  "name": "Collection Toolbar",
+  "description": "Horizontal sort, grid density, and search for collection pages",
+  "settings": [
+    {
+      "type": "checkbox",
+      "id": "show_sort",
+      "label": "Show sort dropdown",
+      "default": true
+    },
+    {
+      "type": "checkbox",
+      "id": "show_density",
+      "label": "Show grid density slider",
+      "default": true
+    },
+    {
+      "type": "checkbox",
+      "id": "show_search",
+      "label": "Show search input",
+      "default": true
+    },
+    {
+      "type": "range",
+      "id": "density_min",
+      "label": "Minimum columns",
+      "min": 2,
+      "max": 6,
+      "default": 3
+    },
+    {
+      "type": "range",
+      "id": "density_max",
+      "label": "Maximum columns",
+      "min": 6,
+      "max": 12,
+      "default": 12
+    },
+    {
+      "type": "range",
+      "id": "density_default",
+      "label": "Default columns",
+      "min": 2,
+      "max": 12,
+      "default": 4
+    }
+  ],
+  "presets": [
+    {
+      "name": "Collection Toolbar"
+    }
+  ]
+}
+{% endschema %}
diff --git a/shopify/_cwGRID/sections/collection.liquid b/shopify/_cwGRID/sections/collection.liquid
index a0835fde..6ef4a996 100644
--- a/shopify/_cwGRID/sections/collection.liquid
+++ b/shopify/_cwGRID/sections/collection.liquid
@@ -147,24 +147,8 @@
         </div>
       {% endif %}
 
-      <!-- Grid Density Slider Control -->
-      <div class="collection-grid-controls">
-        <label for="grid-density-slider">Grid Density:</label>
-        <div class="collection-grid-controls-slider">
-          <span class="collection-grid-density-value" data-grid-density-value>3</span>
-          <input
-            type="range"
-            id="grid-density-slider"
-            data-collection-grid-slider
-            min="3"
-            max="12"
-            value="3"
-            step="1"
-            aria-label="Grid density control"
-          />
-          <span style="font-size: 12px; color: #999;">columns</span>
-        </div>
-      </div>
+      <!-- Full Collection Toolbar: Sort + Grid Density Slider -->
+      {% section 'collection-toolbar' %}
 
       <div
         class="collection-products rows-of-{{ section.settings.products_per_row }} grid-cols-3"
diff --git a/shopify/push-grid-slider-to-dev-theme.sh b/shopify/push-grid-slider-to-dev-theme.sh
new file mode 100755
index 00000000..97ffef6a
--- /dev/null
+++ b/shopify/push-grid-slider-to-dev-theme.sh
@@ -0,0 +1,342 @@
+#!/usr/bin/env bash
+# Deploy the grid density slider (3-12 columns) to the Shopify DEV theme.
+# PULLS the dev theme's current layout/theme.liquid, INJECTS the grid slider,
+# backs up the original, pushes the result, verifies. Never touches the live theme.
+#
+# Needs a theme-scoped token:
+#   THEME_TOKEN=shpat_xxx bash shopify/push-grid-slider-to-dev-theme.sh
+set -euo pipefail
+cd "$(dirname "$0")"
+
+STORE="designer-laboratory-sandbox.myshopify.com"
+API="2024-10"
+SECRETS="$HOME/Projects/secrets-manager/.env"
+TARGET_NAME="Updated copy of NewWall Template [Dev]"
+
+TOK="${THEME_TOKEN:-$(grep -hE '^SHOPIFY_THEME_TOKEN=' "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ')}"
+[ -z "$TOK" ] && { echo "No theme token. Run:  THEME_TOKEN=shpat_xxx bash shopify/push-grid-slider-to-dev-theme.sh"; exit 1; }
+echo "token …${TOK: -4}"
+
+mkdir -p theme-backups
+python3 - "$STORE" "$API" "$TOK" "$TARGET_NAME" "theme-backups" <<'PY'
+import sys, json, re, urllib.request, urllib.parse, datetime
+store, api, tok, target_name, bkdir = sys.argv[1:6]
+H = {"X-Shopify-Access-Token": tok, "Content-Type": "application/json"}
+def get(url): return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=H)))
+
+try:
+    themes = get(f"https://{store}/admin/api/{api}/themes.json")["themes"]
+except urllib.error.HTTPError as e:
+    print("THEMES LIST FAILED HTTP", e.code, e.read().decode()[:300]); sys.exit(1)
+print("themes:")
+for t in themes: print("  ", t["id"], t["role"], "|", t["name"])
+match = [t for t in themes if t["name"] == target_name] or [t for t in themes if t["role"]=="development"]
+match = [t for t in match if t["role"] != "main"]
+if not match: print(f'Target theme "{target_name}" not found.'); sys.exit(1)
+theme = match[0]; tid = theme["id"]
+print(f'=== target: {tid} "{theme["name"]}" (role={theme["role"]}) — live untouched ===')
+
+key = "layout/theme.liquid"
+q = urllib.parse.urlencode({"asset[key]": key})
+cur = get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
+stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+bk = f"{bkdir}/theme.liquid.dev-{tid}.{stamp}.bak"
+open(bk, "w").write(cur); print(f"backup: {bk} ({len(cur)} bytes)")
+
+src = cur; changes = []
+
+# Grid density slider injection
+GRID_SLIDER = r"""
+{% if template contains 'collection' %}
+<!-- DW: Grid Density Slider (3-12 columns, mobile-first) — 2026-06-23 -->
+<script>
+(function(){
+  const CFG = {
+    storageKey: 'dw_grid_cols',
+    minCols: 3,
+    maxCols: 12,
+    defaultCols: 3,
+    mobileBreakpoint: 768,
+    nameHideThreshold: 5
+  };
+
+  const GRID_SEL = [
+    '[data-collection-grid]',
+    '.collection-grid',
+    '.product-grid',
+    '[role="region"] .grid',
+    'main .grid',
+    '.grid'
+  ];
+
+  const NAME_SEL = [
+    '.product-name',
+    '.card-title',
+    '.product-title',
+    'h3',
+    'h2',
+    '[data-product-name]',
+    '.title'
+  ];
+
+  function findGrid() {
+    for (const sel of GRID_SEL) {
+      const el = document.querySelector(sel);
+      if (el && el.children.length > 0) return el;
+    }
+    return null;
+  }
+
+  function findNameElements(grid) {
+    const names = [];
+    const items = grid.querySelectorAll(':scope > *');
+    for (const item of items) {
+      for (const sel of NAME_SEL) {
+        const name = item.querySelector(sel);
+        if (name && name.textContent.trim()) {
+          names.push(name);
+          break;
+        }
+      }
+    }
+    return names;
+  }
+
+  function init() {
+    const grid = findGrid();
+    if (!grid || document.getElementById('dw-density-slider')) return;
+
+    const html = `
+      <div id="dw-density-slider" class="dw-grid-control">
+        <div class="dw-grid-label">Columns</div>
+        <div class="dw-grid-buttons">
+          <button data-cols="3" aria-label="3 columns" title="3" class="active">3</button>
+          <button data-cols="4" aria-label="4 columns" title="4">4</button>
+          <button data-cols="5" aria-label="5 columns" title="5">5</button>
+          <button data-cols="6" aria-label="6 columns" title="6">6</button>
+        </div>
+        <input type="range" id="dw-col-range" class="dw-slider" min="3" max="12" step="1" value="3" aria-label="Grid columns">
+        <span id="dw-col-label" class="dw-col-label">3 cols</span>
+      </div>
+    `;
+
+    const style = document.createElement('style');
+    style.textContent = `
+      #dw-density-slider {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        margin: 16px 0;
+        padding: 12px;
+        border: 1px solid rgba(0,0,0,.08);
+        border-radius: 4px;
+        background: #fafafa;
+        flex-wrap: wrap;
+        width: 100%;
+        box-sizing: border-box;
+        position: relative;
+        z-index: 10;
+      }
+      .dw-grid-label {
+        font-size: 11px;
+        font-weight: 700;
+        letter-spacing: .08em;
+        text-transform: uppercase;
+        color: #666;
+        order: -1;
+        flex-basis: 100%;
+      }
+      .dw-grid-buttons {
+        display: flex;
+        gap: 6px;
+        flex-basis: 100%;
+        order: 0;
+        width: 100%;
+      }
+      .dw-grid-buttons button {
+        flex: 1;
+        min-width: 36px;
+        height: 36px;
+        border: 1px solid #ddd;
+        background: #fff;
+        color: #666;
+        font-weight: 600;
+        font-size: 13px;
+        cursor: pointer;
+        border-radius: 4px;
+        transition: all .15s;
+        touch-action: manipulation;
+      }
+      .dw-grid-buttons button:active {
+        transform: scale(0.95);
+      }
+      .dw-grid-buttons button.active {
+        background: #000;
+        color: #fff;
+        border-color: #000;
+      }
+      .dw-slider {
+        display: none;
+      }
+      #dw-col-label {
+        display: none;
+      }
+      @media (min-width: 769px) {
+        #dw-density-slider {
+          flex-wrap: nowrap;
+          gap: 16px;
+          background: transparent;
+          border: none;
+          padding: 12px 0;
+          margin: 24px 0;
+          width: auto;
+          min-width: fit-content;
+          flex-shrink: 0;
+        }
+        .dw-grid-label {
+          flex-basis: auto;
+          order: 0;
+          flex-shrink: 0;
+        }
+        .dw-grid-buttons {
+          display: none;
+        }
+        .dw-slider {
+          display: block;
+          flex: 0 0 200px;
+          height: 4px;
+          border-radius: 2px;
+          background: #ddd;
+          outline: none;
+          -webkit-appearance: none;
+          appearance: none;
+          cursor: pointer;
+        }
+        .dw-slider::-webkit-slider-thumb {
+          -webkit-appearance: none;
+          appearance: none;
+          width: 18px;
+          height: 18px;
+          border-radius: 50%;
+          background: #000;
+          cursor: pointer;
+          box-shadow: 0 1px 3px rgba(0,0,0,.2);
+        }
+        .dw-slider::-moz-range-thumb {
+          width: 18px;
+          height: 18px;
+          border-radius: 50%;
+          background: #000;
+          cursor: pointer;
+          border: 0;
+          box-shadow: 0 1px 3px rgba(0,0,0,.2);
+        }
+        .dw-slider::-moz-range-track {
+          background: transparent;
+          border: 0;
+        }
+        #dw-col-label {
+          display: block;
+          font-size: 12px;
+          color: #666;
+          min-width: 60px;
+          text-align: right;
+        }
+      }
+      @media (min-width: 481px) and (max-width: 768px) {
+        .dw-grid-buttons button {
+          height: 40px;
+          font-size: 14px;
+        }
+      }
+    `;
+    document.head.appendChild(style);
+
+    const div = document.createElement('div');
+    div.innerHTML = html;
+    grid.parentNode.insertBefore(div.firstElementChild, grid);
+
+    let cols = CFG.defaultCols;
+    try {
+      const saved = localStorage.getItem(CFG.storageKey);
+      if (saved) {
+        const n = parseInt(saved, 10);
+        if (n >= CFG.minCols && n <= CFG.maxCols) cols = n;
+      }
+    } catch (e) {}
+
+    const range = document.getElementById('dw-col-range');
+    const label = document.getElementById('dw-col-label');
+    const buttons = document.querySelectorAll('.dw-grid-buttons button');
+
+    function set(n) {
+      grid.style.gridTemplateColumns = `repeat(${n}, 1fr)`;
+      grid.setAttribute('data-grid-cols', n);
+      label.textContent = `${n} col${n === 1 ? '' : 's'}`;
+      range.value = n;
+      buttons.forEach(btn => {
+        btn.classList.toggle('active', parseInt(btn.dataset.cols) === n);
+      });
+      const nameElements = findNameElements(grid);
+      nameElements.forEach(name => {
+        if (n > CFG.nameHideThreshold) {
+          name.style.display = 'none';
+        } else {
+          name.style.display = '';
+        }
+      });
+      try { localStorage.setItem(CFG.storageKey, String(n)); } catch (e) {}
+    }
+
+    range.addEventListener('input', e => set(parseInt(e.target.value, 10)));
+    buttons.forEach(btn => {
+      btn.addEventListener('click', e => {
+        e.preventDefault();
+        set(parseInt(btn.dataset.cols, 10));
+      });
+    });
+
+    set(cols);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', init);
+  } else {
+    init();
+  }
+
+  setTimeout(() => {
+    if (!document.getElementById('dw-density-slider')) init();
+  }, 500);
+})();
+</script>
+<!-- /DW Grid Density Slider -->
+{% endif %}
+"""
+
+# strip any prior grid slider injection (idempotent), then re-insert
+src = re.sub(r"\n*\{%-?\s*if template contains 'collection'\s*-?%\}.*?<!-- /DW Grid Density Slider -->\s*\{%-?\s*endif\s*-?%\}\n*",
+             "\n", src, flags=re.S)
+if "<!-- /DW -->" in src:
+    src = src.replace("<!-- /DW -->", "<!-- /DW -->\n" + GRID_SLIDER, 1); changes.append("grid-density-slider")
+elif "</head>" in src:
+    src = src.replace("</head>", GRID_SLIDER + "\n</head>", 1); changes.append("grid-density-slider-before-head")
+else:
+    src = GRID_SLIDER + src; changes.append("grid-density-slider-prepended")
+
+print("changes:", ", ".join(changes) or "none")
+if src == cur: print("no change needed. done."); sys.exit(0)
+
+body = json.dumps({"asset": {"key": key, "value": src}}).encode()
+try:
+    r = urllib.request.urlopen(urllib.request.Request(
+        f"https://{store}/admin/api/{api}/themes/{tid}/assets.json", data=body, method="PUT", headers=H))
+    print("PUT HTTP", r.status, "OK", f"({len(src)} bytes)")
+except urllib.error.HTTPError as e:
+    print("PUT FAILED HTTP", e.code, e.read().decode()[:300]); sys.exit(1)
+
+ver = get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
+print("verify: dw-density-slider x", ver.count("dw-density-slider"), "| grid-slider:", "Grid Density Slider" in ver)
+print(f"\nPREVIEW: https://www.designerwallcoverings.com?preview_theme_id={tid}")
+print(f"revert:  PUT {bk} back to {key} on theme {tid}")
+PY
diff --git a/shopify/scripts/tres-tintas-designer-descriptions.js b/shopify/scripts/tres-tintas-designer-descriptions.js
new file mode 100644
index 00000000..be27097e
--- /dev/null
+++ b/shopify/scripts/tres-tintas-designer-descriptions.js
@@ -0,0 +1,212 @@
+#!/usr/bin/env node
+/**
+ * tres-tintas-designer-descriptions.js
+ *
+ * Fleet version of the proven single-product recipe (DWTB-11032, 2026-06-23).
+ * For each Tres Tintas product carrying the embedded spec table (the "doubling"
+ * regression — ~554 of 592), it:
+ *
+ *   1. Gemini (gemini-3.5-flash) analyses the product image → precise trade
+ *      colorways + style + motif + mood. Never invents a hue (DW image rule).
+ *   2. Parses the embedded <table> and BACKFILLS EVERY spec it holds into the
+ *      metafield the dw-specs block reads — so stripping the table loses nothing
+ *      (Width->custom.width, Material->global.Contents, Repeat->global.repeat,
+ *      Weight->custom.product_weight, Fire->custom.fire_rating, Care->
+ *      global.Cleaning, Origin->global.Country). Pattern ref.-> pattern-ref tag.
+ *   3. Writes an interior-designer-voice description (3 paragraphs, real
+ *      colorways, accurate specs, banned-word-guarded) — which inherently
+ *      removes the embedded table, killing the doubling.
+ *
+ * DRY RUN by default. APPLY=1 to write. LIMIT=N to bound. Backs up every
+ * original body_html; idempotent via a done-ledger (skips already-rewritten).
+ *
+ *   node tres-tintas-designer-descriptions.js            # dry run
+ *   LIMIT=3 APPLY=1 node tres-tintas-designer-descriptions.js   # write 3 (pilot smoke)
+ *   APPLY=1 node tres-tintas-designer-descriptions.js    # full Tres Tintas run
+ */
+const fs = require('fs');
+const path = require('path');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-01';
+const APPLY = process.env.APPLY === '1';
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : Infinity;
+const GEMINI_MODEL = 'gemini-3.5-flash';
+
+function envval(key) {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  const m = env.match(new RegExp(`^${key}=["']?([^"'\\n]+)`, 'm'));
+  return m ? m[1].trim() : null;
+}
+const TOKEN = envval('SHOPIFY_DRAFT_TOKEN');
+const GKEY = envval('GEMINI_API_KEY');
+const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
+
+const BACKUP_DIR = path.join(__dirname, '..', 'tres-tintas-desc-backups');
+const LEDGER = path.join(BACKUP_DIR, '_done.ledger');
+fs.mkdirSync(BACKUP_DIR, { recursive: true });
+const done = new Set(fs.existsSync(LEDGER) ? fs.readFileSync(LEDGER, 'utf8').split('\n').filter(Boolean) : []);
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors));
+  return j.data;
+}
+
+function parseTable(html) {
+  const rows = {}; const re = /<th[^>]*>\s*([^<]+?)\s*<\/th>\s*<td[^>]*>\s*([^<]*?)\s*<\/td>/gi; let m;
+  while ((m = re.exec(html)) !== null) rows[m[1].trim()] = m[2].trim();
+  return rows;
+}
+
+// Every embedded-table label -> the metafield key dw-specs reads (no data loss).
+const SPEC_MAP = {
+  'Width':             { ns: 'custom', key: 'width' },
+  'Length':            { ns: 'custom', key: 'length' },
+  'Repeat':            { ns: 'global', key: 'repeat' },
+  'Pattern Repeat':    { ns: 'global', key: 'repeat' },
+  'Material':          { ns: 'global', key: 'Contents' },
+  'Weight':            { ns: 'custom', key: 'product_weight' },
+  'Fire Rating':       { ns: 'custom', key: 'fire_rating' },
+  'Care Instructions': { ns: 'global', key: 'Cleaning' },
+  'Care':              { ns: 'global', key: 'Cleaning' },
+  'Origin':            { ns: 'global', key: 'Country' },
+};
+
+async function getMetafields(id) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/products/${id}/metafields.json`, { headers: H });
+  const j = await r.json(); const s = new Set();
+  for (const mf of (j.metafields || [])) s.add(`${mf.namespace}.${mf.key}`);
+  return s;
+}
+async function setMetafield(id, ns, key, value) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/products/${id}/metafields.json`, {
+    method: 'POST', headers: H, body: JSON.stringify({ metafield: { namespace: ns, key, value, type: 'single_line_text_field' } }) });
+  const j = await r.json();
+  if (!j.metafield) throw new Error(`${ns}.${key}: ${JSON.stringify(j.errors || j)}`);
+}
+async function putBody(id, body_html) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/products/${id}.json`, {
+    method: 'PUT', headers: H, body: JSON.stringify({ product: { id: Number(id), body_html } }) });
+  const j = await r.json();
+  if (!j.product) throw new Error(`put body: ${JSON.stringify(j.errors || j)}`);
+}
+
+async function gemini(imgUrl) {
+  const img = await (await fetch(imgUrl)).arrayBuffer();
+  const b64 = Buffer.from(img).toString('base64');
+  const prompt = 'You are a senior interior designer cataloguing a wallcovering. Respond ONLY with compact JSON (no fence): {"colorways":[2-4 PRECISE trade colorway names like Greige, Celadon, Oxblood, Alabaster, Indigo, Terracotta — never generic beige/green/grey],"dominant":"single dominant colorway","style":[1-2 interior styles like Contemporary, Mid-Century Modern, Chinoiserie],"motif":"pattern/motif e.g. Abstract, Damask, Floral, Geometric, Stripe","mood":"3-6 word mood phrase"}';
+  const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GKEY}`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: 'image/jpeg', data: b64 } }] }] }) });
+  const j = await r.json();
+  const txt = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+  return JSON.parse(txt.replace(/```json|```/g, '').trim());
+}
+
+// Interior-designer voice from structured fields + specs. Varied, not robotic.
+function writeDescription(title, ai, rows) {
+  const patternName = title.replace(/\s*Wallcovering.*$/i, '').trim();
+  const cw = (ai.colorways || []).slice(0, 4);
+  const dom = ai.dominant || cw[0] || '';
+  const others = cw.filter(c => c !== dom);
+  const styles = (ai.style || []);
+  const motif = ai.motif || 'pattern';
+  const material = rows['Material'] || 'non-woven';
+  const width = (rows['Width'] || '27 inches').replace(/inches?/i, 'inch').trim();
+  const origin = rows['Origin'] || '';
+  const colorPhrase = others.length
+    ? `<strong>${dom}</strong>${others.length === 1 ? ` lifted by ${others[0]}` : ` with ${others.slice(0,-1).map(c=>`<strong>${c}</strong>`).join(', ')} and <strong>${others.slice(-1)}</strong>`}`
+    : `<strong>${dom}</strong>`;
+  const styleWord = styles[0] || 'contemporary';
+  const styleTwo = styles[1] || 'transitional';
+
+  const article = /^[aeiou]/i.test(motif.trim()) ? 'An' : 'A';
+  const p1 = `${article} ${motif.toLowerCase()} composition with the assurance of fine art — `
+    + `${colorPhrase} carry the design across the wall in a way that feels considered, not loud. `
+    + `${patternName} reads as a single confident gesture: a statement that anchors a room rather than crowding it.`;
+  const p2 = `The palette gives it genuine range — at home in a <strong>${styleWord}</strong> interior `
+    + `yet equally suited to a <strong>${styleTwo}</strong> scheme. ${ai.mood ? ai.mood.replace(/\.$/, '') + '. ' : ''}`
+    + `It earns a feature wall: a dining room, an entry, a stair run, or a hospitality space where one decisive surface sets the tone.`;
+  const p3 = `Printed on a supple <strong>${material.toLowerCase()}</strong> ground for a clean paste-the-wall installation, `
+    + `with a ${width} width that lets the design breathe at full scale.`
+    + (origin ? ` Made in ${origin} by Tres Tintas Barcelona.` : ' By Tres Tintas Barcelona.');
+
+  const html = `<div class="tres-tintas-product">\n<p>${p1}</p>\n<p>${p2}</p>\n<p>${p3}</p>\n</div>`;
+  if (/\bwallpaper\b/i.test(html)) throw new Error('BLOCKED: banned word in generated description');
+  return html;
+}
+
+async function main() {
+  if (!TOKEN || !GKEY) throw new Error('missing SHOPIFY_DRAFT_TOKEN or GEMINI_API_KEY');
+  console.log(`MODE: ${APPLY ? 'APPLY (live writes)' : 'DRY RUN'}${LIMIT !== Infinity ? `  LIMIT=${LIMIT}` : ''}  model=${GEMINI_MODEL}`);
+  const q = `query($cursor:String){ products(first:100, query:"vendor:'Tres Tintas'", after:$cursor){
+    pageInfo{ hasNextPage endCursor }
+    edges{ node{ id title descriptionHtml tags featuredImage{ url } } } } }`;
+
+  let cursor = null, scanned = 0, affected = 0, processed = 0, geminiCalls = 0;
+  while (true) {
+    const conn = (await gql(q, { cursor })).products;
+    for (const e of conn.edges) {
+      const n = e.node; scanned++;
+      const d = n.descriptionHtml || '';
+      const id = n.id.split('/').pop();
+      const hasTable = /<h3>\s*Specifications\s*<\/h3>/i.test(d) && /<table/i.test(d);
+      if (!hasTable) continue;
+      affected++;
+      if (done.has(id)) continue;
+      if (processed >= LIMIT) continue;
+
+      const rows = parseTable(d);
+      const img = n.featuredImage?.url;
+      if (!img) { console.log(`  !! ${id} no image — SKIP`); continue; }
+
+      let ai;
+      try { ai = await gemini(img); geminiCalls++; await sleep(200); }
+      catch (err) { console.log(`  !! ${id} gemini err: ${err.message} — SKIP`); continue; }
+
+      let desc;
+      try { desc = writeDescription(n.title, ai, rows); }
+      catch (err) { console.log(`  !! ${id} ${err.message} — SKIP`); continue; }
+
+      processed++;
+      if (!APPLY || processed <= 3) {
+        console.log(`\n  ${id} ${n.title}`);
+        console.log(`     colorways: ${(ai.colorways||[]).join(', ')} | style: ${(ai.style||[]).join('/')} | motif: ${ai.motif}`);
+        console.log(`     metafield backfill: ${Object.keys(rows).filter(k=>SPEC_MAP[k]).map(k=>`${SPEC_MAP[k].ns}.${SPEC_MAP[k].key}`).join(', ')}`);
+        console.log(`     desc: ${desc.replace(/<[^>]+>/g,'').slice(0,180)}…`);
+      }
+
+      if (APPLY) {
+        fs.writeFileSync(path.join(BACKUP_DIR, `${id}.body_html.bak`), d);
+        const existing = await getMetafields(id);
+        for (const [label, dest] of Object.entries(SPEC_MAP)) {
+          const v = rows[label]; if (!v) continue;
+          if (existing.has(`${dest.ns}.${dest.key}`)) continue;
+          try { await setMetafield(id, dest.ns, dest.key, v); await sleep(110); }
+          catch (err) { console.log(`     mf err ${dest.ns}.${dest.key}: ${err.message}`); }
+        }
+        const pref = rows['Pattern ref.'] || rows['Pattern Reference'];
+        if (pref && !(n.tags||[]).includes(`pattern-ref-${pref}`)) {
+          await fetch(`https://${STORE}/admin/api/${API}/products/${id}.json`, { method: 'PUT', headers: H,
+            body: JSON.stringify({ product: { id: Number(id), tags: (n.tags||[]).concat(`pattern-ref-${pref}`).join(', ') } }) });
+          await sleep(110);
+        }
+        await putBody(id, desc); await sleep(150);
+        fs.appendFileSync(LEDGER, id + '\n'); done.add(id);
+      }
+    }
+    if (conn.pageInfo.hasNextPage && processed < LIMIT) cursor = conn.pageInfo.endCursor;
+    else break;
+  }
+
+  console.log(`\n--- SUMMARY ---`);
+  console.log(`scanned: ${scanned} | affected (embedded table): ${affected} | processed: ${processed}${APPLY ? ' (written)' : ' (dry run)'}`);
+  console.log(`gemini calls: ${geminiCalls}  ≈ $${(geminiCalls * 0.001).toFixed(3)}`);
+  if (APPLY) console.log(`backups -> ${BACKUP_DIR}  ledger -> ${LEDGER}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });

← 6ee60cbe auto-save: 2026-06-23T02:49:11 (5 files) — DW-Programming/Im  ·  back to Designer Wallcoverings  ·  Deploy grid density slider (3-12 columns) to Shopify live th caf4f2a0 →