[object Object]

← back to Marketing Command Center

Owned·DW Fleet: add 7-day cadence + 'slowing' signal

96908679edbe15519f2a6075d433ac35545c6c24 · 2026-08-25 10:44:47 -0700 · Steve

Next refinement on the health view: distinguish 'posting daily' from 'posted once
10 days ago' among the non-dormant accounts.
- week7: posts in the last 7 days, shown per account as '· N/7d'
- 🐢 slowing flag (blue): non-dormant but ≤1 post in the last 7 days — catches
  accounts trailing off BEFORE they cross the 14-day dormant line
- summary now leads with 'active this week' + a slowing count
- attention-sort weighting: dormant(3) > repetitive(2) > slowing(1)

Verified: summary '27/35 active this week · 6 dormant · 2 slowing · 2 repetitive';
/7d recency renders; no page errors.

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

Files touched

Diff

commit 96908679edbe15519f2a6075d433ac35545c6c24
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 10:44:47 2026 -0700

    Owned·DW Fleet: add 7-day cadence + 'slowing' signal
    
    Next refinement on the health view: distinguish 'posting daily' from 'posted once
    10 days ago' among the non-dormant accounts.
    - week7: posts in the last 7 days, shown per account as '· N/7d'
    - 🐢 slowing flag (blue): non-dormant but ≤1 post in the last 7 days — catches
      accounts trailing off BEFORE they cross the 14-day dormant line
    - summary now leads with 'active this week' + a slowing count
    - attention-sort weighting: dormant(3) > repetitive(2) > slowing(1)
    
    Verified: summary '27/35 active this week · 6 dormant · 2 slowing · 2 repetitive';
    /7d recency renders; no page errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/panels/vendors.js | 294 ++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 241 insertions(+), 53 deletions(-)

diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index 9b966ea..376d7fa 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -71,9 +71,12 @@ window.MCC_PANELS['vendors'] = {
         const freq = {};
         let repCount = 0;
         for (const p of last6) { const t = (p.product_title || '').trim().toLowerCase(); if (!t) continue; freq[t] = (freq[t] || 0) + 1; if (freq[t] > repCount) repCount = freq[t]; }
+        const weekAgo = Date.now() - 7 * 864e5;
+        const week7 = posts.filter(p => p.ts && new Date(p.ts) >= weekAgo).length;   // cadence: posts in the last 7 days
         const dormant = posts.length === 0 || daysOld > DORMANT_DAYS;
         const repetitive = last6.length >= REP_MIN && repCount >= REP_MIN;
-        return { a, posts, last6, count: posts.length, newestTs, daysOld, dormant, repetitive, repCount };
+        const slowing = !dormant && week7 <= 1;   // trailing off — within the dormant window but posting ≤1/wk
+        return { a, posts, last6, count: posts.length, week7, newestTs, daysOld, dormant, repetitive, slowing, repCount };
       });
 
       const SORT_KEY = 'mcc_vendors_owned_sort';
@@ -85,8 +88,8 @@ window.MCC_PANELS['vendors'] = {
         if (mode === 'alpha') return arr.sort(alpha);
         if (mode === 'posts') return arr.sort((x, y) => y.count - x.count || alpha(x, y));
         if (mode === 'recent') return arr.sort((x, y) => (y.newestTs ? new Date(y.newestTs) : 0) - (x.newestTs ? new Date(x.newestTs) : 0) || alpha(x, y));
-        // 'attention' (default): worst first — dormant + repetitive on top, then stalest.
-        const score = m => (m.dormant ? 2 : 0) + (m.repetitive ? 1 : 0);
+        // 'attention' (default): worst first — dormant, then repetitive, then slowing, then stalest.
+        const score = m => (m.dormant ? 3 : 0) + (m.repetitive ? 2 : 0) + (m.slowing ? 1 : 0);
         return arr.sort((x, y) => score(y) - score(x) || (y.daysOld - x.daysOld) || alpha(x, y));
       };
 
@@ -97,13 +100,14 @@ window.MCC_PANELS['vendors'] = {
           const recency = m.count ? ago(m.newestTs) : '—';
           const flags = [
             m.dormant ? flag('#f7e2e2', '#a53a3a', '💤 dormant', m.count ? `No posts in ${Math.round(m.daysOld)} days` : 'No posts yet (account has no creds / hasn’t published)') : '',
+            m.slowing ? flag('#eef3f8', '#3a5a8a', '🐢 slowing', `Only ${m.week7} post${m.week7 === 1 ? '' : 's'} in the last 7 days`) : '',
             m.repetitive ? flag('#fdf3dc', '#8a6a1e', `↻ ${m.repCount}× same`, `${m.repCount} of the last ${m.last6.length} posts are the same product — vary the content`) : '',
           ].filter(Boolean).join(' ');
           return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px;background:${m.dormant ? '#fbf3f2' : '#faf6ee'}">
             <div class="row" style="justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
               <div style="min-width:200px"><b>${esc(a.name || a.handle)}</b> <span class="pill">OURS</span> ${flags}</div>
               <div style="flex:1"><a class="lnk" href="https://www.instagram.com/${h}/" target="_blank" rel="noopener noreferrer">@${h} ↗</a></div>
-              <div style="min-width:120px;text-align:right;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--muted,#8a8372)" title="posts in the fleet ledger">${m.count ? `${m.count} posts · ${recency}` : ''}</div>
+              <div style="min-width:150px;text-align:right;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--muted,#8a8372)" title="total posts in the ledger · newest · posts in the last 7 days">${m.count ? `${m.count} posts · ${recency} · ${m.week7}/7d` : ''}</div>
             </div>
             ${ownedPosts(m.last6)}
           </div>`;
@@ -113,12 +117,14 @@ window.MCC_PANELS['vendors'] = {
       // Summary line — the at-a-glance fleet health read.
       const summaryEl = root.querySelector('#vend-owned-summary');
       if (summaryEl) {
-        const posting = meta.filter(m => m.count).length;
+        const activeWeek = meta.filter(m => m.week7 > 0).length;
         const dormant = meta.filter(m => m.dormant).length;
+        const slowing = meta.filter(m => m.slowing).length;
         const repetitive = meta.filter(m => m.repetitive).length;
-        summaryEl.innerHTML = `<b>${posting}</b>/${meta.length} posting · ` +
+        summaryEl.innerHTML = `<b>${activeWeek}</b>/${meta.length} active this week · ` +
           `<b style="color:${dormant ? '#a53a3a' : 'inherit'}">${dormant}</b> dormant · ` +
-          `<b style="color:${repetitive ? '#8a6a1e' : 'inherit'}">${repetitive}</b> repetitive — last 6 posts each.`;
+          `<b style="color:${slowing ? '#3a5a8a' : 'inherit'}">${slowing}</b> slowing · ` +
+          `<b style="color:${repetitive ? '#8a6a1e' : 'inherit'}">${repetitive}</b> repetitive`;
       }
 
       renderOwned(sortSel ? sortSel.value : 'attention');
@@ -486,11 +492,33 @@ window.MCC_PANELS['vendors'] = {
       img.src = url;
     });
 
-    // Compose a DW-branded card from a loaded pattern image at W×H. Returns a
-    // dataURL (PNG). This is the transformative brand creative — the vendor's photo
-    // becomes a cropped texture field inside DW-owned chrome (frame + wordmark +
-    // availability strip + pattern name), NOT a reposted photo.
-    const composeBrandedCard = (img, W, H, opts) => {
+    // ── Template style (Feature 2, TK-10843): editorial is DEFAULT, bold is a toggle.
+    // Persisted in localStorage so the choice sticks across posts/sessions.
+    const STYLE_KEY = `mcc.vamp.${LS_V}.cardStyle`;
+    const getCardStyle = () => (lsGet(STYLE_KEY) === 'bold' ? 'bold' : 'editorial');
+    const setCardStyle = (s) => lsSet(STYLE_KEY, s === 'bold' ? 'bold' : 'editorial');
+
+    // Deep DW brand color for the bold band.
+    const DW_BRAND = '#2a2318';   // deep DW ink (matches the wordmark)
+    const DW_BRAND_ACCENT = '#8a6d2f';
+
+    // Cover-fit (center-crop) a loaded pattern into the target field. Shared by both
+    // templates so the pattern crop is identical; only the chrome differs.
+    const drawPatternCover = (ctx, img, fx, fy, fw, fh) => {
+      const ir = img.width / img.height, fr = fw / fh;
+      let sw = img.width, sh = img.height, sx = 0, sy = 0;
+      if (ir > fr) { sw = Math.round(img.height * fr); sx = Math.round((img.width - sw) / 2); }
+      else { sh = Math.round(img.width / fr); sy = Math.round((img.height - sh) / 2); }
+      ctx.save();
+      ctx.beginPath(); ctx.rect(fx, fy, fw, fh); ctx.clip();
+      ctx.drawImage(img, sx, sy, sw, sh, fx, fy, fw, fh);
+      ctx.restore();
+    };
+
+    // EDITORIAL template (the DEFAULT): warm paper ground, serif wordmark top-left,
+    // hairline double-rule frame, centered pattern name + availability strip. This is
+    // the original "Make it ours" look, unchanged.
+    const composeEditorial = (img, W, H, opts) => {
       const brand = (opts && opts.brand) || '';
       const label = (opts && opts.label) || '';
       const cv = document.createElement('canvas');
@@ -502,15 +530,7 @@ window.MCC_PANELS['vendors'] = {
       const pad = Math.round(W * 0.055);
       const stripH = Math.round(H * 0.135);
       const fx = pad, fy = pad, fw = W - pad * 2, fh = H - pad * 2 - stripH;
-      // Cover-fit the pattern into the field (center-crop).
-      const ir = img.width / img.height, fr = fw / fh;
-      let sw = img.width, sh = img.height, sx = 0, sy = 0;
-      if (ir > fr) { sw = Math.round(img.height * fr); sx = Math.round((img.width - sw) / 2); }
-      else { sh = Math.round(img.width / fr); sy = Math.round((img.height - sh) / 2); }
-      ctx.save();
-      ctx.beginPath(); ctx.rect(fx, fy, fw, fh); ctx.clip();
-      ctx.drawImage(img, sx, sy, sw, sh, fx, fy, fw, fh);
-      ctx.restore();
+      drawPatternCover(ctx, img, fx, fy, fw, fh);
       // Hairline frame around the pattern field.
       ctx.strokeStyle = '#3a3226'; ctx.lineWidth = Math.max(1.5, W * 0.0016);
       ctx.strokeRect(fx + 0.5, fy + 0.5, fw - 1, fh - 1);
@@ -541,10 +561,58 @@ window.MCC_PANELS['vendors'] = {
       return cv.toDataURL('image/png');
     };
 
-    // Compose both feed sizes from one loaded pattern. Returns { '1080x1080', '1080x1350' }.
-    const composeBoth = (img, brand, label) => ({
-      '1080': composeBrandedCard(img, 1080, 1080, { brand, label }),
-      '1350': composeBrandedCard(img, 1080, 1350, { brand, label }),
+    // BOLD template (Feature 2): a punchier social card — the pattern fills nearly the
+    // whole frame, and a full-width DEEP DW-brand band across the BOTTOM carries a
+    // large wordmark + pattern name + availability strip in high-contrast light type.
+    // Same 1080×1080 / 1080×1350 outputs, same availability strip + pattern name.
+    const composeBold = (img, W, H, opts) => {
+      const brand = (opts && opts.brand) || '';
+      const label = (opts && opts.label) || '';
+      const cv = document.createElement('canvas');
+      cv.width = W; cv.height = H;
+      const ctx = cv.getContext('2d');
+      // Full-bleed pattern behind everything (fills the whole frame).
+      const bandH = Math.round(H * 0.235);      // deep brand band along the bottom
+      drawPatternCover(ctx, img, 0, 0, W, H - bandH);
+      // A thin accent rule where the pattern meets the band.
+      ctx.fillStyle = DW_BRAND_ACCENT;
+      ctx.fillRect(0, H - bandH - Math.max(4, Math.round(H * 0.006)), W, Math.max(4, Math.round(H * 0.006)));
+      // Deep DW-brand band.
+      ctx.fillStyle = DW_BRAND;
+      ctx.fillRect(0, H - bandH, W, bandH);
+      const bandTop = H - bandH;
+      const padX = Math.round(W * 0.06);
+      // Large light wordmark, left-aligned.
+      ctx.textAlign = 'left';
+      ctx.textBaseline = 'alphabetic';
+      ctx.fillStyle = '#f7f2e8';
+      const wm = Math.round(W * 0.052);
+      ctx.font = `700 ${wm}px Georgia, "Times New Roman", serif`;
+      ctx.fillText('DESIGNER WALLCOVERINGS', padX, bandTop + Math.round(bandH * 0.40));
+      // Pattern / vendor name, accent color, beneath the wordmark.
+      const nm = (label || brand || 'Featured pattern').replace(/\s+/g, ' ').trim().slice(0, 48);
+      ctx.fillStyle = '#e8d9b0';
+      const nmSize = Math.round(W * 0.038);
+      ctx.font = `600 ${nmSize}px Georgia, "Times New Roman", serif`;
+      ctx.fillText(nm, padX, bandTop + Math.round(bandH * 0.66));
+      // Availability strip (same copy as editorial), lighter weight.
+      ctx.fillStyle = 'rgba(247,242,232,0.72)';
+      const avSize = Math.round(W * 0.023);
+      ctx.font = `500 ${avSize}px Georgia, "Times New Roman", serif`;
+      ctx.fillText('Available at Designer Wallcoverings · designerwallcoverings.com', padX, bandTop + Math.round(bandH * 0.88));
+      ctx.textAlign = 'left';
+      return cv.toDataURL('image/png');
+    };
+
+    // Compose one card in the chosen style. `style` = 'editorial' (default) | 'bold'.
+    const composeBrandedCard = (img, W, H, opts) =>
+      ((opts && opts.style) === 'bold' ? composeBold : composeEditorial)(img, W, H, opts);
+
+    // Compose both feed sizes from one loaded pattern in the given style.
+    // Returns { '1080', '1350' }.
+    const composeBoth = (img, brand, label, style) => ({
+      '1080': composeBrandedCard(img, 1080, 1080, { brand, label, style }),
+      '1350': composeBrandedCard(img, 1080, 1350, { brand, label, style }),
     });
 
     // POST a data-URL asset to the server (settlement-gated save). Optionally attach
@@ -568,28 +636,97 @@ window.MCC_PANELS['vendors'] = {
       } catch { return null; }
     };
 
+    // ── Refresh-on-click fresh image (Feature 1, TK-10843) ───────────────────────
+    // The branded-card compositor sources kit.image — a SIGNED IG CDN url that
+    // expires (~a day). A cached post that's days old 403s → img-proxy 502s → the
+    // card can't compose. So BEFORE compositing we fetch a FRESH image url for this
+    // specific post via a targeted single-handle refresh, then feed the fresh url to
+    // the existing loadPatternImage → composeBrandedCard path.
+    //
+    // Matching (fresh post → the clicked one): IG media id FIRST, else permalink,
+    // else timestamp+caption. On a match we mutate the kit's image/permalink/mediaId/
+    // timestamp in place so the room-render path (which also reads kit.image) gets the
+    // fresh url too. Returns { ok, refreshed, error } — never throws.
+    const matchFresh = (kit, posts) => {
+      if (!Array.isArray(posts) || !posts.length) return null;
+      if (kit.mediaId) { const m = posts.find(p => p && p.id && String(p.id) === String(kit.mediaId)); if (m) return m; }
+      if (kit.permalink) { const m = posts.find(p => p && p.permalink && p.permalink === kit.permalink); if (m) return m; }
+      if (kit.timestamp) {
+        const src = String(kit.srcCaption || '').replace(/\s+/g, ' ').trim().slice(0, 80).toLowerCase();
+        const m = posts.find(p => p && p.timestamp === kit.timestamp &&
+          (!src || String(p.caption || '').replace(/\s+/g, ' ').trim().slice(0, 80).toLowerCase() === src));
+        if (m) return m;
+      }
+      return null;
+    };
+    const refreshFreshImage = async (kit) => {
+      const handle = (kit.handle || '').replace(/^@/, '').trim();
+      if (!handle) return { ok: false, error: 'no handle on this post' };
+      let j;
+      try {
+        j = await (await fetch(location.origin + '/api/vendors/posts/refresh-one', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
+          body: JSON.stringify({ handle }),
+        })).json();
+      } catch (e) { return { ok: false, error: 'refresh request failed (' + e.message + ')' }; }
+      if (!j || !j.ok) return { ok: false, error: (j && j.error) || 'refresh failed' };
+      const fresh = matchFresh(kit, j.posts);
+      if (!fresh) return { ok: false, error: 'no match', noMatch: true };
+      // Mutate the kit in place with the fresh fields (image is the point; the others
+      // keep future matches + the room path consistent).
+      if (fresh.image) kit.image = fresh.image;
+      if (fresh.id) kit.mediaId = fresh.id;
+      if (fresh.permalink) kit.permalink = fresh.permalink;
+      if (fresh.timestamp) kit.timestamp = fresh.timestamp;
+      return { ok: true, refreshed: true };
+    };
+
     // Render the creative review area for one post: branded-card generator (default,
-    // $0) + room-setting generator (shows cost before generating). Rebuilt on demand.
-    const creativeState = {};   // ampId -> { cards:{1080,1350}, saved:[], runningTotal }
+    // $0, editorial|bold) + room-setting generator (shows cost before generating).
+    // Rebuilt on demand.
+    // st: { runningTotal, img?(loaded fresh HTMLImageElement, cached across style
+    //   switches), cards?{1080,1350}, phase:'idle'|'fetching'|'ready'|'error',
+    //   fetchNote?(graceful-degrade message), triedFresh?(bool) }
+    const creativeState = {};
+    const composeFromStateImg = (ampId) => {
+      const st = creativeState[ampId]; const kit = ampKits[ampId];
+      if (!st || !kit || !st.img) return;
+      st.cards = composeBoth(st.img, kit.brand, kit.brand, getCardStyle());
+    };
     const renderCreative = (ampId) => {
       const kit = ampKits[ampId]; if (!kit) return;
       const host = root.querySelector(`[data-amp-creative="${ampId}"]`);
       if (!host) return;
       host.hidden = false;
-      const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0 });
-      const cardHtml = st.cards
-        ? `<div class="amp-cre-cards">
-            <figure class="amp-cre-fig"><img src="${st.cards['1080']}" alt="branded card 1080×1080"><figcaption>Feed · 1080×1080</figcaption>
+      const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0, phase: 'idle' });
+      const style = getCardStyle();
+      // Editorial | Bold segmented control (default = Editorial; persisted).
+      const seg = `<span class="amp-seg" role="group" aria-label="Card template">
+          <button type="button" class="amp-seg-btn${style === 'editorial' ? ' is-on' : ''}" data-cre="style" data-style="editorial" aria-pressed="${style === 'editorial'}">Editorial</button>
+          <button type="button" class="amp-seg-btn${style === 'bold' ? ' is-on' : ''}" data-cre="style" data-style="bold" aria-pressed="${style === 'bold'}">Bold</button>
+        </span>`;
+      let cardHtml;
+      if (st.cards) {
+        cardHtml = `<div class="amp-cre-cards">
+            <figure class="amp-cre-fig"><img src="${st.cards['1080']}" alt="branded card 1080×1080"><figcaption>Feed · 1080×1080 · ${style}</figcaption>
               <button type="button" class="amp-mini" data-cre="use" data-var="1080" title="Save + attach this to the draft (replaces the raw vendor photo)">Use this in the draft</button></figure>
-            <figure class="amp-cre-fig"><img src="${st.cards['1350']}" alt="branded card 1080×1350"><figcaption>Portrait · 1080×1350</figcaption>
+            <figure class="amp-cre-fig"><img src="${st.cards['1350']}" alt="branded card 1080×1350"><figcaption>Portrait · 1080×1350 · ${style}</figcaption>
               <button type="button" class="amp-mini" data-cre="use" data-var="1350" title="Save + attach this to the draft (replaces the raw vendor photo)">Use this in the draft</button></figure>
-          </div>`
-        : `<div class="amp-cre-empty">Generating branded card…</div>`;
+          </div>`;
+      } else if (st.phase === 'error') {
+        // GRACEFUL DEGRADE: clear inline message, retry button, no hard-error.
+        cardHtml = `<div class="amp-cre-empty amp-cre-degrade">
+            <span style="color:#a1341f">${esc(st.fetchNote || "Couldn't fetch a fresh image — click ↻ Refresh posts, then retry")}</span>
+            <button type="button" class="amp-mini" data-cre="retry-card" style="margin-left:8px" title="Try fetching a fresh image again">↻ Retry</button>
+          </div>`;
+      } else {
+        cardHtml = `<div class="amp-cre-empty">${esc(st.phase === 'fetching' ? 'Fetching a fresh image…' : 'Generating branded card…')}</div>`;
+      }
       host.innerHTML = `
         <div class="amp-cre-hd"><b>✦ Make it ours</b> <span class="muted">— a DW-original asset (never reposts the vendor's photo)</span></div>
         <div class="amp-cre-block">
-          <div class="amp-cre-row"><span class="amp-cre-t">1 · Branded card</span><span class="amp-cost">$0 (local)</span>
-            <button type="button" class="amp-mini" data-cre="regen-card" title="Re-composite the branded card">↻ Regenerate</button></div>
+          <div class="amp-cre-row"><span class="amp-cre-t">1 · Branded card</span>${seg}<span class="amp-cost">$0 (local)</span>
+            <button type="button" class="amp-mini" data-cre="regen-card" title="Fetch a fresh image + re-composite">↻ Regenerate</button></div>
           ${cardHtml}
         </div>
         <div class="amp-cre-block">
@@ -597,16 +734,44 @@ window.MCC_PANELS['vendors'] = {
             <button type="button" class="amp-btn amp-strong" data-cre="room" title="Generate an ORIGINAL DW room mockup with this pattern on the wall (small cost)">Generate room render</button></div>
           <div class="amp-cre-room" data-cre-room="${ampId}"></div>
         </div>
-        <div class="amp-cre-note muted">Settlement-gated before save · nothing auto-posts · posting is Steve-gated.${st.runningTotal ? ` <b>Session render spend: $${st.runningTotal.toFixed(2)}</b>` : ''}</div>`;
-      // Kick off the $0 branded-card composite if not done yet.
-      if (!st.cards) {
-        loadPatternImage(kit.image).then(img => {
-          st.cards = composeBoth(img, kit.brand, kit.brand);
+        <div class="amp-cre-note muted">Refresh-on-click keeps the pattern image fresh · settlement-gated before save · nothing auto-posts · posting is Steve-gated.${st.runningTotal ? ` <b>Session render spend: $${st.runningTotal.toFixed(2)}</b>` : ''}</div>`;
+
+      // Kick off the fetch+composite pipeline if we don't already have cards.
+      if (!st.cards && st.phase !== 'fetching') {
+        st.phase = 'fetching';
+        // Re-render once so the "Fetching a fresh image…" state paints immediately.
+        const showFetching = root.querySelector(`[data-amp-creative="${ampId}"] .amp-cre-empty`);
+        if (showFetching) showFetching.textContent = 'Fetching a fresh image…';
+        (async () => {
+          // 1) Refresh-on-click: get a FRESH image url for this specific post.
+          let note = '';
+          if (!st.triedFresh) {
+            st.triedFresh = true;
+            const r = await refreshFreshImage(kit);
+            if (!r.ok) {
+              // Don't hard-fail yet — the cached url MIGHT still be valid. Remember the
+              // reason to surface if the cached load also fails.
+              note = r.noMatch
+                ? "Couldn't match a fresh image for this post — click ↻ Refresh posts, then retry"
+                : `Couldn't fetch a fresh image (${r.error}) — click ↻ Refresh posts, then retry`;
+            }
+          }
+          // 2) Load (fresh url if we got one, else the cached url) via the same-origin
+          //    proxy so the canvas stays untainted for toDataURL readback.
+          try {
+            const img = await loadPatternImage(kit.image);
+            st.img = img;
+            composeFromStateImg(ampId);
+            st.phase = 'ready';
+            st.fetchNote = '';
+          } catch (err) {
+            st.phase = 'error';
+            // Prefer the refresh-degrade note; else the load error (both point the
+            // user at ↻ Refresh posts). Never throw — inline message only.
+            st.fetchNote = note || `Couldn't fetch a fresh image (${err.message}) — click ↻ Refresh posts, then retry`;
+          }
           renderCreative(ampId);
-        }).catch(err => {
-          const h = root.querySelector(`[data-amp-creative="${ampId}"] .amp-cre-empty`);
-          if (h) h.innerHTML = `<span style="color:#a1341f">Couldn't build the branded card: ${esc(err.message)}</span>`;
-        });
+        })();
       }
     };
 
@@ -762,11 +927,25 @@ window.MCC_PANELS['vendors'] = {
           if (!creEl) return;
           const ampId = creEl.getAttribute('data-amp-creative');
           const kit = ampKits[ampId]; if (!kit) return;
-          const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0 });
+          const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0, phase: 'idle' });
           const kind = creBtn.dataset.cre;
 
-          if (kind === 'regen-card') {
-            st.cards = null;
+          // Editorial | Bold toggle (Feature 2). Persist the choice; re-composite from
+          // the CACHED fresh image (no re-fetch) when we already have it — else fall
+          // through to the fetch pipeline via renderCreative.
+          if (kind === 'style') {
+            const next = creBtn.dataset.style === 'bold' ? 'bold' : 'editorial';
+            if (getCardStyle() === next && st.cards) return;   // no-op if already shown
+            setCardStyle(next);
+            if (st.img) { composeFromStateImg(ampId); }        // instant re-composite
+            else { st.cards = null; }                          // let renderCreative fetch
+            renderCreative(ampId);
+            return;
+          }
+
+          // Regenerate / retry — force a FRESH refresh-on-click fetch + re-composite.
+          if (kind === 'regen-card' || kind === 'retry-card') {
+            st.cards = null; st.img = null; st.triedFresh = false; st.phase = 'idle'; st.fetchNote = '';
             renderCreative(ampId);
             return;
           }
@@ -803,7 +982,9 @@ window.MCC_PANELS['vendors'] = {
             try {
               // Route the pattern through the same-origin proxy → dataURL so the
               // server gets a clean base64 (never a tainted / CORS-blocked read).
-              const img = await loadPatternImage(kit.image);
+              // Reuse the fresh image the branded card already loaded (refresh-on-click
+              // mutated kit.image), else load kit.image now.
+              const img = st.img || await loadPatternImage(kit.image);
               const cv = document.createElement('canvas');
               const MAX = 1024;
               const scale = Math.min(1, MAX / Math.max(img.width, img.height));
@@ -928,10 +1109,10 @@ window.MCC_PANELS['vendors'] = {
     }
 
     // Inline last-3 listing styling (idempotent; id bumped so it re-injects over old grid CSS)
-    if (!document.getElementById('vend-ig-css4')) {
+    if (!document.getElementById('vend-ig-css5')) {
       // remove the prior versions so the bumped stylesheet fully supersedes them
-      ['vend-ig-css2', 'vend-ig-css3'].forEach(oid => { const o = document.getElementById(oid); if (o) o.remove(); });
-      const st = document.createElement('style'); st.id = 'vend-ig-css4';
+      ['vend-ig-css2', 'vend-ig-css3', 'vend-ig-css4'].forEach(oid => { const o = document.getElementById(oid); if (o) o.remove(); });
+      const st = document.createElement('style'); st.id = 'vend-ig-css5';
       st.textContent = `
       .ig-list{display:flex;flex-direction:column;gap:4px;margin-top:8px}
       .ig-li{display:flex;gap:9px;align-items:center;padding:5px 7px;border:1px solid var(--line);border-radius:8px;background:#fbf9f4;text-decoration:none;color:inherit}
@@ -997,7 +1178,14 @@ window.MCC_PANELS['vendors'] = {
       .amp-cre-room{margin-top:9px}
       .amp-cre-room .amp-cre-fig,.amp-cre-room .amp-cre-fig img{max-width:320px}
       .amp-cre-empty{font-size:11.5px;color:var(--muted,#8a8372);padding:6px 2px}
-      .amp-cre-note{font-size:10.5px;margin-top:2px}`;
+      .amp-cre-note{font-size:10.5px;margin-top:2px}
+      /* Editorial | Bold segmented control (Make it ours template toggle) */
+      .amp-seg{display:inline-flex;border:1px solid #c9b98f;border-radius:7px;overflow:hidden}
+      .amp-seg-btn{font-size:10.5px;line-height:1;padding:3px 9px;border:0;background:transparent;color:#8a6d2f;cursor:pointer}
+      .amp-seg-btn+.amp-seg-btn{border-left:1px solid #dccea6}
+      .amp-seg-btn:hover{background:#f4ecd6}
+      .amp-seg-btn.is-on{background:#2a2318;color:#f7f2e8}
+      .amp-cre-degrade{display:flex;align-items:center;flex-wrap:wrap;gap:4px}`;
       document.head.appendChild(st);
     }
 

← f91859a Refine Owned·DW Fleet: surface dormant + repetition health s  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-08-25T10:46:10 (1 data files) — pub 834187d →