mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 08:02:42 +00:00
750 lines
46 KiB
Plaintext
750 lines
46 KiB
Plaintext
---
|
|
import Layout from '../layouts/Layout.astro';
|
|
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
|
|
import Icon from '../components/Icon.astro';
|
|
import CTA from '../components/CTA.astro';
|
|
|
|
// All numbers below are platform-level facts from the codebase:
|
|
// internal/api/handler/*, internal/app/ratelimit/service.go,
|
|
// internal/api/middleware/ratelimit.go, internal/app/consumer/*,
|
|
// internal/app/advanced/service.go.
|
|
|
|
// =========================================================================
|
|
// Section 2 · tabbed client editor
|
|
// =========================================================================
|
|
type Client = { id: string; label: string };
|
|
const clients: Client[] = [
|
|
{ id: 'ts', label: 'TypeScript' },
|
|
{ id: 'py', label: 'Python' },
|
|
{ id: 'go', label: 'Go' },
|
|
{ id: 'curl', label: 'curl' },
|
|
];
|
|
|
|
// =========================================================================
|
|
// Section 3 · event catalog, colored per surface
|
|
// =========================================================================
|
|
type EventGroup = {
|
|
surface: string;
|
|
blurb: string;
|
|
events: string[];
|
|
tone: string;
|
|
ring: string; // tailwind ring color for the card border accent
|
|
eyebrow: string; // tailwind classes for eyebrow text
|
|
pill: string; // tailwind classes for the count pill
|
|
chip: string; // tailwind classes for individual event chips
|
|
};
|
|
const eventGroups: EventGroup[] = [
|
|
{
|
|
surface: 'Replies',
|
|
blurb: 'Every inbound that lands on a sequenced thread, with the classifier verdict attached.',
|
|
events: ['reply.received', 'reply.classified'],
|
|
tone: 'emerald',
|
|
ring: 'ring-emerald-200',
|
|
eyebrow: 'text-emerald-700',
|
|
pill: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
|
|
chip: 'bg-emerald-50 text-emerald-800 ring-emerald-200',
|
|
},
|
|
{
|
|
surface: 'Deliverability',
|
|
blurb: 'Hard signals from mailbox providers and recipient action. Suppressed workspace-wide on first pass.',
|
|
events: ['bounce.hard', 'bounce.soft', 'complaint.received', 'unsubscribe.received'],
|
|
tone: 'rose',
|
|
ring: 'ring-rose-200',
|
|
eyebrow: 'text-rose-700',
|
|
pill: 'bg-rose-50 text-rose-700 ring-rose-200',
|
|
chip: 'bg-rose-50 text-rose-800 ring-rose-200',
|
|
},
|
|
{
|
|
surface: 'Mailboxes',
|
|
blurb: 'Lifecycle of each sender, from the first warmup pass through quarantine and back.',
|
|
events: ['mailbox.connected', 'mailbox.quarantined', 'mailbox.recovered'],
|
|
tone: 'amber',
|
|
ring: 'ring-amber-200',
|
|
eyebrow: 'text-amber-700',
|
|
pill: 'bg-amber-50 text-amber-700 ring-amber-200',
|
|
chip: 'bg-amber-50 text-amber-800 ring-amber-200',
|
|
},
|
|
{
|
|
surface: 'Sequences',
|
|
blurb: 'Pipeline state the dashboard shows you, in the same shape the dashboard reads.',
|
|
events: ['sequence.started', 'sequence.completed', 'campaign.paused'],
|
|
tone: 'sky',
|
|
ring: 'ring-[color:var(--sky-2)]',
|
|
eyebrow: 'text-[#0369a1]',
|
|
pill: 'bg-[color:var(--sky-1)]/60 text-[#0369a1] ring-[color:var(--sky-2)]',
|
|
chip: 'bg-[color:var(--sky-1)]/60 text-[#0369a1] ring-[color:var(--sky-2)]',
|
|
},
|
|
{
|
|
surface: 'CRM',
|
|
blurb: 'Contacts, deals, pipeline stage changes mirrored to the integration on the same event.',
|
|
events: ['contact.created', 'deal.stage_changed'],
|
|
tone: 'violet',
|
|
ring: 'ring-violet-200',
|
|
eyebrow: 'text-violet-700',
|
|
pill: 'bg-violet-50 text-violet-700 ring-violet-200',
|
|
chip: 'bg-violet-50 text-violet-800 ring-violet-200',
|
|
},
|
|
];
|
|
|
|
// =========================================================================
|
|
// Section 4 · webhook delivery as a branching flow
|
|
// =========================================================================
|
|
const retries = [
|
|
{ n: '01', when: '+30s', hint: 'Most transient outages resolve here.' },
|
|
{ n: '02', when: '+2m', hint: 'Receivers that throttle briefly recover.' },
|
|
{ n: '03', when: '+10m', hint: 'Covers a short rolling restart.' },
|
|
{ n: '04', when: '+1h', hint: 'Covers a maintenance window.' },
|
|
{ n: '05', when: '+6h', hint: 'Last attempt before dead-letter.' },
|
|
];
|
|
|
|
// =========================================================================
|
|
// Section 5 · scope matrix
|
|
// =========================================================================
|
|
const scopeRows = [
|
|
{ resource: 'Mailboxes', read: 'mailboxes:read', write: 'mailboxes:write', admin: '' },
|
|
{ resource: 'Sequences', read: 'sequences:read', write: 'sequences:write', admin: '' },
|
|
{ resource: 'Contacts', read: 'contacts:read', write: 'contacts:write', admin: '' },
|
|
{ resource: 'Events', read: 'events:read', write: '', admin: '' },
|
|
{ resource: 'CRM', read: 'crm:read', write: 'crm:write', admin: '' },
|
|
{ resource: 'Workspace', read: '', write: '', admin: 'admin:write' },
|
|
];
|
|
|
|
// =========================================================================
|
|
// Section 6 · numbers
|
|
// =========================================================================
|
|
const numbers = [
|
|
{ v: '600', u: '/ min', l: 'default per-key limit', note: 'per key, not per workspace' },
|
|
{ v: '120', u: '/ min', l: 'read-only key limit', note: 'reads get their own lane' },
|
|
{ v: '5', u: 'tries', l: 'webhook retry attempts', note: 'backoff from 30s to 6h' },
|
|
{ v: '10', u: 's', l: 'webhook delivery timeout', note: 'then it counts as a miss' },
|
|
{ v: '24', u: 'h', l: 'idempotency-key window', note: 'same key, same result' },
|
|
{ v: '256', u: 'KB', l: 'event payload ceiling', note: 'full body, never truncated' },
|
|
];
|
|
|
|
// =========================================================================
|
|
// FAQ
|
|
// =========================================================================
|
|
const faq: [string, string][] = [
|
|
['How do I verify a webhook signature?',
|
|
'Compute HMAC-SHA256 of the raw request body using your endpoint secret. Compare the hex digest to the X-Warmbly-Signature header in constant time. Reject any request older than 5 minutes against X-Warmbly-Timestamp.'],
|
|
['Are webhooks delivered exactly once?',
|
|
'No. We deliver at least once. Every event carries an idempotency_key. Persist it on first receipt and skip duplicates. Retries happen on any non-2xx response or a timeout above 10 seconds.'],
|
|
['What happens after 5 retries?',
|
|
'The event lands in the dead-letter view in the dashboard. From there you can inspect the last failure, replay one event or a range, or rotate the endpoint secret without resetting delivery history.'],
|
|
['Can a single key be scoped to one campaign or mailbox?',
|
|
'Yes. Each key carries a list of scopes and an optional resource filter, for example mailboxes:write limited to mbx_01HQX. Cross-resource calls return 403 with a scope_required error code.'],
|
|
['Do you publish OpenAPI?',
|
|
'Yes, at docs.warmbly.com/openapi.json. It is OpenAPI 3.1, so you can import it into Postman or Insomnia, generate a typed client in your language, or build straight against it. There are no official SDKs yet, so plain HTTP is the path today.'],
|
|
];
|
|
---
|
|
<Layout
|
|
title="Developer API and Webhooks | Warmbly"
|
|
description="REST API with idempotency keys, scoped keys, HMAC-signed webhooks, and a dead-letter view. The same surface the dashboard runs on."
|
|
>
|
|
<!-- ============================================================
|
|
HERO · stands alone
|
|
============================================================ -->
|
|
<section class="relative isolate overflow-hidden">
|
|
<HeroAtmosphere />
|
|
|
|
<div class="container-page relative pt-16 md:pt-24 pb-20 md:pb-28 text-center">
|
|
<div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]">
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;">
|
|
API and webhooks
|
|
</span>
|
|
The same surface our dashboard runs on
|
|
</div>
|
|
|
|
<h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto">
|
|
REST in.<br/>Signed events out.
|
|
</h1>
|
|
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
|
|
Scoped keys with per-minute limits. Idempotent writes. HMAC-signed webhooks with five retries and a dead-letter view. No hidden surface, no second-class API.
|
|
</p>
|
|
|
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
<a href="https://app.warmbly.com/app/api-keys" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]">
|
|
<span style="color:#075985;">Get an API key</span>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
|
</svg>
|
|
</a>
|
|
<a href="https://docs.warmbly.com" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out">
|
|
Read the docs
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SECTION 1 · quickstart: key → endpoint → first event
|
|
============================================================ -->
|
|
<section class="bg-white border-t border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Section 1 · Quickstart</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Key to first event in three steps.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
|
No OAuth dance, no app review queue. Create a key, point us at an endpoint, and the first signed event shows up the moment something happens in your workspace.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid gap-4 lg:grid-cols-[1fr_auto_1fr_auto_1fr] lg:gap-3 items-stretch">
|
|
<!-- Step 1 · scoped key -->
|
|
<div class="rounded-xl border border-sky-200 bg-white shadow-sm overflow-hidden flex flex-col">
|
|
<div class="flex items-center gap-2 border-b border-sky-200/70 bg-gradient-to-r from-sky-50 to-white px-3.5 py-2.5">
|
|
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-sky-100 text-sky-600 ring-1 ring-sky-200/70"><Icon name="key" size={13} /></span>
|
|
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">Create a scoped key</span>
|
|
<span class="shrink-0 rounded bg-sky-600 px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-white">Step 1</span>
|
|
</div>
|
|
<div class="p-3.5 flex-1 flex flex-col">
|
|
<div class="rounded-lg bg-slate-50 ring-1 ring-slate-200/70 p-3 font-mono">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-[11.5px] font-semibold text-slate-700 truncate">wmbly_live_8f3kq2…</span>
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded bg-white ring-1 ring-sky-200 text-[9.5px] text-sky-700 shrink-0">600 req/min</span>
|
|
</div>
|
|
<div class="mt-2 flex flex-wrap gap-1">
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded bg-sky-50 ring-1 ring-sky-200 text-[10px] text-sky-800">events:read</span>
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded bg-sky-50 ring-1 ring-sky-200 text-[10px] text-sky-800">mailboxes:write</span>
|
|
</div>
|
|
</div>
|
|
<p class="mt-3 text-[12.5px] text-slate-600 leading-relaxed">Pick scopes in the dashboard. The key can never do more than what you picked, and a cross-scope call returns 403 with the scope it was missing.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden lg:flex items-center text-slate-300" aria-hidden="true"><Icon name="arrowRight" size={16} /></div>
|
|
|
|
<!-- Step 2 · endpoint -->
|
|
<div class="rounded-xl border border-violet-200 bg-white shadow-sm overflow-hidden flex flex-col">
|
|
<div class="flex items-center gap-2 border-b border-violet-200/70 bg-gradient-to-r from-violet-50 to-white px-3.5 py-2.5">
|
|
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-600 ring-1 ring-violet-200/70"><Icon name="webhook" size={13} /></span>
|
|
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">Register your endpoint</span>
|
|
<span class="shrink-0 rounded bg-violet-600 px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-white">Step 2</span>
|
|
</div>
|
|
<div class="p-3.5 flex-1 flex flex-col">
|
|
<div class="rounded-lg bg-slate-50 ring-1 ring-slate-200/70 p-3 font-mono">
|
|
<div class="flex items-center gap-1.5 min-w-0">
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded bg-violet-50 ring-1 ring-violet-200 text-[9.5px] font-semibold text-violet-700 shrink-0">POST</span>
|
|
<span class="text-[11.5px] text-slate-700 truncate">api.acme.dev/hooks/warmbly</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center justify-between gap-2">
|
|
<span class="text-[10.5px] text-slate-500 truncate">whsec_9d41kk…</span>
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded bg-white ring-1 ring-violet-200 text-[9.5px] text-violet-700 shrink-0">HMAC-SHA256</span>
|
|
</div>
|
|
</div>
|
|
<p class="mt-3 text-[12.5px] text-slate-600 leading-relaxed">Paste a URL, get a signing secret. Subscribe to one surface or the whole stream, and rotate the secret any time without losing delivery history.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden lg:flex items-center text-slate-300" aria-hidden="true"><Icon name="arrowRight" size={16} /></div>
|
|
|
|
<!-- Step 3 · first event -->
|
|
<div class="rounded-xl border border-emerald-200 bg-white shadow-sm overflow-hidden flex flex-col">
|
|
<div class="flex items-center gap-2 border-b border-emerald-200/70 bg-gradient-to-r from-emerald-50 to-white px-3.5 py-2.5">
|
|
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600 ring-1 ring-emerald-200/70"><Icon name="inbox" size={13} /></span>
|
|
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">Catch the first event</span>
|
|
<span class="shrink-0 rounded bg-emerald-600 px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-white">Step 3</span>
|
|
</div>
|
|
<div class="p-3.5 flex-1 flex flex-col">
|
|
<div class="rounded-lg bg-slate-50 ring-1 ring-slate-200/70 p-3 font-mono text-[11px] leading-[1.6] text-slate-600">
|
|
<div><span class="text-slate-400">type </span>reply.classified</div>
|
|
<div><span class="text-slate-400">verdict </span>positive</div>
|
|
<div class="mt-2 pt-2 border-t border-slate-200/70 flex items-center justify-between gap-2">
|
|
<span class="text-slate-400">your reply</span>
|
|
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded bg-emerald-50 ring-1 ring-emerald-200 text-[9.5px] text-emerald-700 shrink-0">
|
|
<span class="w-1 h-1 rounded-full bg-emerald-500"></span>
|
|
200 OK · 41ms
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p class="mt-3 text-[12.5px] text-slate-600 leading-relaxed">Verify the signature in constant time, return 2xx inside ten seconds, process async. That is the whole contract.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 rounded-[12px] bg-[color:var(--surface-2)]/60 ring-1 ring-[color:var(--border)] px-5 py-3 flex flex-wrap items-center justify-between gap-2 text-[11.5px] font-mono text-muted-foreground">
|
|
<span>Keys and endpoints live in Settings · every delivery is inspectable</span>
|
|
<span class="text-[#0369a1]">After step three it is just HTTP</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SECTION 2 · pick your client (tabbed editor)
|
|
============================================================ -->
|
|
<section class="bg-[color:var(--surface-2)]/60 border-y border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Section 2 · Pick your client</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
One operation. Every client.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
|
A published OpenAPI 3.1 spec you can generate a typed client from, and plain HTTP from anywhere else. Same request shape, same idempotency semantics, same error codes.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-[14px] overflow-hidden ring-1 ring-white/10 bg-[#0c1224] shadow-[0_30px_60px_-30px_rgba(2,32,71,0.35),0_8px_20px_-10px_rgba(15,23,42,0.1)]" data-tabs>
|
|
<div class="flex items-center gap-1 px-2 py-2 border-b border-white/10 overflow-x-auto" role="tablist">
|
|
<div class="flex items-center gap-1.5 shrink-0 pl-2 pr-3" aria-hidden="true">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/60"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/60"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/60"></span>
|
|
</div>
|
|
{clients.map((c, i) => (
|
|
<button
|
|
type="button"
|
|
data-tab-button={c.id}
|
|
class={`tab-trigger relative inline-flex items-center h-8 px-3 rounded-md text-[12.5px] font-mono ${i === 0 ? 'is-active' : ''}`}
|
|
aria-selected={i === 0 ? 'true' : 'false'}
|
|
>{c.label}</button>
|
|
))}
|
|
<span class="ml-auto pr-2 text-[10.5px] font-mono text-white/40 hidden md:inline">list campaigns · opaque cursor pagination</span>
|
|
</div>
|
|
|
|
<!-- Tab panel stack · panels share one grid cell so the editor keeps
|
|
the height of its tallest panel and the swap never jumps -->
|
|
<div data-tabs-stack>
|
|
<!-- TypeScript -->
|
|
<pre data-tab-panel="ts" data-state="active" class="tab-panel px-6 py-6 text-[12.5px] leading-[1.75] font-mono overflow-x-auto text-white/85"><code><span style="color:#94a3b8">// list-campaigns.ts — plain fetch, no SDK required</span>
|
|
<span style="color:#7dd3fc">const</span> res <span style="color:#f472b6">=</span> <span style="color:#7dd3fc">await</span> <span style="color:#a78bfa">fetch</span>(
|
|
<span style="color:#fde68a">"https://api.warmbly.com/v1/campaigns?limit=20"</span>,
|
|
{ headers: { Authorization: <span style="color:#fde68a">`Bearer ${process.env.WARMBLY_KEY}`</span> } },
|
|
);
|
|
|
|
<span style="color:#7dd3fc">const</span> { data, pagination } <span style="color:#f472b6">=</span> <span style="color:#7dd3fc">await</span> res.<span style="color:#a78bfa">json</span>();
|
|
console.<span style="color:#a78bfa">log</span>(data.length, pagination.next_cursor);</code></pre>
|
|
|
|
<!-- Python -->
|
|
<pre data-tab-panel="py" data-state="inactive" class="tab-panel px-6 py-6 text-[12.5px] leading-[1.75] font-mono overflow-x-auto text-white/85"><code><span style="color:#94a3b8"># list_campaigns.py — plain requests, no SDK required</span>
|
|
<span style="color:#7dd3fc">import</span> os, requests
|
|
|
|
res <span style="color:#f472b6">=</span> requests.<span style="color:#a78bfa">get</span>(
|
|
<span style="color:#fde68a">"https://api.warmbly.com/v1/campaigns"</span>,
|
|
headers<span style="color:#f472b6">=</span>{<span style="color:#fde68a">"Authorization"</span>: <span style="color:#fde68a">f"Bearer {os.environ['WARMBLY_KEY']}"</span>},
|
|
params<span style="color:#f472b6">=</span>{<span style="color:#fde68a">"limit"</span>: <span style="color:#fde68a">20</span>},
|
|
)
|
|
|
|
body <span style="color:#f472b6">=</span> res.<span style="color:#a78bfa">json</span>()
|
|
<span style="color:#a78bfa">print</span>(<span style="color:#a78bfa">len</span>(body[<span style="color:#fde68a">"data"</span>]), body[<span style="color:#fde68a">"pagination"</span>][<span style="color:#fde68a">"next_cursor"</span>])</code></pre>
|
|
|
|
<!-- Go -->
|
|
<pre data-tab-panel="go" data-state="inactive" class="tab-panel px-6 py-6 text-[12.5px] leading-[1.75] font-mono overflow-x-auto text-white/85"><code><span style="color:#94a3b8">// main.go — net/http, no SDK required</span>
|
|
req, _ <span style="color:#f472b6">:=</span> http.<span style="color:#a78bfa">NewRequest</span>(<span style="color:#fde68a">"GET"</span>,
|
|
<span style="color:#fde68a">"https://api.warmbly.com/v1/campaigns?limit=20"</span>, <span style="color:#a78bfa">nil</span>)
|
|
req.Header.<span style="color:#a78bfa">Set</span>(<span style="color:#fde68a">"Authorization"</span>, <span style="color:#fde68a">"Bearer "</span><span style="color:#f472b6">+</span>os.<span style="color:#a78bfa">Getenv</span>(<span style="color:#fde68a">"WARMBLY_KEY"</span>))
|
|
|
|
res, err <span style="color:#f472b6">:=</span> http.DefaultClient.<span style="color:#a78bfa">Do</span>(req)
|
|
<span style="color:#7dd3fc">if</span> err <span style="color:#f472b6">!=</span> <span style="color:#a78bfa">nil</span> { <span style="color:#a78bfa">log</span>.<span style="color:#a78bfa">Fatal</span>(err) }
|
|
<span style="color:#7dd3fc">defer</span> res.Body.<span style="color:#a78bfa">Close</span>() <span style="color:#94a3b8">// decode { data, pagination }</span></code></pre>
|
|
|
|
<!-- curl -->
|
|
<pre data-tab-panel="curl" data-state="inactive" class="tab-panel px-6 py-6 text-[12.5px] leading-[1.75] font-mono overflow-x-auto text-white/85"><code><span style="color:#94a3b8"># terminal</span>
|
|
<span style="color:#94a3b8">$</span> curl <span style="color:#fde68a">https://api.warmbly.com/v1/campaigns?limit=20</span> \
|
|
-H <span style="color:#fde68a">"Authorization: Bearer $WARMBLY_KEY"</span>
|
|
|
|
<span style="color:#94a3b8"># -> { "data": [ ... ], "pagination": { "next_cursor": "c1_…", "has_more": true } }</span></code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<script is:inline>
|
|
(function () {
|
|
document.querySelectorAll('[data-tabs]').forEach(function (root) {
|
|
var buttons = root.querySelectorAll('[data-tab-button]');
|
|
var panels = root.querySelectorAll('[data-tab-panel]');
|
|
buttons.forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var id = btn.getAttribute('data-tab-button');
|
|
buttons.forEach(function (b) {
|
|
var on = b === btn;
|
|
b.classList.toggle('is-active', on);
|
|
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
});
|
|
panels.forEach(function (p) {
|
|
p.setAttribute('data-state', p.getAttribute('data-tab-panel') === id ? 'active' : 'inactive');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SECTION 3 · event catalog, colored per surface
|
|
============================================================ -->
|
|
<section class="bg-white border-b border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Section 3 · Event catalog</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Five surfaces, one firehose.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
|
Subscribe to the whole stream or pick the surfaces you care about. The same event the dashboard reads is the event you receive, in the same shape.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4 lg:gap-5">
|
|
{eventGroups.map((g) => (
|
|
<div class={`rounded-[14px] bg-white ring-1 ${g.ring} overflow-hidden flex flex-col`}>
|
|
<div class="px-5 py-3 border-b border-[color:var(--border)] bg-[color:var(--surface-2)]/40 flex items-baseline justify-between">
|
|
<span class={`text-[11px] font-mono uppercase tracking-[0.18em] font-semibold ${g.eyebrow}`}>{g.surface}</span>
|
|
<span class={`inline-flex items-center h-5 px-1.5 rounded text-[10px] font-mono uppercase tracking-[0.08em] ring-1 ${g.pill}`}>{g.events.length} events</span>
|
|
</div>
|
|
<div class="p-5 flex-1">
|
|
<p class="text-[13px] text-foreground/70 leading-relaxed">{g.blurb}</p>
|
|
<div class="mt-4 flex flex-wrap gap-1.5">
|
|
{g.events.map((e) => (
|
|
<code class={`inline-flex items-center h-6 px-2 rounded-md font-mono text-[11.5px] ring-1 ${g.chip}`}>{e}</code>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden flex flex-col p-5 items-start justify-center">
|
|
<div class="text-[11px] font-mono uppercase tracking-[0.18em] text-muted-foreground mb-2">Subscribe to all</div>
|
|
<code class="font-mono text-[12.5px] text-heading bg-[color:var(--surface-2)]/60 ring-1 ring-[color:var(--border)] px-2.5 py-1 rounded">events: ["*"]</code>
|
|
<p class="mt-3 text-[12.5px] text-foreground/65 leading-relaxed">
|
|
Or pick by surface, by type, or by a single sequence or mailbox. The dispatcher filters before the network hop.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SECTION 4 · webhook delivery as a flow
|
|
============================================================ -->
|
|
<section class="bg-[color:var(--surface-2)]/60 border-b border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="grid lg:grid-cols-[1fr_1.55fr] gap-12 lg:gap-16 items-start">
|
|
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Section 4 · Delivery</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Signed bodies. Replayable. Dead-letter visible.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
|
Every event is HMAC-signed with your endpoint secret and carries an idempotency key. A 2xx in ten seconds closes the delivery. Anything else, and we walk a five-step retry schedule before the event lands in a view you can replay from.
|
|
</p>
|
|
<ul class="mt-6 space-y-3 text-[14px] text-foreground/85">
|
|
<li class="flex items-start gap-3">
|
|
<span class="mt-px inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200/70"><Icon name="lock" size={13} /></span>
|
|
<span><span class="font-medium text-heading">Signed with your secret.</span> Verify the signature in constant time before you trust the body.</span>
|
|
</li>
|
|
<li class="flex items-start gap-3">
|
|
<span class="mt-px inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-amber-50 text-amber-600 ring-1 ring-amber-200/70"><Icon name="clock" size={13} /></span>
|
|
<span><span class="font-medium text-heading">Ten seconds to ack.</span> Return 2xx fast and process async.</span>
|
|
</li>
|
|
<li class="flex items-start gap-3">
|
|
<span class="mt-px inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-600 ring-1 ring-rose-200/70"><Icon name="warning" size={13} /></span>
|
|
<span><span class="font-medium text-heading">Dead-letter is a screen.</span> Inspect, replay a range, rotate the secret without losing history.</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Branching canvas -->
|
|
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
|
<div class="px-5 py-3.5 border-b border-[color:var(--border)] bg-[color:var(--surface-2)]/70 flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex items-baseline gap-3">
|
|
<div class="text-[13px] font-semibold text-heading">POST /webhooks/yours</div>
|
|
<span class="inline-flex items-center gap-1.5 h-5 px-2 rounded-full bg-emerald-50 ring-1 ring-emerald-200 text-emerald-700 text-[10.5px] font-medium font-mono uppercase tracking-[0.08em]">
|
|
<span class="w-1 h-1 rounded-full bg-emerald-500"></span>
|
|
reply.classified
|
|
</span>
|
|
</div>
|
|
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-mono uppercase tracking-[0.12em] text-muted-foreground">
|
|
<Icon name="webhook" size={11} /> at-least-once
|
|
</span>
|
|
</div>
|
|
|
|
<div class="p-4 sm:p-5">
|
|
<!-- Event fires card -->
|
|
<div class="rounded-xl border border-sky-200 bg-white shadow-sm">
|
|
<div class="flex items-center gap-2 rounded-t-xl border-b border-sky-200/70 bg-gradient-to-r from-sky-50 to-white px-3 py-2">
|
|
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-sky-100 text-sky-600 ring-1 ring-sky-200/70"><Icon name="zap" size={13} /></span>
|
|
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">Event fires on the consumer</span>
|
|
<span class="shrink-0 rounded bg-sky-600 px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-white">Start</span>
|
|
</div>
|
|
<div class="px-3 py-2.5">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">Payload</span>
|
|
<span class="text-[10px] font-mono text-slate-400">signed t+ts.body</span>
|
|
</div>
|
|
<div class="mt-2 rounded-lg bg-slate-50 ring-1 ring-slate-200/70 p-3 font-mono text-[11px] leading-[1.6] text-slate-600">
|
|
<div><span class="text-slate-400">id </span>evt_01HQX9F7P3A8KY2NJM4R6BWT0S</div>
|
|
<div><span class="text-slate-400">type </span>reply.classified</div>
|
|
<div><span class="text-slate-400">idem </span>reply:th_01HQX…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Branches -->
|
|
<div class="ml-3.5 border-l-2 border-dashed border-slate-200 pl-4 sm:pl-5 pt-3 space-y-4">
|
|
<!-- 2xx branch -->
|
|
<div>
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
<span class="inline-flex items-center gap-1.5 h-6 px-2 rounded-md bg-emerald-50 ring-1 ring-emerald-200 text-[11px] font-medium text-emerald-700">
|
|
<Icon name="check" size={11} />
|
|
2xx in under 10s
|
|
</span>
|
|
<span class="text-slate-300"><Icon name="arrowRight" size={12} /></span>
|
|
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded bg-emerald-50 ring-1 ring-emerald-200 text-[10px] font-medium text-emerald-700">acknowledged</span>
|
|
</div>
|
|
<p class="mt-2 text-[12px] text-slate-500 leading-snug">Delivery closes. A duplicate retry from us is a no-op on your side.</p>
|
|
</div>
|
|
|
|
<!-- retry chain -->
|
|
<div>
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
<span class="inline-flex items-center gap-1.5 h-6 px-2 rounded-md bg-amber-50 ring-1 ring-amber-200 text-[11px] font-medium text-amber-700">
|
|
<Icon name="warning" size={11} />
|
|
non-2xx or timeout
|
|
</span>
|
|
<span class="text-slate-300"><Icon name="arrowRight" size={12} /></span>
|
|
<span class="text-[11px] font-medium text-slate-500">walk the retry schedule</span>
|
|
</div>
|
|
|
|
<div class="mt-3 rounded-xl border border-amber-200/70 bg-amber-50/30 p-3 sm:p-4">
|
|
<div class="divide-y divide-amber-200/40">
|
|
{retries.map((r) => (
|
|
<div class="grid grid-cols-[44px_72px_1fr] gap-3 py-2 items-center">
|
|
<span class="font-mono text-[10.5px] text-amber-700 font-semibold">{r.n}</span>
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded bg-white ring-1 ring-amber-200 text-[10.5px] font-mono text-amber-800 w-fit">{r.when}</span>
|
|
<span class="text-[12px] text-slate-600 leading-snug">{r.hint}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div class="mt-3 pt-3 border-t border-amber-200/60 flex items-center gap-2 text-[11px] font-mono">
|
|
<span class="text-amber-700">After 5 failed attempts</span>
|
|
<span class="text-slate-300"><Icon name="arrowRight" size={11} /></span>
|
|
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded bg-rose-50 ring-1 ring-rose-200 text-rose-700">
|
|
<span class="w-1 h-1 rounded-full bg-rose-500"></span>
|
|
dead-letter view
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-5 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-2)]/60 flex flex-wrap items-center justify-between gap-2 text-[11.5px] font-mono text-muted-foreground">
|
|
<span>Signed payload, idempotent on event id</span>
|
|
<span class="text-[#0369a1]">Replay any range from the dashboard</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SECTION 5 · scope matrix
|
|
============================================================ -->
|
|
<section class="bg-white border-b border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="grid lg:grid-cols-[1fr_2fr] gap-12 lg:gap-16 items-start">
|
|
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Section 5 · Per-key scopes</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Scope down. Stay down.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
|
Six resources, two verbs, one admin scope. A cross-scope call returns 403 with <code class="font-mono text-[13px] text-[#0369a1]">scope_required</code> and the exact scope the call needs.
|
|
</p>
|
|
<ul class="mt-6 space-y-2.5 text-[13.5px] text-foreground/80">
|
|
<li class="flex items-start gap-3">
|
|
<span class="inline-flex items-center justify-center h-5 px-1.5 rounded font-mono text-[10px] font-semibold bg-sky-50 text-sky-700 ring-1 ring-sky-200 mt-0.5">read</span>
|
|
<span>List, inspect, subscribe.</span>
|
|
</li>
|
|
<li class="flex items-start gap-3">
|
|
<span class="inline-flex items-center justify-center h-5 px-1.5 rounded font-mono text-[10px] font-semibold bg-violet-50 text-violet-700 ring-1 ring-violet-200 mt-0.5">write</span>
|
|
<span>Create, mutate, delete.</span>
|
|
</li>
|
|
<li class="flex items-start gap-3">
|
|
<span class="inline-flex items-center justify-center h-5 px-1.5 rounded font-mono text-[10px] font-semibold bg-amber-50 text-amber-700 ring-1 ring-amber-200 mt-0.5">admin</span>
|
|
<span>Workspace, billing, members.</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="rounded-[14px] ring-1 ring-[color:var(--border)] overflow-hidden bg-white">
|
|
<table class="w-full border-collapse">
|
|
<thead>
|
|
<tr class="bg-[color:var(--surface-2)]/70 border-b border-[color:var(--border)]">
|
|
<th class="text-left px-5 py-3 text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground font-medium">Resource</th>
|
|
<th class="text-left px-5 py-3 text-[10.5px] uppercase tracking-[0.18em] font-mono text-sky-700 font-medium">Read</th>
|
|
<th class="text-left px-5 py-3 text-[10.5px] uppercase tracking-[0.18em] font-mono text-violet-700 font-medium">Write</th>
|
|
<th class="text-left px-5 py-3 text-[10.5px] uppercase tracking-[0.18em] font-mono text-amber-700 font-medium">Admin</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{scopeRows.map((r) => (
|
|
<tr class="border-b border-[color:var(--border)] last:border-b-0 hover:bg-[color:var(--surface-2)]/30 transition-colors">
|
|
<td class="px-5 py-4 text-[13.5px] font-semibold text-heading">{r.resource}</td>
|
|
<td class="px-5 py-4">
|
|
{r.read
|
|
? <code class="inline-flex items-center h-6 px-2 rounded-md bg-sky-50 ring-1 ring-sky-200 font-mono text-[11.5px] text-sky-800">{r.read}</code>
|
|
: <span class="inline-block w-3 h-px bg-[color:var(--border-strong)]"></span>}
|
|
</td>
|
|
<td class="px-5 py-4">
|
|
{r.write
|
|
? <code class="inline-flex items-center h-6 px-2 rounded-md bg-violet-50 ring-1 ring-violet-200 font-mono text-[11.5px] text-violet-800">{r.write}</code>
|
|
: <span class="inline-block w-3 h-px bg-[color:var(--border-strong)]"></span>}
|
|
</td>
|
|
<td class="px-5 py-4">
|
|
{r.admin
|
|
? <code class="inline-flex items-center h-6 px-2 rounded-md bg-amber-50 ring-1 ring-amber-200 font-mono text-[11.5px] text-amber-800">{r.admin}</code>
|
|
: <span class="inline-block w-3 h-px bg-[color:var(--border-strong)]"></span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<div class="px-5 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-2)]/60 flex flex-wrap items-center justify-between gap-2 text-[11.5px] font-mono text-muted-foreground">
|
|
<span>403 scope_required carries the exact scope the call needs</span>
|
|
<span class="text-[#0369a1]">Keys can also be pinned to one mailbox or sequence</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SECTION 6 · numbers
|
|
============================================================ -->
|
|
<section class="bg-[color:var(--surface-2)]/60 border-b border-[color:var(--border)] py-16 md:py-20">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-10">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Section 6 · By the numbers</div>
|
|
<h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
The whole rate policy, in one row.
|
|
</h2>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
|
{numbers.map((n) => (
|
|
<div class="bg-white p-5 md:p-6">
|
|
<div class="flex items-baseline gap-1">
|
|
<span class="text-[28px] md:text-[34px] font-semibold tracking-[-0.025em] font-mono text-heading">{n.v}</span>
|
|
<span class="text-[12px] text-muted-foreground">{n.u}</span>
|
|
</div>
|
|
<div class="mt-2 text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{n.l}</div>
|
|
<div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[11px] text-muted-foreground">{n.note}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
FAQ
|
|
============================================================ -->
|
|
<section class="bg-white border-b border-[color:var(--border)] py-20 md:py-24">
|
|
<div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
|
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Developer FAQ</div>
|
|
<h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
|
The questions we get a lot.
|
|
</h2>
|
|
<p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed">
|
|
Full API reference and the OpenAPI spec at <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="https://docs.warmbly.com">docs.warmbly.com</a>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="divide-y divide-[color:var(--border)]" data-faq>
|
|
{faq.map(([q, a]) => (
|
|
<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="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">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
</span>
|
|
</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>
|
|
|
|
<CTA
|
|
title="Wire Warmbly into your stack."
|
|
description="Create a key, paste a webhook URL, ship the integration before lunch."
|
|
primaryLabel="Get an API key"
|
|
primaryHref="https://app.warmbly.com/app/api-keys"
|
|
secondaryLabel="Read the docs"
|
|
secondaryHref="https://docs.warmbly.com"
|
|
/>
|
|
|
|
<style is:inline>
|
|
/* Tab triggers for the client editor in Section 2. State lives in one
|
|
class so the script never juggles utility names. */
|
|
.tab-trigger {
|
|
color: rgba(255, 255, 255, 0.55);
|
|
transition: color 180ms ease, background-color 180ms ease;
|
|
}
|
|
.tab-trigger:hover {
|
|
color: rgba(255, 255, 255, 0.92);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
.tab-trigger.is-active {
|
|
color: #ffffff;
|
|
background: rgba(255, 255, 255, 0.10);
|
|
}
|
|
|
|
/* Panel stack: every panel occupies the same grid cell, so the editor
|
|
holds the height of its tallest panel and the swap never jumps.
|
|
Outgoing panel fades down and hides; incoming one fades up after a
|
|
short beat so the two never read as a blur. */
|
|
[data-tabs-stack] {
|
|
display: grid;
|
|
}
|
|
[data-tabs-stack] > .tab-panel {
|
|
grid-area: 1 / 1;
|
|
min-width: 0;
|
|
transition: opacity 260ms cubic-bezier(0.22, 1, 0.36, 1) 70ms,
|
|
transform 260ms cubic-bezier(0.22, 1, 0.36, 1) 70ms;
|
|
}
|
|
[data-tabs-stack] > .tab-panel[data-state="inactive"] {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
transition: opacity 160ms ease, transform 160ms ease,
|
|
visibility 0s linear 160ms;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
[data-tabs-stack] > .tab-panel,
|
|
[data-tabs-stack] > .tab-panel[data-state="inactive"] {
|
|
transition: none;
|
|
transform: none;
|
|
}
|
|
}
|
|
</style>
|
|
</Layout>
|