← back to Council 0809 Builds
Council 2026-08-09: build ideas 1,2,3,5 (disk forecaster, Kravet MAP gate, sourcing brief, rolls AR deep-link)
371b4e83e14cd533e80b2b58effeefcfca4fdf88 · 2026-08-09 08:40:18 -0700 · Claude (claude-mail)
Files touched
A .gitignoreA README.mdA disk-forecaster/INSTALL.mdA disk-forecaster/forecast-disk.shA kravet-map-gate/map-gate.jsA rolls-ar-deeplink/README.mdA rolls-ar-deeplink/deeplink.jsA sourcing-brief/build-brief.jsA sourcing-brief/sample-gaps.json
Diff
commit 371b4e83e14cd533e80b2b58effeefcfca4fdf88
Author: Claude (claude-mail) <steve@designerwallcoverings.com>
Date: Sun Aug 9 08:40:18 2026 -0700
Council 2026-08-09: build ideas 1,2,3,5 (disk forecaster, Kravet MAP gate, sourcing brief, rolls AR deep-link)
---
.gitignore | 9 +++
README.md | 17 ++++++
disk-forecaster/INSTALL.md | 22 ++++++++
disk-forecaster/forecast-disk.sh | 88 ++++++++++++++++++++++++++++++
kravet-map-gate/map-gate.js | 115 +++++++++++++++++++++++++++++++++++++++
rolls-ar-deeplink/README.md | 43 +++++++++++++++
rolls-ar-deeplink/deeplink.js | 40 ++++++++++++++
sourcing-brief/build-brief.js | 90 ++++++++++++++++++++++++++++++
sourcing-brief/sample-gaps.json | 35 ++++++++++++
9 files changed, 459 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..52d085c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/*.tsv
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f531f46
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# Council 2026-08-09 builds — "Build except 4"
+
+Steve's 2026-08-09 email reply to the Officer Idea Council Top-5 digest:
+**"Build except 4. Dust2026"** → build ideas 1, 2, 3, 5; skip idea 4 (canary
+circuit-breaker dashboard).
+
+| # | Idea | Owner | Built this pass | Gated / staged |
+|---|------|-------|-----------------|----------------|
+| 1 | Kamatera Disk Runway Forecaster | vp-operations | `disk-forecaster/forecast-disk.sh` — du→growth-log→linear extrapolation→George+CNCP alert. Verified locally (rate-based runway math). | Kamatera cron **install** (remote prod scheduled job) — staged, see `disk-forecaster/INSTALL.md` |
+| 2 | Kravet MAP Activation Gate | vp-dw-commerce | `kravet-map-gate/map-gate.js` — report + pre-activation `check` mode. Verified: blocks below-MAP, passes at-MAP. Exposure = **286 active SKUs below MAP + 1 sample-leak**. | The **reprice** of the 286 (Shopify price writes) — customer-facing, already in memo `TK-10014-dwkk-below-map` |
+| 3 | Weekly Sourcing Brief | vp-research-content | `sourcing-brief/build-brief.js` + sample. Verified: ranks by demand×gap, carries settlement warnings. | Wire to live design-trend-scout output + weekly George send (additive, low-risk follow-up) |
+| 5 | PDP "How Many Rolls?" AR deep-link | vp-dw-commerce | `rolls-ar-deeplink/deeplink.js` — safe link builder (hides button w/o data). Audit done. | Live Admin-API metafield coverage pull + **PDP theme injection** — customer-facing, gated (see subdir README) |
+
+Everything in this repo is read-only against the `dw_unified` mirror or pure
+local codegen. No production write, no customer-facing change, no DNS, no
+remote push has been executed. The gated columns above are what still needs
+Steve's explicit go (or a live session with the right tokens).
diff --git a/disk-forecaster/INSTALL.md b/disk-forecaster/INSTALL.md
new file mode 100644
index 0000000..4e7a5ee
--- /dev/null
+++ b/disk-forecaster/INSTALL.md
@@ -0,0 +1,22 @@
+# Installing the Disk Runway Forecaster on Kamatera
+
+The script runs locally for testing but its home is Kamatera (45.61.58.125),
+where the TK-10236 ENOSPC crash happened. Installing a cron on the prod box is
+a remote scheduled-job install — **staged for Steve's go**, not auto-run.
+
+## One-time install (run in a live session or paste to Steve)
+```bash
+scp forecast-disk.sh root@45.61.58.125:/root/ops/forecast-disk.sh
+ssh root@45.61.58.125 'chmod +x /root/ops/forecast-disk.sh && \
+ ( crontab -l 2>/dev/null; echo "0 5 * * 1 ROOT=/root TOP=20 THRESHOLD_DAYS=14 GEORGE_BASIC_AUTH_PASS=<pass> /root/ops/forecast-disk.sh >> /root/ops/forecast-disk.log 2>&1" ) | crontab -'
+```
+Weekly (Mon 05:00). First 2 runs only build the baseline log; forecasts begin
+once ≥2 time-separated samples exist.
+
+## Reversible
+`ssh root@45.61.58.125 'crontab -l | grep -v forecast-disk.sh | crontab -'`
+
+## Note on George pass
+The alert path needs `GEORGE_BASIC_AUTH_PASS` (inner George auth). Per memory
+`george-gmail-auth-topology` it lives only in the pm2 process env — wire it in
+during the live install, don't hardcode it here.
diff --git a/disk-forecaster/forecast-disk.sh b/disk-forecaster/forecast-disk.sh
new file mode 100755
index 0000000..1ba64a2
--- /dev/null
+++ b/disk-forecaster/forecast-disk.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Kamatera Disk Runway Forecaster (Council idea #1, 2026-08-09)
+# -------------------------------------------------------------
+# Forecasting, not thresholding: samples du per top directory, appends to a
+# rolling log, fits a linear growth rate over the trailing window, and
+# extrapolates DAYS-UNTIL-FULL. Fires a George email + CNCP card when any
+# tracked path's runway drops below THRESHOLD_DAYS.
+#
+# Born from TK-10236: prod Postgres ENOSPC-crashed on a 98%-full disk. A
+# threshold canary only screams once you're already near the wall; this
+# predicts the wall ~2 weeks out so the prune happens on a weekday, calmly.
+#
+# Designed to run on Kamatera via cron/launchd (weekly). Safe to dry-run
+# anywhere: pass a ROOT to scan and it just prints the forecast (no email).
+#
+# Usage:
+# ROOT=/root TOP=20 THRESHOLD_DAYS=14 ./forecast-disk.sh # real
+# ROOT="$HOME/Projects" DRY=1 ./forecast-disk.sh # dry-run
+set -euo pipefail
+
+ROOT="${ROOT:-/root}"
+TOP="${TOP:-20}"
+THRESHOLD_DAYS="${THRESHOLD_DAYS:-14}"
+LOG="${LOG:-$HOME/disk-growth-log.tsv}"
+DRY="${DRY:-0}"
+NOW="$(date +%s)"
+
+# --- capacity of the filesystem holding ROOT (KB) ---
+avail_kb="$(df -Pk "$ROOT" | awk 'NR==2{print $4}')"
+size_kb="$(df -Pk "$ROOT" | awk 'NR==2{print $2}')"
+
+# --- sample: du -sk per top-N child dir, append to the rolling log ---
+touch "$LOG"
+du -sk "$ROOT"/*/ 2>/dev/null | sort -rn | head -n "$TOP" | while IFS=$'\t' read -r kb path; do
+ printf '%s\t%s\t%s\n' "$NOW" "$kb" "$path" >> "$LOG"
+done
+
+# --- forecast per path from the trailing samples in the log ---
+# linear rate = (latest - earliest)/(t_latest - t_earliest) KB/sec over >=2 pts
+# days_until_full = free_kb / (rate_kb_per_sec * 86400), attributed to fastest grower
+alerts="$(awk -v now="$NOW" -v avail="$avail_kb" -v thr="$THRESHOLD_DAYS" '
+ { t=$1; kb=$2; p=$3;
+ if (!(p in firstT) || t<firstT[p]) { firstT[p]=t; firstKB[p]=kb }
+ if (!(p in lastT) || t>lastT[p]) { lastT[p]=t; lastKB[p]=kb }
+ }
+ END {
+ for (p in lastT) {
+ dt = lastT[p]-firstT[p];
+ if (dt <= 0) continue; # need >=2 samples apart
+ rate = (lastKB[p]-firstKB[p])/dt; # KB/sec
+ if (rate <= 0) continue; # flat/shrinking = no runway risk
+ days = avail / (rate*86400);
+ if (days < thr) printf "%s\t%.1f\t%.2f\n", p, days, rate*86400/1024/1024;
+ }
+ }' "$LOG" | sort -t$'\t' -k2 -n)"
+
+pct_used="$(df -Pk "$ROOT" | awk 'NR==2{gsub("%","",$5); print $5}')"
+echo "[$(date -u +%FT%TZ)] ROOT=$ROOT used=${pct_used}% avail=$((avail_kb/1024/1024))G threshold=${THRESHOLD_DAYS}d"
+
+if [ -z "$alerts" ]; then
+ echo "OK: no tracked path forecast to fill within ${THRESHOLD_DAYS} days (or <2 samples yet)."
+ exit 0
+fi
+
+echo "RUNWAY ALERT — paths forecast to fill within ${THRESHOLD_DAYS} days:"
+echo "$alerts" | awk -F'\t' '{printf " %-40s %5.1f days (+%.2f GB/day)\n",$1,$2,$3}'
+
+if [ "$DRY" = "1" ]; then
+ echo "(dry-run: email/CNCP suppressed)"
+ exit 0
+fi
+
+# --- fire George email + CNCP card (only on real run) ---
+body="Kamatera disk runway forecast — paths projected to fill within ${THRESHOLD_DAYS} days:
+$(echo "$alerts" | awk -F'\t' '{printf " %s : %.1f days (+%.2f GB/day)\n",$1,$2,$3}')
+
+Filesystem holding ${ROOT}: ${pct_used}% used, $((avail_kb/1024/1024))G free.
+Prune before the wall — TK-10236 was an ENOSPC crash. Suggested: homesonspec/var/snapshots cache."
+curl -s -X POST http://127.0.0.1:9850/api/send \
+ -H 'Content-Type: application/json' \
+ -u "admin:${GEORGE_BASIC_AUTH_PASS:-}" \
+ -d "$(python3 -c 'import json,os,sys; print(json.dumps({"to":"steve@designerwallcoverings.com","subject":"[disk-forecaster] Kamatera runway < '"$THRESHOLD_DAYS"'d","text":sys.stdin.read()}))' <<<"$body")" \
+ >/dev/null 2>&1 || echo "(warn: George send failed)"
+curl -s -X POST http://127.0.0.1:3333/api/parking-lot \
+ -H 'Content-Type: application/json' \
+ -d "{\"project\":\"kamatera-ops\",\"title\":\"Disk runway < ${THRESHOLD_DAYS}d\",\"detail\":$(python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))' <<<"$body")}" \
+ >/dev/null 2>&1 || true
+echo "alerted."
diff --git a/kravet-map-gate/map-gate.js b/kravet-map-gate/map-gate.js
new file mode 100644
index 0000000..8bda9f1
--- /dev/null
+++ b/kravet-map-gate/map-gate.js
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/**
+ * Kravet MAP Activation Gate (Council idea #2, 2026-08-09)
+ * ---------------------------------------------------------
+ * Kravet-umbrella brands sell at MAP = wholesale × 1.5 (Kravet enforces it).
+ * A batch push that activates at the generic cost/0.65/0.85 formula instead of
+ * MAP creates a live MAP-compliance breach. This gate answers ONE question
+ * before any status flip to ACTIVE: "is this price >= MAP?"
+ *
+ * Two modes:
+ * report - print current exposure (active Kravet SKUs priced < MAP)
+ * check <json> - gate a proposed activation batch; exit 1 if any would breach
+ *
+ * READ-ONLY against the local dw_unified mirror. It never writes a price and
+ * never flips a status — it is a guard other scripts call, not an executor.
+ *
+ * MAP source priority (per CLAUDE.md kravet-lines-price-at-map):
+ * 1. kravet_authoritative_pricing.new_map (loaded from the Kravet list)
+ * 2. new_whls * 1.5 (derived, when map is null but whls is known)
+ */
+const { execFileSync } = require('child_process');
+
+const KRAVET_VENDOR_RE =
+ 'kravet|lee jofa|brunschwig|groundworks|cole & son|baker|clarke|mulberry|' +
+ 'threads|gp ?& ?j|colefax|nicolette|aerin|barclay|thom filicia|andrew martin';
+
+function q(sql) {
+ const out = execFileSync(
+ 'psql',
+ ['-h', '/tmp', '-d', 'dw_unified', '-tAF', '\t', '-c', sql],
+ { encoding: 'utf8', env: { ...process.env, PATH: '/opt/homebrew/bin:' + process.env.PATH } }
+ );
+ return out.trim().split('\n').filter(Boolean).map((l) => l.split('\t'));
+}
+
+// Effective MAP per mfr_sku: explicit map, else whls*1.5
+const MAP_EXPR = `COALESCE(kap.new_map, kap.new_whls * 1.5)`;
+
+function report() {
+ const rows = q(`
+ WITH k AS (
+ SELECT sp.dw_sku, sp.vendor, sp.mfr_sku, sp.price::numeric AS price,
+ ${MAP_EXPR} AS map
+ FROM shopify_products sp
+ JOIN kravet_authoritative_pricing kap
+ ON upper(trim(sp.mfr_sku)) = upper(trim(kap.mfr_sku))
+ AND coalesce(trim(sp.mfr_sku),'') <> ''
+ WHERE sp.status = 'ACTIVE' AND sp.vendor ~* '${KRAVET_VENDOR_RE}')
+ SELECT
+ count(*),
+ count(*) FILTER (WHERE price < map - 0.01),
+ count(*) FILTER (WHERE abs(price - 4.25) < 0.01)
+ FROM k;`)[0];
+ const [matched, belowMap, sampleLeak] = rows.map(Number);
+ console.log(`Kravet MAP exposure (active, mfr_sku-matched):`);
+ console.log(` matched to authoritative pricing : ${matched}`);
+ console.log(` priced BELOW MAP (breach risk) : ${belowMap}`);
+ console.log(` $4.25 sample leaking as sell price: ${sampleLeak}`);
+ // worst offenders
+ const worst = q(`
+ WITH k AS (
+ SELECT sp.dw_sku, sp.vendor, sp.price::numeric AS price, ${MAP_EXPR} AS map
+ FROM shopify_products sp
+ JOIN kravet_authoritative_pricing kap
+ ON upper(trim(sp.mfr_sku)) = upper(trim(kap.mfr_sku))
+ AND coalesce(trim(sp.mfr_sku),'') <> ''
+ WHERE sp.status='ACTIVE' AND sp.vendor ~* '${KRAVET_VENDOR_RE}')
+ SELECT dw_sku, vendor, price, round(map,2), round((map-price),2) AS short
+ FROM k WHERE price < map - 0.01 ORDER BY (map-price) DESC LIMIT 10;`);
+ if (worst.length) {
+ console.log(`\n Top 10 below-MAP (dw_sku | vendor | price | MAP | short-by):`);
+ worst.forEach((r) => console.log(' ', r.join(' | ')));
+ }
+ return { matched, belowMap, sampleLeak };
+}
+
+/**
+ * Gate a proposed activation batch.
+ * batch = [{ mfr_sku, price }] (the values a push is about to make ACTIVE)
+ * Returns { ok, breaches: [{mfr_sku, price, map}] }.
+ */
+function check(batch) {
+ if (!Array.isArray(batch) || !batch.length) return { ok: true, breaches: [] };
+ const values = batch
+ .map((b) => `('${String(b.mfr_sku).replace(/'/g, "''")}', ${Number(b.price)})`)
+ .join(',');
+ const rows = q(`
+ WITH proposed(mfr_sku, price) AS (VALUES ${values})
+ SELECT p.mfr_sku, p.price, round(${MAP_EXPR},2) AS map
+ FROM proposed p
+ JOIN kravet_authoritative_pricing kap
+ ON upper(trim(p.mfr_sku)) = upper(trim(kap.mfr_sku))
+ WHERE p.price < ${MAP_EXPR} - 0.01;`);
+ const breaches = rows.map(([mfr_sku, price, map]) => ({ mfr_sku, price: +price, map: +map }));
+ return { ok: breaches.length === 0, breaches };
+}
+
+if (require.main === module) {
+ const [, , mode, arg] = process.argv;
+ if (mode === 'check') {
+ const batch = JSON.parse(arg || '[]');
+ const res = check(batch);
+ if (!res.ok) {
+ console.error(`BLOCKED: ${res.breaches.length} SKU(s) would activate below MAP:`);
+ res.breaches.forEach((b) =>
+ console.error(` ${b.mfr_sku} price ${b.price} < MAP ${b.map}`));
+ process.exit(1);
+ }
+ console.log(`OK: all ${batch.length} proposed activations are >= MAP.`);
+ } else {
+ report();
+ }
+}
+
+module.exports = { check, report };
diff --git a/rolls-ar-deeplink/README.md b/rolls-ar-deeplink/README.md
new file mode 100644
index 0000000..5a34b7a
--- /dev/null
+++ b/rolls-ar-deeplink/README.md
@@ -0,0 +1,43 @@
+# DW PDP "How Many Rolls?" AR Deep-Link — Council idea #5 (2026-08-09)
+
+**Status: AUDIT DONE · build STAGED (customer-facing PDP change → Steve-gated)**
+
+Idea: DW products carry width + pattern-repeat metafields. Deep-link those into
+the NPH AR measure tool from the product page so a shopper gets "how many rolls
+do I need?" answered at the decision moment — the #1 pre-purchase question, and
+a moat because competitor PDPs don't carry the metafields.
+
+## First-step audit finding (the idea's own gate: "is the data reliable enough?")
+
+Ran against the local `dw_unified` mirror on 2026-08-09:
+
+- `product_specs` holds **zero** `width`/`repeat` rows — it is NOT the store.
+- Width/repeat actually live in **Shopify custom metafields**, which the Mac2
+ mirror does not carry.
+- `pr_width_repeat_migration_log` proves a real migration happened:
+ **1,548 products** got `new_custom_width` + `new_custom_pattern_repeat`.
+
+**Conclusion:** coverage is *partial and not verifiable from the mirror*. Before
+committing a URL format we need a **live Shopify Admin-API metafield pull**
+(custom.width, custom.pattern_repeat) to measure true population across the
+active catalog. That pull needs the Admin token + is a live read against the
+canonical store — staged, not run from this email session.
+
+## Deep-link format (proposed, pending the live coverage number)
+
+```
+https://nph.designerwallcoverings.com/measure
+ ?w={custom.width_in}
+ &vr={custom.vertical_repeat_in}
+ &hr={custom.horizontal_repeat_in}
+ &sku={dw_sku}
+```
+
+`buildDeepLink()` in `deeplink.js` is written and unit-safe; it only emits a
+link when width AND a repeat are present (else the button hides — no broken
+"0 rolls" experience).
+
+## Gated next steps (need Steve go)
+1. Live Admin-API metafield coverage pull → real population %.
+2. If coverage is high enough, inject the button into the PDP theme
+ (snippet in the DW theme 144396058675) — **customer-facing, gated**.
diff --git a/rolls-ar-deeplink/deeplink.js b/rolls-ar-deeplink/deeplink.js
new file mode 100644
index 0000000..4111ed2
--- /dev/null
+++ b/rolls-ar-deeplink/deeplink.js
@@ -0,0 +1,40 @@
+/**
+ * "How Many Rolls?" AR deep-link builder (Council idea #5, 2026-08-09)
+ * Emits a measure-tool deep-link ONLY when the product carries the metafields
+ * needed to answer the question. Missing data -> null -> the PDP hides the
+ * button rather than showing a broken "0 rolls" flow.
+ */
+const BASE = 'https://nph.designerwallcoverings.com/measure';
+
+function num(v) {
+ const n = parseFloat(v);
+ return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+/**
+ * @param {object} mf metafields: { width_in, vertical_repeat_in, horizontal_repeat_in }
+ * @param {string} dwSku
+ * @returns {string|null} deep-link, or null if insufficient data (hide the button)
+ */
+function buildDeepLink(mf = {}, dwSku = '') {
+ const w = num(mf.width_in);
+ const vr = num(mf.vertical_repeat_in);
+ const hr = num(mf.horizontal_repeat_in);
+ if (!w || (!vr && !hr)) return null; // need width AND at least one repeat
+ const q = new URLSearchParams();
+ q.set('w', w);
+ if (vr) q.set('vr', vr);
+ if (hr) q.set('hr', hr);
+ if (dwSku) q.set('sku', dwSku);
+ return `${BASE}?${q.toString()}`;
+}
+
+module.exports = { buildDeepLink };
+
+// quick self-test when run directly
+if (require.main === module) {
+ console.log('full :', buildDeepLink({ width_in: 27, vertical_repeat_in: 18, horizontal_repeat_in: 13.5 }, 'DWKK-100367'));
+ console.log('width+vr only:', buildDeepLink({ width_in: 20.5, vertical_repeat_in: 25 }, 'DW-1'));
+ console.log('no repeat -> null:', buildDeepLink({ width_in: 27 }, 'DW-2'));
+ console.log('no width -> null:', buildDeepLink({ vertical_repeat_in: 18 }, 'DW-3'));
+}
diff --git a/sourcing-brief/build-brief.js b/sourcing-brief/build-brief.js
new file mode 100644
index 0000000..5f7d4fa
--- /dev/null
+++ b/sourcing-brief/build-brief.js
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+/**
+ * Design Trend Scout -> Weekly Sourcing Brief (Council idea #3, 2026-08-09)
+ * -------------------------------------------------------------------------
+ * design-trend-scout already finds catalog gaps vs Spoonflower/Etsy top
+ * sellers, but its findings are ephemeral. This turns them into a standing
+ * Monday HTML artifact Steve can act on: top-N trend gaps, each with the
+ * marketplace evidence, the DW gap, and a suggested (settlement-safe) design
+ * brief.
+ *
+ * Input : a JSON array of gap objects (from design-trend-scout output), or the
+ * bundled sample if none supplied.
+ * Output: an HTML brief to stdout (pipe to a file) — deliver via George.
+ *
+ * Zero paid spend. Reads local JSON, emits HTML. No network.
+ *
+ * Usage:
+ * node build-brief.js path/to/scout-gaps.json > brief.html
+ * node build-brief.js # uses sample-gaps.json
+ */
+const fs = require('fs');
+const path = require('path');
+
+const esc = (s) =>
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+function loadGaps(argPath) {
+ const p = argPath || path.join(__dirname, 'sample-gaps.json');
+ const gaps = JSON.parse(fs.readFileSync(p, 'utf8'));
+ // rank: marketplace demand (evidence count / rank) x DW gap size
+ return gaps
+ .map((g) => ({ ...g, _score: (g.demand_score ?? 5) * (g.dw_gap_score ?? 5) }))
+ .sort((a, b) => b._score - a._score);
+}
+
+function weekLabel() {
+ // deterministic: caller passes WEEK env when scheduled; else 'this week'
+ return process.env.WEEK || 'this week';
+}
+
+function render(gaps) {
+ const rows = gaps
+ .slice(0, Number(process.env.TOP || 5))
+ .map(
+ (g, i) => `
+ <tr>
+ <td class="rank">${i + 1}</td>
+ <td>
+ <div class="trend">${esc(g.trend)}</div>
+ <div class="meta">${esc(g.style || '')} ${g.palette ? '· ' + esc(g.palette) : ''}</div>
+ </td>
+ <td>${esc(g.marketplace_evidence)}</td>
+ <td>${esc(g.dw_gap)}</td>
+ <td class="brief">${esc(g.design_brief)}
+ ${g.settlement_note ? `<div class="warn">⚠ ${esc(g.settlement_note)}</div>` : ''}
+ </td>
+ </tr>`
+ )
+ .join('');
+
+ return `<!doctype html><html><head><meta charset="utf-8">
+<title>DW Sourcing Brief — ${esc(weekLabel())}</title>
+<style>
+ body{font:15px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif;color:#1a1a1a;max-width:920px;margin:32px auto;padding:0 20px}
+ h1{font-weight:600;letter-spacing:.3px;margin-bottom:2px}
+ .sub{color:#777;margin-bottom:24px}
+ table{border-collapse:collapse;width:100%}
+ th,td{border-bottom:1px solid #e6e6e6;padding:12px 10px;vertical-align:top;text-align:left}
+ th{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:#999;font-weight:600}
+ .rank{font-size:22px;color:#b7913f;font-weight:700;width:34px}
+ .trend{font-weight:600}
+ .meta{color:#888;font-size:13px}
+ .brief{font-size:14px}
+ .warn{color:#b00;font-size:12px;margin-top:6px}
+ footer{margin-top:28px;color:#aaa;font-size:12px}
+</style></head><body>
+ <h1>Designer Wallcoverings — Weekly Sourcing Brief</h1>
+ <div class="sub">Top trend-gaps vs Spoonflower / Etsy best-sellers · ${esc(weekLabel())} · generated from design-trend-scout</div>
+ <table>
+ <thead><tr><th>#</th><th>Trend</th><th>Marketplace evidence</th><th>DW gap</th><th>Suggested original design brief</th></tr></thead>
+ <tbody>${rows}</tbody>
+ </table>
+ <footer>Every suggested brief must clear the settlement gate before generation. Company/vendor names are text-only. Original designs only — the scout identifies demand lanes, never copies a listing.</footer>
+</body></html>`;
+}
+
+if (require.main === module) {
+ process.stdout.write(render(loadGaps(process.argv[2])));
+}
+module.exports = { render, loadGaps };
diff --git a/sourcing-brief/sample-gaps.json b/sourcing-brief/sample-gaps.json
new file mode 100644
index 0000000..e5e2297
--- /dev/null
+++ b/sourcing-brief/sample-gaps.json
@@ -0,0 +1,35 @@
+[
+ {
+ "trend": "Moody botanical / dark cottagecore",
+ "style": "Maximalist floral",
+ "palette": "Ink green + oxblood",
+ "demand_score": 9,
+ "dw_gap_score": 8,
+ "marketplace_evidence": "Spoonflower 'dark floral' top-20 for 6 wks; Etsy 'moody botanical wallpaper' ~2.4k sales/mo across top listings",
+ "dw_gap": "DW catalog skews light/traditional floral; <15 active SKUs in the dark-ground floral lane",
+ "design_brief": "Dense original botanical on near-black ground, 2-color leaf layer with open space between motifs, oxblood accents",
+ "settlement_note": "Directional-leaf + open-space + multi-color triggers Part A — keep leaves non-directional OR remove open space; no birds/butterflies"
+ },
+ {
+ "trend": "Checkerboard / warped grid",
+ "style": "Modern geometric",
+ "palette": "Butter + cream",
+ "demand_score": 8,
+ "dw_gap_score": 9,
+ "marketplace_evidence": "Etsy 'checkerboard wallpaper' sustained best-seller; Society6 warped-checker prints trending",
+ "dw_gap": "Almost no geometric checker in DW active grid; competitors (Chasing Paper) lead this lane",
+ "design_brief": "Hand-wobbled checkerboard, warm butter/cream, subtle painterly edge for a non-flat look",
+ "settlement_note": ""
+ },
+ {
+ "trend": "70s retro arch / sunburst",
+ "style": "Mid-century revival",
+ "palette": "Terracotta + mustard",
+ "demand_score": 7,
+ "dw_gap_score": 7,
+ "marketplace_evidence": "Spoonflower 'retro arch' + 'sunburst' both in wallpaper top-50",
+ "dw_gap": "DW has scattered MCM but no cohesive retro-arch collection",
+ "design_brief": "Repeating rounded-arch/sunburst geometric, terracotta+mustard on cream, clean vector edges",
+ "settlement_note": ""
+ }
+]
(oldest)
·
back to Council 0809 Builds
·
Council #5 (TK-10385): Path A+B build — width-only deeplink 37767de →