← 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
A pending-approval/tres-tintas-designer-descriptions-pilot.mdA shopify/_cwGRID/sections/collection-filters-horizontal.liquidA shopify/_cwGRID/sections/collection-toolbar.liquidM shopify/_cwGRID/sections/collection.liquidA shopify/push-grid-slider-to-dev-theme.shA shopify/scripts/tres-tintas-designer-descriptions.js
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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ 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 →