[object Object]

← back to Marketing Command Center

backsync: pull Yuri's 10 CRM features from prod (RFM, send-times, ab-tests, profiles, segment-perf, templates, journeys, browse-abandon, copy-scoring, playbook) — Mac2 source now matches live 18-panel build

a905372f818459471a2008a433aeb6682e4bd660 · 2026-06-10 16:30:48 -0700 · Steve

Files touched

Diff

commit a905372f818459471a2008a433aeb6682e4bd660
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 10 16:30:48 2026 -0700

    backsync: pull Yuri's 10 CRM features from prod (RFM, send-times, ab-tests, profiles, segment-perf, templates, journeys, browse-abandon, copy-scoring, playbook) — Mac2 source now matches live 18-panel build
---
 data/ab-tests.json                |   1 +
 data/browse-abandon-config.json   |  39 +++
 data/browse-abandon-events.json   | 218 +++++++++++++
 data/journeys.json                |  72 +++++
 data/playbook.json                | 184 +++++++++++
 modules/ab-tests/index.js         | 395 +++++++++++++++++++++++
 modules/browse-abandon/index.js   | 531 +++++++++++++++++++++++++++++++
 modules/copy/index.js             | 434 ++++++++++++++++++++++++++
 modules/journeys/index.js         | 639 ++++++++++++++++++++++++++++++++++++++
 modules/playbook/index.js         | 614 ++++++++++++++++++++++++++++++++++++
 modules/profiles/index.js         | 539 ++++++++++++++++++++++++++++++++
 modules/registry.js               |   8 +
 modules/segment-perf/index.js     | 307 ++++++++++++++++++
 modules/segments/index.js         | 205 +++++++++++-
 modules/send-times/index.js       | 372 ++++++++++++++++++++++
 modules/templates/index.js        | 434 ++++++++++++++++++++++++++
 public/panels/ab-tests.html       |  95 ++++++
 public/panels/ab-tests.js         | 287 +++++++++++++++++
 public/panels/browse-abandon.html |  91 ++++++
 public/panels/browse-abandon.js   | 392 +++++++++++++++++++++++
 public/panels/copy.html           |  31 ++
 public/panels/copy.js             | 134 ++++++++
 public/panels/journeys.html       |  53 ++++
 public/panels/journeys.js         | 427 +++++++++++++++++++++++++
 public/panels/playbook.html       |  77 +++++
 public/panels/playbook.js         | 310 ++++++++++++++++++
 public/panels/profiles.html       |  27 ++
 public/panels/profiles.js         | 296 ++++++++++++++++++
 public/panels/segment-perf.html   |  30 ++
 public/panels/segment-perf.js     | 294 ++++++++++++++++++
 public/panels/segments.html       |  19 ++
 public/panels/segments.js         |  65 ++++
 public/panels/send-times.html     |  64 ++++
 public/panels/send-times.js       | 208 +++++++++++++
 public/panels/templates.html      |  26 ++
 public/panels/templates.js        | 251 +++++++++++++++
 36 files changed, 8167 insertions(+), 2 deletions(-)

diff --git a/data/ab-tests.json b/data/ab-tests.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/ab-tests.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/data/browse-abandon-config.json b/data/browse-abandon-config.json
new file mode 100644
index 0000000..b1292bb
--- /dev/null
+++ b/data/browse-abandon-config.json
@@ -0,0 +1,39 @@
+{
+  "enabled": true,
+  "noSampleThresholdDays": 1,
+  "dailySendCap": 250,
+  "steps": [
+    {
+      "id": "step_day1_storytelling",
+      "dayOffset": 1,
+      "kind": "email",
+      "enabled": true,
+      "title": "Day 1 — Storytelling",
+      "subject": "The story behind {pattern_name}",
+      "bodyTemplate": "Hello {first_name},\n\nYou spent a moment with {pattern_name} from our {collection} edit — and we wanted to share where it comes from. {vendor} has been hand-finishing this {product_type} for decades, and every roll carries the irregularities that separate a true wallcovering from a printed substitute.\n\nIf it stayed with you, the next move is a memo sample. Hold it against your scheme; the gold, the cream, the ink all read differently in your light.\n\nRequest a memo: {memo_url}\n\nQuietly,\nThe Designer Wallcoverings atelier",
+      "cta": "Request memo samples",
+      "suppressIfSampleRequested": true
+    },
+    {
+      "id": "step_day14_room",
+      "dayOffset": 14,
+      "kind": "email",
+      "enabled": true,
+      "title": "Day 14 — Room setting",
+      "subject": "{pattern_name} — see it in a room",
+      "bodyTemplate": "Hello {first_name},\n\n{pattern_name} reads one way as a flat scan and another way on a wall. We staged a room-setting render so you can see the scale, the drop, and the way the {color_name} sits against trim.\n\nView the room setting: {product_url}\n\nA memo box ships free to your trade address — the swatches let you confirm the field colour under your client's light.\n\nRequest a memo: {memo_url}\n\nWith care,\nThe Designer Wallcoverings atelier",
+      "cta": "Explore the collection",
+      "suppressIfSampleRequested": true
+    },
+    {
+      "id": "step_day30_suppress",
+      "dayOffset": 30,
+      "kind": "suppress",
+      "enabled": true,
+      "title": "Day 30 — Suppress",
+      "suppressList": "browse-abandon-lapsed",
+      "note": "Move the contact to the \"browse-abandon-lapsed\" cohort, stop the drip, and roll them into the quarterly editorial cadence instead. No email fires on Day 30."
+    }
+  ],
+  "updatedAt": "2026-06-10T17:49:07.845Z"
+}
\ No newline at end of file
diff --git a/data/browse-abandon-events.json b/data/browse-abandon-events.json
new file mode 100644
index 0000000..4672c93
--- /dev/null
+++ b/data/browse-abandon-events.json
@@ -0,0 +1,218 @@
+[
+  {
+    "id": "evt_606110e1",
+    "contactEmail": "audrey.bell@studiobell.com",
+    "contactFirstName": "Audrey",
+    "segment": "Trade & Designers — engaged",
+    "productSku": "DWBR-7821",
+    "productPattern": "Cliveden Grasscloth",
+    "productColor": "Ink Slate",
+    "productType": "Wallcovering",
+    "vendor": "Brewster",
+    "collection": "Brewster Naturals",
+    "productLine": "grasscloth",
+    "productUrl": "https://designerwallcoverings.com/products/dwbr-7821",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWBR-7821",
+    "browsedAt": "2026-06-10T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_9ca40b7c",
+    "contactEmail": "marcus@hauserarch.com",
+    "contactFirstName": "Marcus",
+    "segment": "Trade & Designers — engaged",
+    "productSku": "DWKK-4490",
+    "productPattern": "Hampshire Silk",
+    "productColor": "Antique Cream",
+    "productType": "Wallcovering",
+    "vendor": "Kravet",
+    "collection": "Heritage Silk",
+    "productLine": "silk",
+    "productUrl": "https://designerwallcoverings.com/products/dwkk-4490",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWKK-4490",
+    "browsedAt": "2026-06-09T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_bb82f044",
+    "contactEmail": "priya@vermillionatelier.com",
+    "contactFirstName": "Priya",
+    "segment": "High-intent — clicked memo CTA",
+    "productSku": "DWAR-1208",
+    "productPattern": "Estoril Cork",
+    "productColor": "Mineral Sage",
+    "productType": "Wallcovering",
+    "vendor": "Arte",
+    "collection": "Mediterranean",
+    "productLine": "cork",
+    "productUrl": "https://designerwallcoverings.com/products/dwar-1208",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWAR-1208",
+    "browsedAt": "2026-06-09T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_249331f4",
+    "contactEmail": "eli.kane@kanedesign.studio",
+    "contactFirstName": "Eli",
+    "segment": "Trade & Designers — engaged",
+    "productSku": "DWMR-3361",
+    "productPattern": "Aurora Flock",
+    "productColor": "Carmine",
+    "productType": "Wallcovering",
+    "vendor": "Maya Romanoff",
+    "collection": "Aurora",
+    "productLine": "flocked",
+    "productUrl": "https://designerwallcoverings.com/products/dwmr-3361",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWMR-3361",
+    "browsedAt": "2026-06-08T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_591383fc",
+    "contactEmail": "naomi.s@hospitalitypartnersgroup.com",
+    "contactFirstName": "Naomi",
+    "segment": "Hospitality / Commercial spec",
+    "productSku": "DWPR-9904",
+    "productPattern": "Coral Gables Vinyl",
+    "productColor": "Surf",
+    "productType": "Wallcovering",
+    "vendor": "Phillipe Romano",
+    "collection": "Hollywood Vinyls Vol. 1",
+    "productLine": "vinyl",
+    "productUrl": "https://designerwallcoverings.com/products/dwpr-9904",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWPR-9904",
+    "browsedAt": "2026-06-05T17:49:07.875Z",
+    "sampleRequestedAt": "2026-06-06T17:49:07.875Z",
+    "mock": true
+  },
+  {
+    "id": "evt_3f6d8808",
+    "contactEmail": "rhianna@laurelandfern.co",
+    "contactFirstName": "Rhianna",
+    "segment": "Retail — lapsed 90d",
+    "productSku": "DWGB-1145",
+    "productPattern": "Beaded Constellation",
+    "productColor": "Champagne",
+    "productType": "Wallcovering",
+    "vendor": "Glass Bead",
+    "collection": "Constellation",
+    "productLine": "glass bead",
+    "productUrl": "https://designerwallcoverings.com/products/dwgb-1145",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWGB-1145",
+    "browsedAt": "2026-06-03T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_988d3529",
+    "contactEmail": "cyrus@morrowinteriors.com",
+    "contactFirstName": "Cyrus",
+    "segment": "Trade & Designers — engaged",
+    "productSku": "DWPI-2003",
+    "productPattern": "Empire Linen",
+    "productColor": "Bone",
+    "productType": "Wallcovering",
+    "vendor": "Phillip Jeffries",
+    "collection": "Empire",
+    "productLine": "linen",
+    "productUrl": "https://designerwallcoverings.com/products/dwpi-2003",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWPI-2003",
+    "browsedAt": "2026-05-27T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_53fb304b",
+    "contactEmail": "wren.day@daydesignhouse.com",
+    "contactFirstName": "Wren",
+    "segment": "High-intent — clicked memo CTA",
+    "productSku": "DWSC-6677",
+    "productPattern": "Schiaparelli Mural",
+    "productColor": "Garden",
+    "productType": "Mural",
+    "vendor": "Schumacher",
+    "collection": "Couture Murals",
+    "productLine": "mural",
+    "productUrl": "https://designerwallcoverings.com/products/dwsc-6677",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWSC-6677",
+    "browsedAt": "2026-05-26T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_89ac8349",
+    "contactEmail": "imogen@borealis-design.co.uk",
+    "contactFirstName": "Imogen",
+    "segment": "Trade & Designers — engaged",
+    "productSku": "DWBR-7821",
+    "productPattern": "Cliveden Grasscloth",
+    "productColor": "Ink Slate",
+    "productType": "Wallcovering",
+    "vendor": "Brewster",
+    "collection": "Brewster Naturals",
+    "productLine": "grasscloth",
+    "productUrl": "https://designerwallcoverings.com/products/dwbr-7821",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWBR-7821",
+    "browsedAt": "2026-05-20T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_eb4f547f",
+    "contactEmail": "tobias@northernlightstudio.com",
+    "contactFirstName": "Tobias",
+    "segment": "Hospitality / Commercial spec",
+    "productSku": "DWKK-4490",
+    "productPattern": "Hampshire Silk",
+    "productColor": "Antique Cream",
+    "productType": "Wallcovering",
+    "vendor": "Kravet",
+    "collection": "Heritage Silk",
+    "productLine": "silk",
+    "productUrl": "https://designerwallcoverings.com/products/dwkk-4490",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWKK-4490",
+    "browsedAt": "2026-05-13T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_ff18d5fd",
+    "contactEmail": "sasha@sashalindersinteriors.com",
+    "contactFirstName": "Sasha",
+    "segment": "Trade & Designers — engaged",
+    "productSku": "DWAR-1208",
+    "productPattern": "Estoril Cork",
+    "productColor": "Mineral Sage",
+    "productType": "Wallcovering",
+    "vendor": "Arte",
+    "collection": "Mediterranean",
+    "productLine": "cork",
+    "productUrl": "https://designerwallcoverings.com/products/dwar-1208",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWAR-1208",
+    "browsedAt": "2026-05-11T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  },
+  {
+    "id": "evt_1f905ec1",
+    "contactEmail": "leo.b@leonardobruzzese.it",
+    "contactFirstName": "Leo",
+    "segment": "High-intent — clicked memo CTA",
+    "productSku": "DWMR-3361",
+    "productPattern": "Aurora Flock",
+    "productColor": "Carmine",
+    "productType": "Wallcovering",
+    "vendor": "Maya Romanoff",
+    "collection": "Aurora",
+    "productLine": "flocked",
+    "productUrl": "https://designerwallcoverings.com/products/dwmr-3361",
+    "memoUrl": "https://designerwallcoverings.com/memos/request?sku=DWMR-3361",
+    "browsedAt": "2026-05-08T17:49:07.875Z",
+    "sampleRequestedAt": null,
+    "mock": true
+  }
+]
\ No newline at end of file
diff --git a/data/journeys.json b/data/journeys.json
new file mode 100644
index 0000000..c6d1c54
--- /dev/null
+++ b/data/journeys.json
@@ -0,0 +1,72 @@
+[
+  {
+    "id": "jny_seed_welcome",
+    "name": "Designer Welcome — Cross-Channel",
+    "description": "New trade-approved designer: welcome email → 24h SMS for non-clickers → 3-day IG retarget on non-openers → 7-day re-engagement.",
+    "segmentId": "seg_trade_engaged",
+    "segmentName": "Trade & Designers — engaged",
+    "audienceSize": 240,
+    "status": "staged",
+    "createdAt": "2026-06-10T17:33:22.196Z",
+    "updatedAt": "2026-06-10T17:33:22.196Z",
+    "steps": [
+      {
+        "id": "stp_seed_1",
+        "kind": "email",
+        "condition": "always",
+        "subject": "Welcome to the Designer Wallcoverings atelier",
+        "templateId": "welcome-designer-1",
+        "note": "Anchor welcome email (Part 1 of 2)."
+      },
+      {
+        "id": "stp_seed_2",
+        "kind": "wait",
+        "hours": 24,
+        "label": "24 hours"
+      },
+      {
+        "id": "stp_seed_3",
+        "kind": "sms",
+        "condition": "if-no-click",
+        "message": "Designer Wallcoverings — your memo box from this week's edit is one tap away: dwall.co/memos. Reply STOP to opt out.",
+        "note": "Catches mobile openers who did not tap through."
+      },
+      {
+        "id": "stp_seed_4",
+        "kind": "wait",
+        "hours": 72,
+        "label": "3 days"
+      },
+      {
+        "id": "stp_seed_5",
+        "kind": "social",
+        "condition": "if-no-open",
+        "platform": "instagram",
+        "caption": "Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth",
+        "note": "IG retarget creative for non-openers of the welcome email."
+      },
+      {
+        "id": "stp_seed_6",
+        "kind": "retarget",
+        "condition": "if-no-open",
+        "platform": "meta",
+        "audienceType": "non-openers",
+        "note": "Push non-openers into Meta custom audience for a 7-day window."
+      },
+      {
+        "id": "stp_seed_7",
+        "kind": "wait",
+        "hours": 168,
+        "label": "7 days"
+      },
+      {
+        "id": "stp_seed_8",
+        "kind": "email",
+        "condition": "if-no-open",
+        "subject": "Still considering? A note from the atelier",
+        "templateId": "memo-followup-day21",
+        "note": "Graceful re-engagement before exit."
+      }
+    ]
+  }
+]
\ No newline at end of file
diff --git a/data/playbook.json b/data/playbook.json
new file mode 100644
index 0000000..9ac5319
--- /dev/null
+++ b/data/playbook.json
@@ -0,0 +1,184 @@
+{
+  "playbooks": {
+    "2026-06": {
+      "monthId": "2026-06",
+      "startIso": "2026-06-01",
+      "generatedAt": "2026-06-10T18:13:39.585Z",
+      "mode": "template-fallback",
+      "generatorError": "gemini_http_429",
+      "signals": {
+        "startIso": "2026-06-01",
+        "days": 28,
+        "dates": [
+          {
+            "date": "2026-06-01",
+            "title": "Summer Entertaining",
+            "type": "awareness",
+            "suggestion": "Dining-room + powder-room statement walls for the entertaining season."
+          },
+          {
+            "date": "2026-06-15",
+            "title": "Father's Day",
+            "type": "holiday",
+            "suggestion": "Refined study + library moodboard — leather-look + deep flocked tones."
+          },
+          {
+            "date": "2026-06-21",
+            "title": "First Day of Summer",
+            "type": "awareness",
+            "suggestion": "Light, airy linen + glass-bead palettes; “summer-light interiors.”"
+          }
+        ],
+        "segments": [
+          {
+            "id": "designers",
+            "name": "Interior Designers",
+            "icon": "🪷",
+            "listSize": 2840,
+            "sendsPerCampaign": 2620,
+            "openRate": 0.4437,
+            "pctChange30d": 12.8,
+            "band": "good"
+          },
+          {
+            "id": "commercial",
+            "name": "Commercial / Hospitality",
+            "icon": "🏛",
+            "listSize": 612,
+            "sendsPerCampaign": 590,
+            "openRate": 0.3959,
+            "pctChange30d": 3.4,
+            "band": "good"
+          },
+          {
+            "id": "retail",
+            "name": "Retail",
+            "icon": "🛍",
+            "listSize": 7415,
+            "sendsPerCampaign": 7080,
+            "openRate": 0.2849,
+            "pctChange30d": -1.6,
+            "band": "ok"
+          },
+          {
+            "id": "at-risk",
+            "name": "At-Risk (Winback)",
+            "icon": "⚠",
+            "listSize": 1190,
+            "sendsPerCampaign": 1150,
+            "openRate": 0.071,
+            "pctChange30d": -21.4,
+            "band": "bad"
+          }
+        ],
+        "gaps": [
+          {
+            "segmentId": "at-risk",
+            "segment": "At-Risk (Winback)",
+            "kind": "declining",
+            "note": "At-Risk (Winback) 30-day open rate is down -21.4% — schedule a winback or refresh creative."
+          },
+          {
+            "segmentId": "at-risk",
+            "segment": "At-Risk (Winback)",
+            "kind": "underperforming",
+            "note": "At-Risk (Winback) sits in the BAD band on open rate (7.1%) — try a high-relevance moment or list hygiene pass."
+          }
+        ]
+      },
+      "entries": [
+        {
+          "id": "play_78857c90",
+          "week": 1,
+          "date": "2026-06-01",
+          "title": "Summer Entertaining",
+          "audience": "designers",
+          "audienceLabel": "Interior Designers",
+          "channel": "email",
+          "type": "awareness",
+          "rationale": "Dining-room + powder-room statement walls for the entertaining season.",
+          "source": "calendar",
+          "status": "approved",
+          "notes": "flagged for trade preview",
+          "updatedAt": "2026-06-10T18:13:53.455Z"
+        },
+        {
+          "id": "play_800cf012",
+          "week": 1,
+          "date": "2026-06-03",
+          "title": "At-Risk (Winback) — Winback editorial",
+          "audience": "at-risk",
+          "audienceLabel": "At-Risk Winback",
+          "channel": "email",
+          "type": "declining",
+          "rationale": "At-Risk (Winback) 30-day open rate is down -21.4% — schedule a winback or refresh creative.",
+          "source": "performance-gap",
+          "status": "suggested",
+          "notes": "",
+          "updatedAt": "2026-06-10T18:13:39.585Z"
+        },
+        {
+          "id": "play_8dbfa919",
+          "week": 2,
+          "date": "2026-06-13",
+          "title": "Interior Designers — Glass bead editorial",
+          "audience": "designers",
+          "audienceLabel": "Interior Designers",
+          "channel": "email",
+          "type": "segment-feature",
+          "rationale": "Interior Designers 30d open rate 44.4% (good). Feed momentum with a glass bead-led editorial — keep voice elevated / editorial.",
+          "source": "segment-health",
+          "status": "suggested",
+          "notes": "",
+          "updatedAt": "2026-06-10T18:13:39.585Z"
+        },
+        {
+          "id": "play_c6bc6701",
+          "week": 2,
+          "date": "2026-06-13",
+          "title": "Commercial / Hospitality — Linen editorial",
+          "audience": "commercial",
+          "audienceLabel": "Commercial / Hospitality",
+          "channel": "email",
+          "type": "segment-feature",
+          "rationale": "Commercial / Hospitality 30d open rate 39.6% (good). Feed momentum with a linen-led editorial — keep voice elevated / editorial.",
+          "source": "segment-health",
+          "status": "suggested",
+          "notes": "",
+          "updatedAt": "2026-06-10T18:13:39.585Z"
+        },
+        {
+          "id": "play_d8f59448",
+          "week": 3,
+          "date": "2026-06-17",
+          "title": "At-Risk (Winback) — High-relevance refresh",
+          "audience": "at-risk",
+          "audienceLabel": "At-Risk Winback",
+          "channel": "email",
+          "type": "underperforming",
+          "rationale": "At-Risk (Winback) sits in the BAD band on open rate (7.1%) — try a high-relevance moment or list hygiene pass.",
+          "source": "performance-gap",
+          "status": "suggested",
+          "notes": "",
+          "updatedAt": "2026-06-10T18:13:39.585Z"
+        },
+        {
+          "id": "play_aed3714e",
+          "week": 3,
+          "date": "2026-06-21",
+          "title": "First Day of Summer",
+          "audience": "designers",
+          "audienceLabel": "Interior Designers",
+          "channel": "email",
+          "type": "awareness",
+          "rationale": "Light, airy linen + glass-bead palettes; “summer-light interiors.”",
+          "source": "calendar",
+          "status": "suggested",
+          "notes": "",
+          "updatedAt": "2026-06-10T18:13:39.585Z"
+        }
+      ]
+    }
+  },
+  "updatedAt": "2026-06-10T18:13:53.541Z"
+}
\ No newline at end of file
diff --git a/modules/ab-tests/index.js b/modules/ab-tests/index.js
new file mode 100644
index 0000000..bd3c2c2
--- /dev/null
+++ b/modules/ab-tests/index.js
@@ -0,0 +1,395 @@
+// A/B Tests module — plan + track email A/B campaign tests. The user defines
+// two variants (subject + body/angle) and a holdout %; this module persists the
+// test to data/ab-tests.json, simulates predicted opens/clicks per variant
+// using a deterministic copy-quality heuristic, and surfaces a winner call.
+//
+// STAGED ONLY. No endpoint here triggers a real send. The "conclude" route
+// just freezes the simulated/staged numbers + names a winner in the JSON.
+// If real send numbers ever flow in (CTCT reporting webhook, GA4 event tap),
+// the same record can be updated via PATCH /tests/:id/results without
+// changing the front-end contract.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+
+const DATA_FILE = path.join(__dirname, '..', '..', 'data', 'ab-tests.json');
+
+// ── persistence ─────────────────────────────────────────────────────────────
+function readAll() {
+  try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
+  catch { return []; }
+}
+function writeAll(rows) {
+  fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
+  fs.writeFileSync(DATA_FILE, JSON.stringify(rows, null, 2));
+}
+function newId() {
+  return 'abt_' + crypto.randomBytes(4).toString('hex');
+}
+
+// ── deterministic PRNG (mulberry32) so the same test re-simulates to the
+// same numbers unless the user explicitly re-rolls with a new seed.
+function mulberry32(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s |= 0; s = (s + 0x6d2b79f5) | 0;
+    let t = Math.imul(s ^ (s >>> 15), 1 | s);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+function hashSeed(s) {
+  let h = 2166136261 >>> 0;
+  for (let i = 0; i < s.length; i++) {
+    h ^= s.charCodeAt(i);
+    h = Math.imul(h, 16777619) >>> 0;
+  }
+  return h >>> 0;
+}
+
+// ── copy-quality heuristic ──────────────────────────────────────────────────
+// Translates a variant's subject + body/angle into a baseline open-rate and
+// click-rate multiplier. Designed to be transparent (each factor is named
+// and exposed in the API response) so the user can see *why* a variant won.
+const BRAND_KEYWORDS = brand.productLines.concat([
+  'grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo', 'lookbook',
+  'atelier', 'archive', 'limited', 'exclusive', 'designer', 'curated',
+]);
+const POWER_VERBS = ['discover', 'introducing', 'unveil', 'inside', 'meet', 'explore', 'request'];
+const GIMMICKY = ['!!!', 'free', '$$$', 'act now', 'last chance', 'urgent', 'click here'];
+
+function scoreCopy(variant) {
+  const subj = String(variant.subject || '').trim();
+  const body = String(variant.body || '').trim();
+  const angle = String(variant.angle || '').trim().toLowerCase();
+  const haystack = (subj + ' ' + body + ' ' + angle).toLowerCase();
+
+  const factors = [];
+  let openMult = 1.0;
+  let clickMult = 1.0;
+
+  // Subject length sweet spot 28-55 chars (editorial-feeling)
+  const len = subj.length;
+  if (len === 0) { openMult *= 0.4; factors.push({ name: 'empty-subject', open: -60, click: 0 }); }
+  else if (len < 18) { openMult *= 0.88; factors.push({ name: 'subject-too-short', open: -12, click: 0 }); }
+  else if (len > 70) { openMult *= 0.85; factors.push({ name: 'subject-too-long', open: -15, click: -3 }); clickMult *= 0.97; }
+  else if (len >= 28 && len <= 55) { openMult *= 1.10; factors.push({ name: 'subject-length-ideal', open: +10, click: 0 }); }
+
+  // Personalization-ish token (Designer, Studio, "your")
+  if (/\b(designer|studio|atelier|your)\b/i.test(subj)) {
+    openMult *= 1.06; factors.push({ name: 'audience-cue', open: +6, click: +1 }); clickMult *= 1.01;
+  }
+
+  // Question-mark headlines = curiosity bump
+  if (/\?$/.test(subj)) { openMult *= 1.04; factors.push({ name: 'curiosity-question', open: +4, click: 0 }); }
+
+  // Power verb in opening
+  if (POWER_VERBS.some(v => new RegExp('^\\s*' + v + '\\b', 'i').test(subj))) {
+    openMult *= 1.05; factors.push({ name: 'power-verb-open', open: +5, click: +1 }); clickMult *= 1.01;
+  }
+
+  // ALL CAPS penalty (>40% of subject in caps)
+  const caps = (subj.match(/[A-Z]/g) || []).length;
+  if (subj.length >= 10 && caps / subj.length > 0.4) {
+    openMult *= 0.82; factors.push({ name: 'shouting-caps', open: -18, click: -5 }); clickMult *= 0.95;
+  }
+
+  // Gimmicky / spammy tokens (DW voice = "never gimmicky")
+  let gimCount = 0;
+  for (const g of GIMMICKY) if (haystack.includes(g)) gimCount++;
+  if (gimCount > 0) {
+    const pen = Math.min(0.9, 0.94 ** gimCount);
+    openMult *= pen; clickMult *= pen;
+    factors.push({ name: 'gimmicky-words', open: -((1 - pen) * 100).toFixed(0) * 1, click: -((1 - pen) * 100).toFixed(0) * 1, count: gimCount });
+  }
+
+  // Brand keyword in subject (grasscloth, silk, mural, etc.) — concrete product
+  // tokens out-perform vague "wallpaper" subjects in DW historical data.
+  let brandHit = false;
+  for (const kw of BRAND_KEYWORDS) {
+    if (subj.toLowerCase().includes(kw)) { brandHit = true; break; }
+  }
+  if (brandHit) { openMult *= 1.07; clickMult *= 1.04; factors.push({ name: 'concrete-product-token', open: +7, click: +4 }); }
+
+  // Body length 80-700 chars = readable preview-text length
+  if (body.length === 0) { clickMult *= 0.7; factors.push({ name: 'empty-body', open: 0, click: -30 }); }
+  else if (body.length < 60) { clickMult *= 0.9; factors.push({ name: 'thin-body', open: 0, click: -10 }); }
+  else if (body.length >= 80 && body.length <= 700) { clickMult *= 1.05; factors.push({ name: 'body-length-ideal', open: 0, click: +5 }); }
+
+  // Body has a CTA-shaped phrase
+  const ctaHit = brand.cta.some(c => haystack.includes(c.toLowerCase()))
+    || /\b(request|explore|book|shop|view|see)\b/i.test(body);
+  if (ctaHit) { clickMult *= 1.12; factors.push({ name: 'clear-cta', open: 0, click: +12 }); }
+
+  // Angle tag bumps (editorial > promo > generic)
+  if (angle === 'editorial' || angle === 'story') { openMult *= 1.05; clickMult *= 1.03; factors.push({ name: 'editorial-angle', open: +5, click: +3 }); }
+  else if (angle === 'promo' || angle === 'sale') { openMult *= 0.95; clickMult *= 1.06; factors.push({ name: 'promo-angle', open: -5, click: +6 }); }
+  else if (angle === 'new-arrival' || angle === 'launch') { openMult *= 1.04; clickMult *= 1.04; factors.push({ name: 'launch-angle', open: +4, click: +4 }); }
+
+  return { openMult, clickMult, factors };
+}
+
+// ── simulator ───────────────────────────────────────────────────────────────
+// Baseline DW historical-ish rates: 22% open, 3.2% click on a trade-leaning
+// list. We split the audience between A / B (after holdout), apply each
+// variant's quality multipliers, then add a small per-variant noise term
+// derived from the test seed so re-rolls produce slightly different numbers.
+const BASE_OPEN = 0.22;
+const BASE_CLICK = 0.032;
+
+function simulate(test) {
+  const seed = hashSeed(test.id + ':' + (test.seedSalt || 'v1'));
+  const rnd = mulberry32(seed);
+  const audienceSize = Math.max(0, Number(test.audienceSize) || 0);
+  const holdoutPct = Math.max(0, Math.min(50, Number(test.holdoutPct) || 0));
+
+  const holdoutSize = Math.round(audienceSize * (holdoutPct / 100));
+  const sendable = Math.max(0, audienceSize - holdoutSize);
+  // Equal A/B split of the sendable pool. Round A down; B gets the remainder.
+  const sentA = Math.floor(sendable / 2);
+  const sentB = sendable - sentA;
+
+  const scoreA = scoreCopy(test.variants[0]);
+  const scoreB = scoreCopy(test.variants[1]);
+
+  const noise = () => 0.94 + rnd() * 0.12; // ~±6%
+
+  const variantResult = (sent, sc) => {
+    const openRate = Math.max(0.01, Math.min(0.85, BASE_OPEN * sc.openMult * noise()));
+    const clickRate = Math.max(0.001, Math.min(0.35, BASE_CLICK * sc.clickMult * sc.openMult * noise()));
+    const opens = Math.round(sent * openRate);
+    const clicks = Math.round(sent * clickRate);
+    return {
+      sent,
+      opens, clicks,
+      openRate: +openRate.toFixed(4),
+      clickRate: +clickRate.toFixed(4),
+      openMult: +sc.openMult.toFixed(3),
+      clickMult: +sc.clickMult.toFixed(3),
+      factors: sc.factors,
+    };
+  };
+
+  // Holdout = no email at all — surface as zeros so the UI shows the bucket
+  // size for "what we held back" comparison.
+  const holdout = {
+    sent: 0,
+    held: holdoutSize,
+    opens: 0,
+    clicks: 0,
+    openRate: 0,
+    clickRate: 0,
+  };
+
+  const a = variantResult(sentA, scoreA);
+  const b = variantResult(sentB, scoreB);
+
+  return { variant_a: a, variant_b: b, holdout, simulatedAt: new Date().toISOString() };
+}
+
+// Two-proportion z-test (open-rate) for variant comparison. Returns the
+// absolute lift, relative lift, and a coarse confidence bucket. We don't
+// want to pretend this is a real significance result with mock data, so
+// we expose the z-score honestly and tier it as low/med/high.
+function compareWinner(results) {
+  const a = results.variant_a, b = results.variant_b;
+  if (!a.sent || !b.sent) return { winner: null, confidence: 'n/a', lift: 0, z: 0, metric: 'open-rate' };
+
+  // Composite score: blended open × click, since either alone can mislead
+  // (a clickbait subject might win opens but lose clicks).
+  const compA = a.openRate * 0.6 + a.clickRate * 0.4;
+  const compB = b.openRate * 0.6 + b.clickRate * 0.4;
+  const winnerKey = compA === compB ? null : (compA > compB ? 'A' : 'B');
+
+  // Stats on open-rate (more samples than clicks → tighter CI)
+  const p1 = a.openRate, p2 = b.openRate;
+  const n1 = a.sent, n2 = b.sent;
+  const p = (a.opens + b.opens) / (n1 + n2);
+  const se = Math.sqrt(p * (1 - p) * (1 / n1 + 1 / n2));
+  const z = se > 0 ? Math.abs((p1 - p2) / se) : 0;
+
+  // Map z to confidence: 1.28→80%, 1.65→90%, 1.96→95%, 2.58→99%
+  let confidence = 'low';
+  if (z >= 1.96) confidence = 'high';
+  else if (z >= 1.28) confidence = 'med';
+
+  const liftAbs = Math.abs(p1 - p2);
+  const liftRel = winnerKey === 'A'
+    ? (p2 > 0 ? (p1 - p2) / p2 : 0)
+    : (p1 > 0 ? (p2 - p1) / p1 : 0);
+
+  return {
+    winner: winnerKey,
+    confidence,
+    metric: 'open-rate',
+    liftAbsolute: +liftAbs.toFixed(4),
+    liftRelative: +liftRel.toFixed(4),
+    z: +z.toFixed(3),
+    compositeA: +compA.toFixed(4),
+    compositeB: +compB.toFixed(4),
+  };
+}
+
+// ── validation ──────────────────────────────────────────────────────────────
+function validateInput(body) {
+  const errs = [];
+  if (!body || typeof body !== 'object') return ['body must be an object'];
+  if (!String(body.name || '').trim()) errs.push('name is required');
+  if (!Array.isArray(body.variants) || body.variants.length !== 2) errs.push('variants must be an array of exactly 2');
+  if (Array.isArray(body.variants)) {
+    body.variants.forEach((v, i) => {
+      const label = i === 0 ? 'A' : 'B';
+      if (!v || typeof v !== 'object') errs.push(`variant ${label} must be an object`);
+      else {
+        if (!String(v.subject || '').trim()) errs.push(`variant ${label} subject is required`);
+        if (!String(v.body || '').trim()) errs.push(`variant ${label} body is required`);
+      }
+    });
+  }
+  const aSize = Number(body.audienceSize);
+  if (!Number.isFinite(aSize) || aSize < 100) errs.push('audienceSize must be ≥ 100');
+  const hp = Number(body.holdoutPct);
+  if (!Number.isFinite(hp) || hp < 0 || hp > 50) errs.push('holdoutPct must be 0–50');
+  return errs;
+}
+
+function normalize(body) {
+  return {
+    name: String(body.name).trim(),
+    description: String(body.description || '').trim(),
+    audienceSegmentId: body.audienceSegmentId ? String(body.audienceSegmentId) : null,
+    audienceSize: Math.round(Number(body.audienceSize)),
+    holdoutPct: +Number(body.holdoutPct).toFixed(2),
+    variants: [0, 1].map(i => ({
+      key: i === 0 ? 'A' : 'B',
+      subject: String(body.variants[i].subject).trim(),
+      body: String(body.variants[i].body).trim(),
+      angle: String(body.variants[i].angle || '').trim(),
+    })),
+  };
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'ab-tests',
+  title: 'A/B Tests',
+  icon: '🔬',
+
+  _scoreCopy: scoreCopy,
+  _simulate: simulate,
+  _compareWinner: compareWinner,
+
+  mount(router) {
+    const guard = (handler) => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) { res.status(500).json({ error: e.message }); }
+    };
+
+    // Constants — reference for the panel (angle options, baselines, gate copy)
+    router.get('/meta', guard(async (_req, res) => {
+      res.json({
+        mock: true,
+        staged: true,
+        baseline: { openRate: BASE_OPEN, clickRate: BASE_CLICK, note: 'DW trade-leaning historical baseline' },
+        angles: ['editorial', 'story', 'new-arrival', 'launch', 'promo', 'sale', 'memo-cta', 'lookbook'],
+        holdoutRange: [0, 50],
+        gateMessage: 'A/B Tests are STAGED — this module plans and tracks; it never triggers a live send. Approved variants must still ship through Constant Contact with Steve gating the send.',
+      });
+    }));
+
+    // List all tests
+    router.get('/tests', guard(async (_req, res) => {
+      const rows = readAll();
+      res.json({
+        mock: true, staged: true,
+        count: rows.length,
+        tests: rows.map(t => ({
+          id: t.id, name: t.name, status: t.status,
+          createdAt: t.createdAt, concludedAt: t.concludedAt || null,
+          audienceSize: t.audienceSize, holdoutPct: t.holdoutPct,
+          winner: t.winner ? t.winner.winner : null,
+          subjectA: t.variants?.[0]?.subject,
+          subjectB: t.variants?.[1]?.subject,
+        })),
+      });
+    }));
+
+    // Single test detail
+    router.get('/tests/:id', guard(async (req, res) => {
+      const row = readAll().find(t => t.id === req.params.id);
+      if (!row) return res.status(404).json({ error: 'test not found' });
+      res.json({ mock: true, staged: true, test: row });
+    }));
+
+    // Create a new A/B test (staged — no send)
+    router.post('/tests', guard(async (req, res) => {
+      const errs = validateInput(req.body);
+      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+
+      const norm = normalize(req.body);
+      const id = newId();
+      const test = {
+        id,
+        ...norm,
+        status: 'staged',          // staged → simulated → concluded
+        createdAt: new Date().toISOString(),
+        seedSalt: 'v1',
+      };
+      test.results = simulate(test);
+      test.winner = compareWinner(test.results);
+
+      const rows = readAll();
+      rows.unshift(test);
+      writeAll(rows);
+      res.json({ mock: true, staged: true, test });
+    }));
+
+    // Re-simulate (re-roll noise term so the user can see variance)
+    router.post('/tests/:id/simulate', guard(async (req, res) => {
+      const rows = readAll();
+      const idx = rows.findIndex(t => t.id === req.params.id);
+      if (idx < 0) return res.status(404).json({ error: 'test not found' });
+      const test = rows[idx];
+      if (test.status === 'concluded') {
+        return res.status(409).json({ error: 'test is concluded; results are frozen' });
+      }
+      // bump seed so simulation differs
+      test.seedSalt = 'v' + (Date.now() % 100000);
+      test.results = simulate(test);
+      test.winner = compareWinner(test.results);
+      rows[idx] = test;
+      writeAll(rows);
+      res.json({ mock: true, staged: true, test });
+    }));
+
+    // Conclude (freeze current numbers + winner). Still no send.
+    router.post('/tests/:id/conclude', guard(async (req, res) => {
+      const rows = readAll();
+      const idx = rows.findIndex(t => t.id === req.params.id);
+      if (idx < 0) return res.status(404).json({ error: 'test not found' });
+      const test = rows[idx];
+      if (test.status === 'concluded') {
+        return res.json({ mock: true, staged: true, test, note: 'already concluded' });
+      }
+      if (!test.results) test.results = simulate(test);
+      if (!test.winner) test.winner = compareWinner(test.results);
+      test.status = 'concluded';
+      test.concludedAt = new Date().toISOString();
+      rows[idx] = test;
+      writeAll(rows);
+      res.json({ mock: true, staged: true, test });
+    }));
+
+    // Delete a test
+    router.delete('/tests/:id', guard(async (req, res) => {
+      const rows = readAll();
+      const idx = rows.findIndex(t => t.id === req.params.id);
+      if (idx < 0) return res.status(404).json({ error: 'test not found' });
+      const [removed] = rows.splice(idx, 1);
+      writeAll(rows);
+      res.json({ mock: true, staged: true, deleted: removed.id });
+    }));
+  },
+};
diff --git a/modules/browse-abandon/index.js b/modules/browse-abandon/index.js
new file mode 100644
index 0000000..5f02f75
--- /dev/null
+++ b/modules/browse-abandon/index.js
@@ -0,0 +1,531 @@
+// browse-abandon — Browse Abandonment drip module for the Marketing Command Center.
+//
+// A designer (or trade contact) loads a product page on the DW store but never
+// asks for a memo sample. After a quiet window we stage a small drip:
+//   Day 1   — storytelling email (the pattern, the maker, the collection lineage)
+//   Day 14  — room-setting email (the same product photographed in situ)
+//   Day 30  — suppress (move to lapsed cohort; stop the drip)
+//
+// PLANNING ONLY. Nothing here ever fires a real email. The /events endpoint
+// shows what WOULD fire today + over the next 7 days; the /fire/:id endpoint is
+// a dry-run renderer that returns the proposed subject + body for inspection.
+//
+// State lives in two files:
+//   data/browse-abandon-config.json — drip steps + the "no-sample threshold"
+//   data/browse-abandon-events.json — mock browse events (no real shopper data)
+//
+// External integrations (Shopify event stream, Constant Contact send) are not
+// wired here — Steve gates those separately. The mock event seed gives the UI
+// realistic-shaped data so the dashboard works out of the box.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+
+const CONFIG_FILE = path.join(__dirname, '..', '..', 'data', 'browse-abandon-config.json');
+const EVENTS_FILE = path.join(__dirname, '..', '..', 'data', 'browse-abandon-events.json');
+
+// ── default drip config ─────────────────────────────────────────────────────
+// Three canonical steps. Steve can edit / disable / reorder via PUT /config.
+// noSampleThresholdDays = the silence window before the drip evaluates the
+// contact (so a designer who requested a sample within 24h never enters).
+function defaultConfig() {
+  return {
+    enabled: true,
+    noSampleThresholdDays: 1,
+    dailySendCap: 250,
+    steps: [
+      {
+        id: 'step_day1_storytelling',
+        dayOffset: 1,
+        kind: 'email',
+        enabled: true,
+        title: 'Day 1 — Storytelling',
+        subject: 'The story behind {pattern_name}',
+        bodyTemplate:
+          'Hello {first_name},\n\n' +
+          'You spent a moment with {pattern_name} from our {collection} edit — and we wanted to share where it comes from. ' +
+          '{vendor} has been hand-finishing this {product_type} for decades, and every roll carries the irregularities that ' +
+          'separate a true wallcovering from a printed substitute.\n\n' +
+          'If it stayed with you, the next move is a memo sample. Hold it against your scheme; the gold, the cream, the ink ' +
+          'all read differently in your light.\n\n' +
+          'Request a memo: {memo_url}\n\n' +
+          'Quietly,\nThe Designer Wallcoverings atelier',
+        cta: 'Request memo samples',
+        suppressIfSampleRequested: true,
+      },
+      {
+        id: 'step_day14_room',
+        dayOffset: 14,
+        kind: 'email',
+        enabled: true,
+        title: 'Day 14 — Room setting',
+        subject: '{pattern_name} — see it in a room',
+        bodyTemplate:
+          'Hello {first_name},\n\n' +
+          '{pattern_name} reads one way as a flat scan and another way on a wall. We staged a room-setting render so ' +
+          'you can see the scale, the drop, and the way the {color_name} sits against trim.\n\n' +
+          'View the room setting: {product_url}\n\n' +
+          'A memo box ships free to your trade address — the swatches let you confirm the field colour under your client\'s light.\n\n' +
+          'Request a memo: {memo_url}\n\n' +
+          'With care,\nThe Designer Wallcoverings atelier',
+        cta: 'Explore the collection',
+        suppressIfSampleRequested: true,
+      },
+      {
+        id: 'step_day30_suppress',
+        dayOffset: 30,
+        kind: 'suppress',
+        enabled: true,
+        title: 'Day 30 — Suppress',
+        suppressList: 'browse-abandon-lapsed',
+        note:
+          'Move the contact to the "browse-abandon-lapsed" cohort, stop the drip, and roll them into the quarterly ' +
+          'editorial cadence instead. No email fires on Day 30.',
+      },
+    ],
+    updatedAt: new Date().toISOString(),
+  };
+}
+
+// ── default mock events ─────────────────────────────────────────────────────
+// A spread of realistic-shaped browse events: trade designers + retail, across
+// the canonical DW product lines (grasscloth, silk, cork, flocked, glass bead),
+// each browsed N days ago so the dashboard shows entries at every drip stage.
+const MOCK_PEOPLE = [
+  { email: 'audrey.bell@studiobell.com',       firstName: 'Audrey',  segment: 'Trade & Designers — engaged' },
+  { email: 'marcus@hauserarch.com',            firstName: 'Marcus',  segment: 'Trade & Designers — engaged' },
+  { email: 'priya@vermillionatelier.com',      firstName: 'Priya',   segment: 'High-intent — clicked memo CTA' },
+  { email: 'eli.kane@kanedesign.studio',       firstName: 'Eli',     segment: 'Trade & Designers — engaged' },
+  { email: 'naomi.s@hospitalitypartnersgroup.com', firstName: 'Naomi', segment: 'Hospitality / Commercial spec' },
+  { email: 'rhianna@laurelandfern.co',         firstName: 'Rhianna', segment: 'Retail — lapsed 90d' },
+  { email: 'cyrus@morrowinteriors.com',        firstName: 'Cyrus',   segment: 'Trade & Designers — engaged' },
+  { email: 'wren.day@daydesignhouse.com',      firstName: 'Wren',    segment: 'High-intent — clicked memo CTA' },
+  { email: 'imogen@borealis-design.co.uk',     firstName: 'Imogen',  segment: 'Trade & Designers — engaged' },
+  { email: 'tobias@northernlightstudio.com',   firstName: 'Tobias',  segment: 'Hospitality / Commercial spec' },
+  { email: 'sasha@sashalindersinteriors.com',  firstName: 'Sasha',   segment: 'Trade & Designers — engaged' },
+  { email: 'leo.b@leonardobruzzese.it',        firstName: 'Leo',     segment: 'High-intent — clicked memo CTA' },
+];
+
+const MOCK_PRODUCTS = [
+  { sku: 'DWBR-7821', pattern: 'Cliveden Grasscloth',   color: 'Ink Slate',    productType: 'Wallcovering', vendor: 'Brewster',     collection: 'Brewster Naturals', line: 'grasscloth' },
+  { sku: 'DWKK-4490', pattern: 'Hampshire Silk',        color: 'Antique Cream', productType: 'Wallcovering', vendor: 'Kravet',       collection: 'Heritage Silk',     line: 'silk' },
+  { sku: 'DWAR-1208', pattern: 'Estoril Cork',          color: 'Mineral Sage', productType: 'Wallcovering', vendor: 'Arte',         collection: 'Mediterranean',     line: 'cork' },
+  { sku: 'DWMR-3361', pattern: 'Aurora Flock',          color: 'Carmine',      productType: 'Wallcovering', vendor: 'Maya Romanoff',collection: 'Aurora',            line: 'flocked' },
+  { sku: 'DWPR-9904', pattern: 'Coral Gables Vinyl',    color: 'Surf',         productType: 'Wallcovering', vendor: 'Phillipe Romano', collection: 'Hollywood Vinyls Vol. 1', line: 'vinyl' },
+  { sku: 'DWGB-1145', pattern: 'Beaded Constellation',  color: 'Champagne',    productType: 'Wallcovering', vendor: 'Glass Bead',   collection: 'Constellation',     line: 'glass bead' },
+  { sku: 'DWPI-2003', pattern: 'Empire Linen',          color: 'Bone',         productType: 'Wallcovering', vendor: 'Phillip Jeffries', collection: 'Empire',         line: 'linen' },
+  { sku: 'DWSC-6677', pattern: 'Schiaparelli Mural',    color: 'Garden',       productType: 'Mural',        vendor: 'Schumacher',   collection: 'Couture Murals',    line: 'mural' },
+];
+
+function seedMockEvents(now = Date.now()) {
+  // Spread browse events across day-windows so every drip stage is visible.
+  // browsedDaysAgo controls which drip step the event has reached.
+  const pattern = [
+    { browsedDaysAgo: 0,  sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 1,  sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 1,  sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 2,  sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 5,  sampleRequestedDaysAgo: 4 },     // requested → suppressed
+    { browsedDaysAgo: 7,  sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 14, sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 15, sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 21, sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 28, sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 30, sampleRequestedDaysAgo: null },
+    { browsedDaysAgo: 33, sampleRequestedDaysAgo: null },
+  ];
+  const day = 24 * 60 * 60 * 1000;
+  return pattern.map((p, i) => {
+    const person  = MOCK_PEOPLE[i % MOCK_PEOPLE.length];
+    const product = MOCK_PRODUCTS[i % MOCK_PRODUCTS.length];
+    const browsedAt = new Date(now - p.browsedDaysAgo * day).toISOString();
+    const sampleRequestedAt = p.sampleRequestedDaysAgo != null
+      ? new Date(now - p.sampleRequestedDaysAgo * day).toISOString()
+      : null;
+    return {
+      id: 'evt_' + crypto.randomBytes(4).toString('hex'),
+      contactEmail: person.email,
+      contactFirstName: person.firstName,
+      segment: person.segment,
+      productSku: product.sku,
+      productPattern: product.pattern,
+      productColor: product.color,
+      productType: product.productType,
+      vendor: product.vendor,
+      collection: product.collection,
+      productLine: product.line,
+      productUrl: `https://designerwallcoverings.com/products/${product.sku.toLowerCase()}`,
+      memoUrl: `https://designerwallcoverings.com/memos/request?sku=${product.sku}`,
+      browsedAt,
+      sampleRequestedAt,
+      mock: true,
+    };
+  });
+}
+
+// ── persistence ─────────────────────────────────────────────────────────────
+function readJson(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+  catch { return fallback; }
+}
+function writeJson(file, val) {
+  try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
+  fs.writeFileSync(file, JSON.stringify(val, null, 2));
+}
+
+function readConfig() {
+  const v = readJson(CONFIG_FILE, null);
+  if (v && typeof v === 'object') return v;
+  const def = defaultConfig();
+  writeJson(CONFIG_FILE, def);
+  return def;
+}
+function writeConfig(cfg) { writeJson(CONFIG_FILE, cfg); }
+
+function readEvents() {
+  const v = readJson(EVENTS_FILE, null);
+  if (Array.isArray(v) && v.length) return v;
+  const seeded = seedMockEvents();
+  writeJson(EVENTS_FILE, seeded);
+  return seeded;
+}
+function writeEvents(rows) { writeJson(EVENTS_FILE, rows); }
+
+// ── validation ──────────────────────────────────────────────────────────────
+const STEP_KINDS = ['email', 'suppress'];
+
+function validateConfig(body) {
+  const errs = [];
+  if (!body || typeof body !== 'object') return ['body must be an object'];
+  if (body.enabled !== undefined && typeof body.enabled !== 'boolean') errs.push('enabled must be boolean');
+  if (body.noSampleThresholdDays !== undefined) {
+    const n = Number(body.noSampleThresholdDays);
+    if (!Number.isFinite(n) || n < 0 || n > 90) errs.push('noSampleThresholdDays must be a number between 0 and 90');
+  }
+  if (body.dailySendCap !== undefined) {
+    const n = Number(body.dailySendCap);
+    if (!Number.isFinite(n) || n < 0 || n > 10000) errs.push('dailySendCap must be a number between 0 and 10000');
+  }
+  if (body.steps !== undefined) {
+    if (!Array.isArray(body.steps)) errs.push('steps must be an array');
+    else {
+      if (body.steps.length === 0) errs.push('at least one step is required');
+      const seenOffsets = new Set();
+      body.steps.forEach((s, i) => {
+        if (!s || typeof s !== 'object') { errs.push(`step #${i + 1} must be an object`); return; }
+        if (!STEP_KINDS.includes(s.kind)) errs.push(`step #${i + 1} has unknown kind "${s.kind}" (allowed: ${STEP_KINDS.join(', ')})`);
+        const off = Number(s.dayOffset);
+        if (!Number.isFinite(off) || off < 0 || off > 365) errs.push(`step #${i + 1} dayOffset must be 0–365`);
+        else if (seenOffsets.has(off)) errs.push(`step #${i + 1} duplicate dayOffset ${off} — each step needs a unique day`);
+        else seenOffsets.add(off);
+        if (s.kind === 'email') {
+          if (!String(s.subject || '').trim()) errs.push(`step #${i + 1} (email) subject is required`);
+          if (!String(s.bodyTemplate || '').trim()) errs.push(`step #${i + 1} (email) bodyTemplate is required`);
+        }
+        if (s.kind === 'suppress') {
+          if (!String(s.suppressList || '').trim()) errs.push(`step #${i + 1} (suppress) suppressList is required`);
+        }
+      });
+    }
+  }
+  return errs;
+}
+
+function normalizeStep(s) {
+  const out = {
+    id: s.id && /^step_/.test(s.id) ? s.id : 'step_' + crypto.randomBytes(3).toString('hex'),
+    dayOffset: Math.max(0, Math.min(365, Math.round(Number(s.dayOffset) || 0))),
+    kind: STEP_KINDS.includes(s.kind) ? s.kind : 'email',
+    enabled: s.enabled === false ? false : true,
+    title: String(s.title || '').trim().slice(0, 80) || `Day ${s.dayOffset || 0}`,
+  };
+  if (out.kind === 'email') {
+    out.subject = String(s.subject || '').trim().slice(0, 200);
+    out.bodyTemplate = String(s.bodyTemplate || '').trim().slice(0, 4000);
+    out.cta = String(s.cta || '').trim().slice(0, 60);
+    out.suppressIfSampleRequested = s.suppressIfSampleRequested !== false;
+  }
+  if (out.kind === 'suppress') {
+    out.suppressList = String(s.suppressList || '').trim().slice(0, 80);
+    out.note = String(s.note || '').trim().slice(0, 400);
+  }
+  return out;
+}
+
+function normalizeConfig(body, existing) {
+  const cur = existing || readConfig();
+  const merged = {
+    enabled: body.enabled !== undefined ? !!body.enabled : !!cur.enabled,
+    noSampleThresholdDays: body.noSampleThresholdDays !== undefined
+      ? Math.max(0, Math.min(90, Math.round(Number(body.noSampleThresholdDays))))
+      : Number(cur.noSampleThresholdDays) || 1,
+    dailySendCap: body.dailySendCap !== undefined
+      ? Math.max(0, Math.min(10000, Math.round(Number(body.dailySendCap))))
+      : Number(cur.dailySendCap) || 250,
+    steps: Array.isArray(body.steps) ? body.steps.map(normalizeStep) : (cur.steps || []),
+    updatedAt: new Date().toISOString(),
+  };
+  // Keep steps sorted by dayOffset so the UI + scheduler walk them in order.
+  merged.steps.sort((a, b) => a.dayOffset - b.dayOffset);
+  return merged;
+}
+
+// ── drip evaluation ─────────────────────────────────────────────────────────
+// Given an event and the current config, decide what stage it sits in and
+// which step (if any) WOULD fire today.
+function dayDiffDays(fromIso, now) {
+  if (!fromIso) return null;
+  const t = new Date(fromIso).getTime();
+  if (!Number.isFinite(t)) return null;
+  return Math.floor((now - t) / (24 * 60 * 60 * 1000));
+}
+
+function evaluateEvent(evt, cfg, now = Date.now()) {
+  const browsedDaysAgo = dayDiffDays(evt.browsedAt, now);
+  const sampleRequested = !!evt.sampleRequestedAt;
+  const sampleDaysAgo = dayDiffDays(evt.sampleRequestedAt, now);
+
+  // Sample requested → out of the drip immediately. The Day 30 suppress also
+  // takes any contact past day 30 out, regardless of intermediate progress.
+  let stage = 'pending';
+  let reason = '';
+  if (sampleRequested) {
+    stage = 'sample-requested';
+    reason = `Sample requested ${sampleDaysAgo} day${sampleDaysAgo === 1 ? '' : 's'} ago — removed from drip.`;
+  } else if (browsedDaysAgo == null || browsedDaysAgo < cfg.noSampleThresholdDays) {
+    stage = 'cooling';
+    reason = `Browsed ${browsedDaysAgo == null ? '—' : browsedDaysAgo + 'd'} ago · still inside the ${cfg.noSampleThresholdDays}d no-sample window.`;
+  }
+
+  // Which step is *due today*? A step is "due" when browsedDaysAgo === dayOffset.
+  // "would fire today" = the matching step is enabled and the contact is still in the drip.
+  const orderedSteps = (cfg.steps || []).slice().sort((a, b) => a.dayOffset - b.dayOffset);
+  let dueStep = null;
+  let lastDueIdx = -1;
+  for (let i = 0; i < orderedSteps.length; i++) {
+    const s = orderedSteps[i];
+    if (browsedDaysAgo != null && browsedDaysAgo >= s.dayOffset) lastDueIdx = i;
+    if (browsedDaysAgo === s.dayOffset) dueStep = s;
+  }
+
+  // Determine "current step" for stage labelling: the latest step the contact
+  // has reached (used so the dashboard groups them as "at Day 1" / "at Day 14").
+  let currentStep = lastDueIdx >= 0 ? orderedSteps[lastDueIdx] : null;
+  if (currentStep && currentStep.kind === 'suppress') stage = 'suppressed';
+  else if (stage === 'pending' && currentStep) stage = 'in-drip';
+
+  // What WOULD fire today?
+  let wouldFire = null;
+  if (dueStep && dueStep.enabled !== false && stage !== 'sample-requested' && stage !== 'cooling') {
+    wouldFire = dueStep;
+  }
+
+  return {
+    browsedDaysAgo,
+    sampleRequestedDaysAgo: sampleDaysAgo,
+    stage,
+    reason,
+    currentStep: currentStep ? { id: currentStep.id, title: currentStep.title, dayOffset: currentStep.dayOffset, kind: currentStep.kind } : null,
+    wouldFire: wouldFire ? { id: wouldFire.id, title: wouldFire.title, dayOffset: wouldFire.dayOffset, kind: wouldFire.kind } : null,
+  };
+}
+
+// ── template rendering ──────────────────────────────────────────────────────
+// Replace {token} placeholders with event fields. Used only for dry-run preview.
+function renderTemplate(text, evt) {
+  const subs = {
+    first_name: evt.contactFirstName || 'there',
+    pattern_name: evt.productPattern || 'this pattern',
+    color_name: evt.productColor || 'the colour',
+    product_type: (evt.productType || 'wallcovering').toLowerCase(),
+    vendor: evt.vendor || 'the mill',
+    collection: evt.collection || 'the collection',
+    product_url: evt.productUrl || 'https://designerwallcoverings.com',
+    memo_url: evt.memoUrl || 'https://designerwallcoverings.com/memos',
+    sku: evt.productSku || '',
+  };
+  return String(text || '').replace(/\{(\w+)\}/g, (m, k) => (subs[k] != null ? subs[k] : m));
+}
+
+function renderEmailPreview(step, evt) {
+  return {
+    to: evt.contactEmail,
+    subject: renderTemplate(step.subject, evt),
+    body: renderTemplate(step.bodyTemplate, evt),
+    cta: step.cta || '',
+    canSpamFooter:
+      `${brand.name} · 8 Greenwich Office Park, Greenwich, CT 06831 · ` +
+      `Unsubscribe: https://designerwallcoverings.com/unsubscribe?c=${encodeURIComponent(evt.contactEmail || '')}`,
+    compliance: brand.compliance,
+  };
+}
+
+// ── schedule projection ─────────────────────────────────────────────────────
+// For each upcoming N-day window, count how many events WOULD hit each step.
+function projectSchedule(events, cfg, days = 7, now = Date.now()) {
+  const oneDay = 24 * 60 * 60 * 1000;
+  const out = [];
+  for (let d = 0; d <= days; d++) {
+    const asOf = now + d * oneDay;
+    const dayLabel = d === 0 ? 'Today' : d === 1 ? 'Tomorrow' : new Date(asOf).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
+    const buckets = {};
+    for (const evt of events) {
+      // Skip events that have requested a sample by now.
+      if (evt.sampleRequestedAt && new Date(evt.sampleRequestedAt).getTime() <= asOf) continue;
+      const ev = evaluateEvent(evt, cfg, asOf);
+      if (!ev.wouldFire) continue;
+      const key = ev.wouldFire.id;
+      if (!buckets[key]) buckets[key] = { stepId: key, title: ev.wouldFire.title, kind: ev.wouldFire.kind, dayOffset: ev.wouldFire.dayOffset, count: 0 };
+      buckets[key].count += 1;
+    }
+    const buckArr = Object.values(buckets).sort((a, b) => a.dayOffset - b.dayOffset);
+    out.push({
+      dayIndex: d,
+      label: dayLabel,
+      iso: new Date(asOf).toISOString().slice(0, 10),
+      totalWouldFire: buckArr.reduce((s, b) => s + b.count, 0),
+      byStep: buckArr,
+      hitCap: buckArr.reduce((s, b) => s + b.count, 0) > (cfg.dailySendCap || 0),
+    });
+  }
+  return out;
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'browse-abandon',
+  title: 'Browse Abandonment',
+  icon: '🔁',
+
+  _evaluateEvent: evaluateEvent,
+  _renderEmailPreview: renderEmailPreview,
+  _projectSchedule: projectSchedule,
+
+  mount(router) {
+    const guard = (handler) => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) { res.status(500).json({ error: e.message }); }
+    };
+
+    // Touch both files on first mount so a fresh deploy has shape.
+    readConfig();
+    readEvents();
+
+    router.get('/meta', guard(async (_req, res) => {
+      const cfg = readConfig();
+      res.json({
+        staged: true,
+        mock: true,
+        stepKinds: STEP_KINDS,
+        tokens: ['first_name', 'pattern_name', 'color_name', 'product_type', 'vendor', 'collection', 'product_url', 'memo_url', 'sku'],
+        defaults: defaultConfig(),
+        brand: { voice: brand.voice, cta: brand.cta },
+        currentConfigUpdatedAt: cfg.updatedAt,
+        gateMessage:
+          'Browse Abandonment is PLANNING ONLY. It defines the drip (Day 1 storytelling → Day 14 room-setting → Day 30 ' +
+          'suppress) and shows which mock browse events WOULD fire today and over the next 7 days. Nothing is sent. ' +
+          'A live execution path requires a Shopify browse-event feed, contact identity resolution, and the existing ' +
+          'Constant Contact send pipeline — each step Steve-gated. CAN-SPAM footer (physical address + working ' +
+          'unsubscribe) is required on every live email.',
+      });
+    }));
+
+    router.get('/config', guard(async (_req, res) => {
+      res.json({ staged: true, config: readConfig() });
+    }));
+
+    router.put('/config', guard(async (req, res) => {
+      const errs = validateConfig(req.body);
+      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+      const next = normalizeConfig(req.body || {}, readConfig());
+      writeConfig(next);
+      res.json({ staged: true, config: next, saved: true });
+    }));
+
+    router.post('/config/reset', guard(async (_req, res) => {
+      const def = defaultConfig();
+      writeConfig(def);
+      res.json({ staged: true, config: def, reset: true });
+    }));
+
+    router.get('/events', guard(async (_req, res) => {
+      const cfg = readConfig();
+      const events = readEvents();
+      const now = Date.now();
+      const enriched = events.map(e => {
+        const ev = evaluateEvent(e, cfg, now);
+        return { ...e, ...ev };
+      });
+      // Summary buckets the dashboard uses for headline counts.
+      const summary = {
+        total: enriched.length,
+        sampleRequested: enriched.filter(e => e.stage === 'sample-requested').length,
+        cooling: enriched.filter(e => e.stage === 'cooling').length,
+        inDrip: enriched.filter(e => e.stage === 'in-drip').length,
+        suppressed: enriched.filter(e => e.stage === 'suppressed').length,
+        wouldFireToday: enriched.filter(e => e.wouldFire).length,
+      };
+      res.json({
+        staged: true,
+        mock: true,
+        config: { enabled: cfg.enabled, noSampleThresholdDays: cfg.noSampleThresholdDays, dailySendCap: cfg.dailySendCap },
+        summary,
+        events: enriched.sort((a, b) => (a.browsedDaysAgo ?? 0) - (b.browsedDaysAgo ?? 0)),
+      });
+    }));
+
+    router.post('/events/refresh-mock', guard(async (_req, res) => {
+      const seeded = seedMockEvents();
+      writeEvents(seeded);
+      res.json({ staged: true, mock: true, count: seeded.length, reseededAt: new Date().toISOString() });
+    }));
+
+    router.get('/schedule', guard(async (req, res) => {
+      const cfg = readConfig();
+      const events = readEvents();
+      const days = Math.max(1, Math.min(30, Number(req.query.days) || 7));
+      const projection = projectSchedule(events, cfg, days);
+      res.json({
+        staged: true,
+        mock: true,
+        days,
+        dailySendCap: cfg.dailySendCap,
+        projection,
+        sendNote: 'PLANNING ONLY — these counts show what WOULD fire if the drip were live. Zero real sends occur.',
+      });
+    }));
+
+    // Dry-run renderer: returns the rendered email (or suppress note) for the
+    // step that is due TODAY for this event. Never sends. The UI uses it as the
+    // "Preview what would fire" inspector.
+    router.post('/fire/:eventId', guard(async (req, res) => {
+      const cfg = readConfig();
+      const events = readEvents();
+      const evt = events.find(e => e.id === req.params.eventId);
+      if (!evt) return res.status(404).json({ error: 'event not found' });
+      const ev = evaluateEvent(evt, cfg);
+      if (!ev.wouldFire) {
+        return res.json({
+          staged: true,
+          dryRun: true,
+          fired: false,
+          reason: ev.reason || `No step is due on day ${ev.browsedDaysAgo} for this contact (stage: ${ev.stage}).`,
+          stage: ev.stage,
+          eventId: evt.id,
+        });
+      }
+      const step = (cfg.steps || []).find(s => s.id === ev.wouldFire.id);
+      const payload = { staged: true, dryRun: true, fired: false, eventId: evt.id, stage: ev.stage, step: { id: step.id, title: step.title, kind: step.kind, dayOffset: step.dayOffset } };
+      if (step.kind === 'email') {
+        payload.preview = renderEmailPreview(step, evt);
+        payload.note = 'PLANNING ONLY — this email was NOT sent. It is a rendered preview for inspection.';
+      } else if (step.kind === 'suppress') {
+        payload.suppress = { list: step.suppressList, note: renderTemplate(step.note, evt) };
+        payload.note = 'PLANNING ONLY — the contact would be moved to the "' + step.suppressList + '" cohort. No action taken.';
+      }
+      res.json(payload);
+    }));
+  },
+};
diff --git a/modules/copy/index.js b/modules/copy/index.js
index 26918a2..2c07175 100644
--- a/modules/copy/index.js
+++ b/modules/copy/index.js
@@ -5,6 +5,9 @@
 //
 // Routes (mounted at /api/copy by the shell):
 //   POST /generate  {kind, topic, product, audience, tone, n} -> {variants:[…], mock?}
+//   POST /score     {kind, text} -> {score, openClass, tone:{reassurance,storytelling,urgency},
+//                                    personalization:{tokens, missing, recommended, coverage},
+//                                    flags:[…], notes, source}
 //   GET  /presets   -> {kinds, tones, audiences, products, ctas}
 //
 // No outbound sends here — this only DRAFTS copy. CAN-SPAM footer is flagged as
@@ -283,6 +286,377 @@ function parseVariants(raw, n) {
   return out.slice(0, n);
 }
 
+// ── scoring ──────────────────────────────────────────────────────────────────
+// Inline copy/subject-line scoring. Returns a predicted-open score (0–100), a
+// luxury-tone breakdown (reassurance / storytelling / urgency, summing to 100),
+// and a personalization-coverage check that hunts for merge tags across the
+// dialects we see in the wild (Constant Contact, Mailchimp, Klaviyo, Liquid).
+//
+// Heuristic-first: this is the baseline and always runs so the panel works
+// without external creds. If GEMINI_API_KEY is set we additionally ask Gemini
+// for a second opinion, average the two predicted-open scores, and pass through
+// Gemini's notes. On any Gemini failure we fall back silently to the heuristic.
+
+const PERSONALIZATION_PATTERNS = [
+  // {{first_name}}, {{ firstName }}, {{customer.first_name}}
+  /\{\{\s*([a-z0-9_.]+)\s*\}\}/gi,
+  // [firstName], [first_name]
+  /\[\s*([a-z0-9_.]+)\s*\]/gi,
+  // %FIRSTNAME%
+  /%([A-Z0-9_]+)%/g,
+  // Mailchimp-style *|FNAME|*
+  /\*\|([A-Z0-9_]+)\|\*/g,
+  // Constant Contact / generic [[token]]
+  /\[\[\s*([a-z0-9_.]+)\s*\]\]/gi,
+];
+
+// Canonical merge-tag buckets we look for. Each entry: bucket label + a regex
+// matched against the lowercased, normalized token name extracted above.
+const PERSONALIZATION_BUCKETS = [
+  { id: 'first_name',   label: 'First name',     match: /(^|[_.])f(irst)?[_.]?name$|^fname$|^firstname$/i },
+  { id: 'last_name',    label: 'Last name',      match: /(^|[_.])l(ast)?[_.]?name$|^lname$|^lastname$/i },
+  { id: 'company',      label: 'Firm / company', match: /^(company|firm|studio|business|organization|org)$/i },
+  { id: 'role',         label: 'Designer role',  match: /^(role|title|job|jobtitle|persona|segment)$/i },
+  { id: 'city',         label: 'City / region',  match: /^(city|region|state|territory|market)$/i },
+];
+
+// Word banks tuned to luxury-wallcovering voice. Each is matched word-boundary,
+// case-insensitive. Counts are clamped before being normalized.
+const TONE_BANKS = {
+  reassurance: [
+    'handcrafted', 'handmade', 'curated', 'considered', 'trade-only', 'trade only',
+    'memo', 'samples', 'archive', 'heritage', 'house', 'guarantee', 'guaranteed',
+    'trusted', 'made to order', 'made-to-order', 'workroom', 'atelier', 'crafted',
+    'designed', 'specified', 'spec', 'quiet luxury', 'timeless', 'enduring',
+    'authentic', 'pedigree', 'provenance', 'since',
+  ],
+  storytelling: [
+    'imagine', 'picture', 'envision', 'consider', 'inside', 'closer look',
+    'inside look', 'the story', 'story', 'journey', 'origin', 'inspired',
+    'inspiration', 'tactile', 'texture', 'light', 'shadow', 'soft', 'silken',
+    'grain', 'hand', 'weave', 'thread', 'fiber', 'patina', 'depth', 'whisper',
+    'glow', 'mood', 'atmosphere', 'rooms that', 'room that', 'walls that',
+    'wall that', 'a closer look', 'between',
+  ],
+  urgency: [
+    'today', 'now', 'limited', 'last chance', 'final', 'ending', 'ends',
+    'closes', 'closing', 'this week', 'this month', 'while supplies last',
+    "don't miss", 'just dropped', 'just landed', 'new', 'just in', 'hurry',
+    'before', 'expires', 'expiring', 'only', 'exclusive', 'this weekend',
+    'tonight', 'tomorrow', 'last', 'final hours',
+  ],
+};
+
+// Subject-line predicted-open heuristics. Each rule contributes a delta to a
+// baseline. The result is clamped to [0,100]. Tuned against ESP best-practice
+// guidance for luxury B2B/B2C senders (designers + trade).
+function scoreSubjectLine(text) {
+  const t = String(text || '');
+  const lower = t.toLowerCase();
+  const len = t.trim().length;
+  const words = t.trim().split(/\s+/).filter(Boolean);
+  const reasons = [];
+  let s = 56;
+
+  // length sweet spot (30–55 chars)
+  if (len === 0)            { reasons.push({ d: -56, why: 'empty subject' }); s -= 56; }
+  else if (len < 18)        { reasons.push({ d: -8,  why: `short (${len} chars)` }); s -= 8; }
+  else if (len <= 55)       { reasons.push({ d: +10, why: `length on target (${len} chars)` }); s += 10; }
+  else if (len <= 70)       { reasons.push({ d: -4,  why: `getting long (${len} chars)` }); s -= 4; }
+  else                      { reasons.push({ d: -14, why: `too long for inbox preview (${len} chars)` }); s -= 14; }
+
+  // SHOUTING is brand-off and lowers opens for premium senders
+  const upperWords = words.filter(w => w.length >= 3 && w === w.toUpperCase() && /[A-Z]/.test(w));
+  if (upperWords.length >= 2) { reasons.push({ d: -10, why: 'ALL-CAPS reads shouty' }); s -= 10; }
+
+  // exclamation spam
+  const bangs = (t.match(/!/g) || []).length;
+  if (bangs >= 2)       { reasons.push({ d: -8, why: 'multiple exclamation points' }); s -= 8; }
+  else if (bangs === 1) { reasons.push({ d: -2, why: 'avoid exclamation in luxury voice' }); s -= 2; }
+
+  // common spam triggers (luxury-relevant subset)
+  const spamTriggers = ['free', 'free!!', 'guarantee', 'cheap', '100%', 'act now', 'click here',
+    '$$$', 'winner', 'congratulations', 'risk-free', 'no obligation', 'discount', 'sale!!'];
+  const hits = spamTriggers.filter(w => lower.includes(w));
+  if (hits.length) {
+    reasons.push({ d: -6 * hits.length, why: `spam-trigger words: ${hits.slice(0,3).join(', ')}` });
+    s -= 6 * hits.length;
+  }
+
+  // personalization bumps opens materially
+  const merged = extractMergeTokens(t).list.length;
+  if (merged >= 1) {
+    reasons.push({ d: +8, why: 'personalization token present' });
+    s += 8;
+  }
+
+  // numerals / specificity ("5 grasscloths", "12 walls"), modest bump
+  if (/\b\d{1,3}\b/.test(t)) { reasons.push({ d: +3, why: 'specific number' }); s += 3; }
+
+  // luxury / curiosity words
+  const lux = ['inside', 'closer look', 'memo', 'curated', 'editor', 'trade', 'collection',
+    'archive', 'atelier', 'limited', 'private', 'first look', 'preview'];
+  if (lux.some(w => lower.includes(w))) { reasons.push({ d: +4, why: 'editorial / curiosity word' }); s += 4; }
+
+  // questions perform well in B2B
+  if (/\?$/.test(t.trim())) { reasons.push({ d: +3, why: 'question framing' }); s += 3; }
+
+  return { score: clamp(Math.round(s), 0, 100), reasons };
+}
+
+// Body / caption / SMS scoring. Slightly different signal set: rewards
+// concrete CTA presence, specificity, sensory language, and presence of merge
+// tags; penalizes wall-of-text and missing CTA.
+function scoreBody(text, kind) {
+  const t = String(text || '');
+  const lower = t.toLowerCase();
+  const len = t.trim().length;
+  const reasons = [];
+  let s = 55;
+
+  if (len === 0) { reasons.push({ d: -55, why: 'empty copy' }); return { score: 0, reasons }; }
+
+  // length expectations differ by kind
+  const targets = {
+    email:      { min: 220, ideal: [320, 900], max: 1600 },
+    instagram:  { min: 90,  ideal: [130, 320], max: 600 },
+    tiktok:     { min: 60,  ideal: [80,  220], max: 400 },
+    sms:        { min: 40,  ideal: [70,  160], max: 220 },
+    headline:   { min: 16,  ideal: [22,  60],  max: 90  },
+    'subject-lines': { min: 18, ideal: [30, 55], max: 70 },
+  }[kind] || { min: 80, ideal: [180, 500], max: 1200 };
+
+  if (len < targets.min)      { reasons.push({ d: -10, why: `short for ${kind} (${len} chars)` }); s -= 10; }
+  else if (len > targets.max) { reasons.push({ d: -12, why: `long for ${kind} (${len} chars)` }); s -= 12; }
+  else if (len >= targets.ideal[0] && len <= targets.ideal[1]) {
+    reasons.push({ d: +8, why: `length on target (${len} chars)` }); s += 8;
+  }
+
+  // CTA presence — match brand CTAs + common verbs
+  const ctaWords = [...brand.cta.map(c => c.toLowerCase()), 'request', 'explore', 'book',
+    'reserve', 'browse', 'shop', 'see', 'order memo', 'order samples', 'view', 'learn more'];
+  const hasCTA = ctaWords.some(w => lower.includes(w));
+  if (hasCTA) { reasons.push({ d: +8, why: 'CTA present' }); s += 8; }
+  else        { reasons.push({ d: -10, why: 'no clear CTA' }); s -= 10; }
+
+  // personalization tokens
+  const merged = extractMergeTokens(t).list.length;
+  if (merged >= 1) { reasons.push({ d: +5, why: 'personalization token present' }); s += 5; }
+
+  // sensory / brand-voice words (rewards luxury copy)
+  const sensoryHits = TONE_BANKS.storytelling.filter(w => lower.includes(w)).length;
+  if (sensoryHits >= 2) { reasons.push({ d: +6, why: 'sensory / editorial language' }); s += 6; }
+
+  // SHOUTING penalty
+  const bangs = (t.match(/!/g) || []).length;
+  if (bangs >= 3) { reasons.push({ d: -8, why: 'exclamation-heavy' }); s -= 8; }
+  const wordsArr = t.split(/\s+/).filter(Boolean);
+  const upperWords = wordsArr.filter(w => w.length >= 3 && w === w.toUpperCase() && /[A-Z]/.test(w));
+  if (upperWords.length >= 3) { reasons.push({ d: -8, why: 'ALL-CAPS bursts' }); s -= 8; }
+
+  // big wall of unbroken text reads poorly on mobile
+  const longestParagraph = Math.max(0, ...t.split(/\n\s*\n/).map(p => p.length));
+  if (longestParagraph > 600) { reasons.push({ d: -6, why: 'paragraph too long for mobile' }); s -= 6; }
+
+  // SMS-specific: STOP opt-out
+  if (kind === 'sms' && !/stop/i.test(lower)) {
+    reasons.push({ d: -10, why: 'SMS missing STOP opt-out' });
+    s -= 10;
+  }
+
+  // hashtags help social
+  if ((kind === 'instagram' || kind === 'tiktok') && (t.match(/#\w+/g) || []).length < 3) {
+    reasons.push({ d: -4, why: 'social caption needs more hashtags' });
+    s -= 4;
+  }
+
+  return { score: clamp(Math.round(s), 0, 100), reasons };
+}
+
+function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
+
+function openClassOf(score) {
+  if (score >= 80) return 'excellent';
+  if (score >= 65) return 'strong';
+  if (score >= 50) return 'fair';
+  if (score >= 35) return 'weak';
+  return 'poor';
+}
+
+// Tone breakdown — count word-bank hits per bucket, normalize to a 100% split.
+// If nothing matches, we report a neutral 33/33/34 with `confidence: 'low'`.
+function toneBreakdown(text) {
+  const lower = String(text || '').toLowerCase();
+  const count = bank => bank.reduce((acc, w) => {
+    const re = new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
+    const m = lower.match(re);
+    return acc + (m ? m.length : 0);
+  }, 0);
+
+  const raw = {
+    reassurance:  count(TONE_BANKS.reassurance),
+    storytelling: count(TONE_BANKS.storytelling),
+    urgency:      count(TONE_BANKS.urgency),
+  };
+  const total = raw.reassurance + raw.storytelling + raw.urgency;
+  if (total === 0) return { reassurance: 33, storytelling: 33, urgency: 34, confidence: 'low', hits: raw };
+
+  // normalize, then round preserving sum = 100
+  const pct = {
+    reassurance:  (raw.reassurance  / total) * 100,
+    storytelling: (raw.storytelling / total) * 100,
+    urgency:      (raw.urgency      / total) * 100,
+  };
+  const r = { reassurance: Math.round(pct.reassurance), storytelling: Math.round(pct.storytelling), urgency: Math.round(pct.urgency) };
+  const drift = 100 - (r.reassurance + r.storytelling + r.urgency);
+  if (drift !== 0) {
+    const key = Object.keys(pct).sort((a, b) => pct[b] - pct[a])[0];
+    r[key] += drift;
+  }
+  return { ...r, confidence: total >= 3 ? 'high' : 'medium', hits: raw };
+}
+
+// Pull every merge token out of the text, normalize the name, and label it.
+function extractMergeTokens(text) {
+  const list = [];
+  const seen = new Set();
+  const s = String(text || '');
+  for (const re of PERSONALIZATION_PATTERNS) {
+    let m;
+    re.lastIndex = 0;
+    while ((m = re.exec(s)) !== null) {
+      const raw = m[0];
+      const name = (m[1] || '').trim();
+      if (!name) continue;
+      const key = name.toLowerCase();
+      if (seen.has(raw)) continue;
+      seen.add(raw);
+      const bucket = PERSONALIZATION_BUCKETS.find(b => b.match.test(key)) || null;
+      list.push({ raw, name, bucket: bucket ? bucket.id : 'other', label: bucket ? bucket.label : name });
+    }
+  }
+  return { list };
+}
+
+function personalizationCoverage(text, kind) {
+  const { list } = extractMergeTokens(text);
+  const covered = new Set(list.map(t => t.bucket).filter(b => b !== 'other'));
+  // What we recommend by kind. Luxury subject lines benefit most from first name
+  // + firm; email body adds role; social/SMS typically skip personalization.
+  const recommendedMap = {
+    'subject-lines': ['first_name'],
+    email:           ['first_name', 'company'],
+    headline:        [],
+    instagram:       [],
+    tiktok:          [],
+    sms:             ['first_name'],
+  };
+  const recommendedIds = recommendedMap[kind] || ['first_name'];
+  const recommended = recommendedIds.map(id => {
+    const def = PERSONALIZATION_BUCKETS.find(b => b.id === id);
+    return { id, label: def ? def.label : id, present: covered.has(id) };
+  });
+  const missing = recommended.filter(r => !r.present).map(r => r.id);
+  const coverage = recommended.length === 0
+    ? 1
+    : recommended.filter(r => r.present).length / recommended.length;
+  return { tokens: list, recommended, missing, coverage: Math.round(coverage * 100) };
+}
+
+function heuristicScore({ kind, text }) {
+  const k = String(kind || '').toLowerCase();
+  const isSubject = k === 'subject-lines' || k === 'subject' || k === 'subject-line';
+  const head = isSubject ? scoreSubjectLine(text) : scoreBody(text, k);
+  const tone = toneBreakdown(text);
+  const personalization = personalizationCoverage(text, isSubject ? 'subject-lines' : k);
+
+  const flags = [];
+  if (k === 'email' && !/unsubscribe/i.test(text)) flags.push('email draft has no unsubscribe phrasing — CAN-SPAM footer still required before sending');
+  if (k === 'sms' && !/stop/i.test(text))         flags.push('SMS draft is missing STOP opt-out');
+  if (isSubject && text.trim().length > 70)        flags.push('subject likely truncated in most inboxes');
+  if (text.length > 8000)                          flags.push('text is unusually long — was a whole thread pasted in?');
+
+  // tone-shape notes
+  const dominant = ['reassurance', 'storytelling', 'urgency'].sort((a, b) => tone[b] - tone[a])[0];
+  const notes = [];
+  notes.push(`Predicted-open class: ${openClassOf(head.score)} (heuristic).`);
+  notes.push(`Tone leans ${dominant} (${tone[dominant]}%).`);
+  if (personalization.missing.length) {
+    notes.push(`Add merge token(s): ${personalization.missing.join(', ')}.`);
+  } else if (personalization.tokens.length) {
+    notes.push(`Personalization coverage: ${personalization.coverage}%.`);
+  }
+
+  return {
+    score: head.score,
+    openClass: openClassOf(head.score),
+    tone,
+    personalization,
+    reasons: head.reasons,
+    flags,
+    notes: notes.join(' '),
+    source: 'heuristic',
+  };
+}
+
+// Optional Gemini second-opinion. Returns null on any failure so the caller
+// uses the heuristic result unchanged.
+async function geminiScore({ kind, text }) {
+  const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
+  if (!key) return null;
+  const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
+  const sys = [
+    `You are a senior email & social copy reviewer for ${brand.name}, a luxury wallcovering house.`,
+    `Brand voice: ${brand.voice.join(', ')}. Audience: ${brand.audiences.join(', ')}.`,
+    `Score the supplied ${kind} copy. Return ONLY valid JSON of shape`,
+    `{"predicted_open":0-100,"tone":{"reassurance":0-100,"storytelling":0-100,"urgency":0-100},"notes":"…"}`,
+    `Tone keys MUST sum to 100. No markdown, no prose.`,
+  ].join('\n');
+  const user = `KIND: ${kind}\nCOPY:\n${String(text || '').slice(0, 4000)}`;
+  let resp;
+  try {
+    resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({
+        systemInstruction: { parts: [{ text: sys }] },
+        contents: [{ role: 'user', parts: [{ text: user }] }],
+        generationConfig: { maxOutputTokens: 400, temperature: 0.2 },
+      }),
+    });
+  } catch (e) {
+    console.warn(`[copy/score] gemini network: ${e.message}`);
+    return null;
+  }
+  if (!resp.ok) {
+    console.warn(`[copy/score] gemini ${resp.status}`);
+    return null;
+  }
+  let data;
+  try { data = await resp.json(); } catch { return null; }
+  const raw = (data.candidates?.[0]?.content?.parts || []).map(p => p.text || '').join('').trim();
+  let obj;
+  try {
+    const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
+    obj = JSON.parse(cleaned);
+  } catch {
+    const m = raw.match(/\{[\s\S]*\}/);
+    if (!m) return null;
+    try { obj = JSON.parse(m[0]); } catch { return null; }
+  }
+  if (!obj || typeof obj.predicted_open !== 'number') return null;
+  const tone = obj.tone || {};
+  return {
+    predictedOpen: clamp(Math.round(obj.predicted_open), 0, 100),
+    tone: {
+      reassurance:  clamp(Math.round(+tone.reassurance  || 0), 0, 100),
+      storytelling: clamp(Math.round(+tone.storytelling || 0), 0, 100),
+      urgency:      clamp(Math.round(+tone.urgency      || 0), 0, 100),
+    },
+    notes: String(obj.notes || '').slice(0, 600),
+  };
+}
+
 // ── module ───────────────────────────────────────────────────────────────────
 module.exports = {
   id: 'copy',
@@ -333,5 +707,65 @@ module.exports = {
       const variants = localGenerate(ctx, n);
       res.json({ variants, kind, mock: true, source: 'templates' });
     });
+
+    // POST /api/copy/score — inline scoring for a subject line or copy block.
+    // Always runs the heuristic; if Gemini is keyed, averages predicted-open
+    // with Gemini's read and blends the tone breakdown (heuristic weight 0.6,
+    // Gemini 0.4). Returns the same shape either way so the panel stays simple.
+    router.post('/score', async (req, res) => {
+      const b = req.body || {};
+      const kind = (b.kind || 'subject-lines').toString().trim().toLowerCase();
+      const text = (b.text || '').toString().slice(0, 12000);
+      if (!text.trim()) {
+        return res.status(400).json({ error: 'empty text' });
+      }
+
+      const base = heuristicScore({ kind, text });
+      let source = 'heuristic';
+      let gem = null;
+      if (process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
+        try { gem = await geminiScore({ kind, text }); }
+        catch (e) { console.warn(`[copy/score] gemini failed: ${e.message}`); }
+      }
+
+      if (gem) {
+        source = 'gemini+heuristic';
+        const blendedScore = Math.round(base.score * 0.6 + gem.predictedOpen * 0.4);
+        // weighted tone blend, then normalize to sum=100
+        const blendTone = {
+          reassurance:  base.tone.reassurance  * 0.6 + gem.tone.reassurance  * 0.4,
+          storytelling: base.tone.storytelling * 0.6 + gem.tone.storytelling * 0.4,
+          urgency:      base.tone.urgency      * 0.6 + gem.tone.urgency      * 0.4,
+        };
+        const sum = blendTone.reassurance + blendTone.storytelling + blendTone.urgency || 1;
+        const norm = {
+          reassurance:  Math.round((blendTone.reassurance  / sum) * 100),
+          storytelling: Math.round((blendTone.storytelling / sum) * 100),
+          urgency:      Math.round((blendTone.urgency      / sum) * 100),
+        };
+        const drift = 100 - (norm.reassurance + norm.storytelling + norm.urgency);
+        if (drift !== 0) {
+          const top = ['reassurance', 'storytelling', 'urgency']
+            .sort((a, b) => norm[b] - norm[a])[0];
+          norm[top] += drift;
+        }
+        return res.json({
+          score: blendedScore,
+          openClass: openClassOf(blendedScore),
+          tone: { ...norm, confidence: 'high', hits: base.tone.hits },
+          personalization: base.personalization,
+          reasons: base.reasons,
+          flags: base.flags,
+          notes: [base.notes, gem.notes].filter(Boolean).join(' '),
+          source,
+          detail: {
+            heuristic: { score: base.score, tone: base.tone },
+            gemini:    { score: gem.predictedOpen, tone: gem.tone },
+          },
+        });
+      }
+
+      res.json({ ...base, source });
+    });
   },
 };
diff --git a/modules/journeys/index.js b/modules/journeys/index.js
new file mode 100644
index 0000000..ffc6320
--- /dev/null
+++ b/modules/journeys/index.js
@@ -0,0 +1,639 @@
+// journeys — Cross-Channel Journeys module for the Marketing Command Center.
+// A journey is an ordered sequence of marketing steps for one segment:
+//   email → wait → SMS → wait → social retarget → email re-engagement
+// Steps carry an optional "condition" (e.g. if-no-click) so the user can plan a
+// branching cross-channel cadence. Journeys persist to data/journeys.json.
+//
+// PLANNING ONLY. Nothing here fires a live send/SMS/social post. POST /:id/run
+// returns a dry-run simulation (estimated reach + drop-off across steps) — never
+// a side effect. If a real automation engine wires up later, it can read these
+// journeys from disk; this module owns the plan, not the execution.
+//
+// Routes (mounted at /api/journeys by the shell):
+//   GET    /meta                       — module metadata, step kinds, recommendation rules
+//   GET    /refs                       — segments + templates the UI uses to populate dropdowns
+//   GET    /journeys                   — list all journeys (summary shape)
+//   GET    /journeys/:id               — one journey (full steps)
+//   POST   /journeys                   — create
+//   PUT    /journeys/:id               — update (name/description/segment/steps)
+//   DELETE /journeys/:id               — remove
+//   POST   /journeys/:id/recommend     — recommended next steps to append after current journey
+//   POST   /journeys/:id/run           — dry-run simulation (audience flow, never sends)
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+
+const DATA_FILE = path.join(__dirname, '..', '..', 'data', 'journeys.json');
+const SEG_FILE  = path.join(__dirname, '..', '..', 'data', 'segments.json');
+
+// ── step schema ─────────────────────────────────────────────────────────────
+// Each kind declares the fields the UI should collect. The validator below
+// uses the same shape so the schema and the gate never drift.
+const STEP_KINDS = {
+  email: {
+    label: 'Email',
+    icon: '✉️',
+    description: 'Send a templated email (drafted; live send is gated by Constant Contact).',
+    fields: [
+      { key: 'subject',    label: 'Subject line',   required: true },
+      { key: 'templateId', label: 'Template id (optional)', required: false },
+      { key: 'note',       label: 'Internal note',  required: false },
+    ],
+    supportsCondition: true,
+  },
+  wait: {
+    label: 'Wait',
+    icon: '⏱',
+    description: 'Pause the journey for a defined window before evaluating the next step.',
+    fields: [
+      { key: 'hours', label: 'Wait (hours)', required: true, type: 'number' },
+      { key: 'label', label: 'Reason (optional)', required: false },
+    ],
+    supportsCondition: false,
+  },
+  sms: {
+    label: 'SMS',
+    icon: '📱',
+    description: 'Send a short SMS follow-up (planning only; live send routes through a gated provider).',
+    fields: [
+      { key: 'message', label: 'SMS message (160 chars)', required: true },
+      { key: 'note',    label: 'Internal note',           required: false },
+    ],
+    supportsCondition: true,
+  },
+  social: {
+    label: 'Social',
+    icon: '📸',
+    description: 'Stage an organic social post (Instagram / Facebook / TikTok). Live publish stays gated.',
+    fields: [
+      { key: 'platform', label: 'Platform', required: true, enum: ['instagram', 'facebook', 'tiktok', 'linkedin'] },
+      { key: 'caption',  label: 'Caption',  required: true },
+      { key: 'note',     label: 'Internal note', required: false },
+    ],
+    supportsCondition: true,
+  },
+  retarget: {
+    label: 'Retarget',
+    icon: '🎯',
+    description: 'Add the segment slice to a retargeting audience (Meta / Google). Planning only.',
+    fields: [
+      { key: 'platform',     label: 'Retarget platform', required: true, enum: ['meta', 'google', 'tiktok'] },
+      { key: 'audienceType', label: 'Audience slice',    required: true, enum: ['non-openers', 'non-clickers', 'openers', 'all'] },
+      { key: 'note',         label: 'Internal note',     required: false },
+    ],
+    supportsCondition: true,
+  },
+};
+
+// Conditions that can gate a non-wait step. "always" = no branching. The other
+// values fire only when the prior email step's outcome matches.
+const CONDITIONS = {
+  always:        { label: 'Always',              applies: () => true },
+  'if-no-open':  { label: 'If no open',          requiresPriorEmail: true },
+  'if-no-click': { label: 'If no click',         requiresPriorEmail: true },
+  'if-opened':   { label: 'If opened',           requiresPriorEmail: true },
+  'if-clicked':  { label: 'If clicked',          requiresPriorEmail: true },
+};
+
+// ── recommendations engine ──────────────────────────────────────────────────
+// Given a journey, recommend cross-channel next steps. The first recommendation
+// only fires when the journey ends on an email step — that's the inflection
+// point where cross-channel follow-up actually helps.
+function recommendNextSteps(journey) {
+  const steps = Array.isArray(journey.steps) ? journey.steps : [];
+  const last = steps[steps.length - 1] || null;
+  const lastEmailIdx = (() => {
+    for (let i = steps.length - 1; i >= 0; i--) if (steps[i].kind === 'email') return i;
+    return -1;
+  })();
+  const hasEmail = lastEmailIdx >= 0;
+  const recs = [];
+
+  if (!steps.length) {
+    recs.push({
+      title: 'Start with a welcome email',
+      reason: 'Every journey opens with one anchor email — set the brand voice before any cross-channel follow-up.',
+      steps: [
+        {
+          kind: 'email',
+          condition: 'always',
+          subject: 'Welcome to the Designer Wallcoverings atelier',
+          note: 'Use the welcome-designer-1 template from the Campaign Templates module.',
+        },
+      ],
+    });
+    return recs;
+  }
+
+  if (!hasEmail) {
+    recs.push({
+      title: 'Add an anchor email before branching cross-channel',
+      reason: 'The cross-channel recommendations below assume an email open/click signal to react to. Add an email step first.',
+      steps: [
+        { kind: 'email', condition: 'always', subject: 'Designer Wallcoverings — a curated look', note: 'Anchor send for the journey.' },
+      ],
+    });
+    return recs;
+  }
+
+  // Standard cross-channel ladder after an email send.
+  recs.push({
+    title: '24h SMS to non-clickers',
+    reason: 'A short SMS one day later catches designers who opened the email on mobile but did not tap through. Keep it under 160 characters and lead with the memo offer — never a discount.',
+    steps: [
+      { kind: 'wait', hours: 24, label: '24h after email send' },
+      {
+        kind: 'sms',
+        condition: 'if-no-click',
+        message: 'Designer Wallcoverings — your memo box from this week\'s edit is one tap away: dwall.co/memos. Reply STOP to opt out.',
+        note: 'Provider TBD; live send stays gated behind Steve approval.',
+      },
+    ],
+  });
+
+  recs.push({
+    title: '3-day Instagram retarget on non-openers',
+    reason: 'After three days, anyone who never opened is unreachable by email until next send — surface the collection through paid social retargeting instead. Plan only; never auto-publish.',
+    steps: [
+      { kind: 'wait', hours: 72, label: '3 days after email send' },
+      {
+        kind: 'social',
+        condition: 'if-no-open',
+        platform: 'instagram',
+        caption: 'Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth',
+        note: 'Stage as a retarget creative; tag the journey segment in the audience set.',
+      },
+      {
+        kind: 'retarget',
+        condition: 'if-no-open',
+        platform: 'meta',
+        audienceType: 'non-openers',
+        note: 'Push the non-opener slice into a Meta custom audience for a 7-day retarget window.',
+      },
+    ],
+  });
+
+  recs.push({
+    title: '7-day re-engagement email to non-openers',
+    reason: 'The graceful close — a final email re-cast at a different angle (editorial vs. transactional). After this, the contact rolls off this journey.',
+    steps: [
+      { kind: 'wait', hours: 168, label: '7 days after email send' },
+      {
+        kind: 'email',
+        condition: 'if-no-open',
+        subject: 'Still considering? A note from the atelier',
+        note: 'Use the memo-followup-day21 or re-engagement template — softer, no pressure.',
+      },
+    ],
+  });
+
+  // If the journey is already long, hint at an exit step.
+  if (steps.length >= 5) {
+    recs.push({
+      title: 'Add a graceful exit',
+      reason: 'A journey longer than five steps risks fatigue. Close with a wait + retarget rollover so the audience moves to evergreen, not silence.',
+      steps: [
+        { kind: 'wait', hours: 168, label: 'Cool-down before exit' },
+        {
+          kind: 'retarget',
+          condition: 'always',
+          platform: 'meta',
+          audienceType: 'all',
+          note: 'Roll the whole segment into the evergreen brand-retention audience and exit the journey.',
+        },
+      ],
+    });
+  }
+
+  // Annotate each recommendation with the anchor email it follows so the UI can
+  // explain "this 24h SMS follows step #2 (your welcome email)".
+  for (const r of recs) r.anchorStepIndex = lastEmailIdx;
+  return recs;
+}
+
+// ── dry-run simulation ──────────────────────────────────────────────────────
+// Walk the journey and project audience flow: how many enter each step, how
+// many proceed, how many drop off (because the condition gated them out, or
+// because we model an open/click rate per email).
+//
+// Deterministic per-journey via a seeded PRNG so the same journey simulates to
+// the same numbers — predictable for the UI.
+function mulberry32(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s |= 0; s = (s + 0x6d2b79f5) | 0;
+    let t = Math.imul(s ^ (s >>> 15), 1 | s);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+function hashSeed(s) {
+  let h = 2166136261 >>> 0;
+  for (let i = 0; i < s.length; i++) {
+    h ^= s.charCodeAt(i);
+    h = Math.imul(h, 16777619) >>> 0;
+  }
+  return h >>> 0;
+}
+
+const BASE_OPEN = 0.22;
+const BASE_CLICK = 0.032;
+
+function simulateJourney(journey, audienceSize) {
+  const seed = hashSeed((journey.id || 'jny') + ':' + journey.steps.length);
+  const rnd = mulberry32(seed);
+  const noise = () => 0.92 + rnd() * 0.16;
+
+  // Track the audience as it flows: { active, opened, clicked }. "active" is
+  // the count still in the journey at this step; opens/clicks come from the
+  // most recent email and reset whenever a new email is sent.
+  let active = Math.max(0, Math.round(Number(audienceSize) || 0));
+  let opened = 0;
+  let clicked = 0;
+  let lastEmailIdx = -1;
+
+  const stepResults = [];
+  for (let i = 0; i < journey.steps.length; i++) {
+    const step = journey.steps[i];
+    const entry = active;
+    let proceeded = active;
+    let droppedByCondition = 0;
+    let note = '';
+
+    if (step.kind === 'email') {
+      // Send to everyone active; project opens + clicks.
+      const openRate  = Math.max(0.02, Math.min(0.7, BASE_OPEN  * noise()));
+      const clickRate = Math.max(0.005, Math.min(0.4, BASE_CLICK * noise()));
+      opened  = Math.round(active * openRate);
+      clicked = Math.round(active * clickRate);
+      lastEmailIdx = i;
+      note = `Projected ${opened} opens (${(openRate * 100).toFixed(1)}%) · ${clicked} clicks (${(clickRate * 100).toFixed(1)}%)`;
+      proceeded = active;
+    } else if (step.kind === 'wait') {
+      const hours = Number(step.hours) || 0;
+      note = `Pause ${hours}h — audience continues.`;
+      proceeded = active;
+    } else {
+      // sms / social / retarget — gate by condition relative to the last email.
+      const cond = step.condition || 'always';
+      if (cond === 'if-no-open')  { proceeded = Math.max(0, active - opened); }
+      else if (cond === 'if-no-click') { proceeded = Math.max(0, active - clicked); }
+      else if (cond === 'if-opened')  { proceeded = opened; }
+      else if (cond === 'if-clicked') { proceeded = clicked; }
+      else proceeded = active;
+      droppedByCondition = entry - proceeded;
+      if (cond !== 'always' && lastEmailIdx < 0) {
+        note = `Condition "${cond}" needs a prior email step — no signal to branch on, treating as 0 reach.`;
+        proceeded = 0;
+        droppedByCondition = entry;
+      } else {
+        note = `${proceeded.toLocaleString()} continue${droppedByCondition ? ` · ${droppedByCondition.toLocaleString()} held by "${cond}"` : ''}`;
+      }
+    }
+
+    stepResults.push({
+      index: i,
+      kind: step.kind,
+      condition: step.condition || 'always',
+      entry,
+      proceeded,
+      droppedByCondition,
+      opens: step.kind === 'email' ? opened : null,
+      clicks: step.kind === 'email' ? clicked : null,
+      note,
+    });
+
+    // Audience that drops by condition exits the journey entirely.
+    active = proceeded;
+  }
+
+  return {
+    audienceSize: Math.max(0, Math.round(Number(audienceSize) || 0)),
+    finalActive: active,
+    totalEmails:  journey.steps.filter(s => s.kind === 'email').length,
+    totalSms:     journey.steps.filter(s => s.kind === 'sms').length,
+    totalSocial:  journey.steps.filter(s => s.kind === 'social').length,
+    totalRetargets: journey.steps.filter(s => s.kind === 'retarget').length,
+    stepResults,
+    simulatedAt: new Date().toISOString(),
+  };
+}
+
+// ── persistence ─────────────────────────────────────────────────────────────
+function readJson(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+  catch { return fallback; }
+}
+function writeJson(file, val) {
+  try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
+  fs.writeFileSync(file, JSON.stringify(val, null, 2));
+}
+function readAll()  { const v = readJson(DATA_FILE, null); return Array.isArray(v) ? v : []; }
+function writeAll(rows) { writeJson(DATA_FILE, rows); }
+function newId() { return 'jny_' + crypto.randomBytes(4).toString('hex'); }
+function newStepId() { return 'stp_' + crypto.randomBytes(3).toString('hex'); }
+
+// Seed a couple of realistic journeys on first load so the UI has content out
+// of the gate. Only written if the file doesn't already exist.
+function defaultJourneys() {
+  return [
+    {
+      id: 'jny_seed_welcome',
+      name: 'Designer Welcome — Cross-Channel',
+      description: 'New trade-approved designer: welcome email → 24h SMS for non-clickers → 3-day IG retarget on non-openers → 7-day re-engagement.',
+      segmentId: 'seg_trade_engaged',
+      segmentName: 'Trade & Designers — engaged',
+      audienceSize: 240,
+      status: 'staged',
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      steps: [
+        {
+          id: 'stp_seed_1', kind: 'email', condition: 'always',
+          subject: 'Welcome to the Designer Wallcoverings atelier',
+          templateId: 'welcome-designer-1',
+          note: 'Anchor welcome email (Part 1 of 2).',
+        },
+        { id: 'stp_seed_2', kind: 'wait', hours: 24, label: '24 hours' },
+        {
+          id: 'stp_seed_3', kind: 'sms', condition: 'if-no-click',
+          message: 'Designer Wallcoverings — your memo box from this week\'s edit is one tap away: dwall.co/memos. Reply STOP to opt out.',
+          note: 'Catches mobile openers who did not tap through.',
+        },
+        { id: 'stp_seed_4', kind: 'wait', hours: 72, label: '3 days' },
+        {
+          id: 'stp_seed_5', kind: 'social', condition: 'if-no-open',
+          platform: 'instagram',
+          caption: 'Three rooms, three wallcoverings — a place to start. #InteriorDesign #Grasscloth',
+          note: 'IG retarget creative for non-openers of the welcome email.',
+        },
+        {
+          id: 'stp_seed_6', kind: 'retarget', condition: 'if-no-open',
+          platform: 'meta', audienceType: 'non-openers',
+          note: 'Push non-openers into Meta custom audience for a 7-day window.',
+        },
+        { id: 'stp_seed_7', kind: 'wait', hours: 168, label: '7 days' },
+        {
+          id: 'stp_seed_8', kind: 'email', condition: 'if-no-open',
+          subject: 'Still considering? A note from the atelier',
+          templateId: 'memo-followup-day21',
+          note: 'Graceful re-engagement before exit.',
+        },
+      ],
+    },
+  ];
+}
+
+// ── validation ──────────────────────────────────────────────────────────────
+function validateStep(step, index) {
+  const errs = [];
+  if (!step || typeof step !== 'object') { errs.push(`step #${index + 1} must be an object`); return errs; }
+  const kindSpec = STEP_KINDS[step.kind];
+  if (!kindSpec) { errs.push(`step #${index + 1} has unknown kind "${step.kind}"`); return errs; }
+  for (const f of kindSpec.fields) {
+    if (!f.required) continue;
+    const v = step[f.key];
+    if (v === undefined || v === null || String(v).trim() === '') {
+      errs.push(`step #${index + 1} (${kindSpec.label}) missing required field "${f.key}"`);
+    } else if (f.enum && !f.enum.includes(String(v))) {
+      errs.push(`step #${index + 1} field "${f.key}" must be one of: ${f.enum.join(', ')}`);
+    } else if (f.type === 'number' && !Number.isFinite(Number(v))) {
+      errs.push(`step #${index + 1} field "${f.key}" must be a number`);
+    }
+  }
+  if (kindSpec.supportsCondition) {
+    const c = step.condition || 'always';
+    if (!CONDITIONS[c]) errs.push(`step #${index + 1} has unknown condition "${c}"`);
+  }
+  return errs;
+}
+
+function validateJourney(body, { partial = false } = {}) {
+  const errs = [];
+  if (!body || typeof body !== 'object') return ['body must be an object'];
+  if (!partial && !String(body.name || '').trim()) errs.push('name is required');
+  if (body.steps !== undefined) {
+    if (!Array.isArray(body.steps)) errs.push('steps must be an array');
+    else if (!partial && body.steps.length === 0) errs.push('journey needs at least one step');
+    else body.steps.forEach((s, i) => errs.push(...validateStep(s, i)));
+  } else if (!partial) {
+    errs.push('steps is required');
+  }
+  const audience = Number(body.audienceSize);
+  if (body.audienceSize !== undefined && (!Number.isFinite(audience) || audience < 0)) {
+    errs.push('audienceSize must be a non-negative number');
+  }
+  return errs;
+}
+
+function normalizeStep(step) {
+  const kindSpec = STEP_KINDS[step.kind];
+  const out = { id: step.id && /^stp_/.test(step.id) ? step.id : newStepId(), kind: step.kind };
+  for (const f of kindSpec.fields) {
+    if (step[f.key] !== undefined) {
+      if (f.type === 'number') out[f.key] = Number(step[f.key]);
+      else out[f.key] = String(step[f.key]).trim();
+    }
+  }
+  if (kindSpec.supportsCondition) out.condition = CONDITIONS[step.condition] ? step.condition : 'always';
+  return out;
+}
+
+function normalizeJourney(body, existing = null) {
+  const segmentName = body.segmentName !== undefined
+    ? String(body.segmentName).trim()
+    : (existing && existing.segmentName) || '';
+  return {
+    id: existing ? existing.id : newId(),
+    name: String(body.name || (existing && existing.name) || '').trim().slice(0, 140),
+    description: String(body.description !== undefined ? body.description : (existing && existing.description) || '').trim().slice(0, 500),
+    segmentId: body.segmentId !== undefined ? (body.segmentId ? String(body.segmentId) : null) : (existing && existing.segmentId) || null,
+    segmentName,
+    audienceSize: body.audienceSize !== undefined
+      ? Math.max(0, Math.round(Number(body.audienceSize)))
+      : (existing && existing.audienceSize) || 0,
+    status: 'staged',
+    steps: Array.isArray(body.steps)
+      ? body.steps.map(normalizeStep)
+      : (existing && existing.steps) || [],
+    createdAt: existing ? existing.createdAt : new Date().toISOString(),
+    updatedAt: new Date().toISOString(),
+  };
+}
+
+function summarize(j) {
+  return {
+    id: j.id,
+    name: j.name,
+    description: j.description,
+    segmentId: j.segmentId || null,
+    segmentName: j.segmentName || '',
+    audienceSize: j.audienceSize || 0,
+    status: j.status || 'staged',
+    stepCount: (j.steps || []).length,
+    kinds: Array.from(new Set((j.steps || []).map(s => s.kind))),
+    updatedAt: j.updatedAt,
+    createdAt: j.createdAt,
+  };
+}
+
+// Pull the segments file directly (peer module). Read-only — no mutation.
+function loadSegmentRefs() {
+  const v = readJson(SEG_FILE, []);
+  if (!Array.isArray(v)) return [];
+  return v.map(s => ({
+    id: s.id,
+    name: s.name,
+    description: s.description || '',
+    estimatedSize: s.estimatedSize || null,
+  }));
+}
+
+// Templates are in-memory in the templates module — reuse them so the UI can
+// show "use this template" labels in email steps without a second fetch.
+function loadTemplateRefs() {
+  try {
+    const tmpl = require('../templates');
+    const arr = Array.isArray(tmpl._templates) ? tmpl._templates : [];
+    return arr.map(t => ({
+      id: t.id,
+      title: t.title,
+      subject: t.subject,
+      category: t.category,
+      sequencePosition: t.sequencePosition || '',
+    }));
+  } catch { return []; }
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'journeys',
+  title: 'Cross-Channel Journeys',
+  icon: '🗺️',
+
+  _STEP_KINDS: STEP_KINDS,
+  _CONDITIONS: CONDITIONS,
+  _recommend: recommendNextSteps,
+  _simulate: simulateJourney,
+
+  mount(router) {
+    const guard = (handler) => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) { res.status(500).json({ error: e.message }); }
+    };
+
+    // Seed on first load — only if data file is empty.
+    if (!readAll().length) writeAll(defaultJourneys());
+
+    router.get('/meta', guard(async (_req, res) => {
+      const kinds = Object.entries(STEP_KINDS).map(([key, spec]) => ({
+        key, label: spec.label, icon: spec.icon, description: spec.description,
+        fields: spec.fields, supportsCondition: spec.supportsCondition,
+      }));
+      const conditions = Object.entries(CONDITIONS).map(([key, spec]) => ({
+        key, label: spec.label, requiresPriorEmail: !!spec.requiresPriorEmail,
+      }));
+      res.json({
+        staged: true,
+        count: readAll().length,
+        kinds,
+        conditions,
+        brand: { voice: brand.voice, cta: brand.cta },
+        gateMessage:
+          'Cross-Channel Journeys are PLANNING ONLY. This module visualizes and saves the journey. ' +
+          'It never fires an email, SMS, social post, or retarget audience push. Live sends still ' +
+          'route through Constant Contact (or the matching channel provider) with Steve gating each ' +
+          'send. CAN-SPAM footer (physical mailing address + working unsubscribe) is required on ' +
+          'every email step before the real send.',
+      });
+    }));
+
+    // Combined refs endpoint — segments + templates the UI populates dropdowns with.
+    router.get('/refs', guard(async (_req, res) => {
+      res.json({
+        staged: true,
+        segments: loadSegmentRefs(),
+        templates: loadTemplateRefs(),
+      });
+    }));
+
+    router.get('/journeys', guard(async (_req, res) => {
+      const rows = readAll();
+      res.json({ staged: true, count: rows.length, journeys: rows.map(summarize) });
+    }));
+
+    router.get('/journeys/:id', guard(async (req, res) => {
+      const row = readAll().find(j => j.id === req.params.id);
+      if (!row) return res.status(404).json({ error: 'journey not found' });
+      res.json({ staged: true, journey: row });
+    }));
+
+    router.post('/journeys', guard(async (req, res) => {
+      const errs = validateJourney(req.body);
+      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+      const norm = normalizeJourney(req.body);
+      const rows = readAll();
+      rows.unshift(norm);
+      writeAll(rows);
+      res.json({ staged: true, journey: norm });
+    }));
+
+    router.put('/journeys/:id', guard(async (req, res) => {
+      const rows = readAll();
+      const idx = rows.findIndex(j => j.id === req.params.id);
+      if (idx < 0) return res.status(404).json({ error: 'journey not found' });
+      const errs = validateJourney(req.body, { partial: true });
+      if (errs.length) return res.status(400).json({ error: 'validation failed', issues: errs });
+      const norm = normalizeJourney(req.body, rows[idx]);
+      rows[idx] = norm;
+      writeAll(rows);
+      res.json({ staged: true, journey: norm });
+    }));
+
+    router.delete('/journeys/:id', guard(async (req, res) => {
+      const rows = readAll();
+      const idx = rows.findIndex(j => j.id === req.params.id);
+      if (idx < 0) return res.status(404).json({ error: 'journey not found' });
+      const [removed] = rows.splice(idx, 1);
+      writeAll(rows);
+      res.json({ staged: true, deleted: removed.id });
+    }));
+
+    // Recommended next steps. Reads the journey from disk (or uses a posted
+    // draft if the caller hasn't saved yet) and returns a list of recommended
+    // appendable step blocks for the user to accept/reject in the UI.
+    router.post('/journeys/:id/recommend', guard(async (req, res) => {
+      let journey;
+      if (req.params.id === 'draft') {
+        // Allow drafts that haven't been saved yet — useful for the builder UI.
+        const body = req.body || {};
+        journey = { id: 'draft', steps: Array.isArray(body.steps) ? body.steps : [] };
+      } else {
+        journey = readAll().find(j => j.id === req.params.id);
+        if (!journey) return res.status(404).json({ error: 'journey not found' });
+      }
+      const recs = recommendNextSteps(journey);
+      res.json({ staged: true, journeyId: journey.id, recommendations: recs });
+    }));
+
+    // Dry-run simulation — NEVER sends. Returns the projected step-by-step flow.
+    router.post('/journeys/:id/run', guard(async (req, res) => {
+      const rows = readAll();
+      const journey = rows.find(j => j.id === req.params.id);
+      if (!journey) return res.status(404).json({ error: 'journey not found' });
+      const audience = (req.body && Number.isFinite(Number(req.body.audienceSize)))
+        ? Number(req.body.audienceSize)
+        : (journey.audienceSize || 0);
+      const sim = simulateJourney(journey, audience);
+      res.json({
+        staged: true,
+        dryRun: true,
+        journeyId: journey.id,
+        ...sim,
+        sendNote: 'PLANNING ONLY — this run produced zero real sends. Live execution requires Steve-gated provider flows.',
+      });
+    }));
+  },
+};
diff --git a/modules/playbook/index.js b/modules/playbook/index.js
new file mode 100644
index 0000000..d49fd4f
--- /dev/null
+++ b/modules/playbook/index.js
@@ -0,0 +1,614 @@
+// playbook — Monthly Marketing Playbook generator for the Command Center.
+//
+// Pulls three live-ish signals from sibling modules:
+//   1. Calendar dates       — curated retail/holiday/awareness/trade moments for
+//                             the four weeks ahead (modules/calendar + dates file).
+//   2. Segment health       — per-segment open / CTOR / click / rev-per-recipient
+//                             bands from segment-perf, so weak cohorts surface
+//                             as winback / re-engagement plays.
+//   3. Performance gaps     — list growth + recent open-rate trend (modules/
+//                             performance helpers reused indirectly).
+//
+// Then it produces 4–6 suggested campaigns/actions for the next 4 weeks, each
+// with title, audience, channel, date, rationale, source signal. Gemini is used
+// when GEMINI_API_KEY is set; otherwise a deterministic template generator runs
+// against the same signal set. Output is persisted to data/playbook.json and is
+// editable from the panel. SUGGESTIONS ONLY — nothing here sends or schedules a
+// live campaign. The calendar module is the canonical home for scheduled work;
+// the panel offers a one-click "Add to calendar" handoff that POSTs to its
+// /api/calendar/schedule endpoint.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const brand = require('../../lib/brand.js');
+const segmentPerf = require('../segment-perf');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const STORE_FILE = path.join(DATA_DIR, 'playbook.json');
+const CAL_DATES_FILE = path.join(DATA_DIR, 'calendar-dates.json');
+
+// ── tiny JSON persistence helpers ───────────────────────────────────────────
+function readJson(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+  catch { return fallback; }
+}
+function writeJson(file, val) {
+  try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
+  fs.writeFileSync(file, JSON.stringify(val, null, 2));
+}
+
+function readStore() {
+  const v = readJson(STORE_FILE, null);
+  if (v && typeof v === 'object' && v.playbooks) return v;
+  const def = { playbooks: {}, updatedAt: new Date().toISOString() };
+  writeJson(STORE_FILE, def);
+  return def;
+}
+function writeStore(s) { s.updatedAt = new Date().toISOString(); writeJson(STORE_FILE, s); }
+
+// ── date helpers (lifted verbatim from the calendar module so we don't have
+// to cross-require its private helpers; the curated dates file is the source) ─
+const pad = n => String(n).padStart(2, '0');
+const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
+
+function nthWeekday(year, month, weekday, ordinal) {
+  if (ordinal === -1) {
+    const last = new Date(year, month, 0).getDate();
+    for (let d = last; d >= 1; d--) {
+      if (new Date(year, month - 1, d).getDay() === weekday) return d;
+    }
+    return last;
+  }
+  let count = 0;
+  const daysInMonth = new Date(year, month, 0).getDate();
+  for (let d = 1; d <= daysInMonth; d++) {
+    if (new Date(year, month - 1, d).getDay() === weekday) {
+      if (++count === ordinal) return d;
+    }
+  }
+  return null;
+}
+
+function datesForMonth(year, month) {
+  const ds = readJson(CAL_DATES_FILE, { fixed: [], floating: [] });
+  const out = [];
+  for (const f of ds.fixed || []) {
+    if (f.month !== month) continue;
+    const daysInMonth = new Date(year, month, 0).getDate();
+    const day = Math.min(f.day, daysInMonth);
+    out.push({ date: iso(year, month, day), title: f.title, type: f.type, suggestion: f.suggestion });
+  }
+  for (const f of ds.floating || []) {
+    if (f.month !== month) continue;
+    const base = nthWeekday(year, month, f.weekday, f.ordinal);
+    if (base == null) continue;
+    let dt = new Date(year, month - 1, base);
+    if (f.offset) dt = new Date(dt.getTime() + f.offset * 86400000);
+    out.push({
+      date: iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()),
+      title: f.title, type: f.type, suggestion: f.suggestion,
+    });
+  }
+  out.sort((a, b) => a.date.localeCompare(b.date));
+  return out;
+}
+
+// Curated dates that fall inside the 4-week window starting from `startIso`.
+function datesInWindow(startIso, days = 28) {
+  const start = new Date(startIso + 'T00:00:00');
+  const end = new Date(start.getTime() + (days - 1) * 86400000);
+  const months = new Set();
+  for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 86400000)) {
+    months.add(`${d.getFullYear()}-${pad(d.getMonth() + 1)}`);
+  }
+  const all = [];
+  for (const ym of months) {
+    const [y, m] = ym.split('-').map(Number);
+    for (const e of datesForMonth(y, m)) all.push(e);
+  }
+  const startKey = iso(start.getFullYear(), start.getMonth() + 1, start.getDate());
+  const endKey = iso(end.getFullYear(), end.getMonth() + 1, end.getDate());
+  return all
+    .filter(d => d.date >= startKey && d.date <= endKey)
+    .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+// ── segment health snapshot ─────────────────────────────────────────────────
+// Reuse the segment-perf helpers to read each segment's 30d open-rate trend
+// and produce a health verdict: which cohorts are healthy vs. need rescuing.
+function segmentSignals() {
+  const segs = segmentPerf._segments || [];
+  const health = segmentPerf._health || {};
+  const out = [];
+  for (const s of segs) {
+    const openTrend = segmentPerf._trendFor(s, 'openRate', 90);
+    const last30 = openTrend.series.slice(-30).map(p => p.value);
+    const meanOpen = last30.reduce((a, b) => a + b, 0) / last30.length;
+    let band = 'unknown';
+    if (health.openRate) {
+      band = meanOpen < health.openRate.bad ? 'bad'
+        : meanOpen < health.openRate.good ? 'ok' : 'good';
+    }
+    out.push({
+      id: s.id, name: s.name, icon: s.icon,
+      listSize: s.listSize, sendsPerCampaign: s.sendsPerCampaign,
+      openRate: +meanOpen.toFixed(4),
+      pctChange30d: openTrend.pctChange,
+      band,
+    });
+  }
+  return out;
+}
+
+// Performance gap heuristic — derive from segment signals so we don't duplicate
+// the performance module's mock. Surfaces the segment that's leaking the most
+// engagement (largest negative trend), which becomes a recommended winback.
+function performanceGaps(segments) {
+  if (!segments.length) return [];
+  const declining = segments.filter(s => s.pctChange30d < -5);
+  const worstBand = segments
+    .filter(s => s.band === 'bad' || s.band === 'ok')
+    .sort((a, b) => a.openRate - b.openRate);
+  const gaps = [];
+  for (const s of declining) {
+    gaps.push({
+      segmentId: s.id, segment: s.name,
+      kind: 'declining',
+      note: `${s.name} 30-day open rate is down ${s.pctChange30d}% — schedule a winback or refresh creative.`,
+    });
+  }
+  for (const s of worstBand.slice(0, 1)) {
+    gaps.push({
+      segmentId: s.id, segment: s.name,
+      kind: 'underperforming',
+      note: `${s.name} sits in the ${s.band.toUpperCase()} band on open rate (${(s.openRate * 100).toFixed(1)}%) — try a high-relevance moment or list hygiene pass.`,
+    });
+  }
+  return gaps;
+}
+
+// ── month + week helpers ────────────────────────────────────────────────────
+function monthIdToWindow(monthId) {
+  // monthId: 'YYYY-MM' — we treat the window as the FIRST 28 days of that month
+  // so "4 weeks" is exact. If the caller wants a rolling window, they can pass
+  // ?start=YYYY-MM-DD on the route instead.
+  const [y, m] = monthId.split('-').map(Number);
+  return iso(y, m, 1);
+}
+function thisMonthId(now = new Date()) {
+  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;
+}
+function weekOf(startIso, dateIso) {
+  const a = new Date(startIso + 'T00:00:00').getTime();
+  const b = new Date(dateIso + 'T00:00:00').getTime();
+  const dDay = Math.floor((b - a) / 86400000);
+  return Math.max(1, Math.min(4, Math.floor(dDay / 7) + 1));
+}
+
+// ── signal aggregation ──────────────────────────────────────────────────────
+function gatherSignals(startIso, days = 28) {
+  const dates = datesInWindow(startIso, days);
+  const segments = segmentSignals();
+  const gaps = performanceGaps(segments);
+  return { startIso, days, dates, segments, gaps };
+}
+
+// ── template generator (fallback when Gemini isn't available) ───────────────
+const AUDIENCES = ['designers', 'commercial', 'retail', 'at-risk'];
+const AUDIENCE_LABEL = {
+  designers: 'Interior Designers',
+  commercial: 'Commercial / Hospitality',
+  retail: 'Retail',
+  'at-risk': 'At-Risk Winback',
+};
+
+function pickAudienceForDate(d) {
+  if (d.type === 'trade') return 'designers';
+  if (d.type === 'retail') return 'retail';
+  if (d.type === 'holiday') return 'retail';
+  return 'designers';
+}
+
+function templateEntryFromCalendar(d, startIso) {
+  const aud = pickAudienceForDate(d);
+  return {
+    week: weekOf(startIso, d.date),
+    date: d.date,
+    title: d.title,
+    audience: aud,
+    audienceLabel: AUDIENCE_LABEL[aud] || aud,
+    channel: d.type === 'holiday' || d.type === 'retail' ? 'email + instagram' : 'email',
+    type: d.type,
+    rationale: d.suggestion || `Curated marketing moment (${d.type}). Lean into the brand voice — ${brand.voice.slice(0, 3).join(', ')}.`,
+    source: 'calendar',
+  };
+}
+
+function templateEntryFromGap(g, startIso) {
+  // Place gap-driven plays evenly across the 4 weeks based on hash so it's
+  // deterministic between regenerations of the same signal set.
+  const seed = (g.segmentId + g.kind).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
+  const dayOffset = (seed % 4) * 7 + 2; // mid-week
+  const dt = new Date(startIso + 'T00:00:00');
+  dt.setDate(dt.getDate() + dayOffset);
+  const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
+  return {
+    week: weekOf(startIso, dateIso),
+    date: dateIso,
+    title: g.kind === 'declining'
+      ? `${g.segment} — Winback editorial`
+      : `${g.segment} — High-relevance refresh`,
+    audience: g.segmentId,
+    audienceLabel: g.segment,
+    channel: 'email',
+    type: g.kind,
+    rationale: g.note,
+    source: 'performance-gap',
+  };
+}
+
+function templateEntryFromSegmentHealth(s, startIso) {
+  // For segments that look healthy, lean in — schedule one positive-reinforcement
+  // campaign tied to a luxury product line so the cohort keeps engaging.
+  const line = brand.productLines[Math.abs(s.id.charCodeAt(0)) % brand.productLines.length];
+  const seed = s.id.charCodeAt(0) + s.id.length;
+  const dayOffset = (seed % 3) * 7 + 5;
+  const dt = new Date(startIso + 'T00:00:00');
+  dt.setDate(dt.getDate() + dayOffset);
+  const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
+  return {
+    week: weekOf(startIso, dateIso),
+    date: dateIso,
+    title: `${s.name} — ${line.charAt(0).toUpperCase() + line.slice(1)} editorial`,
+    audience: s.id,
+    audienceLabel: s.name,
+    channel: 'email',
+    type: 'segment-feature',
+    rationale: `${s.name} 30d open rate ${(s.openRate * 100).toFixed(1)}% (${s.band}). Feed momentum with a ${line}-led editorial — keep voice ${brand.voice.slice(0, 2).join(' / ')}.`,
+    source: 'segment-health',
+  };
+}
+
+function templateGenerate(signals) {
+  const { startIso, dates, segments, gaps } = signals;
+  const entries = [];
+
+  // Up to 2 calendar-driven picks (best of curated moments).
+  const tradeAndAware = dates.filter(d => ['trade', 'awareness', 'retail'].includes(d.type));
+  for (const d of tradeAndAware.slice(0, 2)) entries.push(templateEntryFromCalendar(d, startIso));
+
+  // 1 gap-driven pick if we have one.
+  for (const g of gaps.slice(0, 2)) entries.push(templateEntryFromGap(g, startIso));
+
+  // 1-2 healthy-segment leans.
+  const healthy = segments.filter(s => s.band === 'good').slice(0, 2);
+  for (const s of healthy) entries.push(templateEntryFromSegmentHealth(s, startIso));
+
+  // If we're short of 4 (sparse calendar month), fall back to a holiday/holiday-
+  // adjacent calendar pick or a generic editorial on the dominant line.
+  if (entries.length < 4) {
+    const remaining = dates.filter(d => !entries.find(e => e.date === d.date && e.title === d.title));
+    for (const d of remaining) {
+      if (entries.length >= 5) break;
+      entries.push(templateEntryFromCalendar(d, startIso));
+    }
+  }
+  if (entries.length < 4) {
+    // Synthesize a brand-tone editorial campaign per remaining week.
+    for (let w = 1; w <= 4 && entries.length < 4; w++) {
+      if (entries.find(e => e.week === w)) continue;
+      const dt = new Date(startIso + 'T00:00:00');
+      dt.setDate(dt.getDate() + (w - 1) * 7 + 3);
+      const dateIso = iso(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
+      const line = brand.productLines[(w - 1) % brand.productLines.length];
+      entries.push({
+        week: w,
+        date: dateIso,
+        title: `${line.charAt(0).toUpperCase() + line.slice(1)} editorial — Week ${w}`,
+        audience: 'designers',
+        audienceLabel: AUDIENCE_LABEL.designers,
+        channel: 'email + instagram',
+        type: 'editorial',
+        rationale: `Quiet calendar week — fall back on a tactile ${line} editorial. Voice: ${brand.voice.slice(0, 3).join(', ')}.`,
+        source: 'fallback-editorial',
+      });
+    }
+  }
+
+  // Trim to 6 and sort by date.
+  entries.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
+  return entries.slice(0, 6);
+}
+
+// ── Gemini generator ────────────────────────────────────────────────────────
+const GEMINI_MODEL = 'gemini-2.0-flash';
+function geminiKey() {
+  return process.env.GEMINI_API_KEY || process.env.GEMINI_KEY || '';
+}
+
+function geminiPrompt(signals) {
+  return [
+    'You are the marketing planner for Designer Wallcoverings, a luxury wallcoverings house.',
+    `Brand voice: ${brand.voice.join(', ')}. Tagline: "${brand.tagline}".`,
+    `Audiences: ${brand.audiences.join('; ')}.`,
+    `Product lines: ${brand.productLines.join(', ')}.`,
+    '',
+    'Build a 4-WEEK marketing playbook starting ' + signals.startIso + '.',
+    'Output 4 to 6 distinct campaigns or actions. Each must reference at least one signal below.',
+    'Spread the dates across the 4 weeks. No two entries on the same day.',
+    '',
+    'SIGNAL — Curated marketing moments in the window:',
+    ...signals.dates.slice(0, 12).map(d => `  • ${d.date} (${d.type}) ${d.title} — ${d.suggestion || ''}`),
+    '',
+    'SIGNAL — Segment health (30-day open rate, trend % vs prior 30d):',
+    ...signals.segments.map(s => `  • ${s.name}: ${(s.openRate * 100).toFixed(1)}% open · trend ${s.pctChange30d}% · band ${s.band} · list ${s.listSize}`),
+    '',
+    'SIGNAL — Performance gaps:',
+    ...signals.gaps.map(g => `  • ${g.kind.toUpperCase()} — ${g.note}`),
+    '',
+    'Return ONLY a JSON array. No prose, no markdown fence. Schema for each entry:',
+    '{',
+    '  "date": "YYYY-MM-DD",  // must fall inside the 28-day window',
+    '  "week": 1|2|3|4,',
+    '  "title": "...",',
+    '  "audience": "designers"|"commercial"|"retail"|"at-risk",',
+    '  "channel": "email"|"instagram"|"email + instagram"|"tiktok",',
+    '  "type": "campaign"|"editorial"|"winback"|"trade"|"holiday"|"retail",',
+    '  "rationale": "1-2 sentence justification referencing the signal",',
+    '  "source": "calendar"|"segment-health"|"performance-gap"',
+    '}',
+  ].join('\n');
+}
+
+async function geminiGenerate(signals) {
+  const key = geminiKey();
+  if (!key) throw new Error('no_gemini_key');
+  if (typeof fetch !== 'function') throw new Error('fetch_unavailable');
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(key)}`;
+  const body = {
+    contents: [{ role: 'user', parts: [{ text: geminiPrompt(signals) }] }],
+    generationConfig: { temperature: 0.55, maxOutputTokens: 1400, responseMimeType: 'application/json' },
+  };
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+  const text = await r.text();
+  let json; try { json = JSON.parse(text); } catch { json = null; }
+  if (!r.ok || !json) {
+    const err = new Error(`gemini_http_${r.status}`);
+    err.detail = json || text;
+    throw err;
+  }
+  const out = json.candidates && json.candidates[0] && json.candidates[0].content
+    && json.candidates[0].content.parts && json.candidates[0].content.parts[0]
+    && json.candidates[0].content.parts[0].text;
+  if (!out) throw new Error('gemini_empty');
+  // out should be a JSON array per the prompt; tolerate stray fence anyway.
+  let arr;
+  try { arr = JSON.parse(out); }
+  catch {
+    const m = out.match(/\[[\s\S]*\]/);
+    if (!m) throw new Error('gemini_unparseable');
+    arr = JSON.parse(m[0]);
+  }
+  if (!Array.isArray(arr)) throw new Error('gemini_not_array');
+  return arr;
+}
+
+// ── entry normalization (Gemini output + edits both pass through here) ─────
+const VALID_AUDIENCES = new Set(AUDIENCES);
+const VALID_CHANNELS = new Set(['email', 'instagram', 'tiktok', 'email + instagram', 'social', 'other']);
+const VALID_STATUSES = new Set(['suggested', 'approved', 'scheduled', 'skipped', 'done']);
+
+function normalizeEntry(raw, startIso) {
+  const e = raw || {};
+  const id = e.id && /^play_/.test(e.id) ? e.id : 'play_' + crypto.randomBytes(4).toString('hex');
+  const dateOk = typeof e.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.date);
+  const date = dateOk ? e.date : startIso;
+  const audience = VALID_AUDIENCES.has(e.audience) ? e.audience : 'designers';
+  return {
+    id,
+    week: Number.isInteger(e.week) && e.week >= 1 && e.week <= 4 ? e.week : weekOf(startIso, date),
+    date,
+    title: String(e.title || 'Untitled campaign').trim().slice(0, 140),
+    audience,
+    audienceLabel: AUDIENCE_LABEL[audience] || audience,
+    channel: VALID_CHANNELS.has(e.channel) ? e.channel : 'email',
+    type: String(e.type || 'campaign').trim().slice(0, 40),
+    rationale: String(e.rationale || '').trim().slice(0, 800),
+    source: ['calendar', 'segment-health', 'performance-gap', 'fallback-editorial', 'manual', 'gemini'].includes(e.source) ? e.source : 'gemini',
+    status: VALID_STATUSES.has(e.status) ? e.status : 'suggested',
+    notes: String(e.notes || '').trim().slice(0, 1000),
+    updatedAt: new Date().toISOString(),
+  };
+}
+
+// ── unified generate ────────────────────────────────────────────────────────
+async function generatePlaybook(signals) {
+  let raw, mode = 'gemini', error = null;
+  if (geminiKey()) {
+    try { raw = await geminiGenerate(signals); }
+    catch (e) {
+      error = e.message;
+      raw = templateGenerate(signals);
+      mode = 'template-fallback';
+    }
+  } else {
+    raw = templateGenerate(signals);
+    mode = 'template';
+  }
+  const entries = (Array.isArray(raw) ? raw : [])
+    .slice(0, 6)
+    .map(r => normalizeEntry(r, signals.startIso));
+  // Re-sort + de-dup same-date+title in case Gemini repeated itself.
+  const seen = new Set();
+  const unique = [];
+  for (const e of entries.sort((a, b) => a.date.localeCompare(b.date))) {
+    const k = `${e.date}|${e.title.toLowerCase()}`;
+    if (seen.has(k)) continue;
+    seen.add(k); unique.push(e);
+  }
+  // If Gemini returned too few, top up with template entries.
+  if (unique.length < 4) {
+    const filler = templateGenerate(signals)
+      .map(r => normalizeEntry(r, signals.startIso))
+      .filter(t => !seen.has(`${t.date}|${t.title.toLowerCase()}`));
+    for (const t of filler) {
+      if (unique.length >= 6) break;
+      unique.push(t);
+    }
+    unique.sort((a, b) => a.date.localeCompare(b.date));
+  }
+  return { mode, error, entries: unique };
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'playbook',
+  title: 'Monthly Playbook',
+  icon: '📅',
+
+  // exposed for tests / sibling reuse
+  _gatherSignals: gatherSignals,
+  _templateGenerate: templateGenerate,
+  _normalizeEntry: normalizeEntry,
+
+  mount(router) {
+    const guard = handler => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+    };
+
+    readStore(); // touch the file so a fresh deploy has shape
+
+    // GET /meta — module description + whether Gemini is keyed
+    router.get('/meta', guard(async (_req, res) => {
+      res.json({
+        staged: true,
+        mock: !geminiKey(),
+        geminiKeyed: !!geminiKey(),
+        geminiModel: GEMINI_MODEL,
+        audiences: AUDIENCES.map(id => ({ id, label: AUDIENCE_LABEL[id] })),
+        statuses: [...VALID_STATUSES],
+        channels: [...VALID_CHANNELS],
+        sources: ['calendar', 'segment-health', 'performance-gap', 'fallback-editorial', 'manual', 'gemini'],
+        gateMessage:
+          'Monthly Playbook is SUGGESTIONS ONLY. It generates 4–6 campaign/action ideas for the next 4 weeks ' +
+          'from calendar moments, segment health, and performance gaps. Entries are editable and persistent. ' +
+          'Nothing here schedules or sends anything — promoting a suggestion to a real plan still goes through ' +
+          'the Calendar module (and any actual send is gated separately in Constant Contact / Channels).',
+        brand: { voice: brand.voice, tagline: brand.tagline },
+      });
+    }));
+
+    // GET /signals?start=YYYY-MM-DD — what we'd feed the generator right now
+    router.get('/signals', guard(async (req, res) => {
+      const start = typeof req.query.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.start)
+        ? req.query.start
+        : monthIdToWindow(typeof req.query.month === 'string' ? req.query.month : thisMonthId());
+      const days = Math.max(7, Math.min(56, Number(req.query.days) || 28));
+      res.json({ staged: true, signals: gatherSignals(start, days) });
+    }));
+
+    // GET /playbook?month=YYYY-MM — read the persisted playbook for a month
+    router.get('/playbook', guard(async (req, res) => {
+      const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
+        ? req.query.month
+        : thisMonthId();
+      const store = readStore();
+      const pb = store.playbooks[monthId] || null;
+      res.json({ staged: true, monthId, playbook: pb });
+    }));
+
+    // GET /full — full store (for export / debugging)
+    router.get('/full', guard(async (_req, res) => {
+      res.json({ staged: true, store: readStore() });
+    }));
+
+    // POST /generate { month?: 'YYYY-MM', start?: 'YYYY-MM-DD' } — regenerate
+    router.post('/generate', guard(async (req, res) => {
+      const body = req.body || {};
+      const monthId = typeof body.month === 'string' && /^\d{4}-\d{2}$/.test(body.month)
+        ? body.month
+        : thisMonthId();
+      const start = typeof body.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.start)
+        ? body.start
+        : monthIdToWindow(monthId);
+      const signals = gatherSignals(start, 28);
+      const { mode, error, entries } = await generatePlaybook(signals);
+      const pb = {
+        monthId,
+        startIso: start,
+        generatedAt: new Date().toISOString(),
+        mode,                          // 'gemini' | 'template' | 'template-fallback'
+        generatorError: error || null, // populated when Gemini failed → template kicked in
+        signals,
+        entries,
+      };
+      const store = readStore();
+      store.playbooks[monthId] = pb;
+      writeStore(store);
+      res.json({ staged: true, mock: !geminiKey() || mode !== 'gemini', monthId, playbook: pb });
+    }));
+
+    // POST /entry { month, ...entry } — add or upsert a single entry
+    router.post('/entry', guard(async (req, res) => {
+      const body = req.body || {};
+      const monthId = typeof body.month === 'string' && /^\d{4}-\d{2}$/.test(body.month)
+        ? body.month
+        : thisMonthId();
+      const store = readStore();
+      const pb = store.playbooks[monthId] || {
+        monthId, startIso: monthIdToWindow(monthId), generatedAt: new Date().toISOString(),
+        mode: 'manual', generatorError: null,
+        signals: gatherSignals(monthIdToWindow(monthId), 28),
+        entries: [],
+      };
+      const entry = normalizeEntry({ ...body, source: body.source || 'manual' }, pb.startIso);
+      const i = pb.entries.findIndex(e => e.id === entry.id);
+      if (i >= 0) pb.entries[i] = { ...pb.entries[i], ...entry };
+      else pb.entries.push(entry);
+      pb.entries.sort((a, b) => a.date.localeCompare(b.date));
+      store.playbooks[monthId] = pb;
+      writeStore(store);
+      res.json({ staged: true, monthId, entry, playbook: pb });
+    }));
+
+    // PUT /entry/:id?month=YYYY-MM — edit one entry's fields
+    router.put('/entry/:id', guard(async (req, res) => {
+      const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
+        ? req.query.month
+        : thisMonthId();
+      const store = readStore();
+      const pb = store.playbooks[monthId];
+      if (!pb) return res.status(404).json({ ok: false, error: 'no playbook for that month — generate one first' });
+      const i = pb.entries.findIndex(e => e.id === req.params.id);
+      if (i < 0) return res.status(404).json({ ok: false, error: 'entry not found' });
+      const merged = normalizeEntry({ ...pb.entries[i], ...(req.body || {}), id: pb.entries[i].id }, pb.startIso);
+      pb.entries[i] = merged;
+      pb.entries.sort((a, b) => a.date.localeCompare(b.date));
+      writeStore(store);
+      res.json({ staged: true, monthId, entry: merged, playbook: pb });
+    }));
+
+    // POST /delete?month=YYYY-MM&id=... — remove an entry
+    router.post('/delete', guard(async (req, res) => {
+      const monthId = typeof req.query.month === 'string' && /^\d{4}-\d{2}$/.test(req.query.month)
+        ? req.query.month
+        : thisMonthId();
+      const id = String(req.query.id || (req.body && req.body.id) || '');
+      const store = readStore();
+      const pb = store.playbooks[monthId];
+      if (!pb) return res.status(404).json({ ok: false, error: 'no playbook for that month' });
+      const before = pb.entries.length;
+      pb.entries = pb.entries.filter(e => e.id !== id);
+      if (pb.entries.length === before) return res.status(404).json({ ok: false, error: 'entry not found' });
+      writeStore(store);
+      res.json({ staged: true, monthId, deletedId: id, playbook: pb });
+    }));
+  },
+};
diff --git a/modules/profiles/index.js b/modules/profiles/index.js
new file mode 100644
index 0000000..318f8d0
--- /dev/null
+++ b/modules/profiles/index.js
@@ -0,0 +1,539 @@
+// Contact Profiles module — a unified 360° view of one contact for the Marketing
+// Command Center. Read-only. Surfaces:
+//   • who they are (name, company, role, list memberships, tags, lifecycle stage)
+//   • email engagement timeline (campaign-level sent / open / click events)
+//   • segment memberships (resolved against saved segments from segments.json)
+//   • cross-channel placeholder: last site visit + last social touch
+//
+// Live data sources, when CTCT_ACCESS_TOKEN is present, are read via the v3 API
+// for the contact roster; per-contact event streams are not first-class in CC v3,
+// so the timeline is *derived* from lastOpen/lastClick + recent campaigns and is
+// flagged with derived:true on each event. Cross-channel data is ALWAYS a
+// placeholder (no GA4 / IG creds wired) and is flagged with placeholder:true.
+//
+// Self-contained per the MODULE CONTRACT (README.md). No writes to outside
+// systems — pure read.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const SEG_FILE = path.join(DATA_DIR, 'segments.json');
+
+const CTCT_API = 'https://api.cc.email/v3';
+const live = () => Boolean(process.env.CTCT_ACCESS_TOKEN);
+
+// ── shared lookup vocab (kept aligned with segments + constant-contact mocks) ──
+const LISTS = ['Trade & Designers', 'Retail Newsletter', 'Memo Sample Requests', 'Hospitality / Commercial Spec'];
+const TAGS = ['grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo-cta', 'lookbook', 'high-intent', 'vip', 'architect'];
+const ROLES = ['Principal Designer', 'Senior Designer', 'Architect', 'Project Manager',
+  'Studio Director', 'Procurement Lead', 'Spec Writer', 'Homeowner', 'Showroom Manager'];
+const COUNTRIES = ['United States', 'United States', 'United States', 'Canada', 'United Kingdom', 'Mexico'];
+const CITIES = [
+  ['New York', 'NY'], ['Los Angeles', 'CA'], ['Chicago', 'IL'], ['Austin', 'TX'],
+  ['Miami', 'FL'], ['Boston', 'MA'], ['Seattle', 'WA'], ['San Francisco', 'CA'],
+  ['Atlanta', 'GA'], ['Denver', 'CO'], ['Charleston', 'SC'], ['Toronto', 'ON'],
+  ['London', null], ['Mexico City', 'CDMX'],
+];
+const SITE_PAGES = [
+  { slug: 'grasscloth-wallcoverings', title: 'Grasscloth Wallcoverings' },
+  { slug: 'silk-wallcoverings', title: 'Silk Wallcoverings' },
+  { slug: 'cork-wallcoverings', title: 'Cork Wallcoverings' },
+  { slug: 'flocked-wallpaper', title: 'Flocked Wallpaper' },
+  { slug: 'glass-bead-wallcoverings', title: 'Glass-Bead Wallcoverings' },
+  { slug: 'trade-program', title: 'Trade Program' },
+  { slug: 'memo-samples', title: 'Request Memo Samples' },
+  { slug: 'lookbook-2026', title: '2026 Lookbook' },
+  { slug: 'product/phillip-jeffries-silver-velvet', title: 'PJ Silver Velvet — product' },
+  { slug: 'collections/hollywood-vinyls-vol-1', title: 'Hollywood Vinyls Vol. 1' },
+];
+const SOCIAL_PLATFORMS = ['instagram', 'pinterest', 'linkedin'];
+
+// Mock email campaigns the timeline can reference (aligned with constant-contact's
+// MOCK pool conceptually; we don't import to keep the module standalone).
+const MOCK_CAMPAIGNS = [
+  { id: 'mock-cmp-001', name: 'Spring Grasscloth Edit',     sentAgoDays: 17 },
+  { id: 'mock-cmp-101', name: 'Cork & Linen — May Memo',    sentAgoDays: 36 },
+  { id: 'mock-cmp-102', name: 'Metallic Murals Lookbook',   sentAgoDays: 51 },
+  { id: 'mock-cmp-103', name: 'Trade-Only Studio Visits',   sentAgoDays: 67 },
+  { id: 'mock-cmp-104', name: 'Hospitality Spec Highlights', sentAgoDays: 82 },
+  { id: 'mock-cmp-105', name: 'Glass-Bead Drop',            sentAgoDays: 104 },
+];
+
+// ── deterministic mock contact pool ────────────────────────────────────────────
+// Seeded mulberry32 PRNG so the same install always sees the same contacts. Pool
+// shape is a superset of what segments.js evaluates over so segment membership
+// resolution is consistent. ~240 contacts is plenty for search demo without
+// making the panel sluggish.
+const POOL_SIZE = 240;
+
+function mulberry32(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s |= 0; s = (s + 0x6d2b79f5) | 0;
+    let t = Math.imul(s ^ (s >>> 15), 1 | s);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+
+let _pool = null;
+function pool() {
+  if (_pool) return _pool;
+  const rnd = mulberry32(0xC0FFEE);
+  const pick = arr => arr[Math.floor(rnd() * arr.length)];
+
+  const studios = [
+    ['atelier-noir',        'Atelier Noir'],
+    ['maison-blanc',        'Maison Blanc'],
+    ['verdant-interiors',   'Verdant Interiors'],
+    ['harbor-hospitality',  'Harbor Hospitality Group'],
+    ['lumiere-design',      'Lumière Design Co.'],
+    ['oak-and-ash',         'Oak & Ash Studio'],
+    ['studio-meridian',     'Studio Meridian'],
+    ['thornfield-co',       'Thornfield & Co.'],
+    ['cobalt-interiors',    'Cobalt Interiors'],
+    ['gilded-room',         'The Gilded Room'],
+    ['north-star-design',   'North Star Design'],
+    ['ember-and-stone',     'Ember & Stone'],
+    ['palette-house',       'Palette House'],
+    ['crestwood-studio',    'Crestwood Studio'],
+    ['ivory-lane',          'Ivory Lane Interiors'],
+    ['monarch-interiors',   'Monarch Interiors'],
+    ['saffron-studio',      'Saffron Studio'],
+    ['westbrook-design',    'Westbrook Design Group'],
+    ['vellum-co',           'Vellum Co.'],
+    ['aria-spaces',         'Aria Spaces'],
+  ];
+  const firstNames = ['Avery', 'Bea', 'Camille', 'Dante', 'Elena', 'Felix', 'Greta', 'Henrik',
+    'Iris', 'Jude', 'Kira', 'Leon', 'Mira', 'Nadia', 'Otto', 'Petra', 'Quinn', 'Ruth',
+    'Soren', 'Theo', 'Una', 'Vera', 'Wren', 'Xavi', 'Yael', 'Zara'];
+  const lastNames = ['Ashford', 'Bellamy', 'Castell', 'Devereux', 'Eastwood', 'Fontaine',
+    'Greaves', 'Holloway', 'Iverson', 'Janowicz', 'Kestrel', 'Lockhart', 'Marchetti',
+    'Norwood', 'Okafor', 'Pemberton', 'Quill', 'Rowan', 'Saint-Clair', 'Thackeray',
+    'Underhill', 'Vance', 'Whitlock', 'Yardley', 'Zborowski'];
+  const tlds = ['com', 'co', 'studio', 'design'];
+
+  const now = Date.now();
+  const DAY = 86400000;
+  const ago = d => d == null ? null : new Date(now - d * DAY).toISOString();
+
+  const out = [];
+  for (let i = 0; i < POOL_SIZE; i++) {
+    const [studioSlug, studioName] = studios[i % studios.length];
+    const isTrade = rnd() < 0.6;
+    const type = isTrade ? 'trade' : 'retail';
+
+    const first = pick(firstNames);
+    const last = pick(lastNames);
+    const role = isTrade
+      ? pick(['Principal Designer', 'Senior Designer', 'Architect', 'Project Manager',
+              'Studio Director', 'Procurement Lead', 'Spec Writer', 'Showroom Manager'])
+      : pick(['Homeowner', 'Project Manager', 'Spec Writer']);
+    const [city, region] = pick(CITIES);
+    const country = region == null ? pick(['United Kingdom', 'United Kingdom', 'Mexico']) : pick(COUNTRIES);
+
+    const lists = [];
+    if (isTrade) {
+      lists.push('Trade & Designers');
+      if (rnd() < 0.35) lists.push('Hospitality / Commercial Spec');
+    } else {
+      lists.push('Retail Newsletter');
+    }
+    if (rnd() < 0.30) lists.push('Memo Sample Requests');
+
+    // engagement recency mirrors the segments module distribution
+    let openAgo, clickAgo;
+    const r = rnd();
+    if (r < 0.18)      { openAgo = null; clickAgo = null; }
+    else if (r < 0.40) { openAgo = 90 + Math.floor(rnd() * 200);
+                         clickAgo = rnd() < 0.3 ? openAgo + Math.floor(rnd() * 10) : null; }
+    else               { openAgo = Math.floor(rnd() * 60);
+                         clickAgo = rnd() < 0.55 ? openAgo + Math.floor(rnd() * 8) : null; }
+
+    const tags = [];
+    const nTags = Math.floor(rnd() * 3);
+    for (let t = 0; t < nTags; t++) {
+      const tg = pick(TAGS);
+      if (!tags.includes(tg)) tags.push(tg);
+    }
+    if (clickAgo != null && clickAgo <= 30 && rnd() < 0.5 && !tags.includes('memo-cta')) tags.push('memo-cta');
+    if (isTrade && rnd() < 0.15 && !tags.includes('vip')) tags.push('vip');
+    if (role === 'Architect' && !tags.includes('architect')) tags.push('architect');
+
+    // opens/clicks counts correlate to recency (same logic the segments RFM uses)
+    let opens = 0, clicks = 0;
+    if (openAgo != null) {
+      if (openAgo <= 14)       opens = 6 + Math.floor(rnd() * 9);
+      else if (openAgo <= 30)  opens = 4 + Math.floor(rnd() * 6);
+      else if (openAgo <= 60)  opens = 2 + Math.floor(rnd() * 4);
+      else if (openAgo <= 120) opens = 1 + Math.floor(rnd() * 3);
+      else                     opens = Math.floor(rnd() * 2);
+    }
+    if (clickAgo != null) {
+      if (clickAgo <= 14)      clicks = 2 + Math.floor(rnd() * 5);
+      else if (clickAgo <= 30) clicks = 1 + Math.floor(rnd() * 4);
+      else if (clickAgo <= 60) clicks = 1 + Math.floor(rnd() * 2);
+      else                     clicks = Math.floor(rnd() * 2);
+    }
+
+    const handle = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}`;
+    const email = `${handle}@${studioSlug}.${pick(tlds)}`;
+
+    // last site visit + social: cross-channel placeholders. Most engaged contacts
+    // get a recent visit; lapsed ones may not.
+    const visited = openAgo != null && rnd() < 0.85;
+    const visitAgo = visited ? Math.max(0, (openAgo || 30) + Math.floor(rnd() * 8) - 4) : null;
+    const page = visited ? pick(SITE_PAGES) : null;
+
+    const hasSocial = rnd() < 0.55;
+    const social = hasSocial ? {
+      platform: pick(SOCIAL_PLATFORMS),
+      handle: `@${first.toLowerCase()}.${last.toLowerCase().replace(/[^a-z]/g, '')}`.slice(0, 30),
+      lastTouch: ago(Math.floor(rnd() * 120)),
+      action: pick(['liked a post', 'commented', 'saved a post', 'DM\'d', 'followed']),
+    } : null;
+
+    out.push({
+      id: `prof_${i.toString(36).padStart(3, '0')}`,
+      email,
+      name: `${first} ${last}`,
+      firstName: first,
+      lastName: last,
+      company: studioName,
+      role,
+      type,
+      location: { city, region, country },
+      lists,
+      tags,
+      lastOpen: ago(openAgo),
+      lastClick: ago(clickAgo),
+      opens,
+      clicks,
+      lastSiteVisit: visited ? {
+        at: ago(visitAgo),
+        page: page.slug,
+        pageTitle: page.title,
+        source: pick(['email', 'google', 'instagram', 'direct', 'pinterest']),
+        placeholder: true,
+      } : null,
+      social: social ? { ...social, placeholder: true } : null,
+      // a stable "joined" date in the past 0..900 days
+      since: ago(30 + Math.floor(rnd() * 870)),
+    });
+  }
+  _pool = out;
+  return out;
+}
+
+// ── live CTCT contact pull (best-effort) ───────────────────────────────────────
+async function ctct(pathname) {
+  const headers = {
+    Authorization: `Bearer ${process.env.CTCT_ACCESS_TOKEN}`,
+    Accept: 'application/json',
+  };
+  if (process.env.CTCT_API_KEY) headers['x-api-key'] = process.env.CTCT_API_KEY;
+  const r = await fetch(`${CTCT_API}${pathname}`, { headers });
+  const text = await r.text();
+  let json; try { json = text ? JSON.parse(text) : {}; } catch { json = {}; }
+  if (!r.ok) { const e = new Error(`CC GET ${pathname} → ${r.status}`); e.status = r.status; e.detail = json; throw e; }
+  return json;
+}
+
+function mapContact(c, listNameById, i) {
+  const email = (c.email_address && c.email_address.address) || c.email || '';
+  const lists = (c.list_memberships || []).map(id => listNameById[id]).filter(Boolean);
+  const isTrade = lists.some(n => /trade|designer|spec|commercial|hospitality/i.test(n));
+  const tags = (c.taggings || c.tags || [])
+    .map(t => (typeof t === 'string' ? t : (t.name || t.tag_id)))
+    .filter(Boolean);
+  const lastOpen = c.last_open_date || null;
+  const lastClick = c.last_click_date || null;
+  const fn = c.first_name || '';
+  const ln = c.last_name || '';
+  const name = (`${fn} ${ln}`).trim() || email.split('@')[0];
+  const company = c.company_name || (email.split('@')[1] || '').split('.')[0] || '';
+  return {
+    id: `cc_${c.contact_id || c.id || i}`,
+    email,
+    name,
+    firstName: fn,
+    lastName: ln,
+    company,
+    role: c.job_title || '',
+    type: isTrade ? 'trade' : 'retail',
+    location: {
+      city: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].city) || '',
+      region: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].state) || '',
+      country: (c.street_addresses && c.street_addresses[0] && c.street_addresses[0].country) || '',
+    },
+    lists,
+    tags,
+    lastOpen,
+    lastClick,
+    opens: Number.isFinite(c.opens_count) ? c.opens_count : 0,
+    clicks: Number.isFinite(c.clicks_count) ? c.clicks_count : 0,
+    lastSiteVisit: null,           // GA4 not wired — kept null for live mode
+    social: null,                  // IG/LI not wired — kept null for live mode
+    since: c.created_at || null,
+  };
+}
+
+async function contactPool() {
+  if (!live()) return { pool: pool(), mock: true };
+  try {
+    const listsResp = await ctct('/contact_lists?include_count=false&limit=100');
+    const listNameById = {};
+    for (const l of (listsResp.lists || [])) listNameById[l.list_id || l.id] = l.name;
+    const data = await ctct('/contacts?limit=200&include=list_memberships,taggings,custom_fields');
+    const p = (data.contacts || []).map((c, i) => mapContact(c, listNameById, i)).filter(c => c.email);
+    if (!p.length) return { pool: pool(), mock: true };
+    return { pool: p, mock: false };
+  } catch {
+    return { pool: pool(), mock: true };
+  }
+}
+
+// ── derive an engagement timeline from a contact + campaign list ───────────────
+// CC v3 doesn't expose per-contact event streams cheaply, so we synthesise a
+// realistic timeline from lastOpen / lastClick / counts against the recent
+// campaigns. Each event carries derived:true so the UI can disclose it.
+function timelineFor(contact, campaigns = MOCK_CAMPAIGNS, useMock = true) {
+  const events = [];
+  const lastOpenAgo = daysSince(contact.lastOpen);
+  const lastClickAgo = daysSince(contact.lastClick);
+
+  for (const c of campaigns) {
+    const sentAt = isoDaysAgo(c.sentAgoDays);
+    events.push({ at: sentAt, type: 'sent', campaignId: c.id, campaignName: c.name, derived: useMock });
+
+    // "open" if the campaign was sent before lastOpen and within a ~30d window of it
+    const opened = isFiniteNum(lastOpenAgo) && c.sentAgoDays >= lastOpenAgo && c.sentAgoDays - lastOpenAgo <= 35;
+    if (opened) {
+      const openAtAgo = Math.max(0, c.sentAgoDays - Math.floor(Math.random() * 2));
+      events.push({ at: isoDaysAgo(openAtAgo), type: 'open', campaignId: c.id, campaignName: c.name, derived: true });
+
+      // "click" if there's a recent click that aligns
+      const clicked = isFiniteNum(lastClickAgo) && Math.abs(c.sentAgoDays - lastClickAgo) <= 35
+        && contact.clicks > 0;
+      if (clicked) {
+        events.push({
+          at: isoDaysAgo(Math.max(0, openAtAgo - 0)),
+          type: 'click', campaignId: c.id, campaignName: c.name,
+          link: pickStable(['/grasscloth-wallcoverings', '/silk-wallcoverings', '/memo-samples', '/trade-program', '/lookbook-2026'], c.id),
+          derived: true,
+        });
+      }
+    }
+  }
+
+  // sort newest-first
+  events.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
+  return events;
+}
+
+function isFiniteNum(n) { return typeof n === 'number' && Number.isFinite(n); }
+function daysSince(iso) {
+  if (!iso) return Infinity;
+  const t = Date.parse(iso);
+  return Number.isNaN(t) ? Infinity : (Date.now() - t) / 86400000;
+}
+function isoDaysAgo(d) {
+  if (d == null) return null;
+  return new Date(Date.now() - d * 86400000).toISOString();
+}
+// Deterministic pick — same campaign id → same link, so a re-render doesn't shuffle.
+function pickStable(arr, key) {
+  let h = 0;
+  for (const ch of String(key)) h = (h * 31 + ch.charCodeAt(0)) | 0;
+  return arr[Math.abs(h) % arr.length];
+}
+
+// ── segment membership resolver ────────────────────────────────────────────────
+// We reuse the segment rule schema rather than require()-ing the segments module
+// (modules stay independent). Identical evaluation semantics.
+function loadSegments() {
+  try { return JSON.parse(fs.readFileSync(SEG_FILE, 'utf8')) || []; }
+  catch { return []; }
+}
+
+function evalRule(contact, rule) {
+  if (!rule || !rule.field) return true;
+  const { field, op, value } = rule;
+  switch (field) {
+    case 'type':
+      return op === 'is_not' ? contact.type !== value : contact.type === value;
+    case 'list': {
+      const has = (contact.lists || []).includes(value);
+      return op === 'not_in' ? !has : has;
+    }
+    case 'tag': {
+      const has = (contact.tags || []).includes(value);
+      return op === 'not_has' ? !has : has;
+    }
+    case 'engagement': {
+      const o = daysSince(contact.lastOpen);
+      const c = daysSince(contact.lastClick);
+      switch (value) {
+        case 'opened-last-30d':  return o <= 30;
+        case 'clicked-last-30d': return c <= 30;
+        case 'clicked':          return c !== Infinity;
+        case 'lapsed-90d':       return o > 90;
+        case 'never-opened':     return o === Infinity;
+        default: return true;
+      }
+    }
+    default: return true;
+  }
+}
+function matchesAll(contact, rules) {
+  if (!Array.isArray(rules) || !rules.length) return true;
+  return rules.every(r => evalRule(contact, r));
+}
+
+function segmentMembershipsFor(contact) {
+  return loadSegments()
+    .filter(seg => matchesAll(contact, seg.rules || []))
+    .map(seg => ({ id: seg.id, name: seg.name, description: seg.description || '' }));
+}
+
+// ── lifecycle stage (a friendlier rollup than the raw engagement field) ────────
+function lifecycleFor(contact) {
+  const o = daysSince(contact.lastOpen);
+  const c = daysSince(contact.lastClick);
+  if (o === Infinity) return { stage: 'Never engaged', tone: 'cold' };
+  if (c <= 30) return { stage: 'High-intent', tone: 'hot' };
+  if (o <= 30) return { stage: 'Engaged', tone: 'warm' };
+  if (o <= 60) return { stage: 'Recently active', tone: 'warm' };
+  if (o <= 90) return { stage: 'Cooling', tone: 'neutral' };
+  return { stage: 'Lapsed', tone: 'cold' };
+}
+
+// ── summary row (used by /search and /list) ────────────────────────────────────
+function summarize(contact) {
+  return {
+    id: contact.id,
+    email: contact.email,
+    name: contact.name,
+    company: contact.company,
+    role: contact.role,
+    type: contact.type,
+    lastOpen: contact.lastOpen,
+    opens: contact.opens,
+    clicks: contact.clicks,
+    lifecycle: lifecycleFor(contact).stage,
+  };
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'profiles',
+  title: 'Contact Profiles',
+  icon: '👤',
+
+  // exported for tests
+  _pool: pool,
+  _timelineFor: timelineFor,
+  _segmentMembershipsFor: segmentMembershipsFor,
+  _lifecycleFor: lifecycleFor,
+
+  mount(router) {
+    const guard = (handler) => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) {
+        res.status(e.status && e.status < 500 ? e.status : 502)
+          .json({ error: e.message, detail: e.detail || null });
+      }
+    };
+
+    // GET /list — recent / most-engaged contacts (default landing list)
+    router.get('/list', guard(async (req, res) => {
+      const { pool, mock } = await contactPool();
+      const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 25, 1), 100);
+      // sort by lastOpen desc (most recently engaged first)
+      const sorted = [...pool].sort((a, b) =>
+        (Date.parse(b.lastOpen || 0) || 0) - (Date.parse(a.lastOpen || 0) || 0));
+      res.json({
+        mock,
+        total: pool.length,
+        contacts: sorted.slice(0, limit).map(summarize),
+      });
+    }));
+
+    // GET /search?q=... — case-insensitive search across email/name/company/role/tags
+    router.get('/search', guard(async (req, res) => {
+      const q = String(req.query.q || '').trim().toLowerCase();
+      const { pool, mock } = await contactPool();
+      if (!q) {
+        return res.json({ mock, query: '', total: pool.length, count: 0, contacts: [] });
+      }
+      const hits = pool.filter(c => {
+        const hay = [
+          c.email, c.name, c.company, c.role,
+          ...(c.tags || []),
+          ...(c.lists || []),
+        ].join(' ').toLowerCase();
+        return hay.includes(q);
+      });
+      res.json({
+        mock,
+        query: q,
+        total: pool.length,
+        count: hits.length,
+        contacts: hits.slice(0, 25).map(summarize),
+      });
+    }));
+
+    // GET /contact?email=...  OR  /contact?id=...
+    // Full 360 view: profile, lifecycle, lists, tags, segment memberships,
+    // engagement timeline, last site visit, social cross-channel.
+    router.get('/contact', guard(async (req, res) => {
+      const email = String(req.query.email || '').trim().toLowerCase();
+      const id = String(req.query.id || '').trim();
+      if (!email && !id) {
+        return res.status(400).json({ error: 'email or id is required' });
+      }
+      const { pool, mock } = await contactPool();
+      const contact = pool.find(c =>
+        (id && c.id === id) || (email && (c.email || '').toLowerCase() === email));
+      if (!contact) return res.status(404).json({ error: 'contact not found' });
+
+      const lifecycle = lifecycleFor(contact);
+      const segments = segmentMembershipsFor(contact);
+      const timeline = timelineFor(contact, MOCK_CAMPAIGNS, mock);
+      // condense timeline into top-line counts the UI uses for headline stats
+      const counts = timeline.reduce((a, e) => { a[e.type] = (a[e.type] || 0) + 1; return a; },
+                                     { sent: 0, open: 0, click: 0 });
+
+      res.json({
+        mock,
+        contact: {
+          id: contact.id,
+          email: contact.email,
+          name: contact.name,
+          company: contact.company,
+          role: contact.role,
+          type: contact.type,
+          location: contact.location,
+          since: contact.since,
+          lists: contact.lists,
+          tags: contact.tags,
+          opens: contact.opens,
+          clicks: contact.clicks,
+          lastOpen: contact.lastOpen,
+          lastClick: contact.lastClick,
+        },
+        lifecycle,
+        segments,
+        timeline,
+        timelineCounts: counts,
+        crossChannel: {
+          lastSiteVisit: contact.lastSiteVisit,
+          social: contact.social,
+          // explicit so the UI can render a "wire GA4 / IG to fill these in" note
+          note: 'Cross-channel signals are placeholders — wire GA4 + social tokens to populate.',
+          placeholder: true,
+        },
+      });
+    }));
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 565bc01..05d6db6 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -17,4 +17,12 @@ module.exports = [
   'segments',           // audience segments: rule builder + live preview over contacts
   'vendors',            // vendor IG reporting: DW + all vendor Instagram accounts roster
   'channels',           // publishing engine: FB/IG/TikTok/YouTube connectors + composer (gated live posts)
+  'send-times',         // send-time optimization: heatmap + per-contact predicted peak open window
+  'ab-tests',           // A/B test planner: two variants + holdout, predicted opens/clicks, winner call (STAGED)
+  'profiles',           // contact 360: search + email engagement timeline + segment memberships + cross-channel placeholder (read-only)
+  'segment-perf',       // segment performance: email KPIs split by audience (Designers/Commercial/Retail/At-Risk), 90d SVG trends, health colour-coding
+  'templates',          // campaign templates: 8 pre-built DW luxury email campaigns (welcome, memo D0/D7/D21, VIP preview, seasonal, re-engagement) — STAGED
+  'journeys',           // cross-channel journeys: email → wait → SMS/social → retarget per segment, persisted, with recommendations + dry-run (STAGED)
+  'browse-abandon',     // browse-abandonment drip: Day1 storytelling → Day14 room-setting → Day30 suppress, mock events + dry-run preview (STAGED)
+  'playbook',           // monthly playbook: 4-6 suggested campaigns/actions for next 4 weeks from calendar + segment health + perf gaps (Gemini or template)
 ];
diff --git a/modules/segment-perf/index.js b/modules/segment-perf/index.js
new file mode 100644
index 0000000..5ccb5f9
--- /dev/null
+++ b/modules/segment-perf/index.js
@@ -0,0 +1,307 @@
+// Segment Performance module — splits email KPIs by audience segment so the
+// marketing lead can see at a glance which cohorts are healthy vs. tanking and
+// where revenue per recipient actually lives. Read-only analysis; never sends.
+//
+// KPIs surfaced per segment:
+//   • open rate            (opens / sends)
+//   • CTOR                 (clicks / opens — engagement quality of openers)
+//   • click rate           (clicks / sends — overall response)
+//   • est revenue / recipient   (orders × AOV / sends)
+//
+// Each KPI is colour-coded against luxury B2B email benchmarks (see HEALTH). A
+// 90-day daily trend per segment + metric powers the SVG bar chart panel-side.
+//
+// Data: deterministic mock (CTCT reporting + Shopify revenue attribution not
+// wired here). Every response carries { mock: true } so the panel can banner.
+//
+// The four segments mirror DW's real audience lens:
+//   designers     — interior designers / trade (mainstream luxury residential)
+//   commercial    — hospitality, healthcare, corporate spec / Type II vinyls
+//   retail        — direct-to-consumer newsletter readers
+//   at-risk       — historically engaged contacts who've gone cold (winback)
+
+const SEGMENTS = [
+  {
+    id: 'designers',
+    name: 'Interior Designers',
+    description: 'Trade designers + residential specifiers — the core luxury book of business.',
+    icon: '🪷',
+    listSize: 2840,
+    sendsPerCampaign: 2620,
+    // realistic baselines for a strong trade list
+    baseOpenRate: 0.42,   // 42%
+    baseCTOR:     0.155,  // 15.5%
+    aov:          1480,   // avg order value for a trade specifier
+    orderRate:    0.014,  // 1.4% of recipients buy within attribution window
+  },
+  {
+    id: 'commercial',
+    name: 'Commercial / Hospitality',
+    description: 'Architects + hospitality / healthcare spec — fewer eyes but high project value.',
+    icon: '🏛',
+    listSize: 612,
+    sendsPerCampaign: 590,
+    baseOpenRate: 0.385,
+    baseCTOR:     0.122,
+    aov:          6200,   // big contract project
+    orderRate:    0.006,  // rare but huge
+  },
+  {
+    id: 'retail',
+    name: 'Retail',
+    description: 'Direct-to-consumer newsletter — broadest list, lowest intent.',
+    icon: '🛍',
+    listSize: 7415,
+    sendsPerCampaign: 7080,
+    baseOpenRate: 0.275,
+    baseCTOR:     0.072,
+    aov:          340,
+    orderRate:    0.0085,
+  },
+  {
+    id: 'at-risk',
+    name: 'At-Risk (Winback)',
+    description: 'Previously engaged contacts that have gone cold — winback campaigns target these.',
+    icon: '⚠',
+    listSize: 1190,
+    sendsPerCampaign: 1150,
+    baseOpenRate: 0.082,
+    baseCTOR:     0.034,
+    aov:          410,
+    orderRate:    0.0011,
+  },
+];
+
+// Health thresholds — luxury B2B email standards. Anything below `bad` is red,
+// between bad and good is amber, at-or-above good is green. Higher-is-better
+// for every metric we track.
+const HEALTH = {
+  openRate:   { bad: 0.20, good: 0.35 },
+  ctor:       { bad: 0.08, good: 0.13 },
+  clickRate:  { bad: 0.025, good: 0.055 },
+  revPerRecipient: { bad: 0.50, good: 2.00 },
+};
+
+function healthBand(metric, value) {
+  const h = HEALTH[metric];
+  if (!h) return 'unknown';
+  if (value < h.bad) return 'bad';
+  if (value < h.good) return 'ok';
+  return 'good';
+}
+
+// ── deterministic PRNG (mulberry32) — same generator used by the other mock
+// modules in this app so behaviour stays stable across calls.
+function mulberry32(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s |= 0; s = (s + 0x6d2b79f5) | 0;
+    let t = Math.imul(s ^ (s >>> 15), 1 | s);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+function hashSeed(str) {
+  let h = 2166136261 >>> 0;
+  for (let i = 0; i < str.length; i++) {
+    h ^= str.charCodeAt(i);
+    h = Math.imul(h, 16777619) >>> 0;
+  }
+  return h >>> 0;
+}
+
+const pad = n => String(n).padStart(2, '0');
+const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+function dayList(days, end = new Date()) {
+  const out = [];
+  const base = new Date(end.getFullYear(), end.getMonth(), end.getDate());
+  for (let i = days - 1; i >= 0; i--) {
+    out.push(isoDay(new Date(base.getTime() - i * 86400000)));
+  }
+  return out;
+}
+
+// Generate a 90-day trend for one segment+metric. Approach: ~9 campaigns spaced
+// evenly across the window (one every ~10 days). Each campaign yields a per-send
+// metric value; non-campaign days inherit the previous campaign's value so the
+// SVG bars read as a step-down history. At-Risk gets a slight downward drift to
+// look like an audience genuinely going cold.
+function trendFor(segment, metric, days = 90) {
+  const dates = dayList(days);
+  const rnd = mulberry32(hashSeed(`${segment.id}|${metric}|v2`));
+  const nCampaigns = Math.max(2, Math.round(days / 10));
+  // pick campaign dates (indices into `dates`) — evenly distributed
+  const campIdxs = [];
+  for (let i = 0; i < nCampaigns; i++) {
+    campIdxs.push(Math.round(((i + 0.5) / nCampaigns) * (days - 1)));
+  }
+
+  // Step 1: compute campaign-day values
+  const campValues = campIdxs.map((idx, i) => {
+    // jitter ±18% on the base rate
+    const jitter = 0.82 + 0.36 * rnd();
+    // slight directional drift across the window
+    const driftSlope =
+      segment.id === 'at-risk' ? -0.25 :       // going cold
+      segment.id === 'commercial' ? +0.12 :   // tightening up
+      segment.id === 'designers' ? +0.05 :    // mild improvement
+      -0.04;                                   // retail mostly flat
+    const drift = 1 + driftSlope * ((i / Math.max(1, nCampaigns - 1)) - 0.5);
+
+    let value;
+    if (metric === 'openRate') {
+      value = segment.baseOpenRate * jitter * drift;
+    } else if (metric === 'ctor') {
+      value = segment.baseCTOR * jitter * drift;
+    } else if (metric === 'clickRate') {
+      // click rate = open rate × CTOR with their own jitter
+      const o = segment.baseOpenRate * (0.85 + 0.30 * rnd());
+      const c = segment.baseCTOR    * (0.85 + 0.30 * rnd());
+      value = o * c * drift;
+    } else if (metric === 'revPerRecipient') {
+      // expected dollars per recipient = orderRate × AOV, with project-deal noise
+      const orderRate = segment.orderRate * (0.6 + 1.0 * rnd()) * drift;
+      value = orderRate * segment.aov;
+    } else {
+      value = 0;
+    }
+    return value;
+  });
+
+  // Step 2: build per-day series by holding the last campaign value
+  const series = [];
+  let lastVal = campValues[0];
+  let lastCampIdx = -1;
+  for (let i = 0; i < days; i++) {
+    const ci = campIdxs.indexOf(i);
+    if (ci >= 0) { lastVal = campValues[ci]; lastCampIdx = ci; }
+    series.push({
+      date: dates[i],
+      value: +lastVal.toFixed(metric === 'revPerRecipient' ? 3 : 4),
+      isCampaign: ci >= 0,
+      campaignIndex: lastCampIdx,
+    });
+  }
+
+  const mean = campValues.reduce((a, b) => a + b, 0) / campValues.length;
+  // 30-day trend: avg of last 30 campaign-day values vs first 30
+  const last30 = series.slice(-30).map(s => s.value);
+  const first30 = series.slice(0, 30).map(s => s.value);
+  const lastAvg = last30.reduce((a, b) => a + b, 0) / last30.length;
+  const firstAvg = first30.reduce((a, b) => a + b, 0) / first30.length;
+  const pctChange = firstAvg ? +(((lastAvg - firstAvg) / firstAvg) * 100).toFixed(1) : 0;
+
+  return { series, mean: +mean.toFixed(4), campaignDays: campIdxs.map(i => dates[i]), pctChange };
+}
+
+// Headline KPI block for one segment — uses the last-30-day average from each
+// metric's trend so the headline matches the chart.
+function kpisFor(segment) {
+  const openTrend = trendFor(segment, 'openRate');
+  const ctorTrend = trendFor(segment, 'ctor');
+  const clickTrend = trendFor(segment, 'clickRate');
+  const revTrend = trendFor(segment, 'revPerRecipient');
+
+  // 30-day windowed averages (chart-aligned)
+  const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
+  const openRate  = avg(openTrend.series.slice(-30).map(s => s.value));
+  const ctor      = avg(ctorTrend.series.slice(-30).map(s => s.value));
+  const clickRate = avg(clickTrend.series.slice(-30).map(s => s.value));
+  const revPerRecipient = avg(revTrend.series.slice(-30).map(s => s.value));
+
+  return {
+    openRate:        { value: +openRate.toFixed(4),  band: healthBand('openRate', openRate),       trend: openTrend.pctChange  },
+    ctor:            { value: +ctor.toFixed(4),      band: healthBand('ctor', ctor),               trend: ctorTrend.pctChange  },
+    clickRate:       { value: +clickRate.toFixed(4), band: healthBand('clickRate', clickRate),     trend: clickTrend.pctChange },
+    revPerRecipient: { value: +revPerRecipient.toFixed(3), band: healthBand('revPerRecipient', revPerRecipient), trend: revTrend.pctChange },
+  };
+}
+
+// Overall band: worst single KPI drives the segment-level health colour, so the
+// summary card honestly flags problem cohorts even if 3 of 4 metrics look fine.
+function overallBand(kpis) {
+  const order = { bad: 0, ok: 1, good: 2, unknown: 3 };
+  return Object.values(kpis).reduce((worst, k) =>
+    (order[k.band] < order[worst] ? k.band : worst), 'good');
+}
+
+// ── module ─────────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'segment-perf',
+  title: 'Segment Performance',
+  icon: '📈',
+
+  // exposed for unit tests / sibling modules if they ever need to reuse the model
+  _segments: SEGMENTS,
+  _health: HEALTH,
+  _trendFor: trendFor,
+
+  mount(router) {
+    // GET /summary — one card's worth of data per segment
+    router.get('/summary', (_req, res) => {
+      const segments = SEGMENTS.map(s => {
+        const kpis = kpisFor(s);
+        return {
+          id: s.id,
+          name: s.name,
+          description: s.description,
+          icon: s.icon,
+          listSize: s.listSize,
+          sendsPerCampaign: s.sendsPerCampaign,
+          kpis,
+          overallBand: overallBand(kpis),
+        };
+      });
+      res.json({
+        mock: true,
+        generatedAt: new Date().toISOString(),
+        benchmarks: HEALTH,
+        segments,
+      });
+    });
+
+    // GET /trend?segmentId=designers&metric=openRate&days=90 — bar chart series
+    router.get('/trend', (req, res) => {
+      const segmentId = String(req.query.segmentId || '');
+      const metric = String(req.query.metric || 'openRate');
+      const days = Math.max(7, Math.min(180, Number(req.query.days) || 90));
+
+      const seg = SEGMENTS.find(s => s.id === segmentId);
+      if (!seg) {
+        return res.status(404).json({
+          ok: false,
+          error: `unknown segment "${segmentId}"`,
+          allowed: SEGMENTS.map(s => s.id),
+        });
+      }
+      const allowedMetrics = ['openRate', 'ctor', 'clickRate', 'revPerRecipient'];
+      if (!allowedMetrics.includes(metric)) {
+        return res.status(400).json({
+          ok: false,
+          error: `unknown metric "${metric}"`,
+          allowed: allowedMetrics,
+        });
+      }
+
+      const t = trendFor(seg, metric, days);
+      const benchmark = HEALTH[metric];
+      res.json({
+        mock: true,
+        segmentId,
+        segmentName: seg.name,
+        metric,
+        days,
+        benchmark,                       // { bad, good }
+        mean: t.mean,
+        pctChange: t.pctChange,          // last 30d vs first 30d
+        campaignDays: t.campaignDays,
+        series: t.series,
+      });
+    });
+
+    // GET /benchmarks — just the thresholds (panel uses these to colour the legend)
+    router.get('/benchmarks', (_req, res) => {
+      res.json({ mock: true, benchmarks: HEALTH });
+    });
+  },
+};
diff --git a/modules/segments/index.js b/modules/segments/index.js
index 4813634..6ab00a6 100644
--- a/modules/segments/index.js
+++ b/modules/segments/index.js
@@ -104,10 +104,28 @@ function mockPool() {
     const studio = studios[i % studios.length];
     const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
 
+    // opens/clicks counts — correlate with recency so RFM scoring is realistic.
+    // Never-engaged contacts get 0; recently-engaged contacts get the highest counts.
+    let opens = 0, clicks = 0;
+    if (openDaysAgo != null) {
+      if (openDaysAgo <= 14)      opens = 6 + Math.floor(rnd() * 9);   // 6-14
+      else if (openDaysAgo <= 30) opens = 4 + Math.floor(rnd() * 6);   // 4-9
+      else if (openDaysAgo <= 60) opens = 2 + Math.floor(rnd() * 4);   // 2-5
+      else if (openDaysAgo <= 120) opens = 1 + Math.floor(rnd() * 3);  // 1-3
+      else opens = Math.floor(rnd() * 2);                              // 0-1 (lapsed tail)
+    }
+    if (clickDaysAgo != null) {
+      if (clickDaysAgo <= 14)      clicks = 2 + Math.floor(rnd() * 5);  // 2-6
+      else if (clickDaysAgo <= 30) clicks = 1 + Math.floor(rnd() * 4);  // 1-4
+      else if (clickDaysAgo <= 60) clicks = 1 + Math.floor(rnd() * 2);  // 1-2
+      else clicks = Math.floor(rnd() * 2);                              // 0-1
+    }
+
     pool.push({
       email, type, lists,
       lastOpen: isoDaysAgo(openDaysAgo),
       lastClick: isoDaysAgo(clickDaysAgo),
+      opens, clicks,
       tags,
     });
   }
@@ -136,16 +154,36 @@ function mapContact(c, listNameById) {
   // CC has no first-class trade/retail flag; infer from list names, else 'retail'.
   const isTrade = lists.some(n => /trade|designer|spec|commercial|hospitality/i.test(n));
   const tags = (c.taggings || c.tags || []).map(t => (typeof t === 'string' ? t : (t.name || t.tag_id))).filter(Boolean);
+  const lastOpen = c.last_open_date || null;
+  const lastClick = c.last_click_date || null;
+  // CC v3 doesn't expose lifetime open/click counts on the contact resource.
+  // Derive a count heuristic from recency so RFM can score live contacts too.
+  // Explicit lifetime counts (if a future API change exposes them) win.
+  const explicitOpens = Number.isFinite(c.opens_count) ? c.opens_count
+    : Number.isFinite(c.lifetime_opens) ? c.lifetime_opens : null;
+  const explicitClicks = Number.isFinite(c.clicks_count) ? c.clicks_count
+    : Number.isFinite(c.lifetime_clicks) ? c.lifetime_clicks : null;
   return {
     email,
     type: isTrade ? 'trade' : 'retail',
     lists,
-    lastOpen: c.last_open_date || null,   // not always present on v3 contact; null-safe
-    lastClick: c.last_click_date || null,
+    lastOpen,
+    lastClick,
+    opens: explicitOpens != null ? explicitOpens : derivedCount(lastOpen, 'open'),
+    clicks: explicitClicks != null ? explicitClicks : derivedCount(lastClick, 'click'),
     tags,
   };
 }
 
+// Heuristic count for live contacts when CC doesn't surface lifetime open/click counts.
+function derivedCount(iso, kind) {
+  if (!iso) return 0;
+  const d = daysSince(iso);
+  if (d === Infinity) return 0;
+  const scale = kind === 'open' ? 8 : 4; // opens range higher than clicks
+  return Math.max(0, Math.round(scale / (1 + d / 30)));
+}
+
 // Pull a live pool (capped). Falls back to mock on any failure so the panel never
 // breaks. Returns { pool, mock }.
 async function contactPool() {
@@ -219,6 +257,158 @@ function evaluate(pool, rules, sampleSize = 12) {
   return { count: hits.length, total: pool.length, sample };
 }
 
+// ── RFM auto-segmentation ─────────────────────────────────────────────────────
+// R(ecency) — days since lastOpen (lower days → higher score, 1-5)
+// F(requency) — lifetime opens count (higher → higher score)
+// M(onetary proxy) — lifetime clicks (intent / conversion signal)
+//
+// We use thresholds rather than per-pool quintiles so scores are stable when a
+// new contact is added and so small pools (< 100) still bucket sensibly.
+const RFM_THRESHOLDS = {
+  // boundaries: score 5 if value beats first threshold, 4 next, ... 1 last
+  recencyDays: [14, 30, 60, 120], // days since open — fewer days = higher score
+  frequencyOpens: [10, 6, 3, 1],   // opens count — more = higher score
+  monetaryClicks: [5, 3, 2, 1],    // clicks count — more = higher score
+};
+
+function rScore(daysAgo) {
+  const t = RFM_THRESHOLDS.recencyDays;
+  if (daysAgo <= t[0]) return 5;
+  if (daysAgo <= t[1]) return 4;
+  if (daysAgo <= t[2]) return 3;
+  if (daysAgo <= t[3]) return 2;
+  return 1;
+}
+function fmScore(n, table) {
+  if (n >= table[0]) return 5;
+  if (n >= table[1]) return 4;
+  if (n >= table[2]) return 3;
+  if (n >= table[3]) return 2;
+  return 1;
+}
+
+function rfmScore(contact) {
+  const r = rScore(daysSince(contact.lastOpen));
+  const f = fmScore(Number(contact.opens) || 0, RFM_THRESHOLDS.frequencyOpens);
+  const m = fmScore(Number(contact.clicks) || 0, RFM_THRESHOLDS.monetaryClicks);
+  return { r, f, m };
+}
+
+// Bucket priority order — first match wins so every contact lands in exactly one.
+// Promising catches recently-engaged-but-low-frequency (new subscribers, casual)
+// so totals reconcile to pool size.
+const RFM_BUCKETS = [
+  {
+    id: 'champions',
+    name: 'Champions',
+    icon: '🏆',
+    description: 'Recently engaged, frequent openers, strong click intent — your top tier.',
+    criteria: 'R≥4 · F≥4 · M≥3',
+    test: ({ r, f, m }) => r >= 4 && f >= 4 && m >= 3,
+    nextStep: 'Send the curated trade lookbook + a $0 memo-bundle CTA.',
+  },
+  {
+    id: 'loyal',
+    name: 'Loyal',
+    icon: '💛',
+    description: 'Consistent openers across recent campaigns — keep the cadence.',
+    criteria: 'R≥3 · F≥3',
+    test: ({ r, f }) => r >= 3 && f >= 3,
+    nextStep: 'Reward with a behind-the-scenes vendor story or new-arrival peek.',
+  },
+  {
+    id: 'at-risk',
+    name: 'At-Risk',
+    icon: '⚠️',
+    description: 'Used to engage frequently — opens have gone cold.',
+    criteria: 'R≤2 · F≥3',
+    test: ({ r, f }) => r <= 2 && f >= 3,
+    nextStep: 'Win-back: "We miss you" subject + a hand-picked vignette from past clicks.',
+  },
+  {
+    id: 'hibernating',
+    name: 'Hibernating',
+    icon: '🌙',
+    description: 'Disengaged across recency and frequency — re-permission or sunset.',
+    criteria: 'R≤2 · F≤2',
+    test: ({ r, f }) => r <= 2 && f <= 2,
+    nextStep: 'Final re-permission ask, then move off the active sending pool.',
+  },
+  // Catch-all so counts reconcile to pool size (recently joined, low frequency)
+  {
+    id: 'promising',
+    name: 'Promising',
+    icon: '🌱',
+    description: 'Recent opens but low frequency — newer or casual subscribers.',
+    criteria: 'R≥3 · F≤2',
+    test: ({ r, f }) => r >= 3 && f <= 2,
+    nextStep: 'Welcome-style nurture: brand story + memo-sample CTA.',
+  },
+];
+
+function bucketFor(score) {
+  for (const b of RFM_BUCKETS) {
+    if (b.test(score)) return b.id;
+  }
+  return 'promising';
+}
+
+// Score every contact, group, and return per-bucket counts + sample.
+function rfmBuckets(pool, sampleSize = 8) {
+  const total = pool.length;
+  const groups = {};
+  for (const b of RFM_BUCKETS) groups[b.id] = [];
+  let sumR = 0, sumF = 0, sumM = 0;
+  const scored = pool.map(c => {
+    const s = rfmScore(c);
+    sumR += s.r; sumF += s.f; sumM += s.m;
+    return { contact: c, score: s };
+  });
+  // Sort by Champions/Loyal preference (highest R+F+M first), so the sample
+  // pulled per bucket is the strongest representative of that bucket.
+  scored.sort((a, b) => (b.score.r + b.score.f + b.score.m) - (a.score.r + a.score.f + a.score.m));
+  for (const row of scored) {
+    const id = bucketFor(row.score);
+    groups[id].push(row);
+  }
+
+  const buckets = RFM_BUCKETS.map(b => {
+    const rows = groups[b.id] || [];
+    const sample = rows.slice(0, sampleSize).map(({ contact, score }) => ({
+      email: contact.email,
+      type: contact.type,
+      lists: contact.lists,
+      lastOpen: contact.lastOpen,
+      lastClick: contact.lastClick,
+      opens: contact.opens || 0,
+      clicks: contact.clicks || 0,
+      r: score.r, f: score.f, m: score.m,
+    }));
+    return {
+      id: b.id,
+      name: b.name,
+      icon: b.icon,
+      description: b.description,
+      criteria: b.criteria,
+      nextStep: b.nextStep,
+      count: rows.length,
+      share: total ? rows.length / total : 0,
+      sample,
+    };
+  });
+
+  return {
+    total,
+    buckets,
+    summary: {
+      avgR: total ? +(sumR / total).toFixed(2) : 0,
+      avgF: total ? +(sumF / total).toFixed(2) : 0,
+      avgM: total ? +(sumM / total).toFixed(2) : 0,
+    },
+    thresholds: RFM_THRESHOLDS,
+  };
+}
+
 // ── segment store ────────────────────────────────────────────────────────────
 function defaultSegments() {
   return [
@@ -305,6 +495,8 @@ module.exports = {
   // exported for reuse / testing
   _evaluate: evaluate,
   _matches: matches,
+  _rfmScore: rfmScore,
+  _rfmBuckets: rfmBuckets,
 
   mount(router) {
     const guard = (handler) => async (req, res) => {
@@ -391,5 +583,14 @@ module.exports = {
       const result = evaluate(pool, rules);
       res.json({ mock, rules, ...result });
     }));
+
+    // GET /rfm — auto-bucket contacts into Champions / Loyal / At-Risk /
+    // Hibernating / Promising using Recency-Frequency-Monetary-style scoring
+    // over email engagement (lastOpen, opens count, lastClick/clicks as M proxy).
+    router.get('/rfm', guard(async (_req, res) => {
+      const { pool, mock } = await contactPool();
+      const result = rfmBuckets(pool);
+      res.json({ mock, ...result });
+    }));
   },
 };
diff --git a/modules/send-times/index.js b/modules/send-times/index.js
new file mode 100644
index 0000000..550ff57
--- /dev/null
+++ b/modules/send-times/index.js
@@ -0,0 +1,372 @@
+// Send-Time Optimization module — for a chosen segment, mine historical email
+// open timestamps and recommend the best day×hour send window. Read-only; never
+// triggers a send. Heatmap is built per-segment from per-contact open history.
+//
+// Data source: when CTCT_ACCESS_TOKEN is present we'd pull v3 reporting, but that
+// endpoint isn't on every CC plan, so this module always operates on a stable
+// deterministic mock pool (~200 contacts, each with 12-48 historical opens drawn
+// from a persona-shaped day×hour distribution). Every response includes mock:true
+// so the panel can show the "mock data" banner.
+//
+// Segment filtering reuses the segments module's rule evaluator (require it so
+// any schema additions there flow through here automatically).
+const fs = require('fs');
+const path = require('path');
+
+const segmentsMod = require('../segments');
+const SEG_FILE = path.join(__dirname, '..', '..', 'data', 'segments.json');
+
+const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+// ── deterministic PRNG ──────────────────────────────────────────────────────
+// mulberry32 — same generator used by the segments mock so behaviour is stable.
+function mulberry32(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s |= 0; s = (s + 0x6d2b79f5) | 0;
+    let t = Math.imul(s ^ (s >>> 15), 1 | s);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+
+// ── persona day×hour open distributions ─────────────────────────────────────
+// Each persona has a probability mass over (day, hour). Sampling from these
+// produces realistic-looking individual open timestamps and the aggregate
+// heatmap will surface the persona's peak windows.
+//
+// Trade designers: weekday mornings 9-11 AM, light secondary lunch 12-1 PM.
+// Retail: weekend evenings 7-9 PM + weekday lunch 12 PM.
+// Hospitality / spec: Mon-Tue 7-9 AM (project-week kickoff).
+function personaWeights(persona) {
+  const W = Array.from({ length: 7 }, () => new Array(24).fill(0.02)); // floor
+  if (persona === 'trade') {
+    for (const d of [1, 2, 3, 4]) {              // Mon-Thu
+      W[d][9]  = 1.4; W[d][10] = 1.7; W[d][11] = 1.2;
+      W[d][12] = 0.5; W[d][13] = 0.4;
+      W[d][14] = 0.7; W[d][15] = 0.6;
+    }
+    W[5][10] = 0.6;                              // Fri morning fade
+    W[2][10] = 1.9;                              // Tue 10 AM is the peak day
+  } else if (persona === 'hospitality') {
+    for (const d of [1, 2]) {                    // Mon, Tue
+      W[d][7] = 1.5; W[d][8] = 1.8; W[d][9] = 1.3;
+    }
+    W[3][8] = 0.8; W[4][8] = 0.6;
+  } else {                                       // retail
+    W[0][19] = 1.5; W[0][20] = 1.4; W[6][19] = 1.3; W[6][20] = 1.4; // weekend evenings
+    for (const d of [1, 2, 3, 4, 5]) {
+      W[d][12] = 0.9; W[d][13] = 0.5;
+      W[d][20] = 0.7;
+    }
+  }
+  // normalize
+  let sum = 0;
+  for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) sum += W[d][h];
+  for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) W[d][h] /= sum;
+  return W;
+}
+
+// Sample a (day, hour) cell from a weight matrix using `rnd` for the draw.
+function sampleCell(W, rnd) {
+  const r = rnd();
+  let acc = 0;
+  for (let d = 0; d < 7; d++) {
+    for (let h = 0; h < 24; h++) {
+      acc += W[d][h];
+      if (r <= acc) return { day: d, hour: h };
+    }
+  }
+  return { day: 2, hour: 10 };
+}
+
+// Build an ISO timestamp for the most-recent past occurrence of (day, hour),
+// offset by `weeksBack` whole weeks. Used to synthesise realistic open history.
+function isoForDayHour(day, hour, weeksBack) {
+  const d = new Date();
+  d.setUTCMinutes(0, 0, 0);
+  d.setUTCHours(hour);
+  const dayDelta = (d.getUTCDay() - day + 7) % 7;
+  d.setUTCDate(d.getUTCDate() - dayDelta - 7 * weeksBack);
+  return d.toISOString();
+}
+
+// ── mock contact pool with open history ─────────────────────────────────────
+let _pool = null;
+function mockPool() {
+  if (_pool) return _pool;
+  const rnd = mulberry32(0x517a8e35); // distinct seed from segments module
+  const studios = ['atelier-noir', 'maison-blanc', 'verdant-interiors', 'harbor-hospitality',
+    'lumiere-design', 'oak-and-ash', 'studio-meridian', 'thornfield-co', 'cobalt-interiors',
+    'gilded-room', 'north-star-design', 'ember-and-stone', 'palette-house', 'crestwood-studio',
+    'ivory-lane', 'monarch-interiors', 'saffron-studio', 'westbrook-design', 'vellum-co', 'aria-spaces'];
+  const tlds = ['com', 'co', 'studio', 'design'];
+  const TAGS = ['grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo-cta', 'lookbook', 'high-intent', 'vip', 'architect'];
+  const pick = arr => arr[Math.floor(rnd() * arr.length)];
+
+  const pool = [];
+  for (let i = 0; i < 200; i++) {
+    const isTrade = rnd() < 0.58;
+    const type = isTrade ? 'trade' : 'retail';
+
+    const lists = [];
+    if (isTrade) {
+      lists.push('Trade & Designers');
+      if (rnd() < 0.35) lists.push('Hospitality / Commercial Spec');
+    } else {
+      lists.push('Retail Newsletter');
+    }
+    if (rnd() < 0.30) lists.push('Memo Sample Requests');
+
+    let persona = 'retail';
+    if (lists.includes('Hospitality / Commercial Spec')) persona = 'hospitality';
+    else if (isTrade) persona = 'trade';
+
+    const tags = [];
+    const nTags = Math.floor(rnd() * 3);
+    for (let t = 0; t < nTags; t++) {
+      const tg = pick(TAGS);
+      if (!tags.includes(tg)) tags.push(tg);
+    }
+
+    // 12% never-engaged → no open history at all
+    const neverEngaged = rnd() < 0.12;
+    let openHistory = [];
+    if (!neverEngaged) {
+      const W = personaWeights(persona);
+      const nOpens = 12 + Math.floor(rnd() * 37); // 12-48 opens
+      for (let k = 0; k < nOpens; k++) {
+        const { day, hour } = sampleCell(W, rnd);
+        // distribute across ~16 weeks back; recent weeks slightly likelier.
+        const weeksBack = Math.floor(rnd() * rnd() * 16);
+        openHistory.push({ day, hour, ts: isoForDayHour(day, hour, weeksBack) });
+      }
+      // sort newest first so lastOpen is the head
+      openHistory.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
+    }
+
+    const lastOpen = openHistory[0] ? openHistory[0].ts : null;
+    // synthesise lastClick as ~40% of the most recent open
+    const lastClick = !neverEngaged && rnd() < 0.4 && openHistory[0]
+      ? openHistory[0].ts
+      : null;
+
+    const studio = studios[i % studios.length];
+    const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
+
+    pool.push({
+      email, type, lists, tags,
+      persona,
+      lastOpen, lastClick,
+      opens: openHistory.length,
+      clicks: lastClick ? Math.max(1, Math.floor(openHistory.length * 0.25)) : 0,
+      openHistory,
+    });
+  }
+  _pool = pool;
+  return pool;
+}
+
+// ── aggregation ─────────────────────────────────────────────────────────────
+function blankMatrix() {
+  return Array.from({ length: 7 }, () => new Array(24).fill(0));
+}
+
+function aggregateHeatmap(contacts) {
+  const M = blankMatrix();
+  let totalOpens = 0;
+  let contactsWithHistory = 0;
+  for (const c of contacts) {
+    if (!c.openHistory || !c.openHistory.length) continue;
+    contactsWithHistory++;
+    for (const o of c.openHistory) {
+      M[o.day][o.hour]++;
+      totalOpens++;
+    }
+  }
+  return { matrix: M, totalOpens, contactsWithHistory };
+}
+
+// Find the single best (day, hour) cell and a second-best for backup.
+function topCells(matrix, n = 3) {
+  const cells = [];
+  for (let d = 0; d < 7; d++) {
+    for (let h = 0; h < 24; h++) {
+      cells.push({ day: d, hour: h, count: matrix[d][h] });
+    }
+  }
+  cells.sort((a, b) => b.count - a.count);
+  return cells.slice(0, n);
+}
+
+// Group cells by day to produce a "best hour per day" digest (useful for
+// scheduling around a constraint like "must send on Tuesday").
+function bestHourPerDay(matrix) {
+  return matrix.map((row, day) => {
+    let bestHour = 0, bestCount = -1;
+    for (let h = 0; h < 24; h++) {
+      if (row[h] > bestCount) { bestCount = row[h]; bestHour = h; }
+    }
+    return { day, dayLabel: DAY_LABELS[day], hour: bestHour, count: bestCount };
+  });
+}
+
+// Per-contact predicted peak — argmax over their own openHistory matrix.
+function predictedPeak(contact) {
+  if (!contact.openHistory || contact.openHistory.length < 3) {
+    return { day: null, hour: null, confidence: 'low', samples: contact.openHistory ? contact.openHistory.length : 0 };
+  }
+  const M = blankMatrix();
+  for (const o of contact.openHistory) M[o.day][o.hour]++;
+  let bestDay = 0, bestHour = 0, bestCount = 0;
+  for (let d = 0; d < 7; d++) {
+    for (let h = 0; h < 24; h++) {
+      if (M[d][h] > bestCount) { bestCount = M[d][h]; bestDay = d; bestHour = h; }
+    }
+  }
+  // Confidence: share of opens that land in the top cell.
+  const share = bestCount / contact.openHistory.length;
+  let confidence = 'low';
+  if (share >= 0.35) confidence = 'high';
+  else if (share >= 0.20) confidence = 'med';
+  return {
+    day: bestDay, dayLabel: DAY_LABELS[bestDay],
+    hour: bestHour,
+    confidence,
+    share: +share.toFixed(2),
+    samples: contact.openHistory.length,
+  };
+}
+
+// ── segment loading ─────────────────────────────────────────────────────────
+function loadSegments() {
+  try { return JSON.parse(fs.readFileSync(SEG_FILE, 'utf8')); }
+  catch { return []; }
+}
+
+// Filter the pool by segment rules. If segmentId == '__all__' or unknown,
+// return the full pool.
+function filterBySegment(pool, segmentId) {
+  if (!segmentId || segmentId === '__all__') return { contacts: pool, segment: null };
+  const seg = loadSegments().find(s => s.id === segmentId);
+  if (!seg) return { contacts: pool, segment: null };
+  const match = segmentsMod._matches || (() => true);
+  return { contacts: pool.filter(c => match(c, seg.rules)), segment: seg };
+}
+
+function fmtHour(h) {
+  if (h == null) return '—';
+  const am = h < 12;
+  const h12 = h % 12 || 12;
+  return `${h12}:00 ${am ? 'AM' : 'PM'}`;
+}
+
+function describeCell(cell) {
+  if (!cell || cell.count === 0) return null;
+  return {
+    day: cell.day,
+    dayLabel: DAY_LABELS[cell.day],
+    hour: cell.hour,
+    hourLabel: fmtHour(cell.hour),
+    count: cell.count,
+  };
+}
+
+// ── module ──────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'send-times',
+  title: 'Send-Time Optimization',
+  icon: '⏰',
+
+  _mockPool: mockPool,
+  _aggregate: aggregateHeatmap,
+  _predictedPeak: predictedPeak,
+
+  mount(router) {
+    const guard = (handler) => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) {
+        res.status(500).json({ error: e.message });
+      }
+    };
+
+    // GET /segments — picker list (saved segments + an "all contacts" sentinel)
+    router.get('/segments', guard(async (_req, res) => {
+      const pool = mockPool();
+      const segs = loadSegments();
+      const items = [{ id: '__all__', name: 'All contacts (whole pool)', estimatedSize: pool.length }];
+      for (const s of segs) {
+        const match = segmentsMod._matches || (() => true);
+        const count = pool.filter(c => match(c, s.rules)).length;
+        items.push({ id: s.id, name: s.name, description: s.description, estimatedSize: count });
+      }
+      res.json({ mock: true, segments: items });
+    }));
+
+    // GET /heatmap?segmentId=... — day×hour aggregate + recommendations
+    router.get('/heatmap', guard(async (req, res) => {
+      const pool = mockPool();
+      const { contacts, segment } = filterBySegment(pool, String(req.query.segmentId || '__all__'));
+      const { matrix, totalOpens, contactsWithHistory } = aggregateHeatmap(contacts);
+      const top = topCells(matrix, 3).map(describeCell).filter(Boolean);
+      const perDay = bestHourPerDay(matrix);
+      res.json({
+        mock: true,
+        segment: segment ? { id: segment.id, name: segment.name, description: segment.description } : null,
+        segmentId: req.query.segmentId || '__all__',
+        contactCount: contacts.length,
+        contactsWithHistory,
+        totalOpens,
+        matrix,
+        dayLabels: DAY_LABELS,
+        recommendation: top[0] || null,
+        backups: top.slice(1),
+        bestHourPerDay: perDay,
+      });
+    }));
+
+    // GET /contacts?segmentId=...&limit=50 — per-contact predicted peak window
+    router.get('/contacts', guard(async (req, res) => {
+      const pool = mockPool();
+      const { contacts, segment } = filterBySegment(pool, String(req.query.segmentId || '__all__'));
+      const limit = Math.max(1, Math.min(500, Number(req.query.limit) || 50));
+      const rows = contacts
+        .map(c => {
+          const peak = predictedPeak(c);
+          return {
+            email: c.email,
+            type: c.type,
+            lists: c.lists,
+            tags: c.tags,
+            opens: c.opens,
+            clicks: c.clicks,
+            lastOpen: c.lastOpen,
+            peak: {
+              day: peak.day,
+              dayLabel: peak.dayLabel || null,
+              hour: peak.hour,
+              hourLabel: peak.hour == null ? null : fmtHour(peak.hour),
+              confidence: peak.confidence,
+              share: peak.share == null ? null : peak.share,
+              samples: peak.samples,
+            },
+          };
+        })
+        // Strongest signal first: high-confidence + most samples
+        .sort((a, b) => {
+          const rank = x => ({ high: 3, med: 2, low: 1 }[x.peak.confidence] || 0);
+          if (rank(b) !== rank(a)) return rank(b) - rank(a);
+          return (b.peak.samples || 0) - (a.peak.samples || 0);
+        })
+        .slice(0, limit);
+      res.json({
+        mock: true,
+        segment: segment ? { id: segment.id, name: segment.name } : null,
+        segmentId: req.query.segmentId || '__all__',
+        total: contacts.length,
+        returned: rows.length,
+        contacts: rows,
+      });
+    }));
+  },
+};
diff --git a/modules/templates/index.js b/modules/templates/index.js
new file mode 100644
index 0000000..f61f080
--- /dev/null
+++ b/modules/templates/index.js
@@ -0,0 +1,434 @@
+// templates — Campaign Templates module for the Marketing Command Center.
+// A curated library of 8 pre-built luxury DW email campaigns. Each template has
+// a brand-voice subject + body (3 short editorial paragraphs + a CTA), a set of
+// named {{placeholders}} the user can fill in, and a default value for each.
+//
+// STAGED ONLY. Nothing here sends. POST /:id/use returns the filled subject + body
+// for the user to copy/paste into Constant Contact (where Steve gates the send).
+//
+// Routes (mounted at /api/templates by the shell):
+//   GET  /meta                 — module metadata + the gate message
+//   GET  /list                 — all templates (no body if `?summary=1`)
+//   GET  /:id                  — one template (full subject + body + placeholders)
+//   POST /:id/use {fields}     — returns filled template + a CAN-SPAM reminder
+const brand = require('../../lib/brand.js');
+
+// ── Templates ────────────────────────────────────────────────────────────────
+// Each template is fully self-contained — no nested partials, no shared chrome.
+// Bodies are written in the DW voice (elevated, editorial, tactile, confident,
+// never gimmicky). Subjects sit in the 28–55 char editorial sweet spot when the
+// placeholders are filled with the default values below.
+const TEMPLATES = [
+  {
+    id: 'welcome-designer-1',
+    title: 'Welcome — Designer (Part 1 of 2)',
+    category: 'Welcome Series',
+    sequencePosition: 'Day 0',
+    description: 'Sent the moment a designer\'s trade account is approved. Warm, atelier-toned welcome that frames DW as a house, not a catalog.',
+    audience: 'interior designers',
+    productLines: ['grasscloth', 'silk', 'cork', 'linen'],
+    cta: 'Request memo samples',
+    placeholders: [
+      { key: 'first_name', label: 'Designer first name', default: 'Designer' },
+      { key: 'studio_name', label: 'Studio / firm name',  default: 'your studio' },
+      { key: 'sender_name', label: 'Sender (from)',        default: 'Steve' },
+      { key: 'cta_url',     label: 'CTA link',             default: 'https://designerwallcoverings.com/pages/trade' },
+    ],
+    subject: 'Welcome to the Designer Wallcoverings atelier',
+    preheader: 'Trade access, memo samples, and a curated archive for {{studio_name}}.',
+    body:
+`Hello {{first_name}},
+
+Welcome to Designer Wallcoverings. We built this house for designers who specify with intention — the studios that treat the wall as the most generous surface in the room. {{studio_name}} now has full trade access to the archive: grasscloth, silk, cork, linen, glass bead, flocked, mural and metallic, sourced from the makers we trust.
+
+You'll find every pattern carries a real width, a real fire rating, and a real memo at our cost — because a sample held against actual light, at the right scale, is the only honest way to specify. Order what you'd like to see; we ship same day.
+
+A good first move is a curated memo box around the project on your board. Tell us the room, the light, and the mood, and we'll pull six to ten options worth living with.
+
+[ {{cta_url}} — Request memo samples ]
+
+With our regards,
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 'welcome-designer-2',
+    title: 'Welcome — Designer (Part 2 of 2)',
+    category: 'Welcome Series',
+    sequencePosition: 'Day 3',
+    description: 'Follow-up to the welcome email — gives a designer three concrete starting points (rooms × wallcoverings) to demystify the archive.',
+    audience: 'interior designers',
+    productLines: ['grasscloth', 'mural', 'metallic'],
+    cta: 'Explore the collection',
+    placeholders: [
+      { key: 'first_name',     label: 'Designer first name', default: 'Designer' },
+      { key: 'collection_name',label: 'Featured collection', default: 'the new arrivals' },
+      { key: 'sender_name',    label: 'Sender (from)',       default: 'Steve' },
+      { key: 'cta_url',        label: 'CTA link',            default: 'https://designerwallcoverings.com/collections/new-arrivals' },
+    ],
+    subject: 'Three rooms, three wallcoverings — a place to start',
+    preheader: 'Concrete pairings from {{collection_name}} so the archive feels smaller.',
+    body:
+`{{first_name}},
+
+The archive is large; the project on your desk is specific. To make the first specification easier, three pairings we'd reach for this season.
+
+For the entry that should feel like an arrival — a deep, hand-troweled grasscloth, weighed so the seams disappear and the room hums at low light. For the library that wants to read more like a study — a textural mural with a quiet repeat, scaled to the long wall. For the powder room that should earn a photograph — a soft metallic, listed for damp service, that turns honest gold under the sconces.
+
+Each of these lives in {{collection_name}}. If one feels right, send the room and we'll pull memos at the size you need.
+
+[ {{cta_url}} — Explore {{collection_name}} ]
+
+Warmly,
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 'memo-followup-day0',
+    title: 'Memo Follow-Up — Day 0 (Shipped)',
+    category: 'Memo Sample Sequence',
+    sequencePosition: 'Day 0 (ship confirmation)',
+    description: 'Transactional confirmation the moment memos ship. Sets the right reading conditions (light, distance, time of day) so the samples are evaluated fairly.',
+    audience: 'interior designers',
+    productLines: [],
+    cta: 'View tracking',
+    placeholders: [
+      { key: 'first_name',   label: 'Recipient first name', default: 'Designer' },
+      { key: 'sample_count', label: 'Sample count',         default: '6' },
+      { key: 'tracking_url', label: 'Tracking URL',         default: 'https://designerwallcoverings.com/account/orders' },
+      { key: 'sender_name',  label: 'Sender (from)',        default: 'Steve' },
+    ],
+    subject: 'Your memo samples are on the way',
+    preheader: '{{sample_count}} memos shipped today — and how to read them at scale.',
+    body:
+`{{first_name}},
+
+Your {{sample_count}} memos shipped today. A note on reading them — a swatch in hand is closer to a paint chip than to the finished wall, so a little staging pays back.
+
+Tape each memo to the wall at the height it will live. Look at it in the morning, the late afternoon, and under the sconces you actually use; texture and metallic read very differently across the day. Stand at the distance the room is normally seen from, not at arm's length.
+
+If two patterns earn a second look, ask for a larger drop. We'll cut a meter and ship overnight — no charge on the swap.
+
+[ {{tracking_url}} — View tracking ]
+
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 'memo-followup-day7',
+    title: 'Memo Follow-Up — Day 7 (Check-in)',
+    category: 'Memo Sample Sequence',
+    sequencePosition: 'Day 7',
+    description: 'Soft check-in a week after memos arrive — never pushy. Offers a larger drop, a swap, or a trade consult; positions the studio as a partner, not a vendor.',
+    audience: 'interior designers',
+    productLines: [],
+    cta: 'Book a trade consultation',
+    placeholders: [
+      { key: 'first_name',  label: 'Recipient first name', default: 'Designer' },
+      { key: 'sender_name', label: 'Sender (from)',        default: 'Steve' },
+      { key: 'cta_url',     label: 'CTA link',             default: 'https://designerwallcoverings.com/pages/trade-consultation' },
+    ],
+    subject: 'How are the samples reading at scale?',
+    preheader: 'A larger drop, a swap, or a quick call — whatever helps the decision.',
+    body:
+`{{first_name}},
+
+Wanted to check on the memos. A week is usually enough time to know which one keeps catching your eye and which two were never quite right.
+
+If a pattern is close — within reach for the room but not yet certain — let us cut a meter so you can see the repeat in full. If none of them are, send the project notes and we'll re-curate against the brief. Either swap is on us.
+
+For the rooms where a wallcovering is doing more than dressing the wall (acoustics, light, a registered repeat across two surfaces), a fifteen-minute call with the studio often saves a full sampling round.
+
+[ {{cta_url}} — Book a trade consultation ]
+
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 'memo-followup-day21',
+    title: 'Memo Follow-Up — Day 21 (Final)',
+    category: 'Memo Sample Sequence',
+    sequencePosition: 'Day 21',
+    description: 'Final touch in the memo sequence — graceful, no pressure. Re-opens the door, recommends one related collection, exits cleanly if the project moved on.',
+    audience: 'interior designers',
+    productLines: [],
+    cta: 'Explore the collection',
+    placeholders: [
+      { key: 'first_name',     label: 'Recipient first name', default: 'Designer' },
+      { key: 'collection_name',label: 'Related collection',   default: 'the Atelier Edit' },
+      { key: 'sender_name',    label: 'Sender (from)',        default: 'Steve' },
+      { key: 'cta_url',        label: 'CTA link',             default: 'https://designerwallcoverings.com/collections' },
+    ],
+    subject: 'Still considering? A note from the atelier',
+    preheader: 'No pressure — and one related collection worth a second look.',
+    body:
+`{{first_name}},
+
+A last note on the memos. Projects move — sometimes the room changes, sometimes the wallcovering does, sometimes the client surprises everyone. No pressure either way.
+
+If the brief shifted and the original options no longer fit, the closest neighbours to that conversation now live in {{collection_name}}. The textures are quieter, the colour story is warmer, and the widths run a little broader for the larger walls you mentioned.
+
+When the project picks back up — this one or the next — we'll be here, and a fresh memo box is always one note away.
+
+[ {{cta_url}} — Explore {{collection_name}} ]
+
+With our regards,
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 'vip-preview',
+    title: 'New Collection — VIP Preview',
+    category: 'Collection Launch',
+    sequencePosition: 'Pre-launch (7 days before public)',
+    description: 'Exclusive preview to top-tier designers + commercial spec contacts before a collection ships to the trade at large. Frames the access as earned, not gimmicky.',
+    audience: 'interior designers',
+    productLines: ['grasscloth', 'silk', 'mural'],
+    cta: 'Reserve memo samples',
+    placeholders: [
+      { key: 'first_name',     label: 'Recipient first name', default: 'Designer' },
+      { key: 'collection_name',label: 'Collection name',      default: 'the Marais Collection' },
+      { key: 'designer_name',  label: 'Featured designer/house', default: 'the studio' },
+      { key: 'launch_date',    label: 'Public launch date',   default: 'next Tuesday' },
+      { key: 'sender_name',    label: 'Sender (from)',        default: 'Steve' },
+      { key: 'cta_url',        label: 'CTA link',             default: 'https://designerwallcoverings.com/collections/preview' },
+    ],
+    subject: 'Preview: {{collection_name}} — before it ships to the trade',
+    preheader: 'Seven days early access. Memos open this morning.',
+    body:
+`{{first_name}},
+
+A small note before {{collection_name}} reaches the wider trade on {{launch_date}}. Because of how often this list specifies our work, we're opening the archive seven days early.
+
+{{collection_name}} was developed with {{designer_name}} and reads like a single, sustained idea — a tighter palette than usual, larger repeats, and a few patterns sized for the proportions modern projects actually have. The grounds are unusually quiet; the highlights do all of the heavy lifting.
+
+Memos are live this morning. Order what you'd like to see, and the patterns will land before the collection is announced anywhere else.
+
+[ {{cta_url}} — Reserve memo samples ]
+
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 'seasonal-inspiration',
+    title: 'Seasonal Inspiration — Editorial',
+    category: 'Editorial / Newsletter',
+    sequencePosition: 'Quarterly',
+    description: 'Quarterly editorial letter — what the studio is reading, specifying, and noticing this season. Sells nothing directly; builds the brand voice that sells everything later.',
+    audience: 'interior designers',
+    productLines: ['grasscloth', 'mural', 'linen'],
+    cta: 'Explore the collection',
+    placeholders: [
+      { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+      { key: 'season',     label: 'Season',               default: 'Spring' },
+      { key: 'year',       label: 'Year',                 default: '2026' },
+      { key: 'theme',      label: 'Season theme',         default: 'quieter rooms, deeper texture' },
+      { key: 'sender_name',label: 'Sender (from)',        default: 'Steve' },
+      { key: 'cta_url',    label: 'CTA link',             default: 'https://designerwallcoverings.com/collections/new-arrivals' },
+    ],
+    subject: 'Notes from the studio — {{season}} {{year}}',
+    preheader: 'What we\'re reading, specifying, and watching this {{season}}.',
+    body:
+`{{first_name}},
+
+Notes from the studio for {{season}} {{year}} — what we're watching this quarter is {{theme}}.
+
+The projects coming across the desk are quieter than they were a year ago. Designers are pulling back on contrast and leaning into surface — wider grasscloth seams disguised against deeper grounds, murals scaled to the long wall rather than the feature wall, linens treated more like a painted finish than a paper. The room is doing less, but doing it more carefully.
+
+If the brief on your board is in that conversation, three places in the archive worth a look: the grasscloth weights above 9-oz, the new mural drops in the 12-foot width, and any of the linen colourways with a hand-dyed ground. Memos for any of them ship same day.
+
+[ {{cta_url}} — Explore the collection ]
+
+Until next quarter,
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+
+  {
+    id: 're-engagement',
+    title: 'Re-engagement — Quiet Contact',
+    category: 'Lifecycle',
+    sequencePosition: 'Triggered (90+ days inactive)',
+    description: 'Gentle re-engagement for contacts who have been quiet for 90+ days. Never guilty, never gimmicky — restates the trade benefit and offers one easy first step.',
+    audience: 'interior designers',
+    productLines: [],
+    cta: 'Request memo samples',
+    placeholders: [
+      { key: 'first_name', label: 'Recipient first name', default: 'Designer' },
+      { key: 'sender_name',label: 'Sender (from)',        default: 'Steve' },
+      { key: 'cta_url',    label: 'CTA link',             default: 'https://designerwallcoverings.com/pages/memo-samples' },
+    ],
+    subject: 'We saved a place on your moodboard',
+    preheader: 'A new archive, the same trade pricing, and memos on us.',
+    body:
+`{{first_name}},
+
+It's been a while, and we wanted to send a quiet note rather than another launch.
+
+A lot of the archive has turned over since we last spoke — the grasscloth weights are heavier, the mural drops are wider, and a handful of collections we knew you liked have new colourways. Your trade pricing and same-day memo shipping are still in place; nothing about your account has changed.
+
+If a project on your desk could use a fresh starting point, send the brief and we'll curate a small memo box around it. No commitment, no follow-up cadence — just a useful first look.
+
+[ {{cta_url}} — Request memo samples ]
+
+Glad to hear from you whenever the time is right,
+{{sender_name}}
+Designer Wallcoverings`,
+  },
+];
+
+// ── Placeholder fill ────────────────────────────────────────────────────────
+// Replace every {{key}} in `text` with fields[key]. Unknown placeholders are
+// left intact (with the {{}} preserved) so the user immediately sees what still
+// needs to be filled in before they paste into Constant Contact.
+function fill(text, fields) {
+  return String(text || '').replace(/\{\{\s*([a-z0-9_]+)\s*\}\}/gi, (m, key) => {
+    const v = fields && Object.prototype.hasOwnProperty.call(fields, key) ? fields[key] : undefined;
+    if (v === undefined || v === null || String(v).trim() === '') return m;
+    return String(v);
+  });
+}
+
+// Build the default-fields object from a template's placeholders array, then
+// overlay anything the caller provided.
+function defaultsFor(tpl) {
+  const out = {};
+  for (const p of tpl.placeholders || []) out[p.key] = p.default || '';
+  return out;
+}
+
+// Return the count of placeholders still unfilled in the rendered text (i.e.
+// `{{key}}` tokens the caller didn't supply). Used as a hint in the UI so the
+// designer knows whether the template is ready to copy.
+function unfilledTokens(text) {
+  const set = new Set();
+  const re = /\{\{\s*([a-z0-9_]+)\s*\}\}/gi;
+  let m;
+  while ((m = re.exec(String(text || ''))) !== null) set.add(m[1]);
+  return Array.from(set);
+}
+
+function summarize(tpl) {
+  return {
+    id: tpl.id,
+    title: tpl.title,
+    category: tpl.category,
+    sequencePosition: tpl.sequencePosition,
+    description: tpl.description,
+    audience: tpl.audience,
+    productLines: tpl.productLines || [],
+    cta: tpl.cta,
+    subject: tpl.subject,
+    preheader: tpl.preheader || '',
+    placeholders: tpl.placeholders || [],
+  };
+}
+
+// ── Module ──────────────────────────────────────────────────────────────────
+module.exports = {
+  id: 'templates',
+  title: 'Campaign Templates',
+  icon: '📋',
+
+  // exposed for unit testing / introspection only
+  _templates: TEMPLATES,
+  _fill: fill,
+
+  mount(router) {
+    const guard = (handler) => async (req, res) => {
+      try { await handler(req, res); }
+      catch (e) { res.status(500).json({ error: e.message }); }
+    };
+
+    // Module metadata — categories, brand voice context, gate copy.
+    router.get('/meta', guard(async (_req, res) => {
+      const categories = Array.from(new Set(TEMPLATES.map(t => t.category)));
+      res.json({
+        staged: true,
+        count: TEMPLATES.length,
+        categories,
+        brand: {
+          name: brand.name,
+          tagline: brand.tagline,
+          voice: brand.voice,
+          compliance: brand.compliance,
+        },
+        gateMessage: 'Campaign Templates are STAGED — this module drafts subject + body in the DW voice for you to copy into Constant Contact. Steve gates the actual send. CAN-SPAM footer (physical mailing address + working unsubscribe) must be added before any campaign ships.',
+      });
+    }));
+
+    // Full list with subject + body — the front-end uses this to render cards
+    // and let the user open one for the "Use this" flow. `?summary=1` returns
+    // a lighter shape (no body) for any future list-only views.
+    router.get('/list', guard(async (req, res) => {
+      const summary = String(req.query.summary || '') === '1';
+      res.json({
+        staged: true,
+        count: TEMPLATES.length,
+        templates: TEMPLATES.map(t => summary
+          ? summarize(t)
+          : { ...summarize(t), body: t.body }),
+      });
+    }));
+
+    // Single template — full subject + body + placeholders.
+    router.get('/:id', guard(async (req, res) => {
+      const tpl = TEMPLATES.find(t => t.id === req.params.id);
+      if (!tpl) return res.status(404).json({ error: 'template not found' });
+      res.json({ staged: true, template: { ...summarize(tpl), body: tpl.body } });
+    }));
+
+    // Fill a template. Body: { fields: { key: value, ... } }.
+    // Returns the filled subject + body, plus the placeholders still unfilled
+    // and a CAN-SPAM reminder. NEVER sends — this is the "Use this" return.
+    router.post('/:id/use', guard(async (req, res) => {
+      const tpl = TEMPLATES.find(t => t.id === req.params.id);
+      if (!tpl) return res.status(404).json({ error: 'template not found' });
+
+      const incoming = (req.body && typeof req.body === 'object' && req.body.fields && typeof req.body.fields === 'object')
+        ? req.body.fields : {};
+      // Defaults first so an empty form still produces a fully-readable draft.
+      const fields = { ...defaultsFor(tpl), ...incoming };
+
+      // Strip undefined / empty-string from incoming so defaults win for blanks.
+      for (const k of Object.keys(incoming)) {
+        const v = incoming[k];
+        if (v === undefined || v === null || String(v).trim() === '') {
+          fields[k] = defaultsFor(tpl)[k] || '';
+        }
+      }
+
+      const subject  = fill(tpl.subject, fields);
+      const preheader = fill(tpl.preheader || '', fields);
+      const body     = fill(tpl.body, fields);
+
+      const unfilled = Array.from(new Set([
+        ...unfilledTokens(subject),
+        ...unfilledTokens(body),
+        ...unfilledTokens(preheader),
+      ]));
+
+      res.json({
+        staged: true,
+        templateId: tpl.id,
+        title: tpl.title,
+        category: tpl.category,
+        cta: tpl.cta,
+        fields,
+        subject,
+        preheader,
+        body,
+        unfilled,
+        complianceReminder: brand.compliance,
+        sent: false,
+        sendNote: 'STAGED draft only — paste into Constant Contact, add the CAN-SPAM footer, and route through the gated send flow. This endpoint never delivers.',
+      });
+    }));
+  },
+};
diff --git a/public/panels/ab-tests.html b/public/panels/ab-tests.html
new file mode 100644
index 0000000..bbbb1f4
--- /dev/null
+++ b/public/panels/ab-tests.html
@@ -0,0 +1,95 @@
+<div id="ab-banner"></div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>A/B Tests</h2>
+      <div class="muted" style="font-size:12px">
+        Plan two campaign variants — subject + body/angle — set a holdout %, and
+        see predicted opens, clicks, and a winner call. <b>STAGED</b>: this module
+        plans and tracks; it never triggers a live send.
+      </div>
+    </div>
+    <span class="pill" id="ab-count">0 tests</span>
+  </div>
+</div>
+
+<div class="grid" style="grid-template-columns:420px 1fr;align-items:start">
+
+  <!-- new test form -->
+  <div class="card" id="ab-form-card">
+    <h2>New A/B test</h2>
+    <div class="muted" style="font-size:12px">Drafting only — no email leaves the building.</div>
+
+    <div style="margin-top:12px">
+      <label>Test name</label>
+      <input id="ab-name" placeholder="e.g. Grasscloth lookbook — subject test" />
+    </div>
+
+    <div class="row" style="margin-top:10px;gap:10px">
+      <div style="flex:1;min-width:120px">
+        <label>Audience size</label>
+        <input id="ab-audience" type="number" min="100" value="2400" />
+      </div>
+      <div style="flex:1;min-width:120px">
+        <label>Holdout %</label>
+        <input id="ab-holdout" type="number" min="0" max="50" step="1" value="10" />
+      </div>
+    </div>
+
+    <!-- Variant A -->
+    <div style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
+      <div class="row" style="justify-content:space-between;align-items:baseline">
+        <label style="margin:0">Variant A</label>
+        <span class="pill" style="background:#efe7d6">A</span>
+      </div>
+      <div style="margin-top:6px">
+        <input id="ab-a-subject" placeholder="Subject line" maxlength="140" />
+      </div>
+      <div style="margin-top:8px">
+        <textarea id="ab-a-body" rows="3" placeholder="Body / preview text — what's the angle?"></textarea>
+      </div>
+      <div style="margin-top:8px">
+        <select id="ab-a-angle"></select>
+      </div>
+    </div>
+
+    <!-- Variant B -->
+    <div style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
+      <div class="row" style="justify-content:space-between;align-items:baseline">
+        <label style="margin:0">Variant B</label>
+        <span class="pill" style="background:var(--cream)">B</span>
+      </div>
+      <div style="margin-top:6px">
+        <input id="ab-b-subject" placeholder="Subject line" maxlength="140" />
+      </div>
+      <div style="margin-top:8px">
+        <textarea id="ab-b-body" rows="3" placeholder="Body / preview text — what's the angle?"></textarea>
+      </div>
+      <div style="margin-top:8px">
+        <select id="ab-b-angle"></select>
+      </div>
+    </div>
+
+    <div class="row" style="margin-top:14px;justify-content:space-between;align-items:center">
+      <span class="muted" id="ab-form-msg" style="font-size:12px">&nbsp;</span>
+      <button class="btn gold" id="ab-create">Stage A/B test</button>
+    </div>
+  </div>
+
+  <!-- tests list + detail -->
+  <div>
+    <div class="card">
+      <div class="row" style="justify-content:space-between;align-items:baseline">
+        <h2 style="margin:0">Staged tests</h2>
+        <button class="btn ghost" id="ab-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+      </div>
+      <div id="ab-list" class="loading" style="margin-top:12px">Loading…</div>
+    </div>
+
+    <div class="card" id="ab-detail-card" style="display:none">
+      <div id="ab-detail"></div>
+    </div>
+  </div>
+
+</div>
diff --git a/public/panels/ab-tests.js b/public/panels/ab-tests.js
new file mode 100644
index 0000000..89de684
--- /dev/null
+++ b/public/panels/ab-tests.js
@@ -0,0 +1,287 @@
+// A/B Tests panel — author two variants, set a holdout %, stage the test,
+// and review predicted opens/clicks + a winner call. STAGED only — no send
+// action is exposed in the UI.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['ab-tests'] = {
+  init(root) {
+    const $ = s => root.querySelector(s);
+    const $$ = s => Array.from(root.querySelectorAll(s));
+    const api = (p, opts) => fetch(`/api/ab-tests${p}`, opts).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const pct = (n, d = 1) => (n == null ? '—' : (n * 100).toFixed(d) + '%');
+    const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
+      { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
+
+    let META = null;
+    let SELECTED = null;
+
+    function banner() {
+      $('#ab-banner').innerHTML = `<div class="muted-banner">
+        <b>STAGED only.</b> This module plans and tracks A/B tests with predicted
+        (mock) open/click rates. It does <b>not</b> send email. Approved variants
+        ship through Constant Contact, where Steve gates the live send.
+      </div>`;
+    }
+
+    function fillAngles() {
+      const angles = (META && META.angles) || [];
+      const options = ['<option value="">— angle (optional) —</option>']
+        .concat(angles.map(a => `<option value="${esc(a)}">${esc(a)}</option>`)).join('');
+      $('#ab-a-angle').innerHTML = options;
+      $('#ab-b-angle').innerHTML = options;
+    }
+
+    // ── list ─────────────────────────────────────────────────────────────
+    async function loadList() {
+      $('#ab-list').classList.add('loading');
+      $('#ab-list').textContent = 'Loading…';
+      const data = await api('/tests');
+      $('#ab-count').textContent = `${data.count} test${data.count === 1 ? '' : 's'}`;
+      const target = $('#ab-list');
+      target.classList.remove('loading');
+      if (!data.tests || !data.tests.length) {
+        target.innerHTML = '<div class="muted">No tests staged yet. Use the form to draft variant A and variant B.</div>';
+        return;
+      }
+      target.innerHTML = `
+        <table style="border-collapse:collapse;width:100%;font-size:13px">
+          <thead>
+            <tr style="text-align:left;font-size:11px;color:var(--mut)">
+              <th style="padding:6px 8px">Name</th>
+              <th style="padding:6px 8px">Status</th>
+              <th style="padding:6px 8px">Audience</th>
+              <th style="padding:6px 8px">Hold</th>
+              <th style="padding:6px 8px">Winner</th>
+              <th style="padding:6px 8px">Created</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            ${data.tests.map(t => `
+              <tr data-id="${esc(t.id)}" style="border-top:1px solid var(--line);cursor:pointer"
+                  class="ab-row${SELECTED === t.id ? ' ab-row-active' : ''}">
+                <td style="padding:7px 8px">
+                  <div style="font-weight:600">${esc(t.name)}</div>
+                  <div class="muted" style="font-size:11px">A: ${esc((t.subjectA || '').slice(0, 40))}${t.subjectA && t.subjectA.length > 40 ? '…' : ''}</div>
+                </td>
+                <td style="padding:7px 8px">
+                  <span class="pill" style="background:${t.status === 'concluded' ? 'var(--cream)' : '#efe7d6'};
+                    color:${t.status === 'concluded' ? 'var(--sage)' : 'var(--accent)'}">${esc(t.status)}</span>
+                </td>
+                <td style="padding:7px 8px">${Number(t.audienceSize || 0).toLocaleString()}</td>
+                <td style="padding:7px 8px">${esc(String(t.holdoutPct))}%</td>
+                <td style="padding:7px 8px">${t.winner ? `<b>${esc(t.winner)}</b>` : '<span class="muted">—</span>'}</td>
+                <td style="padding:7px 8px" class="muted" style="font-size:11px">${esc(fmtDate(t.createdAt))}</td>
+                <td style="padding:7px 8px;text-align:right">
+                  <button class="btn ghost ab-view" data-id="${esc(t.id)}" style="padding:4px 10px;font-size:12px">View</button>
+                </td>
+              </tr>
+            `).join('')}
+          </tbody>
+        </table>
+      `;
+      target.querySelectorAll('.ab-view').forEach(b => b.onclick = (e) => {
+        e.stopPropagation();
+        loadDetail(b.dataset.id);
+      });
+      target.querySelectorAll('.ab-row').forEach(r => r.onclick = () => loadDetail(r.dataset.id));
+    }
+
+    // ── detail ───────────────────────────────────────────────────────────
+    async function loadDetail(id) {
+      SELECTED = id;
+      const data = await api(`/tests/${encodeURIComponent(id)}`);
+      if (data.error) {
+        $('#ab-detail-card').style.display = 'none';
+        return;
+      }
+      const t = data.test;
+      $('#ab-detail-card').style.display = '';
+
+      const r = t.results || {};
+      const w = t.winner || {};
+      const a = r.variant_a || {};
+      const b = r.variant_b || {};
+
+      const variantCard = (v, k, res, isWinner) => `
+        <div class="card" style="margin:0;padding:14px;
+             background:${isWinner ? 'var(--cream)' : '#fff'};
+             border-color:${isWinner ? 'var(--gold)' : 'var(--line)'}">
+          <div class="row" style="justify-content:space-between;align-items:baseline">
+            <h2 style="margin:0;font-size:15px">Variant ${esc(k)}</h2>
+            ${isWinner ? `<span class="pill" style="background:var(--gold);color:var(--ink)">winner</span>`
+                       : `<span class="pill">${esc(v.angle || '—')}</span>`}
+          </div>
+          <div style="margin-top:8px">
+            <div class="muted" style="font-size:11px">Subject</div>
+            <div style="font-weight:600">${esc(v.subject)}</div>
+          </div>
+          <div style="margin-top:8px">
+            <div class="muted" style="font-size:11px">Body / angle</div>
+            <div style="font-size:13px;color:var(--ink)">${esc(v.body)}</div>
+          </div>
+          <div class="row" style="margin-top:12px;gap:18px">
+            <div><div class="muted" style="font-size:11px">Sent</div>
+              <div style="font-weight:700;font-size:18px">${Number(res.sent || 0).toLocaleString()}</div></div>
+            <div><div class="muted" style="font-size:11px">Opens</div>
+              <div style="font-weight:700;font-size:18px">${Number(res.opens || 0).toLocaleString()}
+                <span style="font-size:12px;color:var(--mut);font-weight:400">(${pct(res.openRate, 2)})</span></div></div>
+            <div><div class="muted" style="font-size:11px">Clicks</div>
+              <div style="font-weight:700;font-size:18px">${Number(res.clicks || 0).toLocaleString()}
+                <span style="font-size:12px;color:var(--mut);font-weight:400">(${pct(res.clickRate, 2)})</span></div></div>
+          </div>
+          ${(res.factors || []).length ? `
+            <div style="margin-top:10px">
+              <div class="muted" style="font-size:11px">Copy-quality factors</div>
+              <div style="margin-top:4px">${
+                res.factors.map(f => `<span class="pill" style="margin:2px 4px 0 0;background:#fbf9f5;border:1px solid var(--line);color:var(--ink)">${esc(f.name)}</span>`).join('')
+              }</div>
+            </div>` : ''}
+        </div>`;
+
+      const isAWin = w.winner === 'A';
+      const isBWin = w.winner === 'B';
+
+      const winnerCallout = w.winner
+        ? `<div style="background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px;margin-top:14px">
+            <div class="row" style="justify-content:space-between;align-items:baseline">
+              <div>
+                <div class="muted" style="font-size:11px">Winner call</div>
+                <div style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--accent)">
+                  Variant ${esc(w.winner)} wins on ${esc(w.metric)}
+                </div>
+              </div>
+              <span class="pill" style="background:${w.confidence === 'high' ? 'var(--sage)' : w.confidence === 'med' ? 'var(--gold)' : 'var(--cream)'};
+                                       color:${w.confidence === 'high' || w.confidence === 'med' ? 'var(--ink)' : 'var(--mut)'}">
+                ${esc(w.confidence)} confidence
+              </span>
+            </div>
+            <div class="row" style="gap:18px;margin-top:10px;font-size:13px">
+              <div><span class="muted">Lift abs:</span> <b>${pct(w.liftAbsolute, 2)}</b></div>
+              <div><span class="muted">Lift rel:</span> <b>${(w.liftRelative * 100).toFixed(1)}%</b></div>
+              <div><span class="muted">z-score:</span> <b>${esc(String(w.z))}</b></div>
+              <div><span class="muted">Composite A/B:</span> <b>${esc(String(w.compositeA))}</b> / <b>${esc(String(w.compositeB))}</b></div>
+            </div>
+            <div class="muted" style="font-size:11px;margin-top:8px">
+              Winner is chosen by a blended 60/40 open-rate × click-rate composite; confidence
+              comes from a two-proportion z-test on open-rate.
+            </div>
+          </div>`
+        : '<div class="muted" style="margin-top:12px">No winner — variants tied or audience empty.</div>';
+
+      $('#ab-detail').innerHTML = `
+        <div class="row" style="justify-content:space-between;align-items:baseline">
+          <div>
+            <h2 style="margin:0">${esc(t.name)}</h2>
+            <div class="muted" style="font-size:12px">
+              ${esc(t.audienceSize.toLocaleString())} contacts · ${esc(String(t.holdoutPct))}% holdout
+              (${Number(r.holdout && r.holdout.held || 0).toLocaleString()} held) ·
+              status <b>${esc(t.status)}</b> · simulated ${esc(fmtDate(r.simulatedAt))}
+            </div>
+          </div>
+          <div class="row" style="gap:8px">
+            <button class="btn ghost" data-act="simulate" data-id="${esc(t.id)}"
+                    ${t.status === 'concluded' ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''}>Re-simulate</button>
+            <button class="btn" data-act="conclude" data-id="${esc(t.id)}"
+                    ${t.status === 'concluded' ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''}>Freeze winner</button>
+            <button class="btn ghost" data-act="delete" data-id="${esc(t.id)}"
+                    style="color:var(--accent)">Delete</button>
+          </div>
+        </div>
+
+        ${winnerCallout}
+
+        <div class="grid" style="grid-template-columns:1fr 1fr;margin-top:14px">
+          ${variantCard(t.variants[0], 'A', a, isAWin)}
+          ${variantCard(t.variants[1], 'B', b, isBWin)}
+        </div>
+
+        <div class="muted" style="font-size:11px;margin-top:12px">
+          Mock numbers — predicted using a deterministic copy-quality heuristic
+          over DW historical baselines (open ${pct((META && META.baseline.openRate) || 0.22)},
+          click ${pct((META && META.baseline.clickRate) || 0.032)}). Real reporting
+          can flow back through <code>PATCH /api/ab-tests/tests/${esc(t.id)}/results</code> later.
+        </div>
+      `;
+
+      $('#ab-detail').querySelectorAll('button[data-act]').forEach(btn => {
+        btn.onclick = async () => {
+          const act = btn.dataset.act;
+          if (act === 'delete') {
+            if (!confirm('Delete this staged A/B test? This cannot be undone.')) return;
+            await api(`/tests/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
+            $('#ab-detail-card').style.display = 'none';
+            SELECTED = null;
+            await loadList();
+            return;
+          }
+          if (act === 'conclude') {
+            if (!confirm('Freeze this test? Status becomes "concluded" and results stop changing.')) return;
+          }
+          const url = `/tests/${encodeURIComponent(btn.dataset.id)}/${act}`;
+          const res = await api(url, { method: 'POST' });
+          if (res.error) { alert(res.error); return; }
+          await loadList();
+          await loadDetail(btn.dataset.id);
+        };
+      });
+    }
+
+    // ── form ─────────────────────────────────────────────────────────────
+    $('#ab-create').onclick = async () => {
+      const body = {
+        name: $('#ab-name').value.trim(),
+        audienceSize: Number($('#ab-audience').value),
+        holdoutPct: Number($('#ab-holdout').value),
+        variants: [
+          {
+            subject: $('#ab-a-subject').value.trim(),
+            body:    $('#ab-a-body').value.trim(),
+            angle:   $('#ab-a-angle').value,
+          },
+          {
+            subject: $('#ab-b-subject').value.trim(),
+            body:    $('#ab-b-body').value.trim(),
+            angle:   $('#ab-b-angle').value,
+          },
+        ],
+      };
+      $('#ab-form-msg').textContent = 'Staging…';
+      $('#ab-form-msg').style.color = 'var(--mut)';
+
+      const res = await api('/tests', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body),
+      });
+      if (res.error) {
+        $('#ab-form-msg').style.color = 'var(--accent)';
+        $('#ab-form-msg').textContent = res.error + (res.issues ? ' — ' + res.issues.join(', ') : '');
+        return;
+      }
+      $('#ab-form-msg').style.color = 'var(--sage)';
+      $('#ab-form-msg').textContent = `Staged: ${res.test.id}`;
+      // soft reset
+      ['ab-name', 'ab-a-subject', 'ab-a-body', 'ab-b-subject', 'ab-b-body'].forEach(id => $('#' + id).value = '');
+      await loadList();
+      await loadDetail(res.test.id);
+    };
+
+    $('#ab-refresh').onclick = loadList;
+
+    (async () => {
+      banner();
+      META = await api('/meta');
+      fillAngles();
+      // Sensible defaults that show off the heuristic on first render.
+      $('#ab-a-subject').value = 'Discover the grasscloth archive — designer';
+      $('#ab-a-body').value = 'A fresh memo of Phillip Jeffries & Innovations grasscloths from the new archive. Request memo samples to feel the texture.';
+      $('#ab-a-angle').value = 'editorial';
+      $('#ab-b-subject').value = 'FREE samples!!! click here';
+      $('#ab-b-body').value = 'Get free wallpaper now act fast last chance.';
+      $('#ab-b-angle').value = 'promo';
+      await loadList();
+    })();
+  },
+};
diff --git a/public/panels/browse-abandon.html b/public/panels/browse-abandon.html
new file mode 100644
index 0000000..93c5164
--- /dev/null
+++ b/public/panels/browse-abandon.html
@@ -0,0 +1,91 @@
+<div id="ba-banner"></div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>Browse Abandonment</h2>
+      <div class="muted" style="font-size:12px">
+        A designer views a product but never asks for a memo sample. The drip stages a follow-up:
+        <b>Day 1 storytelling</b> → <b>Day 14 room-setting</b> → <b>Day 30 suppress</b>.
+        <b>PLANNING ONLY</b> — nothing here triggers a live send.
+      </div>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <span class="pill" id="ba-summary-pill">— browse events</span>
+    </div>
+  </div>
+</div>
+
+<div class="grid" style="grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px">
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Total events</div>
+    <div id="ba-stat-total" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Cooling window</div>
+    <div id="ba-stat-cooling" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">In drip</div>
+    <div id="ba-stat-indrip" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--gold)">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Would fire today</div>
+    <div id="ba-stat-today" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--accent)">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Suppressed / sampled</div>
+    <div id="ba-stat-out" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--sage)">—</div>
+  </div>
+</div>
+
+<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
+
+  <!-- Left: drip config editor -->
+  <div class="card">
+    <div class="row" style="justify-content:space-between;align-items:baseline">
+      <h2 style="margin:0">Drip configuration</h2>
+      <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · planning only</span>
+    </div>
+    <div id="ba-config" class="loading" style="margin-top:10px">Loading…</div>
+  </div>
+
+  <!-- Right: 7-day schedule projection -->
+  <div class="card">
+    <div class="row" style="justify-content:space-between;align-items:baseline">
+      <h2 style="margin:0">Next 7 days — what WOULD fire</h2>
+      <span class="pill" style="background:var(--cream)">dry-run</span>
+    </div>
+    <div id="ba-schedule" class="loading" style="margin-top:10px">Loading…</div>
+  </div>
+
+</div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2 style="margin:0">Browse events</h2>
+      <div class="muted" style="font-size:12px">Mock browse activity. Click an event to preview what the next-due step would render.</div>
+    </div>
+    <div class="row" style="gap:8px">
+      <select id="ba-filter" style="width:auto;min-width:160px">
+        <option value="all">All stages</option>
+        <option value="in-drip">In drip</option>
+        <option value="cooling">Cooling window</option>
+        <option value="suppressed">Suppressed</option>
+        <option value="sample-requested">Sample requested</option>
+        <option value="would-fire">Would fire today</option>
+      </select>
+      <button class="btn ghost" id="ba-reseed" style="padding:7px 12px;font-size:12.5px">↻ Reseed mock events</button>
+    </div>
+  </div>
+  <div id="ba-events" class="loading" style="margin-top:10px">Loading…</div>
+</div>
+
+<div class="card" id="ba-preview-card" style="display:none">
+  <div class="row" style="justify-content:space-between;align-items:baseline">
+    <h2 style="margin:0">Dry-run preview</h2>
+    <span class="pill" style="background:#fff0e6;color:var(--accent)">NOT sent</span>
+  </div>
+  <div id="ba-preview" style="margin-top:10px"></div>
+</div>
diff --git a/public/panels/browse-abandon.js b/public/panels/browse-abandon.js
new file mode 100644
index 0000000..fb53703
--- /dev/null
+++ b/public/panels/browse-abandon.js
@@ -0,0 +1,392 @@
+// browse-abandon panel — Browse Abandonment drip designer + dry-run dashboard.
+// PLANNING ONLY. Talks to /api/browse-abandon; never triggers a live send.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['browse-abandon'] = {
+  async init(root) {
+    const $  = s => root.querySelector(s);
+    const $$ = s => Array.from(root.querySelectorAll(s));
+    const api = (p, opts) => fetch(`/api/browse-abandon${p}`, opts).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
+      { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
+
+    let META = null;
+    let CONFIG = null;
+    let EVENTS = [];
+    let SUMMARY = null;
+    let FILTER = 'all';
+
+    function banner() {
+      $('#ba-banner').innerHTML = `<div class="muted-banner">
+        🔁 <b>STAGED — planning only.</b> ${esc(META && META.gateMessage || 'This module never sends.')}
+      </div>`;
+    }
+
+    // ── load ────────────────────────────────────────────────────────────────
+    try {
+      META = await api('/meta');
+    } catch (e) {
+      $('#ba-config').innerHTML = '<div class="muted-banner">Could not reach the browse-abandon service.</div>';
+      return;
+    }
+    banner();
+
+    async function loadConfig() {
+      const data = await api('/config');
+      CONFIG = data.config;
+      renderConfig();
+    }
+
+    async function loadEvents() {
+      const data = await api('/events');
+      EVENTS = data.events || [];
+      SUMMARY = data.summary || {};
+      renderStats();
+      renderEvents();
+    }
+
+    async function loadSchedule() {
+      const data = await api('/schedule?days=7');
+      renderSchedule(data);
+    }
+
+    // ── stats ───────────────────────────────────────────────────────────────
+    function renderStats() {
+      const s = SUMMARY || {};
+      $('#ba-summary-pill').textContent = `${s.total || 0} browse event${s.total === 1 ? '' : 's'}`;
+      $('#ba-stat-total').textContent   = (s.total || 0).toLocaleString();
+      $('#ba-stat-cooling').textContent = (s.cooling || 0).toLocaleString();
+      $('#ba-stat-indrip').textContent  = (s.inDrip || 0).toLocaleString();
+      $('#ba-stat-today').textContent   = (s.wouldFireToday || 0).toLocaleString();
+      $('#ba-stat-out').textContent     = ((s.sampleRequested || 0) + (s.suppressed || 0)).toLocaleString();
+    }
+
+    // ── config editor ───────────────────────────────────────────────────────
+    function renderConfig() {
+      const target = $('#ba-config');
+      target.classList.remove('loading');
+      if (!CONFIG) { target.innerHTML = '<div class="muted">No config.</div>'; return; }
+      target.innerHTML = `
+        <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
+          <div>
+            <label for="ba-enabled">Drip status</label>
+            <select id="ba-enabled">
+              <option value="true"  ${CONFIG.enabled  ? 'selected' : ''}>Enabled (would evaluate events)</option>
+              <option value="false" ${!CONFIG.enabled ? 'selected' : ''}>Paused</option>
+            </select>
+          </div>
+          <div>
+            <label for="ba-threshold">No-sample window (days)</label>
+            <input id="ba-threshold" type="number" min="0" max="90" value="${Number(CONFIG.noSampleThresholdDays) || 1}" />
+          </div>
+          <div>
+            <label for="ba-cap">Daily send cap</label>
+            <input id="ba-cap" type="number" min="0" max="10000" value="${Number(CONFIG.dailySendCap) || 0}" />
+          </div>
+          <div>
+            <label>&nbsp;</label>
+            <div class="muted" style="font-size:11.5px;padding:8px 0">
+              Tokens you can use in subject + body: ${(META.tokens || []).map(t => `<code style="background:var(--paper);padding:1px 4px;border-radius:4px">{${esc(t)}}</code>`).join(' ')}
+            </div>
+          </div>
+        </div>
+
+        <div id="ba-steps"></div>
+
+        <div class="row" style="justify-content:space-between;align-items:center;margin-top:10px;border-top:1px solid var(--line);padding-top:12px">
+          <span class="muted" id="ba-msg" style="font-size:12px">&nbsp;</span>
+          <div class="row" style="gap:8px">
+            <button class="btn ghost" id="ba-reset" style="padding:7px 12px">↺ Reset to defaults</button>
+            <button class="btn gold"  id="ba-save"  style="padding:7px 14px">💾 Save drip</button>
+          </div>
+        </div>
+      `;
+
+      renderSteps();
+      $('#ba-enabled').onchange    = e => { CONFIG.enabled = e.target.value === 'true'; };
+      $('#ba-threshold').oninput   = e => { CONFIG.noSampleThresholdDays = Number(e.target.value) || 0; };
+      $('#ba-cap').oninput         = e => { CONFIG.dailySendCap = Number(e.target.value) || 0; };
+      $('#ba-save').onclick        = saveConfig;
+      $('#ba-reset').onclick       = resetConfig;
+    }
+
+    function renderSteps() {
+      const target = $('#ba-steps');
+      if (!target) return;
+      const steps = (CONFIG.steps || []).slice().sort((a, b) => a.dayOffset - b.dayOffset);
+      target.innerHTML = steps.map((s, i) => stepCard(s, i)).join('');
+      $$('[data-step-id]').forEach(card => {
+        const id = card.dataset.stepId;
+        const step = CONFIG.steps.find(x => x.id === id);
+        if (!step) return;
+        card.querySelectorAll('[data-field]').forEach(input => {
+          const f = input.dataset.field;
+          input.oninput = () => {
+            if (input.type === 'number') step[f] = Number(input.value) || 0;
+            else if (input.type === 'checkbox') step[f] = input.checked;
+            else step[f] = input.value;
+          };
+          input.onchange = input.oninput;
+        });
+      });
+    }
+
+    function stepCard(step, idx) {
+      const fieldHtml = step.kind === 'email' ? `
+        <div style="grid-column:1/-1">
+          <label>Subject line</label>
+          <input type="text" data-field="subject" value="${esc(step.subject)}" />
+        </div>
+        <div style="grid-column:1/-1">
+          <label>Body template <span class="muted" style="font-weight:400">— supports {tokens}</span></label>
+          <textarea rows="6" data-field="bodyTemplate">${esc(step.bodyTemplate)}</textarea>
+        </div>
+        <div>
+          <label>CTA label</label>
+          <input type="text" data-field="cta" value="${esc(step.cta || '')}" />
+        </div>
+        <div>
+          <label>Day offset</label>
+          <input type="number" min="0" max="365" data-field="dayOffset" value="${Number(step.dayOffset) || 0}" />
+        </div>
+      ` : `
+        <div>
+          <label>Suppress cohort</label>
+          <input type="text" data-field="suppressList" value="${esc(step.suppressList || '')}" />
+        </div>
+        <div>
+          <label>Day offset</label>
+          <input type="number" min="0" max="365" data-field="dayOffset" value="${Number(step.dayOffset) || 0}" />
+        </div>
+        <div style="grid-column:1/-1">
+          <label>Internal note</label>
+          <textarea rows="2" data-field="note">${esc(step.note || '')}</textarea>
+        </div>
+      `;
+      const kindIcon = step.kind === 'email' ? '✉️' : '⏹';
+      return `
+        <div data-step-id="${esc(step.id)}"
+             style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:#fff">
+          <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px">
+            <div class="row" style="gap:10px;align-items:center">
+              <span style="font:600 14px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--mut)">${idx + 1}.</span>
+              <span style="font-size:18px">${kindIcon}</span>
+              <input type="text" data-field="title" value="${esc(step.title || '')}" style="font-weight:700;max-width:300px" />
+              <span class="pill" style="background:var(--cream)">Day ${Number(step.dayOffset) || 0}</span>
+              <span class="pill" style="background:#efece5;color:var(--ink)">${esc(step.kind)}</span>
+            </div>
+            <label style="display:inline-flex;gap:6px;align-items:center;margin:0;font-weight:600">
+              <input type="checkbox" data-field="enabled" ${step.enabled !== false ? 'checked' : ''} style="width:auto" />
+              <span style="font-size:12px">Enabled</span>
+            </label>
+          </div>
+          <div class="grid" style="grid-template-columns:repeat(2,1fr);gap:10px">
+            ${fieldHtml}
+          </div>
+        </div>
+      `;
+    }
+
+    function setMsg(text, kind) {
+      const el = $('#ba-msg'); if (!el) return;
+      el.textContent = text || '';
+      el.style.color = kind === 'err' ? 'var(--accent)' : kind === 'ok' ? 'var(--sage)' : 'var(--mut)';
+    }
+
+    async function saveConfig() {
+      setMsg('Saving…');
+      const res = await fetch('/api/browse-abandon/config', {
+        method: 'PUT',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(CONFIG),
+      }).then(r => r.json());
+      if (res.error) {
+        setMsg(res.error + (res.issues ? ' — ' + res.issues.join(', ') : ''), 'err');
+        return;
+      }
+      CONFIG = res.config;
+      setMsg('Saved · drip re-evaluating mock events…', 'ok');
+      await Promise.all([loadEvents(), loadSchedule()]);
+      renderSteps();
+    }
+
+    async function resetConfig() {
+      if (!confirm('Reset the drip to default Day 1 / Day 14 / Day 30?')) return;
+      const res = await api('/config/reset', { method: 'POST' });
+      CONFIG = res.config;
+      renderConfig();
+      await Promise.all([loadEvents(), loadSchedule()]);
+    }
+
+    // ── schedule projection ─────────────────────────────────────────────────
+    function renderSchedule(data) {
+      const target = $('#ba-schedule');
+      target.classList.remove('loading');
+      const cap = Number(data.dailySendCap) || 0;
+      const proj = data.projection || [];
+      const max = Math.max(1, ...proj.map(d => d.totalWouldFire || 0));
+      target.innerHTML = `
+        <div class="muted" style="font-size:11.5px;margin-bottom:10px">
+          Daily cap: <b>${cap.toLocaleString()}</b> ·
+          <span class="muted">Bars show how many mock events WOULD trigger a drip step each day.</span>
+        </div>
+        ${proj.map(day => {
+          const pct = Math.round(((day.totalWouldFire || 0) / max) * 100);
+          const overCap = day.hitCap;
+          const barColor = overCap ? 'var(--accent)' : 'var(--gold)';
+          return `
+            <div style="display:grid;grid-template-columns:90px 1fr 70px;gap:10px;align-items:center;padding:6px 0;border-top:1px solid var(--line)">
+              <div style="font-weight:600;font-size:12.5px">${esc(day.label)}</div>
+              <div>
+                <div style="background:var(--paper);border:1px solid var(--line);border-radius:99px;height:10px;overflow:hidden">
+                  <div style="background:${barColor};height:100%;width:${pct}%"></div>
+                </div>
+                <div class="muted" style="font-size:10.5px;margin-top:3px">
+                  ${(day.byStep || []).map(b => `<span style="margin-right:8px">D${b.dayOffset} ${esc(b.title)}: <b>${b.count}</b></span>`).join('') || 'no steps due'}
+                </div>
+              </div>
+              <div style="text-align:right;font-weight:700">${(day.totalWouldFire || 0).toLocaleString()}${overCap ? ' <span class="pill" style="background:#fff0e6;color:var(--accent);font-size:9px">cap</span>' : ''}</div>
+            </div>
+          `;
+        }).join('')}
+        <div class="muted-banner" style="margin-top:12px">${esc(data.sendNote || 'Planning only — no sends.')}</div>
+      `;
+    }
+
+    // ── events table ────────────────────────────────────────────────────────
+    function renderEvents() {
+      const target = $('#ba-events');
+      target.classList.remove('loading');
+      const filtered = EVENTS.filter(e => {
+        if (FILTER === 'all') return true;
+        if (FILTER === 'would-fire') return !!e.wouldFire;
+        return e.stage === FILTER;
+      });
+      if (!filtered.length) {
+        target.innerHTML = '<div class="muted">No events match this filter.</div>';
+        return;
+      }
+      target.innerHTML = `
+        <div style="overflow-x:auto">
+          <table style="width:100%;border-collapse:collapse;font-size:12.5px">
+            <thead>
+              <tr style="text-align:left;color:var(--mut);font-weight:600">
+                <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Contact</th>
+                <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Product</th>
+                <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Browsed</th>
+                <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Stage</th>
+                <th style="padding:8px 6px;border-bottom:1px solid var(--line)">Would fire</th>
+                <th style="padding:8px 6px;border-bottom:1px solid var(--line)"></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${filtered.map(e => `
+                <tr data-event-id="${esc(e.id)}" style="border-bottom:1px solid var(--line)">
+                  <td style="padding:9px 6px;vertical-align:top">
+                    <div style="font-weight:600">${esc(e.contactFirstName)}</div>
+                    <div class="muted" style="font-size:11px">${esc(e.contactEmail)}</div>
+                    <div class="muted" style="font-size:10.5px">${esc(e.segment || '')}</div>
+                  </td>
+                  <td style="padding:9px 6px;vertical-align:top">
+                    <div style="font-weight:600">${esc(e.productPattern)} — ${esc(e.productColor)}</div>
+                    <div class="muted" style="font-size:11px">${esc(e.vendor)} · ${esc(e.productSku)}</div>
+                  </td>
+                  <td style="padding:9px 6px;vertical-align:top">
+                    <div>${e.browsedDaysAgo == null ? '—' : 'Day ' + e.browsedDaysAgo}</div>
+                    <div class="muted" style="font-size:10.5px">${fmtDate(e.browsedAt)}</div>
+                  </td>
+                  <td style="padding:9px 6px;vertical-align:top">
+                    ${stagePill(e.stage)}
+                    ${e.currentStep ? `<div class="muted" style="font-size:10.5px;margin-top:3px">at ${esc(e.currentStep.title)}</div>` : ''}
+                  </td>
+                  <td style="padding:9px 6px;vertical-align:top">
+                    ${e.wouldFire
+                      ? `<span class="pill" style="background:#fff0e6;color:var(--accent)">${esc(e.wouldFire.title)}</span>`
+                      : `<span class="muted" style="font-size:11px">—</span>`}
+                  </td>
+                  <td style="padding:9px 6px;vertical-align:top;text-align:right">
+                    <button class="btn ghost ba-preview" data-id="${esc(e.id)}" style="padding:4px 9px;font-size:11.5px">👁 Preview</button>
+                  </td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      `;
+      target.querySelectorAll('.ba-preview').forEach(b => b.onclick = () => previewEvent(b.dataset.id));
+    }
+
+    function stagePill(stage) {
+      const map = {
+        'pending':           { bg: '#efece5', fg: 'var(--ink)',    label: 'pending' },
+        'cooling':           { bg: '#eef2eb', fg: 'var(--sage)',   label: 'cooling' },
+        'in-drip':           { bg: 'var(--cream)', fg: 'var(--accent)', label: 'in drip' },
+        'sample-requested':  { bg: '#e7f4ea', fg: 'var(--sage)',   label: 'sample requested' },
+        'suppressed':        { bg: '#f2eee5', fg: 'var(--mut)',    label: 'suppressed' },
+      };
+      const s = map[stage] || map.pending;
+      return `<span class="pill" style="background:${s.bg};color:${s.fg}">${esc(s.label)}</span>`;
+    }
+
+    async function previewEvent(id) {
+      const res = await fetch(`/api/browse-abandon/fire/${encodeURIComponent(id)}`, { method: 'POST' }).then(r => r.json());
+      $('#ba-preview-card').style.display = '';
+      const target = $('#ba-preview');
+      if (res.error) { target.innerHTML = `<div class="muted-banner">${esc(res.error)}</div>`; return; }
+      if (!res.preview && !res.suppress) {
+        target.innerHTML = `
+          <div class="muted-banner">${esc(res.reason || 'No step is due for this event today.')}</div>
+          <div class="muted" style="font-size:12px">Stage: <b>${esc(res.stage)}</b></div>
+        `;
+        return;
+      }
+      if (res.preview) {
+        const p = res.preview;
+        target.innerHTML = `
+          <div class="row" style="gap:8px;align-items:center;margin-bottom:10px">
+            <span class="pill" style="background:var(--cream)">${esc(res.step.title)}</span>
+            <span class="pill" style="background:#efece5;color:var(--ink)">Day ${res.step.dayOffset}</span>
+            <span class="pill" style="background:#fff0e6;color:var(--accent)">NOT sent · dry-run</span>
+          </div>
+          <div style="border:1px solid var(--line);border-radius:11px;padding:14px 16px;background:#fff">
+            <div class="muted" style="font-size:11px;letter-spacing:.4px;text-transform:uppercase">To</div>
+            <div style="font-weight:600;margin-bottom:8px">${esc(p.to)}</div>
+            <div class="muted" style="font-size:11px;letter-spacing:.4px;text-transform:uppercase">Subject</div>
+            <div style="font:600 16px/1.3 'Cormorant Garamond',Georgia,serif;margin-bottom:10px">${esc(p.subject)}</div>
+            <pre style="white-space:pre-wrap;font:13px/1.55 Georgia,serif;background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:12px;margin:0">${esc(p.body)}</pre>
+            ${p.cta ? `<div style="margin-top:10px"><button class="btn gold" style="padding:7px 14px" disabled title="Dry-run only">${esc(p.cta)}</button></div>` : ''}
+            <div class="muted" style="font-size:10.5px;margin-top:14px;border-top:1px solid var(--line);padding-top:8px">${esc(p.canSpamFooter)}</div>
+          </div>
+          <div class="muted-banner" style="margin-top:10px">${esc(res.note || 'Planning only — no send.')}</div>
+        `;
+      } else if (res.suppress) {
+        target.innerHTML = `
+          <div class="row" style="gap:8px;align-items:center;margin-bottom:10px">
+            <span class="pill" style="background:var(--cream)">${esc(res.step.title)}</span>
+            <span class="pill" style="background:#efece5;color:var(--ink)">Day ${res.step.dayOffset}</span>
+            <span class="pill" style="background:#fff0e6;color:var(--accent)">NO ACTION · dry-run</span>
+          </div>
+          <div style="border:1px solid var(--line);border-radius:11px;padding:14px 16px;background:#fff">
+            <div style="font-weight:600;margin-bottom:6px">Would suppress to: <code>${esc(res.suppress.list)}</code></div>
+            <div class="muted" style="font-size:12px;line-height:1.55">${esc(res.suppress.note || '')}</div>
+          </div>
+          <div class="muted-banner" style="margin-top:10px">${esc(res.note || 'Planning only — no action.')}</div>
+        `;
+      }
+      $('#ba-preview-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+    }
+
+    // ── controls ────────────────────────────────────────────────────────────
+    $('#ba-filter').onchange = e => { FILTER = e.target.value; renderEvents(); };
+    $('#ba-reseed').onclick  = async () => {
+      const btn = $('#ba-reseed'); btn.disabled = true; btn.textContent = 'Reseeding…';
+      await api('/events/refresh-mock', { method: 'POST' });
+      await Promise.all([loadEvents(), loadSchedule()]);
+      btn.disabled = false; btn.textContent = '↻ Reseed mock events';
+    };
+
+    // ── initial render ──────────────────────────────────────────────────────
+    await Promise.all([loadConfig(), loadEvents(), loadSchedule()]);
+  },
+};
diff --git a/public/panels/copy.html b/public/panels/copy.html
index afce9f1..d41783a 100644
--- a/public/panels/copy.html
+++ b/public/panels/copy.html
@@ -40,3 +40,34 @@
 </div>
 
 <div id="copy-out" class="grid"></div>
+
+<div class="card">
+  <h2>Score a Subject Line or Copy Block</h2>
+  <p class="muted" style="margin:0 0 14px">Predicted open potential, luxury-tone breakdown, and merge-tag coverage. Heuristic by default; uses Gemini for a second opinion when <b>GEMINI_API_KEY</b> is configured.</p>
+
+  <div id="score-banner"></div>
+
+  <div class="grid" style="grid-template-columns:1fr 2fr;align-items:flex-start">
+    <div>
+      <label for="score-kind">Format</label>
+      <select id="score-kind">
+        <option value="subject-lines">Email subject line</option>
+        <option value="email">Email body</option>
+        <option value="instagram">Instagram caption</option>
+        <option value="tiktok">TikTok caption</option>
+        <option value="sms">SMS / text</option>
+        <option value="headline">Web / ad headline</option>
+      </select>
+      <div style="margin-top:14px">
+        <button id="score-go" class="btn gold" style="width:100%">📊 Score this copy</button>
+      </div>
+      <div class="muted" style="margin-top:10px;font-size:12px">Tip: paste a subject line OR a full email body. Merge tags like <code>{{first_name}}</code>, <code>[firstName]</code>, <code>*|FNAME|*</code> all count.</div>
+    </div>
+    <div>
+      <label for="score-text">Copy to score</label>
+      <textarea id="score-text" rows="8" placeholder="Inside the archive: {{first_name}}, a closer look at our new grasscloth…"></textarea>
+    </div>
+  </div>
+
+  <div id="score-out" style="margin-top:18px"></div>
+</div>
diff --git a/public/panels/copy.js b/public/panels/copy.js
index 752f83a..417648f 100644
--- a/public/panels/copy.js
+++ b/public/panels/copy.js
@@ -131,5 +131,139 @@ window.MCC_PANELS['copy'] = {
     if (presets && presets.llm === false) {
       banner.innerHTML = '<div class="muted-banner">Templates mode — add <b>ANTHROPIC_API_KEY</b> for AI-written copy.</div>';
     }
+
+    // ── inline copy scoring ──────────────────────────────────────────────────
+    const scoreOut = $('#score-out');
+    const scoreBanner = $('#score-banner');
+    const scoreBtn = $('#score-go');
+    const scoreKind = $('#score-kind');
+    const scoreText = $('#score-text');
+    if (presets && presets.llm === false) {
+      scoreBanner.innerHTML = '<div class="muted-banner">Heuristic mode — add <b>GEMINI_API_KEY</b> for a model-graded second opinion.</div>';
+    }
+
+    function classColor(cls) {
+      return { excellent:'#3f7a4d', strong:'#6f7a63', fair:'#b8925a', weak:'#a26b48', poor:'#8a4a3a' }[cls] || '#8a8275';
+    }
+
+    function bar(label, pct, color) {
+      const p = Math.max(0, Math.min(100, +pct || 0));
+      return `<div style="margin-bottom:10px">
+        <div class="row" style="justify-content:space-between;margin-bottom:4px;font-size:12.5px">
+          <span style="font-weight:600">${esc(label)}</span><span class="muted">${p}%</span>
+        </div>
+        <div style="height:10px;background:#efe9dc;border-radius:999px;overflow:hidden">
+          <div style="width:${p}%;height:100%;background:${color}"></div>
+        </div>
+      </div>`;
+    }
+
+    function renderScore(data) {
+      if (!data || data.error) {
+        scoreOut.innerHTML = `<div class="muted-banner">${esc((data && data.error) || 'Could not score that copy.')}</div>`;
+        return;
+      }
+      const c = classColor(data.openClass);
+      const tone = data.tone || { reassurance:0, storytelling:0, urgency:0 };
+      const pers = data.personalization || { tokens:[], recommended:[], missing:[], coverage:0 };
+      const recList = (pers.recommended || []).map(r =>
+        `<span class="pill" style="background:${r.present ? '#e8f1ea' : '#fff0e6'};color:${r.present ? '#3f7a4d' : '#8a5a44'};margin-right:6px">${r.present ? '✓' : '○'} ${esc(r.label)}</span>`
+      ).join('') || '<span class="muted">No personalization recommended for this format.</span>';
+      const tokenList = (pers.tokens || []).length
+        ? '<div style="margin-top:6px">' + pers.tokens.map(t =>
+            `<code style="background:#f4efe7;border:1px solid var(--line);border-radius:6px;padding:2px 7px;margin:2px 4px 2px 0;display:inline-block;font-size:12px">${esc(t.raw)}<span class="muted" style="margin-left:5px">${esc(t.label)}</span></code>`
+          ).join('') + '</div>'
+        : '<div class="muted" style="margin-top:6px;font-size:12.5px">No merge tags detected in this copy.</div>';
+      const flags = (data.flags || []).length
+        ? '<ul style="margin:6px 0 0 18px;padding:0">' + data.flags.map(f => `<li style="color:#8a5a44">${esc(f)}</li>`).join('') + '</ul>'
+        : '';
+      const reasons = (data.reasons || []).slice(0, 6).map(r =>
+        `<li><b style="color:${r.d >= 0 ? '#3f7a4d' : '#8a4a3a'}">${r.d >= 0 ? '+' : ''}${r.d}</b> — ${esc(r.why)}</li>`
+      ).join('');
+      const detail = data.detail
+        ? `<div class="muted" style="font-size:12px;margin-top:6px">Heuristic: ${data.detail.heuristic.score} · Gemini: ${data.detail.gemini.score}</div>`
+        : '';
+      const sourceLabel = data.source === 'gemini+heuristic' ? '🧠 Gemini + heuristic' : '📐 Heuristic';
+
+      scoreOut.innerHTML = `
+        <div class="grid" style="grid-template-columns:1fr 2fr;align-items:flex-start;gap:20px">
+          <div class="card" style="margin:0;text-align:center;padding:24px 18px;border-color:${c}">
+            <div class="muted" style="font-size:11px;letter-spacing:1px;text-transform:uppercase">Predicted open</div>
+            <div style="font:600 56px/1 'Cormorant Garamond',Georgia,serif;color:${c};margin:6px 0 4px">${data.score}</div>
+            <div class="pill" style="background:${c};color:#fff">${esc(data.openClass)}</div>
+            ${detail}
+            <div class="muted" style="margin-top:12px;font-size:11px">${sourceLabel}</div>
+          </div>
+          <div class="card" style="margin:0">
+            <div style="font-weight:600;margin-bottom:10px">Luxury tone breakdown</div>
+            ${bar('Reassurance',  tone.reassurance,  '#6f7a63')}
+            ${bar('Storytelling', tone.storytelling, '#b8925a')}
+            ${bar('Urgency',      tone.urgency,      '#8a5a44')}
+            ${tone.confidence === 'low' ? '<div class="muted" style="font-size:12px;margin-top:4px">⚠ Low signal — try adding more brand-voice words.</div>' : ''}
+          </div>
+        </div>
+
+        <div class="card" style="margin-top:16px">
+          <div style="font-weight:600;margin-bottom:6px">Personalization coverage <span class="muted" style="font-weight:400">— ${pers.coverage}%</span></div>
+          <div>${recList}</div>
+          ${tokenList}
+        </div>
+
+        ${(data.flags && data.flags.length) || reasons ? `
+          <div class="card" style="margin-top:16px">
+            ${data.flags && data.flags.length ? `<div style="font-weight:600;margin-bottom:4px">Flags</div>${flags}` : ''}
+            ${reasons ? `<div style="font-weight:600;margin:${data.flags && data.flags.length ? '12px' : '0'} 0 4px">Why this score</div><ul style="margin:0 0 0 18px;padding:0;font-size:13px">${reasons}</ul>` : ''}
+          </div>` : ''}
+
+        ${data.notes ? `<div class="muted" style="font-size:12.5px;margin-top:10px">${esc(data.notes)}</div>` : ''}
+      `;
+    }
+
+    async function score() {
+      const text = scoreText.value;
+      if (!text.trim()) {
+        scoreOut.innerHTML = '<div class="muted-banner">Paste some copy to score.</div>';
+        return;
+      }
+      scoreBtn.disabled = true;
+      const orig = scoreBtn.textContent;
+      scoreBtn.textContent = 'Scoring…';
+      scoreOut.innerHTML = '<div class="loading">Reading the copy…</div>';
+      try {
+        const r = await fetch('/api/copy/score', {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({ kind: scoreKind.value, text }),
+        });
+        const data = await r.json();
+        if (!r.ok) {
+          scoreOut.innerHTML = `<div class="muted-banner">${esc(data.error || ('Score failed: HTTP ' + r.status))}</div>`;
+        } else {
+          renderScore(data);
+        }
+      } catch (e) {
+        scoreOut.innerHTML = '<div class="muted-banner">Could not reach the scoring service.</div>';
+      } finally {
+        scoreBtn.disabled = false;
+        scoreBtn.textContent = orig;
+      }
+    }
+
+    scoreBtn.addEventListener('click', score);
+    // Lightweight "use last generated variant" — if the user clicks a generated
+    // card's text, drop it straight into the score box. (Wires in generically
+    // after each render via event delegation on the output container.)
+    out.addEventListener('click', e => {
+      const card = e.target.closest('.card');
+      if (!card || e.target.closest('.copy-btn')) return;
+      const txt = card.querySelector('[style*="white-space:pre-wrap"]');
+      if (!txt) return;
+      scoreText.value = txt.textContent || '';
+      const kindVal = $('#copy-kind').value;
+      if (Array.from(scoreKind.options).some(o => o.value === kindVal)) {
+        scoreKind.value = kindVal;
+      }
+      scoreText.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    });
   },
 };
diff --git a/public/panels/journeys.html b/public/panels/journeys.html
new file mode 100644
index 0000000..74846aa
--- /dev/null
+++ b/public/panels/journeys.html
@@ -0,0 +1,53 @@
+<div id="journeys-banner"></div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>Cross-Channel Journeys</h2>
+      <div class="muted" style="font-size:12px">
+        Plan a multi-step journey across email, SMS, social, and retargeting for one segment.
+        Add steps, set conditions, and review the recommended cross-channel ladder.
+        <b>PLANNING ONLY</b> — nothing here triggers a live send.
+      </div>
+    </div>
+    <span class="pill" id="journeys-count">0 journeys</span>
+  </div>
+</div>
+
+<div class="grid" style="grid-template-columns:320px 1fr;align-items:start">
+
+  <!-- Left rail: journey list + new -->
+  <div>
+    <div class="card">
+      <div class="row" style="justify-content:space-between;align-items:baseline">
+        <h2 style="margin:0;font-size:15px">Journeys</h2>
+        <button class="btn ghost" id="journeys-new" style="padding:5px 11px;font-size:12.5px">+ New</button>
+      </div>
+      <div id="journeys-list" class="loading" style="margin-top:10px">Loading…</div>
+    </div>
+  </div>
+
+  <!-- Right pane: editor / recommendations / dry-run -->
+  <div>
+    <div class="card" id="journeys-editor-card" style="display:none">
+      <div id="journeys-editor"></div>
+    </div>
+
+    <div class="card" id="journeys-recs-card" style="display:none">
+      <div class="row" style="justify-content:space-between;align-items:baseline">
+        <h2 style="margin:0;font-size:15px">Recommended next steps</h2>
+        <span class="pill" style="background:var(--cream)">cross-channel ladder</span>
+      </div>
+      <div id="journeys-recs" style="margin-top:10px"></div>
+    </div>
+
+    <div class="card" id="journeys-run-card" style="display:none">
+      <div class="row" style="justify-content:space-between;align-items:baseline">
+        <h2 style="margin:0;font-size:15px">Dry-run simulation</h2>
+        <span class="pill" style="background:#fff0e6;color:var(--accent)">no sends · planning only</span>
+      </div>
+      <div id="journeys-run" style="margin-top:10px"></div>
+    </div>
+  </div>
+
+</div>
diff --git a/public/panels/journeys.js b/public/panels/journeys.js
new file mode 100644
index 0000000..64637a4
--- /dev/null
+++ b/public/panels/journeys.js
@@ -0,0 +1,427 @@
+// journeys panel — Cross-Channel Journeys builder + recommendations + dry-run.
+// PLANNING ONLY. The panel persists the journey to data/journeys.json via the
+// /api/journeys endpoints; nothing here triggers a live send.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['journeys'] = {
+  async init(root) {
+    const $  = s => root.querySelector(s);
+    const $$ = s => Array.from(root.querySelectorAll(s));
+    const api = (p, opts) => fetch(`/api/journeys${p}`, opts).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
+      { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
+
+    let META = null;
+    let REFS = { segments: [], templates: [] };
+    let SELECTED = null;            // currently-open journey id (or 'draft')
+    let DRAFT = null;               // working copy {name, description, segmentId, ..., steps:[]}
+
+    function banner() {
+      $('#journeys-banner').innerHTML = `<div class="muted-banner">
+        🗺️ <b>STAGED — planning only.</b> ${esc(META && META.gateMessage || 'This module never sends.')}
+      </div>`;
+    }
+
+    // ── load ────────────────────────────────────────────────────────────────
+    try {
+      [META, REFS] = await Promise.all([
+        api('/meta'),
+        api('/refs'),
+      ]);
+    } catch (e) {
+      $('#journeys-list').innerHTML = '<div class="muted-banner">Could not reach the journeys service.</div>';
+      return;
+    }
+    banner();
+
+    // ── list ────────────────────────────────────────────────────────────────
+    async function loadList() {
+      const data = await api('/journeys');
+      $('#journeys-count').textContent = `${data.count} journey${data.count === 1 ? '' : 's'}`;
+      const target = $('#journeys-list');
+      target.classList.remove('loading');
+      if (!data.journeys || !data.journeys.length) {
+        target.innerHTML = '<div class="muted">No journeys yet. Click + New to plan one.</div>';
+        return;
+      }
+      target.innerHTML = data.journeys.map(j => `
+        <div class="jny-item${SELECTED === j.id ? ' active' : ''}" data-id="${esc(j.id)}"
+             style="border:1px solid ${SELECTED === j.id ? 'var(--gold)' : 'var(--line)'};
+                    background:${SELECTED === j.id ? 'var(--cream)' : '#fff'};
+                    border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer">
+          <div style="font-weight:600;font-size:13.5px;line-height:1.25">${esc(j.name)}</div>
+          <div class="muted" style="font-size:11.5px;margin-top:3px">
+            ${esc(j.segmentName || '— no segment —')} · ${Number(j.audienceSize || 0).toLocaleString()} contacts
+          </div>
+          <div class="row" style="gap:4px;margin-top:6px;flex-wrap:wrap">
+            <span class="pill" style="font-size:10px">${j.stepCount} step${j.stepCount === 1 ? '' : 's'}</span>
+            ${(j.kinds || []).map(k => `<span class="pill" style="font-size:10px;background:#efece5;color:var(--ink)">${esc(k)}</span>`).join('')}
+          </div>
+        </div>
+      `).join('');
+      target.querySelectorAll('.jny-item').forEach(el => el.onclick = () => openJourney(el.dataset.id));
+    }
+
+    // ── new (draft) ─────────────────────────────────────────────────────────
+    $('#journeys-new').onclick = () => {
+      SELECTED = 'draft';
+      DRAFT = {
+        name: '',
+        description: '',
+        segmentId: '',
+        segmentName: '',
+        audienceSize: 500,
+        steps: [],
+      };
+      renderEditor();
+      loadList();
+    };
+
+    // ── open existing ───────────────────────────────────────────────────────
+    async function openJourney(id) {
+      SELECTED = id;
+      const data = await api(`/journeys/${encodeURIComponent(id)}`);
+      if (data.error) { alert(data.error); return; }
+      DRAFT = JSON.parse(JSON.stringify(data.journey));
+      renderEditor();
+      loadList();
+      loadRecommendations();
+      $('#journeys-run-card').style.display = 'none';
+    }
+
+    // ── editor ──────────────────────────────────────────────────────────────
+    function renderEditor() {
+      if (!DRAFT) { $('#journeys-editor-card').style.display = 'none'; return; }
+      $('#journeys-editor-card').style.display = '';
+      const isNew = SELECTED === 'draft';
+
+      const segmentOptions = ['<option value="">— pick a segment —</option>'].concat(
+        (REFS.segments || []).map(s =>
+          `<option value="${esc(s.id)}" data-name="${esc(s.name)}" ${DRAFT.segmentId === s.id ? 'selected' : ''}>${esc(s.name)}${s.estimatedSize != null ? ` (~${s.estimatedSize})` : ''}</option>`
+        )).join('');
+
+      $('#journeys-editor').innerHTML = `
+        <div class="row" style="justify-content:space-between;align-items:baseline">
+          <h2 style="margin:0">${isNew ? 'New journey' : esc(DRAFT.name || 'Untitled journey')}</h2>
+          <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · planning only</span>
+        </div>
+
+        <div class="grid" style="grid-template-columns:1fr 1fr;margin-top:14px">
+          <div>
+            <label for="j-name">Journey name</label>
+            <input id="j-name" type="text" placeholder="e.g. Designer Welcome — Cross-Channel" value="${esc(DRAFT.name)}" />
+          </div>
+          <div>
+            <label for="j-segment">Segment</label>
+            <select id="j-segment">${segmentOptions}</select>
+          </div>
+          <div style="grid-column:1/-1">
+            <label for="j-desc">Description</label>
+            <textarea id="j-desc" rows="2" placeholder="What is this journey for?">${esc(DRAFT.description)}</textarea>
+          </div>
+          <div>
+            <label for="j-audience">Audience size (estimated)</label>
+            <input id="j-audience" type="number" min="0" value="${Number(DRAFT.audienceSize) || 0}" />
+          </div>
+          <div>
+            <label>&nbsp;</label>
+            <div class="muted" style="font-size:12px;padding:9px 0">
+              Used by the dry-run simulation. Does not affect a live send.
+            </div>
+          </div>
+        </div>
+
+        <div style="border-top:1px solid var(--line);margin:14px 0 10px;padding-top:12px">
+          <div class="row" style="justify-content:space-between;align-items:baseline">
+            <h3 style="margin:0;font:600 14px/1.2 'Cormorant Garamond',Georgia,serif">Steps</h3>
+            <div class="row" style="gap:6px">
+              ${(META.kinds || []).map(k =>
+                `<button class="btn ghost j-add" data-kind="${esc(k.key)}" style="padding:5px 10px;font-size:12px">+ ${esc(k.icon)} ${esc(k.label)}</button>`
+              ).join('')}
+            </div>
+          </div>
+          <div id="j-steps" style="margin-top:10px"></div>
+        </div>
+
+        <div class="row" style="justify-content:space-between;align-items:center;margin-top:10px;border-top:1px solid var(--line);padding-top:12px">
+          <span class="muted" id="j-msg" style="font-size:12px">&nbsp;</span>
+          <div class="row" style="gap:8px">
+            ${isNew ? '' : `<button class="btn ghost" id="j-delete" style="color:var(--accent);padding:7px 14px">Delete</button>`}
+            <button class="btn ghost" id="j-run" ${isNew ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''} style="padding:7px 14px">▶ Dry-run</button>
+            <button class="btn gold" id="j-save" style="padding:7px 14px">${isNew ? '💾 Save journey' : '💾 Save changes'}</button>
+          </div>
+        </div>
+      `;
+
+      renderSteps();
+
+      $('#j-name').oninput = e => { DRAFT.name = e.target.value; };
+      $('#j-desc').oninput = e => { DRAFT.description = e.target.value; };
+      $('#j-audience').oninput = e => { DRAFT.audienceSize = Number(e.target.value) || 0; };
+      $('#j-segment').onchange = e => {
+        const opt = e.target.selectedOptions[0];
+        DRAFT.segmentId = e.target.value || '';
+        DRAFT.segmentName = opt ? (opt.dataset.name || opt.textContent.replace(/\s*\(~\d+\)\s*$/, '')) : '';
+      };
+
+      $$('#j-steps + *, .j-add').forEach(() => {});
+      $$('.j-add').forEach(b => b.onclick = () => addStep(b.dataset.kind));
+      $('#j-save').onclick = saveJourney;
+      $('#j-run').onclick = dryRun;
+      const delBtn = $('#j-delete');
+      if (delBtn) delBtn.onclick = deleteJourney;
+    }
+
+    function renderSteps() {
+      const target = $('#j-steps');
+      if (!target) return;
+      if (!DRAFT.steps.length) {
+        target.innerHTML = '<div class="muted-banner" style="margin:0">No steps yet — start with an email, then layer cross-channel follow-up below.</div>';
+        return;
+      }
+      target.innerHTML = DRAFT.steps.map((step, i) => stepCard(step, i)).join('');
+      // Wire up field bindings, condition select, remove, move
+      $$('[data-step-idx]').forEach(card => {
+        const idx = Number(card.dataset.stepIdx);
+        card.querySelectorAll('[data-field]').forEach(input => {
+          input.oninput = () => { DRAFT.steps[idx][input.dataset.field] = input.value; };
+          input.onchange = () => { DRAFT.steps[idx][input.dataset.field] = input.value; };
+        });
+        const condSel = card.querySelector('[data-condition]');
+        if (condSel) condSel.onchange = () => { DRAFT.steps[idx].condition = condSel.value; };
+        const tplSel = card.querySelector('[data-template]');
+        if (tplSel) {
+          tplSel.onchange = () => {
+            const v = tplSel.value;
+            DRAFT.steps[idx].templateId = v;
+            if (v) {
+              const tpl = (REFS.templates || []).find(t => t.id === v);
+              if (tpl && !DRAFT.steps[idx].subject) {
+                DRAFT.steps[idx].subject = tpl.subject;
+                renderSteps();
+              }
+            }
+          };
+        }
+        const rm = card.querySelector('.step-remove');
+        if (rm) rm.onclick = () => { DRAFT.steps.splice(idx, 1); renderSteps(); };
+        const up = card.querySelector('.step-up');
+        if (up) up.onclick = () => { if (idx > 0) { const s = DRAFT.steps[idx]; DRAFT.steps[idx] = DRAFT.steps[idx - 1]; DRAFT.steps[idx - 1] = s; renderSteps(); } };
+        const dn = card.querySelector('.step-down');
+        if (dn) dn.onclick = () => { if (idx < DRAFT.steps.length - 1) { const s = DRAFT.steps[idx]; DRAFT.steps[idx] = DRAFT.steps[idx + 1]; DRAFT.steps[idx + 1] = s; renderSteps(); } };
+      });
+    }
+
+    function kindSpec(key) {
+      return (META && META.kinds || []).find(k => k.key === key) || null;
+    }
+
+    function stepCard(step, idx) {
+      const spec = kindSpec(step.kind);
+      if (!spec) return '';
+      const conditions = (META.conditions || []);
+      const tpls = (REFS.templates || []);
+      const fieldHtml = spec.fields.map(f => {
+        const val = step[f.key] == null ? '' : step[f.key];
+        if (f.enum) {
+          const opts = ['<option value="">— pick —</option>'].concat(
+            f.enum.map(o => `<option value="${esc(o)}" ${val === o ? 'selected' : ''}>${esc(o)}</option>`)).join('');
+          return `<div><label>${esc(f.label)}</label><select data-field="${esc(f.key)}">${opts}</select></div>`;
+        }
+        if (f.type === 'number') {
+          return `<div><label>${esc(f.label)}</label><input type="number" min="0" data-field="${esc(f.key)}" value="${esc(val)}" /></div>`;
+        }
+        if (step.kind === 'email' && f.key === 'templateId') {
+          const opts = ['<option value="">— pick template (optional) —</option>'].concat(
+            tpls.map(t => `<option value="${esc(t.id)}" ${val === t.id ? 'selected' : ''}>${esc(t.title)} · ${esc(t.category)}</option>`)).join('');
+          return `<div><label>${esc(f.label)}</label><select data-field="${esc(f.key)}" data-template>${opts}</select></div>`;
+        }
+        if (f.key === 'caption' || f.key === 'message' || f.key === 'note') {
+          return `<div style="grid-column:1/-1"><label>${esc(f.label)}</label><textarea rows="2" data-field="${esc(f.key)}">${esc(val)}</textarea></div>`;
+        }
+        return `<div><label>${esc(f.label)}</label><input type="text" data-field="${esc(f.key)}" value="${esc(val)}" /></div>`;
+      }).join('');
+
+      const condHtml = spec.supportsCondition
+        ? `<div><label>Condition</label><select data-condition>${
+            conditions.map(c => `<option value="${esc(c.key)}" ${(step.condition || 'always') === c.key ? 'selected' : ''}>${esc(c.label)}</option>`).join('')
+          }</select></div>`
+        : '';
+
+      return `
+        <div data-step-idx="${idx}"
+             style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:#fff">
+          <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px">
+            <div class="row" style="gap:8px;align-items:center">
+              <span style="font:600 14px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--mut)">${idx + 1}.</span>
+              <span style="font-size:18px">${esc(spec.icon)}</span>
+              <span style="font-weight:700">${esc(spec.label)}</span>
+              ${step.kind !== 'wait' && (step.condition && step.condition !== 'always')
+                ? `<span class="pill" style="background:#fff0e6;color:var(--accent)">${esc(step.condition)}</span>`
+                : ''}
+            </div>
+            <div class="row" style="gap:4px">
+              <button class="btn ghost step-up" style="padding:3px 8px;font-size:11px" title="Move up">↑</button>
+              <button class="btn ghost step-down" style="padding:3px 8px;font-size:11px" title="Move down">↓</button>
+              <button class="btn ghost step-remove" style="padding:3px 8px;font-size:11px;color:var(--accent)" title="Remove">✕</button>
+            </div>
+          </div>
+          <div class="grid" style="grid-template-columns:repeat(2,1fr);gap:10px">
+            ${fieldHtml}
+            ${condHtml}
+          </div>
+        </div>`;
+    }
+
+    function addStep(kind) {
+      const spec = kindSpec(kind);
+      if (!spec) return;
+      const step = { kind };
+      // sensible defaults
+      if (kind === 'wait') { step.hours = 24; step.label = '24 hours'; }
+      if (kind === 'email') { step.subject = ''; step.templateId = ''; step.condition = 'always'; }
+      if (kind === 'sms')   { step.message = ''; step.condition = 'if-no-click'; }
+      if (kind === 'social'){ step.platform = 'instagram'; step.caption = ''; step.condition = 'if-no-open'; }
+      if (kind === 'retarget'){ step.platform = 'meta'; step.audienceType = 'non-openers'; step.condition = 'if-no-open'; }
+      DRAFT.steps.push(step);
+      renderSteps();
+    }
+
+    function setMsg(text, kind) {
+      const el = $('#j-msg'); if (!el) return;
+      el.textContent = text || '';
+      el.style.color = kind === 'err' ? 'var(--accent)' : kind === 'ok' ? 'var(--sage)' : 'var(--mut)';
+    }
+
+    async function saveJourney() {
+      if (!DRAFT.name || !DRAFT.name.trim()) { setMsg('Name is required.', 'err'); return; }
+      if (!DRAFT.steps.length) { setMsg('Add at least one step before saving.', 'err'); return; }
+      setMsg('Saving…');
+      const isNew = SELECTED === 'draft';
+      const url = isNew ? '/journeys' : `/journeys/${encodeURIComponent(SELECTED)}`;
+      const method = isNew ? 'POST' : 'PUT';
+      const res = await fetch(`/api/journeys${url}`, {
+        method,
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(DRAFT),
+      }).then(r => r.json());
+      if (res.error) {
+        setMsg(res.error + (res.issues ? ' — ' + res.issues.join(', ') : ''), 'err');
+        return;
+      }
+      setMsg(isNew ? `Created: ${res.journey.id}` : 'Saved.', 'ok');
+      SELECTED = res.journey.id;
+      DRAFT = JSON.parse(JSON.stringify(res.journey));
+      await loadList();
+      await loadRecommendations();
+      renderEditor();
+    }
+
+    async function deleteJourney() {
+      if (!confirm('Delete this journey? This cannot be undone.')) return;
+      await api(`/journeys/${encodeURIComponent(SELECTED)}`, { method: 'DELETE' });
+      SELECTED = null;
+      DRAFT = null;
+      $('#journeys-editor-card').style.display = 'none';
+      $('#journeys-recs-card').style.display = 'none';
+      $('#journeys-run-card').style.display = 'none';
+      await loadList();
+    }
+
+    async function dryRun() {
+      if (SELECTED === 'draft' || !SELECTED) return;
+      const data = await fetch(`/api/journeys/${encodeURIComponent(SELECTED)}/run`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ audienceSize: DRAFT.audienceSize }),
+      }).then(r => r.json());
+      if (data.error) { alert(data.error); return; }
+      renderDryRun(data);
+    }
+
+    function renderDryRun(data) {
+      $('#journeys-run-card').style.display = '';
+      const target = $('#journeys-run');
+      const rows = (data.stepResults || []).map(r => {
+        const flowPct = data.audienceSize > 0 ? Math.round((r.proceeded / data.audienceSize) * 100) : 0;
+        return `
+          <div style="display:grid;grid-template-columns:30px 1fr 100px 100px 120px;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--line)">
+            <div style="font-weight:700;color:var(--mut)">${r.index + 1}.</div>
+            <div>
+              <div style="font-weight:600">${esc(r.kind)} <span class="muted" style="font-weight:400;font-size:11px">${esc(r.condition)}</span></div>
+              <div class="muted" style="font-size:11.5px">${esc(r.note || '')}</div>
+            </div>
+            <div style="font-size:12.5px"><span class="muted">in</span> <b>${Number(r.entry || 0).toLocaleString()}</b></div>
+            <div style="font-size:12.5px"><span class="muted">out</span> <b>${Number(r.proceeded || 0).toLocaleString()}</b></div>
+            <div>
+              <div style="background:var(--paper);border:1px solid var(--line);border-radius:99px;height:8px;overflow:hidden">
+                <div style="background:var(--gold);height:100%;width:${flowPct}%"></div>
+              </div>
+              <div class="muted" style="font-size:10.5px;margin-top:2px">${flowPct}% of start</div>
+            </div>
+          </div>
+        `;
+      }).join('');
+      target.innerHTML = `
+        <div class="row" style="gap:18px;margin-bottom:10px;flex-wrap:wrap">
+          <div><div class="muted" style="font-size:11px">Audience</div>
+            <div style="font-weight:700;font-size:17px">${Number(data.audienceSize || 0).toLocaleString()}</div></div>
+          <div><div class="muted" style="font-size:11px">Emails</div>
+            <div style="font-weight:700;font-size:17px">${data.totalEmails || 0}</div></div>
+          <div><div class="muted" style="font-size:11px">SMS</div>
+            <div style="font-weight:700;font-size:17px">${data.totalSms || 0}</div></div>
+          <div><div class="muted" style="font-size:11px">Social</div>
+            <div style="font-weight:700;font-size:17px">${data.totalSocial || 0}</div></div>
+          <div><div class="muted" style="font-size:11px">Retargets</div>
+            <div style="font-weight:700;font-size:17px">${data.totalRetargets || 0}</div></div>
+          <div><div class="muted" style="font-size:11px">Reach final step</div>
+            <div style="font-weight:700;font-size:17px">${Number(data.finalActive || 0).toLocaleString()}</div></div>
+        </div>
+        ${rows}
+        <div class="muted-banner" style="margin-top:12px">${esc(data.sendNote || 'Planning only — no sends.')}</div>
+      `;
+    }
+
+    // ── recommendations ─────────────────────────────────────────────────────
+    async function loadRecommendations() {
+      $('#journeys-recs-card').style.display = '';
+      const target = $('#journeys-recs');
+      target.innerHTML = '<div class="muted">Loading recommendations…</div>';
+      const id = SELECTED === 'draft' ? 'draft' : SELECTED;
+      const res = await fetch(`/api/journeys/${encodeURIComponent(id)}/recommend`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ steps: DRAFT.steps || [] }),
+      }).then(r => r.json());
+      if (!res.recommendations || !res.recommendations.length) {
+        target.innerHTML = '<div class="muted">No recommendations right now — the journey looks complete.</div>';
+        return;
+      }
+      target.innerHTML = res.recommendations.map((rec, i) => `
+        <div style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:var(--paper)">
+          <div class="row" style="justify-content:space-between;align-items:baseline">
+            <h3 style="margin:0;font:600 14px/1.2 'Cormorant Garamond',Georgia,serif">${esc(rec.title)}</h3>
+            <button class="btn ghost rec-apply" data-i="${i}" style="padding:5px 11px;font-size:12px">+ Append to journey</button>
+          </div>
+          <div class="muted" style="font-size:12px;margin:5px 0 10px;line-height:1.5">${esc(rec.reason)}</div>
+          <div class="row" style="gap:6px;flex-wrap:wrap">
+            ${(rec.steps || []).map(s => {
+              const spec = kindSpec(s.kind);
+              return `<span class="pill" style="background:#fff;border:1px solid var(--line);color:var(--ink)">${esc(spec ? spec.icon : '')} ${esc(spec ? spec.label : s.kind)}${s.condition && s.condition !== 'always' ? ` · ${esc(s.condition)}` : ''}${s.kind === 'wait' ? ` · ${s.hours}h` : ''}</span>`;
+            }).join('')}
+          </div>
+        </div>
+      `).join('');
+      target.querySelectorAll('.rec-apply').forEach(b => b.onclick = () => {
+        const rec = res.recommendations[Number(b.dataset.i)];
+        for (const s of rec.steps || []) DRAFT.steps.push(JSON.parse(JSON.stringify(s)));
+        renderSteps();
+        setMsg('Appended — review and save.', 'ok');
+      });
+    }
+
+    // ── initial render ──────────────────────────────────────────────────────
+    await loadList();
+  },
+};
diff --git a/public/panels/playbook.html b/public/panels/playbook.html
new file mode 100644
index 0000000..f82b77c
--- /dev/null
+++ b/public/panels/playbook.html
@@ -0,0 +1,77 @@
+<div id="pb-banner"></div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>Monthly Playbook</h2>
+      <div class="muted" style="font-size:12px">
+        A 4-week marketing plan synthesised from the calendar, segment health, and recent performance gaps.
+        <b>SUGGESTIONS ONLY</b> — nothing here sends or schedules. Promote an entry to the Calendar to make it real.
+      </div>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <span class="pill" id="pb-mode-pill">—</span>
+      <span class="pill" id="pb-month-pill">—</span>
+    </div>
+  </div>
+</div>
+
+<div class="grid" style="grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px">
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Entries</div>
+    <div id="pb-stat-count" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Calendar moments</div>
+    <div id="pb-stat-calendar" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--gold)">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Performance gaps</div>
+    <div id="pb-stat-gaps" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--accent)">—</div>
+  </div>
+  <div class="card" style="margin:0;padding:14px 16px">
+    <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px">Healthy segments</div>
+    <div id="pb-stat-healthy" style="font:600 22px/1.2 'Cormorant Garamond',Georgia,serif;margin-top:4px;color:var(--sage)">—</div>
+  </div>
+</div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2 style="margin:0">Playbook</h2>
+      <div class="muted" style="font-size:12px" id="pb-gen-meta">—</div>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <label style="margin:0;align-self:center">Month</label>
+      <input type="month" id="pb-month" style="width:auto;min-width:160px" />
+      <button class="btn ghost" id="pb-regen" style="padding:7px 12px;font-size:12.5px">↻ Regenerate</button>
+      <button class="btn" id="pb-add" style="padding:7px 12px;font-size:12.5px">+ Add entry</button>
+    </div>
+  </div>
+  <div id="pb-list" class="loading" style="margin-top:12px">Loading…</div>
+</div>
+
+<div class="grid" style="grid-template-columns:1fr 1fr;align-items:start">
+  <div class="card">
+    <div class="row" style="justify-content:space-between;align-items:baseline">
+      <h2 style="margin:0">Signals — Calendar window</h2>
+      <span class="pill" style="background:var(--cream)">curated</span>
+    </div>
+    <div id="pb-signals-cal" class="loading" style="margin-top:10px">Loading…</div>
+  </div>
+  <div class="card">
+    <div class="row" style="justify-content:space-between;align-items:baseline">
+      <h2 style="margin:0">Signals — Segment health</h2>
+      <span class="pill" style="background:#fff0e6;color:var(--accent)">last 30d</span>
+    </div>
+    <div id="pb-signals-seg" class="loading" style="margin-top:10px">Loading…</div>
+  </div>
+</div>
+
+<div class="card" id="pb-edit-card" style="display:none">
+  <div class="row" style="justify-content:space-between;align-items:baseline">
+    <h2 style="margin:0" id="pb-edit-title">Edit entry</h2>
+    <button class="btn ghost" id="pb-edit-close" style="padding:5px 10px;font-size:12px">✕ Close</button>
+  </div>
+  <div id="pb-edit" style="margin-top:12px"></div>
+</div>
diff --git a/public/panels/playbook.js b/public/panels/playbook.js
new file mode 100644
index 0000000..02aa4ab
--- /dev/null
+++ b/public/panels/playbook.js
@@ -0,0 +1,310 @@
+// playbook panel — Monthly Marketing Playbook viewer/editor.
+// Suggestions only — no live sends. Talks to /api/playbook.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['playbook'] = {
+  async init(root) {
+    const $  = s => root.querySelector(s);
+    const api = (p, opts) => fetch(`/api/playbook${p}`, opts).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c =>
+      ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
+    const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString(undefined,
+      { weekday:'short', month:'short', day:'numeric' }) : '—';
+    const pad = n => String(n).padStart(2,'0');
+
+    let META = null;
+    let MONTH = null;
+    let PB = null;
+    let SIGNALS = null;
+
+    function bandClass(b){ return b === 'good' ? 'sage' : b === 'ok' ? 'gold' : b === 'bad' ? 'accent' : 'mut'; }
+    function bandLabel(b){ return b === 'good' ? 'HEALTHY' : b === 'ok' ? 'WATCH' : b === 'bad' ? 'NEEDS HELP' : '—'; }
+    function sourcePill(src){
+      const map = { calendar:['var(--cream)','var(--accent)','calendar'],
+                    'segment-health':['#eef4ec','var(--sage)','segment'],
+                    'performance-gap':['#fff0e6','var(--accent)','gap'],
+                    'fallback-editorial':['var(--paper)','var(--mut)','editorial'],
+                    manual:['#f0ece4','var(--ink)','manual'],
+                    gemini:['#eaf0fb','#3556a5','gemini'] };
+      const [bg, fg, label] = map[src] || map.manual;
+      return `<span class="pill" style="background:${bg};color:${fg}">${esc(label)}</span>`;
+    }
+
+    function thisMonthId(){ const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth()+1)}`; }
+
+    function banner(){
+      const live = META && META.geminiKeyed;
+      $('#pb-banner').innerHTML = `<div class="muted-banner">
+        📅 <b>SUGGESTIONS ONLY.</b> ${esc(META && META.gateMessage || '')}
+        ${live ? '' : '<br><b>Mock mode:</b> Gemini key absent — using deterministic template generator.'}
+      </div>`;
+    }
+
+    // ── load ────────────────────────────────────────────────────────────────
+    try { META = await api('/meta'); }
+    catch { $('#pb-list').innerHTML = '<div class="muted-banner">Could not reach the playbook service.</div>'; return; }
+
+    MONTH = thisMonthId();
+    $('#pb-month').value = MONTH;
+    banner();
+
+    async function loadAll(){
+      $('#pb-list').classList.add('loading'); $('#pb-list').textContent = 'Loading…';
+      $('#pb-signals-cal').classList.add('loading'); $('#pb-signals-cal').textContent = 'Loading…';
+      $('#pb-signals-seg').classList.add('loading'); $('#pb-signals-seg').textContent = 'Loading…';
+
+      const [pbRes, sigRes] = await Promise.all([
+        api(`/playbook?month=${encodeURIComponent(MONTH)}`),
+        api(`/signals?month=${encodeURIComponent(MONTH)}`),
+      ]);
+      SIGNALS = sigRes.signals || null;
+      PB = pbRes.playbook || null;
+      $('#pb-month-pill').textContent = MONTH;
+      renderSignals();
+      renderStats();
+      renderList();
+      renderGenMeta();
+    }
+
+    function renderGenMeta(){
+      const m = $('#pb-mode-pill');
+      if (!PB){ m.textContent = 'not generated'; m.style.background = 'var(--paper)'; m.style.color = 'var(--mut)'; }
+      else if (PB.mode === 'gemini'){ m.textContent = 'gemini'; m.style.background = '#eaf0fb'; m.style.color = '#3556a5'; }
+      else if (PB.mode === 'template'){ m.textContent = 'template'; m.style.background = 'var(--cream)'; m.style.color = 'var(--accent)'; }
+      else if (PB.mode === 'template-fallback'){ m.textContent = 'template (gemini failed)'; m.style.background = '#fff0e6'; m.style.color = 'var(--accent)'; }
+      else { m.textContent = PB.mode || '—'; m.style.background = 'var(--paper)'; m.style.color = 'var(--mut)'; }
+
+      const meta = $('#pb-gen-meta');
+      if (!PB) meta.textContent = 'No playbook yet for this month — hit Regenerate to seed one from current signals.';
+      else {
+        const when = PB.generatedAt ? new Date(PB.generatedAt).toLocaleString() : '—';
+        const err = PB.generatorError ? ` · <span style="color:var(--accent)">gemini error: ${esc(PB.generatorError)}</span>` : '';
+        meta.innerHTML = `${esc(PB.entries.length)} ideas · generated ${esc(when)} · window starts ${esc(PB.startIso || '—')}${err}`;
+      }
+    }
+
+    function renderStats(){
+      $('#pb-stat-count').textContent    = PB ? PB.entries.length : '—';
+      $('#pb-stat-calendar').textContent = SIGNALS ? SIGNALS.dates.length : '—';
+      $('#pb-stat-gaps').textContent     = SIGNALS ? SIGNALS.gaps.length  : '—';
+      $('#pb-stat-healthy').textContent  = SIGNALS ? SIGNALS.segments.filter(s => s.band === 'good').length : '—';
+    }
+
+    function renderSignals(){
+      const cal = $('#pb-signals-cal'); cal.classList.remove('loading');
+      const seg = $('#pb-signals-seg'); seg.classList.remove('loading');
+
+      if (!SIGNALS){ cal.innerHTML = '<div class="muted">No signal data.</div>'; seg.innerHTML = '<div class="muted">No signal data.</div>'; return; }
+
+      const dates = SIGNALS.dates || [];
+      if (!dates.length){
+        cal.innerHTML = '<div class="muted">No curated marketing moments in this window.</div>';
+      } else {
+        cal.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">` + dates.slice(0, 10).map(d => `
+          <div style="display:grid;grid-template-columns:88px 1fr auto;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line)">
+            <div class="muted" style="font-size:11.5px">${esc(fmtDate(d.date))}</div>
+            <div>
+              <div style="font-weight:600;font-size:13px">${esc(d.title)}</div>
+              <div class="muted" style="font-size:11.5px;margin-top:2px">${esc(d.suggestion || '')}</div>
+            </div>
+            <span class="pill" style="background:var(--cream);align-self:start">${esc(d.type)}</span>
+          </div>
+        `).join('') + `</div>`;
+      }
+
+      const segs = SIGNALS.segments || [];
+      seg.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">` + segs.map(s => `
+        <div style="display:grid;grid-template-columns:1fr auto auto;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line);align-items:center">
+          <div>
+            <div style="font-weight:600;font-size:13px">${esc(s.icon || '')} ${esc(s.name)}</div>
+            <div class="muted" style="font-size:11.5px">${(s.openRate*100).toFixed(1)}% open · trend ${s.pctChange30d}%</div>
+          </div>
+          <span class="pill" style="background:var(--paper);color:var(--${bandClass(s.band)})">${bandLabel(s.band)}</span>
+          <span class="muted" style="font-size:11.5px">${esc((s.listSize||0).toLocaleString())} contacts</span>
+        </div>
+      `).join('') +
+      (SIGNALS.gaps && SIGNALS.gaps.length
+        ? `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--line)">
+             <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:5px">Performance gaps</div>
+             ${SIGNALS.gaps.map(g => `<div style="font-size:12px;margin-bottom:4px">⚠ ${esc(g.note)}</div>`).join('')}
+           </div>`
+        : '') + `</div>`;
+    }
+
+    function renderList(){
+      const list = $('#pb-list'); list.classList.remove('loading');
+      if (!PB || !PB.entries.length){
+        list.innerHTML = `<div class="muted-banner">
+          No playbook yet for <b>${esc(MONTH)}</b>. Press <b>↻ Regenerate</b> above to draft one from current signals.
+        </div>`;
+        return;
+      }
+      // group by week
+      const byWeek = {1:[],2:[],3:[],4:[]};
+      for (const e of PB.entries){ const w = e.week || 1; (byWeek[w] || byWeek[1]).push(e); }
+
+      const html = [1,2,3,4].map(w => {
+        const items = byWeek[w];
+        if (!items.length) return `
+          <div style="margin-bottom:14px">
+            <div class="muted" style="font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Week ${w}</div>
+            <div class="muted" style="font-size:12.5px;padding:8px 12px;border:1px dashed var(--line);border-radius:9px">Open slot — no plays scheduled.</div>
+          </div>`;
+        return `
+          <div style="margin-bottom:14px">
+            <div class="muted" style="font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Week ${w}</div>
+            <div style="display:flex;flex-direction:column;gap:8px">
+              ${items.map(e => `
+                <div style="border:1px solid var(--line);border-radius:11px;padding:11px 14px;background:#fff">
+                  <div class="row" style="justify-content:space-between;align-items:baseline;gap:10px">
+                    <div style="min-width:0">
+                      <div style="font:600 15px/1.25 'Cormorant Garamond',Georgia,serif;color:var(--ink)">${esc(e.title)}</div>
+                      <div class="muted" style="font-size:11.5px;margin-top:2px">
+                        ${esc(fmtDate(e.date))} · ${esc(e.audienceLabel || e.audience)} · ${esc(e.channel)} · <span style="text-transform:uppercase;letter-spacing:.4px">${esc(e.type)}</span>
+                      </div>
+                    </div>
+                    <div class="row" style="gap:6px;flex-shrink:0">
+                      ${sourcePill(e.source)}
+                      <span class="pill" style="background:var(--paper);color:var(--mut)">${esc(e.status || 'suggested')}</span>
+                    </div>
+                  </div>
+                  <div style="font-size:12.5px;margin-top:7px;color:var(--ink)">${esc(e.rationale || '')}</div>
+                  ${e.notes ? `<div class="muted" style="font-size:11.5px;margin-top:6px"><b>Notes:</b> ${esc(e.notes)}</div>` : ''}
+                  <div class="row" style="gap:6px;margin-top:10px;justify-content:flex-end">
+                    <button class="btn ghost" data-act="edit" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px">Edit</button>
+                    <button class="btn ghost" data-act="calendar" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px">→ Add to Calendar</button>
+                    <button class="btn ghost" data-act="delete" data-id="${esc(e.id)}" style="padding:5px 10px;font-size:11.5px;color:var(--accent)">Delete</button>
+                  </div>
+                </div>
+              `).join('')}
+            </div>
+          </div>`;
+      }).join('');
+      list.innerHTML = html;
+    }
+
+    function renderEdit(entry){
+      const card = $('#pb-edit-card'); card.style.display = 'block';
+      $('#pb-edit-title').textContent = entry ? 'Edit entry' : 'Add entry';
+      const e = entry || {
+        date: PB ? PB.startIso : MONTH + '-01', title: '', audience: 'designers',
+        channel: 'email', type: 'campaign', rationale: '', notes: '', status: 'suggested', source: 'manual',
+      };
+      const audOpts = (META.audiences || []).map(a =>
+        `<option value="${esc(a.id)}" ${a.id === e.audience ? 'selected' : ''}>${esc(a.label)}</option>`).join('');
+      const chOpts = (META.channels || ['email']).map(c =>
+        `<option value="${esc(c)}" ${c === e.channel ? 'selected' : ''}>${esc(c)}</option>`).join('');
+      const stOpts = (META.statuses || ['suggested']).map(s =>
+        `<option value="${esc(s)}" ${s === e.status ? 'selected' : ''}>${esc(s)}</option>`).join('');
+
+      $('#pb-edit').innerHTML = `
+        <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px">
+          <div><label>Title</label><input id="pe-title" value="${esc(e.title)}" /></div>
+          <div><label>Date</label><input id="pe-date" type="date" value="${esc(e.date)}" /></div>
+          <div><label>Audience</label><select id="pe-audience">${audOpts}</select></div>
+          <div><label>Channel</label><select id="pe-channel">${chOpts}</select></div>
+          <div><label>Type</label><input id="pe-type" value="${esc(e.type)}" /></div>
+          <div><label>Status</label><select id="pe-status">${stOpts}</select></div>
+        </div>
+        <div style="margin-top:8px"><label>Rationale</label><textarea id="pe-rationale" rows="3">${esc(e.rationale)}</textarea></div>
+        <div style="margin-top:8px"><label>Notes</label><textarea id="pe-notes" rows="2">${esc(e.notes)}</textarea></div>
+        <div class="row" style="justify-content:flex-end;gap:8px;margin-top:12px">
+          <span class="muted" id="pe-msg" style="font-size:12px;align-self:center;flex:1">&nbsp;</span>
+          <button class="btn ghost" id="pe-cancel" style="padding:7px 12px">Cancel</button>
+          <button class="btn" id="pe-save" style="padding:7px 12px" data-id="${esc(e.id || '')}">${entry ? 'Save changes' : 'Add to playbook'}</button>
+        </div>
+      `;
+
+      $('#pe-cancel').onclick = () => { card.style.display = 'none'; };
+      $('#pe-save').onclick = async () => {
+        const body = {
+          month: MONTH,
+          id: entry ? entry.id : undefined,
+          title: $('#pe-title').value.trim(),
+          date: $('#pe-date').value,
+          audience: $('#pe-audience').value,
+          channel: $('#pe-channel').value,
+          type: $('#pe-type').value.trim(),
+          status: $('#pe-status').value,
+          rationale: $('#pe-rationale').value.trim(),
+          notes: $('#pe-notes').value.trim(),
+        };
+        const msg = $('#pe-msg');
+        if (!body.title) { msg.textContent = 'Title is required.'; return; }
+        if (!body.date)  { msg.textContent = 'Date is required.'; return; }
+        msg.textContent = 'Saving…';
+        try {
+          let res;
+          if (entry) {
+            res = await api(`/entry/${encodeURIComponent(entry.id)}?month=${encodeURIComponent(MONTH)}`, {
+              method:'PUT', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body),
+            });
+          } else {
+            res = await api(`/entry`, {
+              method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body),
+            });
+          }
+          if (res && res.playbook) { PB = res.playbook; renderList(); renderStats(); renderGenMeta(); }
+          card.style.display = 'none';
+        } catch (e) { msg.textContent = 'Save failed: ' + e.message; }
+      };
+    }
+
+    // ── event wiring ────────────────────────────────────────────────────────
+    $('#pb-month').addEventListener('change', e => { MONTH = e.target.value; loadAll(); });
+
+    $('#pb-regen').addEventListener('click', async () => {
+      const btn = $('#pb-regen'); const orig = btn.textContent;
+      btn.disabled = true; btn.textContent = '… generating';
+      try {
+        const res = await api('/generate', {
+          method:'POST', headers:{ 'Content-Type':'application/json' },
+          body: JSON.stringify({ month: MONTH }),
+        });
+        if (res && res.playbook) { PB = res.playbook; SIGNALS = PB.signals || SIGNALS; }
+        renderSignals(); renderStats(); renderList(); renderGenMeta();
+      } catch (e) { alert('Regenerate failed: ' + e.message); }
+      finally { btn.disabled = false; btn.textContent = orig; }
+    });
+
+    $('#pb-add').addEventListener('click', () => renderEdit(null));
+    $('#pb-edit-close').addEventListener('click', () => { $('#pb-edit-card').style.display = 'none'; });
+
+    $('#pb-list').addEventListener('click', async ev => {
+      const btn = ev.target.closest('button[data-act]');
+      if (!btn) return;
+      const id = btn.getAttribute('data-id');
+      const entry = PB && PB.entries.find(e => e.id === id);
+      const act = btn.getAttribute('data-act');
+      if (act === 'edit') return renderEdit(entry);
+      if (act === 'delete') {
+        if (!confirm(`Delete "${entry && entry.title}" from the playbook? This does not affect the calendar.`)) return;
+        const res = await api(`/delete?month=${encodeURIComponent(MONTH)}&id=${encodeURIComponent(id)}`, { method:'POST' });
+        if (res && res.playbook) { PB = res.playbook; renderList(); renderStats(); renderGenMeta(); }
+        return;
+      }
+      if (act === 'calendar') {
+        if (!entry) return;
+        if (!confirm(`Add "${entry.title}" to the marketing Calendar as a planned campaign on ${entry.date}? It will appear in /calendar — no email is sent.`)) return;
+        try {
+          const r = await fetch('/api/calendar/schedule', {
+            method:'POST', headers:{ 'Content-Type':'application/json' },
+            body: JSON.stringify({
+              date: entry.date, title: entry.title,
+              channel: (entry.channel || '').includes('instagram') ? 'instagram' : 'email',
+              status: 'planned',
+              notes: `From playbook · audience: ${entry.audienceLabel || entry.audience} · ${entry.rationale || ''}`,
+            }),
+          }).then(r => r.json());
+          if (r && r.ok) {
+            btn.textContent = '✓ Added to calendar';
+            btn.disabled = true;
+          } else {
+            alert('Could not add to calendar: ' + (r && r.error || 'unknown error'));
+          }
+        } catch (e) { alert('Could not add to calendar: ' + e.message); }
+      }
+    });
+
+    await loadAll();
+  },
+};
diff --git a/public/panels/profiles.html b/public/panels/profiles.html
new file mode 100644
index 0000000..46bccb4
--- /dev/null
+++ b/public/panels/profiles.html
@@ -0,0 +1,27 @@
+<div id="prof-banner"></div>
+
+<div class="grid" style="grid-template-columns:minmax(280px,340px) 1fr;align-items:start">
+
+  <!-- Left: search + recent list -->
+  <div class="card" id="prof-list-card">
+    <h2>Find a contact</h2>
+    <div class="muted" style="font-size:12px">Search email, name, company, role or tag.</div>
+    <div style="margin-top:12px">
+      <input id="prof-q" placeholder="e.g. atelier-noir, grasscloth, principal" autocomplete="off">
+    </div>
+    <div class="row" style="margin-top:8px;justify-content:space-between;align-items:center">
+      <span class="muted" id="prof-list-meta" style="font-size:12px">—</span>
+      <button class="btn ghost" id="prof-list-refresh" style="padding:5px 11px;font-size:12px">Refresh</button>
+    </div>
+    <div id="prof-list" class="loading" style="margin-top:12px">Loading contacts…</div>
+  </div>
+
+  <!-- Right: 360 profile card -->
+  <div class="card" id="prof-detail-card">
+    <div id="prof-detail-empty" class="muted" style="padding:30px 0;text-align:center">
+      Pick a contact on the left to see their 360° profile.
+    </div>
+    <div id="prof-detail" style="display:none"></div>
+  </div>
+
+</div>
diff --git a/public/panels/profiles.js b/public/panels/profiles.js
new file mode 100644
index 0000000..e5e3c67
--- /dev/null
+++ b/public/panels/profiles.js
@@ -0,0 +1,296 @@
+// Contact Profiles panel — left: searchable contact list; right: a 360° card
+// for the selected contact (lifecycle, lists, tags, segment memberships, email
+// engagement timeline, cross-channel placeholders). Read-only.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['profiles'] = {
+  init(root) {
+    const $ = s => root.querySelector(s);
+    const api = (p) => fetch(`/api/profiles${p}`).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
+      { year: 'numeric', month: 'short', day: 'numeric' }) : '—';
+    const fmtDateTime = d => d ? new Date(d).toLocaleString(undefined,
+      { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
+    const fmtRelative = d => {
+      if (!d) return 'never';
+      const days = Math.round((Date.now() - Date.parse(d)) / 86400000);
+      if (days <= 0) return 'today';
+      if (days === 1) return 'yesterday';
+      if (days < 30) return `${days}d ago`;
+      if (days < 365) return `${Math.round(days / 30)}mo ago`;
+      return `${Math.round(days / 365)}y ago`;
+    };
+
+    let anyMock = false;
+    let selectedEmail = null;
+    let listCache = [];
+    let listIsSearch = false;
+
+    function setMock(m) {
+      if (m) {
+        anyMock = true;
+        $('#prof-banner').innerHTML =
+          `<div class="muted-banner">Running on a <b>mock contact pool</b> (~240 luxury-design contacts).
+           Paste your <code>CTCT_ACCESS_TOKEN</code> in <code>.env</code> to power profiles from your live Constant Contact roster.</div>`;
+      }
+    }
+
+    // ── list rendering ────────────────────────────────────────────────────────
+    function renderList(payload) {
+      const el = $('#prof-list');
+      listCache = payload.contacts || [];
+      const meta = $('#prof-list-meta');
+      if (payload.query) {
+        meta.textContent = `${Number(payload.count).toLocaleString()} match${payload.count === 1 ? '' : 'es'} for “${payload.query}” · ${Number(payload.total).toLocaleString()} contacts`;
+      } else {
+        meta.textContent = `${Number(payload.total).toLocaleString()} contacts · sorted by most recent open`;
+      }
+
+      if (!listCache.length) {
+        el.classList.remove('loading');
+        el.innerHTML = `<div class="muted" style="padding:18px 0">No contacts match.</div>`;
+        return;
+      }
+      el.classList.remove('loading');
+      el.innerHTML = listCache.map(c => {
+        const isSel = selectedEmail && c.email.toLowerCase() === selectedEmail.toLowerCase();
+        return `<div class="prof-row" data-email="${esc(c.email)}"
+          style="border-top:1px solid var(--line);padding:10px 0;cursor:pointer;${isSel ? 'background:#f7f2e8' : ''}">
+          <div class="row" style="justify-content:space-between;align-items:baseline;gap:8px">
+            <div style="font-weight:600">${esc(c.name || c.email)}</div>
+            <span class="pill">${esc(c.type)}</span>
+          </div>
+          <div class="muted" style="font-size:12px">${esc(c.email)}</div>
+          <div class="muted" style="font-size:11.5px;margin-top:3px">
+            ${esc(c.company || '')}${c.role ? ` · ${esc(c.role)}` : ''}
+          </div>
+          <div class="row" style="gap:6px;margin-top:5px;align-items:center">
+            <span class="pill" style="background:#eee5d4">${esc(c.lifecycle)}</span>
+            <span class="muted" style="font-size:11px">last open ${fmtRelative(c.lastOpen)}</span>
+          </div>
+        </div>`;
+      }).join('');
+
+      el.querySelectorAll('.prof-row').forEach(row => {
+        row.onclick = () => loadDetail(row.dataset.email);
+      });
+    }
+
+    function loadList() {
+      $('#prof-list').classList.add('loading');
+      $('#prof-list').textContent = 'Loading contacts…';
+      listIsSearch = false;
+      return api('/list?limit=25').then(p => { setMock(p.mock); renderList(p); })
+        .catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+    }
+
+    function runSearch(q) {
+      listIsSearch = true;
+      if (!q) return loadList();
+      $('#prof-list').classList.add('loading');
+      $('#prof-list').textContent = 'Searching…';
+      return api(`/search?q=${encodeURIComponent(q)}`)
+        .then(p => { setMock(p.mock); renderList(p); })
+        .catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+    }
+
+    // ── detail rendering ───────────────────────────────────────────────────────
+    function renderTimeline(events) {
+      if (!events || !events.length) {
+        return `<div class="muted" style="padding:14px 0">No email events recorded.</div>`;
+      }
+      // Limit to 60 newest so the card doesn't get unwieldy
+      const visible = events.slice(0, 60);
+      const iconFor = t => t === 'sent' ? '✉️' : t === 'open' ? '👁️' : t === 'click' ? '🔗' : '•';
+      const colorFor = t => t === 'open' ? '#6f7a63' : t === 'click' ? '#8a5a44' : '#8a8275';
+      const rows = visible.map(e => `
+        <div class="row" style="gap:10px;align-items:baseline;border-top:1px solid var(--line);padding:8px 0">
+          <span style="width:22px;text-align:center" title="${esc(e.type)}">${iconFor(e.type)}</span>
+          <div style="flex:1;min-width:0">
+            <div style="font-weight:600;font-size:13px;color:${colorFor(e.type)}">
+              ${e.type === 'sent' ? 'Sent' : e.type === 'open' ? 'Opened' : 'Clicked'}
+              <span style="color:var(--ink);font-weight:500">· ${esc(e.campaignName || '(campaign)')}</span>
+            </div>
+            <div class="muted" style="font-size:11.5px">
+              ${esc(fmtDateTime(e.at))}${e.link ? ` · <code style="font-size:11px">${esc(e.link)}</code>` : ''}
+              ${e.derived ? ' · <span title="Derived from CC aggregate fields — true per-event streams require ETL">derived</span>' : ''}
+            </div>
+          </div>
+        </div>`).join('');
+      return `<div>${rows}</div>
+        ${events.length > visible.length
+          ? `<div class="muted" style="font-size:12px;margin-top:8px">Showing ${visible.length} of ${events.length} events.</div>`
+          : ''}`;
+    }
+
+    function lifecyclePill(stage, tone) {
+      const bg = tone === 'hot' ? '#fde6d8' : tone === 'warm' ? '#fff1d2' :
+                 tone === 'cold' ? '#e6e2da' : '#eee5d4';
+      const fg = tone === 'hot' ? '#8a5a44' : tone === 'warm' ? '#7a5a2a' :
+                 tone === 'cold' ? '#5a5347' : '#5a5347';
+      return `<span class="pill" style="background:${bg};color:${fg}">${esc(stage)}</span>`;
+    }
+
+    function renderDetail(payload) {
+      setMock(payload.mock);
+      $('#prof-detail-empty').style.display = 'none';
+      const c = payload.contact;
+      const lc = payload.lifecycle || { stage: '—', tone: 'neutral' };
+      const segs = payload.segments || [];
+      const cx = payload.crossChannel || {};
+      const lastVisit = cx.lastSiteVisit;
+      const social = cx.social;
+      const counts = payload.timelineCounts || { sent: 0, open: 0, click: 0 };
+      const tags = (c.tags || []).map(t =>
+        `<span class="pill" style="margin:0 4px 4px 0">${esc(t)}</span>`).join('') ||
+        '<span class="muted" style="font-size:12px">no tags</span>';
+      const lists = (c.lists || []).map(l =>
+        `<span class="pill" style="margin:0 4px 4px 0;background:#eee5d4">${esc(l)}</span>`).join('') ||
+        '<span class="muted" style="font-size:12px">no list memberships</span>';
+      const segChips = segs.length
+        ? segs.map(s => `<span class="pill" title="${esc(s.description)}" style="margin:0 4px 4px 0;background:#e6dec8">${esc(s.name)}</span>`).join('')
+        : '<span class="muted" style="font-size:12px">Does not match any saved segment.</span>';
+
+      const loc = c.location || {};
+      const locStr = [loc.city, loc.region, loc.country].filter(Boolean).join(', ') || '—';
+
+      const detail = $('#prof-detail');
+      detail.style.display = '';
+      detail.innerHTML = `
+        <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+          <div style="min-width:0">
+            <h2 style="margin:0">${esc(c.name || c.email)}</h2>
+            <div class="muted" style="font-size:12.5px">${esc(c.email)}</div>
+          </div>
+          <div class="row" style="gap:6px;align-items:center">
+            ${lifecyclePill(lc.stage, lc.tone)}
+            <span class="pill">${esc(c.type)}</span>
+          </div>
+        </div>
+
+        <div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
+          <div><label>Company</label><div style="font-weight:600">${esc(c.company || '—')}</div></div>
+          <div><label>Role</label><div>${esc(c.role || '—')}</div></div>
+          <div><label>Location</label><div>${esc(locStr)}</div></div>
+          <div><label>Contact since</label><div>${esc(fmtDate(c.since))}</div></div>
+        </div>
+
+        <div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
+          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime opens</div>
+            <div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.opens || 0).toLocaleString()}</div>
+          </div>
+          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime clicks</div>
+            <div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.clicks || 0).toLocaleString()}</div>
+          </div>
+          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last open</div>
+            <div style="font-weight:600">${esc(fmtRelative(c.lastOpen))}</div>
+            <div class="muted" style="font-size:11px">${esc(fmtDate(c.lastOpen))}</div>
+          </div>
+          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last click</div>
+            <div style="font-weight:600">${esc(fmtRelative(c.lastClick))}</div>
+            <div class="muted" style="font-size:11px">${esc(fmtDate(c.lastClick))}</div>
+          </div>
+        </div>
+
+        <div class="grid" style="margin-top:18px;grid-template-columns:1fr 1fr;gap:16px;align-items:start">
+
+          <div>
+            <label style="margin-bottom:6px">List memberships</label>
+            <div>${lists}</div>
+            <label style="margin:14px 0 6px">Tags</label>
+            <div>${tags}</div>
+            <label style="margin:14px 0 6px">Segment memberships</label>
+            <div>${segChips}</div>
+          </div>
+
+          <div>
+            <label style="margin-bottom:6px">Cross-channel</label>
+            <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
+              <div class="row" style="justify-content:space-between;align-items:baseline">
+                <div style="font-weight:600;font-size:13px">Last site visit</div>
+                <span class="muted" style="font-size:11px">placeholder</span>
+              </div>
+              ${lastVisit ? `
+                <div style="margin-top:4px;font-size:13px">${esc(lastVisit.pageTitle || lastVisit.page)}</div>
+                <div class="muted" style="font-size:11.5px">${esc(fmtRelative(lastVisit.at))} · ${esc(fmtDate(lastVisit.at))} · source: ${esc(lastVisit.source || '—')}</div>
+                <div class="muted" style="font-size:11px;margin-top:3px"><code>/${esc(lastVisit.page)}</code></div>
+              ` : `<div class="muted" style="font-size:12px;margin-top:4px">No tracked site visits.</div>`}
+            </div>
+            <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:10px">
+              <div class="row" style="justify-content:space-between;align-items:baseline">
+                <div style="font-weight:600;font-size:13px">Social</div>
+                <span class="muted" style="font-size:11px">placeholder</span>
+              </div>
+              ${social ? `
+                <div style="margin-top:4px;font-size:13px">
+                  <span class="pill" style="margin-right:6px">${esc(social.platform)}</span>
+                  <code style="font-size:12px">${esc(social.handle)}</code>
+                </div>
+                <div class="muted" style="font-size:11.5px;margin-top:3px">${esc(social.action || 'last touch')} · ${esc(fmtRelative(social.lastTouch))}</div>
+              ` : `<div class="muted" style="font-size:12px;margin-top:4px">No social signal on file.</div>`}
+            </div>
+            ${cx.note ? `<div class="muted" style="font-size:11.5px;margin-top:8px">${esc(cx.note)}</div>` : ''}
+          </div>
+
+        </div>
+
+        <div style="margin-top:18px">
+          <div class="row" style="justify-content:space-between;align-items:baseline">
+            <label style="margin:0">Email engagement timeline</label>
+            <span class="muted" style="font-size:11.5px">
+              ${Number(counts.sent || 0)} sent · ${Number(counts.open || 0)} opens · ${Number(counts.click || 0)} clicks
+            </span>
+          </div>
+          <div style="margin-top:6px">${renderTimeline(payload.timeline || [])}</div>
+        </div>
+      `;
+    }
+
+    function loadDetail(email) {
+      selectedEmail = email;
+      // re-style the list row selection
+      root.querySelectorAll('.prof-row').forEach(r => {
+        r.style.background = (r.dataset.email.toLowerCase() === email.toLowerCase()) ? '#f7f2e8' : '';
+      });
+      $('#prof-detail-empty').style.display = 'none';
+      $('#prof-detail').style.display = '';
+      $('#prof-detail').innerHTML = '<div class="loading" style="padding:30px 0">Loading profile…</div>';
+      return api(`/contact?email=${encodeURIComponent(email)}`)
+        .then(r => {
+          if (r.error) {
+            $('#prof-detail').innerHTML = `<div class="muted-banner">${esc(r.error)}</div>`;
+            return;
+          }
+          renderDetail(r);
+        })
+        .catch(e => {
+          $('#prof-detail').innerHTML = `<div class="muted-banner">Error: ${esc(e.message)}</div>`;
+        });
+    }
+
+    // ── wire up ────────────────────────────────────────────────────────────────
+    let searchTimer = null;
+    $('#prof-q').addEventListener('input', (e) => {
+      const q = e.target.value.trim();
+      clearTimeout(searchTimer);
+      searchTimer = setTimeout(() => runSearch(q), 220);
+    });
+    $('#prof-q').addEventListener('keydown', (e) => {
+      if (e.key === 'Enter') {
+        clearTimeout(searchTimer);
+        runSearch(e.target.value.trim());
+      }
+    });
+    $('#prof-list-refresh').addEventListener('click', () => {
+      $('#prof-q').value = '';
+      loadList();
+    });
+
+    loadList();
+  },
+};
diff --git a/public/panels/segment-perf.html b/public/panels/segment-perf.html
new file mode 100644
index 0000000..49b40de
--- /dev/null
+++ b/public/panels/segment-perf.html
@@ -0,0 +1,30 @@
+<div id="sp-banner"></div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>Segment Performance</h2>
+      <div class="muted" style="font-size:12px">
+        Email KPIs split by audience segment, colour-coded against luxury B2B
+        benchmarks. 30-day headline averages with a 90-day per-segment trend.
+        Read-only — never triggers a send.
+      </div>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <label for="sp-metric" style="margin:0">Trend metric</label>
+      <select id="sp-metric" style="min-width:200px;width:auto">
+        <option value="openRate">Open rate</option>
+        <option value="ctor">CTOR (clicks / opens)</option>
+        <option value="clickRate">Click rate</option>
+        <option value="revPerRecipient">Est. revenue / recipient</option>
+      </select>
+      <button class="btn ghost" id="sp-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+    </div>
+  </div>
+
+  <div class="row" id="sp-legend" style="gap:14px;margin-top:14px;align-items:center;font-size:11.5px"></div>
+</div>
+
+<div id="sp-cards" class="grid" style="grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:18px">
+  <div class="loading" style="grid-column:1 / -1">Loading segments…</div>
+</div>
diff --git a/public/panels/segment-perf.js b/public/panels/segment-perf.js
new file mode 100644
index 0000000..14772ed
--- /dev/null
+++ b/public/panels/segment-perf.js
@@ -0,0 +1,294 @@
+// Segment Performance panel — fetches /api/segment-perf/summary, renders one
+// card per segment with KPI tiles colour-coded by health band, then fetches a
+// 90-day per-segment trend (currently-selected metric) and draws a hand-rolled
+// SVG bar chart inside each card. No external charting lib.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['segment-perf'] = {
+  init(root) {
+    const $ = s => root.querySelector(s);
+    const $$ = s => Array.from(root.querySelectorAll(s));
+    const api = (p) => fetch(`/api/segment-perf${p}`).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+
+    // ── formatting helpers ───────────────────────────────────────────────────
+    const fmtPct = v => (v * 100).toFixed(1) + '%';
+    const fmtMoney = v => '$' + (v >= 10
+      ? v.toFixed(2)
+      : v >= 1 ? v.toFixed(2) : v.toFixed(3));
+    const fmtNum = v => Number(v || 0).toLocaleString();
+    const fmtTrend = v => {
+      if (v == null) return '';
+      const sign = v > 0 ? '▲' : v < 0 ? '▼' : '·';
+      return `${sign} ${Math.abs(v).toFixed(1)}%`;
+    };
+
+    const fmtValue = (metric, v) =>
+      metric === 'revPerRecipient' ? fmtMoney(v) : fmtPct(v);
+
+    const METRIC_LABELS = {
+      openRate:       { short: 'Open rate',  hint: 'opens / sends' },
+      ctor:           { short: 'CTOR',       hint: 'clicks / opens' },
+      clickRate:      { short: 'Click rate', hint: 'clicks / sends' },
+      revPerRecipient:{ short: '$ / recip.', hint: 'orders × AOV / sends' },
+    };
+    // Colour mapping reuses the existing CSS palette tokens so segment perf
+    // tiles share the look of the rest of the Command Center.
+    //   good = sage (calm green-grey)
+    //   ok   = gold (warm hold)
+    //   bad  = accent (the brick / clay accent used for gated buttons)
+    const BAND = {
+      good: { bg: 'rgba(111,122,99,0.12)',  fg: 'var(--sage)',   label: 'Healthy' },
+      ok:   { bg: 'rgba(184,146,90,0.16)',  fg: 'var(--gold)',   label: 'Watch'   },
+      bad:  { bg: 'rgba(138,90,68,0.16)',   fg: 'var(--accent)', label: 'At risk' },
+      unknown: { bg: 'var(--cream)',        fg: 'var(--mut)',    label: '—'       },
+    };
+
+    let benchmarks = null;
+    let summaryCache = null;
+    let anyMock = false;
+
+    function banner() {
+      $('#sp-banner').innerHTML = anyMock
+        ? `<div class="muted-banner">Running on <b>deterministic mock data</b> (CTCT
+             reporting + Shopify revenue attribution not yet wired in). Numbers are stable
+             so screenshots stay reproducible.</div>`
+        : '';
+    }
+
+    function renderLegend() {
+      const target = $('#sp-legend');
+      if (!benchmarks) { target.innerHTML = ''; return; }
+      const metric = $('#sp-metric').value;
+      const b = benchmarks[metric];
+      const isMoney = metric === 'revPerRecipient';
+      const fmt = isMoney ? fmtMoney : fmtPct;
+      target.innerHTML = `
+        <span class="muted">Benchmarks (${esc(METRIC_LABELS[metric].short)}):</span>
+        <span style="display:inline-flex;gap:6px;align-items:center">
+          <span style="width:10px;height:10px;border-radius:50%;background:var(--accent)"></span>
+          at risk &lt; ${fmt(b.bad)}
+        </span>
+        <span style="display:inline-flex;gap:6px;align-items:center">
+          <span style="width:10px;height:10px;border-radius:50%;background:var(--gold)"></span>
+          watch ${fmt(b.bad)}–${fmt(b.good)}
+        </span>
+        <span style="display:inline-flex;gap:6px;align-items:center">
+          <span style="width:10px;height:10px;border-radius:50%;background:var(--sage)"></span>
+          healthy ≥ ${fmt(b.good)}
+        </span>
+      `;
+    }
+
+    // ── SVG bar chart (hand-rolled) ──────────────────────────────────────────
+    // Renders a 90-day trend as vertical bars with three reference bands
+    // (bad / ok / good) drawn as horizontal stripes underneath, plus the mean
+    // line. Campaign send days are drawn slightly taller / saturated.
+    function renderTrendSvg(trend, metric) {
+      const W = 360, H = 110;
+      const padL = 4, padR = 4, padT = 10, padB = 14;
+      const innerW = W - padL - padR;
+      const innerH = H - padT - padB;
+      const series = trend.series || [];
+      if (!series.length) return '<div class="muted">No data</div>';
+
+      // y-domain: stretch from 0 to max(value, benchmark.good × 1.15)
+      const benchmark = trend.benchmark || { bad: 0, good: 0 };
+      let maxV = 0;
+      for (const p of series) if (p.value > maxV) maxV = p.value;
+      const yMax = Math.max(maxV, benchmark.good * 1.15, 0.0001);
+      const yToPx = v => padT + innerH - (v / yMax) * innerH;
+
+      const barW = innerW / series.length;
+      const drawW = Math.max(1.5, barW - 0.6);
+
+      // colour each bar by which band its value falls in (gives instant visual
+      // health signal across the timeline, not just at the latest point)
+      const colorFor = v => {
+        if (v < benchmark.bad) return 'var(--accent)';
+        if (v < benchmark.good) return 'var(--gold)';
+        return 'var(--sage)';
+      };
+
+      // health bands as stacked translucent stripes
+      const badTop = yToPx(benchmark.bad);
+      const goodTop = yToPx(benchmark.good);
+      const baseY = padT + innerH;
+      const bands = `
+        <rect x="${padL}" y="${badTop}" width="${innerW}" height="${baseY - badTop}"
+              fill="rgba(138,90,68,0.06)" />
+        <rect x="${padL}" y="${goodTop}" width="${innerW}" height="${badTop - goodTop}"
+              fill="rgba(184,146,90,0.07)" />
+        <rect x="${padL}" y="${padT}" width="${innerW}" height="${goodTop - padT}"
+              fill="rgba(111,122,99,0.07)" />
+      `;
+
+      const bars = series.map((p, i) => {
+        const x = padL + i * barW;
+        const y = yToPx(p.value);
+        const h = baseY - y;
+        const fill = colorFor(p.value);
+        const opacity = p.isCampaign ? 1 : 0.55;
+        return `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${drawW.toFixed(2)}"
+                       height="${Math.max(0.5, h).toFixed(2)}" fill="${fill}" opacity="${opacity}">
+                  <title>${esc(p.date)} — ${metric === 'revPerRecipient' ? fmtMoney(p.value) : fmtPct(p.value)}${p.isCampaign ? ' (campaign day)' : ''}</title>
+                </rect>`;
+      }).join('');
+
+      // mean line
+      const meanY = yToPx(trend.mean || 0);
+      const meanLine = `
+        <line x1="${padL}" x2="${W - padR}" y1="${meanY}" y2="${meanY}"
+              stroke="var(--ink)" stroke-width="0.7" stroke-dasharray="2 3" opacity="0.5" />
+        <text x="${W - padR - 2}" y="${Math.max(8, meanY - 2)}" text-anchor="end"
+              font-size="9" fill="var(--mut)">mean ${metric === 'revPerRecipient' ? fmtMoney(trend.mean) : fmtPct(trend.mean)}</text>
+      `;
+
+      // x-axis: tick at first / mid / last date
+      const firstDate = series[0].date;
+      const midDate = series[Math.floor(series.length / 2)].date;
+      const lastDate = series[series.length - 1].date;
+      const tickStyle = 'font-size:9px;fill:var(--mut)';
+      const axis = `
+        <text x="${padL}" y="${H - 2}" style="${tickStyle}">${esc(firstDate)}</text>
+        <text x="${W / 2}" y="${H - 2}" text-anchor="middle" style="${tickStyle}">${esc(midDate)}</text>
+        <text x="${W - padR}" y="${H - 2}" text-anchor="end" style="${tickStyle}">${esc(lastDate)}</text>
+      `;
+
+      return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none"
+                style="display:block;background:#fff;border:1px solid var(--line);border-radius:8px">
+                ${bands}
+                ${bars}
+                ${meanLine}
+                ${axis}
+              </svg>`;
+    }
+
+    // ── KPI tile ─────────────────────────────────────────────────────────────
+    function kpiTile(metric, kpi, isSelected) {
+      const band = BAND[kpi.band] || BAND.unknown;
+      const label = METRIC_LABELS[metric];
+      const selectedRing = isSelected
+        ? 'box-shadow:0 0 0 2px var(--ink)'
+        : '';
+      const trendColor = kpi.trend > 0 ? 'var(--sage)' : kpi.trend < 0 ? 'var(--accent)' : 'var(--mut)';
+      return `
+        <div data-metric="${esc(metric)}" class="sp-tile" role="button" tabindex="0"
+             style="background:${band.bg};border-radius:10px;padding:10px 12px;cursor:pointer;
+                    transition:transform .12s ease;${selectedRing}">
+          <div class="row" style="justify-content:space-between;align-items:baseline">
+            <span style="font-size:10.5px;letter-spacing:.5px;text-transform:uppercase;
+                         color:${band.fg};font-weight:700">${esc(label.short)}</span>
+            <span style="font-size:10px;color:${trendColor};font-weight:700">${esc(fmtTrend(kpi.trend))}</span>
+          </div>
+          <div style="font:600 22px/1.1 'Cormorant Garamond',Georgia,serif;color:var(--ink);margin-top:2px">
+            ${esc(fmtValue(metric, kpi.value))}
+          </div>
+          <div class="muted" style="font-size:10.5px;margin-top:1px">${esc(label.hint)}</div>
+        </div>`;
+    }
+
+    function badge(seg) {
+      const b = BAND[seg.overallBand] || BAND.unknown;
+      return `<span class="pill" style="background:${b.bg};color:${b.fg}">${esc(b.label)}</span>`;
+    }
+
+    // ── render one segment card ──────────────────────────────────────────────
+    function renderCard(seg, trend, selectedMetric) {
+      const tiles = ['openRate', 'ctor', 'clickRate', 'revPerRecipient']
+        .map(m => kpiTile(m, seg.kpis[m], m === selectedMetric))
+        .join('');
+      const trendChart = trend
+        ? renderTrendSvg(trend, selectedMetric)
+        : '<div class="loading" style="padding:30px">Loading trend…</div>';
+      const trendLabel = METRIC_LABELS[selectedMetric].short;
+      const trendDelta = trend ? fmtTrend(trend.pctChange) : '';
+      const trendDeltaColor = trend && trend.pctChange > 0 ? 'var(--sage)'
+        : trend && trend.pctChange < 0 ? 'var(--accent)' : 'var(--mut)';
+      return `
+        <div class="card" data-segment="${esc(seg.id)}">
+          <div class="row" style="justify-content:space-between;align-items:baseline">
+            <div>
+              <h2 style="display:flex;gap:8px;align-items:center">
+                <span style="font-size:18px">${esc(seg.icon || '◆')}</span>
+                ${esc(seg.name)}
+              </h2>
+              <div class="muted" style="font-size:11.5px">${esc(seg.description)}</div>
+            </div>
+            ${badge(seg)}
+          </div>
+
+          <div class="row" style="gap:14px;margin:10px 0 0;font-size:11.5px;color:var(--mut)">
+            <span>List: <b style="color:var(--ink)">${fmtNum(seg.listSize)}</b></span>
+            <span>Sends / campaign: <b style="color:var(--ink)">${fmtNum(seg.sendsPerCampaign)}</b></span>
+          </div>
+
+          <div class="grid sp-tiles" style="grid-template-columns:repeat(2,1fr);gap:8px;margin-top:12px">
+            ${tiles}
+          </div>
+
+          <div style="margin-top:14px">
+            <div class="row" style="justify-content:space-between;align-items:baseline">
+              <label style="margin:0">90-day trend · ${esc(trendLabel)}</label>
+              <span style="font-size:11px;color:${trendDeltaColor};font-weight:700">${esc(trendDelta)} 30d vs 30d</span>
+            </div>
+            <div class="sp-chart" style="margin-top:6px">${trendChart}</div>
+          </div>
+        </div>`;
+    }
+
+    // ── orchestration ────────────────────────────────────────────────────────
+    async function fetchAllTrends(segments, metric) {
+      const out = {};
+      await Promise.all(segments.map(async s => {
+        const t = await api(`/trend?segmentId=${encodeURIComponent(s.id)}&metric=${encodeURIComponent(metric)}&days=90`);
+        if (t.mock) anyMock = true;
+        out[s.id] = t;
+      }));
+      return out;
+    }
+
+    async function render() {
+      const metric = $('#sp-metric').value;
+      const target = $('#sp-cards');
+      target.innerHTML = '<div class="loading" style="grid-column:1 / -1">Loading segments…</div>';
+
+      const summary = await api('/summary');
+      summaryCache = summary;
+      if (summary.mock) anyMock = true;
+      benchmarks = summary.benchmarks;
+      renderLegend();
+      banner();
+
+      // paint cards first with no chart, then fill in trends as they arrive
+      target.innerHTML = summary.segments.map(s => renderCard(s, null, metric)).join('');
+      wireTileClicks();
+
+      const trends = await fetchAllTrends(summary.segments, metric);
+      banner();
+      target.innerHTML = summary.segments
+        .map(s => renderCard(s, trends[s.id], metric))
+        .join('');
+      wireTileClicks();
+    }
+
+    // Click a KPI tile in any card → switch the selected trend metric and
+    // re-render. Saves the user from hunting for the dropdown.
+    function wireTileClicks() {
+      $$('.sp-tile').forEach(el => {
+        el.onclick = () => {
+          const m = el.getAttribute('data-metric');
+          if (!m) return;
+          $('#sp-metric').value = m;
+          render();
+        };
+        el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } };
+      });
+    }
+
+    $('#sp-metric').onchange = render;
+    $('#sp-refresh').onclick = render;
+
+    render();
+  },
+};
diff --git a/public/panels/segments.html b/public/panels/segments.html
index 1299c9b..286cfc4 100644
--- a/public/panels/segments.html
+++ b/public/panels/segments.html
@@ -1,5 +1,24 @@
 <div id="seg-banner"></div>
 
+<!-- RFM auto-segmentation: computed from email engagement -->
+<div class="card" id="seg-rfm-card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>RFM auto-segments</h2>
+      <div class="muted" style="font-size:12px">
+        Recency (last open) · Frequency (open count) · Monetary proxy (clicks).
+        Every contact lands in one bucket — totals reconcile to the pool.
+      </div>
+    </div>
+    <div class="row" style="gap:6px;align-items:center">
+      <span class="pill" id="seg-rfm-total">—</span>
+      <button class="btn ghost" id="seg-rfm-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+    </div>
+  </div>
+  <div class="muted" id="seg-rfm-summary" style="font-size:12px;margin-top:6px"></div>
+  <div id="seg-rfm-grid" class="loading" style="margin-top:12px">Loading RFM…</div>
+</div>
+
 <div class="grid" style="grid-template-columns:minmax(280px,360px) 1fr;align-items:start">
 
   <!-- Left: saved segments -->
diff --git a/public/panels/segments.js b/public/panels/segments.js
index 8d35ce7..44d96ce 100644
--- a/public/panels/segments.js
+++ b/public/panels/segments.js
@@ -219,6 +219,70 @@ window.MCC_PANELS['segments'] = {
       finally { $('#seg-save').disabled = false; }
     }
 
+    // ── RFM auto-segments ───────────────────────────────────────────────────────
+    const BUCKET_COLOR = {
+      champions:    '#b8925a',  // brand gold
+      loyal:        '#6f7a63',  // sage
+      'at-risk':    '#c47a4a',  // warm warning
+      hibernating:  '#8a8275',  // muted
+      promising:    '#7a8b9a',  // cool fresh
+    };
+    function rfmCard(b) {
+      const pct = (b.share * 100).toFixed(1);
+      const accent = BUCKET_COLOR[b.id] || 'var(--accent)';
+      const samp = (b.sample || []).slice(0, 5).map(s => {
+        const tail = `R${s.r}·F${s.f}·M${s.m}`;
+        return `<div style="display:flex;justify-content:space-between;gap:8px;
+                 border-top:1px solid var(--line);padding:5px 0;font-size:12px">
+          <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${esc(s.email)}</span>
+          <span class="muted" style="font-size:11px;flex-shrink:0">${tail}</span>
+        </div>`;
+      }).join('') || '<div class="muted" style="font-size:12px;padding:6px 0">No contacts in this bucket.</div>';
+      const moreCount = Math.max(0, b.count - 5);
+      return `<div class="card" style="margin:0;border-top:3px solid ${accent}">
+        <div class="row" style="justify-content:space-between;align-items:baseline">
+          <div style="font:600 16px/1.2 'Cormorant Garamond',Georgia,serif">
+            <span style="font-size:18px;margin-right:6px">${esc(b.icon)}</span>${esc(b.name)}
+          </div>
+          <span class="pill" style="background:${accent};color:#fff">${b.count.toLocaleString()}</span>
+        </div>
+        <div class="muted" style="font-size:11.5px;margin:4px 0 2px">${esc(b.description)}</div>
+        <div class="muted" style="font-size:11px;margin-bottom:8px">
+          <code style="font-size:10.5px">${esc(b.criteria)}</code> · ${pct}% of pool
+        </div>
+        <div style="margin-top:6px">${samp}
+          ${moreCount > 0 ? `<div class="muted" style="font-size:11px;padding-top:5px">+ ${moreCount.toLocaleString()} more…</div>` : ''}
+        </div>
+        <div class="muted" style="font-size:11.5px;border-top:1px solid var(--line);
+             padding-top:8px;margin-top:8px;font-style:italic">${esc(b.nextStep || '')}</div>
+      </div>`;
+    }
+    function renderRfm(r) {
+      markMock(r.mock);
+      $('#seg-rfm-total').textContent = `${Number(r.total || 0).toLocaleString()} contacts`;
+      const s = r.summary || {};
+      $('#seg-rfm-summary').innerHTML =
+        `Pool averages — R <b>${s.avgR ?? '—'}</b> · F <b>${s.avgF ?? '—'}</b> · M <b>${s.avgM ?? '—'}</b>`;
+      const grid = $('#seg-rfm-grid');
+      grid.classList.remove('loading');
+      const buckets = r.buckets || [];
+      if (!buckets.length) {
+        grid.innerHTML = '<div class="muted">No RFM data.</div>';
+        return;
+      }
+      grid.style.cssText =
+        'margin-top:12px;display:grid;gap:14px;' +
+        'grid-template-columns:repeat(auto-fit,minmax(220px,1fr))';
+      grid.innerHTML = buckets.map(rfmCard).join('');
+    }
+    function loadRfm() {
+      $('#seg-rfm-grid').classList.add('loading');
+      $('#seg-rfm-grid').innerHTML = 'Computing RFM…';
+      return api('/rfm').then(renderRfm)
+        .catch(e => { $('#seg-rfm-grid').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
+    }
+    $('#seg-rfm-refresh').onclick = loadRfm;
+
     // ── wire up ──────────────────────────────────────────────────────────────────
     $('#seg-add-rule').onclick = () => addRule({});
     $('#seg-new').onclick = () => loadIntoBuilder(null);
@@ -230,6 +294,7 @@ window.MCC_PANELS['segments'] = {
       SCHEMA = s || SCHEMA;
       loadIntoBuilder(null);
       loadList();
+      loadRfm();
     }).catch(e => setMsg(`Error loading schema: ${e.message}`, true));
   },
 };
diff --git a/public/panels/send-times.html b/public/panels/send-times.html
new file mode 100644
index 0000000..ec756f8
--- /dev/null
+++ b/public/panels/send-times.html
@@ -0,0 +1,64 @@
+<div id="st-banner"></div>
+
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+    <div>
+      <h2>Send-Time Optimization</h2>
+      <div class="muted" style="font-size:12px">
+        Mines historical email-open timestamps to find the best day×hour window per
+        audience. Read-only analysis — does not trigger a send.
+      </div>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <label for="st-segment" style="margin:0">Audience</label>
+      <select id="st-segment" style="min-width:260px;width:auto"></select>
+      <button class="btn ghost" id="st-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+    </div>
+  </div>
+</div>
+
+<div class="grid" style="grid-template-columns:1fr 320px;align-items:start">
+
+  <!-- heatmap -->
+  <div class="card" id="st-heatmap-card">
+    <div class="row" style="justify-content:space-between;align-items:baseline">
+      <div>
+        <h2 id="st-heatmap-title">Open heatmap · day × hour</h2>
+        <div class="muted" id="st-heatmap-sub" style="font-size:12px">Loading…</div>
+      </div>
+      <span class="pill" id="st-total">—</span>
+    </div>
+    <div id="st-heatmap" class="loading" style="margin-top:14px">Loading heatmap…</div>
+    <div class="muted" style="font-size:11px;margin-top:8px">
+      Each cell counts opens by weekday × hour-of-day (UTC). Darker = more opens.
+    </div>
+  </div>
+
+  <!-- recommendation panel -->
+  <div class="card" id="st-rec-card">
+    <h2>Recommended window</h2>
+    <div class="muted" style="font-size:12px">Best send slot for this audience</div>
+    <div id="st-rec" class="loading" style="margin-top:12px">Loading…</div>
+
+    <div style="margin-top:14px">
+      <label>Best hour per weekday</label>
+      <div id="st-perday" class="muted">—</div>
+    </div>
+  </div>
+
+</div>
+
+<!-- per-contact table -->
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline">
+    <div>
+      <h2>Per-contact predicted peak</h2>
+      <div class="muted" style="font-size:12px">
+        Each contact's modal open window across their history. High-confidence
+        rows first (≥35% of opens land in the peak cell).
+      </div>
+    </div>
+    <span class="pill" id="st-contact-count">—</span>
+  </div>
+  <div id="st-contacts" class="loading" style="margin-top:14px">Loading contacts…</div>
+</div>
diff --git a/public/panels/send-times.js b/public/panels/send-times.js
new file mode 100644
index 0000000..0cc5e3f
--- /dev/null
+++ b/public/panels/send-times.js
@@ -0,0 +1,208 @@
+// Send-Time Optimization panel — picks a segment, fetches a day×hour heatmap of
+// historical opens, and renders: (1) the heatmap, (2) the recommended send
+// window pill + backup slots, (3) a per-contact table of predicted peak windows.
+// Always read-only; no send action lives here.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['send-times'] = {
+  init(root) {
+    const $ = s => root.querySelector(s);
+    const api = (p) => fetch(`/api/send-times${p}`).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const fmtHour = h => {
+      if (h == null) return '—';
+      const am = h < 12, h12 = h % 12 || 12;
+      return `${h12}:00 ${am ? 'AM' : 'PM'}`;
+    };
+    const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
+      { year: 'numeric', month: 'short', day: 'numeric' }) : '—';
+
+    let anyMock = false;
+    function banner() {
+      $('#st-banner').innerHTML = anyMock
+        ? `<div class="muted-banner">Running on a <b>mock open-history pool</b> (~200 contacts,
+             12–48 synthetic opens each, persona-shaped distributions). Wire up a CTCT reporting
+             token or GA4 events to mine real open timestamps.</div>`
+        : '';
+    }
+
+    // ── segment picker ─────────────────────────────────────────────────────
+    async function loadSegments() {
+      const data = await api('/segments');
+      if (data.mock) { anyMock = true; banner(); }
+      const sel = $('#st-segment');
+      sel.innerHTML = (data.segments || []).map(s =>
+        `<option value="${esc(s.id)}">${esc(s.name)} (${Number(s.estimatedSize || 0).toLocaleString()})</option>`
+      ).join('');
+      sel.onchange = () => render();
+    }
+
+    // ── heatmap rendering ──────────────────────────────────────────────────
+    function renderHeatmap(data) {
+      const card = $('#st-heatmap');
+      const M = data.matrix || [];
+      const days = data.dayLabels || ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+      // find max for normalization
+      let max = 0;
+      for (const row of M) for (const v of row) if (v > max) max = v;
+      const scale = max || 1;
+
+      // recommended cell highlight
+      const recDay = data.recommendation ? data.recommendation.day : null;
+      const recHour = data.recommendation ? data.recommendation.hour : null;
+
+      // table-based heatmap (7 rows × 24 cols)
+      const hourHeader = Array.from({ length: 24 }, (_, h) =>
+        `<th style="font-weight:500;color:var(--mut);font-size:10px;padding:2px 0;text-align:center">${h}</th>`).join('');
+      const rows = M.map((row, d) => {
+        const cells = row.map((v, h) => {
+          const t = v / scale;
+          // gold gradient over the cream background
+          const alpha = v === 0 ? 0 : 0.10 + 0.85 * t;
+          const isRec = (d === recDay && h === recHour);
+          const ring = isRec ? 'outline:2px solid var(--accent);outline-offset:-2px' : '';
+          return `<td title="${esc(days[d])} ${fmtHour(h)} — ${v} open${v === 1 ? '' : 's'}"
+            style="background:rgba(184,146,90,${alpha.toFixed(3)});width:22px;height:22px;
+                   border:1px solid #f0eadf;padding:0;${ring}"></td>`;
+        }).join('');
+        return `<tr>
+          <th style="font-weight:600;color:var(--ink);font-size:11px;text-align:right;padding-right:8px;width:34px">${esc(days[d])}</th>
+          ${cells}
+        </tr>`;
+      }).join('');
+
+      card.classList.remove('loading');
+      card.innerHTML = `
+        <div style="overflow-x:auto">
+          <table style="border-collapse:collapse;font-size:11px">
+            <thead><tr><th></th>${hourHeader}</tr></thead>
+            <tbody>${rows}</tbody>
+          </table>
+        </div>
+        <div class="row" style="gap:14px;margin-top:10px;align-items:center;font-size:11px;color:var(--mut)">
+          <span>Hour of day (UTC, 0–23)</span>
+          <span class="row" style="gap:4px;align-items:center;margin-left:auto">
+            <span>fewer</span>
+            <span style="width:90px;height:10px;border:1px solid var(--line);border-radius:4px;
+              background:linear-gradient(to right, rgba(184,146,90,0.1), rgba(184,146,90,0.95))"></span>
+            <span>more</span>
+          </span>
+        </div>`;
+    }
+
+    function renderRecommendation(data) {
+      const rec = data.recommendation;
+      const target = $('#st-rec');
+      if (!rec) {
+        target.classList.remove('loading');
+        target.innerHTML = '<div class="muted">No open history in this audience yet.</div>';
+        return;
+      }
+      const backups = (data.backups || []).map(b =>
+        `<li><b>${esc(b.dayLabel)}</b> · ${esc(b.hourLabel)} <span class="muted">(${b.count} opens)</span></li>`
+      ).join('');
+      target.classList.remove('loading');
+      target.innerHTML = `
+        <div style="background:var(--cream);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center">
+          <div style="font:600 28px/1.1 'Cormorant Garamond',Georgia,serif;color:var(--accent)">
+            ${esc(rec.dayLabel)} · ${esc(rec.hourLabel)}
+          </div>
+          <div class="muted" style="font-size:12px;margin-top:4px">${rec.count} opens in this window</div>
+        </div>
+        ${backups ? `<div style="margin-top:10px"><label>Backup slots</label>
+          <ul style="margin:6px 0 0;padding-left:18px;font-size:13px">${backups}</ul></div>` : ''}`;
+    }
+
+    function renderPerDay(data) {
+      const target = $('#st-perday');
+      const rows = (data.bestHourPerDay || []).filter(r => r.count > 0);
+      if (!rows.length) { target.textContent = '—'; return; }
+      target.style.fontSize = '12.5px';
+      target.style.color = 'var(--ink)';
+      target.innerHTML = rows.map(r =>
+        `<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:4px 0">
+          <span style="font-weight:600">${esc(r.dayLabel)}</span>
+          <span>${esc(fmtHour(r.hour))} <span class="muted" style="font-size:11px">(${r.count})</span></span>
+        </div>`).join('');
+    }
+
+    // ── per-contact table ──────────────────────────────────────────────────
+    function renderContacts(data) {
+      const target = $('#st-contacts');
+      $('#st-contact-count').textContent =
+        `${data.returned}/${data.total} contacts`;
+      if (!data.contacts || !data.contacts.length) {
+        target.classList.remove('loading');
+        target.innerHTML = '<div class="muted">No contacts in this audience.</div>';
+        return;
+      }
+      const confColor = c => c === 'high' ? 'var(--sage)' : c === 'med' ? 'var(--gold)' : 'var(--mut)';
+      const rows = data.contacts.map(c => `
+        <tr style="border-top:1px solid var(--line)">
+          <td style="padding:7px 8px;font-size:12.5px"><code style="font-size:12px">${esc(c.email)}</code></td>
+          <td style="padding:7px 8px"><span class="pill" style="background:${c.type === 'trade' ? '#efe7d6' : 'var(--cream)'}">${esc(c.type)}</span></td>
+          <td style="padding:7px 8px;font-size:12.5px">
+            ${c.peak.dayLabel ? `<b>${esc(c.peak.dayLabel)}</b> · ${esc(c.peak.hourLabel)}` : '<span class="muted">insufficient data</span>'}
+          </td>
+          <td style="padding:7px 8px;font-size:11.5px;color:${confColor(c.peak.confidence)};font-weight:600;text-transform:uppercase">
+            ${esc(c.peak.confidence)}${c.peak.share != null ? ` <span class="muted" style="font-weight:400;text-transform:none">(${Math.round(c.peak.share * 100)}%)</span>` : ''}
+          </td>
+          <td style="padding:7px 8px;font-size:12px;text-align:right">${c.peak.samples}</td>
+          <td style="padding:7px 8px;font-size:12px;text-align:right" class="muted">${esc(fmtDate(c.lastOpen))}</td>
+        </tr>`).join('');
+      target.classList.remove('loading');
+      target.innerHTML = `
+        <div style="overflow-x:auto">
+          <table style="border-collapse:collapse;width:100%">
+            <thead>
+              <tr style="text-align:left;font-size:11px;color:var(--mut)">
+                <th style="padding:6px 8px">Email</th>
+                <th style="padding:6px 8px">Type</th>
+                <th style="padding:6px 8px">Predicted peak</th>
+                <th style="padding:6px 8px">Confidence</th>
+                <th style="padding:6px 8px;text-align:right">Opens</th>
+                <th style="padding:6px 8px;text-align:right">Last open</th>
+              </tr>
+            </thead>
+            <tbody>${rows}</tbody>
+          </table>
+        </div>`;
+    }
+
+    // ── orchestration ──────────────────────────────────────────────────────
+    async function render() {
+      const segId = $('#st-segment').value || '__all__';
+      $('#st-heatmap').classList.add('loading');
+      $('#st-heatmap').textContent = 'Loading heatmap…';
+      $('#st-rec').classList.add('loading');
+      $('#st-rec').textContent = 'Loading…';
+      $('#st-contacts').classList.add('loading');
+      $('#st-contacts').textContent = 'Loading contacts…';
+
+      const [heat, cts] = await Promise.all([
+        api(`/heatmap?segmentId=${encodeURIComponent(segId)}`),
+        api(`/contacts?segmentId=${encodeURIComponent(segId)}&limit=50`),
+      ]);
+      if (heat.mock || cts.mock) { anyMock = true; banner(); }
+
+      const segLabel = heat.segment ? heat.segment.name : 'All contacts';
+      $('#st-heatmap-title').textContent = `Open heatmap · ${segLabel}`;
+      $('#st-heatmap-sub').textContent =
+        `${heat.contactCount} contacts · ${heat.contactsWithHistory} with open history · ${heat.totalOpens.toLocaleString()} opens`;
+      $('#st-total').textContent = `${heat.totalOpens.toLocaleString()} opens`;
+
+      renderHeatmap(heat);
+      renderRecommendation(heat);
+      renderPerDay(heat);
+      renderContacts(cts);
+    }
+
+    $('#st-refresh').onclick = render;
+
+    (async () => {
+      await loadSegments();
+      await render();
+    })();
+  },
+};
diff --git a/public/panels/templates.html b/public/panels/templates.html
new file mode 100644
index 0000000..9865cbe
--- /dev/null
+++ b/public/panels/templates.html
@@ -0,0 +1,26 @@
+<div class="card">
+  <h2>Campaign Templates</h2>
+  <p class="muted" style="margin:0 0 14px">Pre-built luxury DW email campaigns in the house voice — Welcome, memo-sample sequence, VIP previews, seasonal editorials, re-engagement. Pick one, fill the fields, copy into Constant Contact.</p>
+
+  <div id="templates-banner"></div>
+
+  <div class="row" style="align-items:center;gap:14px">
+    <div style="flex:1;min-width:240px">
+      <label for="templates-search">Search</label>
+      <input id="templates-search" type="text" placeholder="welcome, memo, VIP, seasonal…" />
+    </div>
+    <div style="min-width:220px">
+      <label for="templates-category">Category</label>
+      <select id="templates-category"></select>
+    </div>
+  </div>
+</div>
+
+<div id="templates-out" class="grid" style="grid-template-columns:repeat(auto-fill, minmax(360px, 1fr))"></div>
+
+<div id="templates-modal" style="display:none;position:fixed;inset:0;background:rgba(20,17,15,0.6);z-index:50;align-items:flex-start;justify-content:center;padding:40px 20px;overflow:auto">
+  <div class="card" style="max-width:760px;width:100%;margin:0;position:relative">
+    <button id="templates-modal-close" class="btn ghost" style="position:absolute;top:14px;right:14px;padding:5px 12px">✕ Close</button>
+    <div id="templates-modal-body"></div>
+  </div>
+</div>
diff --git a/public/panels/templates.js b/public/panels/templates.js
new file mode 100644
index 0000000..7443b9f
--- /dev/null
+++ b/public/panels/templates.js
@@ -0,0 +1,251 @@
+// templates panel — Campaign Templates browser + filler.
+// Loads /api/templates/list, renders card-per-template, opens a modal with a
+// placeholder form on "Use this", POSTs /api/templates/:id/use to get the
+// filled template, and provides copy-to-clipboard for subject and body.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['templates'] = {
+  async init(root) {
+    const $ = s => root.querySelector(s);
+    const $$ = s => Array.from(root.querySelectorAll(s));
+    const esc = s => String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+    const banner    = $('#templates-banner');
+    const out       = $('#templates-out');
+    const search    = $('#templates-search');
+    const catSel    = $('#templates-category');
+    const modal     = $('#templates-modal');
+    const modalBody = $('#templates-modal-body');
+    const modalClose = $('#templates-modal-close');
+
+    let TEMPLATES = [];
+    let META = null;
+
+    // ── load ─────────────────────────────────────────────────────────────────
+    try {
+      const [metaRes, listRes] = await Promise.all([
+        fetch('/api/templates/meta').then(r => r.json()),
+        fetch('/api/templates/list').then(r => r.json()),
+      ]);
+      META = metaRes;
+      TEMPLATES = Array.isArray(listRes.templates) ? listRes.templates : [];
+    } catch (e) {
+      out.innerHTML = '<div class="muted-banner">Could not reach the campaign templates service.</div>';
+      return;
+    }
+
+    if (META && META.gateMessage) {
+      banner.innerHTML = `<div class="muted-banner">📋 STAGED — ${esc(META.gateMessage)}</div>`;
+    }
+
+    // ── category select ─────────────────────────────────────────────────────
+    const cats = ['All categories', ...(META && META.categories ? META.categories : [])];
+    catSel.innerHTML = cats.map((c, i) =>
+      `<option value="${i === 0 ? '' : esc(c)}">${esc(c)}</option>`).join('');
+
+    // ── render ──────────────────────────────────────────────────────────────
+    function visible() {
+      const q = (search.value || '').trim().toLowerCase();
+      const cat = catSel.value;
+      return TEMPLATES.filter(t => {
+        if (cat && t.category !== cat) return false;
+        if (!q) return true;
+        const hay = `${t.title} ${t.category} ${t.description} ${t.subject} ${t.audience} ${(t.productLines || []).join(' ')}`.toLowerCase();
+        return hay.includes(q);
+      });
+    }
+
+    function render() {
+      const list = visible();
+      if (!list.length) {
+        out.innerHTML = '<div class="muted-banner">No templates match that filter.</div>';
+        return;
+      }
+      out.innerHTML = list.map(t => `
+        <div class="card" style="margin-bottom:0;display:flex;flex-direction:column">
+          <div class="row" style="justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
+            <span class="pill">${esc(t.category || '—')}</span>
+            <span class="muted" style="font-size:11px;font-weight:600;letter-spacing:.5px;text-transform:uppercase">${esc(t.sequencePosition || '')}</span>
+          </div>
+          <h3 style="margin:0 0 6px;font:600 16px/1.25 'Cormorant Garamond',Georgia,serif">${esc(t.title)}</h3>
+          <div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:6px">${esc(t.subject)}</div>
+          <div class="muted" style="font-size:12.5px;line-height:1.5;margin-bottom:12px;flex:1">${esc(t.description)}</div>
+          <div class="row" style="gap:6px;margin-bottom:12px;flex-wrap:wrap">
+            ${esc(t.audience) ? `<span class="pill" style="background:#efece5;color:var(--ink)">${esc(t.audience)}</span>` : ''}
+            ${(t.productLines || []).slice(0, 3).map(p => `<span class="pill" style="background:#f4efe7;color:var(--accent)">${esc(p)}</span>`).join('')}
+          </div>
+          <div class="row" style="justify-content:flex-end;gap:8px;margin-top:auto">
+            <button class="btn ghost preview-btn" data-id="${esc(t.id)}" style="padding:7px 14px">👁 Preview</button>
+            <button class="btn gold use-btn" data-id="${esc(t.id)}" style="padding:7px 14px">📋 Use this</button>
+          </div>
+        </div>`).join('');
+
+      out.querySelectorAll('.preview-btn').forEach(b =>
+        b.addEventListener('click', () => openModal(b.dataset.id, 'preview')));
+      out.querySelectorAll('.use-btn').forEach(b =>
+        b.addEventListener('click', () => openModal(b.dataset.id, 'use')));
+    }
+
+    search.addEventListener('input', render);
+    catSel.addEventListener('change', render);
+    render();
+
+    // ── modal ────────────────────────────────────────────────────────────────
+    function showModal() { modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
+    function hideModal() { modal.style.display = 'none'; modalBody.innerHTML = ''; document.body.style.overflow = ''; }
+    modalClose.addEventListener('click', hideModal);
+    modal.addEventListener('click', (e) => { if (e.target === modal) hideModal(); });
+
+    function openModal(id, mode) {
+      const tpl = TEMPLATES.find(t => t.id === id);
+      if (!tpl) return;
+      if (mode === 'preview') renderPreview(tpl);
+      else renderUseForm(tpl);
+      showModal();
+    }
+
+    function renderPreview(tpl) {
+      modalBody.innerHTML = `
+        <span class="pill">${esc(tpl.category)}</span>
+        <h2 style="margin:10px 0 4px">${esc(tpl.title)}</h2>
+        <div class="muted" style="font-size:12.5px;margin-bottom:14px">${esc(tpl.description)}</div>
+
+        <label>Subject (template)</label>
+        <div style="background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-weight:600;margin-bottom:14px">${esc(tpl.subject)}</div>
+
+        ${tpl.preheader ? `
+          <label>Preheader (template)</label>
+          <div style="background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:14px;font-size:13px;color:var(--mut)">${esc(tpl.preheader)}</div>
+        ` : ''}
+
+        <label>Body (template — with {{placeholders}})</label>
+        <pre style="white-space:pre-wrap;font:13px/1.6 'Inter',sans-serif;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 14px;color:var(--ink)">${esc(tpl.body)}</pre>
+
+        <div class="muted-banner" style="margin:0 0 14px">⚠ Add CAN-SPAM footer (physical mailing address + working unsubscribe) before this template is queued in Constant Contact.</div>
+
+        <div class="row" style="justify-content:flex-end">
+          <button class="btn gold" id="from-preview-use" style="padding:8px 16px">📋 Use this template</button>
+        </div>`;
+      modalBody.querySelector('#from-preview-use').addEventListener('click', () => renderUseForm(tpl));
+    }
+
+    function renderUseForm(tpl) {
+      const placeholders = tpl.placeholders || [];
+      modalBody.innerHTML = `
+        <span class="pill">${esc(tpl.category)}</span>
+        <h2 style="margin:10px 0 4px">${esc(tpl.title)} — fill in</h2>
+        <div class="muted" style="font-size:12.5px;margin-bottom:14px">Fill the fields and click <b>Generate draft</b>. STAGED only — nothing sends.</div>
+
+        ${placeholders.length === 0
+          ? '<div class="muted-banner" style="margin-bottom:14px">This template has no placeholders — it\'s ready to copy as-is.</div>'
+          : `<div class="grid" style="grid-template-columns:repeat(2,1fr);margin-bottom:16px">
+              ${placeholders.map(p => `
+                <div>
+                  <label for="ph-${esc(p.key)}">${esc(p.label || p.key)}</label>
+                  <input id="ph-${esc(p.key)}" data-key="${esc(p.key)}" type="text" placeholder="${esc(p.default || '')}" value="" />
+                </div>`).join('')}
+            </div>`}
+
+        <div class="row" style="justify-content:flex-end;gap:8px;margin-bottom:14px">
+          <button class="btn ghost" id="use-cancel" style="padding:7px 14px">Cancel</button>
+          <button class="btn gold" id="use-generate" style="padding:7px 14px">📋 Generate draft</button>
+        </div>
+
+        <div id="use-result"></div>`;
+
+      modalBody.querySelector('#use-cancel').addEventListener('click', hideModal);
+      modalBody.querySelector('#use-generate').addEventListener('click', async () => {
+        const fields = {};
+        modalBody.querySelectorAll('input[data-key]').forEach(el => {
+          fields[el.dataset.key] = el.value;
+        });
+        const btn = modalBody.querySelector('#use-generate');
+        btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
+        try {
+          const r = await fetch(`/api/templates/${encodeURIComponent(tpl.id)}/use`, {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ fields }),
+          });
+          const data = await r.json();
+          renderUseResult(tpl, data);
+        } catch (e) {
+          modalBody.querySelector('#use-result').innerHTML =
+            '<div class="muted-banner">Could not generate the draft. Try again.</div>';
+        } finally {
+          btn.disabled = false; btn.textContent = orig;
+        }
+      });
+    }
+
+    function renderUseResult(tpl, data) {
+      const target = modalBody.querySelector('#use-result');
+      if (!target) return;
+      const unfilledNote = (data.unfilled && data.unfilled.length)
+        ? `<div class="muted-banner" style="margin:14px 0 0">Still unfilled: ${data.unfilled.map(esc).join(', ')} — the placeholders are kept in the draft so you can finish them in Constant Contact.</div>`
+        : '';
+      target.innerHTML = `
+        <div style="margin-top:8px;padding-top:14px;border-top:1px solid var(--line)">
+          <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:8px">
+            <h3 style="margin:0;font:600 15px/1.2 'Cormorant Garamond',Georgia,serif">Filled draft (STAGED)</h3>
+            <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · no send</span>
+          </div>
+
+          <label>Subject</label>
+          <div class="row" style="align-items:center;gap:8px;margin-bottom:14px">
+            <div style="flex:1;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-weight:600">${esc(data.subject)}</div>
+            <button class="btn ghost copy-subject" style="padding:7px 12px">📋 Copy</button>
+          </div>
+
+          ${data.preheader ? `
+            <label>Preheader</label>
+            <div class="row" style="align-items:center;gap:8px;margin-bottom:14px">
+              <div style="flex:1;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:13px;color:var(--mut)">${esc(data.preheader)}</div>
+              <button class="btn ghost copy-preheader" style="padding:7px 12px">📋 Copy</button>
+            </div>` : ''}
+
+          <label>Body</label>
+          <pre style="white-space:pre-wrap;font:13px/1.6 'Inter',sans-serif;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 8px;max-height:400px;overflow:auto">${esc(data.body)}</pre>
+          <div class="row" style="justify-content:flex-end;gap:8px;margin-bottom:14px">
+            <button class="btn ghost copy-body" style="padding:7px 12px">📋 Copy body</button>
+            <button class="btn ghost copy-all" style="padding:7px 12px">📋 Copy subject + body</button>
+          </div>
+
+          <div class="muted-banner" style="margin:0">⚠ Before this ships in Constant Contact: ${esc(data.complianceReminder || 'add CAN-SPAM footer (physical mailing address + working unsubscribe).')}</div>
+          ${unfilledNote}
+        </div>`;
+
+      const setCopier = (sel, text) => {
+        const b = target.querySelector(sel);
+        if (!b) return;
+        b.addEventListener('click', async () => {
+          const ok = await copyText(text);
+          const prev = b.textContent;
+          b.textContent = ok ? '✓ Copied' : 'Copy failed';
+          setTimeout(() => { b.textContent = prev; }, 1400);
+        });
+      };
+      setCopier('.copy-subject', data.subject || '');
+      setCopier('.copy-preheader', data.preheader || '');
+      setCopier('.copy-body', data.body || '');
+      setCopier('.copy-all', `Subject: ${data.subject || ''}\n\n${data.body || ''}`);
+    }
+
+    async function copyText(text) {
+      try {
+        if (navigator.clipboard && navigator.clipboard.writeText) {
+          await navigator.clipboard.writeText(text);
+          return true;
+        }
+      } catch { /* fall through to legacy */ }
+      try {
+        const ta = document.createElement('textarea');
+        ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
+        document.body.appendChild(ta); ta.select();
+        const ok = document.execCommand('copy');
+        document.body.removeChild(ta);
+        return ok;
+      } catch { return false; }
+    }
+  },
+};

← 57a572a vendors module: prefer headless-verified follower counts + v  ·  back to Marketing Command Center  ·  social: Ayrshare live-publish + scheduler scaffold (gated, o cfea2d7 →