[object Object]

← back to Marketing Command Center

Embed all 35 DW-owned Instagram accounts across every social/account panel

093bb5705fb67a0fc22ab9b346bdcb94ad5e69d2 · 2026-08-25 09:30:19 -0700 · Steve

Add one canonical accounts source (data/dw-ig-accounts.json, built from Norma's
registry by scripts/build-dw-accounts.js) served at GET /api/dw-accounts and read
by a shared public/dw-accounts.js helper. Retrofit the 9 social/account panels
(ig-activity, accounts, channels, composer, social, follow-counts, quickpost,
vendors, board) to union the canonical 35 with each panel's dynamic data so every
owned account shows everywhere — not just the ones with posts/data on a surface.
No new nav panel (endpoint mounts directly like clients-notes).

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

Files touched

Diff

commit 093bb5705fb67a0fc22ab9b346bdcb94ad5e69d2
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 09:30:19 2026 -0700

    Embed all 35 DW-owned Instagram accounts across every social/account panel
    
    Add one canonical accounts source (data/dw-ig-accounts.json, built from Norma's
    registry by scripts/build-dw-accounts.js) served at GET /api/dw-accounts and read
    by a shared public/dw-accounts.js helper. Retrofit the 9 social/account panels
    (ig-activity, accounts, channels, composer, social, follow-counts, quickpost,
    vendors, board) to union the canonical 35 with each panel's dynamic data so every
    owned account shows everywhere — not just the ones with posts/data on a surface.
    No new nav panel (endpoint mounts directly like clients-notes).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/dw-ig-accounts.json       | 182 +++++++++++++++++++++++++++++++++++++++++
 package.json                   |   3 +-
 public/dw-accounts.js          |  50 +++++++++++
 public/index.html              |   1 +
 public/panels/accounts.html    |   2 +
 public/panels/accounts.js      |  55 ++++++++++++-
 public/panels/board.html       |   5 ++
 public/panels/board.js         |  56 ++++++++++++-
 public/panels/channels.js      |  25 +++++-
 public/panels/composer.html    |  21 +++++
 public/panels/composer.js      |  58 ++++++++++++-
 public/panels/follow-counts.js |  55 ++++++++-----
 public/panels/ig-activity.js   |  12 ++-
 public/panels/quickpost.html   |   4 +
 public/panels/quickpost.js     |  13 ++-
 public/panels/social.html      |   8 ++
 public/panels/social.js        |  29 +++++++
 public/panels/vendors.html     |   9 +-
 public/panels/vendors.js       |  22 +++++
 scripts/build-dw-accounts.js   |  64 +++++++++++++++
 server.js                      |  15 ++++
 21 files changed, 648 insertions(+), 41 deletions(-)

diff --git a/data/dw-ig-accounts.json b/data/dw-ig-accounts.json
new file mode 100644
index 0000000..e3f8b94
--- /dev/null
+++ b/data/dw-ig-accounts.json
@@ -0,0 +1,182 @@
+{
+  "source": "Norma/agents/instagram-agent/accounts.json",
+  "generated_at": "2026-08-25T16:17:35.584Z",
+  "count": 35,
+  "accounts": [
+    {
+      "handle": "asseeninhotels",
+      "name": "As Seen in Hotels",
+      "ig_user_id": "17841409119139761"
+    },
+    {
+      "handle": "beverlyhillsvideos",
+      "name": "Designer Wallcoverings",
+      "ig_user_id": "17841408681887144"
+    },
+    {
+      "handle": "borninbeverlyhills",
+      "name": "Born in Beverly Hills",
+      "ig_user_id": "17841408772995866"
+    },
+    {
+      "handle": "brazilliancewallpaper",
+      "name": "Brazilliance Wallpaper and Fabric",
+      "ig_user_id": "17841407025318686"
+    },
+    {
+      "handle": "chinoiseriewallpaper",
+      "name": "Et Cie Chinoiserie Wall Murals",
+      "ig_user_id": "17841407176278551"
+    },
+    {
+      "handle": "commercialwallcoverings",
+      "name": "Commercial Wallcoverings",
+      "ig_user_id": "17841408775091102"
+    },
+    {
+      "handle": "customwallcoverings",
+      "name": "Custom Wall Papers and Wallcoverings",
+      "ig_user_id": "17841408685240867"
+    },
+    {
+      "handle": "designerlaboratory",
+      "name": "Designer Laboratory",
+      "ig_user_id": "17841405610219019"
+    },
+    {
+      "handle": "designerwallcoverings",
+      "name": "DesignerWallcoverings.com",
+      "ig_user_id": "17841400781263605"
+    },
+    {
+      "handle": "fabric_fridays",
+      "name": "Fabric Fridays",
+      "ig_user_id": "17841434885415391"
+    },
+    {
+      "handle": "ffepurchasing",
+      "name": "FFE Purchasing",
+      "ig_user_id": "17841407058348607"
+    },
+    {
+      "handle": "goldleafwallpaper",
+      "name": "Gold Leaf Wallpaper",
+      "ig_user_id": "17841408856660866"
+    },
+    {
+      "handle": "grassclothwallpaper",
+      "name": "Wallpaper Weekly",
+      "ig_user_id": "17841408868631110"
+    },
+    {
+      "handle": "hollywoodwallcoverings",
+      "name": "Hollywood Wallcoverings",
+      "ig_user_id": "17841406772943912"
+    },
+    {
+      "handle": "hospitalitywallcoverings",
+      "name": "Hospitality Wallpaper",
+      "ig_user_id": "17841407188578748"
+    },
+    {
+      "handle": "interiordesignervideos",
+      "name": "Interior Designer Videos",
+      "ig_user_id": "17841408790905574"
+    },
+    {
+      "handle": "linenwallpaper",
+      "name": "Linen Wallpaper",
+      "ig_user_id": "17841408700186822"
+    },
+    {
+      "handle": "metallicwallpaper",
+      "name": "Metallic Wallpaper",
+      "ig_user_id": "17841408654197157"
+    },
+    {
+      "handle": "papelestapiz",
+      "name": "Papel Tapiz",
+      "ig_user_id": "17841407051208925"
+    },
+    {
+      "handle": "patterndesignlab",
+      "name": "Pattern Design Lab",
+      "ig_user_id": "17841405752089037"
+    },
+    {
+      "handle": "philliperomanodesigns",
+      "name": "Phillipe Romano",
+      "ig_user_id": "17841406249858383"
+    },
+    {
+      "handle": "restorationwallpaper",
+      "name": "Restoration Wallpaper",
+      "ig_user_id": "17841408840359842"
+    },
+    {
+      "handle": "retrowalls",
+      "name": "Retro Walls",
+      "ig_user_id": "17841406464478261"
+    },
+    {
+      "handle": "roomsettings",
+      "name": "Room Settings",
+      "ig_user_id": "17841408698848037"
+    },
+    {
+      "handle": "screenprintedwallpaper",
+      "name": "Screen Printed Wallpaper",
+      "ig_user_id": "17841408660869742"
+    },
+    {
+      "handle": "sheltermagazines",
+      "name": "Shelter Magazines",
+      "ig_user_id": "17841406881694118"
+    },
+    {
+      "handle": "suedewallpaper",
+      "name": "Suede Wallpaper",
+      "ig_user_id": "17841408648437136"
+    },
+    {
+      "handle": "textilewallpaper",
+      "name": "Textile Wallpaper",
+      "ig_user_id": "17841408813467105"
+    },
+    {
+      "handle": "thedesignerlibrary",
+      "name": "Designer Library",
+      "ig_user_id": "17841408572952750"
+    },
+    {
+      "handle": "thesetdecorator",
+      "name": "The Set Decorator",
+      "ig_user_id": "17841406774743877"
+    },
+    {
+      "handle": "traditionalwhimsy",
+      "name": "Traditional Whimsy",
+      "ig_user_id": "17841408645959795"
+    },
+    {
+      "handle": "velvetwallpaper",
+      "name": "Velvet Wallpaper",
+      "ig_user_id": "17841408844140138"
+    },
+    {
+      "handle": "wallpaperhistory",
+      "name": "Wallpaper History",
+      "ig_user_id": "17841408790217103"
+    },
+    {
+      "handle": "wallpaperinstallers",
+      "name": "Wallpaper Installers",
+      "ig_user_id": "17841414900195265"
+    },
+    {
+      "handle": "wallpaperwednesdays",
+      "name": "Wallpaper Wednesdays",
+      "ig_user_id": "17841409211532596"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index c461a33..4d2b042 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
   "main": "server.js",
   "scripts": {
     "start": "node server.js",
-    "build:followlist": "node scripts/build-followlist.js"
+    "build:followlist": "node scripts/build-followlist.js",
+    "build:dwaccounts": "node scripts/build-dw-accounts.js"
   },
   "dependencies": {
     "express": "^4.22.2",
diff --git a/public/dw-accounts.js b/public/dw-accounts.js
new file mode 100644
index 0000000..7650bf6
--- /dev/null
+++ b/public/dw-accounts.js
@@ -0,0 +1,50 @@
+// Shared canonical DW-owned Instagram accounts helper. Every social/account panel
+// uses this so all owned accounts render everywhere (not just where a surface has
+// data for them). Loaded once from index.html; the fetch is memoized so panels
+// re-init cheaply.
+//
+//   const accts = await window.MCC_ACCOUNTS.load();   // [{handle, name, ig_user_id}]
+//   window.MCC_ACCOUNTS.handles();                     // ['asseeninhotels', ...] (after load)
+//   window.MCC_ACCOUNTS.byHandle('linenwallpaper');    // {handle,name,...} | undefined
+//
+// merge(dynamicRows, keyFn) unions the canonical accounts with a panel's dynamic
+// data keyed by handle: every canonical account appears, annotated with its dynamic
+// row when one exists (else null) — the one pattern every panel repeats.
+(function () {
+  // Always hit the bare origin — a page opened with embedded URL creds would make
+  // a relative fetch throw "URL that includes credentials" (see app.js).
+  const ORIGIN = location.origin;
+  let cache = null;      // resolved [{handle,name,ig_user_id}]
+  let inflight = null;   // de-dupe concurrent loads
+
+  async function load() {
+    if (cache) return cache;
+    if (inflight) return inflight;
+    inflight = fetch(ORIGIN + '/api/dw-accounts', { credentials: 'same-origin' })
+      .then(r => r.ok ? r.json() : { accounts: [] })
+      .then(d => { cache = (d && d.accounts) || []; inflight = null; return cache; })
+      .catch(() => { inflight = null; return cache || []; });
+    return inflight;
+  }
+
+  function handles() { return (cache || []).map(a => a.handle); }
+  function byHandle(h) { return (cache || []).find(a => a.handle === h); }
+
+  // Union canonical accounts with a panel's dynamic rows. keyFn(row) → handle.
+  // Returns [{ ...account, data: <matching row | null> }] in canonical (sorted) order,
+  // then any dynamic rows whose handle isn't a known canonical account (rare).
+  function merge(rows, keyFn) {
+    const list = cache || [];
+    const byKey = new Map();
+    (rows || []).forEach(r => { const k = keyFn ? keyFn(r) : r.handle; if (k != null) byKey.set(String(k), r); });
+    const out = list.map(a => ({ ...a, data: byKey.get(a.handle) || null }));
+    const known = new Set(list.map(a => a.handle));
+    (rows || []).forEach(r => {
+      const k = String(keyFn ? keyFn(r) : r.handle);
+      if (k && !known.has(k)) out.push({ handle: k, name: k, ig_user_id: null, data: r });
+    });
+    return out;
+  }
+
+  window.MCC_ACCOUNTS = { load, handles, byHandle, merge };
+})();
diff --git a/public/index.html b/public/index.html
index 7345ac7..2743cfa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,6 +23,7 @@
   </header>
   <section id="panel"><div class="loading">Loading…</div></section>
 </main>
+<script src="/dw-accounts.js"></script>
 <script src="/quickpost.js"></script>
 <script src="/app.js"></script>
 </body></html>
diff --git a/public/panels/accounts.html b/public/panels/accounts.html
index f8913ea..4400e36 100644
--- a/public/panels/accounts.html
+++ b/public/panels/accounts.html
@@ -65,6 +65,7 @@
 .dot-ok{background:#3a8a4a}
 .dot-warn{background:#c79a3a}
 .dot-off{background:#c04a4a}
+.dot-known{background:#9aa0a6}
 
 /* right detail */
 .acc-main{min-height:200px}
@@ -75,6 +76,7 @@
 .badge-ok{background:#e3efe0;color:#2f6b34}
 .badge-warn{background:#fdf3dc;color:#8a6a1e}
 .badge-off{background:#f7e2e2;color:#a53a3a}
+.badge-known{background:#eceef0;color:#5a6066}
 .acc-handles{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
 .acc-note{font-size:13px;line-height:1.5;color:#4a443c;margin:6px 0}
 .acc-caveat{background:#fff8ec;border:1px solid #f0e0c0;border-radius:10px;padding:10px 12px;color:#7a5a2a;
diff --git a/public/panels/accounts.js b/public/panels/accounts.js
index ee69a14..f7eca88 100644
--- a/public/panels/accounts.js
+++ b/public/panels/accounts.js
@@ -23,18 +23,65 @@ window.MCC_PANELS['accounts'] = {
       { key: 'staged-only', label: 'Staged only',      dot: 'dot-warn' },
       { key: 'needs-creds', label: 'Needs credentials', dot: 'dot-off' },
     ];
-    const DOT = { 'connected': 'dot-ok', 'staged-only': 'dot-warn', 'needs-creds': 'dot-off' };
-    const BADGE = { 'connected': ['badge-ok', 'Connected · live'], 'staged-only': ['badge-warn', 'Connected · stages'], 'needs-creds': ['badge-off', 'Needs credentials'] };
+    // 'known' = a DW-owned IG account with no connection record yet — neutral,
+    // not an error. It lives under "All accounts" only (no dedicated facet).
+    const DOT = { 'connected': 'dot-ok', 'staged-only': 'dot-warn', 'needs-creds': 'dot-off', 'known': 'dot-known' };
+    const BADGE = { 'connected': ['badge-ok', 'Connected · live'], 'staged-only': ['badge-warn', 'Connected · stages'], 'needs-creds': ['badge-off', 'Needs credentials'], 'known': ['badge-known', 'Known · not yet connected'] };
 
     const state = { accounts: [], counts: {}, facet: 'all', q: '', selected: null };
     const rowTpl = $('#acc-row-tpl');
+    let CANON = [];   // the canonical 35 DW-owned Instagram accounts
+
+    // Union the canonical 35 DW-owned IG accounts into the list as individual
+    // Instagram accounts, keyed by handle. An owned handle already surfaced by
+    // the live 'instagram' platform (its `accounts` array) is NOT duplicated;
+    // the rest get a neutral "known / not yet connected" row with a display name.
+    // Additive only — LinkedIn/TikTok/etc. and the live platform rows are untouched.
+    function mergeCanonical(accounts) {
+      if (!CANON.length) return accounts;
+      const ig = accounts.find(a => a.platform === 'instagram');
+      const connectedHandles = new Set(
+        (ig && ig.accounts || []).map(h => String(h).replace(/^@/, '').toLowerCase()));
+      const extras = [];
+      for (const c of CANON) {
+        const h = String(c.handle || '').replace(/^@/, '').toLowerCase();
+        if (!h || connectedHandles.has(h)) continue;   // already shown on the live IG row
+        extras.push({
+          platform: 'ig:' + h,          // unique key (state.selected/filter key off platform)
+          label: c.name || c.handle,    // human display name
+          icon: '📸',
+          connected: false,
+          configured: false,
+          accounts: ['@' + h],
+          caveat: null,
+          needs: 'This DW-owned Instagram account has no connection record yet. Connect Instagram to post to it.',
+          scopes: [],
+          connectUrl: (ig && ig.connectUrl) || null,   // reuse the IG OAuth entry point if present
+          howToFix: (ig && ig.howToFix) || null,
+          bucket: 'known',
+        });
+      }
+      return accounts.concat(extras);
+    }
 
     async function load() {
       let d;
       try { d = await jget('/api/accounts/list'); }
       catch (_) { $('#acc-banner').textContent = 'Could not load accounts.'; return; }
-      state.accounts = d.accounts || [];
-      state.counts = d.counts || {};
+      // Load the canonical 35 once, then union them into the list.
+      if (!CANON.length && window.MCC_ACCOUNTS) {
+        try { CANON = await window.MCC_ACCOUNTS.load(); } catch (_) {}
+      }
+      state.accounts = mergeCanonical(d.accounts || []);
+      // Recompute counts client-side so the 'All accounts' total reflects the union
+      // (the API counts predate the merge). Per-status counts include the 'known' rows.
+      const merged = state.accounts.reduce((m, a) => (m[a.bucket] = (m[a.bucket] || 0) + 1, m), {});
+      state.counts = Object.assign({}, d.counts || {}, {
+        all: state.accounts.length,
+        connected: merged['connected'] || 0,
+        'staged-only': merged['staged-only'] || 0,
+        'needs-creds': merged['needs-creds'] || 0,
+      });
       const c = state.counts;
       $('#acc-banner').innerHTML =
         `<b>${c.connected || 0}</b> connected · <b>${c['staged-only'] || 0}</b> staged-only · <b>${c['needs-creds'] || 0}</b> need credentials — of ${c.all || 0} accounts. ` +
diff --git a/public/panels/board.html b/public/panels/board.html
index cf888fa..d94723a 100644
--- a/public/panels/board.html
+++ b/public/panels/board.html
@@ -10,6 +10,11 @@
     <label class="muted" style="font-size:12px">Width
       <input id="bd-width" type="range" min="240" max="420" step="10" value="300" style="vertical-align:middle">
     </label>
+    <label class="muted" style="font-size:12px">IG account
+      <select id="bd-ig-account" style="width:auto;font-size:12px;max-width:220px">
+        <option value="">All owned accounts</option>
+      </select>
+    </label>
     <a class="btn gold" href="#composer" style="text-decoration:none;font-size:12.5px;padding:6px 12px">✍️ Compose</a>
     <button class="btn ghost" id="bd-clear" style="font-size:12.5px;padding:6px 12px" title="Remove staged / pending / failed cards (keeps posted history)">🗑 Clear staged</button>
     <button class="btn ghost" id="bd-refresh" style="font-size:12.5px;padding:6px 12px">↻ Refresh</button>
diff --git a/public/panels/board.js b/public/panels/board.js
index f3df4d6..c84325e 100644
--- a/public/panels/board.js
+++ b/public/panels/board.js
@@ -28,9 +28,45 @@ window.MCC_PANELS['board'] = {
     const setWidth = w => { board.style.setProperty('--bd-w', w + 'px'); };
     let WIDTH = +(localStorage.getItem('mcc_board_w') || 300);
     let SORT = localStorage.getItem('mcc_board_sort') || 'newest';
+    let IG_HANDLE = localStorage.getItem('mcc_board_ig_handle') || '';
     $('#bd-width').value = WIDTH; setWidth(WIDTH);
     $('#bd-sort').value = SORT;
 
+    // ── Owned-Instagram account picker (canonical source: window.MCC_ACCOUNTS) ──
+    // Populates the IG-account <select> with all 35 DW-owned @handles. Choosing one
+    // filters the Instagram column's cards to that handle and relabels the column
+    // with the selected @handle; "All owned accounts" restores the full IG stream.
+    // Handle-matching is best-effort against whatever the outbox item carries
+    // (handle / account / igHandle / username), so it's additive — no card is lost
+    // when items don't yet carry a handle unless a specific account is chosen.
+    const igSel = $('#bd-ig-account');
+    async function loadIgAccounts() {
+      if (!igSel) return;
+      let accounts = [];
+      try {
+        if (window.MCC_ACCOUNTS && typeof window.MCC_ACCOUNTS.load === 'function') {
+          accounts = await window.MCC_ACCOUNTS.load();
+        } else {
+          const r = await jget('/api/dw-accounts');
+          accounts = (r && (r.accounts || r.items || (Array.isArray(r) ? r : []))) || [];
+        }
+      } catch { accounts = []; }
+      const opts = ['<option value="">All owned accounts</option>'].concat(
+        (accounts || []).map(a => {
+          const h = String((a && a.handle) || '').replace(/^@/, '');
+          if (!h) return '';
+          const nm = (a && a.name) ? ` — ${esc(a.name)}` : '';
+          return `<option value="${esc(h)}">@${esc(h)}${nm}</option>`;
+        }).filter(Boolean)
+      );
+      igSel.innerHTML = opts.join('');
+      igSel.value = IG_HANDLE;
+    }
+    // normalize any handle-bearing field on an outbox item, lowercased & @-stripped
+    const itemHandle = it => String(
+      (it && (it.handle || it.account || it.igHandle || it.ig_handle || it.username || it.accountHandle)) || ''
+    ).replace(/^@/, '').toLowerCase();
+
     // ── IG re-pull health strip (job that keeps published-IG thumbnails durable) ──
     // Inserted once above the board; shows last-run time + result + a health dot,
     // and a "Run now" fix-it button. The job runs hourly 6am–6pm on Kamatera cron;
@@ -157,15 +193,25 @@ window.MCC_PANELS['board'] = {
       }
       // ── Social: facebook / instagram / tiktok / youtube from the channels outbox ──
       const pf = DATA.platforms[col.id];
-      const items = sortItems(DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id));
+      let items = DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id);
+      // Instagram: when an owned account is picked, filter this column to that handle
+      // and relabel it with the @handle so it reads as a per-account stream.
+      const isIG = col.id === 'instagram';
+      let label = col.label;
+      if (isIG && IG_HANDLE) {
+        const want = IG_HANDLE.toLowerCase();
+        items = items.filter(it => itemHandle(it) === want);
+        label = '@' + IG_HANDLE;
+      }
+      items = sortItems(items);
       let pill;
       if (pf && pf.connected) pill = { cls: 'on', txt: 'Connected · live' };
       else if (pf) pill = { cls: 'stage', txt: 'Stages (not connected)' };
       else pill = { cls: 'off', txt: 'Not connected' };
       const body = items.length
         ? items.map(it => cardHTML({ when: it.at, text: it.caption, media: it.mediaUrl, status: it.status })).join('')
-        : `<div class="bd-empty">Nothing queued for ${esc(col.label)}.</div>`;
-      return { pill, count: items.length, body };
+        : `<div class="bd-empty">Nothing queued for ${esc(label)}.</div>`;
+      return { pill, count: items.length, body, label };
     }
 
     function render() {
@@ -175,7 +221,7 @@ window.MCC_PANELS['board'] = {
         return `<div class="bd-col ${col.soon ? 'soon' : ''}">
           <div class="bd-colhead">
             <div class="row1"><span class="ico">${col.icon || '▪'}</span>
-              <span class="nm">${esc(col.label)}</span>
+              <span class="nm">${esc(c.label != null ? c.label : col.label)}</span>
               <span class="cnt">${c.count}</span></div>
             <span class="bd-pill ${c.pill.cls}">${esc(c.pill.txt)}</span>
           </div>
@@ -187,6 +233,7 @@ window.MCC_PANELS['board'] = {
     // controls
     $('#bd-width').addEventListener('input', e => { WIDTH = +e.target.value; setWidth(WIDTH); localStorage.setItem('mcc_board_w', WIDTH); });
     $('#bd-sort').addEventListener('change', e => { SORT = e.target.value; localStorage.setItem('mcc_board_sort', SORT); render(); });
+    if (igSel) igSel.addEventListener('change', e => { IG_HANDLE = e.target.value; localStorage.setItem('mcc_board_ig_handle', IG_HANDLE); render(); });
     $('#bd-refresh').addEventListener('click', load);
     // Clear staged/pending/failed cards (keeps posted history), then reload the board.
     $('#bd-clear').addEventListener('click', async () => {
@@ -202,6 +249,7 @@ window.MCC_PANELS['board'] = {
       } finally { btn.disabled = false; btn.textContent = label; await load(); }
     });
 
+    await loadIgAccounts();
     await load();
     loadRepullHealth();
   },
diff --git a/public/panels/channels.js b/public/panels/channels.js
index 1b2b3d8..67d6ee7 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -24,6 +24,10 @@ window.MCC_PANELS['channels'] = {
     // ── Page picker (select WHICH FB Pages / IG accounts to post to) ──────────
     const PAGE_KEY = 'mcc-ch-pages';
     let allPages = [], pagesLoaded = false;
+    // Canonical 35 DW-owned IG accounts (loaded once). Every owned IG account must
+    // appear as a known/selectable account even when it's absent from the live
+    // /api/channels/pages response — it shows as "staged / not connected".
+    let CANON = [];
     const selectedPages = new Set((() => { try { return JSON.parse(localStorage.getItem(PAGE_KEY) || '[]'); } catch { return []; } })());
     const savePageSel = () => { try { localStorage.setItem(PAGE_KEY, JSON.stringify([...selectedPages])); } catch {} };
     const metaSelected = () => [...root.querySelectorAll('.ch-t:checked')].some(c => c.value === 'facebook' || c.value === 'instagram');
@@ -52,16 +56,18 @@ window.MCC_PANELS['channels'] = {
       const igOnly = igModeOn();
       const lbl = $('#ch-pages-label'); if (lbl) lbl.textContent = igOnly ? 'Instagram accounts' : 'Pages';
       const list = visiblePages();
+      // staged = owned IG account not in the live connector response → "not connected"
+      const stagedPill = '<span class="pill" style="background:#eef3f8;color:#3a5a8a;font-size:9.5px;padding:1px 7px" title="Owned account, not yet connected to the live posting connector — will stage">staged</span>';
       box.innerHTML = list.length ? list.map(p => igOnly
         ? `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
              <input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
              <span style="font-weight:600;white-space:nowrap">📸 @${esc(p.igUsername || '')}</span>
-             <span class="muted" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px" title="via Page: ${esc(p.name)}">${esc(p.name)}</span>
+             ${p.staged ? stagedPill : `<span class="muted" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px" title="via Page: ${esc(p.name)}">${esc(p.name)}</span>`}
            </label>`
         : `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
              <input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
              <span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(p.name)}</span>
-             ${p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : ''}
+             ${p.staged ? stagedPill : (p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : '')}
            </label>`).join('') : `<div class="muted">${igOnly ? 'No Instagram accounts match.' : 'No pages match.'}</div>`;
       box.querySelectorAll('.ch-pg').forEach(c => c.addEventListener('change', () => {
         if (c.checked) selectedPages.add(c.value); else selectedPages.delete(c.value);
@@ -84,11 +90,22 @@ window.MCC_PANELS['channels'] = {
       try {
         const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
         if (d.error) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(d.error)}</div>`; return; }
-        allPages = d.pages || []; pagesLoaded = true;
+        const livePages = d.pages || [];
+        // Merge the canonical 35 owned IG accounts in by handle (matched to a live
+        // page's igUsername). Every owned IG account that ISN'T in the live connector
+        // response is added as a synthetic "staged / not connected" IG account so it's
+        // still a known/selectable target. Live pages are preserved verbatim.
+        if (!CANON.length && window.MCC_ACCOUNTS) { try { CANON = await window.MCC_ACCOUNTS.load(); } catch {} }
+        const liveIgHandles = new Set(livePages.filter(p => p.hasIG && p.igUsername).map(p => String(p.igUsername).toLowerCase()));
+        const staged = (CANON || [])
+          .filter(a => a.handle && !liveIgHandles.has(String(a.handle).toLowerCase()))
+          .map(a => ({ id: 'staged:ig:' + a.handle, name: a.name || a.handle, igUsername: a.handle, ig_user_id: a.ig_user_id || null, hasIG: true, staged: true }));
+        allPages = livePages.concat(staged); pagesLoaded = true;
         // drop any stale selections no longer in the account
         const ids = new Set(allPages.map(p => p.id));
         [...selectedPages].forEach(id => { if (!ids.has(id)) selectedPages.delete(id); });
-        $('#ch-pages-note').textContent = `${d.count} Pages · ${d.igCount} with Instagram · fetched ${fmtWhen(d.fetchedAt)}`;
+        const igCount = (d.igCount || 0) + staged.length;
+        $('#ch-pages-note').textContent = `${d.count} Pages · ${igCount} with Instagram${staged.length ? ` (${staged.length} staged / not connected)` : ''} · fetched ${fmtWhen(d.fetchedAt)}`;
         renderPages();
       } catch (e) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(e.message)}</div>`; }
     }
diff --git a/public/panels/composer.html b/public/panels/composer.html
index 1e75702..be994f4 100644
--- a/public/panels/composer.html
+++ b/public/panels/composer.html
@@ -59,6 +59,18 @@
       <input id="cmp-fbsearch" class="cmp-search" style="margin-bottom:6px" placeholder="Filter pages…">
       <div id="cmp-fbpages-list" class="cmp-fbpages-list"><div class="loading">Loading…</div></div>
     </div>
+    <!-- DW-owned IG account targets — the 35 canonical accounts from window.MCC_ACCOUNTS -->
+    <div id="cmp-igaccounts" class="cmp-igaccounts">
+      <div class="cmp-igaccounts-head">
+        <span>📸 DW Instagram accounts</span>
+        <span class="cmp-igaccounts-tools">
+          <button id="cmp-igall" class="cmp-mini" type="button">all</button>
+          <button id="cmp-ignone" class="cmp-mini" type="button">none</button>
+        </span>
+      </div>
+      <input id="cmp-igsearch" class="cmp-search" style="margin-bottom:6px" placeholder="Filter accounts…">
+      <div id="cmp-igaccounts-list" class="cmp-igaccounts-list"><div class="loading">Loading…</div></div>
+    </div>
     <label class="cmp-dry"><input type="checkbox" id="cmp-dry" checked> Dry-run (stage, don't post live)</label>
     <button class="btn gold" id="cmp-publish" style="width:100%;font-size:14px;padding:11px">Publish</button>
     <div id="cmp-result" class="cmp-result"></div>
@@ -103,6 +115,15 @@
     font-size:26px;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.6);pointer-events:none}
   .cmp-reel .lbl{position:absolute;left:0;right:0;bottom:0;font-size:9.5px;background:rgba(0,0,0,.6);color:#fff;
     padding:3px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  /* DW IG account picker — mirrors the facebook page picker style */
+  .cmp-igaccounts{border:1px solid var(--line,#ece6da);border-radius:10px;padding:9px;margin-bottom:10px;background:#fbfaf6}
+  .cmp-igaccounts-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;margin-bottom:7px}
+  .cmp-igaccounts-tools{display:flex;gap:5px}
+  .cmp-igaccounts-list{max-height:170px;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
+  .cmp-igrow{display:flex;align-items:center;gap:7px;font-size:12px;padding:4px 6px;border-radius:7px;cursor:pointer}
+  .cmp-igrow:hover{background:#f2ede3}
+  .cmp-igrow .hd{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .cmp-igrow .nm{flex:1;font-size:9.5px;color:#8a8170;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
   /* facebook page picker */
   .cmp-fbpages{border:1px solid var(--line,#ece6da);border-radius:10px;padding:9px;margin-bottom:10px;background:#fbfaf6}
   .cmp-fbpages-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;margin-bottom:7px}
diff --git a/public/panels/composer.js b/public/panels/composer.js
index eca5788..5763a7d 100644
--- a/public/panels/composer.js
+++ b/public/panels/composer.js
@@ -7,7 +7,8 @@ window.MCC_PANELS['composer'] = {
     const jget = async u => { try { const r = await fetch(O + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
     const jpost = async (u, b) => { try { const r = await fetch(O + u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); return await r.json().catch(() => ({})); } catch (e) { return { error: e.message }; } };
 
-    const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set() };
+    const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set() };
+    const IG_LS_KEY = 'mccComposerIgHandles'; // persist the account selection across reloads
     const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
 
     // ── RIGHT rail: publish targets, connection-aware ──────────────────────────
@@ -71,6 +72,53 @@ window.MCC_PANELS['composer'] = {
       });
     });
 
+    // ── DW-owned IG account targets — canonical 35 from window.MCC_ACCOUNTS ─────
+    async function loadIgAccounts() {
+      const box = $('#cmp-igaccounts-list'); if (!box) return;
+      box.innerHTML = '<div class="loading">Loading…</div>';
+      let accts = [];
+      try { accts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
+      catch { accts = []; }
+      if (!accts.length) { box.innerHTML = '<div class="bd-empty" style="font-size:11.5px">No DW accounts available.</div>'; return; }
+      // restore any previously-chosen handles (persisted across reloads)
+      let saved = [];
+      try { saved = JSON.parse(localStorage.getItem(IG_LS_KEY) || '[]'); } catch { saved = []; }
+      const savedSet = new Set(saved);
+      state.handles.clear();
+      box.innerHTML = '';
+      accts.forEach(a => {                                       // already sorted by the helper
+        const h = a.handle || '';
+        if (!h) return;
+        const row = document.createElement('label'); row.className = 'cmp-igrow';
+        const checked = savedSet.has(h);
+        if (checked) state.handles.add(h);
+        row.innerHTML = `<input type="checkbox" data-handle="${esc(h)}"${checked ? ' checked' : ''}>` +
+          `<span class="hd">@${esc(h)}</span>` + (a.name ? `<span class="nm">${esc(a.name)}</span>` : '');
+        const cb = row.querySelector('input');
+        cb.addEventListener('change', () => { cb.checked ? state.handles.add(h) : state.handles.delete(h); persistHandles(); });
+        box.appendChild(row);
+      });
+      persistHandles();
+    }
+    function persistHandles() {
+      try { localStorage.setItem(IG_LS_KEY, JSON.stringify([...state.handles])); } catch {}
+    }
+    $('#cmp-igall').addEventListener('click', () => {
+      root.querySelectorAll('#cmp-igaccounts-list input[data-handle]').forEach(cb => { cb.checked = true; state.handles.add(cb.dataset.handle); });
+      persistHandles();
+    });
+    $('#cmp-ignone').addEventListener('click', () => {
+      root.querySelectorAll('#cmp-igaccounts-list input[data-handle]').forEach(cb => { cb.checked = false; });
+      state.handles.clear(); persistHandles();
+    });
+    $('#cmp-igsearch').addEventListener('input', e => {
+      const q = e.target.value.toLowerCase();
+      root.querySelectorAll('#cmp-igaccounts-list .cmp-igrow').forEach(r => {
+        const t = ((r.querySelector('.hd') || {}).textContent + ' ' + ((r.querySelector('.nm') || {}).textContent || '')).toLowerCase();
+        r.style.display = t.includes(q) ? '' : 'none';
+      });
+    });
+
     // ── LEFT rail: assets + banners ────────────────────────────────────────────
     async function loadSource(kind) {
       const grid = $('#cmp-srcgrid'); grid.innerHTML = '<div class="loading">Loading…</div>';
@@ -207,6 +255,10 @@ window.MCC_PANELS['composer'] = {
         channels, caption: cap.value.trim(),
         mediaUrl: state.mediaUrl, videoUrl: state.videoUrl,
         pages: [...state.pages],
+        // Which DW-owned IG accounts (by @handle) this post targets. Additive field —
+        // the existing gated publish/stage flow is unchanged; this just carries the
+        // user's per-account selection through to the server.
+        handles: [...state.handles],
         confirm: !dry, dryRun: dry,
       });
       if (r && r.results) {
@@ -221,7 +273,7 @@ window.MCC_PANELS['composer'] = {
       $('#cmp-publish').disabled = false; $('#cmp-publish').textContent = 'Publish';
     });
 
-    $('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); });
+    $('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); loadIgAccounts(); });
     $('#cmp-search').addEventListener('input', e => {
       const q = e.target.value.toLowerCase();
       root.querySelectorAll('#cmp-srcgrid .cmp-thumb').forEach(t => {
@@ -230,7 +282,7 @@ window.MCC_PANELS['composer'] = {
       });
     });
 
-    await Promise.all([loadTargets(), loadSource('assets')]);
+    await Promise.all([loadTargets(), loadSource('assets'), loadIgAccounts()]);
     countIt();
 
     // Quick Post — easy per-platform "stage a draft" buttons (gated). Reads the
diff --git a/public/panels/follow-counts.js b/public/panels/follow-counts.js
index ce1b77f..e8465b0 100644
--- a/public/panels/follow-counts.js
+++ b/public/panels/follow-counts.js
@@ -35,45 +35,62 @@ window.MCC_PANELS['follow-counts'] = {
       cards.innerHTML = '<div class="loading">Loading accounts…</div>';
       let d;
       try { d = await api('/accounts'); }
-      catch (e) { cards.innerHTML = `<div class="muted-banner">Failed to load: ${esc(e.message)}</div>`; return; }
-      banner(d.awaitingCreds, d.source);
-      const accts = d.accounts || [];
-      if (!accts.length) { cards.innerHTML = '<div class="muted-banner">No DW Instagram accounts configured.</div>'; return; }
-      cards.innerHTML = accts.map(a => {
-        const l = a.latest;
-        const simBadge = (!l || l.simulated)
-          ? `<span class="pill" style="background:#fff3cd;color:#7a5b00">awaiting creds</span>`
-          : `<span class="pill" style="background:#e6f4ea;color:#1a7f4b">live</span>`;
-        return `<div class="card fc-acct" data-handle="${esc(a.handle)}" style="cursor:pointer">
+      catch (e) { d = null; }
+      // Merge the canonical 35 DW-owned accounts with whatever /accounts returned
+      // (keyed by handle) so every owned account renders — with its follower/
+      // following snapshot where Norma has one, and a neutral "no data yet" where
+      // it doesn't (e.g. hosts that can't reach Norma :9810). Additive over the
+      // existing /accounts response; the per-account switch + chart still fire for
+      // accounts that DO have data.
+      await window.MCC_ACCOUNTS.load();
+      banner(d && d.awaitingCreds, d && d.source);
+      const merged = window.MCC_ACCOUNTS.merge((d && d.accounts) || [], a => a.handle);
+      if (!merged.length) { cards.innerHTML = '<div class="muted-banner">No DW Instagram accounts configured.</div>'; return; }
+      cards.innerHTML = merged.map(a => {
+        const row = a.data;                 // matching /accounts row, or null
+        const l = row && row.latest;
+        const label = (row && row.label) || a.name || a.handle;
+        const primary = !!(row && row.primary);
+        const hasData = !!row;
+        const badge = !hasData
+          ? `<span class="pill" style="background:#eee;color:#777">no data yet</span>`
+          : (!l || l.simulated)
+            ? `<span class="pill" style="background:#fff3cd;color:#7a5b00">awaiting creds</span>`
+            : `<span class="pill" style="background:#e6f4ea;color:#1a7f4b">live</span>`;
+        return `<div class="card fc-acct${hasData ? '' : ' fc-nodata'}" data-handle="${esc(a.handle)}"${hasData ? ' style="cursor:pointer"' : ''}>
           <div class="row" style="justify-content:space-between;align-items:flex-start">
             <div>
               <h2 style="margin:0">@${esc(a.handle)}</h2>
-              <div class="muted" style="font-size:12px">${esc(a.label)}${a.primary ? ' · primary' : ''}</div>
-            </div>${simBadge}
+              <div class="muted" style="font-size:12px">${esc(label)}${primary ? ' · primary' : ''}</div>
+            </div>${badge}
           </div>
           <div class="row" style="gap:24px;margin-top:14px">
             <div>
               <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.04em">Followers</div>
               <div style="font-size:30px;font-weight:700;font-family:'Cormorant Garamond',serif;line-height:1.1">${fmtN(l && l.followers)}</div>
-              <div style="font-size:12px;margin-top:2px">${a.delta ? fmtDelta(a.delta.followers) : '<span class="muted">—</span>'}</div>
+              <div style="font-size:12px;margin-top:2px">${row && row.delta ? fmtDelta(row.delta.followers) : '<span class="muted">—</span>'}</div>
             </div>
             <div>
               <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.04em">Following</div>
               <div style="font-size:30px;font-weight:700;font-family:'Cormorant Garamond',serif;line-height:1.1">${fmtN(l && l.following)}</div>
-              <div style="font-size:12px;margin-top:2px">${a.delta ? fmtDelta(a.delta.following) : '<span class="muted">—</span>'}</div>
+              <div style="font-size:12px;margin-top:2px">${row && row.delta ? fmtDelta(row.delta.following) : '<span class="muted">—</span>'}</div>
             </div>
           </div>
           <div class="muted" style="font-size:11px;margin-top:12px"
                title="${l ? esc(l.ts) : ''}">
-            ${l ? `Last snapshot: ${fmtTime(l.ts)} · ${a.points} day${a.points === 1 ? '' : 's'} of history` : 'No snapshot yet — click “Capture snapshot”.'}
+            ${!hasData ? 'No snapshot yet — Norma has no data for this owned account.'
+              : l ? `Last snapshot: ${fmtTime(l.ts)} · ${row.points} day${row.points === 1 ? '' : 's'} of history`
+                : 'No snapshot yet — click “Capture snapshot”.'}
           </div>
-          <div class="muted" style="font-size:11px;margin-top:2px">Click to chart growth →</div>
+          <div class="muted" style="font-size:11px;margin-top:2px">${hasData ? 'Click to chart growth →' : ''}</div>
         </div>`;
       }).join('');
-      cards.querySelectorAll('.fc-acct').forEach(c =>
+      // Only accounts WITH data are selectable / chartable.
+      cards.querySelectorAll('.fc-acct:not(.fc-nodata)').forEach(c =>
         c.onclick = () => selectAccount(c.dataset.handle));
-      // auto-select the first (primary) account for the chart
-      if (!selectedHandle && accts[0]) selectAccount(accts[0].handle);
+      // auto-select the first account that has data for the chart
+      const firstWithData = merged.find(a => a.data);
+      if (!selectedHandle && firstWithData) selectAccount(firstWithData.handle);
       else if (selectedHandle) renderChart();
     }
 
diff --git a/public/panels/ig-activity.js b/public/panels/ig-activity.js
index 8f5d80f..786ebae 100644
--- a/public/panels/ig-activity.js
+++ b/public/panels/ig-activity.js
@@ -14,6 +14,7 @@ window.MCC_PANELS['ig-activity'] = {
       { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return t || ''; } };
 
     let DATA = [];
+    let CANON = [];         // canonical DW-owned accounts (the 35) — so the filter lists ALL owned accounts, not just posted ones
     let SORT = localStorage.getItem('ig_sort') || 'ts|desc';
     let ACCT = localStorage.getItem('ig_acct') || '';
     let target = null;      // the post being deleted (modal context)
@@ -69,10 +70,16 @@ window.MCC_PANELS['ig-activity'] = {
     }
 
     function fillAccounts() {
-      const accts = [...new Set(DATA.map(x => x.handle))].sort();
+      // Union the canonical DW-owned accounts (all 35) with any handle that has
+      // posted — so every owned account is selectable even with zero posts (it
+      // then shows the "No posts." empty state). Canonical accounts with no posts
+      // are marked so it's obvious nothing has published yet.
+      const posted = new Set(DATA.map(x => x.handle));
+      const canon = CANON.map(a => a.handle);
+      const accts = [...new Set([...canon, ...posted])].sort();
       const sel = $('#ig-acct');
       sel.innerHTML = '<option value="">All accounts</option>' +
-        accts.map(a => `<option value="${esc(a)}">@${esc(a)}</option>`).join('');
+        accts.map(a => `<option value="${esc(a)}">@${esc(a)}${posted.has(a) ? '' : ' · no posts'}</option>`).join('');
       sel.value = ACCT;
     }
 
@@ -160,6 +167,7 @@ window.MCC_PANELS['ig-activity'] = {
     async function load() {
       $('#ig-grid').innerHTML = '<div class="loading">Loading posts…</div>';
       try {
+        if (!CANON.length && window.MCC_ACCOUNTS) { try { CANON = await window.MCC_ACCOUNTS.load(); } catch {} }
         const j = await api('/posts');
         DATA = j.posts || [];
         $('#ig-meta').textContent =
diff --git a/public/panels/quickpost.html b/public/panels/quickpost.html
index bcdac7c..831265e 100644
--- a/public/panels/quickpost.html
+++ b/public/panels/quickpost.html
@@ -7,6 +7,8 @@
     <div class="qp-preview" id="qp-preview"></div>
     <div class="qp-lbl" style="margin-top:10px">Pick an asset</div>
     <div id="qp-assets" class="qp-assets"><div class="muted" style="font-size:12px">Loading assets…</div></div>
+    <label class="qp-lbl" style="margin-top:12px">Account <span class="qp-hint">(which DW-owned IG account)</span></label>
+    <select id="qp-account"><option value="">Loading accounts…</option></select>
     <label class="qp-lbl" style="margin-top:12px">Caption</label>
     <textarea id="qp-caption" placeholder="Write your caption… #designerwallcoverings"></textarea>
     <div class="qp-lbl" style="margin-top:12px">Post to</div>
@@ -26,6 +28,8 @@
   .qp-compose,.qp-drafts{padding:14px}
   .qp-lbl{display:block;font-size:12px;font-weight:600;color:var(--ink);margin-bottom:5px}
   #qp-media{width:100%;box-sizing:border-box;margin-bottom:8px}
+  #qp-account{width:100%;box-sizing:border-box;font:inherit;font-size:13.5px;padding:9px 10px;
+    border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
   #qp-caption{width:100%;box-sizing:border-box;min-height:100px;font:inherit;font-size:13.5px;line-height:1.5;
     padding:10px;border:1px solid var(--line);border-radius:10px;resize:vertical}
   .qp-preview{min-height:0}
diff --git a/public/panels/quickpost.js b/public/panels/quickpost.js
index 329f9cf..06799f3 100644
--- a/public/panels/quickpost.js
+++ b/public/panels/quickpost.js
@@ -7,9 +7,18 @@ window.MCC_PANELS['quickpost'] = {
     const $ = s => root.querySelector(s);
     const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
       ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
-    const media = $('#qp-media'), cap = $('#qp-caption'), preview = $('#qp-preview');
+    const media = $('#qp-media'), cap = $('#qp-caption'), preview = $('#qp-preview'), account = $('#qp-account');
     const PICON = { instagram: '📷', tiktok: '🎵', facebook: '📘', linkedin: '💼' };
 
+    // populate the DW-owned IG account selector (all 35) from the canonical source
+    (async function loadAccounts() {
+      let accts = [];
+      try { accts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; } catch (_) {}
+      if (!accts.length) { account.innerHTML = '<option value="">No accounts found</option>'; return; }
+      account.innerHTML = accts.map(a =>
+        `<option value="${esc(a.handle)}">@${esc(a.handle)}${a.name ? ' — ' + esc(a.name) : ''}</option>`).join('');
+    })();
+
     function renderPreview() {
       const u = media.value.trim();
       if (!u) { preview.innerHTML = ''; return; }
@@ -22,7 +31,7 @@ window.MCC_PANELS['quickpost'] = {
     // wire the shared button row — payload is read live at click time
     if (window.MCCQuickPost) {
       window.MCCQuickPost.attach($('#qp-buttons'),
-        () => ({ caption: cap.value.trim(), mediaUrl: media.value.trim(), source: 'quickpost-panel' }),
+        () => ({ caption: cap.value.trim(), mediaUrl: media.value.trim(), handle: account.value || '', source: 'quickpost-panel' }),
         { live: true });
     } else {
       $('#qp-buttons').innerHTML = '<div class="muted" style="font-size:12px">Quick-post component not loaded.</div>';
diff --git a/public/panels/social.html b/public/panels/social.html
index a95ef28..4e9680c 100644
--- a/public/panels/social.html
+++ b/public/panels/social.html
@@ -25,6 +25,12 @@
             <label>Time</label>
             <input type="time" name="time">
           </div>
+          <div style="width:180px">
+            <label>IG account</label>
+            <select name="handle" id="soc-handle">
+              <option value="">— any / not set —</option>
+            </select>
+          </div>
           <div style="width:150px">
             <label>Status</label>
             <select name="status">
@@ -83,6 +89,7 @@
       <span class="sc-when muted"></span>
       <button class="sc-del" title="Remove" type="button">✕</button>
     </div>
+    <div class="sc-handle muted" hidden></div>
     <div class="sc-cap"></div>
     <div class="sc-tags"></div>
     <div class="sc-foot">
@@ -109,6 +116,7 @@
 .sc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
 .sc-chan{font-size:12px;font-weight:700}
 .sc-when{font-size:11px}
+.sc-handle{font-size:11px;font-weight:600;color:var(--accent);margin-bottom:5px}
 .sc-del{margin-left:auto;background:transparent;border:0;color:var(--mut);cursor:pointer;font-size:13px;padding:0 2px}
 .sc-del:hover{color:var(--accent)}
 .sc-cap{font-size:12.5px;line-height:1.45;color:#3f3a33;white-space:pre-wrap;word-break:break-word;
diff --git a/public/panels/social.js b/public/panels/social.js
index f34c3be..00ffb84 100644
--- a/public/panels/social.js
+++ b/public/panels/social.js
@@ -27,6 +27,25 @@ window.MCC_PANELS['social'] = {
     // live vs simulated instead of always saying "dry-run"). Filled by loadStatus().
     const state = { posts: [], editingId: null, live: false, backend: null, statusNote: '' };
 
+    // Populate the add-post form's IG-account picker from the canonical
+    // DW-owned accounts source (35 accounts). Instagram-only; showing it always
+    // is fine — it just names which owned account a scheduled post is for.
+    async function loadAccounts() {
+      const sel = $('#soc-handle');
+      if (!sel) return;
+      let accounts = [];
+      try {
+        accounts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || [];
+      } catch (_) { accounts = []; }
+      for (const a of accounts) {
+        if (!a || !a.handle) continue;
+        const o = document.createElement('option');
+        o.value = a.handle;
+        o.textContent = '@' + a.handle + (a.name ? ' — ' + a.name : '');
+        sel.appendChild(o);
+      }
+    }
+
     async function load() {
       try {
         const r = await fetch('/api/social/posts').then(r => r.json());
@@ -87,6 +106,12 @@ window.MCC_PANELS['social'] = {
         `${CHAN_ICON[p.channel] || '•'} ${CHAN_NAME[p.channel] || p.channel}`;
       node.querySelector('.sc-when').textContent = whenLabel(p);
 
+      const handleEl = node.querySelector('.sc-handle');
+      if (handleEl && p.handle) {
+        handleEl.textContent = '@' + p.handle;
+        handleEl.hidden = false;
+      }
+
       const cap = node.querySelector('.sc-cap');
       cap.textContent = p.caption || '(no caption yet)';
       if (!p.caption) cap.classList.add('muted');
@@ -213,6 +238,7 @@ window.MCC_PANELS['social'] = {
     function startEdit(p) {
       state.editingId = p.id;
       form.channel.value = p.channel || 'instagram';
+      if (form.handle) form.handle.value = p.handle || '';
       form.date.value = p.date || '';
       form.time.value = p.time || '';
       form.status.value = p.status || 'idea';
@@ -239,6 +265,7 @@ window.MCC_PANELS['social'] = {
       const body = {
         id: state.editingId || undefined,
         channel: fd.get('channel'),
+        handle: (fd.get('handle') || '').toString(),
         date: (fd.get('date') || '').toString(),
         time: (fd.get('time') || '').toString(),
         status: fd.get('status'),
@@ -283,6 +310,7 @@ window.MCC_PANELS['social'] = {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({
             id: state.editingId || undefined, channel,
+            handle: (fd.get('handle') || '').toString(),
             date: (fd.get('date') || '').toString(), time: (fd.get('time') || '').toString(),
             caption, hashtags: (fd.get('hashtags') || '').toString(),
             imageUrl: (fd.get('imageUrl') || '').toString().trim(),
@@ -347,5 +375,6 @@ window.MCC_PANELS['social'] = {
     loadStatus();
     load();
     loadBestTimes();
+    loadAccounts();   // populate the DW-owned IG account picker (all 35)
   },
 };
diff --git a/public/panels/vendors.html b/public/panels/vendors.html
index ec73221..0fe80e2 100644
--- a/public/panels/vendors.html
+++ b/public/panels/vendors.html
@@ -7,8 +7,13 @@
   <div style="margin-left:auto"><label>Sort</label>
     <select id="vs-sort"><option value="followers">Followers ↓</option><option value="brand">Brand A→Z</option><option value="code">Vendor code</option></select></div>
 </div>
+<div class="card" id="vend-owned-card">
+  <h2>Owned · DW Fleet</h2>
+  <div class="muted" style="margin-bottom:10px">Our own Instagram accounts across the DW fleet. Click any handle to open the account.</div>
+  <div id="vend-owned-rows"><div class="muted">Loading owned accounts…</div></div>
+</div>
 <div class="card">
-  <h2>DW + Vendor Instagram accounts</h2>
-  <div class="muted" style="margin-bottom:10px">Click any handle to open the account. Use this roster for competitive/social reporting.</div>
+  <h2>Vendor brands</h2>
+  <div class="muted" style="margin-bottom:10px">External vendor / brand accounts (suppliers &amp; competitors — NOT owned). Click any handle to open the account. Use this roster for competitive/social reporting.</div>
   <div id="vend-rows"></div>
 </div>
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index d85f370..4884940 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -17,6 +17,28 @@ window.MCC_PANELS['vendors'] = {
     };
     setStats();
 
+    // Owned · DW Fleet — all 35 DW-OWNED Instagram accounts from the canonical
+    // source (window.MCC_ACCOUNTS). Rendered as a native vend-block list, clearly
+    // separated ABOVE the external vendor roster. Additive; never touches vendors.
+    (async () => {
+      const host = root.querySelector('#vend-owned-rows');
+      if (!host) return;
+      let owned = [];
+      try { owned = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
+      catch { host.innerHTML = '<div class="muted">Failed to load owned accounts.</div>'; return; }
+      if (!owned.length) { host.innerHTML = '<div class="muted">No owned accounts found.</div>'; return; }
+      const rows = [...owned].sort((a, b) => String(a.name || a.handle).localeCompare(String(b.name || b.handle)));
+      host.innerHTML = rows.map(a => {
+        const h = esc(a.handle);
+        return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px;background:#faf6ee">
+          <div class="row" style="justify-content:space-between;align-items:center">
+            <div style="min-width:200px"><b>${esc(a.name || a.handle)}</b> <span class="pill">OURS</span></div>
+            <div style="flex:1"><a class="lnk" href="https://www.instagram.com/${h}/" target="_blank" rel="noopener noreferrer">@${h} ↗</a></div>
+          </div>
+        </div>`;
+      }).join('');
+    })();
+
     // Last 3 posts rendered INLINE as a stacked listing directly below each
     // vendor row (not a photo grid) — thumb + caption ↗ + date · likes · comments.
     const postList = (r) => {
diff --git a/scripts/build-dw-accounts.js b/scripts/build-dw-accounts.js
new file mode 100644
index 0000000..a034f6b
--- /dev/null
+++ b/scripts/build-dw-accounts.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+/*
+ * build-dw-accounts.js — regenerate data/dw-ig-accounts.json, the CANONICAL list
+ * of every DW-owned Instagram account, from Norma's postable-accounts registry.
+ *
+ * Why this exists: the Command Center's panels each used to derive their own
+ * account list (the IG-Activity dropdown from post handles only, follow-counts
+ * live from Norma, etc.), so an account with no posts/data silently vanished from
+ * a surface. This script promotes ONE source of truth that every social/account
+ * panel reads via GET /api/dw-accounts.
+ *
+ * Source (overridable via env):
+ *   NORMA_ACCOUNTS  (default ~/Projects/Norma/agents/instagram-agent/accounts.json)
+ *
+ * Runs on Mac2 (where Norma lives). The OUT file is committed so it ships to
+ * Kamatera, where Norma is unreachable — same reason the IG-delete flow degrades
+ * on prod. If the source is missing, the existing committed JSON is preserved.
+ * Idempotent; safe to run repeatedly.
+ *
+ *   npm run build:dwaccounts
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const HOME = os.homedir();
+const SRC = process.env.NORMA_ACCOUNTS ||
+  path.join(HOME, 'Projects', 'Norma', 'agents', 'instagram-agent', 'accounts.json');
+const OUT = path.join(__dirname, '..', 'data', 'dw-ig-accounts.json');
+
+const prev = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf8')) : { accounts: [] };
+
+if (!fs.existsSync(SRC)) {
+  console.log(`• Norma source missing (${SRC}) — preserving existing ${(prev.accounts || []).length} accounts`);
+  process.exit(0);
+}
+
+const reg = JSON.parse(fs.readFileSync(SRC, 'utf8'));
+const src = reg.accounts || {};
+
+// Normalize to a flat, sorted list. `name` is the human display name (page_name),
+// falling back to the handle. Keep ig_user_id so panels that publish/track by the
+// Graph id can join without re-reading Norma.
+const accounts = Object.values(src)
+  .filter(a => a && a.handle)
+  .map(a => ({
+    handle: String(a.handle),
+    name: String(a.page_name || a.handle),
+    ig_user_id: a.ig_user_id || null,
+  }))
+  .sort((x, y) => x.handle.localeCompare(y.handle));
+
+const out = {
+  source: 'Norma/agents/instagram-agent/accounts.json',
+  generated_at: new Date().toISOString(),
+  count: accounts.length,
+  accounts,
+};
+
+const tmp = OUT + '.tmp';
+fs.writeFileSync(tmp, JSON.stringify(out, null, 2));
+fs.renameSync(tmp, OUT);
+console.log(`wrote ${path.relative(process.cwd(), OUT)} — ${accounts.length} DW-owned accounts`);
+accounts.forEach(a => console.log(`   @${a.handle}  (${a.name})`));
diff --git a/server.js b/server.js
index cf56b06..ec9a15a 100644
--- a/server.js
+++ b/server.js
@@ -107,6 +107,21 @@ app.post('/api/clients-notes', (req, res) => {
   try { writeNotes(all); } catch { return res.status(500).json({ error: 'write failed' }); }
   res.json({ ok: true });
 });
+// ── Canonical DW-owned Instagram accounts (the 35) ───────────────────────────
+// One source of truth every social/account panel reads so all owned accounts show
+// up everywhere — not just the ones that happen to have posts/data on a surface.
+// Not a module (no nav panel of its own), so it mounts here like clients-notes.
+// File: data/dw-ig-accounts.json — regenerated by scripts/build-dw-accounts.js.
+const DW_ACCOUNTS_FILE = path.join(__dirname, 'data', 'dw-ig-accounts.json');
+app.get('/api/dw-accounts', (_req, res) => {
+  try {
+    const d = JSON.parse(fs.readFileSync(DW_ACCOUNTS_FILE, 'utf8'));
+    res.json({ ok: true, count: (d.accounts || []).length, generated_at: d.generated_at || null, accounts: d.accounts || [] });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: 'dw-accounts unavailable', accounts: [] });
+  }
+});
+
 app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
 app.use(express.static(path.join(__dirname, 'public')));
 app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));

← aa6a459 auto-data-snapshot: 2026-08-25T08:55:17 (1 data files) — pub  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-08-25T09:32:22 (1 data files) — pub 82a9086 →