site(warmup): calendar-grid ramp + smooth FAQ animation

- Replaced the line chart + bar chart with a 5-week x 7-day calendar
  grid. Each cell shows the day label, the warmup volume that day,
  and is shaded by intensity (sky-1 to sky-8). Weekends are skipped
  visibly. Side panel: 4 stat tiles (Day 1 send, daily ramp, ceiling
  reached at Day 30, ~550 total sends).
- FAQ converted from native <details> to the smooth grid-template-rows
  animation pattern used on pricing: click expands panel from 0fr to 1fr
  over 300ms, plus icon rotates 0->45deg.
This commit is contained in:
Matthew Meszaros
2026-05-26 15:06:12 +00:00
parent f4eaf64272
commit b278826d18
+122 -156
View File
@@ -86,180 +86,124 @@ const faq = [
</section>
<!-- ============================================================
THE RAMP · chart + week breakdown + day-by-day data
THE RAMP · 30-day calendar grid (GitHub-style intensity)
============================================================ -->
<section class="relative -mt-32 md:-mt-40 pb-16 md:pb-24 z-10">
<div class="container-page">
<div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_30px_60px_-20px_rgba(2,32,71,0.25),0_8px_24px_-10px_rgba(15,23,42,0.06)]">
<!-- Header -->
<div class="px-7 py-5 border-b border-[color:var(--border)] grid md:grid-cols-[1fr_auto] gap-3 items-baseline">
<div class="px-7 py-5 border-b border-[color:var(--border)] flex flex-wrap items-baseline justify-between gap-3">
<div>
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-[#0284c7]">The ramp · 30 days</div>
<div class="mt-1 text-[18px] font-semibold tracking-[-0.01em] text-heading">One mailbox, day 1 to ceiling.</div>
<p class="mt-2 text-[13px] text-foreground/65 max-w-2xl">
Each day the mailbox sends one more warmup message than yesterday. After 30 days at +1/day from a 10/day floor, it reaches the 40/day ceiling and holds. Total: 775 warmup sends in 30 days.
</p>
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-[#0284c7]">First 30 days · one mailbox</div>
<div class="mt-1 text-[18px] font-semibold tracking-[-0.01em] text-heading">Day 1 sends 10. Day 30 sends 40. Every day in between is +1.</div>
</div>
<div class="flex items-center gap-5 text-[11.5px] font-mono text-muted-foreground">
<span class="inline-flex items-center gap-1.5"><span class="w-2.5 h-[2px] bg-[#0284c7]"></span>volume / day</span>
<span class="inline-flex items-center gap-1.5"><span class="w-2.5 h-[2px] bg-foreground/30" style="border-top:1px dashed"></span>ceiling</span>
<!-- Intensity legend -->
<div class="flex items-center gap-2 text-[10.5px] font-mono text-muted-foreground">
<span>Less</span>
<div class="flex gap-0.5">
<span class="w-4 h-4 rounded-[3px]" style="background:#e0f2fe"></span>
<span class="w-4 h-4 rounded-[3px]" style="background:#7dd3fc"></span>
<span class="w-4 h-4 rounded-[3px]" style="background:#38bdf8"></span>
<span class="w-4 h-4 rounded-[3px]" style="background:#0284c7"></span>
<span class="w-4 h-4 rounded-[3px]" style="background:#075985"></span>
</div>
<span>More</span>
</div>
</div>
<!-- Two-column body: chart left, week breakdown right -->
<div class="grid lg:grid-cols-[1.6fr_1fr]">
<!-- LEFT: bigger chart with milestones -->
<div class="px-7 pt-6 pb-5 border-b lg:border-b-0 lg:border-r border-[color:var(--border)]">
<svg viewBox="0 0 720 340" class="w-full h-[340px]" preserveAspectRatio="none" aria-label="Ramp curve from 10/day to 40/day over 30 days">
<defs>
<linearGradient id="rampFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0284c7" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#0284c7" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- 30-day calendar grid -->
<div class="px-7 py-8">
<div class="grid lg:grid-cols-[1fr_auto] gap-10 items-start">
<!-- LEFT: 5 weeks × 7 days grid (with weekend skipping illustrated by greying out) -->
<div>
<!-- Day-of-week column headers -->
<div class="grid grid-cols-[40px_repeat(7,minmax(0,1fr))] gap-1.5 mb-1.5">
<div></div>
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => (
<div class={`text-[10px] font-mono uppercase tracking-[0.14em] text-center pb-1 ${i >= 5 ? 'text-muted-foreground/40' : 'text-muted-foreground/70'}`}>{d}</div>
))}
</div>
<!-- Horizontal grid + y labels -->
{[10, 20, 30, 40].map((v) => (
<g>
<line x1="40" x2="700" y1={300 - (v / 50) * 280} y2={300 - (v / 50) * 280} stroke="#e6ebf2" stroke-width="1"/>
<text x="32" y={300 - (v / 50) * 280 + 4} text-anchor="end" fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">{v}</text>
</g>
))}
<!-- 5 weeks, each row -->
{[0, 1, 2, 3, 4].map((wk) => (
<div class="grid grid-cols-[40px_repeat(7,minmax(0,1fr))] gap-1.5 mb-1.5">
<div class="text-[10.5px] font-mono text-muted-foreground/70 flex items-center">W{wk + 1}</div>
{[0, 1, 2, 3, 4, 5, 6].map((dow) => {
const calDay = wk * 7 + dow + 1; // 1..35
const isWeekend = dow >= 5;
// Map calendar day → business day (skip weekends), business day → volume
const bizDayCount = Array.from({ length: calDay }, (_, i) => i).filter((d) => {
const dDow = d % 7;
return dDow < 5;
}).length;
const volume = Math.min(9 + bizDayCount, 40);
const inactive = calDay > 30 || isWeekend;
<!-- Week dividers (vertical) -->
{[7, 14, 21].map((d) => (
<line x1={40 + (d / 30) * 660} x2={40 + (d / 30) * 660} y1="20" y2="300" stroke="#e6ebf2" stroke-width="1" stroke-dasharray="2 4"/>
))}
// Color tier
let bg = '#f1f5f9';
let textColor = '#94a3b8';
if (!inactive) {
if (volume >= 40) { bg = '#075985'; textColor = '#ffffff'; }
else if (volume >= 30) { bg = '#0284c7'; textColor = '#ffffff'; }
else if (volume >= 20) { bg = '#38bdf8'; textColor = '#0c4a6e'; }
else if (volume >= 15) { bg = '#7dd3fc'; textColor = '#0c4a6e'; }
else { bg = '#e0f2fe'; textColor = '#075985'; }
} else if (isWeekend && calDay <= 30) {
bg = '#f8fafc';
textColor = '#cbd5e1';
}
<!-- Ceiling line (dashed) -->
<line x1="40" x2="700" y1={300 - (40 / 50) * 280} y2={300 - (40 / 50) * 280} stroke="#0c4a6e" stroke-width="1" stroke-dasharray="4 4" opacity="0.4"/>
<text x="700" y={300 - (40 / 50) * 280 - 6} text-anchor="end" fill="#0c4a6e" font-size="10" font-family="ui-monospace,monospace" opacity="0.6">ceiling · 40 / day</text>
<!-- Ramp polygon + line -->
<polygon
points={'40,300 ' + Array.from({ length: 31 }, (_, day) => {
const v = Math.min(10 + day, 40);
const x = 40 + (day / 30) * 660;
const y = 300 - (v / 50) * 280;
return x + ',' + y;
}).join(' ') + ' 700,300'}
fill="url(#rampFill)"
/>
<polyline
points={Array.from({ length: 31 }, (_, day) => {
const v = Math.min(10 + day, 40);
const x = 40 + (day / 30) * 660;
const y = 300 - (v / 50) * 280;
return x + ',' + y;
}).join(' ')}
fill="none"
stroke="#0284c7"
stroke-width="2"
/>
<!-- Daily dots -->
{Array.from({ length: 31 }, (_, day) => {
const v = Math.min(10 + day, 40);
const x = 40 + (day / 30) * 660;
const y = 300 - (v / 50) * 280;
return <circle cx={x} cy={y} r="2" fill="#0284c7"/>;
})}
<!-- Milestone markers -->
<g>
<circle cx="40" cy={300 - (10 / 50) * 280} r="5" fill="white" stroke="#0284c7" stroke-width="2"/>
<text x="50" y={300 - (10 / 50) * 280 - 8} fill="#0369a1" font-size="11" font-family="ui-monospace,monospace" font-weight="600">Day 1 · 10/d</text>
</g>
<g>
<circle cx={40 + (7 / 30) * 660} cy={300 - (17 / 50) * 280} r="5" fill="white" stroke="#0284c7" stroke-width="2"/>
<text x={40 + (7 / 30) * 660 + 8} y={300 - (17 / 50) * 280 - 8} fill="#0369a1" font-size="11" font-family="ui-monospace,monospace" font-weight="600">Wk 1 · 17/d</text>
</g>
<g>
<circle cx={40 + (30 / 30) * 660} cy={300 - (40 / 50) * 280} r="5" fill="white" stroke="#0284c7" stroke-width="2"/>
<text x={40 + (30 / 30) * 660 - 8} y={300 - (40 / 50) * 280 - 8} text-anchor="end" fill="#0369a1" font-size="11" font-family="ui-monospace,monospace" font-weight="600">Day 30 · 40/d</text>
</g>
<!-- X-axis labels -->
{[0, 7, 14, 21, 30].map((d) => (
<text x={40 + (d / 30) * 660} y="320" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">D{d}</text>
))}
</svg>
</div>
<!-- RIGHT: week-by-week breakdown -->
<div class="px-7 py-6">
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground mb-4">Week by week</div>
<div class="space-y-px">
{[
{ wk: 'Week 1', range: 'D1D7', start: 10, end: 16, total: 91, status: 'Initial ramp' },
{ wk: 'Week 2', range: 'D8D14', start: 17, end: 23, total: 140, status: 'Building reputation' },
{ wk: 'Week 3', range: 'D15D21', start: 24, end: 30, total: 189, status: 'Provider learning' },
{ wk: 'Week 4', range: 'D22D28', start: 31, end: 37, total: 238, status: 'Approaching ceiling' },
{ wk: 'Week 5', range: 'D29D30', start: 38, end: 40, total: 117, status: 'At ceiling', featured: true },
].map((w) => (
<div class={`grid grid-cols-[auto_1fr_auto] gap-3 px-3 py-2.5 rounded-[6px] items-baseline ${w.featured ? 'bg-[color:var(--sky-1)]/60' : 'hover:bg-[color:var(--surface-1)]'}`}>
<div>
<div class={`text-[12.5px] font-semibold ${w.featured ? 'text-[#0369a1]' : 'text-heading'}`}>{w.wk}</div>
<div class="text-[10.5px] font-mono text-muted-foreground mt-0.5">{w.range}</div>
</div>
<div class="text-[11.5px] font-mono text-foreground/65 text-center">
{w.start}<span class="text-muted-foreground/60 mx-0.5">→</span>{w.end}<span class="text-muted-foreground/55"> /d</span>
</div>
<div class="text-right">
<div class={`text-[14px] font-semibold font-mono ${w.featured ? 'text-[#0369a1]' : 'text-heading'}`}>{w.total}</div>
<div class="text-[10px] font-mono text-muted-foreground">total sends</div>
</div>
return (
<div class="relative aspect-square">
<div class="absolute inset-0 rounded-[6px] flex flex-col items-center justify-center" style={`background:${bg}`}>
{calDay <= 30 ? (
<>
<div class="text-[9px] font-mono uppercase tracking-[0.1em]" style={`color:${textColor};opacity:0.7`}>D{calDay}</div>
<div class="text-[14px] font-semibold font-mono" style={`color:${textColor}`}>
{isWeekend ? '—' : volume}
</div>
</>
) : null}
</div>
</div>
);
})}
</div>
))}
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1 text-[11px] font-mono text-muted-foreground">
<span class="inline-flex items-center gap-1.5"><span class="w-3 h-3 rounded-sm" style="background:#f8fafc"></span>Weekend · no send</span>
<span class="inline-flex items-center gap-1.5"><span class="w-3 h-3 rounded-sm" style="background:#0284c7"></span>Active warmup day</span>
<span class="inline-flex items-center gap-1.5"><span class="w-3 h-3 rounded-sm" style="background:#075985"></span>At ceiling</span>
</div>
</div>
<div class="mt-5 pt-5 border-t border-[color:var(--border)] grid grid-cols-2 gap-x-4 gap-y-3 text-[11.5px] font-mono">
<div>
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">775</div>
<div class="uppercase tracking-[0.16em] text-muted-foreground">Total sends · 30d</div>
<!-- RIGHT: stat strip -->
<div class="lg:w-[220px] grid grid-cols-2 lg:grid-cols-1 gap-3 lg:gap-2">
<div class="rounded-[10px] bg-[color:var(--surface-1)] ring-1 ring-[color:var(--border)] p-4">
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Day 1 send</div>
<div class="mt-1 text-[24px] font-semibold tracking-[-0.02em] text-heading font-mono">10<span class="text-[12px] text-muted-foreground ml-1">/d</span></div>
</div>
<div>
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">30%</div>
<div class="uppercase tracking-[0.16em] text-muted-foreground">Reply rate target</div>
<div class="rounded-[10px] bg-[color:var(--surface-1)] ring-1 ring-[color:var(--border)] p-4">
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Daily ramp</div>
<div class="mt-1 text-[24px] font-semibold tracking-[-0.02em] text-heading font-mono">+1<span class="text-[12px] text-muted-foreground ml-1">/d</span></div>
</div>
<div>
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">~10m</div>
<div class="uppercase tracking-[0.16em] text-muted-foreground">Avg send gap</div>
<div class="rounded-[10px] bg-[color:var(--sky-1)]/70 ring-1 ring-[color:var(--sky-2)] p-4">
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-[#0284c7]">Ceiling reached</div>
<div class="mt-1 text-[24px] font-semibold tracking-[-0.02em] text-[#0369a1] font-mono">Day 30</div>
</div>
<div>
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">95</div>
<div class="uppercase tracking-[0.16em] text-muted-foreground">Window · recipient TZ</div>
<div class="rounded-[10px] bg-[color:var(--surface-1)] ring-1 ring-[color:var(--border)] p-4">
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Total sends</div>
<div class="mt-1 text-[24px] font-semibold tracking-[-0.02em] text-heading font-mono">~550</div>
</div>
</div>
</div>
</div>
<!-- Bottom: day-by-day mini table -->
<div class="border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/40">
<div class="px-7 py-3 flex items-center justify-between text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">
<span>Day-by-day volume</span>
<span class="normal-case tracking-normal text-[11px]">Each cell = sends scheduled that day</span>
</div>
<div class="px-7 pb-5 overflow-x-auto">
<div class="grid grid-cols-[repeat(30,minmax(28px,1fr))] gap-1 min-w-[820px]">
{Array.from({ length: 30 }, (_, i) => {
const day = i + 1;
const v = Math.min(9 + day, 40);
const pct = (v - 10) / 30;
const atCeiling = v >= 40;
return (
<div class="flex flex-col items-center gap-1">
<div
class="w-full rounded-sm"
style={`height: ${6 + pct * 36}px; background: ${atCeiling ? '#0c4a6e' : '#0284c7'}; opacity: ${0.45 + pct * 0.5};`}
title={`Day ${day} · ${v}/d`}
></div>
<div class="text-[9px] font-mono text-muted-foreground">{day}</div>
</div>
);
})}
</div>
</div>
<!-- Footer rule -->
<div class="px-7 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 text-[11.5px] font-mono text-muted-foreground">
Weekends are skipped. Business-hour scheduling by recipient time zone. ~10 minute average gap between sends from the same mailbox.
</div>
</div>
</div>
@@ -622,19 +566,41 @@ const faq = [
Want the full mental model? Read the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/learn/email-warmup/">warmup guide</a>.
</p>
</div>
<div class="divide-y divide-[color:var(--border)]">
<div class="divide-y divide-[color:var(--border)]" data-faq>
{faq.map(([q, a]) => (
<details class="group py-5">
<summary class="flex items-start justify-between gap-4 cursor-pointer list-none">
<div class="faq-row py-2">
<button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false">
<span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span>
<span class="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 group-open:rotate-45 transition-transform">
<span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out">
<Icon name="plus" size={12} />
</span>
</summary>
<p class="mt-3 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
</details>
</button>
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
<div class="overflow-hidden">
<p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
</div>
</div>
</div>
))}
</div>
<script is:inline>
(function () {
const faq = document.querySelector('[data-faq]');
if (!faq) return;
faq.querySelectorAll('.faq-trigger').forEach(function (btn) {
btn.addEventListener('click', function () {
const row = btn.closest('.faq-row');
const panel = row.querySelector('.faq-panel');
const icon = btn.querySelector('.faq-icon');
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
panel.style.gridTemplateRows = open ? '0fr' : '1fr';
if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)';
});
});
})();
</script>
</div>
</section>