diff --git a/site/src/components/ShareCardMock.astro b/site/src/components/ShareCardMock.astro
new file mode 100644
index 00000000..dac07ac5
--- /dev/null
+++ b/site/src/components/ShareCardMock.astro
@@ -0,0 +1,157 @@
+---
+// Pixel-perfect static recreation of the in-app "Share image" card
+// (web/src/components/app/analytics/StatsShareCard.tsx, 1:1 preset).
+//
+// Every dimension is expressed in container-query width units (cqw), mapped
+// 1:1 from the real 1080px design (px / 1080 * 100), so the card stays exact at
+// any rendered width and on any device. No JS, no fixed pixels.
+interface Props {
+ title?: string;
+ subtitle?: string;
+ date?: string;
+ metrics?: { label: string; value: string; sub?: string }[];
+}
+const {
+ title = 'Workspace performance',
+ subtitle = 'Last 7 days',
+ date = 'Jun 9, 2026',
+ metrics = [
+ { label: 'Sent', value: '12,847', sub: 'emails' },
+ { label: 'Open rate', value: '34.2%' },
+ { label: 'Reply rate', value: '6.1%' },
+ { label: 'Bounce rate', value: '1.8%' },
+ ],
+} = Astro.props;
+
+// Area-chart geometry — identical math to ShareAreaChart in the app.
+const vals = [22, 30, 24, 38, 33, 45, 40, 29, 48, 55, 50, 58, 64, 60, 69, 76, 72, 66, 79, 86, 81, 74, 88, 94, 90, 83, 96, 100];
+const W = 1000, H = 320, padX = 4, padTop = 14;
+const baseY = H - 6;
+const max = Math.max(1, ...vals);
+const w = W - padX * 2, h = baseY - padTop;
+const step = w / (vals.length - 1);
+const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as [number, number]);
+const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
+const lastX = padX + (vals.length - 1) * step;
+const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`;
+const cols = metrics.length || 1;
+---
+
+
+
+
+
+
+
+
+
+
+
+ Warmbly
+
+ {date}
+
+
+
+
+
+
{subtitle}
+
{title}
+
+
+ {metrics.map((m) => (
+
+
{m.label}
+
{m.value}
+ {m.sub &&
{m.sub}
}
+
+ ))}
+
+
+
+
+
Sends over time
+
+
+
+
+
+ May 13
+ Jun 9
+
+
+
+
+
+
+
+ warmbly.com
+ Cold email, warmed up.
+
+
+
+
+
diff --git a/site/src/pages/analytics.astro b/site/src/pages/analytics.astro
index 6daa5b4e..fc9f7938 100644
--- a/site/src/pages/analytics.astro
+++ b/site/src/pages/analytics.astro
@@ -1,66 +1,161 @@
---
import Layout from '../layouts/Layout.astro';
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
-import Icon from '../components/Icon.astro';
import CTA from '../components/CTA.astro';
+import ShareCardMock from '../components/ShareCardMock.astro';
-// Source: CLAUDE.md analytics + admin sections, internal/repository/*
+// Source anchors for everything claimed on this page:
+// web/src/app/app/analytics/page.tsx (the real dashboard this mirrors)
+// realtime/lib/realtime/* (the websocket fanout)
+// tracking/src/handlers.rs (open/click capture + dedupe)
+// internal/app/consumer/* (reply classify, deliverability)
+// internal/app/advanced/service.go (suppression)
+// internal/repository/pg_* (the aggregates the dashboard reads)
-// What we lead with vs what we treat as noise
+// =========================================================================
+// What we lead with vs what we treat as noise.
+// =========================================================================
const signal = [
- { k: 'Inbox placement rate', why: 'Probed via warmup pool partners. The only number that tells you mail is actually being read.' },
- { k: 'Complaint rate', why: 'ARF feedback loops + Gmail Postmaster Tools, aggregated per mailbox. Hard ceiling at 0.10%.' },
- { k: 'Bounce rate', why: 'Hard vs soft split per mailbox. SES enforcement begins at 5%, suspension at 10%.' },
- { k: 'Reply rate', why: 'Per sequence, per step, per variant. The metric a cold-mail program is actually optimising for.' },
- { k: 'Positive-reply rate', why: 'Replies classified as positive (vs OOO, negative, unsubscribe). Strips noise from reply rate.' },
- { k: 'Suppression growth', why: 'Share of contacted recipients added to suppression per period. Spikes are a leading indicator.' },
+ { k: 'Inbox placement rate', why: 'Probed through warmup pool partners. The only number that says mail is actually being read.' },
+ { k: 'Reply rate', why: 'Per sequence, per step, per variant. The metric a cold program is genuinely optimising for.' },
+ { k: 'Positive-reply rate', why: 'Replies classified positive, stripped of OOO, negative, and opt-out. The real one.' },
+ { k: 'Complaint rate', why: 'FBL and ARF loops plus Postmaster, per mailbox. Hard ceiling at 0.10%.' },
+ { k: 'Bounce rate', why: 'Hard and soft, split per mailbox. SES review at 5%, pause at 10%.' },
+ { k: 'Suppression growth', why: 'Share of contacted recipients suppressed per period. A spike leads the bad news.' },
];
const noise = [
- { k: 'Open rate', why: 'Apple Mail Privacy proxies opens for ~25-45% of B2B inboxes. Gmail caches images. The number is noise dominated.' },
- { k: 'Cold first-touch CTR', why: 'Most well-written cold mail has no links. Optimising for clicks incentivises link-heavy content, which is itself a spam-folder signal.' },
- { k: '"Deliverability score"', why: 'From vendors who will not publish the formula. Unauditable and gameable.' },
+ { k: 'Open rate', why: 'Apple Mail Privacy prefetches pixels for a big share of B2B inboxes, so the number reads near 100% within minutes. We show it, we never decide on it.' },
+ { k: 'First-touch click rate', why: 'Good cold mail has no links. Optimising for clicks pushes you toward link-heavy mail, which is itself a spam signal.' },
+ { k: '"Deliverability score"', why: 'A single vendor number with an unpublished formula. Unauditable and easy to game.' },
];
-// Real export surfaces
+// =========================================================================
+// Drill path. Every workspace KPI resolves down to one recipient event.
+// =========================================================================
+const drill = [
+ { k: 'Workspace', v: 'one number', d: 'The KPI strip: sent, reply rate, bounce rate, placement.' },
+ { k: 'Campaign', v: 'top performers', d: 'Which campaigns carry the result, ranked by reply rate.' },
+ { k: 'Sequence', v: 'per step', d: 'Step one against the follow-ups. Where replies actually come from.' },
+ { k: 'Variant', v: 'A / B', d: 'Subject and body variants, scored on positive replies.' },
+ { k: 'Mailbox', v: 'per sender', d: 'The same metrics per mailbox, pool, and worker IP.' },
+ { k: 'Recipient', v: 'one event', d: 'The exact open, reply, or bounce behind the number.' },
+];
+
+// =========================================================================
+// What updates live, over the websocket fanout.
+// =========================================================================
+const live = [
+ { k: 'Counts and KPIs', d: 'Sent, replies, and bounce rate tick up the moment events arrive.' },
+ { k: 'Lists', d: 'Campaign and mailbox tables reorder themselves without a refresh.' },
+ { k: 'Detail panes', d: 'An open drawer keeps updating while you are reading it.' },
+ { k: 'Activity feed', d: 'New opens, clicks, and replies appear at the top instantly.' },
+ { k: 'Account health', d: 'A mailbox slipping into watch shows the second it crosses.' },
+];
+
+// =========================================================================
+// Exactly what each headline number is, and where it comes from.
+// =========================================================================
+const definitions = [
+ { k: 'Reply rate', f: 'replies ÷ delivered', src: 'consumer.reply' },
+ { k: 'Positive reply', f: 'positive ÷ delivered', src: 'consumer.reply.classifier' },
+ { k: 'Inbox placement', f: 'inbox ÷ (inbox + spam + promo)', src: 'warmup pool probe' },
+ { k: 'Complaint rate', f: 'complaints ÷ delivered', src: 'consumer.deliverability' },
+ { k: 'Bounce rate', f: '(hard + soft) ÷ sent', src: 'consumer.deliverability' },
+ { k: 'Open rate', f: 'opens ÷ delivered', src: 'tracking.handlers' },
+];
+
+// =========================================================================
+// Real export surfaces.
+// =========================================================================
const exports = [
- { k: 'On-demand download', v: 'CSV · XLSX · JSON', why: 'Pick a report, choose fields, download. No row caps.' },
- { k: 'Scheduled email', v: 'Daily · weekly', why: 'Workspace-level summary or a specific saved view to your inbox.' },
- { k: 'Webhooks', v: 'HMAC-signed', why: 'Per-event push for replies, bounces, complaints, suppression and band transitions.' },
- { k: 'REST API', v: 'GET / events', why: 'Pull events into your warehouse. Idempotency keys + rate limits surfaced in headers.' },
- { k: 'S3 push', v: 'Hourly · gzipped', why: 'Newline-delimited JSON to your bucket. Schemas versioned in the event payload.' },
+ { k: 'On-demand download', v: 'CSV · XLSX · JSON', why: 'Pick a report, choose fields, download. No row caps.' },
+ { k: 'Scheduled email', v: 'Daily · weekly', why: 'A workspace summary or a saved view, dropped in your inbox.' },
+ { k: 'Webhooks', v: 'HMAC-signed', why: 'Per-event push for replies, bounces, complaints, suppression, and band changes.' },
+ { k: 'REST API', v: 'GET / events', why: 'Pull events into your warehouse. Idempotency keys and rate limits in the headers.' },
+ { k: 'S3 push', v: 'Hourly · gzipped', why: 'Newline-delimited JSON to your bucket. Schema versioned in the payload.' },
];
const faq = [
- ['Why no open rate as a headline number?', 'Apple Mail Privacy Protection prefetches all tracking pixels through Apple proxies for a large share of B2B recipients. The reported open rate is functionally 100% for those mailboxes within minutes of delivery. We surface opens but never use them for deliverability decisions or ranking.'],
- ['Where does placement data come from?', 'Warmbly\'s warmup pool double-serves as a placement probe. When a partner mailbox receives a warmup message, it reports the folder (Inbox / Promotions / Spam) along with a verification token. That feeds the placement signal for the sending mailbox.'],
- ['How long is data retained?', 'Per-event records (sends, replies, opens, bounces) for 18 months by default. Aggregates retained indefinitely. Custom retention available on Enterprise.'],
- ['Can I export raw events to my warehouse?', 'Yes. Webhooks for real-time push, S3 hourly drops for batched ingestion, or pull through the events API with cursor pagination. Same event schema across all three.'],
+ ['Why is open rate not a headline number?',
+ 'Apple Mail Privacy Protection prefetches every tracking pixel through Apple proxies for a large share of B2B recipients, so the reported open rate is effectively 100% for those mailboxes within minutes of delivery. We still surface opens, but we never use them for deliverability decisions or ranking.'],
+ ['Where does placement data come from?',
+ 'The warmup pool double-serves as a placement probe. When a partner mailbox receives a warmup message, it reports the folder it landed in (Inbox, Promotions, or Spam) along with a signed verification token. That feeds the placement signal for the sending mailbox.'],
+ ['Is everything really realtime?',
+ 'Yes. Opens, clicks, replies, and bounces stream into the dashboard over a websocket as the consumer processes them, so counts, lists, and the activity feed update without a refresh. Exports and rollups run on top of the same events.'],
+ ['How long is data retained?',
+ 'Per-event records (sends, replies, opens, bounces) for 18 months by default, aggregates indefinitely. Custom retention is available on Enterprise.'],
+ ['Can I get the raw events into my warehouse?',
+ 'Yes. Webhooks for realtime push, hourly gzipped S3 drops for batch ingestion, or pull through the events API with cursor pagination. The same event schema is used across all three.'],
];
---
-
+
+
+
+
+
+
+
+
+
+ positive reply
+ +1
+
+
+
+
+
+
+
+
+ inbox placement
+ 94%
+
+
+
+
+
+
+
+
+ open rate
+ noise
+
+
+
+
+
+
+
+
+ bounced, suppressed
+
+
+
+
+
Analytics
- Numbers we trust. Not vanity opens.
+ Signal, not vanity opens.
- Numbers we trust. Not vanity opens.
+ Numbers you can actually act on.
- Placement, complaints, bounces, reply rate, positive-reply rate, suppression growth. Per mailbox, per pool, per worker, per sequence. Drill from a workspace KPI to the exact recipient behind it.
+ Placement, reply and positive-reply rate, complaints, bounces, suppression. Every number drills from a workspace KPI to the exact recipient behind it, and the whole dashboard updates live.
- We lead with six metrics. Three we ignore.
+ Six numbers we lead with. Three we ignore.
- A metric is a signal only if it is reliable and acts on. Open rate fails both tests today. Click rate on cold first-touch fails the second. Vendor "scores" fail the first.
+ A metric earns the headline only if it is reliable and you can act on it. Open rate fails the first test today. First-touch click rate fails the second. Vendor "scores" fail both.
@@ -232,7 +205,7 @@ const faq = [
{signal.map((s) => (
-
+
{s.k}
{s.why}
@@ -262,11 +235,182 @@ const faq = [
+
+
+
+
+
+
Share image
+
+ Post your numbers without a screenshot.
+
+
+ One tap turns the workspace view into a branded image: your headline metrics and the sends trend on a clean Warmbly sky card. Pick an aspect, preview it exactly as it will export, then copy or download a crisp PNG.
+
+
+ {[
+ { k: 'Pick an aspect', d: '1:1 for social, 3:2 for slides, 16:9 for a banner.' },
+ { k: 'Preview live', d: 'The card renders exactly as it exports, every metric in place.' },
+ { k: 'Copy or download', d: 'Straight to the clipboard, or a high-resolution PNG to disk.' },
+ ].map((s, i) => (
+
+ {String(i + 1).padStart(2, '0')}
+
+
{s.k}
+
{s.d}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ 1:1
+ 3:2
+ 16:9
+
+
+
+
+ Copy
+
+
+
+ Download PNG
+
+
+
+
+
+
+
-
+
+
+
+
Drill down
+
+ Every number opens up.
+
+
+ A KPI you cannot explain is a KPI you cannot fix. Each headline number unfolds one level at a time, from the workspace strip all the way to the single recipient event behind it. No dead ends.
+
+
+
+
+
+
+ {drill.map((r, i) => (
+
+
+
+ {String(i + 1).padStart(2, '0')}
+
+
+
+
+
{r.k}
+
{r.v}
+
+
{r.d}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
Realtime
+
+ It updates while you watch.
+
+
+ Events fan out over a websocket the moment the consumer processes them, so the dashboard stays current on its own. No refresh button, no five-minute polling lag. It feels closer to a chat app than a report.
+
+
+
+
+
+
+ Live by default
+
+
+
+
+ {live.map((l) => (
+
+
+
+
{l.k}
+
{l.d}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
Definitions
+
+ No mystery math.
+
+
+ Every metric is a plain ratio over a denominator we name, computed in a codepath you can point at. Nothing is smoothed, weighted, or hidden behind a single blended "score".
+
+
+
+
+ {definitions.map((d, i) => (
+
+
+
+ {String(i + 1).padStart(2, '0')} · {d.k}
+
+
{d.src}
+
+
+
{d.f}
+
+
+ ))}
+
+
+
+ Mirrors the real dashboard: web/src/app/app/analytics/page.tsx
+
+
+
+
+
+
Exports
@@ -274,13 +418,13 @@ const faq = [
Your data is yours.
- Five ways to get the data out. Same event schema across all of them, versioned in the payload.
+ Five ways to get the data out, same event schema across all of them, versioned in the payload.