← back to Whatsmystyle
yolo tick 20: Mac1 health watcher (skip drain when >9GB VRAM busy) + floating embed-chip admin-only lower-right + WooCommerce adapter scaffold (empty seed; no known live DTC targets)
59b9a4ed8312ce2bb89d5b60678c4faccd433665 · 2026-05-12 10:28:49 -0700 · SteveStudio2
Files touched
M public/css/app.cssM public/index.htmlM public/js/app.jsM scripts/clothing-apis/brands.jsonM scripts/clothing-apis/index.jsA scripts/clothing-apis/woocommerce.jsM server.js
Diff
commit 59b9a4ed8312ce2bb89d5b60678c4faccd433665
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 10:28:49 2026 -0700
yolo tick 20: Mac1 health watcher (skip drain when >9GB VRAM busy) + floating embed-chip admin-only lower-right + WooCommerce adapter scaffold (empty seed; no known live DTC targets)
---
public/css/app.css | 46 +++++++++++++
public/index.html | 11 ++++
public/js/app.js | 45 +++++++++++++
scripts/clothing-apis/brands.json | 4 ++
scripts/clothing-apis/index.js | 20 ++++++
scripts/clothing-apis/woocommerce.js | 122 +++++++++++++++++++++++++++++++++++
server.js | 54 +++++++++++++++-
7 files changed, 300 insertions(+), 2 deletions(-)
diff --git a/public/css/app.css b/public/css/app.css
index 7e6b277..6f66216 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -733,6 +733,52 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
}
.tailor-reset:hover { background: #fff; color: #1d1d1f; }
+/* ---- Floating embed-progress chip (tick 20, admin only) ---- */
+.embed-chip {
+ position: fixed;
+ bottom: 16px; right: 16px;
+ z-index: 60;
+ background: #1d1d1f;
+ color: #faf7f2;
+ border-radius: 16px;
+ padding: 10px 14px;
+ font-size: 12px;
+ min-width: 220px;
+ max-width: 320px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.3);
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+.embed-chip-head {
+ display: flex; align-items: center; gap: 8px;
+ margin-bottom: 6px;
+}
+.embed-chip-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: #707070;
+ flex: 0 0 8px;
+}
+.embed-chip-dot-idle { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.6); }
+.embed-chip-dot-busy { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.6); }
+.embed-chip-dot-unreachable { background: #ef4444; }
+.embed-chip-mac1 {
+ margin-left: auto;
+ font-size: 11px;
+ color: #c7c2b8;
+ letter-spacing: 0.02em;
+}
+.embed-chip-track {
+ height: 5px;
+ background: rgba(255,255,255,0.08);
+ border-radius: 999px;
+ overflow: hidden;
+}
+.embed-chip-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #c9a96e 0%, #f3d76f 100%);
+ width: 0%;
+ transition: width 0.6s ease-out;
+}
+
/* ---- Production credit badge (tick 19) ---- */
.card { position: relative; } /* anchor for the absolute-positioned badge */
.prod-credit-badge {
diff --git a/public/index.html b/public/index.html
index 38bc162..dbcd20f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -575,6 +575,17 @@
<button class="link-btn" onclick="forgetMe()" aria-label="Delete all my data">Delete my account + everything</button>
</footer>
+<!-- Tick 20: floating embed-progress chip — admin only, lower-right.
+ Visible across every screen so Steve sees draining state while clicking. -->
+<div id="embed-chip" class="embed-chip" data-role-only="admin" hidden>
+ <div class="embed-chip-head">
+ <span class="embed-chip-dot" id="embed-chip-dot"></span>
+ <span><strong id="embed-chip-pct">0</strong>% · <span id="embed-chip-left">0</span> left</span>
+ <span class="embed-chip-mac1" id="embed-chip-mac1">Mac1: …</span>
+ </div>
+ <div class="embed-chip-track"><div class="embed-chip-fill" id="embed-chip-fill"></div></div>
+</div>
+
<script src="/js/app.js"></script>
</body>
</html>
diff --git a/public/js/app.js b/public/js/app.js
index 0a8ea8e..e6d687e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -127,6 +127,51 @@ const Role = {
};
window.Role = Role;
+// ---- Floating embed-progress chip (tick 20, admin only) ------------------
+// Lives in the lower-right corner across every screen. Polls catalog status
+// every 30s. Hides when admin is previewing-as a non-admin role OR when the
+// catalog is fully embedded. Surfaces Mac1 state so Steve sees why the
+// drainer might be paused.
+async function refreshEmbedChip() {
+ const chip = $('#embed-chip');
+ if (!chip) return;
+ // Visibility — admin role only AND there's work to show.
+ if (!Role.is_admin || Role.view_as) { chip.hidden = true; return; }
+ try {
+ const r = await fetch('/api/admin/catalog-status');
+ if (!r.ok) { chip.hidden = true; return; }
+ const j = await r.json();
+ if (!j.total || j.unembedded === 0) { chip.hidden = true; return; }
+ chip.hidden = false;
+ const pct = Math.round((j.embedded / j.total) * 100);
+ $('#embed-chip-pct').textContent = pct;
+ $('#embed-chip-left').textContent = j.unembedded;
+ $('#embed-chip-fill').style.width = pct + '%';
+ // Mac1 status pill
+ const state = j.mac1?.state || 'unknown';
+ const label = ({
+ idle: 'Mac1 idle',
+ busy: 'Mac1 busy',
+ unreachable: 'Mac1 down',
+ unknown: 'Mac1 ?',
+ })[state];
+ const dot = $('#embed-chip-dot');
+ const mac1 = $('#embed-chip-mac1');
+ mac1.textContent = label;
+ mac1.title = j.mac1?.detail || '';
+ dot.className = 'embed-chip-dot embed-chip-dot-' + state;
+ // If drainer is OFF, show a small "paused" hint.
+ if (!j.drainer_enabled) {
+ mac1.textContent = label + ' · paused';
+ }
+ } catch {
+ chip.hidden = true;
+ }
+}
+setInterval(refreshEmbedChip, 30 * 1000);
+// First refresh after Role boots — small delay so role data is loaded.
+setTimeout(refreshEmbedChip, 1500);
+
// Populate every <select> that's a production picker (closet upload, try-on
// submit, header active-production bar). Idempotent — wipes & re-fills.
// Reads / writes localStorage 'wms.activeProduction' so the choice persists
diff --git a/scripts/clothing-apis/brands.json b/scripts/clothing-apis/brands.json
index f112385..36b1bd2 100644
--- a/scripts/clothing-apis/brands.json
+++ b/scripts/clothing-apis/brands.json
@@ -25,6 +25,10 @@
{ "id": "everybody-world","name": "Everybody.World", "domain": "everybody.world", "sustain_tier": 5, "pro_grade": 1 }
],
+ "woocommerce_dtc": [
+ "_note: empty seed. Tick 20 probing showed major DTC brands (Pact/Reformation/Kotn/Frank+Oak/Burton) don't expose /wp-json/wc/store/v1/products. Add small/indie WooCommerce shops here as they're found. Same shape as shopify_dtc entries."
+ ],
+
"etsy_categories": [
{ "id": "etsy-vintage-clothing", "name": "Etsy — Vintage Clothing", "category_id": "11050", "query": "vintage clothing", "needs_key": true },
{ "id": "etsy-handmade-fashion", "name": "Etsy — Handmade Fashion", "category_id": "11000", "query": "handmade clothing", "needs_key": true }
diff --git a/scripts/clothing-apis/index.js b/scripts/clothing-apis/index.js
index 2bb284e..8ffd24b 100644
--- a/scripts/clothing-apis/index.js
+++ b/scripts/clothing-apis/index.js
@@ -20,6 +20,7 @@ const fs = require('fs');
const BRANDS = JSON.parse(fs.readFileSync(path.join(__dirname, 'brands.json'), 'utf8'));
const shopify = require('./shopify');
+const woocommerce = require('./woocommerce');
const etsy = require('./etsy');
const ebay = require('./ebay');
@@ -29,6 +30,11 @@ async function importOne(db, kind, id) {
if (!b) throw new Error(`shopify brand ${id} not in brands.json`);
return await shopify.importBrand(db, b);
}
+ if (kind === 'woocommerce') {
+ const b = (BRANDS.woocommerce_dtc || []).filter(x => typeof x === 'object').find(x => x.id === id);
+ if (!b) throw new Error(`woocommerce brand ${id} not in brands.json`);
+ return await woocommerce.importBrand(db, b);
+ }
if (kind === 'etsy') {
const q = BRANDS.etsy_categories.find(x => x.id === id);
if (!q) throw new Error(`etsy query ${id} not in brands.json`);
@@ -79,6 +85,20 @@ async function importAll(db, opts = {}) {
}
n += brands.length;
}
+
+ if (!filterKind || filterKind === 'woocommerce') {
+ const woo = (BRANDS.woocommerce_dtc || []).filter(x => typeof x === 'object' && !x.disabled);
+ for (const b of woo) {
+ if (n >= limit) break;
+ try {
+ const r = await woocommerce.importBrand(db, b);
+ results.push({ kind: 'woocommerce', id: b.id, ...r });
+ } catch (e) {
+ results.push({ kind: 'woocommerce', id: b.id, ok: false, err: e.message });
+ }
+ n++;
+ }
+ }
if (!filterKind || filterKind === 'etsy') {
for (const q of BRANDS.etsy_categories) {
if (n >= limit) break;
diff --git a/scripts/clothing-apis/woocommerce.js b/scripts/clothing-apis/woocommerce.js
new file mode 100644
index 0000000..614e6b5
--- /dev/null
+++ b/scripts/clothing-apis/woocommerce.js
@@ -0,0 +1,122 @@
+/**
+ * WooCommerce Store API adapter — zero key, paginated, public.
+ *
+ * Endpoint: GET https://<domain>/wp-json/wc/store/v1/products?per_page=N&page=P
+ *
+ * The Store API is read-only and public on every default WooCommerce
+ * install. Some stores firewall /wp-json/ — in that case the probe returns
+ * HTML (Apache default 404) instead of JSON, which we detect like the
+ * Shopify adapter does and treat as "not WooCommerce".
+ *
+ * Tick 20 probing showed most large DTC fashion brands (Reformation, Pact,
+ * Frank+Oak, Burton, Kotn) no longer expose this — they're all headless or
+ * fully custom. Adapter still useful for the long-tail of small/indie
+ * boutiques (~30% of the global WC install base is fashion-adjacent).
+ *
+ * Add a brand:
+ * brands.json → woocommerce_dtc[] push { id, name, domain, sustain_tier, pro_grade }
+ *
+ * Same row shape + INSERT OR IGNORE idempotence as the Shopify adapter so
+ * the UNIQUE(source, external_id) dedupe just works.
+ */
+const fetch = global.fetch || require('node-fetch');
+
+const UA = 'WhatsMyStyleBot/0.1 (catalog importer; steve@designerwallcoverings.com)';
+const PAGE_SIZE = 100; // WooCommerce Store API hard cap is 100
+const MAX_PAGES = 10;
+const PAGE_DELAY_MS = 700;
+
+const CATEGORY_MAP = [
+ [/^t.?shirt|tee|tank|cami|blouse|shirt|button[- ]?down|top$/i, 'top'],
+ [/sweater|knit|cardigan|pullover|jumper|hoodie/i, 'top'],
+ [/pant|trouser|jean|chino|legging|short/i, 'bottom'],
+ [/skirt|midi$|mini$/i, 'bottom'],
+ [/dress|gown|jumpsuit|romper/i, 'dress'],
+ [/coat|jacket|parka|trench|blazer|puffer|outerwear/i, 'outerwear'],
+ [/shoe|sneaker|boot|sandal|loafer|heel|flat$/i, 'shoes'],
+ [/bag|tote|purse|backpack|crossbody|clutch/i, 'bag'],
+ [/scarf|hat|belt|sunglasses|jewelry|earring|necklace|ring$|bracelet|accessor/i, 'accessory'],
+];
+function normalizeCategory(text) {
+ for (const [re, cat] of CATEGORY_MAP) if (re.test(text)) return cat;
+ return null;
+}
+
+async function fetchOnePage(domain, page) {
+ const url = `https://${domain}/wp-json/wc/store/v1/products?per_page=${PAGE_SIZE}&page=${page}`;
+ const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, redirect: 'follow' });
+ if (r.status === 404 || r.status === 403) return { products: [], end: true, status: r.status };
+ if (r.status === 429) return { products: [], end: true, status: 429 };
+ if (!r.ok) throw new Error(`woo ${domain} page ${page} → ${r.status}`);
+ const ct = (r.headers.get('content-type') || '').toLowerCase();
+ if (!ct.includes('json')) return { products: [], end: true, status: 'not-woo' };
+ let j;
+ try { j = await r.json(); }
+ catch { return { products: [], end: true, status: 'invalid-json' }; }
+ if (!Array.isArray(j)) return { products: [], end: true, status: 'no-products-array' };
+ return { products: j, end: j.length < PAGE_SIZE, status: r.status };
+}
+
+function rowFromProduct(domain, brand, sustainTier, proGrade, p) {
+ const price = Number(p.prices?.price || 0); // Store API returns price as a string in the minor unit (cents) for most currencies
+ const minorMultiplier = Number(p.prices?.currency_minor_unit ?? 2);
+ const price_cents = Number.isFinite(price) && price > 0
+ ? Math.round(price / Math.pow(10, Math.max(0, minorMultiplier - 2))) // already in cents when minor=2
+ : null;
+ const image = p.images?.[0]?.src || p.featured_image || null;
+ const text = `${p.name || ''} ${(p.categories || []).map(c => c.name).join(' ')}`;
+ const category = normalizeCategory(text);
+ if (!image || !price_cents || !category) return null;
+
+ return {
+ source: `woocommerce:${domain}`,
+ external_id: `woo:${domain}:${p.id}`,
+ title: (p.name || '').trim().slice(0, 200),
+ brand: brand || domain,
+ category,
+ color: null,
+ pattern: null,
+ material: null,
+ price_cents,
+ currency: p.prices?.currency_code || 'USD',
+ image_url: image,
+ product_url: p.permalink || `https://${domain}/?p=${p.id}`,
+ tags: JSON.stringify((p.categories || []).map(c => c.name)),
+ pro_grade: proGrade,
+ };
+}
+
+async function importBrand(db, brandEntry) {
+ const { name, domain, sustain_tier, pro_grade } = brandEntry;
+ const t0 = Date.now();
+
+ const all = [];
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const { products, end, status } = await fetchOnePage(domain, page);
+ if (status !== 200) return { brand: name, domain, ok: false, status, fetched: all.length, imported: 0, ms: Date.now() - t0 };
+ all.push(...products);
+ if (end) break;
+ await new Promise(r => setTimeout(r, PAGE_DELAY_MS));
+ }
+
+ const insert = db.prepare(`
+ INSERT OR IGNORE INTO items
+ (source, external_id, title, brand, category, price_cents, currency, image_url, product_url, tags, pro_grade)
+ VALUES
+ (@source, @external_id, @title, @brand, @category, @price_cents, @currency, @image_url, @product_url, @tags, @pro_grade)
+ `);
+ let imported = 0, skipped = 0;
+ const tx = db.transaction((rows) => {
+ for (const r of rows) {
+ if (!r) { skipped++; continue; }
+ const res = insert.run(r);
+ if (res.changes > 0) imported++;
+ else skipped++;
+ }
+ });
+ tx(all.map(p => rowFromProduct(domain, name, sustain_tier, pro_grade, p)));
+
+ return { brand: name, domain, ok: true, fetched: all.length, imported, skipped, ms: Date.now() - t0 };
+}
+
+module.exports = { importBrand };
diff --git a/server.js b/server.js
index 67bd7b7..f024496 100644
--- a/server.js
+++ b/server.js
@@ -862,7 +862,17 @@ app.get('/api/admin/catalog-status', (req, res) => {
const total = db.prepare('SELECT COUNT(*) c FROM items').get().c;
const embedded = db.prepare('SELECT COUNT(*) c FROM items WHERE embedding IS NOT NULL').get().c;
const bySource = db.prepare('SELECT source, COUNT(*) c FROM items GROUP BY source ORDER BY c DESC').all();
- res.json({ total, embedded, unembedded: total - embedded, by_source: bySource });
+ // Tick 20: surface Mac1 state so the UI can explain why draining is paused.
+ let mac1 = null;
+ try {
+ const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
+ if (r) mac1 = JSON.parse(r.value);
+ } catch {}
+ res.json({
+ total, embedded, unembedded: total - embedded, by_source: bySource,
+ drainer_enabled: configValue('embed_drainer_enabled', false),
+ mac1,
+ });
});
// ---- admin / debate -------------------------------------------------------
@@ -1851,10 +1861,44 @@ const EMBED_VISION_MODEL = process.env.EMBED_VISION_MODEL || 'llava:13b';
// Mac1 was already at high concurrency (qwen3:14b loaded + 108% CPU on a
// neighbor process) and Mac2 load avg was 9+ too. Flip ON via admin-config
// once Mac1 frees up. Routing is wired; just the cron is dormant.
-function drainUnembedded() {
+// Tick 20: Mac1 health watcher. Probe /api/ps before spawning. If any model
+// is consuming >MAC1_BUSY_VRAM_GB, mark Mac1 busy and skip this cron tick.
+// The status is cached in app_config.mac1_status with a timestamp so the
+// admin UI can render "busy" / "idle" / "unreachable" without polling Mac1
+// directly from the browser.
+const MAC1_BUSY_VRAM_GB = 9;
+async function mac1Status() {
+ try {
+ const r = await fetch(`${EMBED_OLLAMA_URL}/api/ps`, { signal: AbortSignal.timeout(2500) });
+ if (!r.ok) return { state: 'unreachable', detail: `HTTP ${r.status}` };
+ const j = await r.json();
+ const models = j.models || [];
+ const heavyModels = models.filter(m => (m.size_vram || 0) / 1e9 > MAC1_BUSY_VRAM_GB);
+ if (heavyModels.length > 0) {
+ return { state: 'busy', detail: heavyModels.map(m => `${m.name} (${(m.size_vram/1e9).toFixed(1)}G)`).join(', ') };
+ }
+ return { state: 'idle', detail: models.length ? `${models.length} warm` : 'no models loaded' };
+ } catch (e) {
+ return { state: 'unreachable', detail: e.message };
+ }
+}
+
+function setMac1StatusCache(s) {
+ db.prepare(`INSERT INTO app_config (key, value) VALUES ('mac1_status', ?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`)
+ .run(JSON.stringify({ ...s, checked_at: new Date().toISOString() }));
+}
+
+async function drainUnembedded() {
if (configValue('embed_drainer_enabled', false) !== true) return;
const unembed = db.prepare("SELECT COUNT(*) c FROM items WHERE embedding IS NULL").get().c || 0;
if (unembed === 0) return;
+
+ // Tick 20: skip if Mac1 is busy. The drainer cron would dogpile otherwise.
+ const status = await mac1Status();
+ setMac1StatusCache(status);
+ if (status.state !== 'idle') return;
+
const batch = Math.min(20, unembed);
const p = _spawnEmbed('node', [
path.join(__dirname, 'scripts', 'embed-items.js'),
@@ -1867,6 +1911,12 @@ function drainUnembedded() {
});
p.unref();
}
+
+// Independent of the drainer: refresh the Mac1 status cache every 60s so the
+// admin UI shows fresh state even when the drainer is disabled.
+async function refreshMac1Status() { setMac1StatusCache(await mac1Status()); }
+setTimeout(refreshMac1Status, 5 * 1000);
+setInterval(refreshMac1Status, 60 * 1000);
// Boot-time pass after 60s — let pm2 settle first.
setTimeout(drainUnembedded, 60 * 1000);
// Then every 5 min until the tail is drained.
← 7bf8878 yolo tick 19: embed-progress live bar (30s poll + ETA) + pro
·
back to Whatsmystyle
·
yolo tick 21: Browserbase headless-SPA adapter (Reformation/ 380a952 →