Merge pull request #289 from warmbly/feature/marketing-hero-visual-backdrop

feat: index page visuals 1/1 with the dashboard, hero pricing line, Discord links (#268)
This commit is contained in:
Matthew Meszaros
2026-09-02 06:15:05 -07:00
committed by GitHub
34 changed files with 4876 additions and 2797 deletions
+1
View File
@@ -14,6 +14,7 @@
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.3",
"@fontsource/inter": "^5.2.6",
"@paper-design/shaders": "^0.0.80",
"@tailwindcss/vite": "^4.1.10",
"astro": "^6.4.8",
"motion": "^12.34.0",
+8
View File
@@ -25,6 +25,9 @@ importers:
'@fontsource/inter':
specifier: ^5.2.6
version: 5.2.8
'@paper-design/shaders':
specifier: ^0.0.80
version: 0.0.80
'@tailwindcss/vite':
specifier: ^4.1.10
version: 4.3.0(vite@7.3.6(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))
@@ -601,6 +604,9 @@ packages:
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@paper-design/shaders@0.0.80':
resolution: {integrity: sha512-pcabvt5xDlFoEhpjUj4b1tGJMfqb0i5mXifWMNQf6z7FoOJYxYDo7F8R6k0JAh5D/7ouxjX5+N0cIq5WURyQ1Q==}
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
@@ -2500,6 +2506,8 @@ snapshots:
'@oslojs/encoding@1.1.0': {}
'@paper-design/shaders@0.0.80': {}
'@rollup/pluginutils@5.3.0(rollup@4.62.2)':
dependencies:
'@types/estree': 1.0.9
-314
View File
@@ -1,314 +0,0 @@
---
/**
* Pixel-faithful mock of the Warmbly "Write with AI" assistant, shown IN
* CONTEXT inside the campaign Step email composer.
*
* Maps 1:1 to the real components:
* - web/src/components/app/campaigns/sequences/SequenceView.tsx the Step composer panel
* - web/src/components/app/campaigns/sequences/WriteWithAI.tsx the AI popover (the star)
* - web/src/components/app/campaigns/sequences/RichTextEditor.tsx the body toolbar + editor
* - web/src/lib/api/models/app/generation/Write.ts WRITE_TONES presets
* - web/src/components/ui/field.tsx TextInput / Label
* - web/src/components/ui/popover-menu.tsx SelectButton / PopoverMenuContent
*
* Desktop-locked, authored at the 900px design width (FeatureSection scales it
* to the column). No responsive variants; always the desktop layout.
*/
// Tone presets, copied verbatim from WRITE_TONES (web/.../generation/Write.ts).
// "Default" (empty value) is the selected tone.
const tones = [
{ label: "Default", selected: true },
{ label: "Friendly", selected: false },
{ label: "Professional", selected: false },
{ label: "Casual", selected: false },
{ label: "Concise", selected: false },
{ label: "Persuasive", selected: false },
];
// The freshly generated cold-email draft that fills the body after Generate.
// Matches SequenceView.onInsert -> setBody: each line is a <p> in the tiptap body.
const draftLines = [
"Hi Alex,",
"Noticed Acme has been scaling fast on AWS. Teams your size are usually overpaying 30 to 40 percent on compute they spun up once and never tuned.",
"I help engineering teams find that spend without re-architecting anything. Worth a quick 15-minute call to walk through the three line items I'd check first?",
"Open to next Tuesday or Wednesday afternoon?",
"Best,",
"Jordan",
];
---
<!-- Root: fills parent width, white surface. relative for cursor + reaction. -->
<div class="w-full bg-white text-slate-900 relative" style="min-height:560px;">
<!-- Slim Step tab bar above the composer (campaign sequence builder chrome) -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 bg-white">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Sequence</span>
<div class="h-4 w-px bg-slate-200"></div>
<div class="flex items-center gap-1.5">
<span class="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md bg-slate-900 text-white text-[12px] font-medium">
<span class="size-4 rounded-full bg-white/20 inline-flex items-center justify-center font-mono text-[10px] tabular-nums">1</span>
Step 1
</span>
<span class="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md text-slate-500 text-[12px]">
<span class="size-4 rounded-full bg-slate-100 inline-flex items-center justify-center font-mono text-[10px] tabular-nums text-slate-500">2</span>
Follow-up
<span class="font-mono text-[10px] text-slate-400 tabular-nums">+3d</span>
</span>
<span class="inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-slate-400 text-[12px]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add step
</span>
</div>
<div class="ml-auto inline-flex items-center gap-1.5 text-[11px] text-emerald-600 font-medium">
<span class="relative flex size-1.5">
<span class="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-60 animate-ping"></span>
<span class="relative inline-flex size-1.5 rounded-full bg-emerald-500"></span>
</span>
Draft saved
</div>
</div>
<!-- Composer body padding -->
<div class="p-5">
<!-- The Step composer card (SequenceView root) -->
<div class="rounded-md border border-slate-200 bg-white relative">
<!-- Header: Step label + subtitle (left), action buttons (right) -->
<div class="flex flex-row items-center justify-between gap-3 border-b border-slate-200 px-3 py-2.5">
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Step 1</div>
<p class="mt-0.5 truncate text-[11px] text-slate-400">Compose the email this step sends.</p>
</div>
<div class="flex flex-nowrap shrink-0 items-center gap-2">
<!-- Templates (SelectButton) -->
<button type="button" class="h-7 px-2 rounded-md border border-slate-200 bg-white text-slate-700 inline-flex items-center gap-1.5 text-[12px] font-medium whitespace-nowrap shrink-0">
<span class="text-slate-400 shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/></svg>
</span>
<span class="truncate max-w-[160px]">Templates</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="text-slate-400 shrink-0"><path d="M6 9l6 6 6-6"/></svg>
</button>
<!-- Save as template (icon-only at 900px to keep the row on one line) -->
<button type="button" aria-label="Save as template" title="Save as template" class="h-7 w-7 inline-flex items-center justify-center rounded-md border border-slate-200 bg-white text-slate-700 shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 22v-8.4a.6.6 0 0 0-.6-.6H9.1a.6.6 0 0 0-.6.6V22l3.5-2.5z"/><path d="M4 19V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v15"/><line x1="12" y1="7" x2="12" y2="11"/><line x1="10" y1="9" x2="14" y2="9"/></svg>
</button>
<!-- Write with AI (highlighted, popover open) -->
<button type="button" class="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-sky-300 bg-sky-100 text-[12px] font-medium text-sky-700 whitespace-nowrap shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/></svg>
Write with AI
</button>
<div class="h-4 w-px bg-slate-200 shrink-0"></div>
<!-- Reset -->
<button type="button" class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] font-medium text-slate-700 opacity-40 whitespace-nowrap shrink-0">Reset</button>
<!-- Save changes -->
<button type="button" class="h-7 px-3 rounded-md bg-sky-600 text-[12px] font-medium text-white inline-flex items-center gap-1.5 opacity-40 whitespace-nowrap shrink-0">Save changes</button>
</div>
</div>
<!-- Composer fields -->
<div class="space-y-4 p-3">
<!-- Step name -->
<div>
<label class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Step name</label>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center">Cold intro</div>
<p class="mt-1.5 text-[10.5px] text-slate-400">Internal label only. Recipients never see it.</p>
</div>
<!-- Subject -->
<div>
<div class="flex items-center justify-between gap-2 mb-1.5">
<label class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block">Subject</label>
<button type="button" class="h-7 px-1.5 inline-flex items-center gap-1 rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
</div>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center">
<span>Quick question about Acme's AWS spend, </span><span class="font-mono text-sky-700">{`{{.FirstName}}`}</span>
</div>
</div>
<!-- Body: Edit/Preview toggle + rich-text editor -->
<div>
<div class="flex items-center justify-between gap-2 mb-1.5">
<label class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block">Body</label>
<div class="inline-flex items-center gap-0.5 rounded-md bg-slate-100 p-0.5">
<span class="h-6 px-2 inline-flex items-center gap-1 rounded text-[11.5px] font-medium bg-white text-slate-900 shadow-sm">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
Edit
</span>
<span class="h-6 px-2 inline-flex items-center gap-1 rounded text-[11.5px] font-medium text-slate-500">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
Preview
</span>
</div>
</div>
<!-- RichTextEditor: bordered card, toolbar + body -->
<div class="rounded-md border border-slate-200 bg-white">
<!-- Toolbar (px-1.5 py-1, gap-0.5, hairline bottom border) -->
<div class="relative flex flex-wrap items-center gap-0.5 border-b border-slate-200/70 px-1.5 py-1">
<!-- Bold -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></svg>
</span>
<!-- Italic -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
</span>
<!-- Underline -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
</span>
<!-- Strikethrough -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
</span>
<span class="mx-0.5 h-4 w-px bg-slate-200"></span>
<!-- Heading 2 -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>
</span>
<!-- Bullet list -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
</span>
<!-- Ordered list -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 12h11"/><path d="M10 18h11"/><path d="M10 6h11"/><path d="M4 10h2"/><path d="M4 6h1v4"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>
</span>
<!-- Link -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/></svg>
</span>
<span class="mx-0.5 h-4 w-px bg-slate-200"></span>
<!-- Variables (Braces + chevron) -->
<span class="h-7 px-1.5 inline-flex items-center gap-1 rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
<!-- Spintax (shuffle) -->
<span class="size-7 inline-flex items-center justify-center rounded text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/><path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/><path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/></svg>
</span>
</div>
<!-- Body content area (empty before Generate). The reaction overlays
here, so it reads as Generate -> body filled. -->
<div class="ai-bodyarea relative tiptap-body min-h-[228px] px-3 py-2.5 text-[13px] leading-relaxed">
<p class="text-slate-300 select-none">Hi {`{{.FirstName}}`}, ...</p>
<!-- REACTION: the generated draft fills the body (white bg so it
cleanly covers the placeholder once revealed). -->
<div class="ai-react" style="position:absolute; left:12px; right:12px; top:10px; background:#ffffff;">
<div class="space-y-3 text-slate-800">
{draftLines.map((line) => (
<p>{line}</p>
))}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
THE STAR: "Write with AI" popover, open at the top-right under
its trigger (align="end"). PopoverMenuContent: rounded-md border
border-slate-200 bg-white, minWidth 320, p-3, the popover shadow.
────────────────────────────────────────────────────────────── -->
<div
class="absolute z-40 rounded-md border border-slate-200 bg-white p-3 overflow-hidden"
style="top:48px; right:12px; min-width:408px; box-shadow:0 4px 12px -2px rgba(15,23,42,0.10),0 12px 32px -8px rgba(15,23,42,0.18);"
>
<div class="space-y-3">
<!-- Prompt (textarea, focused state: sky border + ring) -->
<div>
<label class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">What should this email say?</label>
<div class="w-full rounded-md border border-sky-400 ring-2 ring-sky-100 bg-white px-2.5 py-2 text-[12.5px] text-slate-900 leading-relaxed">
A short intro asking to book a 15-min call about cutting their AWS bill.
</div>
</div>
<!-- Tone: 6 chips, Default selected -->
<div>
<label class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Tone</label>
<div class="flex flex-nowrap gap-1">
{tones.map((t) => (
<span class={`h-6 rounded-md border px-1.5 inline-flex items-center whitespace-nowrap shrink-0 text-[11.5px] font-medium ${
t.selected
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-600"
}`}>{t.label}</span>
))}
</div>
</div>
<!-- Footer: credits remaining + Generate. Credits flip to 2 + a
"Generated" check after the press (via the .ai-credits swap). -->
<div class="flex items-center justify-between gap-2 pt-0.5">
<span class="text-[10.5px] text-slate-400">
<span class="ai-credits-before">3 credits remaining</span>
<span class="ai-credits-after inline-flex items-center gap-1 text-emerald-600 font-medium">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
Generated. 2 credits remaining
</span>
</span>
<button type="button" class="ai-genbtn h-7 px-3 inline-flex items-center gap-1.5 rounded-md bg-sky-600 text-[12px] font-medium text-white whitespace-nowrap shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg>
Generate
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
DEMO SCENE: the cursor glides to the popover "Generate" button,
presses it (28%) - the button shows an active ring; the generated
draft fades into the composer body (~32%) and the footer flips to
"Generated. 2 credits remaining". The cursor then dips into the
body (press #2, 64%). 11s infinite loop.
────────────────────────────────────────────────────────────── -->
<!-- Cursor -->
<div class="ai-cursor" aria-hidden="true">
<span class="ai-ripple"></span>
<svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<style>
/* Reaction (generated draft) hidden until press #1, then rests. */
.ai-react{opacity:0;animation:ai-react 11s ease-out infinite;}
/* Credits text swap: "3 remaining" -> "Generated. 2 remaining" on press. */
.ai-credits-after{display:none;}
.ai-credits-before{animation:ai-credits-before 11s step-end infinite;}
.ai-credits-after{animation:ai-credits-after 11s step-end infinite;}
/* Generate button active state synced with press #1. */
.ai-genbtn{animation:ai-genbtn 11s ease-in-out infinite;}
.ai-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:ai-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.ai-cursor svg{display:block;transform-origin:3px 2px;animation:ai-press 11s ease-in-out infinite;}
.ai-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:ai-rip 11s ease-out infinite;}
@keyframes ai-move{0%{left:18%;top:14%}22%{left:89.2%;top:46.2%}32%{left:89.2%;top:46.2%}58%{left:34%;top:72%}68%{left:34%;top:72%}88%{left:34%;top:72%}100%{left:18%;top:14%}}
@keyframes ai-press{0%,26%{transform:scale(1)}28%{transform:scale(.82)}31%,62%{transform:scale(1)}64%{transform:scale(.82)}67%,100%{transform:scale(1)}}
@keyframes ai-rip{0%,26%{transform:scale(0);opacity:0}29%{transform:scale(.25);opacity:.7}41%{transform:scale(1.9);opacity:0}60%{transform:scale(0);opacity:0}63%{transform:scale(.25);opacity:.7}75%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
@keyframes ai-react{0%,29%{opacity:0;transform:translateY(8px)}33%{opacity:1;transform:translateY(0)}88%{opacity:1;transform:translateY(0)}95%{opacity:0;transform:translateY(8px)}100%{opacity:0;transform:translateY(8px)}}
@keyframes ai-genbtn{0%,27%{background-color:#0284c7;box-shadow:none}28%,31%{background-color:#0369a1;box-shadow:0 0 0 2px rgba(186,230,253,1)}33%,100%{background-color:#0284c7;box-shadow:none}}
@keyframes ai-credits-before{0%,31.9%{display:inline}32%,100%{display:none}}
@keyframes ai-credits-after{0%,31.9%{display:none}32%,92%{display:inline-flex}92.1%,100%{display:none}}
@media (prefers-reduced-motion: reduce){.ai-cursor{display:none}.ai-react{opacity:1;transform:none;animation:none}.ai-genbtn{animation:none}.ai-credits-before{display:none;animation:none}.ai-credits-after{display:inline-flex;animation:none}}
</style>
</div>
+280 -236
View File
@@ -1,20 +1,116 @@
---
import Logo from '../components/Logo.astro';
import DemoCursor from './DemoCursor.astro';
/**
* Pixel-faithful mock of the Warmbly /app/analytics screen.
* The /app/analytics screen, 1:1 with web/src/app/app/analytics/page.tsx:
* PageTopbar with the RangeTabs and Share image button, the four-cell
* StatStrip, the MultiTrend chart (every metric on one graph, dithered area
* per series, three gridlines with compact labels, legend pills that toggle
* a series), the Breakdown and Account health aside, Top campaigns and
* Recent activity. Rendered as the content panel only; the shell wraps it.
*
* Maps 1:1 to the real content panel:
* - web/src/app/app/analytics/page.tsx - PageTopbar / StatStrip / SectionBar / charts / rows
* - web/src/components/ui/charts.tsx - DailyBars (rounded-sm bars, baseline, font-mono ticks)
* - web/src/components/app/analytics/StatsShareCard.tsx - the branded share card
* - web/src/components/app/analytics/AnalyticsShareButton.tsx - the preview modal
*
* Renders only the in-app screen content + its immediate chrome (no global
* sidebar / app header). The marketing page wraps this in its own browser frame.
* The share modal is AnalyticsShareButton's preview: aspect presets with 1:1
* active by default, the 1080x1080 StatsShareCard drawn at true size and
* scaled into its 460px preview box, Copy and Download PNG.
*/
// Exact StatsShareCard gradient/wash/haze (copied from the real component).
// play: the cursor opens and dismisses the share modal on a loop.
// off: the plain screen. open: the modal held open, no cursor.
interface Props { demo?: 'play' | 'off' | 'open' }
const { demo = 'play' } = Astro.props;
const demoClass = demo === 'off' ? 'an-off' : demo === 'open' ? 'an-open' : '';
// daily_trend, Dec 2 to Dec 8. Every series shares one y scale, as MultiTrend does.
const days = ['Dec 2', 'Dec 3', 'Dec 4', 'Dec 5', 'Dec 6', 'Dec 7', 'Dec 8'];
const series = [
{ key: 'sent', label: 'Sent', dot: 'bg-sky-500', rgb: '14,165,233', values: [380, 420, 350, 480, 440, 410, 367] },
{ key: 'opens', label: 'Opens', dot: 'bg-emerald-500', rgb: '16,185,129', values: [118, 132, 108, 150, 138, 128, 117] },
{ key: 'clicks', label: 'Clicks', dot: 'bg-violet-500', rgb: '139,92,246', values: [30, 35, 28, 40, 36, 33, 32] },
{ key: 'replies', label: 'Replies', dot: 'bg-amber-500', rgb: '245,158,11', values: [7, 9, 6, 10, 9, 8, 7] },
];
// MultiTrend geometry: height 280, 18px tick row, 5px top pad, 1px bottom pad.
// The chart column is 1280 minus the 256 sidebar minus the 300 aside minus
// the 20px padding each side, so the SVG is authored at that width.
const CH_W = 684;
const CH_H = 280;
const TICK_H = 18;
const CHART_H = CH_H - TICK_H - 4;
const PAD_TOP = 5;
const PAD_BOTTOM = 1;
const INNER_H = CHART_H - PAD_TOP - PAD_BOTTOM;
const max = Math.max(...series.flatMap((s) => s.values));
const yOf = (v: number) => PAD_TOP + (1 - v / max) * INNER_H;
const xOf = (i: number) => (i / (days.length - 1)) * CH_W;
// Monotone cubic through the points, the same shape the canvas painter draws.
function monotonePath(vals: number[]) {
const n = vals.length;
const xs = vals.map((_, i) => xOf(i));
const ys = vals.map(yOf);
const d: number[] = [];
for (let i = 0; i < n - 1; i++) d.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]));
const m: number[] = new Array(n).fill(0);
m[0] = d[0];
m[n - 1] = d[n - 2];
for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
let path = `M ${xs[0].toFixed(1)} ${ys[0].toFixed(1)}`;
for (let i = 0; i < n - 1; i++) {
const dx = (xs[i + 1] - xs[i]) / 3;
path += ` C ${(xs[i] + dx).toFixed(1)} ${(ys[i] + m[i] * dx).toFixed(1)}, ${(xs[i + 1] - dx).toFixed(1)} ${(ys[i + 1] - m[i + 1] * dx).toFixed(1)}, ${xs[i + 1].toFixed(1)} ${ys[i + 1].toFixed(1)}`;
}
return path;
}
const curves = series.map((s) => {
const line = monotonePath(s.values);
return { ...s, line, area: `${line} L ${CH_W} ${CHART_H} L 0 ${CHART_H} Z` };
});
const compact = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n)}`);
const grid = [0.25, 0.5, 0.75].map((f) => ({ top: PAD_TOP + (1 - f) * INNER_H, label: compact(max * f) }));
const statStrip = [
{ label: 'Total sent', value: '2,847', sub: 'in range', accent: true, last: false },
{ label: 'Open rate', value: '31.3%', sub: 'after delivery', accent: false, last: false },
{ label: 'Reply rate', value: '2.0%', sub: 'incl. positive', accent: false, last: false },
{ label: 'Bounce rate', value: '0.5%', sub: 'hard + soft', accent: false, last: true },
];
const breakdown = [
{ label: 'Sent', value: '2,847', dot: 'bg-slate-400' },
{ label: 'Opens', value: '891', dot: 'bg-emerald-500', note: '142 auto' },
{ label: 'Clicks', value: '234', dot: 'bg-violet-500' },
{ label: 'Replies', value: '56', dot: 'bg-amber-500' },
{ label: 'Bounces', value: '14', dot: 'bg-rose-500' },
];
const health = [
{ n: '6', label: 'Healthy', tone: 'text-emerald-600' },
{ n: '1', label: 'At risk', tone: 'text-amber-600' },
{ n: '1', label: 'Issues', tone: 'text-rose-600' },
];
const campaigns = [
{ name: 'Q4 Outreach - Tech Leaders', status: 'active', sent: '850', open: '38.5%', reply: '2.8%' },
{ name: 'Win-back: Former Customers', status: 'active', sent: '620', open: '26.3%', reply: '1.6%' },
{ name: 'Product Launch Beta Access', status: 'paused', sent: '480', open: '42.1%', reply: '3.1%' },
];
const statusDot = (s: string) => (s === 'active' ? 'bg-emerald-500' : s === 'paused' ? 'bg-amber-500' : 'bg-slate-300');
// lucide "activity"
const activityPath = '<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>';
const activity = [
{ tone: 'text-emerald-600', verb: 'Opened by', email: 'sarah.chen@techcorp.com', campaign: 'Q4 Outreach - Tech Leaders', ts: 'Dec 8, 02:32 PM' },
{ tone: 'text-violet-600', verb: 'Clicked by', email: 'mark.ross@northwind.dev', campaign: 'Win-back: Former Customers', ts: 'Dec 8, 02:18 PM' },
{ tone: 'text-amber-600', verb: 'Reply from', email: 'jess@helio.gen', campaign: 'Product Launch Beta Access', ts: 'Dec 8, 01:54 PM' },
{ tone: 'text-slate-500', verb: 'Sent to', email: 'omar@brightco.io', campaign: 'Q4 Outreach - Tech Leaders', ts: 'Dec 8, 01:40 PM' },
{ tone: 'text-rose-600', verb: 'Bounced', email: 'lena@oldmail.example', campaign: 'Win-back: Former Customers', ts: 'Dec 8, 01:12 PM' },
];
// StatsShareCard, 1:1 preset: 1080x1080, drawn at true size and scaled into
// the 460px preview box the modal gives it.
const CARD = 1080;
const PREVIEW = 460;
const SKY_BASE =
'radial-gradient(ellipse 125% 105% at 50% -12%,' +
' #9fe0fb 0%, #74cef7 30%, #4ec1f6 58%, #2fb6f2 82%, #18abed 100%)';
@@ -23,103 +119,44 @@ const SKY_BREATHE =
' rgba(224,242,254,0.85) 0%, rgba(125,211,252,0.38) 24%, rgba(56,189,248,0.14) 46%, transparent 64%)';
const HAZE_BOTTOM =
'linear-gradient(to top, rgba(186,230,253,0.40) 0%, rgba(125,211,252,0.16) 42%, transparent 100%)';
// The card's "Sends over time" area chart, computed from the same trend the
// dashboard shows (ShareAreaChart geometry: viewBox 0 0 1000 320, baseline y=314).
const shareLine = 'M 4.0 76.5 L 169.3 51.5 L 334.7 95.2 L 500.0 14.0 L 665.3 39.0 L 830.7 57.8 L 996.0 84.6';
const shareArea = `${shareLine} L 996.0 314 L 4 314 Z`;
// The 4-metric divided row inside the share card (matches the StatStrip values).
const shareMetrics = [
{ label: 'Sent', value: '2,847' },
{ label: 'Sent', value: '2,847', sub: 'emails' },
{ label: 'Open rate', value: '31.3%' },
{ label: 'Reply rate', value: '2.0%' },
{ label: 'Bounce rate', value: '0.5%' },
];
// daily_trend "sent" series - Dec 2 … Dec 8
const trend = [
{ date: 'Dec 2', sent: 380 },
{ date: 'Dec 3', sent: 420 },
{ date: 'Dec 4', sent: 350 },
{ date: 'Dec 5', sent: 480 },
{ date: 'Dec 6', sent: 440 },
{ date: 'Dec 7', sent: 410 },
{ date: 'Dec 8', sent: 367 },
];
const maxSent = Math.max(...trend.map((p) => p.sent));
const statStrip = [
{ label: 'Total sent', value: '2,847', sub: 'in range', accent: false, last: false },
{ label: 'Open rate', value: '31.3%', sub: 'after delivery', accent: true, last: false },
{ label: 'Reply rate', value: '2.0%', sub: 'incl. positive', accent: false, last: false },
{ label: 'Bounce rate', value: '0.5%', sub: 'hard + soft', accent: false, last: true },
];
const metricTabs = [
{ label: 'Sent', active: true },
{ label: 'Opens', active: false },
{ label: 'Clicks', active: false },
{ label: 'Replies', active: false },
];
const breakdown = [
{ label: 'Sent', value: '2,847', dot: 'bg-slate-400' },
{ label: 'Opens', value: '891', dot: 'bg-emerald-500' },
{ label: 'Clicks', value: '234', dot: 'bg-violet-500' },
{ label: 'Replies', value: '56', dot: 'bg-amber-500' },
{ label: 'Bounces', value: '14', dot: 'bg-rose-500' },
];
const health = [
{ n: '6', label: 'Healthy', tone: 'text-emerald-600' },
{ n: '1', label: 'At risk', tone: 'text-amber-600' },
{ n: '1', label: 'Issues', tone: 'text-rose-600' },
];
const campaigns = [
{ name: 'Q4 Outreach - Tech Leaders', status: 'active', sent: '850', open: '38.5%', reply: '2.8%' },
{ name: 'Win-back: Former Customers', status: 'active', sent: '620', open: '26.3%', reply: '1.6%' },
{ name: 'Product Launch Beta Access', status: 'paused', sent: '480', open: '42.1%', reply: '3.1%' },
];
const statusDot = (s: string) =>
s === 'active' ? 'bg-emerald-500' : s === 'paused' ? 'bg-amber-500' : 'bg-slate-300';
// activity icon path (lucide "activity")
const activityPath = '<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>';
const activity = [
{ tone: 'text-emerald-600', verb: 'Opened by', email: 'sarah.chen@techcorp.com', campaign: 'Q4 Outreach', ts: 'Dec 8, 02:32 PM' },
{ tone: 'text-violet-600', verb: 'Clicked by', email: 'mark.ross@northwind.dev', campaign: 'Win-back', ts: 'Dec 8, 02:18 PM' },
{ tone: 'text-amber-600', verb: 'Reply from', email: 'jess@helio.gen', campaign: 'Product Launch', ts: 'Dec 8, 01:54 PM' },
{ label: 'Reply rate', value: '2.0%' },
{ label: 'Bounce rate', value: '0.5%' },
];
// ShareAreaChart: viewBox 1000x320, padX 4, padTop 14, baseline 314.
const sent = series[0].values;
const sMax = Math.max(...sent);
const sPts = sent.map((v, i) => [4 + (i * 992) / (sent.length - 1), 314 - (v / sMax) * 300] as const);
const shareLine = sPts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4 314 Z`;
---
<div class="relative w-full bg-white text-slate-900 min-h-[480px] flex flex-col">
<!-- ── PageTopbar ── -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
<div class={`an relative w-full bg-white text-slate-900 min-h-full flex flex-col ${demoClass}`} data-demo={demo === 'play' ? 'an' : undefined}>
<!-- PageTopbar -->
<div class="min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Analytics</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">Deliverability across the workspace</span>
<div class="ml-auto flex items-center gap-2">
<div class="ml-auto flex items-center gap-1.5 min-w-0 flex-wrap justify-end md:flex-nowrap">
<!-- RangeTabs -->
<div class="inline-flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5 shrink-0">
<button class="h-6 px-2 rounded text-[11px] font-medium tabular-nums bg-slate-900 text-white whitespace-nowrap shrink-0">7d</button>
<button class="h-6 px-2 rounded text-[11px] font-medium tabular-nums text-slate-500 whitespace-nowrap shrink-0">30d</button>
<button class="h-6 px-2 rounded text-[11px] font-medium tabular-nums text-slate-500 whitespace-nowrap shrink-0">90d</button>
<div class="inline-flex items-center gap-0.5 rounded-md bg-slate-100 p-0.5">
<span class="h-7 px-2.5 rounded text-[12px] font-medium bg-white text-slate-900 shadow-sm inline-flex items-center">7d</span>
<span class="h-7 px-2.5 rounded text-[12px] font-medium text-slate-500 inline-flex items-center">30d</span>
<span class="h-7 px-2.5 rounded text-[12px] font-medium text-slate-500 inline-flex items-center">90d</span>
</div>
<!-- Share image -->
<button class="an-share h-7 px-2.5 rounded-md border border-slate-200 bg-white text-slate-700 text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
Share image
<!-- AnalyticsShareButton -->
<button class="an-share h-7 px-2.5 rounded-md border border-slate-200 text-slate-700 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors [&.is-hover]:border-slate-300 [&.is-hover]:text-slate-900">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
<span class="hidden sm:inline">Share image</span>
</button>
</div>
</div>
<!-- ── StatStrip ── -->
<div class="grid grid-cols-4 border-b border-slate-200 shrink-0">
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
{statStrip.map((s) => (
<div class={`px-5 py-4 ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class={`group px-5 py-4 transition-colors ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
@@ -130,42 +167,57 @@ const activity = [
))}
</div>
<!-- ── Main grid: performance + sidebar ── -->
<div class="grid grid-cols-[1fr_300px]">
<!-- LEFT: Email performance -->
<section class="flex flex-col border-r border-slate-200">
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-2.5 shrink-0">
<!-- Performance + aside -->
<div class="grid lg:grid-cols-[1fr_300px] min-h-0 flex-1">
<section class="flex flex-col min-h-0 lg:border-r lg:border-slate-200">
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Email performance</span>
<div class="ml-auto inline-flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5">
{metricTabs.map((m) => (
<button class={`h-6 px-2 rounded text-[11px] font-medium whitespace-nowrap shrink-0 ${m.active ? 'bg-slate-900 text-white' : 'text-slate-500'}`}>{m.label}</button>
<div class="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0"><div class="inline-flex items-center gap-0.5 rounded-md bg-slate-100 p-0.5 max-w-full overflow-x-auto">
{series.map((m) => (
<span class="h-6 px-2 rounded text-[11px] font-medium transition-colors inline-flex items-center gap-1.5 bg-white text-slate-900 shadow-sm">
<span class={`size-1.5 rounded-full ${m.dot}`}></span>
{m.label}
</span>
))}
</div>
</div></div>
</div>
<!-- Bar chart -->
<div class="px-5 py-4">
<div class="relative w-full select-none" style="height:176px;">
<div class="absolute inset-x-0 top-0 flex items-end gap-1.5" style="height:150px;">
{trend.map((p) => (
<div class="relative flex-1 flex items-end justify-center h-full">
<div class="w-full rounded-sm bg-sky-500" style={`height:${Math.max(2, (p.sent / maxSent) * 150)}px; max-width:64px;`}></div>
</div>
<div class="flex-1 px-5 py-4">
<!-- MultiTrend -->
<div class="relative w-full select-none" style={`height:${CH_H}px;`}>
{grid.map((g) => (
<div class="pointer-events-none absolute left-0 right-0" style={`top:${g.top}px;`}>
<div class="h-px bg-slate-100"></div>
<span class="absolute right-0 -top-3.5 font-mono text-[9px] tabular-nums text-slate-300">{g.label}</span>
</div>
))}
<svg class="absolute left-0 top-0 block w-full" style={`height:${CHART_H}px;`} viewBox={`0 0 ${CH_W} ${CHART_H}`} preserveAspectRatio="none" aria-hidden="true">
<defs>
<!-- DitherMultiAreaChart: one blended wash, denser under the line, capped at half strength -->
{curves.map((c) => (
<linearGradient id={`an-g-${c.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={`rgb(${c.rgb})`} stop-opacity="0.32"/>
<stop offset="100%" stop-color={`rgb(${c.rgb})`} stop-opacity="0.05"/>
</linearGradient>
))}
</defs>
<g style="mix-blend-mode:multiply;">
{curves.map((c) => <path d={c.area} fill={`url(#an-g-${c.key})`}/>)}
</g>
{curves.map((c) => (
<path d={c.line} fill="none" stroke={`rgb(${c.rgb})`} stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
))}
</div>
<!-- baseline -->
<div class="absolute left-0 right-0 h-px bg-slate-200/80" style="bottom:18px;"></div>
<!-- date ticks -->
<div class="absolute left-0 right-0 bottom-0 flex justify-between font-mono text-[9.5px] text-slate-400 tabular-nums">
<span>{trend[0].date}</span>
<span>{trend[trend.length - 1].date}</span>
</svg>
<div class="absolute left-0 right-0 h-px bg-slate-200/80" style={`bottom:${TICK_H}px;`}></div>
<div class="absolute left-0 right-0 bottom-0 flex justify-between font-mono text-[9.5px] text-slate-400">
<span>{days[0]}</span>
<span>{days[days.length - 1]}</span>
</div>
</div>
</div>
</section>
<!-- RIGHT: Breakdown + Account health -->
<aside class="flex flex-col bg-slate-50/40 border-slate-200">
<div class="h-9 px-4 border-b border-slate-200/60 flex items-center shrink-0">
<aside class="flex flex-col min-h-0 bg-slate-50/40">
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Breakdown</span>
</div>
<div class="divide-y divide-slate-200/60">
@@ -173,11 +225,12 @@ const activity = [
<div class="h-9 px-4 flex items-center gap-2">
<span class={`size-1.5 rounded-full ${q.dot}`}></span>
<span class="text-[12px] text-slate-700">{q.label}</span>
{q.note && <span class="text-[9.5px] text-slate-400 font-mono">{q.note}</span>}
<span class="ml-auto font-mono text-[11px] text-slate-500 tabular-nums">{q.value}</span>
</div>
))}
</div>
<div class="h-9 px-4 border-y border-slate-200/60 flex items-center shrink-0">
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Account health</span>
</div>
<div class="px-4 py-3 grid grid-cols-3 gap-2 text-center">
@@ -191,8 +244,8 @@ const activity = [
</aside>
</div>
<!-- ── Top campaigns ── -->
<div class="h-9 px-5 border-y border-slate-200/60 flex items-center gap-2.5 shrink-0">
<!-- Top campaigns -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-x-2.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Top campaigns</span>
<span class="ml-auto inline-flex items-center gap-1 text-[11px] text-slate-500">
All campaigns
@@ -201,41 +254,39 @@ const activity = [
</div>
<div class="divide-y divide-slate-200/60">
{campaigns.map((c) => (
<div class="group h-11 px-5 flex items-center gap-3 hover:bg-slate-50">
<div class="group h-11 px-5 flex items-center gap-3 transition-colors">
<span class={`size-1.5 rounded-full shrink-0 ${statusDot(c.status)}`}></span>
<span class="text-[12.5px] font-medium text-slate-900 truncate max-w-[40%]">{c.name}</span>
<span class="ml-auto flex items-center gap-4 font-mono text-[11px] text-slate-500 tabular-nums shrink-0">
<span class="ml-auto flex items-center gap-2 md:gap-4 font-mono text-[11px] text-slate-500 tabular-nums shrink-0">
<span>{c.sent} sent</span>
<span class="text-emerald-600">{c.open} open</span>
<span class="hidden md:inline text-emerald-600">{c.open} open</span>
<span class="text-amber-600">{c.reply} reply</span>
</span>
</div>
))}
</div>
<!-- ── Recent activity ── -->
<div class="h-9 px-5 border-y border-slate-200/60 flex items-center shrink-0">
<!-- Recent activity -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Recent activity</span>
</div>
<div class="divide-y divide-slate-200/60">
<div class="flex-1 min-h-0 overflow-hidden divide-y divide-slate-200/60">
{activity.map((a) => (
<div class="h-10 px-5 flex items-center gap-3">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class={`shrink-0 ${a.tone}`} set:html={activityPath}></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`shrink-0 ${a.tone}`} set:html={activityPath}></svg>
<span class="text-[12.5px] text-slate-900 truncate">
<span class={a.tone}>{a.verb}</span> {a.email}
</span>
<span class="text-[11.5px] text-slate-400 truncate inline">{a.campaign}</span>
<span class="text-[11.5px] text-slate-400 truncate hidden md:inline">{a.campaign}</span>
<span class="ml-auto font-mono text-[10.5px] text-slate-400 tabular-nums shrink-0">{a.ts}</span>
</div>
))}
</div>
<!-- ── Demo scene: cursor + Share image reaction ── -->
<!-- Reaction: the AnalyticsShareButton preview modal (scrim + centered card) -->
<div class="an-react absolute inset-0 z-40 flex items-center justify-center" aria-hidden="true">
<div class="absolute inset-0 bg-slate-900/30"></div>
<div class="an-modal relative w-full max-w-[680px] mx-6 rounded-lg bg-white border border-slate-200 shadow-xl overflow-hidden">
<!-- modal header -->
<!-- Share image preview modal (AnalyticsShareButton) -->
<!-- Portaled to the body in the app: fixed over the whole window, centred on the screen. -->
<div class="an-react fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4" aria-hidden="true">
<div class="an-modal w-full max-w-[min(94vw,860px)] max-h-[calc(100dvh-2rem)] flex flex-col rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden">
<div class="h-12 px-4 border-b border-slate-200 flex items-center gap-2 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Share image</span>
<div class="h-4 w-px bg-slate-200"></div>
@@ -245,127 +296,120 @@ const activity = [
</span>
</div>
<!-- aspect preset selector -->
<div class="px-4 pt-3 flex items-center gap-2">
<div class="px-4 pt-3 flex items-center gap-2 shrink-0">
<span class="text-[11px] text-slate-500 font-medium">Aspect</span>
<div class="inline-flex items-center gap-1 rounded-md bg-slate-100 p-0.5 shrink-0">
<button class="h-7 px-3 rounded text-[12px] font-medium text-slate-600 whitespace-nowrap shrink-0">1:1</button>
<button class="h-7 px-3 rounded text-[12px] font-medium bg-sky-600 text-white shadow-sm whitespace-nowrap shrink-0">3:2</button>
<button class="h-7 px-3 rounded text-[12px] font-medium text-slate-600 whitespace-nowrap shrink-0">16:9</button>
<div class="inline-flex items-center gap-1 rounded-md bg-slate-100 p-0.5">
<span class="h-7 px-3 rounded text-[12px] font-medium transition-colors bg-sky-600 text-white shadow-sm inline-flex items-center">1:1</span>
<span class="h-7 px-3 rounded text-[12px] font-medium transition-colors text-slate-600 inline-flex items-center">3:2</span>
<span class="h-7 px-3 rounded text-[12px] font-medium transition-colors text-slate-600 inline-flex items-center">16:9</span>
</div>
</div>
<!-- preview: the full StatsShareCard rendered at 3:2 -->
<div class="px-4 py-4 bg-slate-50/40 flex justify-center">
<div class="relative rounded-md border border-slate-200 overflow-hidden" style="width:618px;aspect-ratio:3 / 2;">
<!-- card: symmetric sky base + breathe + bottom haze -->
<div class="absolute inset-0" style={`background:${SKY_BASE};`}></div>
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute inset-0" style={`background:${SKY_BREATHE};opacity:0.6;`}></div>
<div class="absolute inset-x-0 bottom-0" style={`height:55%;background:${HAZE_BOTTOM};`}></div>
</div>
<!-- card content (pad scaled to the 3:2 preset, ~56 of 1620w) -->
<div class="relative z-10 flex flex-col h-full" style="padding:21px;">
<!-- logo on the sky + date -->
<div class="flex items-center justify-between">
<div class="inline-flex items-center gap-[5px]">
<Logo class="w-[23px] h-[23px] text-white" />
<span class="text-white font-extrabold tracking-tight leading-none" style="font-size:16px;">Warmbly</span>
</div>
<span class="font-mono text-[8px] tabular-nums text-white/85">Dec 8, 2025</span>
<div class="px-4 py-4 bg-slate-50/40 flex-1 min-h-0 overflow-y-auto flex justify-center items-start">
<div class="relative max-w-full rounded-md border border-slate-200 bg-white overflow-hidden" style={`aspect-ratio:1/1; width:min(100%, min(${PREVIEW}px, 58dvh));`}>
<!-- StatsShareCard at 1080x1080, scaled to the preview -->
<div class="absolute top-0 left-0 origin-top-left overflow-hidden" style={`width:${CARD}px; height:${CARD}px; transform:scale(${PREVIEW / CARD}); background:${SKY_BASE};`}>
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute inset-0" style={`background:${SKY_BREATHE}; opacity:0.6;`}></div>
<div class="absolute inset-x-0 bottom-0" style={`height:55%; background:${HAZE_BOTTOM};`}></div>
</div>
<!-- single white panel -->
<div class="mt-[10px] flex-1 flex flex-col min-h-0" style="background:#ffffff;border-radius:11px;border:1px solid rgba(255,255,255,0.85);">
<div class="flex flex-col h-full" style="padding:17px;">
<div class="text-[8px] font-semibold uppercase tracking-[0.16em] text-sky-600">Workspace performance</div>
<h1 class="mt-1 font-semibold leading-tight tracking-tight text-slate-900" style="font-size:17px;">Deliverability across the workspace</h1>
<!-- divided metric row -->
<div class="mt-3 grid grid-cols-4 divide-x divide-slate-200">
{shareMetrics.map((m) => (
<div class="px-3 first:pl-0 last:pr-0">
<div class="text-[7px] font-medium uppercase tracking-[0.14em] text-slate-400">{m.label}</div>
<div class="mt-1 font-mono leading-none tabular-nums text-slate-900" style="font-size:19px;">{m.value}</div>
<div class="relative z-10 flex flex-col h-full" style="padding:48px;">
<div class="flex items-center justify-between">
<div class="inline-flex items-center gap-3.5">
<Logo class="w-[54px] h-[54px] text-white" />
<span class="text-white font-extrabold tracking-tight" style="font-family:var(--font-display); font-size:36px;">Warmbly</span>
</div>
<span class="font-mono text-[15px] tabular-nums text-white/85">Dec 8, 2025</span>
</div>
<div class="mt-6 flex-1 flex flex-col min-h-0" style="background:#ffffff; border-radius:26px; border:1px solid rgba(255,255,255,0.85);">
<div class="flex flex-col h-full" style="padding:40px;">
<div class="text-[14px] font-semibold uppercase tracking-[0.16em] text-sky-600">Last 7 days</div>
<h1 class="mt-2 font-semibold leading-tight tracking-tight text-slate-900 line-clamp-2 break-words" style="font-size:40px;">Workspace performance</h1>
<div class="mt-8 grid grid-cols-4 divide-x divide-slate-200">
{shareMetrics.map((m) => (
<div class="px-7 first:pl-0 last:pr-0">
<div class="text-[14px] font-medium uppercase tracking-[0.14em] text-slate-400">{m.label}</div>
<div class="mt-2.5 font-mono leading-none tabular-nums text-slate-900" style="font-size:46px;">{m.value}</div>
{m.sub && <div class="mt-2 text-[14px] text-slate-400">{m.sub}</div>}
</div>
))}
</div>
<div class="mt-8 h-px bg-slate-200/70"></div>
<div class="mt-6 flex items-center justify-between">
<span class="text-[14px] font-medium uppercase tracking-[0.14em] text-slate-400">Sends over time</span>
</div>
<div class="mt-4 flex-1 min-h-0 flex flex-col">
<div class="relative flex-1 min-h-0">
<svg viewBox="0 0 1000 320" preserveAspectRatio="none" width="100%" height="100%" style="display:block;">
<defs>
<linearGradient id="anShareArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0"/>
</linearGradient>
</defs>
<path d={shareArea} fill="url(#anShareArea)"/>
<path d={shareLine} fill="none" stroke="#0284c7" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
<line x1="4" y1="314" x2="996" y2="314" stroke="#cbd5e1" stroke-width="2" vector-effect="non-scaling-stroke"/>
</svg>
</div>
<div class="mt-3 flex justify-between font-mono text-[12px] tabular-nums text-slate-400">
<span>{days[0]}</span>
<span>{days[days.length - 1]}</span>
</div>
))}
</div>
<div class="mt-3 h-px bg-slate-200/70"></div>
<!-- long area chart -->
<div class="mt-2.5 flex items-center justify-between">
<span class="text-[7px] font-medium uppercase tracking-[0.14em] text-slate-400">Sends over time</span>
</div>
<div class="mt-1.5 flex-1 min-h-0 flex flex-col">
<div class="relative flex-1 min-h-0">
<svg viewBox="0 0 1000 320" preserveAspectRatio="none" width="100%" height="100%" style="display:block;">
<defs>
<linearGradient id="anShareArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0"/>
</linearGradient>
</defs>
<path d={shareArea} fill="url(#anShareArea)"/>
<path d={shareLine} fill="none" stroke="#0284c7" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
<line x1="4" y1="314" x2="996" y2="314" stroke="#cbd5e1" stroke-width="2" vector-effect="non-scaling-stroke"/>
</svg>
</div>
<div class="mt-1 flex justify-between font-mono text-[6px] tabular-nums text-slate-400">
<span>Dec 2</span>
<span>Dec 8</span>
</div>
</div>
</div>
</div>
<!-- footer on the sky -->
<div class="mt-[7px] flex items-center justify-between" style="font-size:8px;">
<span class="text-white font-semibold">warmbly.com</span>
<span class="text-white/80">Cold email, warmed up.</span>
<div class="mt-5 flex items-center justify-between text-[16px]">
<span class="text-white font-semibold">warmbly.com</span>
<span class="text-white/80">Cold email, warmed up.</span>
</div>
</div>
</div>
</div>
</div>
<!-- modal footer: Copy + Download PNG -->
<div class="px-4 py-3 border-t border-slate-200 flex items-center justify-end gap-2">
<span class="h-8 px-3 rounded-md border border-slate-200 bg-white text-slate-700 text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<div class="px-4 py-3 border-t border-slate-200 flex items-center justify-end gap-2 shrink-0">
<span class="h-8 px-3 rounded-md border border-slate-200 bg-white text-slate-700 text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
Copy
</span>
<button class="h-8 px-3 rounded-md bg-slate-900 text-white text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
<span class="h-8 px-3 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
Download PNG
</button>
</span>
</div>
</div>
</div>
<!-- Cursor -->
<div class="an-cursor" aria-hidden="true">
<span class="an-ripple"></span>
<svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<DemoCursor />
<!-- Scene styles: AnalyticsShareButton's own enter and exit (scrim fade,
card 8px lift + 0.97 scale over 180ms ease-out), driven by .is-open. -->
<style>
.an-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:an-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.an-cursor svg{display:block;transform-origin:3px 2px;animation:an-press 11s ease-in-out infinite;}
.an-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:an-rip 11s ease-out infinite;}
.an-react{position:absolute;z-index:40;opacity:0;animation:an-react 11s ease-out infinite;}
.an-modal{transform-origin:center;}
/* press #1 (~28%) flashes the topbar "Share image" button (its computed center) */
.an-share{animation:an-share-press 11s ease-out infinite;}
/* press #1 lands on the Share image button center (x 92%, y 4% of the mock);
press #2 lands on the dark scrim in the upper-left corner (x 8%, y 16%) and
dismisses the modal via the click-outside timeline. */
@keyframes an-move{0%{left:30%;top:80%}22%{left:92%;top:4%}32%{left:92%;top:4%}58%{left:8%;top:16%}68%{left:8%;top:16%}88%{left:8%;top:16%}100%{left:30%;top:80%}}
@keyframes an-press{0%,26%{transform:scale(1)}28%{transform:scale(.82)}31%,62%{transform:scale(1)}64%{transform:scale(.82)}67%,100%{transform:scale(1)}}
@keyframes an-rip{0%,26%{transform:scale(0);opacity:0}29%{transform:scale(.25);opacity:.7}41%{transform:scale(1.9);opacity:0}60%{transform:scale(0);opacity:0}63%{transform:scale(.25);opacity:.7}75%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
/* dismissable popup timeline: opens after press #1, closes after press #2 */
@keyframes an-react{0%,29%{opacity:0;transform:scale(.94)}33%{opacity:1;transform:scale(1)}60%{opacity:1;transform:scale(1)}66%{opacity:0;transform:scale(.94)}100%{opacity:0;transform:scale(.94)}}
@keyframes an-share-press{0%,26%{box-shadow:none;border-color:rgb(226,232,240)}28%{box-shadow:0 0 0 2px rgb(186,230,253);border-color:rgb(125,211,252)}33%,100%{box-shadow:none;border-color:rgb(226,232,240)}}
@media (prefers-reduced-motion: reduce){.an-cursor{display:none}.an-react{display:none}.an-share{animation:none}}
.an-react{opacity:0;pointer-events:none;transition:opacity .3s ease-out;}
.an-modal{opacity:0;transform:translateY(8px) scale(.97);transform-origin:center;transition:opacity .18s ease-out,transform .18s ease-out;}
.an.is-open .an-react{opacity:1;}
.an.is-open .an-modal{opacity:1;transform:none;}
/* demo="off": the bare screen. demo="open": the modal held open, no cursor. */
.an-off .an-react,.an-off .demo-cursor,.an-open .demo-cursor{display:none;}
.an-open .an-react,.an-open .an-modal{opacity:1;transform:none;transition:none;}
.an.is-static .an-react{display:none;}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
document.querySelectorAll<HTMLElement>('[data-demo="an"]').forEach((root) => {
playCursor(root, {
loop: 11000,
steps: [
{ at: 0, x: 30, y: 80 },
{ at: 2400, el: '.an-share', ox: 0.5, oy: 0.55, hover: '.an-share' },
{ at: 2540, press: true, target: '.an-share', add: ['is-open'] },
{ at: 5800, x: 8, y: 16 },
{ at: 5940, press: true, remove: ['is-open'] },
{ at: 8600, x: 30, y: 80 },
],
});
});
</script>
+491
View File
@@ -0,0 +1,491 @@
---
/**
* The Warmbly app shell, drawn around any screen mock.
*
* Maps 1:1 to the real chrome:
* - web/src/components/layout/SkyChrome.tsx - the off-white #f5f6f8 base
* - web/src/components/layout/AppHeader.tsx - the h-14 breadcrumb bar with
* PlanPill, CreditsMeter, PresenceAvatars, ConnectionIndicator,
* NotificationBell, the assistant button and Search
* - web/src/components/layout/AppNav.tsx - the w-64 sidebar: LivePanel,
* nav rows with their live counts (TabStat / TabDualStat and the
* campaign dot-grid, warmup flame and overdue ping motifs)
*
* The counts tick the way the realtime layer ticks them in the app: sends
* land every couple of seconds (hero number, capacity meter, Analytics), the
* inbox picks up unread replies, mailboxes start and stop warming.
*
* The shell is authored at 1280x900, a desktop browser viewport, so a window
* holding it has the proportions of an actual screenshot. 56px of that is the
* header, which is why the body floor is 844: the height the real nav needs
* to show every row, Audit log included, without scrolling.
*
* `data-stage` + contain:paint make the shell the containing block for
* anything fixed inside it, so a mock's dialog covers header and sidebar
* exactly as the portaled one covers the real viewport, and the demo cursor
* can travel the whole screen.
*
* `chrome="lg"` drops the header and sidebar below lg, so a narrow frame
* shows the screen alone instead of spending half a phone width on nav.
* `chrome="always"` keeps the whole desktop shell at every width (the hero
* deliberately bleeds a desktop shot off the right edge on mobile).
*/
import { nextId } from '../lib/uid';
interface Props {
/** Breadcrumb leaf + the highlighted sidebar row. */
crumb: string;
/** Sidebar row to mark active; defaults to the crumb. */
active?: string;
/** Extra breadcrumb segment after the section, e.g. "Q1 sales". */
sub?: string;
chrome?: 'always' | 'lg';
}
const { crumb, active = crumb, sub, chrome = 'lg' } = Astro.props;
const shellShow = chrome === 'always' ? 'flex' : 'hidden lg:flex';
const headerShow = chrome === 'always' ? 'flex' : 'hidden lg:flex';
// The sidebar is taken out of flow so the screen mock, not the nav, decides
// how tall the shell is; the nav clips and the footer stays pinned, exactly
// as it behaves in the app.
const mainEdge =
chrome === 'always'
? 'ml-64 rounded-tl-2xl border-t border-l border-slate-200/70'
: 'border-t border-slate-200/70 lg:ml-64 lg:rounded-tl-2xl lg:border-l';
// 1280x900 minus the 56px header. Fixed, so every window is exactly a viewport
// whichever screen is inside it, and the sidebar always draws itself whole.
const bodyMin = chrome === 'always' ? 'min-h-[844px]' : 'lg:min-h-[844px]';
const navIcons: Record<string, string> = {
Inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
Accounts: '<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/>',
Campaigns: '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
Contacts: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/>',
Forms: '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>',
Analytics: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
Deliverability: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
Tasks: '<path d="M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11.344"/><path d="m9 11 3 3L22 4"/>',
Meetings: '<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><path d="M17.5 17.5 16 16.25V14"/><circle cx="16" cy="16" r="6"/>',
Templates: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
Automations: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
Pipelines: '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
Deals: '<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>',
Integrations:'<path d="M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v2"/><path d="M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9"/><path d="M21 21v-2h-4"/><path d="M3 5h4V3"/><path d="M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3"/>',
'API Keys': '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
'Audit log': '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
};
const flameIcon = '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>';
// compactN from AppNav.tsx: 12.3k, 48k, 1.2M.
function compactN(n: number): string {
const v = Math.round(n);
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${Math.round(v / 1000)}k`;
if (v >= 1_000) return `${(v / 1000).toFixed(1)}k`;
return String(v);
}
// The count cluster at the end of a nav row. `active` makes it a dual stat
// (light total | coloured live subset with its motif); `live` names the
// counter the ticker updates.
interface Stat {
total: number;
active?: number;
glyph?: 'grid' | 'flame' | 'ping-red' | 'ping-sky' | 'ping-amber';
activeClass?: string;
live?: string;
liveActive?: string;
}
interface Row { title: string; badge?: boolean; stat?: Stat }
// The `sections` table in AppNav.tsx, row for row, as the owner sees it.
const sidebarSections: { label: string; items: Row[] }[] = [
{ label: '', items: [{ title: 'Inbox', badge: true }] },
{ label: 'Email', items: [
{ title: 'Accounts', stat: { total: 128, active: 96, glyph: 'flame', activeClass: 'text-orange-500', liveActive: 'warming' } },
{ title: 'Campaigns', stat: { total: 24, active: 7, glyph: 'grid', activeClass: 'text-sky-600', liveActive: 'sending' } },
{ title: 'Contacts', stat: { total: 48213 } },
{ title: 'Forms' },
{ title: 'Analytics', stat: { total: 84320, live: 'period' } },
{ title: 'Deliverability' },
]},
{ label: 'CRM', items: [
{ title: 'Pipelines', stat: { total: 3 } },
{ title: 'Deals', stat: { total: 41, live: 'deals' } },
{ title: 'Tasks', stat: { total: 18, active: 2, glyph: 'ping-red', activeClass: 'text-red-600' } },
{ title: 'Meetings', stat: { total: 6, active: 1, glyph: 'ping-sky', activeClass: 'text-sky-600' } },
]},
{ label: 'Resources', items: [
{ title: 'Templates', stat: { total: 12 } },
{ title: 'Integrations', stat: { total: 5, active: 0, glyph: 'ping-amber', activeClass: 'text-amber-600' } },
{ title: 'Automations' },
{ title: 'API Keys', stat: { total: 3 } },
{ title: 'Audit log' },
]},
];
const COUNT_LIGHT = 'text-[10.5px] font-medium tabular-nums leading-none text-slate-300';
// ── LivePanel data ───────────────────────────────────────────────
// Mirrors AppNav.tsx: today's sends against the summed per-mailbox daily
// cap (128 mailboxes x 50/day), a capacity meter, a 14-day area sparkline,
// then mailboxes / active / unread as one quiet chips row.
const mailboxes = 128;
const activeMailboxes = 122;
const unread = 12;
const capacity = 6400;
const trend = [3120, 3480, 2960, 4110, 4620, 4380, 1980, 2240, 4890, 5240, 5010, 5680, 5320, 5420];
const sentToday = trend[trend.length - 1];
const meterPct = Math.min(100, (sentToday / capacity) * 100);
// Same geometry and Catmull-Rom smoothing as the real Sparkline, so the
// curve in the mock is the curve the app draws.
const SPARK_W = 238, SPARK_H = 34, PAD_X = 6, PAD_TOP = 6, PAD_BOTTOM = 3;
const baseY = SPARK_H - PAD_BOTTOM;
const maxSent = Math.max(...trend, 1);
const pts = trend.map((v, i) => ({
x: PAD_X + (i / (trend.length - 1)) * (SPARK_W - PAD_X * 2),
y: baseY - (v / maxSent) * (baseY - PAD_TOP),
}));
const clampY = (y: number) => Math.min(baseY, Math.max(PAD_TOP, y));
let linePath = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
linePath += ` C ${p1.x + (p2.x - p0.x) / 6} ${clampY(p1.y + (p2.y - p0.y) / 6)},`
+ ` ${p2.x - (p3.x - p1.x) / 6} ${clampY(p2.y - (p3.y - p1.y) / 6)},`
+ ` ${p2.x} ${p2.y}`;
}
const areaPath = `${linePath} L ${pts[pts.length - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`;
const endDot = pts[pts.length - 1];
const sparkId = nextId('spark');
// CreditsMeter: plan credits left this month, with the ring at that fraction.
const creditsLeft = 3140;
const creditsAllowance = 5000;
const ringR = 5.5;
const ringC = 2 * Math.PI * ringR;
const ringOffset = ringC * (1 - creditsLeft / creditsAllowance);
// PresenceAvatars: teammates online, the amber one is editing something.
const online = [
{ initials: 'JL', hot: false },
{ initials: 'AK', hot: true },
{ initials: 'RS', hot: false },
];
---
<!-- SkyChrome - #f5f6f8 base + ambient cloud blurs -->
<div class="relative overflow-hidden" style="background:#f5f6f8; contain: paint;" data-stage data-shell aria-hidden="true">
<span aria-hidden="true" class="absolute pointer-events-none" style="top:-80px; left:-60px; width:520px; height:240px; background:rgba(255,255,255,0.95); border-radius:50% 50% 18% 18%; filter:blur(60px); opacity:0.7;"></span>
<span aria-hidden="true" class="absolute pointer-events-none" style="top:10%; right:8%; width:380px; height:180px; background:rgba(255,255,255,0.85); border-radius:50% 50% 18% 18%; filter:blur(70px); opacity:0.55;"></span>
<span aria-hidden="true" class="absolute pointer-events-none" style="top:32%; left:26%; width:300px; height:140px; background:rgba(255,255,255,0.9); border-radius:50% 50% 22% 22%; filter:blur(70px); opacity:0.45;"></span>
<!-- ── AppHeader (h-14) ── -->
<div class={`relative z-10 h-14 items-center shrink-0 ${headerShow}`}>
<div class="w-64 px-5 h-full flex items-center gap-2.5 shrink-0">
<svg viewBox="0 0 746 764" class="w-7 text-slate-900" fill="currentColor"><path d="M222.805 644.772L186.274 108.881L704.5 451.158L484.5 451.158L245.5 196.158L444 463.5L222.805 644.772Z"/></svg>
<span class="font-extrabold text-[15.5px] tracking-tight text-slate-900" style="font-family: var(--font-display);">Warmbly</span>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 pr-4">
<div class="flex items-center gap-2 min-w-0">
<div class="w-full flex items-center gap-2 pl-1 pr-2 h-8 rounded-md text-left">
<span class="size-6 rounded-md ring-1 ring-slate-200 flex items-center justify-center shrink-0 overflow-hidden bg-slate-900"><span class="text-[10px] font-bold text-white leading-none tracking-tight">A</span></span>
<span class="text-[12.5px] font-medium text-slate-900 truncate flex-1 min-w-0">Acme</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div class="flex items-center gap-2 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-300 shrink-0"><polyline points="9 18 15 12 9 6"/></svg>
<span class={`text-[13px] truncate ${sub ? 'text-slate-500' : 'font-medium text-slate-900'}`}>{crumb}</span>
</div>
{sub && (
<div class="flex items-center gap-2 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-300 shrink-0"><polyline points="9 18 15 12 9 6"/></svg>
<span class="text-[13px] font-medium text-slate-900 truncate">{sub}</span>
</div>
)}
</div>
<div class="flex items-center gap-2 px-4 shrink-0">
<!-- PlanPill -->
<span class="inline-flex items-center gap-1.5 h-6 px-2 rounded border text-[11px] font-semibold uppercase tracking-[0.08em] bg-amber-50 text-amber-700 border-amber-100">
<span class="size-1.5 rounded-full bg-amber-500"></span>
Grow
</span>
<!-- CreditsMeter -->
<span class="flex items-center gap-1.5 px-2 h-7 rounded-md text-[11.5px] font-medium tabular-nums text-slate-500">
<svg viewBox="0 0 14 14" class="w-3.5 h-3.5 -rotate-90 shrink-0" aria-hidden="true">
<circle cx="7" cy="7" r={ringR} fill="none" stroke="currentColor" stroke-width="2" opacity="0.2" />
<circle cx="7" cy="7" r={ringR} fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray={ringC} stroke-dashoffset={ringOffset} />
</svg>
<span class="flex items-baseline">{creditsLeft.toLocaleString('en-US')}<span class="opacity-60 font-normal">/{creditsAllowance.toLocaleString('en-US')}</span></span>
</span>
<div class="h-4 w-px bg-slate-200/80"></div>
<!-- PresenceAvatars -->
<div class="flex items-center -space-x-1.5">
{online.map((m) => (
<span class={`relative flex size-6 shrink-0 overflow-hidden select-none items-center justify-center rounded-full ring-2 ring-white border text-[9.5px] font-semibold ${m.hot ? 'border-amber-300 bg-amber-50 text-amber-700' : 'border-emerald-300/70 bg-emerald-50 text-emerald-700'}`}>{m.initials}</span>
))}
</div>
<!-- ConnectionIndicator -->
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span class="text-xs text-[oklch(0.55_0.01_260)]">Live</span>
</div>
<!-- NotificationBell -->
<span class="relative w-7 h-7 rounded-md flex items-center justify-center text-slate-500">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>
<span class="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] px-1 rounded-full bg-rose-500 text-white text-[9px] font-semibold flex items-center justify-center">3</span>
</span>
<!-- AssistantButton (AgentMark) -->
<span class="relative flex items-center justify-center size-7 rounded-md text-slate-500">
<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4" aria-hidden="true"><path d="M9.5 4c.62 4.7 3.3 7.38 8 8-4.7.62-7.38 3.3-8 8-.62-4.7-3.3-7.38-8-8 4.7-.62 7.38-3.3 8-8Z"/><path d="M18.5 2c.31 2.35 1.65 3.69 4 4-2.35.31-3.69 1.65-4 4-.31-2.35-1.65-3.69-4-4 2.35-.31 3.69-1.65 4-4Z"/></svg>
</span>
<span class="flex items-center gap-2 px-2 h-7 rounded-md text-slate-500 text-[12.5px]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span>Search</span>
<kbd class="inline-flex h-4 items-center px-1 rounded border border-slate-300/70 bg-white/60 font-mono text-[10px] text-slate-500 ml-0.5">&#8984;K</kbd>
</span>
</div>
</div>
<!-- ── Body: sidebar + content panel ── -->
<div class={`relative z-10 flex ${bodyMin}`}>
<aside class={`absolute inset-y-0 left-0 w-64 overflow-hidden flex-col text-slate-900 ${shellShow}`}>
<!-- LivePanel: hero number, capacity meter, 14-day sparkline, chips -->
<div class="mx-2 mt-2 mb-3 rounded-md bg-white/80 border border-slate-200/70 pt-2 overflow-hidden">
<div class="px-2.5 flex items-baseline gap-1.5 whitespace-nowrap">
<span class="text-[19px] font-semibold text-slate-900 leading-none" data-live="sent" data-v={sentToday}>{sentToday.toLocaleString('en-US')}</span>
<span class="text-[10.5px] text-slate-500">of {capacity.toLocaleString('en-US')} sent today</span>
</div>
<div class="mt-1.5 px-2.5">
<div class="h-1 rounded-full bg-sky-100 overflow-hidden">
<div class="h-full rounded-full bg-sky-500 transition-[width] duration-700 ease-out" data-live="meter" data-cap={capacity} style={`width:${meterPct.toFixed(1)}%`}></div>
</div>
</div>
<svg viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} preserveAspectRatio="none" aria-hidden="true" class="mt-1 block w-full h-[34px] text-sky-500">
<defs>
<linearGradient id={sparkId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.18" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0.02" />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${sparkId})`} />
<path d={linePath} fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" />
<circle cx={endDot.x} cy={endDot.y} r="2.5" fill="currentColor" stroke="#ffffff" stroke-width="1.5" />
</svg>
<div class="border-t border-slate-100 px-2.5 py-1.5 flex items-center gap-3 text-[10.5px]">
<span class="inline-flex items-center gap-1 text-slate-500">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
<span class="font-mono tabular-nums">{mailboxes}</span>
</span>
<span class="inline-flex items-center gap-1 text-emerald-600">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
<span class="font-mono tabular-nums">{activeMailboxes}</span>
</span>
<span class="ml-auto inline-flex items-center gap-1 text-sky-600">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
<span class="font-mono tabular-nums" data-live="unread">{unread}</span>
</span>
</div>
</div>
<nav class="flex-1 overflow-hidden pb-3">
{sidebarSections.map((s, si) => (
<div class={si === 0 ? '' : 'mt-4 pt-4 border-t border-slate-200/50'}>
{s.label && (
<div class="px-4 mb-1.5">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
</div>
)}
<div class="space-y-px">
{s.items.map((it) => {
const isActive = it.title === active;
const path = navIcons[it.title] ?? '<circle cx="12" cy="12" r="3"/>';
const st = it.stat;
return (
<div class={`mx-2 flex items-center gap-2.5 px-2.5 h-7 rounded-md text-[12.5px] ${isActive ? 'bg-slate-200/70 text-slate-900 font-medium' : 'text-slate-600'}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isActive ? 2 : 1.6} class={isActive ? 'shrink-0 text-slate-700' : 'shrink-0 text-slate-400'} set:html={path}></svg>
<span class="truncate flex-1 min-w-0">{it.title}</span>
{it.badge && (
<span class="text-[10px] font-medium bg-red-500 text-white rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 tabular-nums" data-live="unread">{unread}</span>
)}
{st && st.active == null && (
<span class="ml-auto inline-flex items-center gap-1.5 shrink-0">
<span class={COUNT_LIGHT} data-live={st.live} data-v={st.total} data-fmt="compact">{compactN(st.total)}</span>
</span>
)}
{st && st.active != null && (
<span class="ml-auto inline-flex items-center gap-2.5 shrink-0">
<span class={COUNT_LIGHT}>{compactN(st.total)}</span>
<span class="h-3 w-px shrink-0 bg-slate-200" aria-hidden="true"></span>
<span class={`inline-flex items-center gap-1 ${st.active > 0 ? st.activeClass : 'text-slate-300'}`}>
{st.active > 0 && st.glyph === 'grid' && <span class="campaign-grid" aria-hidden="true"></span>}
{st.active > 0 && st.glyph === 'flame' && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="flame-flicker" set:html={flameIcon}></svg>
)}
{st.active > 0 && st.glyph === 'ping-red' && (
<span class="relative inline-flex shrink-0"><span class="w-1.5 h-1.5 rounded-full bg-red-500"></span><span class="absolute inset-0 rounded-full bg-red-500/40 animate-ping"></span></span>
)}
{st.active > 0 && st.glyph === 'ping-sky' && (
<span class="relative inline-flex shrink-0"><span class="w-1.5 h-1.5 rounded-full bg-sky-500"></span><span class="absolute inset-0 rounded-full bg-sky-500/40 animate-ping"></span></span>
)}
<span class="text-[10.5px] font-semibold tabular-nums leading-none" data-live={st.liveActive} data-v={st.active} data-fmt="compact">{compactN(st.active)}</span>
</span>
</span>
)}
</div>
);
})}
</div>
</div>
))}
</nav>
<div class="border-t border-slate-200/60 py-1 shrink-0">
<div class="mx-2 flex items-center gap-2.5 px-2.5 h-7 rounded-md text-[12.5px] text-slate-600">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" class="shrink-0 text-slate-400"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<span>Settings</span>
</div>
</div>
<!-- UserNav: initials from the email, the email under the name, no trailing icon. -->
<div class="border-t border-slate-200/60 shrink-0">
<div class="flex items-center gap-2.5 mx-3 my-2 px-1.5 py-1 rounded-md w-[calc(100%-1.5rem)]">
<div class="w-7 h-7 rounded-full bg-slate-900 flex items-center justify-center shrink-0 overflow-hidden">
<span class="text-[11px] font-medium text-white leading-none">MA</span>
</div>
<div class="flex-1 min-w-0 text-left">
<div class="text-[13px] text-slate-900 truncate">Maya Okonkwo</div>
<div class="text-[10.5px] text-slate-500 truncate">maya@acme.com</div>
</div>
</div>
</div>
</aside>
<!-- [&>*]:flex-1 stretches the screen mock to the shell height, so a
short one does not leave a white band under the sidebar. -->
<main class={`flex-1 min-w-0 bg-white overflow-hidden flex flex-col [&>*]:flex-1 ${mainEdge}`}>
<slot />
</main>
</div>
</div>
<script>
// The sidebar's realtime feel: counters tween to new values (the app's
// AnimatedNumber: 600ms, ease-out) as sends land, replies arrive and
// mailboxes come and go from warmup. One ticker per shell, paused off
// screen and under reduced motion.
const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function compactN(n: number): string {
const v = Math.round(n);
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${Math.round(v / 1000)}k`;
if (v >= 1_000) return `${(v / 1000).toFixed(1)}k`;
return String(v);
}
const fmt = (el: HTMLElement, v: number) =>
el.dataset.fmt === 'compact' ? compactN(v) : Math.round(v).toLocaleString('en-US');
// Close to framer's [0.16, 1, 0.3, 1].
const ease = (t: number) => 1 - Math.pow(1 - t, 4);
function tween(el: HTMLElement, to: number) {
const from = Number(el.dataset.v ?? el.textContent ?? 0);
el.dataset.v = String(to);
const t0 = performance.now();
const step = (now: number) => {
const u = Math.min(1, (now - t0) / 600);
el.textContent = fmt(el, from + (to - from) * ease(u));
if (u < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
function live(shell: HTMLElement) {
const q = (k: string) => [...shell.querySelectorAll<HTMLElement>(`[data-live="${k}"]`)];
const sentEls = q('sent');
const meter = shell.querySelector<HTMLElement>('[data-live="meter"]');
const cap = Number(meter?.dataset.cap ?? 6400);
if (!sentEls.length || !meter) return;
const state = {
sent: Number(sentEls[0].dataset.v),
period: Number(q('period')[0]?.dataset.v ?? 0),
unread: Number(q('unread')[0]?.textContent ?? 0),
warming: Number(q('warming')[0]?.dataset.v ?? 0),
sending: Number(q('sending')[0]?.dataset.v ?? 0),
deals: Number(q('deals')[0]?.dataset.v ?? 0),
};
const set = (k: string, v: number) => q(k).forEach((el) => tween(el, v));
const timers: number[] = [];
let running = false;
const every = (min: number, max: number, fn: () => void) => {
const tick = () => {
if (!running) return;
fn();
timers.push(window.setTimeout(tick, min + Math.random() * (max - min)));
};
timers.push(window.setTimeout(tick, min + Math.random() * (max - min)));
};
const start = () => {
if (running) return;
running = true;
// A batch of sends lands.
every(900, 2600, () => {
const n = 1 + Math.floor(Math.random() * 3);
state.sent = Math.min(cap, state.sent + n);
state.period += n;
set('sent', state.sent);
set('period', state.period);
meter.style.width = `${Math.min(100, (state.sent / cap) * 100).toFixed(1)}%`;
});
// A reply reaches the inbox.
every(6000, 11000, () => {
state.unread = Math.min(99, state.unread + 1);
set('unread', state.unread);
});
// A mailbox finishes or starts its warmup.
every(12000, 20000, () => {
state.warming = Math.max(90, Math.min(100, state.warming + (Math.random() < 0.5 ? -1 : 1)));
set('warming', state.warming);
});
// A campaign starts or completes.
every(18000, 30000, () => {
state.sending = state.sending === 7 ? 8 : 7;
set('sending', state.sending);
});
// A positive reply opens a deal.
every(25000, 40000, () => {
state.deals += 1;
set('deals', state.deals);
});
};
const stop = () => {
running = false;
for (const t of timers) clearTimeout(t);
timers.length = 0;
};
let onScreen = false;
const sync = () => (onScreen && !document.hidden ? start() : stop());
new IntersectionObserver((entries) => {
onScreen = entries.some((e) => e.isIntersecting);
sync();
}, { threshold: 0.1 }).observe(shell);
document.addEventListener('visibilitychange', sync);
}
if (!REDUCED) document.querySelectorAll<HTMLElement>('[data-shell]').forEach(live);
// The shell is a picture: nothing inside it is a real tab stop.
document.querySelectorAll<HTMLElement>('[data-shell] button, [data-shell] input, [data-shell] a, [data-shell] [tabindex]').forEach((el) => { el.tabIndex = -1; });
</script>
+246 -217
View File
@@ -1,33 +1,76 @@
---
/**
* Live demo of the Warmbly automation builder (/app/automations/:id).
* Live demo of the Warmbly automation builder (/app/automations/:id),
* mirrored from web/src/components/app/automations/AutomationFlow.tsx:
*
* Pixel-faithful to web/src (PageTopbar + React-Flow canvas: TriggerNode /
* ConditionNode / ActionNode, sky-600 chrome, dot grid at 24px), authored at a
* fixed 1280px design width and uniformly scaled to fit its container.
* - the builder header: back, the name input, the Active switch, then
* History / Test / Tidy up / Saved
* - the React Flow canvas: dots at 24px, the top-left Add action split
* button and Add condition, the bottom-center hint, TriggerNode /
* ConditionNode / ActionNode
* - the Test run side panel (InsightsPanel in a SidePanel): the dry-run
* intro, the editable sample event, the steps to run, the Run test
* button, then one NodeResultRow per node the run touched
*
* The demo is a 15s master timeline, all CSS keyframes on one shared duration:
* a cursor presses Test, the insights panel slides in, and the run travels the
* flow node by node: edges draw in, nodes flash as the run passes, result rows
* check off in the panel, a Slack notification pops, the summary lands. The
* "no" branch dims while the run takes the "yes" path. Clicking the real Test
* button restarts the run from zero.
* Drawn inside the full app shell so it is the whole dashboard on screen.
* The builder keeps its 1280px design width, which with the 256px sidebar
* makes a 1536px window, uniformly scaled to fit.
*
* Demo: the cursor presses Test, the panel springs in from the right, Run
* test runs the dry run, the trace lands, then the panel is closed again.
* Clicking the real Test button restarts the loop.
*/
import AppShellMock from './AppShellMock.astro';
import DemoCursor from './DemoCursor.astro';
import { RAW_BRAND_LOGOS } from '../lib/brandLogos';
import { BRAND_ICON } from '../lib/brandIcons';
const zapIcon = '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>';
const gitBranchIcon = '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>';
const briefcaseIcon = '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>';
const tagIcon = '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>';
const sendIcon = '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.5.5 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>';
const historyIcon = '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>';
const playIcon = '<polygon points="6 3 20 12 6 21 6 3"/>';
const wandIcon = '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/>';
const playIcon = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-11.997 7A2 2 0 0 1 5 19z"/>';
const checkIcon = '<path d="M20 6 9 17l-5-5"/>';
const checkCircleIcon = '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>';
const arrowLeftIcon = '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>';
const plusIcon = '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>';
const xIcon = '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>';
const plusIcon = '<path d="M5 12h14"/><path d="M12 5v14"/>';
const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
const trashIcon = '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>';
const chevronIcon = '<path d="m6 9 6 6 6-6"/>';
const loaderIcon = '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>';
// sampleEventData("campaign.reply_received") from lib/api/models/app/automations/meta.ts.
const sample = `{
"contact_email": "jane@example.com",
"contact_id": "00000000-0000-0000-0000-000000000001",
"campaign_id": "00000000-0000-0000-0000-000000000002",
"campaign_name": "Q3 Outbound",
"first_name": "Jane",
"last_name": "Doe",
"company": "Example Inc",
"intent": "positive",
"confidence": 0.92,
"subject": "Re: quick question",
"snippet": "Sure, let's talk next week."
}`;
// Every action node on the canvas, in the order the panel lists them.
const steps = ['Send a Slack message', 'Create a deal', 'Create / update HubSpot contact', 'Add a tag', 'Send a webhook'];
// The dry-run trace, one NodeResultRow each.
type Row = { label: string; kind: 'condition' | 'action'; status: 'ok' | 'branch_true' | 'skipped'; sub?: string; preview?: [string, string][] };
const trace: Row[] = [
{ label: 'IF', kind: 'condition', status: 'branch_true', sub: 'intent is Positive' },
{ label: 'Send a Slack message', kind: 'action', status: 'ok', preview: [['channel', '#sales'], ['text', 'Reply from jane@example.com: "Sure, let\'s talk next week."']] },
{ label: 'Create a deal', kind: 'action', status: 'ok', preview: [['name', 'Jane Doe · Example Inc'], ['stage', 'Discovery'], ['value', '4800']] },
{ label: 'Create / update HubSpot contact', kind: 'action', status: 'ok', preview: [['email', 'jane@example.com'], ['lifecycle_stage', 'opportunity']] },
{ label: 'Add a tag', kind: 'action', status: 'skipped' },
{ label: 'Send a webhook', kind: 'action', status: 'skipped' },
];
---
<div class="ad-root relative rounded-[14px] overflow-hidden bg-white ring-1 ring-slate-900/[0.07]" style="box-shadow: 0 50px 110px -32px rgba(2,132,199,0.42), 0 14px 36px -16px rgba(15,23,42,0.18);">
<div class="ad-root round-clip bg-white ring-1 ring-slate-900/[0.07]" style="box-shadow: 0 50px 110px -32px rgba(2,132,199,0.42), 0 14px 36px -16px rgba(15,23,42,0.18);">
<!-- browser chrome -->
<div class="flex items-center gap-2.5 px-3 h-11 border-b border-slate-200/80 bg-gradient-to-b from-[#f7f8fa] to-[#eceef1]">
<div class="flex items-center gap-2 shrink-0">
@@ -48,319 +91,297 @@ const xIcon = '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18"
</div>
</div>
<!-- scale-to-fit screen: authored at 1280px design width -->
<!-- scale-to-fit screen: 256px shell + 1280px builder -->
<div class="ad-fit relative w-full overflow-hidden bg-white min-h-[240px]">
<div class="ad-screen absolute top-0 left-0 origin-top-left" style="width:1280px;">
<div class="ad-screen absolute top-0 left-0 origin-top-left" style="width:1536px;">
<AppShellMock crumb="Automations" sub="Reply to revenue">
<div class="ad-builder relative flex flex-col" data-demo="ad">
<!-- ── Builder top bar ───────────────────────────────────────── -->
<div class="h-12 px-3 flex items-center gap-2 border-b border-slate-200 bg-white">
<span class="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-500"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={arrowLeftIcon}></svg></span>
<span class="h-7 px-2 inline-flex items-center rounded-md text-[13px] font-medium text-slate-900">Reply to revenue</span>
<span class="inline-flex h-7 items-center gap-2 ml-1">
<!-- ── Builder header ────────────────────────────────────────── -->
<div class="h-12 px-3 flex items-center gap-2 border-b border-slate-200 bg-white shrink-0">
<span class="h-7 w-7 rounded-md text-slate-500 inline-flex items-center justify-center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={arrowLeftIcon}></svg></span>
<span class="h-7 px-2 w-56 rounded-md text-[13px] font-medium text-slate-900 border border-transparent inline-flex items-center truncate">Reply to revenue</span>
<span class="inline-flex h-7 items-center gap-2 rounded-md">
<span class="relative inline-flex h-[18px] w-8 shrink-0 items-center rounded-full bg-sky-600"><span class="inline-block size-3.5 translate-x-[16px] rounded-full bg-white shadow-sm"></span></span>
<span class="text-[12px] font-medium text-slate-700">Active</span>
</span>
<div class="ml-auto flex items-center gap-1.5">
<span class="inline-flex h-7 px-2.5 items-center gap-1.5 rounded-md border border-slate-200 text-slate-700 text-[12px]"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={historyIcon}></svg>History</span>
<button type="button" class="ad-testbtn ad-anim relative inline-flex h-7 px-2.5 items-center gap-1.5 rounded-md border border-slate-200 text-slate-700 text-[12px] cursor-pointer select-none" aria-label="Replay the demo run">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={playIcon}></svg>
<span class="relative inline-grid">
<span class="ad-testlabel-a ad-anim col-start-1 row-start-1">Test</span>
<span class="ad-testlabel-b ad-anim col-start-1 row-start-1 inline-flex items-center gap-1.5 text-sky-700">Running<span class="ad-spin inline-block size-2.5 rounded-full border-[1.5px] border-sky-300 border-t-sky-600"></span></span>
</span>
<span class="h-7 px-2.5 rounded-md border border-slate-200 text-slate-700 text-[12px] inline-flex items-center gap-1.5"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={historyIcon}></svg>History</span>
<button type="button" class="ad-testbtn h-7 px-2.5 rounded-md border border-slate-200 text-slate-700 text-[12px] inline-flex items-center gap-1.5 transition-colors cursor-pointer select-none [&.is-hover]:border-slate-300 [&.is-hover]:text-slate-900" aria-label="Replay the demo run">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={playIcon}></svg>
Test
</button>
<span class="inline-flex h-7 px-2.5 items-center gap-1.5 rounded-md border border-slate-200 text-slate-700 text-[12px]"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={wandIcon}></svg>Tidy up</span>
<span class="inline-flex h-7 px-3 items-center gap-1.5 rounded-md bg-slate-100 text-slate-400 text-[12px] font-medium"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={checkIcon}></svg>Saved</span>
<span class="h-7 px-2.5 rounded-md border border-slate-200 text-slate-700 text-[12px] inline-flex items-center">Tidy up</span>
<span class="h-7 px-3 rounded-md text-[12px] font-medium inline-flex items-center gap-1.5 bg-slate-100 text-slate-400"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-300" set:html={checkIcon}></svg>Saved</span>
</div>
</div>
<!-- ── Canvas ────────────────────────────────────────────────── -->
<div class="relative h-[560px] overflow-hidden bg-slate-50/40" style="background-image: radial-gradient(#e9eef5 1px, transparent 1px); background-size: 24px 24px;">
<div class="relative flex-1 min-h-[720px] overflow-hidden bg-slate-50/40" style="background-image: radial-gradient(#e9eef5 0.5px, transparent 0.6px); background-size: 24px 24px;">
<!-- toolbar -->
<div class="absolute left-3 top-3 z-20 flex items-center gap-1.5">
<span class="inline-flex h-8 items-center gap-1.5 rounded-md bg-sky-600 px-2.5 text-[12px] font-medium text-white shadow-sm"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" set:html={plusIcon}></svg>Add action</span>
<span class="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-700 shadow-sm"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={gitBranchIcon}></svg>Add condition</span>
<!-- Panel top-left (15px): Add action split button, Add condition -->
<div class="absolute left-[15px] top-[15px] z-20 flex items-center gap-1.5">
<div class="relative inline-flex">
<span class="inline-flex h-8 items-center gap-1.5 rounded-l-md bg-sky-600 px-2.5 text-[12px] font-medium text-white shadow-sm"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={plusIcon}></svg>Add action</span>
<span class="inline-flex h-8 items-center rounded-r-md border-l border-sky-500/60 bg-sky-600 px-1.5 text-white shadow-sm"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={chevronIcon}></svg></span>
</div>
<span class="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-700 shadow-sm"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={gitBranchIcon}></svg>Add condition</span>
</div>
<!-- canvas hint -->
<div class="absolute bottom-3 left-1/2 -translate-x-1/2 z-0 rounded-md bg-white/95 px-3 py-1.5 shadow-sm text-[11px] text-slate-500 whitespace-nowrap">drag a node's dot to connect &middot; IF block: right dot = yes, bottom dot = no</div>
<!-- Panel bottom-center: the hint -->
<div class="absolute bottom-[15px] left-1/2 -translate-x-1/2 z-[5] hidden md:block rounded-md bg-white/95 px-3 py-1.5 shadow-sm text-[11px] text-slate-500">drag a node's dot to connect &middot; IF block: right dot = yes, bottom dot = no &middot; drag to empty canvas to pick what comes next &middot; click a line then Delete to remove</div>
<!-- edges -->
<svg width="1280" height="560" viewBox="0 0 1280 560" class="absolute inset-0 pointer-events-none" aria-hidden="true">
<svg width="1280" height="720" viewBox="0 0 1280 720" class="absolute inset-0 pointer-events-none" aria-hidden="true">
<defs>
<marker id="ad-arrow-slate" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M1 1 L9 5 L1 9 z" fill="#94a3b8"/></marker>
<marker id="ad-arrow-sky" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M1 1 L9 5 L1 9 z" fill="#0ea5e9"/></marker>
<marker id="ad-arrow-slate" viewBox="-10 -10 20 20" markerWidth="16" markerHeight="16" refX="0" refY="0" orient="auto-start-reverse" markerUnits="strokeWidth"><polyline stroke-linecap="round" stroke-linejoin="round" points="-5,-4 0,0 -5,4 -5,-4" fill="#94a3b8" stroke="#94a3b8" stroke-width="1"/></marker>
<marker id="ad-arrow-light" viewBox="-10 -10 20 20" markerWidth="16" markerHeight="16" refX="0" refY="0" orient="auto-start-reverse" markerUnits="strokeWidth"><polyline stroke-linecap="round" stroke-linejoin="round" points="-5,-4 0,0 -5,4 -5,-4" fill="#cbd5e1" stroke="#cbd5e1" stroke-width="1"/></marker>
<marker id="ad-arrow-sky" viewBox="-10 -10 20 20" markerWidth="16" markerHeight="16" refX="0" refY="0" orient="auto-start-reverse" markerUnits="strokeWidth"><polyline stroke-linecap="round" stroke-linejoin="round" points="-5,-4 0,0 -5,4 -5,-4" fill="#0ea5e9" stroke="#0ea5e9" stroke-width="1"/></marker>
</defs>
<!-- base edges -->
<path d="M 444 98 L 444 134" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-slate)"/>
<path d="M 558 155 C 670 155, 764 168, 764 214" fill="none" stroke="#0ea5e9" stroke-width="1.5" marker-end="url(#ad-arrow-sky)"/>
<path class="ad-dim ad-anim" d="M 444 176 C 444 215, 220 208, 220 254" fill="none" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ad-arrow-slate)"/>
<path d="M 764 302 L 764 338" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-slate)"/>
<path d="M 764 422 L 764 458" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-slate)"/>
<path class="ad-dim ad-anim" d="M 220 338 L 220 386" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-slate)"/>
<!-- lit overlays: drawn in as the run travels -->
<path class="ad-lit ad-anim" style="--len:60; animation-delay:0s" d="M 444 98 L 444 134" fill="none" stroke="#38bdf8" stroke-width="2.5" stroke-linecap="round"/>
<path class="ad-lit ad-anim" style="--len:280; animation-delay:0.95s" d="M 558 155 C 670 155, 764 168, 764 214" fill="none" stroke="#7dd3fc" stroke-width="2.5" stroke-linecap="round"/>
<path class="ad-lit ad-anim" style="--len:60; animation-delay:2.25s" d="M 764 302 L 764 338" fill="none" stroke="#38bdf8" stroke-width="2.5" stroke-linecap="round"/>
<path class="ad-lit ad-anim" style="--len:60; animation-delay:3.45s" d="M 764 422 L 764 458" fill="none" stroke="#38bdf8" stroke-width="2.5" stroke-linecap="round"/>
<path d="M 444 98 L 444 130" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-light)"/>
<path d="M 552 155 C 670 155, 764 168, 764 214" fill="none" stroke="#0ea5e9" stroke-width="1.5" marker-end="url(#ad-arrow-sky)"/>
<path d="M 444 176 C 444 215, 220 208, 220 254" fill="none" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ad-arrow-slate)"/>
<path d="M 764 310 L 764 334" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-light)"/>
<path d="M 764 430 L 764 454" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-light)"/>
<path d="M 220 346 L 220 382" fill="none" stroke="#cbd5e1" stroke-width="1.5" marker-end="url(#ad-arrow-light)"/>
</svg>
<!-- edge labels -->
<span class="absolute z-10 inline-flex items-center rounded border border-sky-200 bg-white px-1.5 py-px text-[10px] font-medium text-sky-600 shadow-sm" style="left:632px; top:142px;">yes</span>
<span class="ad-dim ad-anim absolute z-10 inline-flex items-center rounded border border-slate-200 bg-white px-1.5 py-px text-[10px] text-slate-500 shadow-sm" style="left:304px; top:192px;">no</span>
<span class="absolute z-10 inline-flex items-center rounded border px-1 py-px text-[10px]" style="left:640px; top:144px; border-color:#0ea5e9; color:#0ea5e9; background:#fff;">yes</span>
<span class="absolute z-10 inline-flex items-center rounded border px-1 py-px text-[10px]" style="left:308px; top:194px; border-color:#94a3b8; color:#94a3b8; background:#fff;">no</span>
<!-- TriggerNode -->
<div class="absolute" style="left:320px; top:22px; width:248px;">
<div class="ad-flash ad-anim relative rounded-xl border border-slate-200 bg-white shadow-sm" style="animation-delay:0s">
<div class="relative rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-2 rounded-t-xl border-b border-slate-200/70 bg-gradient-to-r from-sky-50/80 to-white px-2.5 py-1.5">
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-sky-100 text-sky-600 ring-1 ring-sky-200/70"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={zapIcon}></svg></span>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Prospect replies</span>
<span class="shrink-0 rounded bg-sky-600 px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-white">Trigger</span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-sky-100 text-sky-600 ring-1 ring-sky-200/70"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={zapIcon}></svg></span>
<span class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-sky-500">When</span>
<span class="ml-auto shrink-0 rounded bg-sky-600 px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-white">Trigger</span>
</div>
<div class="px-2.5 py-2">
<div class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">When</div>
<div class="mt-0.5 truncate font-mono text-[11px] text-slate-500">campaign.reply_received</div>
<div class="truncate text-[12.5px] font-semibold text-slate-800">Prospect replies</div>
</div>
<span class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white shadow-sm"></span>
<span class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
</div>
</div>
<!-- ConditionNode -->
<div class="absolute" style="left:334px; top:140px; width:220px;">
<div class="ad-flash ad-anim relative rounded-lg border border-sky-200 bg-gradient-to-b from-sky-50 to-white px-2.5 py-1.5 shadow-sm" style="animation-delay:0.9s">
<div class="absolute" style="left:334px; top:140px;">
<div class="relative inline-block rounded-lg border border-sky-200 bg-gradient-to-b from-sky-50 to-white px-2 py-1 shadow-sm">
<span class="absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-slate-300 border-2 border-white"></span>
<div class="flex items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-sky-600" set:html={gitBranchIcon}></svg>
<span class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-sky-500">if</span>
<span class="min-w-0 flex-1 truncate text-[11px] font-medium text-sky-800">intent is positive &middot; &ge; 80%</span>
<span class="max-w-[150px] truncate text-[11px] font-medium text-sky-800">intent is Positive</span>
<span class="inline-flex size-4 items-center justify-center rounded text-sky-400"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={trashIcon}></svg></span>
</div>
<span class="absolute -right-1.5 top-1/2 -translate-y-1/2 size-3 rounded-full bg-sky-500 border-2 border-white shadow-sm"></span>
<span class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 size-3 rounded-full bg-slate-400 border-2 border-white shadow-sm"></span>
<span class="absolute -right-1 top-1/2 -translate-y-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
<span class="absolute -bottom-1 left-1/2 -translate-x-1/2 size-3 rounded-full bg-slate-400 border-2 border-white"></span>
</div>
</div>
<!-- ActionNode: Slack -->
<div class="absolute" style="left:640px; top:222px; width:248px;">
<div class="ad-flash ad-anim relative rounded-xl border border-slate-200 bg-white shadow-sm" style="animation-delay:1.95s">
<div class="relative rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-2 rounded-t-xl border-b border-slate-200/70 bg-gradient-to-r from-slate-50 to-white px-2.5 py-1.5">
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-fuchsia-50 text-fuchsia-600 ring-1 ring-fuchsia-200 text-[10px] font-bold">S</span>
<div class="w-7 h-7 rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0"><svg viewBox="0 0 256 256" role="img" aria-label="slack" class="w-4 h-4" set:html={RAW_BRAND_LOGOS.slack.inner}></svg></div>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Send a Slack message</span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-300"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={trashIcon}></svg></span>
</div>
<div class="px-2.5 py-2">
<div class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">Then</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">#sales &middot; reply from {`{{.contact_email}}`}</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">Slack</div>
</div>
<span class="absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-slate-300 border-2 border-white"></span>
<span class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white shadow-sm"></span>
<span class="absolute -right-[5px] top-1/2 -translate-y-1/2 size-2.5 rounded-full bg-rose-500 border-2 border-white"></span>
<span class="absolute -bottom-1 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
</div>
</div>
<!-- ActionNode: Create a deal -->
<div class="absolute" style="left:640px; top:342px; width:248px;">
<div class="ad-flash ad-anim relative rounded-xl border border-slate-200 bg-white shadow-sm" style="animation-delay:3.2s">
<div class="relative rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-2 rounded-t-xl border-b border-slate-200/70 bg-gradient-to-r from-slate-50 to-white px-2.5 py-1.5">
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-sky-50 text-sky-600 ring-1 ring-sky-200/70"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={briefcaseIcon}></svg></span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-indigo-100 text-indigo-600 ring-1 ring-indigo-200/70"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={zapIcon}></svg></span>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Create a deal</span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-300"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={trashIcon}></svg></span>
</div>
<div class="px-2.5 py-2">
<div class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">Then</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">Discovery &middot; $4,800</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">Built-in action</div>
</div>
<span class="absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-slate-300 border-2 border-white"></span>
<span class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white shadow-sm"></span>
<span class="absolute -right-[5px] top-1/2 -translate-y-1/2 size-2.5 rounded-full bg-rose-500 border-2 border-white"></span>
<span class="absolute -bottom-1 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
</div>
</div>
<!-- ActionNode: HubSpot -->
<div class="absolute" style="left:640px; top:462px; width:248px;">
<div class="ad-flash ad-anim relative rounded-xl border border-slate-200 bg-white shadow-sm" style="animation-delay:4.4s">
<div class="relative rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-2 rounded-t-xl border-b border-slate-200/70 bg-gradient-to-r from-slate-50 to-white px-2.5 py-1.5">
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-orange-50 text-orange-600 ring-1 ring-orange-200 text-[10px] font-bold">H</span>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Update HubSpot contact</span>
<div class="w-7 h-7 rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0"><svg viewBox="0 0 24 24" role="img" aria-label="hubspot" class="w-4 h-4" fill={BRAND_ICON.hubspot.hex}><path d={BRAND_ICON.hubspot.path}/></svg></div>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Create / update HubSpot contact</span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-300"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={trashIcon}></svg></span>
</div>
<div class="px-2.5 py-2">
<div class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">Then</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">lifecycle stage &middot; opportunity</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">HubSpot</div>
</div>
<span class="absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-slate-300 border-2 border-white"></span>
<span class="absolute -right-[5px] top-1/2 -translate-y-1/2 size-2.5 rounded-full bg-rose-500 border-2 border-white"></span>
<span class="absolute -bottom-1 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
</div>
</div>
<!-- ActionNode: Add a tag (no branch, dims during the run) -->
<div class="ad-dim ad-anim absolute" style="left:96px; top:258px; width:248px;">
<!-- ActionNode: Add a tag (the no branch) -->
<div class="absolute" style="left:96px; top:258px; width:248px;">
<div class="relative rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-2 rounded-t-xl border-b border-slate-200/70 bg-gradient-to-r from-slate-50 to-white px-2.5 py-1.5">
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={tagIcon}></svg></span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-indigo-100 text-indigo-600 ring-1 ring-indigo-200/70"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={zapIcon}></svg></span>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Add a tag</span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-300"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={trashIcon}></svg></span>
</div>
<div class="px-2.5 py-2">
<div class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">Then</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">nurture</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">Built-in action</div>
</div>
<span class="absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-slate-300 border-2 border-white"></span>
<span class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white shadow-sm"></span>
<span class="absolute -right-[5px] top-1/2 -translate-y-1/2 size-2.5 rounded-full bg-rose-500 border-2 border-white"></span>
<span class="absolute -bottom-1 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
</div>
</div>
<!-- ActionNode: Webhook (no branch) -->
<div class="ad-dim ad-anim absolute" style="left:96px; top:390px; width:248px;">
<!-- ActionNode: Webhook (the no branch) -->
<div class="absolute" style="left:96px; top:390px; width:248px;">
<div class="relative rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-2 rounded-t-xl border-b border-slate-200/70 bg-gradient-to-r from-slate-50 to-white px-2.5 py-1.5">
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded-md bg-sky-50 text-sky-600 ring-1 ring-sky-200/70"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={sendIcon}></svg></span>
<div class="w-7 h-7 rounded-md ring-1 ring-sky-100 bg-sky-50 text-sky-700 inline-flex items-center justify-center shrink-0 font-semibold uppercase text-[12px]">w</div>
<span class="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-slate-800">Send a webhook</span>
<span class="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-300"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={trashIcon}></svg></span>
</div>
<div class="px-2.5 py-2">
<div class="text-[9.5px] font-semibold uppercase tracking-[0.12em] text-slate-300">Then</div>
<div class="mt-0.5 truncate font-mono text-[11px] text-slate-500">hooks.yourapp.com/replies</div>
<div class="mt-0.5 truncate text-[11.5px] text-slate-500">Webhook</div>
</div>
<span class="absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-slate-300 border-2 border-white"></span>
<span class="absolute -right-[5px] top-1/2 -translate-y-1/2 size-2.5 rounded-full bg-rose-500 border-2 border-white"></span>
<span class="absolute -bottom-1 left-1/2 -translate-x-1/2 size-3 rounded-full bg-sky-500 border-2 border-white"></span>
</div>
</div>
<!-- Slack notification toast -->
<div class="ad-toast ad-anim absolute left-5 bottom-5 z-30 w-[300px] rounded-[10px] bg-white shadow-[0_14px_36px_-12px_rgba(15,23,42,0.4)] ring-1 ring-slate-200 p-3" aria-hidden="true">
<div class="flex items-start gap-2.5">
<span class="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-fuchsia-50 text-fuchsia-600 ring-1 ring-fuchsia-200 text-[13px] font-bold">S</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="text-[11.5px] font-semibold text-slate-900">#sales</span>
<span class="text-[10px] text-slate-400 font-mono">now</span>
</div>
<p class="text-[11.5px] text-slate-500 mt-0.5 leading-snug"><span class="font-medium text-slate-700">Warmbly</span> maya@northwind.dev replied to Q1 outreach: &ldquo;Thursday works for me&rdquo;</p>
</div>
</div>
<!-- Controls (bottom-left, 15px margin): zoom in, zoom out, fit view -->
<div class="absolute left-[15px] bottom-[15px] z-[5] flex flex-col" style="box-shadow: 0 0 2px 1px rgba(0,0,0,.08);">
<span class="flex items-center justify-center w-[26px] h-[26px] border-b border-[#eee]" style="background:#fefefe;"><svg width="12" height="12" viewBox="0 0 32 32" fill="#0f172a"><path d="M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z"/></svg></span>
<span class="flex items-center justify-center w-[26px] h-[26px] border-b border-[#eee]" style="background:#fefefe;"><svg width="12" height="1.875" viewBox="0 0 32 5" fill="#0f172a"><path d="M0 0h32v4.2H0z"/></svg></span>
<span class="flex items-center justify-center w-[26px] h-[26px]" style="background:#fefefe;"><svg width="12" height="11.25" viewBox="0 0 32 30" fill="#0f172a"><path d="M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z"/></svg></span>
</div>
<!-- ── Insights panel: the test run, step by step ──────────── -->
<div class="ad-panel ad-anim absolute inset-y-0 right-0 z-40 w-[340px] bg-white border-l border-slate-200 shadow-[-10px_0_34px_-14px_rgba(15,23,42,0.3)] flex flex-col" aria-hidden="true">
<!-- ── SidePanel > InsightsPanel (mode "test") ─────────────── -->
<div class="ad-panel absolute top-0 right-0 h-full w-80 max-w-[88vw] bg-white border-l border-slate-200 shadow-xl flex flex-col z-10" aria-hidden="true">
<div class="h-11 px-3 flex items-center border-b border-slate-200 shrink-0">
<span class="text-[12.5px] font-medium text-slate-900">Test run</span>
<span class="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" set:html={xIcon}></svg></span>
<span class="ad-close ml-auto h-7 w-7 rounded-md inline-flex items-center justify-center text-slate-400 transition-colors [&.is-hover]:text-slate-700 [&.is-hover]:bg-slate-100"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" set:html={xIcon}></svg></span>
</div>
<div class="flex-1 min-h-0 p-3 space-y-2 overflow-hidden">
<p class="text-[11px] text-slate-400 leading-relaxed">Dry run with a sample reply event.</p>
<div class="ad-row ad-anim rounded-md border border-slate-200 px-2.5 py-2" style="animation-delay:0s">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 shrink-0" set:html={checkCircleIcon}></svg>
<span class="text-[11.5px] font-medium text-slate-700 truncate">Trigger &middot; Prospect replies</span>
<div class="flex-1 min-h-0 overflow-hidden p-3 space-y-2">
<div class="space-y-3">
<p class="text-[11px] text-slate-400 leading-relaxed">Dry run: no messages are sent and no records change. Edit the sample event your automation receives, then run to see the path it takes and what each action would send.</p>
<div>
<div class="mb-1 flex items-center justify-between">
<span class="text-[11px] font-medium text-slate-600">Sample event data</span>
<span class="text-[11px] text-sky-600">Reset to sample</span>
</div>
<pre class="w-full h-[150px] overflow-hidden rounded-md border border-slate-200 bg-white px-2 py-1.5 text-[11.5px] leading-normal font-mono text-slate-800 whitespace-pre resize-y">{sample}</pre>
<p class="mt-1 text-[10.5px] text-slate-400">Conditions branch on these fields, so editing them changes which actions run.</p>
</div>
<div class="mt-1 pl-[21px] font-mono text-[10.5px] text-slate-400 truncate">maya@northwind.dev &middot; Re: Quick question</div>
</div>
<div class="ad-row ad-anim rounded-md border border-slate-200 px-2.5 py-2" style="animation-delay:0.9s">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-sky-500 shrink-0" set:html={gitBranchIcon}></svg>
<span class="text-[11.5px] font-medium text-slate-700 truncate">intent is positive &middot; &ge; 80%</span>
<span class="ml-auto shrink-0 text-[10.5px] font-semibold text-emerald-600">yes &middot; 92%</span>
<div>
<div class="mb-1 text-[11px] font-medium text-slate-600">Steps to run <span class="text-slate-400">({steps.length}/{steps.length})</span></div>
<div class="space-y-1">
{steps.map((s) => (
<div class="w-full flex items-center gap-2 rounded-md border border-slate-200 px-2 py-1.5 text-left">
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded bg-sky-600 text-white"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={checkIcon}></svg></span>
<span class="text-[12px] text-slate-700">{s}</span>
</div>
))}
</div>
<p class="mt-1 text-[10.5px] text-slate-400">Turn a step off to skip it in this test. Conditions still decide which steps are reached.</p>
</div>
<span class="ad-run h-8 w-full rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors [&.is-hover]:bg-sky-700 [&.is-pressed]:bg-sky-700">
<span class="ad-run-idle inline-flex items-center gap-1.5"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={playIcon}></svg>Run test</span>
<span class="ad-run-busy inline-flex items-center gap-1.5"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin" set:html={loaderIcon}></svg>Running…</span>
</span>
<div class="border-t border-slate-200 pt-3 space-y-2">
<p class="ad-res-idle text-[12px] text-slate-500">Edit the sample event, then Run test.</p>
<div class="ad-res-busy flex items-center gap-2 text-[12px] text-slate-400"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin" set:html={loaderIcon}></svg> Running…</div>
<div class="ad-res-done space-y-2">
{trace.map((r) => (
<div class="rounded-md border border-slate-200 px-2.5 py-1.5">
<div class="flex items-center gap-1.5">
{r.status === 'skipped'
? <span class="inline-block w-3.5 h-3.5 rounded-full border border-slate-300" aria-hidden="true"></span>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 shrink-0" set:html={checkCircleIcon}></svg>}
<span class={`text-[11.5px] font-medium ${r.status === 'skipped' ? 'text-slate-400' : 'text-slate-700'}`}>{r.label}</span>
{r.kind === 'condition' && <span class="ml-auto text-[10.5px] font-medium text-slate-400">→ yes</span>}
{r.status === 'skipped' && <span class="ml-auto text-[10.5px] font-medium text-slate-400">skipped</span>}
</div>
{r.sub && <div class="mt-0.5 text-[11px] text-slate-400">{r.sub}</div>}
{r.preview && (
<div class="mt-1 space-y-0.5">
{r.preview.map(([k, v]) => (
<div class="text-[10.5px] text-slate-500"><span class="text-slate-400">{k}:</span> <span class="font-mono">{v}</span></div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
<div class="ad-row ad-anim rounded-md border border-slate-200 px-2.5 py-2" style="animation-delay:1.95s">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 shrink-0" set:html={checkCircleIcon}></svg>
<span class="text-[11.5px] font-medium text-slate-700 truncate">Slack message &middot; #sales</span>
<span class="ml-auto shrink-0 font-mono text-[10.5px] text-slate-400">212 ms</span>
</div>
</div>
<div class="ad-row ad-anim rounded-md border border-slate-200 px-2.5 py-2" style="animation-delay:3.2s">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 shrink-0" set:html={checkCircleIcon}></svg>
<span class="text-[11.5px] font-medium text-slate-700 truncate">Deal created &middot; Discovery</span>
<span class="ml-auto shrink-0 font-mono text-[10.5px] text-slate-400">$4,800</span>
</div>
</div>
<div class="ad-row ad-anim rounded-md border border-slate-200 px-2.5 py-2" style="animation-delay:4.4s">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 shrink-0" set:html={checkCircleIcon}></svg>
<span class="text-[11.5px] font-medium text-slate-700 truncate">HubSpot contact updated</span>
<span class="ml-auto shrink-0 font-mono text-[10.5px] text-slate-400">2 fields</span>
</div>
</div>
<div class="ad-row ad-anim rounded-md border border-emerald-200 bg-emerald-50 px-2.5 py-2" style="animation-delay:5.2s">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-600 shrink-0" set:html={checkCircleIcon}></svg>
<span class="text-[11.5px] font-semibold text-emerald-700">Run passed &middot; 5 steps &middot; 1.9 s</span>
</div>
</div>
<p class="ad-row ad-anim text-[11px] text-slate-400 leading-relaxed pt-1" style="animation-delay:5.2s">Flip it on and this fires on every real reply.</p>
</div>
</div>
</div>
<!-- Demo cursor: positioned against the whole 1280x608 screen so it can
reach the Test button in the top bar -->
<div class="ad-cursor ad-anim z-50" aria-hidden="true"><span class="ad-ripple ad-anim"></span><svg class="ad-anim" width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg></div>
<DemoCursor />
</div>
</AppShellMock>
</div>
</div>
<style>
/* ── Master timeline: every animation runs on the same 15s loop ── */
.ad-cursor{position:absolute;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:ad-move 15s cubic-bezier(.5,0,.2,1) infinite;}
.ad-cursor svg{display:block;transform-origin:3px 2px;animation:ad-press 15s ease-in-out infinite;}
.ad-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:ad-rip 15s ease-out infinite;}
@keyframes ad-move{0%{left:46%;top:64%}8%,12%{left:80.5%;top:5.6%}22%{left:58%;top:48%}74%{left:60%;top:52%}88%,100%{left:46%;top:64%}}
@keyframes ad-press{0%,8.5%{transform:scale(1)}9.5%{transform:scale(.82)}11%,100%{transform:scale(1)}}
@keyframes ad-rip{0%,9%{transform:scale(0);opacity:0}10.5%{transform:scale(.3);opacity:.7}16%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
/* Test button: press flash, label crossfades to a spinner while running */
.ad-testbtn{animation:ad-btn 15s ease-out infinite;}
@keyframes ad-btn{0%,9%{background:#fff;border-color:#e2e8f0}10.5%,48%{background:#f0f9ff;border-color:#7dd3fc}53%,100%{background:#fff;border-color:#e2e8f0}}
.ad-testlabel-a{animation:ad-lbl-a 15s ease-out infinite;}
.ad-testlabel-b{opacity:0;animation:ad-lbl-b 15s ease-out infinite;}
@keyframes ad-lbl-a{0%,10%{opacity:1}11%,49%{opacity:0}52%,100%{opacity:1}}
@keyframes ad-lbl-b{0%,10%{opacity:0}11%,49%{opacity:1}52%,100%{opacity:0}}
.ad-spin{animation:ad-rot .8s linear infinite;}
@keyframes ad-rot{to{transform:rotate(360deg)}}
/* Insights panel slides in while the run executes */
.ad-panel{transform:translateX(102%);animation:ad-pane 15s cubic-bezier(.22,1,.36,1) infinite;}
@keyframes ad-pane{0%,11%{transform:translateX(102%)}13.5%,79%{transform:translateX(0)}82%,100%{transform:translateX(102%)}}
/* Result rows pop in one by one (per-row phase via animation-delay) */
.ad-row{opacity:0;animation:ad-pop 15s cubic-bezier(.22,1,.36,1) infinite;}
@keyframes ad-pop{0%,16%{opacity:0;transform:translateY(6px)}18%,79%{opacity:1;transform:translateY(0)}82%,100%{opacity:0;transform:translateY(6px)}}
/* Node flash as the run passes through (shared pulse, shifted per node) */
.ad-flash{animation:ad-fl 15s ease-in-out infinite;}
@keyframes ad-fl{0%,13.5%{box-shadow:0 1px 2px 0 rgb(0 0 0 / 0.05)}15%{box-shadow:0 0 0 4px rgba(125,211,252,.85)}18%,100%{box-shadow:0 1px 2px 0 rgb(0 0 0 / 0.05)}}
/* Lit edge overlays draw in along the path */
.ad-lit{stroke-dasharray:var(--len);stroke-dashoffset:var(--len);animation:ad-dr 15s ease-in-out infinite;}
@keyframes ad-dr{0%,17%{stroke-dashoffset:var(--len);opacity:1}19.5%,76%{stroke-dashoffset:0;opacity:1}78%,100%{stroke-dashoffset:var(--len);opacity:0}}
/* The path not taken dims while the run is live */
.ad-dim{animation:ad-dm 15s ease-in-out infinite;}
@keyframes ad-dm{0%,13%{opacity:1}17%,75%{opacity:.4}80%,100%{opacity:1}}
/* Slack toast pops while the Slack step delivers */
.ad-toast{opacity:0;animation:ad-to 15s cubic-bezier(.22,1,.36,1) infinite;}
@keyframes ad-to{0%,29%{opacity:0;transform:translateY(14px) scale(.96)}31.5%,58%{opacity:1;transform:translateY(0) scale(1)}61%,100%{opacity:0;transform:translateY(14px) scale(.96)}}
@media (prefers-reduced-motion: reduce){
.ad-cursor,.ad-panel,.ad-toast{display:none}
.ad-anim{animation:none !important;opacity:1}
.ad-testlabel-b{opacity:0 !important}
.ad-lit{stroke-dashoffset:0}
.ad-row{opacity:1;transform:none}
}
/* Test opens the panel: the button takes its open style, the SidePanel
springs in from the right (spring 300/32, about half a second). */
.ad-testbtn{transition:background-color .15s,border-color .15s,color .15s;}
.ad-builder.is-test .ad-testbtn{border-color:#7dd3fc;background:#f0f9ff;color:#0369a1;}
.ad-panel{transform:translateX(100%);transition:transform .5s cubic-bezier(.22,1,.36,1);}
.ad-builder.is-test .ad-panel{transform:none;}
/* Run test: the button and the result area show Running…, then the trace. */
.ad-run-busy,.ad-res-busy,.ad-res-done{display:none;}
.ad-builder.is-running .ad-run{opacity:.6;}
.ad-builder.is-running .ad-run-idle{display:none;}
.ad-builder.is-running .ad-run-busy{display:inline-flex;}
.ad-builder.is-running .ad-res-idle{display:none;}
.ad-builder.is-running .ad-res-busy{display:flex;}
.ad-builder.is-done .ad-res-idle{display:none;}
.ad-builder.is-done .ad-res-done{display:block;}
/* Reduced motion: hold the finished test still. */
.ad-builder.is-static .ad-panel{transform:none;}
.ad-builder.is-static .ad-testbtn{border-color:#7dd3fc;background:#f0f9ff;color:#0369a1;}
.ad-builder.is-static .ad-res-idle{display:none;}
.ad-builder.is-static .ad-res-done{display:block;}
</style>
<script>
// Scale-to-fit (1280px design width; below md, show the left 860px like the
import { playCursor } from '../lib/cursor';
// Scale-to-fit (1536px window at lg+, where the shell shows; below that
// the shell hides itself and the left 860px of the builder show, like the
// hero mock does) + click Test to restart the run from zero.
const DESIGN = 1280;
const DESIGN = 1536;
const MOBILE_VISIBLE = 860;
function fit(screen: HTMLElement) {
const box = screen.parentElement as HTMLElement | null;
if (!box) return;
const visible = window.innerWidth >= 768 ? DESIGN : MOBILE_VISIBLE;
const visible = window.innerWidth >= 1024 ? DESIGN : MOBILE_VISIBLE;
const s = box.clientWidth / visible;
screen.style.transform = `scale(${s})`;
box.style.height = `${screen.offsetHeight * s}px`;
@@ -373,17 +394,25 @@ const xIcon = '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18"
window.addEventListener('load', run);
window.addEventListener('resize', run);
});
document.querySelectorAll<HTMLElement>('.ad-root').forEach((root) => {
const btn = root.querySelector<HTMLElement>('.ad-testbtn');
if (!btn) return;
btn.addEventListener('click', () => {
// Toggle animation-name only: the per-element animation-delay lives in
// the style attribute and the animation shorthand would wipe it.
const els = root.querySelectorAll<HTMLElement | SVGElement>('.ad-anim');
els.forEach((el) => { (el as HTMLElement).style.animationName = 'none'; });
void root.offsetWidth;
els.forEach((el) => { (el as HTMLElement).style.animationName = ''; });
// Every waypoint is a real control, measured live: Test in the header,
// Run test and the close button inside the panel.
document.querySelectorAll<HTMLElement>('[data-demo="ad"]').forEach((root) => {
const player = playCursor(root, {
loop: 15000,
steps: [
{ at: 0, x: 46, y: 62 },
{ at: 1500, el: '.ad-testbtn', ox: 0.5, oy: 0.55, hover: '.ad-testbtn' },
{ at: 1640, press: true, target: '.ad-testbtn', add: ['is-test'] },
{ at: 3600, el: '.ad-run', ox: 0.5, oy: 0.55, hover: '.ad-run' },
{ at: 3740, press: true, target: '.ad-run', add: ['is-running'] },
{ at: 5300, add: ['is-done'], remove: ['is-running'] },
{ at: 7400, el: '.ad-res-done', ox: 0.7, oy: 0.35 },
{ at: 11600, el: '.ad-close', ox: 0.5, oy: 0.55, hover: '.ad-close' },
{ at: 11740, press: true, target: '.ad-close', remove: ['is-test', 'is-done'] },
{ at: 13600, x: 46, y: 62 },
],
});
root.querySelector('.ad-testbtn')?.addEventListener('click', () => player.restart());
});
</script>
</div>
@@ -85,7 +85,7 @@ const callouts = [
</div>
<!-- The live demo window -->
<div class="relative mt-10 md:mt-14 max-w-[1180px] mx-auto">
<div class="relative mt-10 md:mt-14 max-w-[1280px] mx-auto">
<AutomationsDemo />
</div>
+184 -444
View File
@@ -1,279 +1,124 @@
---
/**
* Pixel-faithful mock of the Warmbly /app/crm/deals screen, in its most
* load-bearing state: the kanban board (breadth) with a deal-detail drawer
* open over it (depth + the differentiator that sets this CRM apart, i.e. it
* lives next to your sending).
* The /app/crm/deals screen, mirrored from the app:
*
* Maps 1:1 to the real source:
* - web/src/app/app/crm/deals/page.tsx PageTopbar / StatStrip / SectionBar / Board / Column / DealCard
* - web/src/components/layout/Page.tsx PageTopbar (h-12) / StatStrip / Stat / SectionBar / TopbarAction
* - the deal-detail drawer + tab bar follow the shared drawer pattern
* (web/src/components/app/emails/InboxDetails, contacts/contact-edit):
* a shrink-0 px-3 tab bar with a bg-sky-600 underline on the active tab
* - the activity timeline + send/reply attribution mirror the data shown on
* site/src/pages/crm.astro (dealActivity), where every event traces back
* to the exact sequence step + mailbox it came from
* web/src/app/app/crm/deals/page.tsx PageTopbar, StatStrip, the Board (280px columns on a 12px
* gap), BoardColumn (h-9 header, open value row), DealCard
* (name, contact, value and expected close), and the deal
* dialog a card click opens (480px, scrim with a 2px blur,
* 8px lift over 160ms)
* web/src/components/layout/Page.tsx PageTopbar / StatStrip / SectionBar
* web/src/components/ui/field.tsx Label / TextInput
* web/src/components/ui/DatePicker.tsx the Expected close trigger
*
* The marketing page (FeatureSection) draws the browser frame and crops
* overflow, so there is no window chrome here. The board is full width behind
* the drawer; the drawer covers the right columns and the timeline overflows
* the bottom on purpose (record depth). No CSS variables: every token is the
* in-app literal (border-slate-200, sky/emerald accents), so the mock is
* self-contained.
*
* DEMO SCENE (cd- prefix, 11s loop, persistent end state, reduced-motion safe):
* press #1 -> click the Stratech card -> the deal drawer slides in
* press #2 -> click "Log activity" -> a new activity entry appends on top
* Both reactions hold open until the loop resets. prefers-reduced-motion shows
* the drawer open statically (it is the load-bearing state) and hides the cursor.
* Demo: the cursor clicks the Stratech card, the deal dialog opens, then it
* closes from the dialog's own X.
*/
import DemoCursor from './DemoCursor.astro';
// Money formatter, mirrors formatMoney() in deals/page.tsx.
const money = (n: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(n);
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
// ── Board dataset: one pipeline, four stages, eight deals ──────────
type Tag = { color: string; label: string };
type Deal = {
name: string;
value: number;
contact: string;
owner: string; // initials for the avatar
gradient: string; // tailwind gradient classes for the owner avatar
tags: Tag[];
status?: "won";
hero?: boolean; // the open deal (press target #1) + carries a Source chip
source?: string;
};
type Stage = {
name: string;
color: string; // hex dot, mirrors STAGE_COLORS in pipelines/page.tsx
won?: boolean;
deals: Deal[];
};
type Deal = { name: string; value: number; contact: string; close?: string; status?: 'won'; key?: string };
type Stage = { name: string; color: string; deals: Deal[] };
const stages: Stage[] = [
{
name: "Qualified",
color: "#0ea5e9", // sky-500
name: 'Qualified',
color: '#0ea5e9',
deals: [
{
name: "Stratech",
value: 45000,
contact: "rowan@stratech.io",
owner: "BH",
gradient: "from-teal-400 to-emerald-500",
tags: [{ color: "#8b5cf6", label: "Enterprise" }],
hero: true,
source: "Sequence · acme-fy26-q2",
},
{
name: "Northwind",
value: 8500,
contact: "ravi@northwind.dev",
owner: "JS",
gradient: "from-sky-400 to-blue-500",
tags: [{ color: "#f59e0b", label: "Outbound" }],
},
{
name: "Spruce Bio",
value: 6200,
contact: "lena@sprucebio.com",
owner: "AM",
gradient: "from-rose-400 to-pink-500",
tags: [{ color: "#3b82f6", label: "Inbound" }],
},
{ name: 'Stratech', value: 45000, contact: 'rowan@stratech.io', close: 'Jul 15', key: 'stratech' },
{ name: 'Northwind', value: 8500, contact: 'ravi@northwind.dev', close: 'Jul 30' },
{ name: 'Spruce Bio', value: 6200, contact: 'lena@sprucebio.com' },
],
},
{
name: "Proposal",
color: "#8b5cf6", // violet-500
name: 'Proposal',
color: '#8b5cf6',
deals: [
{
name: "Helio",
value: 24000,
contact: "founder@helio.gen",
owner: "MO",
gradient: "from-violet-400 to-indigo-500",
tags: [
{ color: "#8b5cf6", label: "Enterprise" },
{ color: "#3b82f6", label: "Inbound" },
],
},
{
name: "Tidewater Labs",
value: 15500,
contact: "ops@tidewater.io",
owner: "CR",
gradient: "from-teal-400 to-emerald-500",
tags: [{ color: "#f59e0b", label: "Outbound" }],
},
{ name: 'Helio', value: 24000, contact: 'founder@helio.gen', close: 'Jun 28' },
{ name: 'Tidewater Labs', value: 15500, contact: 'ops@tidewater.io', close: 'Jul 8' },
],
},
{
name: "Negotiation",
color: "#f59e0b", // amber-500
name: 'Negotiation',
color: '#f59e0b',
deals: [
{
name: "Brightfold",
value: 31000,
contact: "priya@brightfold.com",
owner: "JS",
gradient: "from-sky-400 to-blue-500",
tags: [
{ color: "#8b5cf6", label: "Enterprise" },
{ color: "#ef4444", label: "Hot" },
],
},
{
name: "Maple & Co",
value: 9800,
contact: "sam@mapleco.com",
owner: "AM",
gradient: "from-rose-400 to-pink-500",
tags: [{ color: "#3b82f6", label: "Inbound" }],
},
{ name: 'Brightfold', value: 31000, contact: 'priya@brightfold.com', close: 'Jun 20' },
{ name: 'Maple & Co', value: 9800, contact: 'sam@mapleco.com', close: 'Jun 24' },
],
},
{
name: "Won",
color: "#10b981", // emerald-500
won: true,
deals: [
{
name: "Cobalt Group",
value: 42000,
contact: "ceo@cobaltgroup.com",
owner: "MO",
gradient: "from-violet-400 to-indigo-500",
status: "won",
tags: [{ color: "#10b981", label: "Closed" }],
},
],
name: 'Won',
color: '#10b981',
deals: [{ name: 'Cobalt Group', value: 42000, contact: 'ceo@cobaltgroup.com', close: 'Jun 2', status: 'won' }],
},
];
// ── Derived strip values, mirrors the page's reduce()s ─────────────
const allDeals = stages.flatMap((s) => s.deals);
const openValue = allDeals
.filter((d) => d.status !== "won")
.reduce((a, d) => a + d.value, 0);
const openCount = allDeals.filter((d) => d.status !== "won").length;
const wonValue = allDeals.filter((d) => d.status === "won").reduce((a, d) => a + d.value, 0);
const stageTotal = (s: Stage) => s.deals.reduce((a, d) => a + d.value, 0);
const openValue = allDeals.filter((d) => d.status !== 'won').reduce((a, d) => a + d.value, 0);
const openCount = allDeals.filter((d) => d.status !== 'won').length;
const wonValue = allDeals.filter((d) => d.status === 'won').reduce((a, d) => a + d.value, 0);
// The app's stage summary counts and sums open deals only (pg_crm.go).
const stageOpen = (s: Stage) => s.deals.filter((d) => d.status !== 'won');
const stageTotal = (s: Stage) => stageOpen(s).reduce((a, d) => a + d.value, 0);
const stats = [
{ label: "Open", value: String(openCount), sub: "active deals", last: false },
{ label: "Pipeline value", value: money(openValue), sub: "open · all stages", last: false },
{ label: "Won", value: money(wonValue), sub: "closed won", last: false },
{ label: "Stages", value: String(stages.length), sub: "on this pipeline", last: true },
{ label: 'Open', value: String(openCount), sub: 'open deals', last: false },
{ label: 'Pipeline value', value: money(openValue), sub: 'open · server total', last: false },
{ label: 'Won', value: money(wonValue), sub: 'closed won', last: false },
{ label: 'Stages', value: String(stages.length), sub: 'on this pipeline', last: true },
];
// ── Open deal (drawer): forecast + activity timeline ───────────────
const forecast: [string, string][] = [
["Value", money(45000)],
["Close", "Jul 15"],
["Weighted", money(18000)],
["Probability", "40%"],
];
// Lucide icon inner-paths, copied verbatim from site/src/components/Icon.astro
// (eye is inlined; Icon.astro has no eye). Used by the activity timeline nodes.
const ICONS: Record<string, string> = {
reply: '<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
workflow:
'<rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M6 9v4a2 2 0 0 0 2 2h7"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.5.5 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0z"/><circle cx="12" cy="12" r="3"/>',
};
// `label` overrides the chip text (so a task can show its TYPE, e.g. "Follow-up",
// rather than the generic kind word). Defaults to `type` when unset.
type Activity = { type: string; who: string; t: string; body: string; label?: string };
// Newest first. Every row threads back to the send/sequence that produced it;
// the top three are the auto-actions a positive reply triggers.
const activity: Activity[] = [
{ type: "reply", who: "Rowan Cole", t: "2m ago", body: "Replied positively. Classified Positive, sequence auto-paused." },
{ type: "stage", who: "system", t: "2m ago", body: "Deal opened in Qualified, linked to the reply." },
{ type: "task", who: "Ben Hsu", t: "2m ago", label: "Follow-up", body: "Auto-created from the reply, due Jun 10." },
{ type: "note", who: "Ben Hsu", t: "Jun 05", body: "Two opens on the Step 02 thread, no reply yet." },
{ type: "send", who: "sequence", t: "Jun 05", body: "Step 02 soft follow-up, variant B, from ben@acme.com." },
{ type: "open", who: "system", t: "Jun 04", body: "Opened Step 01, tracked near San Francisco." },
{ type: "send", who: "sequence", t: "Jun 02", body: "Step 01 initial outreach, variant A, from ben@acme.com." },
];
const activityTone = (k: string) => {
if (k === "reply") return { chip: "bg-emerald-50 text-emerald-700", icon: ICONS.reply };
if (k === "stage") return { chip: "bg-sky-50 text-sky-700", icon: ICONS.workflow };
if (k === "task") return { chip: "bg-slate-100 text-slate-700", icon: ICONS.check };
if (k === "note") return { chip: "bg-amber-50 text-amber-700", icon: ICONS.list };
if (k === "open") return { chip: "bg-violet-50 text-violet-700", icon: ICONS.eye };
return { chip: "bg-slate-100 text-slate-600", icon: ICONS.send };
const ic = {
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
dollar: '<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
trophy: '<path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/><path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/><path d="M18 9h1.5a1 1 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/><path d="M6 9H4.5a1 1 0 0 1 0-5H6"/>',
trash: '<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
};
---
<div class="relative w-full flex flex-col bg-white text-slate-900" style="min-height:560px;">
<!-- ─────────────────────────────────────────────────────────────
1) PAGE TOPBAR (PageTopbar, h-12)
────────────────────────────────────────────────────────────── -->
<div class="cd relative w-full flex flex-col bg-white text-slate-900 overflow-hidden" style="min-height:560px;" data-demo="cd">
<!-- PageTopbar -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Deals</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">8 deals on Sales pipeline</span>
<span class="text-[12.5px] text-slate-600 truncate">Sales pipeline</span>
<div class="ml-auto flex items-center gap-1.5">
<!-- view toggle (Table | Board), Board active -->
<div class="inline-flex rounded-md bg-slate-100 p-0.5 gap-0.5 shrink-0">
<div class="inline-flex rounded-md bg-slate-100 p-0.5 gap-0.5">
<span class="h-6 px-2 rounded text-[11px] font-medium inline-flex items-center gap-1.5 text-slate-500">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>
Table
</span>
<span class="h-6 px-2 rounded text-[11px] font-medium inline-flex items-center gap-1.5 bg-white text-slate-900 shadow-sm">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="18" x="3" y="3" rx="1"/><rect width="7" height="18" x="14" y="3" rx="1"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>
Board
</span>
</div>
<!-- pipeline picker (PipelinePicker) -->
<button
type="button"
class="h-7 px-2.5 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center gap-1.5 whitespace-nowrap shrink-0"
>
Sales pipeline
<button type="button" class="h-7 px-2.5 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center gap-1.5 min-w-0">
<span class="truncate max-w-[110px] md:max-w-[200px]">Sales pipeline</span>
<span class="text-slate-400">▾</span>
</button>
<!-- search pill (SearchPill) -->
<div class="h-7 px-2 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 whitespace-nowrap shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="w-[96px] text-[12px] text-slate-400">Search deals…</span>
<div class="h-7 px-2 rounded-md border border-slate-200 bg-white flex items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span class="w-[120px] md:w-[160px] h-5 inline-flex items-center text-[12px] text-slate-400">Search deals…</span>
</div>
<!-- primary action (TopbarAction, sky-600) -->
<button
type="button"
class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 text-white whitespace-nowrap shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<button type="button" class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 text-white">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
New deal
</button>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
2) STAT STRIP (StatStrip cols=4)
────────────────────────────────────────────────────────────── -->
<div class="grid grid-cols-4 border-b border-slate-200 shrink-0 bg-white">
{stats.map((s, i) => (
<div class={`group px-5 py-4 ${!s.last ? "border-r border-slate-200" : ""}`}>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
{stats.map((s) => (
<div class={`group px-5 py-4 transition-colors ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{i === 1 && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
@@ -281,93 +126,48 @@ const activityTone = (k: string) => {
))}
</div>
<!-- ─────────────────────────────────────────────────────────────
3) SECTION BAR (SectionBar, h-9) with a live dot
────────────────────────────────────────────────────────────── -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-2.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">4 stages</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">8</span>
<div class="ml-auto inline-flex items-center gap-1.5 text-[11px] text-emerald-600 font-medium">
<span class="relative flex size-1.5">
<span class="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-60 animate-ping"></span>
<span class="relative inline-flex size-1.5 rounded-full bg-emerald-500"></span>
</span>
live
</div>
<!-- SectionBar -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{stages.length} stages</span>
</div>
<!-- ─────────────────────────────────────────────────────────────
4) BOARD REGION (kanban behind, deal drawer over the right)
(board is full width; the drawer crops the right columns and the
cursor demo is anchored to this region's box)
────────────────────────────────────────────────────────────── -->
<div class="relative flex-1 min-h-0">
<!-- (4a) KANBAN BOARD (Board / Column / DealCard) -->
<div class="flex gap-3 p-5 h-full overflow-hidden">
<!-- Board (PageBody) -->
<div class="flex-1 min-h-0 overflow-hidden px-5 py-5">
<div class="grid gap-3 grid-flow-col auto-cols-[280px] overflow-hidden pb-2">
{stages.map((stage) => (
<div
class={`flex flex-col rounded-md min-h-[400px] w-[280px] shrink-0 border ${
stage.won ? "bg-emerald-50/50 border-emerald-200" : "bg-slate-50 border-slate-200"
}`}
>
<!-- Column header -->
<div class="h-9 px-3 flex items-center gap-2 border-b border-slate-200">
<div class="flex flex-col rounded-md min-h-[300px] max-h-[calc(100dvh-230px)] transition-colors bg-slate-50 border-slate-200 border">
<div class="h-9 px-3 flex items-center gap-2 border-b border-slate-200 shrink-0">
<span class="size-1.5 rounded-full shrink-0" style={`background-color:${stage.color};`}></span>
<span class="text-[11px] uppercase tracking-[0.1em] font-semibold text-slate-700 truncate">{stage.name}</span>
<span class="ml-auto font-mono text-[10.5px] text-slate-400 tabular-nums">{stage.deals.length}</span>
<span class="ml-auto font-mono text-[10.5px] text-slate-400 tabular-nums">{stageOpen(stage).length}</span>
</div>
<!-- Stage total value -->
<div class={`px-3 py-1 border-b ${stage.won ? "border-emerald-200/60 bg-emerald-50/40" : "border-slate-200/60 bg-white"}`}>
<span class="text-[10.5px] text-emerald-700 font-mono tabular-nums">{money(stageTotal(stage))} {stage.won ? "total" : "open"}</span>
</div>
<!-- Deal cards -->
<div class="p-2 space-y-1.5 flex-1">
{stageTotal(stage) > 0 && (
<div class="px-3 py-1 border-b border-slate-200/60 bg-white shrink-0">
<span class="text-[10.5px] text-emerald-700 font-mono tabular-nums">{money(stageTotal(stage))} open</span>
</div>
)}
<div class="p-2 space-y-1.5 flex-1 overflow-y-auto">
{stage.deals.map((d) => (
<div class={`rounded-lg bg-white border border-slate-200 shadow-sm transition-all p-3 ${d.hero ? "cd-card" : "hover:border-slate-300"}`}>
<!-- name + won badge -->
<div class="cursor-pointer rounded-md bg-white border border-slate-200 px-2.5 py-2 transition-all [&.is-hover]:border-slate-300 [&.is-hover]:shadow-sm" data-deal={d.key}>
<div class="flex items-center gap-1.5 mb-1">
<div class="text-[12.5px] font-medium text-slate-900 truncate flex-1">{d.name}</div>
{d.status === "won" && (
<span class="text-[9.5px] uppercase tracking-[0.08em] font-semibold text-emerald-700">Won</span>
)}
<div class="text-[12px] font-medium text-slate-900 truncate flex-1">{d.name}</div>
{d.status === 'won' && <span class="text-[9.5px] uppercase tracking-[0.08em] font-semibold text-emerald-700">Won</span>}
</div>
<!-- deal value (emerald + circle-dollar, mirrors the real DealCard) -->
<div class="flex items-center gap-1 font-mono text-[12px] text-emerald-600 tabular-nums mb-1.5">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
{money(d.value)}
</div>
<!-- contact line -->
<div class="flex items-center gap-1 text-[10.5px] text-slate-500 truncate mb-2">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<div class="flex items-center gap-1 text-[10.5px] text-slate-500 truncate mb-1.5">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0" stroke-linecap="round" stroke-linejoin="round" set:html={ic.user}></svg>
<span class="truncate">{d.contact}</span>
</div>
<!-- source attribution chip (hero card only) -->
{d.source && (
<div class="mb-2">
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded bg-white border border-slate-200 text-[10.5px] text-slate-600 truncate max-w-full">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0 text-slate-400" stroke-linecap="round" stroke-linejoin="round"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg>
<span class="truncate">{d.source}</span>
<div class="flex items-center gap-2 text-[10.5px]">
<span class="inline-flex items-center gap-1 text-emerald-600 font-mono tabular-nums">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ic.dollar}></svg>
{money(d.value)}
</span>
{d.close && (
<span class="inline-flex items-center gap-1 text-slate-400 ml-auto">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ic.calendar}></svg>
{d.close}
</span>
</div>
)}
<!-- owner avatar + tag chips -->
<div class="flex items-center gap-1.5 flex-wrap">
<span class={`size-6 rounded-full bg-gradient-to-br ${d.gradient} flex items-center justify-center shrink-0 text-[9px] font-semibold text-white`}>{d.owner}</span>
{d.tags.map((t) => (
<span
class="inline-flex items-center gap-1 h-4 pl-1 pr-1.5 rounded-sm border bg-white text-[10px] font-medium text-slate-700 truncate max-w-[110px]"
style={`border-color:${t.color}60;background-color:${t.color}12;`}
>
<span class="block size-2 rounded-full shrink-0" style={`background-color:${t.color};`}></span>
{t.label}
</span>
))}
)}
</div>
</div>
))}
@@ -375,168 +175,108 @@ const activityTone = (k: string) => {
</div>
))}
</div>
</div>
<!-- (4b) DEAL DRAWER (front layer, slides in on press #1) -->
<div class="cd-drawer absolute inset-y-0 right-0 w-[560px] bg-white border-l border-slate-200 shadow-[-8px_0_30px_-12px_rgba(15,23,42,0.25)] flex flex-col overflow-hidden">
<!-- header -->
<div class="px-5 py-4 border-b border-slate-200 flex items-start gap-3 shrink-0">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2.5">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Deal</span>
<span class="text-[10px] font-mono text-slate-400">DEAL-2026-0184</span>
<!-- The deal dialog a card click opens -->
<!-- Portaled to the body in the app: fixed over the whole window, centred on the screen. -->
<div class="cd-react fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4" aria-hidden="true">
<div class="cd-dialog w-full max-w-[480px] max-h-[calc(100dvh-2rem)] flex flex-col rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18)] overflow-hidden">
<div class="h-12 shrink-0 px-4 border-b border-slate-200 flex items-center gap-2.5">
<div class="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ic.trophy}></svg>
</div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Edit</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-900 font-medium">Deal</span>
<span class="ml-2 size-7 rounded-md text-slate-400 inline-flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ic.trash}></svg>
</span>
<span class="ml-auto size-7 rounded-md text-slate-500 inline-flex items-center justify-center transition-colors [&.is-hover]:text-slate-900 [&.is-hover]:bg-slate-100" data-cd="close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ic.x}></svg>
</span>
</div>
<div class="px-4 py-4 space-y-3 min-h-0 overflow-y-auto">
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Deal name</span>
<div class="h-7 px-2.5 rounded-md border border-sky-400 ring-2 ring-sky-100 bg-white text-[12.5px] text-slate-900 flex items-center w-full">Stratech</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Stage</span>
<div class="h-7 w-full px-2.5 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center gap-1.5">
<span class="size-1.5 rounded-full shrink-0" style="background-color:#0ea5e9;"></span>
<span class="truncate">Qualified</span>
<span class="ml-auto text-slate-400">▾</span>
</div>
</div>
<h3 class="mt-2 text-[18px] font-semibold text-slate-900 leading-tight">Stratech</h3>
<div class="mt-2 flex items-center gap-2.5 flex-wrap">
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium bg-sky-50 text-sky-700">
<span class="size-1.5 rounded-full bg-sky-500"></span>
Qualified
</span>
<span class="text-[12px] text-slate-500">Rowan Cole · VP Platform</span>
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Status</span>
<div class="inline-flex rounded-md bg-slate-100 p-0.5 gap-0.5 w-full">
<span class="flex-1 h-6 px-2 rounded text-[11px] font-medium transition-colors bg-white text-slate-900 shadow-sm">Open</span>
<span class="flex-1 h-6 px-2 rounded text-[11px] font-medium transition-colors text-slate-500">Won</span>
<span class="flex-1 h-6 px-2 rounded text-[11px] font-medium transition-colors text-slate-500">Lost</span>
</div>
</div>
</div>
<!-- primary action (press target #2) -->
<button
type="button"
class="cd-logbtn h-7 px-2.5 rounded-md bg-slate-900 text-white text-[12px] font-medium inline-flex items-center gap-1.5 shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Log activity
</button>
</div>
<!-- forecast row -->
<div class="px-5 py-3 border-b border-slate-200 grid grid-cols-4 gap-3 shrink-0">
{forecast.map(([k, v]) => (
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div class="sm:col-span-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Value</span>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center w-full">45000</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{k}</div>
<div class="mt-1 font-mono text-[13px] text-slate-900 tabular-nums">{v}</div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Currency</span>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center w-full uppercase">USD</div>
</div>
))}
</div>
<!-- tab bar (drawer pattern: sky-600 underline on active) -->
<div class="shrink-0 px-3 flex items-center gap-1 border-b border-slate-200">
<span class="relative h-10 px-2.5 inline-flex items-center gap-1.5 text-[12.5px] text-slate-900 font-medium">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>
Activity
<span class="absolute left-1.5 right-1.5 -bottom-px h-0.5 rounded-full bg-sky-600"></span>
</span>
<span class="relative h-10 px-2.5 inline-flex items-center gap-1.5 text-[12.5px] text-slate-500">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 9h6"/><path d="M9 13h6"/></svg>
Fields
</span>
<span class="relative h-10 px-2.5 inline-flex items-center gap-1.5 text-[12.5px] text-slate-500">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11 3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Tasks
</span>
</div>
<!-- activity header -->
<div class="px-5 pt-4 pb-2 flex items-baseline justify-between shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Activity · last 14 days</span>
<span class="relative inline-flex items-center">
<span class="cd-count-base font-mono text-[10.5px] text-slate-400 tabular-nums">7 events</span>
<span class="cd-count-new absolute right-0 font-mono text-[10.5px] text-emerald-600 tabular-nums whitespace-nowrap">8 events</span>
</span>
</div>
<!-- timeline (overflows the drawer bottom on purpose) -->
<ol class="relative px-5 pb-5">
<div class="absolute left-[35px] top-2 bottom-2 w-px bg-slate-200"></div>
<!-- appended entry (press #2 reaction) -->
<li class="cd-newrow relative overflow-hidden">
<div class="grid grid-cols-[32px_1fr_auto] gap-4 py-3 items-baseline">
<span class="relative z-10 size-8 rounded-full bg-white ring-1 ring-amber-200 inline-flex items-center justify-center text-amber-600 self-start">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" set:html={ICONS.list}></svg>
</span>
<div class="min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<span class="inline-flex items-center h-4 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] bg-amber-50 text-amber-700">note</span>
<span class="text-[11px] font-mono text-slate-500">Ben Hsu</span>
</div>
<p class="mt-1.5 text-[13px] text-slate-700 leading-snug">Logged a call with Rowan. Sending the seat-tiered proposal.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Expected close</span>
<div class="h-7 w-full px-2 rounded-md border border-slate-200 bg-white inline-flex items-center gap-1.5 text-[12px] transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400 shrink-0" stroke-linecap="round" stroke-linejoin="round" set:html={ic.calendar}></svg>
<span class="truncate flex-1 text-left text-slate-900">Jul 15, 2026</span>
<span class="text-slate-400 shrink-0"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ic.x}></svg></span>
</div>
<span class="text-[10.5px] font-mono text-slate-400 whitespace-nowrap self-start pt-1">just now</span>
</div>
</li>
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Contact (read-only)</span>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-slate-50 text-[12.5px] text-slate-400 flex items-center w-full overflow-hidden whitespace-nowrap">rowan@stratech.io</div>
</div>
</div>
</div>
{activity.map((a) => {
const k = activityTone(a.type);
return (
<li class="relative grid grid-cols-[32px_1fr_auto] gap-4 py-3 items-baseline">
<span class="relative z-10 size-8 rounded-full bg-white ring-1 ring-slate-200 inline-flex items-center justify-center text-slate-500 self-start">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" set:html={k.icon}></svg>
</span>
<div class="min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<span class={`inline-flex items-center h-4 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] ${k.chip}`}>{a.label ?? a.type}</span>
<span class="text-[11px] font-mono text-slate-500">{a.who}</span>
</div>
<p class="mt-1.5 text-[13px] text-slate-700 leading-snug">{a.body}</p>
</div>
<span class="text-[10.5px] font-mono text-slate-400 whitespace-nowrap self-start pt-1">{a.t}</span>
</li>
);
})}
</ol>
<div class="px-3 h-12 shrink-0 border-t border-slate-200 flex items-center gap-1.5">
<span class="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 inline-flex items-center">Cancel</span>
<span class="h-7 px-3 rounded-md bg-slate-900 text-white text-[12px] font-medium inline-flex items-center gap-1.5">Save deal</span>
</div>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
DEMO SCENE: cursor clicks the Stratech card (drawer slides in),
then clicks "Log activity" (a new entry appends on top). Static
Astro only; pure CSS, 11s infinite loop, persistent end state.
────────────────────────────────────────────────────────────── -->
<div class="cd-cursor" aria-hidden="true">
<span class="cd-ripple"></span>
<svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<DemoCursor />
<!-- The dialog's own motion: scrim over 150ms, card 8px lift over 160ms. -->
<style>
/* ── cursor + ripple ─────────────────────────────────────────── */
.cd-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:cd-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.cd-cursor svg{display:block;transform-origin:3px 2px;animation:cd-press 11s ease-in-out infinite;}
.cd-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:cd-rip 11s ease-out infinite;}
/* press #1 lands on the Stratech card (Qualified column center ~17.5%,51%);
press #2 lands on the "Log activity" button in the drawer header (~91.5%,32%). */
@keyframes cd-move{0%{left:50%;top:20%}22%{left:17.5%;top:51%}32%{left:17.5%;top:51%}58%{left:91.5%;top:32%}68%{left:91.5%;top:32%}88%{left:50%;top:20%}100%{left:50%;top:20%}}
@keyframes cd-press{0%,26%{transform:scale(1)}28%{transform:scale(.82)}31%,62%{transform:scale(1)}64%{transform:scale(.82)}67%,100%{transform:scale(1)}}
@keyframes cd-rip{0%,26%{transform:scale(0);opacity:0}29%{transform:scale(.25);opacity:.7}41%{transform:scale(1.9);opacity:0}60%{transform:scale(0);opacity:0}63%{transform:scale(.25);opacity:.7}75%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
/* card press-feedback: sky ring flash on press #1, then a held selected ring while the drawer is open */
.cd-card{box-shadow:0 1px 2px 0 rgba(15,23,42,.05);animation:cd-card 11s ease-out infinite;}
@keyframes cd-card{0%,26%{box-shadow:0 1px 2px 0 rgba(15,23,42,.05)}28%{box-shadow:0 0 0 4px rgba(125,211,252,.9)}33%{box-shadow:0 0 0 2px rgba(56,189,248,.95)}88%{box-shadow:0 0 0 2px rgba(56,189,248,.95)}95%{box-shadow:0 1px 2px 0 rgba(15,23,42,.05)}100%{box-shadow:0 1px 2px 0 rgba(15,23,42,.05)}}
/* drawer is the persistent reaction: slides in at 33%, holds to 88% */
.cd-drawer{animation:cd-drawer 11s ease-out infinite;}
@keyframes cd-drawer{0%,29%{opacity:0;transform:translateX(40px)}33%{opacity:1;transform:translateX(0)}88%{opacity:1;transform:translateX(0)}95%{opacity:0;transform:translateX(40px)}100%{opacity:0;transform:translateX(40px)}}
/* "Log activity" button dip on press #2 */
.cd-logbtn{animation:cd-logbtn 11s ease-in-out infinite;}
@keyframes cd-logbtn{0%,62%{transform:scale(1);background-color:#0f172a}64%{transform:scale(.96);background-color:#1e293b}67%,100%{transform:scale(1);background-color:#0f172a}}
/* appended activity entry expands in on press #2, holds to 88% */
.cd-newrow{max-height:0;opacity:0;animation:cd-newrow 11s ease-out infinite;}
@keyframes cd-newrow{0%,63%{max-height:0;opacity:0}67%{max-height:64px;opacity:1}88%{max-height:64px;opacity:1}94%{max-height:0;opacity:0}100%{max-height:0;opacity:0}}
/* event count swaps 7 -> 8 with the appended entry */
.cd-count-base{animation:cd-count-base 11s ease-out infinite;}
.cd-count-new{opacity:0;animation:cd-count-new 11s ease-out infinite;}
@keyframes cd-count-base{0%,63%{opacity:1}67%{opacity:0}88%{opacity:0}94%{opacity:1}100%{opacity:1}}
@keyframes cd-count-new{0%,63%{opacity:0}67%{opacity:1}88%{opacity:1}94%{opacity:0}100%{opacity:0}}
/* reduced motion: keep the drawer open statically (the load-bearing state),
drop the cursor + the loop reactions, keep the card looking selected */
@media (prefers-reduced-motion: reduce){
.cd-cursor{display:none}
.cd-card{animation:none;box-shadow:0 0 0 2px rgba(56,189,248,.95)}
.cd-drawer{animation:none;opacity:1;transform:none}
.cd-logbtn{animation:none}
.cd-newrow{display:none}
.cd-count-base{animation:none;opacity:1}
.cd-count-new{display:none}
}
.cd-react{opacity:0;pointer-events:none;transition:opacity .15s ease-out;}
.cd-dialog{opacity:0;transform:translateY(8px);transition:opacity .16s ease-out,transform .16s ease-out;}
.cd.is-open .cd-react{opacity:1;}
.cd.is-open .cd-dialog{opacity:1;transform:none;}
.cd.is-static .cd-react{display:none;}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
document.querySelectorAll<HTMLElement>('[data-demo="cd"]').forEach((root) => {
playCursor(root, {
loop: 11000,
steps: [
{ at: 0, x: 50, y: 20 },
{ at: 2400, el: '[data-deal="stratech"]', ox: 0.5, oy: 0.5, hover: '[data-deal="stratech"]' },
{ at: 2540, press: true, target: '[data-deal="stratech"]', add: ['is-open'] },
{ at: 6200, el: '[data-cd="close"]', ox: 0.5, oy: 0.55, hover: '[data-cd="close"]' },
{ at: 6340, press: true, remove: ['is-open'] },
{ at: 9000, x: 50, y: 20 },
],
});
});
</script>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -70,7 +70,7 @@ const styleFor = (c: Release['category']) =>
<!-- ── Artifact: the release feed ────────────────────────────── -->
<div class="lg:col-span-7 lg:order-1">
<div class="clg-stage relative overflow-hidden h-[420px] rounded-[18px]"
<div class="clg-stage round-clip h-[420px]"
style="background: linear-gradient(175deg, #fbfcfe 0%, var(--surface-2) 100%); box-shadow: inset 0 0 0 1px var(--border);">
<span class="clg-texture absolute inset-0 pointer-events-none"></span>
<!-- rail -->
+117 -254
View File
@@ -1,278 +1,141 @@
---
/**
* Pixel-faithful mock of the Warmbly /app/emails screen.
* The hero screen: the real /app/emails page inside the full app shell.
*
* Maps 1:1 to the real shell:
* - web/src/components/layout/AppShell.tsx - the outer frame
* - web/src/components/layout/SkyChrome.tsx - the off-white #f5f6f8 chrome
* - web/src/components/layout/AppHeader.tsx - top breadcrumb bar (h-14)
* - web/src/components/layout/AppNav.tsx - the w-64 sidebar (LivePanel + sections)
* - web/src/app/app/emails/page.tsx - the content panel (PageTopbar/StatStrip/SectionBar/table)
*
* The white content panel meets the bottom + right edges of the frame
* and has only its inner top-left corner rounded (rounded-tl-2xl) plus
* a hairline border on the top + left edges.
* Chrome (header, sidebar, LivePanel) lives in AppShellMock so the hero and
* every feature window show the same dashboard. This file is only the
* content panel — web/src/app/app/emails/page.tsx.
*/
const rows = [
{ email: 'founder@acme.co', sent: 37, warm: 22, status: 'healthy' },
{ email: 'hello@acme.co', sent: 24, warm: 18, status: 'healthy' },
{ email: 'team@northwind.dev', sent: 46, warm: 34, status: 'healthy' },
{ email: 'ml@helio.gen', sent: 18, warm: 12, status: 'warming' },
import AppShellMock from './AppShellMock.astro';
// Warmup: current/target while warming (orange, with the dot-grid), else the
// state word. Health: healthTone() gives "Healthy N", "At risk N" (pulse) or
// "Issue N" (pulse).
type Row = { email: string; warm?: [number, number]; warmState?: 'Off' | 'Paused'; health: 'healthy' | 'risk' | 'issue'; score: number };
const rows: Row[] = [
{ email: 'founder@acme.co', warm: [22, 40], health: 'healthy', score: 96 },
{ email: 'hello@acme.co', warm: [18, 40], health: 'healthy', score: 94 },
{ email: 'team@northwind.dev', warm: [34, 40], health: 'healthy', score: 97 },
{ email: 'ml@helio.gen', warm: [12, 40], health: 'risk', score: 71 },
{ email: 'sales@brightco.io', warm: [27, 40], health: 'healthy', score: 92 },
{ email: 'maya@sunriselabs.io', warm: [25, 40], health: 'healthy', score: 95 },
{ email: 'ops@coastline.io', warm: [10, 40], health: 'healthy', score: 88 },
{ email: 'growth@acme.co', warm: [31, 40], health: 'healthy', score: 96 },
{ email: 'jonas@helio.gen', warmState: 'Off', health: 'healthy', score: 90 },
{ email: 'partners@acme.co', warm: [14, 40], health: 'healthy', score: 93 },
{ email: 'ana@brightco.io', warm: [26, 40], health: 'healthy', score: 95 },
{ email: 'leo@northwind.dev', warm: [33, 40], health: 'healthy', score: 98 },
{ email: 'sofia@coastline.io', warmState: 'Paused', health: 'healthy', score: 91 },
{ email: 'dev@sunriselabs.io', warm: [8, 40], health: 'issue', score: 42 },
{ email: 'hello@brightco.io', warm: [29, 40], health: 'healthy', score: 94 },
];
const statusStyle = (s: string) => {
if (s === 'healthy') return { dot: 'bg-emerald-500', text: 'text-emerald-600' };
if (s === 'warming') return { dot: 'bg-amber-500', text: 'text-amber-600' };
return { dot: 'bg-red-500', text: 'text-red-500' };
const healthTone = (h: Row['health'], score: number) => {
if (h === 'healthy') return { dot: 'bg-emerald-500', text: 'text-emerald-600', label: `Healthy ${score}`, pulse: false };
if (h === 'risk') return { dot: 'bg-amber-500', text: 'text-amber-600', label: `At risk ${score}`, pulse: true };
return { dot: 'bg-rose-500', text: 'text-rose-600', label: `Issue ${score}`, pulse: true };
};
const navIcons: Record<string, string> = {
Inbox: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m22 7-10 5L2 7"/>',
Accounts: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
Campaigns: '<polygon points="3 11 22 2 13 21 11 13 3 11"/>',
Contacts: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>',
Analytics: '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
Pipelines: '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
Deals: '<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/>',
Tasks: '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m9 8 11 0"/><rect x="3" y="13" width="6" height="6" rx="1"/><path d="m9 16 11 0"/>',
Templates: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
'API Keys': '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
'Audit log': '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
};
const sidebarSections: { label: string; items: { title: string; active?: boolean; badge?: string }[] }[] = [
{ label: '', items: [
{ title: 'Inbox', badge: '12' },
]},
{ label: 'Email', items: [
{ title: 'Accounts', active: true },
{ title: 'Campaigns' },
{ title: 'Contacts' },
{ title: 'Analytics' },
]},
{ label: 'CRM', items: [
{ title: 'Pipelines' },
{ title: 'Deals' },
]},
];
---
<!-- Outer SkyChrome - #f5f6f8 base + cloud blurs -->
<div class="relative overflow-hidden text-slate-900" style="background:#f5f6f8;">
<!-- cloud blurs (subtle, ambient) -->
<span aria-hidden="true" class="absolute pointer-events-none" style="top:-80px; left:-60px; width:520px; height:240px; background:rgba(255,255,255,0.95); border-radius:50% 50% 18% 18%; filter:blur(60px); opacity:0.7;"></span>
<span aria-hidden="true" class="absolute pointer-events-none" style="top:10%; right:8%; width:380px; height:180px; background:rgba(255,255,255,0.85); border-radius:50% 50% 18% 18%; filter:blur(70px); opacity:0.55;"></span>
<span aria-hidden="true" class="absolute pointer-events-none" style="top:32%; left:26%; width:300px; height:140px; background:rgba(255,255,255,0.9); border-radius:50% 50% 22% 22%; filter:blur(70px); opacity:0.45;"></span>
<!-- ── AppHeader (h-14, sits over SkyChrome) ── -->
<div class="relative z-10 h-14 flex items-center shrink-0">
<!-- Logo zone - sidebar-width -->
<div class="w-64 px-5 h-full flex items-center gap-2.5 shrink-0">
<svg viewBox="0 0 746 764" class="w-7 h-7 text-slate-900" fill="currentColor"><path d="M222.805 644.772L186.274 108.881L704.5 451.158L484.5 451.158L245.5 196.158L444 463.5L222.805 644.772Z"/></svg>
<span class="font-extrabold text-[15.5px] tracking-tight text-slate-900">Warmbly</span>
</div>
<!-- Breadcrumb -->
<div class="flex items-center gap-1.5 min-w-0 flex-1 pr-4">
<!-- OrgSwitcher -->
<div class="flex items-center gap-2 px-1.5 h-7 rounded-md">
<span class="size-[18px] rounded bg-slate-900 flex items-center justify-center shrink-0 text-white text-[10px] font-bold">A</span>
<span class="text-[13px] text-slate-700 font-medium truncate">Acme</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<!-- > section -->
<div class="flex items-center gap-2 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-300 shrink-0"><polyline points="9 18 15 12 9 6"/></svg>
<span class="text-[13px] font-medium text-slate-900 truncate">Accounts</span>
<AppShellMock crumb="Accounts" chrome="always">
<!-- One root, so the shell stretches this column and not each band. -->
<div class="flex flex-col">
<!-- PageTopbar -->
<div class="min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Accounts</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">128 mailboxes</span>
<div class="ml-auto flex items-center gap-1.5 min-w-0 flex-wrap justify-end md:flex-nowrap">
<button class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors bg-sky-600 text-white">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
Add account
</button>
</div>
</div>
<!-- Right actions: PlanPill + divider + ⌘K -->
<div class="flex items-center gap-2 px-4 shrink-0">
<span class="inline-flex items-center h-6 px-2 rounded border text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-indigo-50 text-indigo-700 border-indigo-100">
<span class="w-1.5 h-1.5 rounded-full bg-indigo-500 mr-1.5"></span>
Growth
</span>
<div class="h-4 w-px bg-slate-200/80"></div>
<div class="inline-flex items-center gap-1.5 text-slate-500">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
<span class="text-[11px] font-mono">28ms</span>
</div>
<button class="flex items-center gap-2 px-2 h-7 rounded-md text-slate-500 text-[12.5px]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span>Search</span>
<kbd class="inline-flex h-4 items-center px-1 rounded border border-slate-300/70 bg-white/60 font-mono text-[10px] text-slate-500 ml-0.5">⌘K</kbd>
</button>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
{[
{ label: 'Total', value: '128', sub: 'connected', accent: false, last: false },
{ label: 'Healthy', value: '98', sub: 'sending now', accent: true, last: false },
{ label: 'Warming', value: '21', sub: 'ramping up', accent: false, last: false },
{ label: 'Needs attention', value: '9', sub: 'paused or failing', accent: false, last: true },
].map((s) => (
<div class={`group px-5 py-4 transition-colors ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
</div>
))}
</div>
</div>
<!-- ── Body: sidebar + white content panel ── -->
<div class="relative z-10 flex min-h-[380px]">
<!-- AppNav sidebar (w-64) sits on SkyChrome -->
<aside class="w-64 shrink-0 flex flex-col text-slate-900">
<!-- LivePanel -->
<div class="mx-2 mt-0 mb-3 rounded-md bg-white/85 border border-slate-200/70 px-2.5 py-2">
<div class="flex items-center gap-1.5">
<span class="relative inline-flex shrink-0">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
<span class="absolute inset-0 rounded-full bg-emerald-500/40 animate-ping"></span>
</span>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-500 font-semibold">LIVE</span>
<span class="ml-auto font-mono text-[10px] tabular-nums text-emerald-600">28ms</span>
</div>
<div class="mt-1.5 flex items-baseline gap-1.5">
<span class="text-[15px] text-slate-900 tabular-nums leading-none">128</span>
<span class="text-[11px] text-slate-500">mailboxes</span>
<span class="ml-auto text-[10.5px] text-emerald-600 tabular-nums">122 active</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-[10.5px]">
<span class="text-slate-400">Inbox</span>
<span class="font-mono tabular-nums text-sky-600">12 unread</span>
</div>
<div class="mt-1 flex items-center justify-between gap-2 text-[10.5px]">
<span class="text-slate-400">Today</span>
<span class="font-mono text-slate-400 tabular-nums">5,420 / 6,400</span>
</div>
<div class="mt-2 flex items-end gap-0.5 h-4">
{[20,30,40,55,70,82,90,95,80,60,55,70,82,90].map((v) => (
<div class="flex-1 rounded-sm bg-slate-300" style={`height:${Math.max(8, v)}%; min-height:2px;`}></div>
))}
<!-- SectionBar -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Mailboxes</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">128</span>
<div class="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0">
<div class="h-7 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 min-w-0 w-56">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span class="flex-1 min-w-0 text-[12.5px] text-slate-400">Search by email&hellip;</span>
</div>
<button class="h-7 px-2 rounded-md border border-slate-200 bg-white text-slate-700 inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors">
<span class="text-slate-400 shrink-0"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg></span>
<span class="truncate max-w-[160px]">All accounts</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="text-slate-400 shrink-0"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
</div>
<!-- Nav sections -->
<nav class="flex-1 overflow-y-auto pb-3">
{sidebarSections.map((s, si) => (
<div class={si === 0 ? '' : 'mt-4 pt-4 border-t border-slate-200/50'}>
{s.label && (
<div class="px-4 mb-1.5">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
</div>
)}
<div class="space-y-px">
{s.items.map((it) => {
const path = navIcons[it.title] ?? '<circle cx="12" cy="12" r="3"/>';
return (
<div class={`group mx-2 flex items-center gap-2.5 px-2.5 h-7 rounded-md text-[12.5px] ${it.active ? 'bg-slate-200/70 text-slate-900 font-medium' : 'text-slate-600'}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={it.active ? 2 : 1.6} class={it.active ? 'text-slate-700' : 'text-slate-400'} set:html={path}></svg>
<span class="truncate flex-1">{it.title}</span>
{it.badge && (
<span class="text-[10px] font-medium bg-slate-900 text-white rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 tabular-nums">{it.badge}</span>
)}
</div>
);
})}
</div>
</div>
))}
</nav>
<!-- Settings + UserNav at the bottom -->
<div class="border-t border-slate-200/60 py-1 shrink-0">
<div class="mx-2 flex items-center gap-2.5 px-2.5 h-7 rounded-md text-[12.5px] text-slate-600">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" class="text-slate-400"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Settings</span>
</div>
</div>
<div class="border-t border-slate-200/60 shrink-0 px-3 py-2 flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-violet-400 to-indigo-500"></div>
<div class="flex-1 min-w-0">
<div class="text-[12px] font-medium text-slate-900 truncate">Maya Okonkwo</div>
<div class="text-[10.5px] text-slate-500 truncate">Owner · Acme</div>
</div>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</div>
</aside>
<!-- Content panel - white with rounded inner top-left corner -->
<main class="flex-1 min-w-0 rounded-tl-2xl bg-white overflow-hidden border-t border-l border-slate-200/70">
<!-- PageTopbar -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Accounts</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">128 mailboxes</span>
<div class="ml-auto flex items-center gap-1.5">
<button class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 text-white">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add account
</button>
</div>
</div>
<!-- StatStrip -->
<div class="grid grid-cols-4 border-b border-slate-200 shrink-0 bg-white">
{[
{ label: 'Total', value: '128', sub: 'connected', accent: false, last: false },
{ label: 'Healthy', value: '122', sub: 'sending now', accent: true, last: false },
{ label: 'Warming', value: '6', sub: 'ramping up', accent: false, last: false },
{ label: 'Needs attention', value: '0', sub: 'paused / fail', accent: false, last: true },
].map((s) => (
<div class={`px-5 py-4 ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="w-1.5 h-1.5 rounded-full bg-sky-500 animate-pulse-soft"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
</div>
))}
</div>
<!-- SectionBar -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-2.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Mailboxes</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">128</span>
<div class="ml-auto flex items-center gap-2">
<div class="inline-flex items-center gap-2 h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] text-slate-500 w-56">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="text-slate-400">Search by email…</span>
</div>
<button class="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] font-medium text-slate-700">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
All accounts
</button>
</div>
</div>
<!-- Table -->
<table class="w-full text-left">
<thead class="bg-white">
<tr class="border-b border-slate-200">
<th class="pl-5 pr-2 py-2 w-9"><span class="w-3.5 h-3.5 rounded border border-slate-300 inline-block"></span></th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Account</th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-20 text-right">Sent</th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-24 text-right">Warmup</th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-28">Status</th>
<th class="px-3 py-2 w-16"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const s = statusStyle(r.status);
return (
<tr class="border-b border-slate-200/60 h-11">
<td class="pl-5 pr-2"><span class="w-3.5 h-3.5 rounded border border-slate-300 inline-block"></span></td>
<td class="px-3">
<div class="flex items-center gap-2.5">
<!-- Table -->
<table class="w-full text-left">
<thead class="sticky top-0 bg-white z-[1]">
<tr class="border-b border-slate-200">
<th class="pl-5 pr-2 py-2 w-9"><input type="checkbox" class="w-3.5 h-3.5 rounded accent-sky-600" /></th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Account</th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-24 text-right">Warmup</th>
<th class="px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-10 md:w-32"><span class="hidden md:inline">Health</span></th>
<th class="px-3 py-2 w-16"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const t = healthTone(r.health, r.score);
return (
<tr class="border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors group h-11 cursor-pointer">
<td class="pl-5 pr-2"><input type="checkbox" class="w-3.5 h-3.5 rounded accent-sky-600" /></td>
<td class="px-3 max-w-0 md:max-w-none">
<div class="flex w-full min-w-0 items-center gap-2">
<div class="flex min-w-0 flex-1 items-center gap-2.5 text-left">
<div class="w-6 h-6 rounded-full bg-sky-100 flex items-center justify-center shrink-0">
<span class="text-[9.5px] font-semibold text-sky-700">{r.email.slice(0, 2).toUpperCase()}</span>
</div>
<span class="text-[12.5px] font-medium text-slate-900 truncate">{r.email}</span>
</div>
</div>
</td>
{r.warm ? (
<td class="px-3 text-[12px] tabular-nums text-right font-mono text-orange-600">
<span class="inline-flex items-center justify-end gap-1.5"><span class="campaign-grid shrink-0"></span><span>{r.warm[0]}/{r.warm[1]}</span></span>
</td>
<td class="px-3 text-[12px] text-slate-500 tabular-nums text-right font-mono">{r.sent}</td>
<td class="px-3 text-[12px] text-slate-500 tabular-nums text-right font-mono">{r.warm}</td>
<td class="px-3">
<span class={`inline-flex items-center gap-1.5 text-[11px] font-medium ${s.text}`}>
<span class={`w-1.5 h-1.5 rounded-full ${s.dot}`}></span>
<span class="uppercase tracking-[0.08em]">{r.status}</span>
) : (
<td class={`px-3 text-[12px] tabular-nums text-right font-mono ${r.warmState === 'Paused' ? 'text-amber-600' : 'text-slate-400'}`}>{r.warmState}</td>
)}
<td class="px-3">
<span class={`inline-flex items-center gap-1.5 text-[11px] font-medium ${t.text}`}>
<span class="relative flex w-1.5 h-1.5">
{t.pulse && <span class={`absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping ${t.dot}`}></span>}
<span class={`relative inline-flex w-1.5 h-1.5 rounded-full ${t.dot}`}></span>
</span>
</td>
<td class="px-3"></td>
</tr>
);
})}
</tbody>
</table>
</main>
<span class="uppercase tracking-[0.08em] hidden md:inline">{t.label}</span>
</span>
</td>
<td class="px-3"></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</AppShellMock>
+10
View File
@@ -0,0 +1,10 @@
---
/**
* The pointer every mock demo drives. Positioned by lib/cursor.ts with a
* transform, so it must sit inside a `position: relative` root.
*/
---
<div class="demo-cursor" aria-hidden="true">
<span class="demo-ripple"></span>
<svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
+323 -392
View File
@@ -1,70 +1,25 @@
---
import DemoCursor from './DemoCursor.astro';
/**
* Pixel-faithful mock of the Warmbly /app/api-keys screen, paired with a
* dark quick-start code panel. Maps 1:1 to the real source:
* The /app/api-keys screen, mirrored from the app:
*
* - web/src/app/app/api-keys/page.tsx PageTopbar / StatStrip / SearchPill / KeyRow / StatusPill
* - web/src/app/app/api-keys/_components/CreateKeyModal.tsx the Create API key modal (the reaction)
* - web/src/app/app/api-keys/page.tsx PageTopbar / StatStrip / Traffic (StackedBars) /
* Quick start (CodeSnippet) / Keys (KeyRow, StatusPill)
* - web/src/app/app/api-keys/_components/CreateKeyModal.tsx the Create API key modal (the reaction): Name,
* Description, Permissions (presets + matrix),
* Rate limit, Expires, the IP allowlist disclosure
* - web/src/components/layout/Page.tsx PageTopbar / StatStrip / Stat / SectionBar / TopbarAction
* - internal/models/api_permission.go the real scope names + descriptions + categories
*
* The dark code panel reuses the slate-950 idiom from the real CodeSnippet
* (page.tsx) and the developers/index marketing surfaces.
*
* Renders only the in-app screen content. The marketing page wraps this in
* its own browser frame and crops overflow.
* Demo: the cursor presses Create key, the modal opens, then a click on the
* scrim dismisses it.
*/
// Scope-dot tone, mirrors the category coloring used across the keys UI.
const scopeTone: Record<string, string> = {
read: 'bg-sky-400',
write: 'bg-violet-400',
send: 'bg-emerald-400',
};
// One row per key, matching the real KeyRow: name+description, mono
// prefix…suffix, status pill, rate limit, last used.
const keys = [
{
name: 'production-server',
desc: 'Server-side calls from EU production',
prefix: 'wmbly_live_a1',
suffix: '4f2a',
scope: 'write',
rpm: 600,
last: 'used 2m ago',
status: 'active' as const,
},
{
name: 'ci-pipeline',
desc: 'GitHub Actions deploy step',
prefix: 'wmbly_live_7c',
suffix: '91c7',
scope: 'read',
rpm: 120,
last: 'used 1h ago',
status: 'active' as const,
},
{
name: 'zapier-replies',
desc: 'Inbound reply automation',
prefix: 'wmbly_live_3d',
suffix: '3d8e',
scope: 'send',
rpm: 300,
last: 'used 3d ago',
status: 'active' as const,
},
{
name: 'old-laptop',
desc: 'Rotated out, kept for audit',
prefix: 'wmbly_live_0p',
suffix: 'xx0p',
scope: 'read',
rpm: 120,
last: 'used 23d ago',
status: 'revoked' as const,
},
{ name: 'production-server', desc: 'Server-side calls from EU production', prefix: 'wmbly_live_a1', suffix: '4f2a', rpm: 600, last: 'used 2m ago', status: 'active' as const },
{ name: 'ci-pipeline', desc: 'GitHub Actions deploy step', prefix: 'wmbly_live_7c', suffix: '91c7', rpm: 120, last: 'used 1h ago', status: 'active' as const },
{ name: 'zapier-replies', desc: 'Inbound reply automation', prefix: 'wmbly_live_3d', suffix: '3d8e', rpm: 300, last: 'never used', status: 'active' as const },
{ name: 'old-laptop', desc: 'Rotated out, kept for audit', prefix: 'wmbly_live_0p', suffix: 'xx0p', rpm: 120, last: 'used 23d ago', status: 'revoked' as const },
];
const statusTone = (s: string) =>
@@ -74,367 +29,343 @@ const statusTone = (s: string) =>
? { bg: 'bg-rose-50', text: 'text-rose-700', dot: 'bg-rose-500' }
: { bg: 'bg-slate-100', text: 'text-slate-600', dot: 'bg-slate-400' };
// Top StatStrip cells, mirroring useAPIKeyUsageSummary on the real page.
const stats = [
{ label: 'Active keys', value: '3', sub: '1 inactive', accent: true, last: false },
{ label: 'Requests · 24h', value: '8,241', sub: '4 errors', accent: false, last: false },
{ label: 'Avg latency · 24h',value: '28ms', sub: 'across all keys', accent: false, last: false },
{ label: 'Last call', value: '14s ago', sub: 'Jun 6, 02:02 PM', accent: false, last: true },
{ label: 'Active keys', value: '3', sub: '1 inactive', accent: true, last: false },
{ label: 'Requests · 24h', value: '8,241', sub: '4 errors', accent: false, last: false },
{ label: 'Avg latency · 24h', value: '28ms', sub: 'across all keys', accent: false, last: false },
{ label: 'Last call', value: '14s ago', sub: 'Jun 6, 02:02 PM', accent: false, last: true },
];
// 24 hourly buckets: 2xx / 4xx / 5xx shares of a 104px-tall bar.
const buckets = [42, 55, 38, 61, 49, 72, 58, 80, 64, 47, 69, 53, 76, 60, 84, 51, 68, 57, 73, 45, 62, 79, 54, 66];
const keyIcon = '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>';
const gaugeIcon = '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>';
// PermissionMatrix groups, in api_permission.go order.
const perms = [
{ group: 'read', rows: [
['READ_EMAILS', 'View email accounts and settings'],
['READ_CAMPAIGNS', 'View campaigns and sequences'],
['READ_CONTACTS', 'View contact lists, segments, notes, and activities'],
['READ_UNIBOX', 'View emails in the unified inbox'],
['READ_ANALYTICS', 'View analytics and reports'],
], on: true },
{ group: 'write', rows: [
['WRITE_EMAILS', 'Modify email account settings'],
['WRITE_CAMPAIGNS', 'Create and modify campaigns and sequences'],
['WRITE_CONTACTS', 'Create and modify contacts, segments, notes, and activities'],
], on: false },
];
---
<div class="relative w-full bg-white text-slate-900" style="min-height:520px;">
<div class="flex min-h-[520px]">
<div class="dv relative w-full bg-white flex flex-col" style="min-height:520px;" data-demo="dv">
<!-- ════════════════════════════════════════════════════════════
LEFT · the API Keys screen (PageTopbar + StatStrip + Keys list)
════════════════════════════════════════════════════════════ -->
<div class="flex-1 min-w-0 flex flex-col border-r border-slate-200">
<!-- PageTopbar (h-12) -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">API keys</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">Programmatic access · stripe-style scoped tokens</span>
<div class="ml-auto flex items-center gap-1.5">
<!-- SearchPill -->
<div class="h-7 px-2 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="text-[12px] text-slate-400 w-[140px]">Search…</span>
</div>
<!-- Refresh -->
<span class="h-7 w-7 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
</span>
<!-- Create key (TopbarAction, sky-600). Press #1 target. -->
<button id="dv-create" class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 text-white whitespace-nowrap shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create key
</button>
</div>
<!-- PageTopbar -->
<div class="min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">API keys</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">Programmatic access · stripe-style scoped tokens</span>
<div class="ml-auto flex items-center gap-1.5 min-w-0 flex-wrap justify-end md:flex-nowrap">
<!-- SearchPill -->
<div class="h-7 px-2 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span class="w-[100px] sm:w-[140px] h-5 inline-flex items-center text-[12px] text-slate-400">Search…</span>
</div>
<!-- Refresh -->
<span class="h-7 w-7 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</span>
<!-- Create key (TopbarAction) -->
<button id="dv-create" class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors bg-sky-600 text-white [&.is-hover]:bg-sky-700 [&.is-pressed]:bg-sky-700">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<span class="hidden sm:inline">Create key</span>
</button>
</div>
</div>
<!-- StatStrip (4 cols) -->
<div class="grid grid-cols-4 border-b border-slate-200 shrink-0 bg-white">
{stats.map((s) => (
<div class={`px-5 py-4 ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
{stats.map((s) => (
<div class={`group px-5 py-4 transition-colors ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
</div>
))}
</div>
<!-- SectionBar · Traffic last 24h -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Traffic · last 24h</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">8,241</span>
</div>
<!-- StackedBars: 120px tall, a 104px DitherColumns canvas, baseline and ticks -->
<div class="px-5 py-4 border-b border-slate-200/60">
<div class="relative w-full" style="height:120px;">
<div class="flex items-end gap-[2px]" style="height:104px;">
{buckets.map((h, i) => (
<div class="flex-1 flex flex-col justify-end" style="height:104px;">
{i % 7 === 3 && <div class="w-full dv-dither" style="height:4px; --c:244,63,94;"></div>}
{i % 5 === 2 && <div class="w-full dv-dither" style="height:6px; --c:245,158,11;"></div>}
<div class="w-full dv-dither" style={`height:${h}%; --c:16,185,129;`}></div>
</div>
))}
</div>
<!-- SectionBar · Traffic last 24h -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-2.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Traffic · last 24h</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">8,241</span>
<div class="absolute left-0 right-0 bottom-3 h-px bg-slate-200/80"></div>
<div class="absolute left-0 right-0 bottom-0 flex justify-between font-mono text-[9.5px] text-slate-400">
<span>Jun 5, 03:00 PM</span>
<span>Jun 6, 02:00 PM</span>
</div>
</div>
<div class="mt-2 flex items-center gap-3 text-[10.5px] text-slate-500">
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-sm bg-emerald-500/80"></span>2xx</span>
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-sm bg-amber-400/80"></span>4xx</span>
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-sm bg-rose-500/80"></span>5xx</span>
<span class="ml-auto inline-flex items-center gap-1.5 text-[10.5px] text-slate-400">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={gaugeIcon}></svg>
live; refreshes every minute
</span>
</div>
</div>
<!-- StackedBars activity graph (2xx / 4xx / 5xx) -->
<div class="px-5 py-4 border-b border-slate-200/60 shrink-0">
<div class="flex items-end gap-[3px] h-[80px]">
{[42,55,38,61,49,72,58,80,64,47,69,53,76,60,84,51,68,57,73,45,62,79,54,66].map((h, i) => (
<div class="flex-1 flex flex-col justify-end gap-px" style={`height:80px`}>
{i % 7 === 3 && <div class="w-full rounded-[1px] bg-rose-500/80" style="height:4px"></div>}
{i % 5 === 2 && <div class="w-full rounded-[1px] bg-amber-400/80" style="height:6px"></div>}
<div class="w-full rounded-[1px] bg-emerald-500/80" style={`height:${h}%`}></div>
<!-- SectionBar · Quick start + CodeSnippet -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Quick start</span>
</div>
<div class="px-5 py-4 border-b border-slate-200/60">
<div class="rounded-md border border-slate-200 bg-slate-950 overflow-hidden">
<div class="h-8 px-3 flex items-center gap-2 border-b border-slate-800/60">
<span class="size-1.5 rounded-full bg-red-400/70"></span>
<span class="size-1.5 rounded-full bg-amber-400/70"></span>
<span class="size-1.5 rounded-full bg-emerald-400/70"></span>
<span class="ml-2 text-[11px] text-slate-400 font-mono">curl</span>
<span class="ml-auto inline-flex items-center gap-1 h-6 px-2 rounded text-slate-400 text-[11px] transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
Copy
</span>
</div>
<pre class="px-4 py-3 text-[12px] leading-relaxed text-slate-200 font-mono whitespace-pre overflow-x-auto">curl https://api.warmbly.com/v1/campaigns \
-H "Authorization: Bearer wmbly_live_a1……" \
-H "Content-Type: application/json"</pre>
</div>
<p class="text-[11.5px] text-slate-500 mt-2 leading-relaxed">
Pass the secret as a <span class="font-mono">Bearer</span> header. Every request returns
rate-limit headers (<span class="font-mono">X-RateLimit-Limit</span>,
<span class="font-mono">X-RateLimit-Remaining</span>) so well-behaved clients can self-throttle
before hitting <span class="font-mono">429</span>.
</p>
</div>
<!-- SectionBar · Keys -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Keys</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">4</span>
</div>
<!-- Keys list (PageBody > KeyRow, divide-y) -->
<div class="flex-1 min-h-0 overflow-hidden">
<div class="divide-y divide-slate-200/60">
{keys.map((k) => {
const t = statusTone(k.status);
return (
<div class="w-full h-12 px-5 flex items-center gap-3 text-left transition-colors group">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class={`shrink-0 ${k.status === 'active' ? 'text-slate-500' : 'text-slate-300'}`} stroke-linecap="round" stroke-linejoin="round" set:html={keyIcon}></svg>
<div class="flex flex-col min-w-0 max-w-[40%]">
<span class="text-[12.5px] text-slate-900 font-medium truncate">{k.name}</span>
<span class="text-[10.5px] text-slate-500 truncate">{k.desc}</span>
</div>
))}
</div>
<div class="mt-2 flex items-center gap-3 text-[10.5px] text-slate-500">
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-sm bg-emerald-500/80"></span>2xx</span>
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-sm bg-amber-400/80"></span>4xx</span>
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-sm bg-rose-500/80"></span>5xx</span>
<span class="ml-auto inline-flex items-center gap-1.5 text-[10.5px] text-slate-400">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
live; refreshes every minute
</span>
</div>
</div>
<!-- SectionBar · Keys -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-2.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Keys</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">4</span>
</div>
<!-- Keys list (KeyRow, divide-y) -->
<div class="divide-y divide-slate-200/60">
{keys.map((k) => {
const t = statusTone(k.status);
return (
<div class="group h-12 px-5 flex items-center gap-3 text-left hover:bg-slate-50/80">
<!-- KeyIcon -->
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class={`shrink-0 ${k.status === 'active' ? 'text-slate-500' : 'text-slate-300'}`} stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
<!-- name + description -->
<div class="flex flex-col min-w-0 max-w-[40%]">
<span class="text-[12.5px] text-slate-900 font-medium truncate">{k.name}</span>
<span class="text-[10.5px] text-slate-500 truncate">{k.desc}</span>
</div>
<!-- mono prefix…suffix -->
<span class="font-mono text-[10.5px] text-slate-500 truncate">{k.prefix}…{k.suffix}</span>
<!-- StatusPill -->
<span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${t.bg} ${t.text}`}>
<span class={`size-1.5 rounded-full ${t.dot}`}></span>
{k.status}
<span class="font-mono text-[10.5px] text-slate-500 truncate">{k.prefix}…{k.suffix}</span>
<span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${t.bg} ${t.text}`}>
<span class={`size-1.5 rounded-full ${t.dot}`}></span>
{k.status}
</span>
<span class="ml-auto flex items-center gap-3 shrink-0">
<span class="hidden sm:inline text-[10.5px] text-slate-400 tabular-nums">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="inline mr-1 align-[-2px]" stroke-linecap="round" stroke-linejoin="round" set:html={gaugeIcon}></svg>{k.rpm}/m
</span>
<!-- right: rate limit + last used -->
<span class="ml-auto flex items-center gap-3 shrink-0">
<span class="text-[10.5px] text-slate-400 tabular-nums inline-flex items-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-1 align-[-2px]" stroke-linecap="round" stroke-linejoin="round"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
{k.rpm}/m
</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums w-32 text-right truncate">{k.last}</span>
</span>
</div>
);
})}
</div>
<span class="hidden md:inline font-mono text-[10.5px] text-slate-400 tabular-nums w-32 text-right truncate">{k.last}</span>
</span>
</div>
);
})}
</div>
<!-- ════════════════════════════════════════════════════════════
RIGHT · Quick start (dark code panel, slate-950 idiom)
════════════════════════════════════════════════════════════ -->
<div class="w-[400px] shrink-0 flex flex-col bg-slate-50/40">
<!-- panel topbar -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Quick start</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">Your first request</span>
</div>
<div class="p-4 flex flex-col gap-3">
<!-- dark code panel: cURL request -->
<div class="rounded-md border border-slate-200 bg-slate-950 overflow-hidden">
<div class="h-8 px-3 flex items-center gap-2 border-b border-slate-800/60">
<span class="size-1.5 rounded-full bg-red-400/70"></span>
<span class="size-1.5 rounded-full bg-amber-400/70"></span>
<span class="size-1.5 rounded-full bg-emerald-400/70"></span>
<span class="ml-2 text-[11px] text-slate-400 font-mono">curl</span>
<span class="ml-auto inline-flex items-center gap-1 h-6 px-2 rounded text-slate-400 text-[11px]">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
Copy
</span>
</div>
<pre class="text-slate-200 px-4 py-3 text-[12px] leading-relaxed font-mono whitespace-pre overflow-x-auto"><code>curl <span style="color:#fde68a">https://api.warmbly.com/v1/campaigns</span> \
<span style="color:#f472b6">-H</span> <span style="color:#fde68a">"Authorization: Bearer wmbly_live_a1…"</span> \
<span style="color:#f472b6">-H</span> <span style="color:#fde68a">"Content-Type: application/json"</span></code></pre>
</div>
<!-- rate-limit-header note, from the page copy -->
<p class="text-[11.5px] text-slate-500 leading-relaxed">
Pass the secret as a <span class="font-mono text-slate-700">Bearer</span> header. Every request returns
rate-limit headers (<span class="font-mono text-slate-700">X-RateLimit-Limit</span>,
<span class="font-mono text-slate-700">X-RateLimit-Remaining</span>) so well-behaved clients can self-throttle
before hitting <span class="font-mono text-slate-700">429</span>.
</p>
<!-- response (200) -->
<div class="rounded-md border border-slate-200 bg-slate-950 overflow-hidden">
<div class="h-8 px-3 flex items-center gap-2 border-b border-slate-800/60">
<span class="text-[10px] font-mono uppercase tracking-[0.14em] text-slate-400">Response</span>
<span class="ml-auto inline-flex items-center gap-1.5 h-5 px-1.5 rounded text-[10px] font-mono font-medium bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-400/20">
<span class="size-1.5 rounded-full bg-emerald-400"></span>
200 OK
</span>
</div>
<pre class="text-slate-200 px-4 py-3 text-[12px] leading-relaxed font-mono overflow-x-auto"><code><span style="color:#94a3b8">&#123;</span>
<span style="color:#7dd3fc">"id"</span>: <span style="color:#fde68a">"cmp_01HQX9F7P3A8KY"</span>,
<span style="color:#7dd3fc">"status"</span>: <span style="color:#fde68a">"scheduled"</span>,
<span style="color:#7dd3fc">"daily_cap"</span>: <span style="color:#a78bfa">50</span>,
<span style="color:#7dd3fc">"created_at"</span>: <span style="color:#fde68a">"2026-06-06T14:02Z"</span>
<span style="color:#94a3b8">&#125;</span></code></pre>
</div>
<!-- HMAC-signed webhooks note -->
<div class="rounded-md border border-slate-200 bg-white px-3.5 py-3">
<div class="flex items-center gap-1.5 mb-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="text-sky-600" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-500 font-medium">HMAC-signed webhooks</span>
</div>
<p class="text-[11px] text-slate-500 leading-relaxed">
Events post to your endpoint with an <span class="font-mono text-slate-700">X-Warmbly-Signature</span> header.
Verify the HMAC-SHA256 digest before you trust the body.
</p>
<div class="mt-2 flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 h-5 px-2 rounded bg-sky-50 text-sky-700 text-[10.5px] font-mono">
<span class="size-1.5 rounded-full bg-sky-500"></span>
contact.replied
</span>
<span class="font-mono text-[10px] text-slate-400 tabular-nums">delivered 4s ago</span>
</div>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════
DEMO SCENE · cursor presses "Create key" -> CreateKeyModal opens,
cursor then presses the dark scrim (click-outside) -> modal dismisses
════════════════════════════════════════════════════════════ -->
<!-- CreateKeyModal: portaled to the body in the app, fixed over the whole window. -->
<div class="dv-react fixed inset-0 z-40" aria-hidden="true">
<div class="absolute inset-0 bg-black/40"></div>
<div class="dv-card absolute left-1/2 -translate-x-1/2 z-50 w-[560px] max-w-[92vw] bg-white rounded-lg border border-slate-200 shadow-[0_24px_60px_-12px_rgba(15,23,42,0.25)] overflow-hidden" style="top:10%;">
<!-- (b) reaction overlay: the real CreateKeyModal -->
<div class="dv-react" style="left:0;top:0;right:0;bottom:0;" aria-hidden="true">
<div class="absolute inset-0 bg-slate-900/30"></div>
<div class="absolute inset-0 flex items-start justify-center pt-6 px-4">
<div class="dv-card w-[560px] bg-white rounded-lg border border-slate-200 shadow-[0_24px_60px_-12px_rgba(15,23,42,0.25)] overflow-hidden">
<!-- ConfigureStep header -->
<div class="h-11 px-4 border-b border-slate-200 flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-500" stroke-linecap="round" stroke-linejoin="round" set:html={keyIcon}></svg>
<span class="text-[12.5px] font-medium text-slate-900">Create API key</span>
<span class="ml-auto w-7 h-7 rounded-md inline-flex items-center justify-center text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</span>
</div>
<!-- modal header -->
<div class="h-11 px-4 border-b border-slate-200 flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="text-slate-500" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
<span class="text-[12.5px] font-medium text-slate-900">Create API key</span>
<span class="ml-auto w-7 h-7 rounded-md inline-flex items-center justify-center text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<div class="px-5 py-4 space-y-4 max-h-[70dvh] overflow-hidden">
<!-- Name (autofocused, empty) -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Name</span>
</div>
<div class="w-full h-8 px-2.5 rounded-md border border-sky-400 ring-1 ring-sky-200 bg-white flex items-center">
<span class="dv-caret w-px h-3.5 bg-slate-900"></span>
<span class="text-[12.5px] text-slate-400">production-server</span>
</div>
<p class="text-[10.5px] text-slate-400 mt-1 leading-relaxed">Shown in the dashboard. e.g. production-server, ci-pipeline.</p>
</div>
<!-- Description -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Description</span>
<span class="text-[10px] text-slate-400 font-mono">optional</span>
</div>
<div class="w-full h-[50px] px-2.5 py-1.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-400 resize-none">Server-side calls from EU production</div>
<p class="text-[10.5px] text-slate-400 mt-1 leading-relaxed">Internal note. Where will this key be used?</p>
</div>
<!-- Permissions: presets + matrix -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Permissions</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-1.5 mb-2">
<div class="relative text-left rounded-md border px-2.5 py-2 transition-colors border-sky-400 bg-sky-50/60">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="text-sky-600"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg></span>
<span class="text-[11.5px] font-medium text-sky-900">Read-only</span>
</div>
<p class="text-[10.5px] text-slate-500">View everything</p>
</div>
<div class="relative text-left rounded-md border px-2.5 py-2 transition-colors border-slate-200 bg-white">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="text-slate-500"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg></span>
<span class="text-[11.5px] font-medium text-slate-900">Full access</span>
</div>
<p class="text-[10.5px] text-slate-500">Read + write</p>
</div>
<div class="relative text-left rounded-md border px-2.5 py-2 transition-colors border-slate-200 bg-white">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="text-slate-500"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="1" x2="7" y1="14" y2="14"/><line x1="9" x2="15" y1="8" y2="8"/><line x1="17" x2="23" y1="16" y2="16"/></svg></span>
<span class="text-[11.5px] font-medium text-slate-900">Custom</span>
</div>
<p class="text-[10.5px] text-slate-500">Pick scopes</p>
</div>
</div>
<!-- PermissionMatrix: 224px, scrolls -->
<div class="rounded-md border border-slate-200 divide-y divide-slate-200/60 max-h-56 overflow-hidden">
{perms.map((g) => (
<div>
<div class="px-2.5 py-1.5 bg-slate-50/60 text-[10px] uppercase tracking-[0.14em] text-slate-500 font-medium">{g.group}</div>
<div>
{g.rows.map(([name, desc]) => (
<div class="w-full px-2.5 py-1.5 flex items-center gap-2 text-left transition-colors">
{g.on
? <span class="w-3.5 h-3.5 rounded border border-sky-600 bg-sky-600 text-white flex items-center justify-center shrink-0 transition-colors"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
: <span class="w-3.5 h-3.5 rounded border border-slate-300 bg-white shrink-0 transition-colors"></span>}
<span class="font-mono text-[11px] text-slate-900">{name}</span>
<span class="text-[10.5px] text-slate-500 truncate">{desc}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
<!-- Rate limit (RateLimitSetter) -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Rate limit</span>
</div>
<div class="rounded-lg border border-slate-200 bg-white px-3 py-3">
<div class="flex items-center justify-center gap-3">
<span class="w-8 h-8 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center transition-colors"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg></span>
<span class="w-[74px] text-center text-[22px] font-semibold tabular-nums text-slate-900">60</span>
<span class="text-[11px] text-slate-400 whitespace-nowrap">req / min</span>
<span class="w-8 h-8 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center transition-colors"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg></span>
</div>
<div class="w-full mt-3 h-1.5 rounded-full bg-slate-200 relative"><div class="absolute left-0 top-0 h-full w-[6%] rounded-full bg-sky-600"></div><span class="absolute top-1/2 -translate-y-1/2 size-3.5 rounded-full bg-sky-600 border border-white shadow" style="left:6%;"></span></div>
<div class="flex items-center justify-center gap-1.5 mt-2.5">
{['30', '60', '120', '600', '1k'].map((v) => (
<span class={`h-6 px-2.5 rounded-md text-[11px] tabular-nums transition-colors inline-flex items-center ${v === '60' ? 'bg-sky-600 text-white' : 'border border-slate-200 text-slate-600'}`}>{v}</span>
))}
</div>
</div>
<p class="text-[10.5px] text-slate-400 mt-1 leading-relaxed">Soft cap, sliding window. We return 429 + Retry-After when exceeded.</p>
</div>
<!-- Expires -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Expires</span>
</div>
<div class="flex items-center gap-1">
<span class="h-7 px-3 rounded-md text-[11.5px] transition-colors bg-slate-900 text-white inline-flex items-center">Never</span>
<span class="h-7 px-3 rounded-md text-[11.5px] transition-colors border border-slate-200 text-slate-600 inline-flex items-center">30 days</span>
<span class="h-7 px-3 rounded-md text-[11.5px] transition-colors border border-slate-200 text-slate-600 inline-flex items-center">90 days</span>
<span class="h-7 px-3 rounded-md text-[11.5px] transition-colors border border-slate-200 text-slate-600 inline-flex items-center">365 days</span>
</div>
</div>
<span class="text-[11.5px] text-slate-500 underline-offset-2">Advanced (IP allowlist)</span>
</div>
<!-- footer -->
<div class="h-12 px-4 border-t border-slate-200 flex items-center gap-2 bg-slate-50/40">
<span class="hidden md:inline-flex text-[11px] text-slate-500 items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
The secret will be shown once. Save it somewhere safe.
</span>
<div class="ml-auto flex items-center gap-1.5">
<span class="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center transition-colors">Cancel</span>
<span class="h-7 px-3 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={keyIcon}></svg>
Create key
</span>
</div>
<!-- modal body -->
<div class="px-5 py-3.5 space-y-3.5">
<!-- Name field -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Name</span>
</div>
<div class="w-full h-8 px-2.5 rounded-md border border-sky-400 ring-1 ring-sky-200 bg-white flex items-center">
<span class="text-[12.5px] text-slate-900">Staging</span>
<span class="dv-caret ml-px w-px h-3.5 bg-sky-500"></span>
</div>
<p class="text-[10.5px] text-slate-400 mt-1 leading-relaxed">Shown in the dashboard. e.g. production-server, ci-pipeline.</p>
</div>
<!-- Permissions: preset cards + matrix -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Permissions</span>
</div>
<!-- preset cards -->
<div class="grid grid-cols-3 gap-1.5 mb-2">
<div class="relative text-left rounded-md border px-2.5 py-2 border-sky-400 bg-sky-50/60">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="text-sky-600"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg></span>
<span class="text-[11.5px] font-medium text-sky-900">Read-only</span>
</div>
<p class="text-[10.5px] text-slate-500">View everything</p>
</div>
<div class="relative text-left rounded-md border px-2.5 py-2 border-slate-200 bg-white">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="text-slate-500"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg></span>
<span class="text-[11.5px] font-medium text-slate-900">Full access</span>
</div>
<p class="text-[10.5px] text-slate-500">Read + write</p>
</div>
<div class="relative text-left rounded-md border px-2.5 py-2 border-slate-200 bg-white">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="text-slate-500"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="1" x2="7" y1="14" y2="14"/><line x1="9" x2="15" y1="8" y2="8"/><line x1="17" x2="23" y1="16" y2="16"/></svg></span>
<span class="text-[11.5px] font-medium text-slate-900">Custom</span>
</div>
<p class="text-[10.5px] text-slate-500">Pick scopes</p>
</div>
</div>
<!-- PermissionMatrix: grouped checkbox rows (read [checked], write [checked], special) -->
<div class="rounded-md border border-slate-200 divide-y divide-slate-200/60 max-h-[148px] overflow-hidden">
<!-- read group -->
<div>
<div class="px-2.5 py-1.5 bg-slate-50/60 text-[10px] uppercase tracking-[0.14em] text-slate-500 font-medium">read</div>
<div>
<div class="w-full px-2.5 py-1.5 flex items-center gap-2 text-left">
<span class="w-3.5 h-3.5 rounded border border-sky-600 bg-sky-600 text-white flex items-center justify-center shrink-0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="font-mono text-[11px] text-slate-900">READ_CAMPAIGNS</span>
<span class="text-[10.5px] text-slate-500 truncate">View campaigns and sequences</span>
</div>
<div class="w-full px-2.5 py-1.5 flex items-center gap-2 text-left">
<span class="w-3.5 h-3.5 rounded border border-sky-600 bg-sky-600 text-white flex items-center justify-center shrink-0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="font-mono text-[11px] text-slate-900">READ_CONTACTS</span>
<span class="text-[10.5px] text-slate-500 truncate">View contact lists, notes, and activities</span>
</div>
</div>
</div>
<!-- write group -->
<div>
<div class="px-2.5 py-1.5 bg-slate-50/60 text-[10px] uppercase tracking-[0.14em] text-slate-500 font-medium">write</div>
<div>
<div class="w-full px-2.5 py-1.5 flex items-center gap-2 text-left">
<span class="w-3.5 h-3.5 rounded border border-sky-600 bg-sky-600 text-white flex items-center justify-center shrink-0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="font-mono text-[11px] text-slate-900">WRITE_CAMPAIGNS</span>
<span class="text-[10.5px] text-slate-500 truncate">Create and modify campaigns and sequences</span>
</div>
<div class="w-full px-2.5 py-1.5 flex items-center gap-2 text-left">
<span class="w-3.5 h-3.5 rounded border border-slate-300 bg-white shrink-0"></span>
<span class="font-mono text-[11px] text-slate-900">SEND_CAMPAIGNS</span>
<span class="text-[10.5px] text-slate-500 truncate">Start and stop campaigns (sends real mail)</span>
</div>
</div>
</div>
</div>
</div>
<!-- Expires pills -->
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">Expires</span>
</div>
<div class="flex items-center gap-1">
<span class="h-7 px-3 rounded-md text-[11.5px] bg-slate-900 text-white inline-flex items-center whitespace-nowrap shrink-0">Never</span>
<span class="h-7 px-3 rounded-md text-[11.5px] border border-slate-200 text-slate-600 inline-flex items-center whitespace-nowrap shrink-0">30 days</span>
<span class="h-7 px-3 rounded-md text-[11.5px] border border-slate-200 text-slate-600 inline-flex items-center whitespace-nowrap shrink-0">90 days</span>
<span class="h-7 px-3 rounded-md text-[11.5px] border border-slate-200 text-slate-600 inline-flex items-center whitespace-nowrap shrink-0">365 days</span>
</div>
</div>
</div>
<!-- modal footer -->
<div class="h-12 px-4 border-t border-slate-200 flex items-center gap-2 bg-slate-50/40">
<span class="text-[11px] text-slate-500 inline-flex items-center gap-1.5 min-w-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span class="truncate">The secret will be shown once. Save it somewhere safe.</span>
</span>
<div class="ml-auto flex items-center gap-1.5 shrink-0">
<span class="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center whitespace-nowrap shrink-0">Cancel</span>
<!-- modal Create key -->
<span class="h-7 px-3 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
Create key
</span>
</div>
</div>
</div>
</div>
</div>
<!-- (a) cursor -->
<div class="dv-cursor" aria-hidden="true">
<span class="dv-ripple"></span>
<svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<DemoCursor />
<!-- (c) demo-scene styles -->
<!-- Scene styles: CreateKeyModal's own enter and exit (scrim 150ms, card
8px lift + 0.99 scale over 180ms), driven by .is-open. The traffic
bars carry the DitherColumns halftone (8x8 Bayer at 0.85). -->
<style>
.dv-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:dv-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.dv-cursor svg{display:block;transform-origin:3px 2px;animation:dv-press 11s ease-in-out infinite;}
.dv-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:dv-rip 11s ease-out infinite;}
.dv-react{position:absolute;z-index:40;opacity:0;animation:dv-react 11s ease-out infinite;}
.dv-card{transform-origin:center;transform:scale(.94);animation:dv-card 11s ease-out infinite;}
.dv-react{opacity:0;pointer-events:none;transition:opacity .15s ease-out;}
.dv-card{opacity:0;transform:translate(-50%, 8px) scale(.99);transition:opacity .18s ease-out,transform .18s ease-out;}
.dv.is-open .dv-react{opacity:1;}
.dv.is-open .dv-card{opacity:1;transform:translate(-50%, 0) scale(1);}
.dv-caret{display:inline-block;animation:dv-blink 1s step-end infinite;}
/* press feedback on the trigger, synced to the press #1 cursor dip; press #2 lands on the scrim (no target highlight, the modal dismisses) */
#dv-create{animation:dv-btn1 11s ease-out infinite;}
@keyframes dv-move{0%{left:30%;top:60%}22%{left:47.5%;top:4.6%}32%{left:47.5%;top:4.6%}58%{left:8%;top:85%}68%{left:8%;top:85%}88%{left:30%;top:60%}100%{left:30%;top:60%}}
@keyframes dv-press{0%,26%{transform:scale(1)}28%{transform:scale(.82)}31%,62%{transform:scale(1)}64%{transform:scale(.82)}67%,100%{transform:scale(1)}}
@keyframes dv-rip{0%,26%{transform:scale(0);opacity:0}29%{transform:scale(.25);opacity:.7}41%{transform:scale(1.9);opacity:0}60%{transform:scale(0);opacity:0}63%{transform:scale(.25);opacity:.7}75%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
@keyframes dv-react{0%,29%{opacity:0}33%{opacity:1}60%{opacity:1}66%{opacity:0}100%{opacity:0}}
@keyframes dv-card{0%,29%{transform:scale(.94)}33%{transform:scale(1)}60%{transform:scale(1)}66%{transform:scale(.94)}100%{transform:scale(.94)}}
@keyframes dv-btn1{0%,27%{background-color:#0284c7;box-shadow:0 0 0 0 rgba(186,230,253,0)}28%{background-color:#0369a1;box-shadow:0 0 0 2px rgba(186,230,253,.9)}31%,100%{background-color:#0284c7;box-shadow:0 0 0 0 rgba(186,230,253,0)}}
@keyframes dv-blink{0%,49%{opacity:1}50%,100%{opacity:0}}
@media (prefers-reduced-motion: reduce){.dv-cursor{display:none}.dv-react{display:none}}
.dv-dither{background-color:rgb(var(--c) / .84);background-image:repeating-conic-gradient(rgb(var(--c) / .3) 0 25%, transparent 0 50%);background-size:2px 2px;}
.dv.is-static .dv-react{display:none;}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
document.querySelectorAll<HTMLElement>('[data-demo="dv"]').forEach((root) => {
playCursor(root, {
loop: 11000,
steps: [
{ at: 0, x: 30, y: 60 },
{ at: 2400, el: '#dv-create', ox: 0.55, oy: 0.55, hover: '#dv-create' },
{ at: 2540, press: true, target: '#dv-create', add: ['is-open'] },
{ at: 5800, x: 8, y: 85 },
{ at: 5940, press: true, remove: ['is-open'] },
{ at: 8600, x: 30, y: 60 },
],
});
});
</script>
+360 -62
View File
@@ -1,14 +1,44 @@
---
import Icon from './Icon.astro';
import AppShellMock from './AppShellMock.astro';
import GrainBackdrop from './GrainBackdrop.astro';
import type { GrainTone } from '../lib/grain';
/**
* One feature showcase section: marketing copy on one side, a pixel-faithful
* product window (mock) floating on the other. Each mock is authored at a fixed
* DESIGN width (1080px) and uniformly scaled to fit its column by a tiny
* ResizeObserver, so it looks identical on every device (just smaller) with no
* reflow, no overlap, and the demo cursor always lands on the right control.
* One feature showcase section: copy on one side, the product on the other.
*
* The visual is a rounded grain-gradient frame with a whole browser window
* inside it, chrome and all, authored at 1280x944. The window is scaled UP
* past the frame and hangs from one of its own real corners (`anchor`), so
* the corner on screen is a real window corner and the two far edges run
* off the frame, clipped by the frame's rounded corners. It is never panned
* into: a visible chrome is a whole chrome, and a section that wants the
* bottom of a screen anchors the window's bottom corner.
*
* `zoom` is how many of the window's 1280 design px map across the frame:
* smaller is closer. The window always at least covers the frame, so a short
* window never leaves grain showing past a cut edge.
*
* `slides` turns the window into a story-style carousel: the default slot is
* slide one and `slide2`, `slide3`... are the rest, each its own whole
* window. Segmented bars at the bottom fill one at a time and the next screen
* slides in when a bar completes; hovering holds it, swiping or the arrows
* move it by hand.
*
* The frame takes its height from the copy beside it (the grid row
* stretches). Below lg the shell and chrome drop away and the screen mock
* alone scales to fit from its top-left.
*/
interface Callout { label: string; text: string }
type Anchor = 'tl' | 'tr' | 'bl' | 'br';
interface Slide {
url: string;
/** Sidebar row + breadcrumb leaf; omit to reuse the section's. */
nav?: string;
crumb?: string;
/** false renders the slot under the chrome alone, no app shell. */
shell?: boolean;
}
interface Props {
eyebrow: string;
title: string;
@@ -20,14 +50,45 @@ interface Props {
url: string;
cursor?: 'a' | 'b' | 'c' | 'd';
id?: string;
/** Sidebar row to highlight + breadcrumb leaf inside the app shell. */
nav: string;
/** Extra breadcrumb segment after the section, e.g. "Q1 sales". */
crumb?: string;
/** Which real corner of the window sits in the frame. */
anchor?: Anchor;
/** Window design px shown across the frame width at lg+. */
zoom?: number;
/** Extra slides after the default one, in order; content in slide2, slide3... */
slides?: Slide[];
/**
* The whole window, as large as the row allows: the visual takes 8 columns
* and bleeds to the viewport edge, and the frame's height follows the
* window's aspect instead of the copy beside it. For screens whose demo is
* a window-centred dialog or a full-height drawer.
*/
wide?: boolean;
}
const {
eyebrow, title, description, callouts = [], cta,
side = 'right', bg = 'sky', url, id,
side = 'right', bg = 'sky', url, id, nav, crumb,
anchor = 'tl', zoom = 1000, slides = [], wide = false,
} = Astro.props;
const allSlides: Slide[] = [{ url, nav, crumb, shell: true }, ...slides];
// Named slots rendered up front, so the template can loop over them.
const slideHtml: string[] = [await Astro.slots.render('default')];
for (let i = 0; i < slides.length; i++) slideHtml.push(await Astro.slots.render(`slide${i + 2}`));
const isCarousel = allSlides.length > 1;
const lightText = bg !== 'light';
// The frame has to read AGAINST its band. Dark bands take the light tone, the
// bright sky band takes the deep one, and the whiteish band keeps the mid blue.
const grainToneByBg: Record<string, GrainTone> = {
sky: 'deep', light: 'mid', navy: 'light', wallpaper: 'light',
};
const grainTone = grainToneByBg[bg];
const bgStyle: Record<string, string> = {
sky: 'background: radial-gradient(ellipse 130% 120% at 50% -10%, #38bdf8 0%, #0ea5e9 34%, #0284c7 68%, #0369a1 100%);',
light: 'background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);',
@@ -35,11 +96,49 @@ const bgStyle: Record<string, string> = {
wallpaper: 'background: radial-gradient(78% 95% at 15% 6%, #38bdf8 0%, #1d4ed8 42%, rgba(29,78,216,0) 72%), linear-gradient(120deg, #1e40af 0%, #1d4ed8 48%, #172554 100%);',
};
const ringClass = bg === 'light' ? 'ring-slate-200/80' : 'ring-white/60';
// One radius for every frame on the page, and one for every window corner.
const RADIUS = 28;
const frameShadow =
bg === 'navy'
? 'box-shadow: 0 50px 100px -30px rgba(2,132,199,0.55), 0 8px 24px -12px rgba(0,0,0,0.4);'
: 'box-shadow: 0 44px 90px -32px rgba(2,132,199,0.45);';
? '0 54px 110px -34px rgba(2,132,199,0.6), 0 10px 28px -14px rgba(0,0,0,0.5)'
: bg === 'light'
? '0 40px 80px -34px rgba(15,23,42,0.45)'
: '0 50px 100px -34px rgba(2,10,32,0.6)';
// Inline, so no stylesheet (ours, Tailwind's, or the shader library's own
// runtime layer) can take the corners back.
const shellStyle = `border-radius:${RADIUS}px; box-shadow:${frameShadow};`;
const frameStyle = `border-radius:${RADIUS}px; overflow:hidden; clip-path: inset(0 round ${RADIUS}px);`;
// Where the window's box sits in the frame: inset on the two sides that meet
// at the anchored corner, flush on the two it bleeds off. Below lg every
// window hangs top-left.
const fitPos: Record<Anchor, string> = {
tl: 'left-4 top-4 right-0 bottom-0 lg:left-7 lg:top-7',
tr: 'left-4 top-4 right-0 bottom-0 lg:left-0 lg:right-7 lg:top-7',
bl: 'left-4 top-4 right-0 bottom-0 lg:left-7 lg:top-0 lg:bottom-7',
br: 'left-4 top-4 right-0 bottom-0 lg:left-0 lg:top-0 lg:right-7 lg:bottom-7',
};
// Only the real window corner is rounded; the edges that meet the frame stay
// straight, the way a cropped screenshot does.
const fitCorner: Record<Anchor, string> = {
tl: 'rounded-tl-[12px]',
tr: 'rounded-tl-[12px] lg:rounded-tl-none lg:rounded-tr-[12px]',
bl: 'rounded-tl-[12px] lg:rounded-tl-none lg:rounded-bl-[12px]',
br: 'rounded-tl-[12px] lg:rounded-tl-none lg:rounded-br-[12px]',
};
const fitShadow: Record<Anchor, string> = {
tl: 'shadow-[-8px_-8px_28px_-10px_rgba(2,10,32,0.55)]',
tr: 'shadow-[-8px_-8px_28px_-10px_rgba(2,10,32,0.55)] lg:shadow-[8px_-8px_28px_-10px_rgba(2,10,32,0.55)]',
bl: 'shadow-[-8px_-8px_28px_-10px_rgba(2,10,32,0.55)] lg:shadow-[-8px_8px_28px_-10px_rgba(2,10,32,0.55)]',
br: 'shadow-[-8px_-8px_28px_-10px_rgba(2,10,32,0.55)] lg:shadow-[8px_8px_28px_-10px_rgba(2,10,32,0.55)]',
};
// The screen pins to the box corner the anchor names and scales away from it.
const screenPos: Record<Anchor, string> = {
tl: 'top-0 left-0 origin-top-left',
tr: 'top-0 left-0 origin-top-left lg:left-auto lg:right-0 lg:origin-top-right',
bl: 'top-0 left-0 origin-top-left lg:top-auto lg:bottom-0 lg:origin-bottom-left',
br: 'top-0 left-0 origin-top-left lg:top-auto lg:bottom-0 lg:left-auto lg:right-0 lg:origin-bottom-right',
};
const eyebrowColor = lightText ? 'text-white/60' : 'text-sky-600';
const titleColor = lightText ? 'text-white' : 'text-slate-900';
@@ -47,12 +146,16 @@ const bodyColor = lightText ? 'text-white/75' : 'text-slate-600';
const calloutLabel = lightText ? 'text-white' : 'text-slate-900';
const calloutText = lightText ? 'text-white/65' : 'text-slate-500';
const slash = url.indexOf('/');
const urlDomain = slash === -1 ? url : url.slice(0, slash);
const urlPath = slash === -1 ? '' : url.slice(slash);
function splitUrl(u: string) {
const i = u.indexOf('/');
return i === -1 ? { domain: u, path: '' } : { domain: u.slice(0, i), path: u.slice(i) };
}
const copyOrder = side === 'left' ? 'lg:order-2' : 'lg:order-1';
const mockOrder = side === 'left' ? 'lg:order-1' : 'lg:order-2';
const copyCol = wide ? 'lg:col-span-4' : 'lg:col-span-5';
// Wide visuals run out through the container's 40px padding on their outer side.
const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-10'}` : 'lg:col-span-7';
---
<section id={id} class="relative isolate overflow-hidden" style={bgStyle[bg]}>
{bg === 'sky' && (
@@ -69,21 +172,21 @@ const mockOrder = side === 'left' ? 'lg:order-1' : 'lg:order-2';
)}
<div class="container-wide relative py-16 md:py-24">
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-center">
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 lg:items-stretch">
<!-- COPY -->
<div class={`lg:col-span-5 ${copyOrder}`}>
<div class={`${copyCol} flex flex-col justify-center ${copyOrder}`}>
<div class={`text-[11px] uppercase tracking-[0.18em] font-mono mb-3 ${eyebrowColor}`}>{eyebrow}</div>
<h2 class={`text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.07] ${titleColor}`}>{title}</h2>
<p class={`mt-5 text-[15.5px] leading-relaxed max-w-xl ${bodyColor}`}>{description}</p>
<p class={`mt-4 text-[15.5px] leading-relaxed max-w-md ${bodyColor}`}>{description}</p>
{callouts.length > 0 && (
<div class="mt-7 space-y-4 max-w-md">
<div class="mt-6 space-y-2.5 max-w-md">
{callouts.map((c) => (
<div class="flex gap-3">
<span class={`mt-[7px] h-1.5 w-1.5 rounded-full shrink-0 ${lightText ? 'bg-sky-300' : 'bg-sky-500'}`}></span>
<div class="flex items-baseline gap-3 text-[13px] leading-relaxed">
<span class={`relative top-[-2px] h-1.5 w-1.5 rounded-full shrink-0 ${lightText ? 'bg-sky-300' : 'bg-sky-500'}`}></span>
<div>
<div class={`text-[13px] font-semibold ${calloutLabel}`}>{c.label}</div>
<div class={`text-[13px] leading-relaxed mt-0.5 ${calloutText}`}>{c.text}</div>
<span class={`font-semibold ${calloutLabel}`}>{c.label}.</span>
<span class={`ml-1 ${calloutText}`}>{c.text}</span>
</div>
</div>
))}
@@ -92,46 +195,94 @@ const mockOrder = side === 'left' ? 'lg:order-1' : 'lg:order-2';
{cta && (
<a href={cta.href}
class={`mt-8 inline-flex items-center gap-1.5 text-[13.5px] font-medium pb-px border-b ${lightText ? 'text-white border-white/30 hover:border-white' : 'text-slate-900 border-slate-900/30 hover:border-slate-900'}`}>
class={`mt-8 self-start inline-flex items-center gap-1.5 text-[13.5px] font-medium underline underline-offset-[5px] decoration-1 ${lightText ? 'text-white decoration-white/40 hover:decoration-white' : 'text-slate-900 decoration-slate-900/30 hover:decoration-slate-900'}`}>
{cta.text}
<Icon name="arrowUpRight" size={13} />
</a>
)}
</div>
<!-- PRODUCT WINDOW -->
<div class={`lg:col-span-7 ${mockOrder} min-w-0`}>
<div class={`feature-window showcase-frame relative rounded-[14px] overflow-hidden bg-white ring-1 ${ringClass}`} style={frameShadow}>
<!-- browser chrome -->
<div class="flex items-center gap-2.5 px-3 h-11 border-b border-slate-200/80 bg-gradient-to-b from-[#f7f8fa] to-[#eceef1]">
<div class="flex items-center gap-2 shrink-0">
<span class="w-3 h-3 rounded-full bg-[#ff5f57]"></span>
<span class="w-3 h-3 rounded-full bg-[#febc2e]"></span>
<span class="w-3 h-3 rounded-full bg-[#28c840]"></span>
</div>
<div class="hidden sm:flex items-center gap-1 shrink-0 text-slate-400">
<span class="grid place-items-center w-6 h-6 rounded-md hover:bg-slate-200/60">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</span>
<span class="grid place-items-center w-6 h-6 rounded-md text-slate-300">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
</div>
<div class="flex-1 min-w-0 flex items-center gap-2 h-7 px-3 rounded-lg bg-white ring-1 ring-slate-200/90 text-[12px] shadow-[inset_0_1px_2px_rgba(15,23,42,0.04)]">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" class="text-emerald-500 shrink-0"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span class="truncate font-medium text-slate-700">{urlDomain}<span class="text-slate-400 font-normal">{urlPath}</span></span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-auto shrink-0 text-slate-300"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>
</div>
<div class="hidden sm:flex items-center gap-2.5 shrink-0 text-slate-300">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</div>
</div>
<!-- PRODUCT VISUAL: grain frame, window hung from one corner -->
<div class={`${mockCol} ${mockOrder} min-w-0 flex`}>
<div class="feat-frame-shell w-full lg:min-h-[480px] flex" style={shellStyle} data-whole={wide ? '' : undefined}>
<!-- The clip is its own box with nothing else on it. -->
<div class="round-clip w-full flex">
<div class="feat-frame feature-window group relative isolate w-full" style={frameStyle} data-slides={allSlides.length}>
<GrainBackdrop tone={grainTone} />
<!-- scale-to-fit screen: authored at 1080px, scaled to the column on every device -->
<div class="feat-fit relative w-full overflow-hidden bg-white min-h-[240px]">
<div class="feat-screen absolute top-0 left-0 origin-top-left" style="width:900px;">
<slot />
<div class={`feat-fit absolute z-10 overflow-hidden ring-1 ring-white/25 ${fitPos[anchor]} ${fitCorner[anchor]} ${fitShadow[anchor]}`}>
<div class="feat-track absolute inset-0">
{allSlides.map((s, i) => {
const { domain, path } = splitUrl(s.url);
return (
<!-- Explicit width: inset-0 plus a left offset would collapse
every slide after the first to zero width. Each slide clips
its own window, so a right-anchored screen that overflows
to the left cannot bleed over the slide before it. -->
<div class="feat-slide absolute top-0 bottom-0 w-full overflow-hidden" style={`left:${i * 100}%`}>
<div class={`feat-screen absolute w-[900px] lg:w-[1280px] ${screenPos[anchor]}`} data-zoom={zoom} aria-hidden="true">
<!-- Browser chrome is part of the screenshot, so it
scales and crops with everything else. The address
is centred, Safari-style, so a right-anchored crop
still shows it whole. -->
<div class="hidden lg:flex items-center gap-2.5 px-3 h-11 border-b border-slate-300/70 bg-gradient-to-b from-[#f7f8fa] to-[#e9ebef]">
<div class="flex items-center gap-2 shrink-0">
<span class="w-3 h-3 rounded-full bg-[#ff5f57]"></span>
<span class="w-3 h-3 rounded-full bg-[#febc2e]"></span>
<span class="w-3 h-3 rounded-full bg-[#28c840]"></span>
</div>
<div class="flex items-center gap-1 shrink-0 text-slate-400">
<span class="grid place-items-center w-6 h-6 rounded-md">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</span>
<span class="grid place-items-center w-6 h-6 rounded-md text-slate-300">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
</div>
<div class="relative flex-1 min-w-0 flex items-center justify-center gap-2 h-7 px-3 rounded-lg bg-white ring-1 ring-slate-200/90 text-[12px] shadow-[inset_0_1px_2px_rgba(15,23,42,0.04)]">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" class="text-emerald-500 shrink-0"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span class="truncate font-medium text-slate-700">{domain}<span class="text-slate-400 font-normal">{path}</span></span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-300"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>
</div>
<div class="flex items-center gap-2.5 shrink-0 text-slate-300">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</div>
</div>
{s.shell === false ? (
<Fragment set:html={slideHtml[i]} />
) : (
<AppShellMock crumb={s.nav ?? nav} sub={s.crumb}>
<Fragment set:html={slideHtml[i]} />
</AppShellMock>
)}
</div>
</div>
);
})}
</div>
</div>
{isCarousel && (
<>
<button type="button" class="feat-prev absolute z-30 left-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-white/90 text-slate-800 shadow-lg backdrop-blur grid place-items-center transition-all duration-300 lg:opacity-0 lg:-translate-x-1 lg:group-hover:opacity-100 lg:group-hover:translate-x-0 cursor-pointer" aria-label="Previous screen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button type="button" class="feat-next absolute z-30 right-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-white/90 text-slate-800 shadow-lg backdrop-blur grid place-items-center transition-all duration-300 lg:opacity-0 lg:translate-x-1 lg:group-hover:opacity-100 lg:group-hover:translate-x-0 cursor-pointer" aria-label="Next screen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<!-- Story bars: the active one fills over the hold time, then the
next screen comes in. -->
<div class="feat-bars absolute z-30 bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 h-7 px-3 rounded-full bg-slate-950/45 backdrop-blur-md ring-1 ring-white/10">
{allSlides.map((_, i) => (
<button type="button" class="feat-bar relative h-[3px] w-8 rounded-full bg-white/30 overflow-hidden cursor-pointer" data-i={i} aria-label={`Screen ${i + 1}`}>
<span class="feat-fill absolute inset-0 rounded-full bg-white origin-left"></span>
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
</div>
@@ -139,27 +290,174 @@ const mockOrder = side === 'left' ? 'lg:order-1' : 'lg:order-2';
</div>
</section>
<style is:global>
.feat-track {
transition: transform 1100ms cubic-bezier(0.25, 0.9, 0.2, 1);
will-change: transform;
}
.feat-fill { transform: scaleX(0); }
.feat-bar.is-done .feat-fill { transform: scaleX(1); }
.feat-bar.is-active .feat-fill {
animation: feat-fill var(--feat-dur, 6000ms) linear forwards;
}
.feat-frame.is-held .feat-fill,
.feat-frame.is-idle .feat-fill {
animation-play-state: paused;
}
@keyframes feat-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.feat-track { transition: none; }
.feat-bar.is-active .feat-fill { animation: none; transform: scaleX(1); }
}
</style>
<script>
// Uniformly scale every fixed-width mock screen to fit its column. Runs on
// load + resize so the showcase is correct on every device width.
const DESIGN = 900;
// Below lg the chrome and shell are display:none and the mock alone (900
// design px) scales to fit from its top-left. At lg+ the window is a
// 1280x944 browser pinned at the anchored corner: `zoom` of its width
// maps across the box, and if that still leaves the box taller than the
// window, the scale grows until the window covers it. Never panned into.
const DESIGN_SM = 900;
function fit(screen: HTMLElement) {
const box = screen.parentElement as HTMLElement | null;
if (!box) return;
const s = box.clientWidth / DESIGN;
const frame = screen.closest<HTMLElement>('.feat-frame');
const shell = frame?.parentElement as HTMLElement | null;
if (!box || !frame || !shell) return;
if (window.innerWidth >= 1024) {
const zoom = Number(screen.dataset.zoom) || 1000;
const byWidth = box.clientWidth / zoom;
if (shell.hasAttribute('data-whole')) {
// The whole window at the scale the width allows; the frame's height
// follows (the box is inset 28px on one edge), so nothing is cropped.
screen.style.transform = `scale(${byWidth})`;
const h = `${Math.round(screen.offsetHeight * byWidth + 28)}px`;
if (frame.style.height !== h) { frame.style.height = h; shell.style.height = h; }
return;
}
// Height comes from the grid row (the copy beside it), not from here.
frame.style.height = '';
shell.style.height = '';
const byHeight = box.clientHeight / Math.max(1, screen.offsetHeight);
screen.style.transform = `scale(${Math.max(byWidth, byHeight)})`;
return;
}
const s = box.clientWidth / DESIGN_SM;
screen.style.transform = `scale(${s})`;
box.style.height = `${screen.offsetHeight * s}px`;
// 16px is the top inset the box is pinned at below lg; the bottom is flush.
const h = `${screen.offsetHeight * s + 16}px`;
frame.style.height = h;
shell.style.height = h;
}
function fitAll() {
document.querySelectorAll<HTMLElement>('.feat-screen').forEach((s) => fit(s));
}
// The story carousel. The active bar's fill animation IS the timer: when
// it ends the next screen slides in, and pausing it (hover, off screen)
// pauses the advance with it, so the bar never lies about when the switch
// happens.
const HOLD_MS = 6500;
function carousel(frame: HTMLElement) {
const count = Number(frame.dataset.slides) || 1;
if (count < 2) return;
const track = frame.querySelector<HTMLElement>('.feat-track');
const bars = [...frame.querySelectorAll<HTMLElement>('.feat-bar')];
if (!track) return;
frame.style.setProperty('--feat-dur', `${HOLD_MS}ms`);
frame.classList.add('is-idle');
let i = 0;
// Reduced motion draws no fill, so no animationend: a timer advances instead.
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let timer: ReturnType<typeof setTimeout> | undefined;
let left = HOLD_MS;
let since = 0;
const paused = () => frame.classList.contains('is-held') || frame.classList.contains('is-idle');
// Runs only while the frame is neither held nor off screen; pausing keeps the remaining time.
const run = () => {
clearTimeout(timer);
if (paused()) return;
since = Date.now();
timer = setTimeout(() => go(i + 1), left);
};
const hold = () => {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
left = Math.max(0, left - (Date.now() - since));
};
const arm = () => { left = HOLD_MS; run(); };
// Held and idle flip on hover and visibility; keep the timer in step with them.
const sync = () => { if (!reduced) return; paused() ? hold() : run(); };
const paint = () => {
bars.forEach((b, k) => {
b.classList.toggle('is-done', k < i);
b.classList.remove('is-active');
});
const active = bars[i];
if (!active) return;
// Reflow between remove and add restarts the fill from zero.
void active.offsetWidth;
active.classList.add('is-active');
if (reduced) arm();
};
const go = (n: number) => {
i = ((n % count) + count) % count;
track.style.transform = `translate3d(${-i * 100}%, 0, 0)`;
paint();
};
bars.forEach((b) => {
b.querySelector('.feat-fill')?.addEventListener('animationend', () => {
if (b.classList.contains('is-active')) go(i + 1);
});
b.addEventListener('click', () => go(Number(b.dataset.i) || 0));
});
frame.querySelector('.feat-prev')?.addEventListener('click', () => go(i - 1));
frame.querySelector('.feat-next')?.addEventListener('click', () => go(i + 1));
frame.addEventListener('pointerenter', () => { frame.classList.add('is-held'); sync(); });
frame.addEventListener('pointerleave', () => { frame.classList.remove('is-held'); sync(); });
let startX: number | null = null;
frame.addEventListener('pointerdown', (e) => { startX = e.clientX; });
frame.addEventListener('pointerup', (e) => {
if (startX === null) return;
const dx = e.clientX - startX;
startX = null;
if (Math.abs(dx) > 40) go(dx < 0 ? i + 1 : i - 1);
});
if (typeof IntersectionObserver !== 'undefined') {
new IntersectionObserver((entries) => {
frame.classList.toggle('is-idle', !entries.some((e) => e.isIntersecting));
sync();
}, { threshold: 0.35 }).observe(frame);
} else {
frame.classList.remove('is-idle');
sync();
}
paint();
}
function init() {
document.querySelectorAll<HTMLElement>('.feat-screen').forEach((screen) => {
const run = () => fit(screen);
run();
requestAnimationFrame(run);
if (screen.parentElement) new ResizeObserver(run).observe(screen.parentElement);
const shell = screen.closest<HTMLElement>('.feat-frame-shell');
if (shell) new ResizeObserver(run).observe(shell);
});
window.addEventListener('load', () =>
document.querySelectorAll<HTMLElement>('.feat-screen').forEach((s) => fit(s)),
);
window.addEventListener('load', fitAll);
window.addEventListener('resize', fitAll);
document.querySelectorAll<HTMLElement>('.feat-frame').forEach(carousel);
// Screens are pictures: nothing inside them is a real tab stop.
document.querySelectorAll<HTMLElement>('.feat-screen button, .feat-screen input, .feat-screen a, .feat-screen [tabindex]').forEach((el) => { el.tabIndex = -1; });
}
init();
</script>
+6
View File
@@ -1,5 +1,6 @@
---
import Wordmark from './Wordmark.astro';
import Icon from './Icon.astro';
const groups: { title: string; links: { label: string; href: string }[] }[] = [
{
@@ -106,6 +107,11 @@ const year = new Date().getFullYear();
github.com/warmbly/warmbly
</a>
<span class="text-xs text-muted-foreground">·</span>
<a href="https://dc.warmbly.com" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
<Icon name="discord" size={13} />
Discord
</a>
<span class="text-xs text-muted-foreground">·</span>
<a href="/blog/rss.xml" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
<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="M4 11a9 9 0 0 1 9 9" /><path d="M4 4a16 16 0 0 1 16 16" /><circle cx="5" cy="19" r="1" />
+142
View File
@@ -0,0 +1,142 @@
---
/**
* The form builder (/app/forms/:id, Build tab) as FormBuilder.tsx draws it:
* h-14 header with the name field and status, the six-tab bar, then the
* palette (w-52), the canvas with the live card, and the field settings
* panel (w-80) for the field that is selected on the canvas.
*/
import FormCardMock from './FormCardMock.astro';
const paletteFields = [
{ label: 'Text', icon: '<path d="M12 4v16"/><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"/><path d="M9 20h6"/>' },
{ label: 'Email', icon: '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>' },
{ label: 'Phone', icon: '<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384z"/>' },
{ label: 'Long text', icon: '<line x1="21" x2="3" y1="6" y2="6"/><line x1="15" x2="3" y1="12" y2="12"/><line x1="17" x2="3" y1="18" y2="18"/>' },
{ label: 'Number', icon: '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>' },
{ label: 'Dropdown', icon: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m8 10 4 4 4-4"/>' },
{ label: 'Radio buttons', icon: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>' },
{ label: 'Checkbox group', icon: '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>' },
{ label: 'Single checkbox', icon: '<path d="M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11.344"/><path d="m9 11 3 3L22 4"/>' },
{ label: 'Date', icon: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>' },
{ label: 'Hidden field', icon: '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>' },
];
const paletteLayout = [
{ label: 'Heading', icon: '<path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/>' },
{ label: 'Text block', icon: '<path d="M17 6.1H3"/><path d="M21 12.1H3"/><path d="M15.1 18H3"/>' },
{ label: 'Divider', icon: '<path d="M5 12h14"/>' },
{ label: 'Page break', icon: '<path d="M3 12h18"/><path d="m8 8 4-4 4 4"/><path d="m16 16-4 4-4-4"/>' },
];
const tabs = [
{ label: 'Build', on: true, icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
{ label: 'Design', icon: '<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>' },
{ label: 'Settings', icon: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>' },
{ label: 'Share', icon: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>' },
{ label: 'Analytics', icon: '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>' },
{ label: 'Submissions', badge: '1,041', icon: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>' },
];
// TextInput and Label from ui/field.tsx: a bare input keeps the browser's default width.
const field = 'h-7 w-[178px] px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center min-w-0';
const label = 'text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5';
---
<div class="h-full min-h-[844px] flex flex-col bg-white text-slate-900">
<!-- Header -->
<div class="shrink-0 px-4 h-14 flex items-center gap-3 border-b border-slate-200 bg-white">
<span class="inline-flex items-center gap-1 text-[12px] text-slate-500 shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
Forms
</span>
<div class="w-px h-5 bg-slate-200 shrink-0"></div>
<div class="h-7 w-[178px] max-w-[260px] rounded-md border border-slate-200 bg-white px-2.5 flex items-center text-[12.5px] font-medium text-slate-900 min-w-0">Book a demo</div>
<span class="inline-flex items-center h-4 px-1.5 rounded text-[10px] font-medium shrink-0 bg-emerald-50 text-emerald-700">published</span>
<div class="flex-1"></div>
<span class="hidden sm:inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md text-[12px] text-slate-600 shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
View live
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
</span>
<span class="h-7 px-2.5 rounded-md border border-slate-200 text-[12px] text-slate-600 inline-flex items-center shrink-0">Unpublish</span>
</div>
<!-- Tab bar -->
<div class="shrink-0 px-3 flex items-center gap-1 border-b border-slate-200 bg-white overflow-x-auto">
{tabs.map((t) => (
<span class={`relative h-10 px-2.5 inline-flex items-center gap-1.5 text-[12.5px] shrink-0 ${t.on ? 'text-slate-900 font-medium' : 'text-slate-500'}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={t.icon}></svg>
{t.label}
{t.badge && <span class="inline-flex items-center h-4 px-1 rounded bg-sky-50 text-sky-700 text-[10px] font-medium tabular-nums">{t.badge}</span>}
{t.on && <span class="absolute left-1.5 right-1.5 -bottom-px h-0.5 rounded-full bg-sky-600"></span>}
</span>
))}
</div>
<!-- Body -->
<div class="flex-1 min-h-0 flex">
<aside class="flex w-52 shrink-0 flex-col gap-3 border-r border-slate-200 bg-white overflow-y-auto p-3">
{[{ group: 'Fields', items: paletteFields }, { group: 'Layout', items: paletteLayout }].map((g) => (
<div>
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium px-2 mb-1">{g.group}</div>
<div class="flex flex-col">
{g.items.map((it) => (
<span class="h-8 px-2 w-full inline-flex items-center gap-2 rounded-md text-[12px] text-slate-700">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400" set:html={it.icon}></svg>
{it.label}
</span>
))}
</div>
</div>
))}
<p class="text-[10.5px] text-slate-400 px-2">Click to add, or drag onto the canvas.</p>
</aside>
<!-- Canvas: the live .wf renderer, split layout, on its own page background -->
<div class="flex-1 min-w-0 overflow-hidden relative flex [&>*]:flex-1" style="background:#f8fafc;">
<FormCardMock selected="em" />
</div>
<!-- Field settings for the selected field -->
<aside class="w-80 shrink-0 border-l border-slate-200 bg-white overflow-y-auto">
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-sky-600"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Email</span>
</div>
<div>
<div class={label}>Label</div>
<div class={field}>Work email</div>
</div>
<div>
<div class={label}>Placeholder</div>
<div class={field}>you@company.com</div>
</div>
<div>
<div class={label}>Help text</div>
<div class={field}><span class="text-slate-400">Shown under the field</span></div>
</div>
<div class="min-w-0 flex flex-col gap-2.5 sm:flex-row sm:items-start sm:justify-between sm:gap-5">
<div>
<div class="text-[12.5px] text-slate-900 font-medium">Required</div>
<p class="text-[11px] text-slate-500 mt-0.5 leading-relaxed">Visitors cannot submit without answering.</p>
</div>
<span class="relative inline-flex h-[18px] w-8 shrink-0 items-center rounded-full bg-sky-600 transition-colors"><span class="inline-block size-3.5 translate-x-[15px] rounded-full bg-white shadow-sm"></span></span>
</div>
<div>
<div class={label}>Width</div>
<div class="inline-flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5">
<span class="h-6 px-2.5 rounded text-[11.5px] font-medium transition-colors bg-sky-600 text-white inline-flex items-center">Full</span>
<span class="h-6 px-2.5 rounded text-[11.5px] font-medium transition-colors text-slate-500 inline-flex items-center">Half</span>
</div>
</div>
<div>
<div class={label}>Saves to contact</div>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] text-slate-700 flex w-full items-center gap-1.5 transition-colors opacity-60">
<span class="truncate flex-1 text-left">Email</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="m6 9 6 6 6-6"/></svg>
</div>
<p class="text-[11px] text-slate-500 mt-1">The email field always fills the contact's email.</p>
</div>
</div>
</aside>
</div>
</div>
+117
View File
@@ -0,0 +1,117 @@
---
/**
* The "Book a demo" form as the hosted renderer draws a split layout
* (forms/src/FormPage.tsx + FormRenderer.tsx + form-theme.css, design
* defaults from designCore.ts): the cover column with the logo, title and
* subtitle over the cover image and its accent wash, then `.wf-main` with
* the field grid, the submit row and the "Powered by Warmbly" line.
*
* The same component sits on the builder canvas, where `selected` marks a
* field the way SortableField does: the sky ring and the drag / duplicate /
* delete toolbar.
*/
interface Props { selected?: string }
const { selected } = Astro.props;
type Field =
| { id: string; kind: 'heading'; text: string }
| { id: string; kind: 'paragraph'; text: string }
| { id: string; kind: 'input'; label: string; placeholder: string; half?: boolean; required?: boolean; value?: string; focus?: boolean }
| { id: string; kind: 'select'; label: string; value: string; half?: boolean }
| { id: string; kind: 'textarea'; label: string; placeholder: string; help?: string }
| { id: string; kind: 'checkbox'; text: string };
const fields: Field[] = [
{ id: 'h', kind: 'heading', text: 'Tell us about your outreach' },
{ id: 'p', kind: 'paragraph', text: 'We reply within one business day.' },
{ id: 'fn', kind: 'input', label: 'First name', placeholder: 'Maya', half: true, required: true, value: 'Maya' },
{ id: 'ln', kind: 'input', label: 'Last name', placeholder: 'Okonkwo', half: true, required: true, value: 'Okonkwo' },
{ id: 'em', kind: 'input', label: 'Work email', placeholder: 'you@company.com', required: true, value: 'maya@northwind.dev', focus: true },
{ id: 'co', kind: 'input', label: 'Company', placeholder: 'Northwind', half: true, value: 'Northwind' },
{ id: 'sz', kind: 'select', label: 'Team size', value: '11 to 50', half: true },
{ id: 'msg', kind: 'textarea', label: 'What are you hoping to solve?', placeholder: 'Deliverability, volume, replies, CRM handoff...', help: 'Optional, but it makes the call better.' },
{ id: 'ok', kind: 'checkbox', text: 'Send me the occasional deliverability note. Unsubscribe any time.' },
];
// FONT_CATALOG.inter.stack and the resolveDesign defaults.
const font = "font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;";
const ACCENT = '#0284c7';
// The owner's cover image (a photo in the app) under the accent wash.
const coverImage = 'linear-gradient(160deg, #0b2a4a 0%, #0f3d6e 55%, #0c2f57 100%)';
const inputStyle = `width:100%; font-size:14px; color:#0f172a; background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; ${font}`;
const focusStyle = `border-color:${ACCENT}; box-shadow:0 0 0 3px color-mix(in srgb, ${ACCENT} 18%, transparent);`;
---
<div class="wfm w-full min-h-full grid" style={`${font} grid-template-columns: minmax(260px, 38%) 1fr; background:#f8fafc;`}>
<!-- .wf-cover: the image, the accent wash, logo / title / subtitle -->
<aside class="relative overflow-hidden" style={`padding:40px 36px; background:${coverImage};`}>
<div class="absolute inset-0 pointer-events-none" style={`background: linear-gradient(150deg, color-mix(in srgb, ${ACCENT} 30%, transparent), transparent 68%);`}></div>
<div class="relative">
<!-- .wf-logo: the owner's logo image, 36px tall -->
<svg viewBox="0 0 160 36" style="display:block; height:36px; max-width:60%; margin:0 0 20px;" aria-label="Northwind">
<rect x="0" y="0" width="36" height="36" rx="9" fill="#ffffff"/>
<text x="18" y="24" text-anchor="middle" font-size="16" font-weight="700" fill="#0f172a" font-family="Inter, system-ui, sans-serif">N</text>
<text x="46" y="24" font-size="17" font-weight="600" fill="#ffffff" letter-spacing="-0.2" font-family="Inter, system-ui, sans-serif">Northwind</text>
</svg>
<p style="margin:16px 0 0; font-size:24px; font-weight:600; line-height:1.2; letter-spacing:-0.02em; color:#fff; text-shadow:0 1px 12px rgba(15,23,42,.35);">See it on your own mailboxes.</p>
<p style="margin:8px 0 0; font-size:14px; line-height:1.55; color:rgba(255,255,255,.86); text-shadow:0 1px 10px rgba(15,23,42,.3); white-space:pre-line;">Thirty minutes with someone who runs outreach at Northwind. Bring a domain and we will show you what a safe ramp looks like.</p>
</div>
</aside>
<!-- .wf-main (split): centred column, 64px vertical padding, 560px measure -->
<main class="flex flex-col justify-center bg-white" style="padding:64px max(48px, calc((100% - 560px) / 2));">
<div class="flex flex-wrap" style="gap:16px;">
{fields.map((f) => {
const half = 'half' in f && f.half;
const isSel = selected === f.id;
return (
<div class={`relative min-w-0 ${isSel ? 'rounded-md ring-2 ring-sky-400 ring-offset-2' : ''}`} style={half ? 'flex: 1 1 calc(50% - 16px);' : 'flex: 1 1 100%;'}>
{isSel && (
<div class="absolute -top-2.5 right-1 z-10 flex items-center gap-0.5 rounded-md border border-slate-200 bg-white shadow-sm px-0.5">
<span class="size-5 inline-flex items-center justify-center text-slate-400"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg></span>
<span class="size-5 inline-flex items-center justify-center text-slate-400"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></span>
<span class="size-5 inline-flex items-center justify-center text-slate-400"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></span>
</div>
)}
{f.kind === 'heading' && <p style="margin:2px 0 0; font-size:22px; font-weight:600; line-height:1.25; letter-spacing:-0.015em; color:#0f172a;">{f.text}</p>}
{f.kind === 'paragraph' && <p style="margin:0; font-size:14px; line-height:1.55; color:#0f172a; opacity:.85;">{f.text}</p>}
{f.kind === 'input' && (
<div>
<label style="display:block; font-size:13px; font-weight:500; color:#334155; margin:0 0 6px;">{f.label}{f.required && <span style="color:#dc2626;"> *</span>}</label>
<div style={`${inputStyle} ${f.focus ? focusStyle : ''}`}>{f.value ? f.value : <span style="color:#94a3b8;">{f.placeholder}</span>}</div>
</div>
)}
{f.kind === 'select' && (
<div>
<label style="display:block; font-size:13px; font-weight:500; color:#334155; margin:0 0 6px;">{f.label}</label>
<div class="flex items-center justify-between" style={inputStyle}>
<span>{f.value}</span>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M1 3l4 4 4-4" fill="none" stroke="#475569" stroke-width="1.5"/></svg>
</div>
</div>
)}
{f.kind === 'textarea' && (
<div>
<label style="display:block; font-size:13px; font-weight:500; color:#334155; margin:0 0 6px;">{f.label}</label>
<div style={`${inputStyle} min-height:104px; resize:vertical;`}><span style="color:#94a3b8;">{f.placeholder}</span></div>
{f.help && <div style="font-size:12px; color:#94a3b8; margin:5px 0 0;">{f.help}</div>}
</div>
)}
{f.kind === 'checkbox' && (
<label class="flex items-center" style="gap:8px; font-size:14px; color:#0f172a; cursor:pointer;">
<span class="shrink-0" style="width:15px; height:15px; border:1px solid #767676; border-radius:3px; background:#fff;"></span>
{f.text}
</label>
)}
</div>
);
})}
<!-- .btnrow -->
<div class="flex" style="flex: 1 1 100%; margin-top:4px; width:100%;">
<span style={`font-weight:600; font-size:14px; color:#fff; background:${ACCENT}; border-radius:10px; padding:10px 20px; display:inline-flex; align-items:center;`}>Book my demo</span>
</div>
</div>
<!-- .brand -->
<div style="text-align:center; margin-top:14px; padding-bottom:8px;"><span style="font-size:11px; color:#94a3b8;">Powered by Warmbly</span></div>
</main>
</div>
+242
View File
@@ -0,0 +1,242 @@
---
/**
* The /app/forms list, as web/src/app/app/forms/page.tsx draws it: topbar,
* five-stat strip, the section bar with status tabs + category filter +
* search, then the leads-style table with a trend sparkline per form.
* Rendered inside AppShellMock by FeatureSection.
*/
import { nextId } from '../lib/uid';
type Status = 'published' | 'draft' | 'archived';
interface Row {
name: string;
status: Status;
cats: { title: string; color: string }[];
trend: number[];
views: number;
starts: number;
subs: number;
identified: number;
created: string;
last: string;
}
const cat = {
inbound: { title: 'Inbound', color: '#0284c7' },
enterprise: { title: 'Enterprise', color: '#7c3aed' },
newsletter: { title: 'Newsletter', color: '#059669' },
webinar: { title: 'Webinar', color: '#d97706' },
partners: { title: 'Partners', color: '#db2777' },
};
// Sorted the way the page opens: newest created first.
const rows: Row[] = [
{ name: 'Pricing questions', status: 'published', cats: [cat.inbound],
trend: [2, 5, 9, 14, 18, 24, 31, 27, 36, 41, 39, 44, 52, 58],
views: 1120, starts: 481, subs: 119, identified: 97, created: '6d ago', last: '3h ago' },
{ name: 'Partner program', status: 'draft', cats: [cat.partners],
trend: [], views: 0, starts: 0, subs: 0, identified: 0, created: '9d ago', last: 'never' },
{ name: 'Webinar: Cold email in 2026', status: 'published', cats: [cat.webinar, cat.inbound],
trend: [4, 12, 19, 33, 28, 41, 56, 72, 64, 83, 91, 74, 68, 77],
views: 1976, starts: 1103, subs: 356, identified: 302, created: '8/11/2026', last: '1h ago' },
{ name: 'Book a demo', status: 'published', cats: [cat.enterprise, cat.inbound],
trend: [38, 42, 35, 51, 47, 63, 58, 71, 66, 74, 69, 82, 88, 94],
views: 4812, starts: 2306, subs: 1041, identified: 388, created: '7/2/2026', last: '4m ago' },
{ name: 'Newsletter signup', status: 'published', cats: [cat.newsletter],
trend: [22, 26, 24, 31, 29, 35, 33, 38, 41, 37, 44, 46, 43, 49],
views: 3904, starts: 1822, subs: 762, identified: 141, created: '6/5/2026', last: '22m ago' },
{ name: 'Q1 campaign feedback', status: 'archived', cats: [cat.newsletter],
trend: [14, 11, 9, 8, 6, 5, 4, 3, 2, 2, 1, 1, 0, 0],
views: 668, starts: 220, subs: 40, identified: 40, created: '5/14/2026', last: '7/9/2026' },
];
const pill: Record<Status, string> = {
draft: 'bg-slate-100 text-slate-600',
published: 'bg-emerald-50 text-emerald-700',
archived: 'bg-amber-50 text-amber-700',
};
const n = (v: number) => v.toLocaleString('en-US');
const conv = (r: Row) => (r.views <= 0 ? '–' : `${Math.min(100, (r.subs / r.views) * 100).toFixed(1)}%`);
const totals = {
forms: rows.length,
live: rows.filter((r) => r.status === 'published').length,
views: rows.reduce((a, r) => a + r.views, 0),
starts: rows.reduce((a, r) => a + r.starts, 0),
subs: rows.reduce((a, r) => a + r.subs, 0),
};
const totalConv = `${Math.min(100, (totals.subs / totals.views) * 100).toFixed(1)}%`;
const counts = {
all: rows.length,
published: totals.live,
draft: rows.filter((r) => r.status === 'draft').length,
archived: rows.filter((r) => r.status === 'archived').length,
};
// Same maths as ui/charts Sparkline at 84x22: 2px pad, straight segments,
// area fill fading to nothing.
const SW = 84, SH = 22, PAD = 2;
function spark(values: number[]) {
const max = Math.max(1, ...values);
const w = SW - PAD * 2, h = SH - PAD * 2;
const step = values.length > 1 ? w / (values.length - 1) : 0;
const pts = values.map((v, i) => [PAD + i * step, PAD + h - (v / max) * h] as const);
const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' ');
const area = `${line} L ${(PAD + (values.length - 1) * step).toFixed(2)} ${PAD + h} L ${PAD} ${PAD + h} Z`;
return { line, area };
}
const sparkId = nextId('fspark');
const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]';
const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowrap';
---
<div class="flex flex-col bg-white text-slate-900">
<!-- PageTopbar -->
<div class="min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Forms</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">Hosted lead-capture forms you can embed anywhere</span>
<div class="ml-auto flex items-center gap-1.5 min-w-0 flex-wrap justify-end md:flex-nowrap">
<button class="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors bg-sky-600 text-white">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
New form
</button>
</div>
</div>
<!-- StatStrip cols=5 -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-5">
{[
{ label: 'Forms', value: String(totals.forms), sub: `${totals.live} live`, accent: false, last: false },
{ label: 'Views', value: n(totals.views), sub: 'all time', accent: false, last: false },
{ label: 'Starts', value: n(totals.starts), sub: 'began filling', accent: false, last: false },
{ label: 'Submissions', value: n(totals.subs), sub: 'all time', accent: true, last: false },
{ label: 'Conversion', value: totalConv, sub: 'submissions / views', accent: false, last: true },
].map((s) => (
<div class={`group px-5 py-4 transition-colors min-w-0 ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
</div>
))}
</div>
<!-- SectionBar: status tabs, category filter, search -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">All forms</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{rows.length}</span>
<div class="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0"><div class="flex flex-wrap items-center gap-2">
<div class="inline-flex items-center gap-0.5 rounded-md bg-slate-100 p-0.5">
{[
{ label: 'All', count: counts.all, on: true },
{ label: 'Published', count: counts.published, on: false },
{ label: 'Drafts', count: counts.draft, on: false },
{ label: 'Archived', count: counts.archived, on: false },
].map((t) => (
<span class={`h-6 px-2 rounded text-[11.5px] font-medium transition-colors inline-flex items-center ${t.on ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'}`}>
{t.label}<span class={`ml-1 tabular-nums ${t.on ? 'text-slate-400' : 'text-slate-400/80'}`}>{t.count}</span>
</span>
))}
</div>
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] text-slate-700 inline-flex items-center gap-1.5 transition-colors">
<span class="truncate flex-1 text-left">All categories</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="h-7 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 min-w-0 w-56">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span class="flex-1 min-w-0 text-[12.5px] text-slate-400">Search forms&hellip;</span>
</div>
</div></div>
</div>
<!-- Table (PageBody) -->
<div class="flex-1 min-h-0 overflow-hidden">
<table class="w-full text-left">
<thead class="sticky top-0 bg-white z-[1]">
<tr class="border-b border-slate-200">
<th class="pl-5 pr-2 py-2 w-9"><input type="checkbox" class="w-3.5 h-3.5 rounded accent-sky-600 align-middle" /></th>
<th class={`${th} max-w-0 w-full md:max-w-none md:w-auto`}>Name</th>
<th class={`${th} w-40 hidden lg:table-cell`}>Categories</th>
<th class={`${th} w-24 hidden lg:table-cell`}>Trend</th>
<th class={`${th} w-16 text-right`}>Views</th>
<th class={`${th} w-16 text-right hidden md:table-cell`}>Starts</th>
<th class={`${th} w-16 text-right`}>Subs</th>
<th class={`${th} w-16 text-right hidden sm:table-cell`}>Conv</th>
<th class={`${th} w-20 text-right hidden md:table-cell`}>Identified</th>
<th class={`${th} w-24 hidden xl:table-cell`}>
<span class="inline-flex items-center gap-0.5 uppercase tracking-[0.14em] transition-colors text-slate-700">Created
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</span>
</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const s = r.trend.some((v) => v > 0) ? spark(r.trend) : null;
const gid = `${sparkId}-${i}`;
return (
<tr class="h-11 border-b border-slate-100 cursor-pointer transition-colors">
<td class="pl-5 pr-2"><input type="checkbox" class="w-3.5 h-3.5 rounded accent-sky-600 align-middle" /></td>
<td class="px-3 max-w-0 w-full md:max-w-none md:w-auto">
<div class="flex items-center gap-2.5 min-w-0">
<div class="w-6 h-6 rounded-md bg-slate-100 flex items-center justify-center shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-500"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg>
</div>
<div class="min-w-0">
<div class="text-[12.5px] font-medium text-slate-900 truncate leading-tight flex items-center gap-1.5">
<span class="truncate">{r.name}</span>
<span class={`inline-flex items-center h-4 px-1.5 rounded text-[10px] font-medium shrink-0 ${pill[r.status]}`}>{r.status}</span>
</div>
<div class="text-[11px] text-slate-500 truncate">Last submission {r.last}</div>
</div>
</div>
</td>
<td class="px-3 hidden lg:table-cell">
<div class="flex items-center gap-1 min-w-0">
{r.cats.map((c) => (
<span class="inline-flex items-center gap-1 h-[18px] px-1.5 rounded bg-slate-50 border border-slate-200 text-[10.5px] text-slate-600 min-w-0">
<span class="w-1.5 h-1.5 rounded-full shrink-0" style={`background-color:${c.color}`}></span>
<span class="truncate max-w-20">{c.title}</span>
</span>
))}
</div>
</td>
<td class="px-3 hidden lg:table-cell">
{s ? (
<svg width={SW} height={SH} viewBox={`0 0 ${SW} ${SH}`} class="block">
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0284c7" stop-opacity="0.18" />
<stop offset="100%" stop-color="#0284c7" stop-opacity="0" />
</linearGradient>
</defs>
<path d={s.area} fill={`url(#${gid})`} />
<path d={s.line} fill="none" stroke="#0284c7" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round" />
</svg>
) : (
<span class="text-[11px] text-slate-300">–</span>
)}
</td>
<td class={`${num} text-slate-500`}>{n(r.views)}</td>
<td class={`${num} text-slate-500 hidden md:table-cell`}>{n(r.starts)}</td>
<td class={`${num} text-slate-900`}>{n(r.subs)}</td>
<td class={`${num} text-slate-500 hidden sm:table-cell`}>{conv(r)}</td>
<td class={`${num} text-slate-500 hidden md:table-cell`}>{n(r.identified)}</td>
<td class="px-3 text-[12px] text-slate-500 whitespace-nowrap hidden xl:table-cell">{r.created}</td>
<td class="pr-3">
<span class="size-7 rounded-md text-slate-400 inline-flex items-center justify-center transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
+141
View File
@@ -0,0 +1,141 @@
---
/**
* Full-bleed grain gradient, from Paper Shaders (paper.design).
*
* The layer drifts slowly; the library already pauses its rAF when the tab is
* hidden or the element is off screen, and reduced-motion pins it to a single
* static render. The CSS gradient underneath is the same palette, so a machine
* without WebGL (or one that never runs the script) gets the right frame
* instead of a hole.
*
* The canvas inherits the frame's radius: WebGL paints a rectangle, so without
* this the grain squares off the corners its frame just rounded.
*/
import { GRAIN_TONES, type GrainTone } from '../lib/grain';
interface Props {
tone: GrainTone;
class?: string;
}
const { tone, class: className = '' } = Astro.props;
const t = GRAIN_TONES[tone];
---
<div
class={`grain-backdrop absolute inset-0 z-0 overflow-hidden pointer-events-none ${className}`}
style={`background: ${t.css};`}
aria-hidden="true"
>
<div
class="grain-canvas absolute inset-0 overflow-hidden"
data-grain
data-back={t.back}
data-colors={t.colors.join(',')}
data-frame={t.frame}
></div>
</div>
<script>
import {
ShaderMount,
grainGradientFragmentShader,
GrainGradientShapes,
ShaderFitOptions,
getShaderColorFromString,
getShaderNoiseTexture,
} from '@paper-design/shaders';
// The grain is sampled from this texture; without it the shader has nothing
// to be grainy with, so we leave the CSS gradient standing instead.
const noise = getShaderNoiseTexture();
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Every frame gets its own rotation, drift and shape. Sharing one palette
// is the point; sharing one ANGLE makes eight cards look like eight copies
// of the same card, so the geometry is varied per index instead.
const SHAPES = [
GrainGradientShapes.wave,
GrainGradientShapes.ripple,
GrainGradientShapes.blob,
GrainGradientShapes.corners,
];
function variant(i: number) {
return {
shape: SHAPES[i % SHAPES.length],
rotation: (37 + i * 63) % 360,
scale: 1.05 + ((i * 17) % 45) / 100,
offsetX: (((i * 29) % 70) - 35) / 100,
offsetY: (((i * 43) % 70) - 35) / 100,
};
}
function mount(el: HTMLElement, i: number) {
if (el.dataset.grainReady) return;
el.dataset.grainReady = 'true';
const colors = (el.dataset.colors ?? '').split(',').filter(Boolean);
if (!noise || !colors.length) return;
const v = variant(i);
try {
new ShaderMount(
el,
grainGradientFragmentShader,
{
u_noiseTexture: noise,
u_colorBack: getShaderColorFromString(el.dataset.back ?? '#04121f'),
u_colors: colors.map((c) => getShaderColorFromString(c)),
u_colorsCount: colors.length,
// Wide soft bands under a fine grain: the texture is the point,
// but the tints are close enough that it stays one surface.
u_softness: 0.9,
u_intensity: 0.32,
u_noise: 0.72,
u_shape: v.shape,
u_fit: ShaderFitOptions.cover,
u_scale: v.scale,
u_rotation: v.rotation,
u_originX: 0.5,
u_originY: 0.5,
u_offsetX: v.offsetX,
u_offsetY: v.offsetY,
u_worldWidth: 0,
u_worldHeight: 0,
},
undefined,
// A slow drift. 0 stops the rAF entirely, which is what reduced-motion
// gets: one render, then nothing.
reduceMotion ? 0 : 0.12,
Number(el.dataset.frame ?? 0),
// Grain is computed in device pixels, so the pixel ratio IS the grain
// size: 2x renders it fine, 1x renders it chunky.
2,
1920 * 1080 * 2,
);
} catch {
// No WebGL, or the context limit on a page with several bands. The CSS
// gradient underneath is already painted, so there is nothing to undo.
}
}
const bands = [...document.querySelectorAll<HTMLElement>('[data-grain]')];
// Index by DOM order, not mount order, so the variant a frame gets does not
// depend on which direction the visitor scrolled.
const indexOf = new Map(bands.map((el, i) => [el, i]));
if (typeof IntersectionObserver === 'undefined') {
bands.forEach((el, i) => mount(el, i));
} else {
// Mount on approach, so a visitor who never scrolls costs one GL context
// rather than eight.
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target);
const el = e.target as HTMLElement;
mount(el, indexOf.get(el) ?? 0);
}
},
{ rootMargin: '300px' },
);
bands.forEach((b) => io.observe(b));
}
</script>
+17
View File
@@ -253,6 +253,19 @@ function active(match?: string) {
</nav>
<div class="ml-auto flex items-center gap-2">
<a
href="https://dc.warmbly.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Join the Warmbly Discord"
title="Discord"
class={
'cta-ghost inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ' +
(onSky ? '' : 'text-muted-foreground hover:text-foreground hover:bg-surface-2')
}
>
<Icon name="discord" size={16} />
</a>
<a
href="https://app.warmbly.com/login"
class={
@@ -339,6 +352,10 @@ function active(match?: string) {
</nav>
<div class="container-page py-5 shrink-0 flex flex-col gap-2.5 border-t border-slate-200/70">
<a href="https://dc.warmbly.com" target="_blank" rel="noopener noreferrer" class="h-12 inline-flex items-center justify-center gap-2 rounded-[11px] border border-slate-200 text-slate-700 text-[15px] font-medium hover:bg-slate-50 transition-colors">
<Icon name="discord" size={17} class="text-[#5865F2]" />
Join the Discord
</a>
<a href="https://app.warmbly.com/login" class="h-12 inline-flex items-center justify-center rounded-[11px] border border-slate-200 text-slate-700 text-[15px] font-medium hover:bg-slate-50 transition-colors">Log in</a>
<a href="https://app.warmbly.com/register" class="h-12 inline-flex items-center justify-center rounded-[11px] bg-foreground text-background text-[15px] font-medium hover:bg-foreground/85 transition-colors">Start free</a>
</div>
+15
View File
@@ -0,0 +1,15 @@
---
/**
* The public form page on the customer's own domain (forms/src FormPage,
* split layout): what a visitor sees when they open the link a campaign
* sent them. Rendered under the browser chrome alone, no app shell, at the
* same 900px viewport height as the app slides. The page is `.wf`: the
* #f8fafc page background with the form filling the viewport.
*/
import FormCardMock from './FormCardMock.astro';
---
<div class="relative w-full h-[900px] flex flex-col overflow-hidden" style="background:#f8fafc;">
<div class="flex-1 min-h-0 flex [&>*]:flex-1">
<FormCardMock />
</div>
</div>
+2
View File
@@ -53,6 +53,8 @@ const icons: Record<string, string> = {
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
rocket: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
// Brand glyphs are filled, not stroked (Simple Icons, CC0).
discord: '<path fill="currentColor" stroke="none" d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>',
};
const path = icons[name] ?? '';
+281 -204
View File
@@ -1,120 +1,128 @@
---
/**
* Static, desktop-locked mock of the Warmbly /app/integrations screen.
* The /app/integrations screen, mirrored from the app:
*
* Faithful to the real source:
* - web/src/app/app/integrations/page.tsx marketplace page (CatalogCard)
* - web/src/components/layout/Page.tsx PageTopbar / StatStrip / Stat / SectionBar
* - web/src/app/app/integrations/_components/ProviderGlyph.tsx per-brand tinted glyph tile (BRAND map)
* - web/src/app/app/integrations/_components/StatusPill.tsx connected / configured pills (TONES map)
* - web/src/app/app/integrations/_components/ConnectDrawer.tsx trust copy + primary button styling
* - web/src/components/ui/field.tsx SearchInput
* - internal/app/integration/catalog.go provider names + taglines + auth methods
* web/src/app/app/integrations/page.tsx PageTopbar, StatStrip, the "Your connections"
* rail, one SectionBar + hairline grid per catalog
* category, then the Meeting bookings list
* web/src/app/app/integrations/_components/ProviderGlyph.tsx the 36px brand tile (real marks from
* brandLogos.ts / BRAND_ICON, copied into lib/)
* web/src/app/app/integrations/_components/StatusPill.tsx the connected pill
* web/src/app/app/integrations/_components/ConnectDrawer.tsx the Connect drawer: 480px, slides in 28px
* over 240ms, scrim with a 2px blur
* internal/app/integration/catalog.go the 12 providers, taglines, highlights, scopes
*
* Authored at a 900px design width and scaled by the page. DESKTOP-LOCKED:
* no responsive variants anywhere. Renders only the in-app content (no global
* sidebar / app header), since the marketing page wraps it in its own frame.
* Demo: the cursor presses Connect on Slack, the Connect drawer slides in,
* then it closes from the drawer's own X.
*/
import DemoCursor from './DemoCursor.astro';
import { RAW_BRAND_LOGOS } from '../lib/brandLogos';
import { BRAND_ICON } from '../lib/brandIcons';
// Per-brand glyph tints, mirroring ProviderGlyph.tsx BRAND (with mail-provider
// + webhook + sheets tints that match the real theme tokens).
const brand: Record<string, { bg: string; ring: string; text: string }> = {
gmail: { bg: 'bg-red-50', ring: 'ring-red-200', text: 'text-red-600' },
outlook: { bg: 'bg-sky-50', ring: 'ring-sky-200', text: 'text-sky-600' },
slack: { bg: 'bg-fuchsia-50', ring: 'ring-fuchsia-200', text: 'text-fuchsia-600' },
hubspot: { bg: 'bg-orange-50', ring: 'ring-orange-200', text: 'text-orange-600' },
salesforce: { bg: 'bg-sky-50', ring: 'ring-sky-200', text: 'text-sky-600' },
zapier: { bg: 'bg-orange-50', ring: 'ring-orange-200', text: 'text-orange-600' },
webhooks: { bg: 'bg-slate-100', ring: 'ring-slate-300', text: 'text-slate-700' },
sheets: { bg: 'bg-emerald-50', ring: 'ring-emerald-200', text: 'text-emerald-600' },
};
// ProviderGlyph, as markup: full-bleed marks fill the tile, raw marks and
// single-path marks sit on a white tile at icon size.
function glyph(provider: string, name: string, size: 7 | 9 = 9): string {
const tile = size === 7 ? 'w-7 h-7' : 'w-9 h-9';
const icon = size === 7 ? 'w-4 h-4' : 'w-5 h-5';
const raw = RAW_BRAND_LOGOS[provider];
if (raw?.fullBleed) {
return `<div class="${tile} rounded-md ring-1 ring-black/5 overflow-hidden inline-flex shrink-0"><svg viewBox="${raw.viewBox}" role="img" aria-label="${name}" class="w-full h-full">${raw.inner}</svg></div>`;
}
if (raw) {
return `<div class="${tile} rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0"><svg viewBox="${raw.viewBox}" role="img" aria-label="${name}" class="${icon}">${raw.inner}</svg></div>`;
}
const ic = BRAND_ICON[provider];
return `<div class="${tile} rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0"><svg viewBox="0 0 24 24" role="img" aria-label="${name}" class="${icon}" fill="${ic.hex}"><path d="${ic.path}"/></svg></div>`;
}
type Tile = {
key: string;
name: string;
initial: string;
tagline: string;
auth: string; // mono caption: one-click / api_key / webhook
state: 'connected' | 'configured' | 'connect';
};
type Entry = { provider: string; name: string; tagline: string; auth: string; connected?: boolean };
type Section = { label: string; entries: Entry[] };
const tiles: Tile[] = [
{
key: 'gmail', name: 'Gmail', initial: 'G',
tagline: 'Send and sync through your Google Workspace mailboxes.',
auth: 'one-click', state: 'connected',
},
{
key: 'outlook', name: 'Microsoft 365', initial: 'M',
tagline: 'Send and sync through Outlook and Exchange Online mailboxes.',
auth: 'one-click', state: 'connected',
},
{
key: 'slack', name: 'Slack', initial: 'S',
tagline: 'Real-time alerts for positive replies, bounces, and deliverability.',
auth: 'one-click', state: 'connect',
},
{
key: 'hubspot', name: 'HubSpot', initial: 'H',
tagline: 'Push positive replies and new leads straight into your CRM.',
auth: 'one-click', state: 'connect',
},
{
key: 'salesforce', name: 'Salesforce', initial: 'S',
tagline: 'Sync leads, contacts, and email activity to Salesforce.',
auth: 'one-click', state: 'connect',
},
{
key: 'zapier', name: 'Zapier', initial: 'Z',
tagline: 'Triggers and actions across 8,000+ apps.',
auth: 'api_key', state: 'connect',
},
{
key: 'webhooks', name: 'Webhooks', initial: 'W',
tagline: 'HMAC-signed event delivery to your own endpoint.',
auth: 'webhook', state: 'configured',
},
{
key: 'sheets', name: 'Google Sheets', initial: 'G',
tagline: 'On-demand lead sync from a spreadsheet into your contacts.',
auth: 'one-click', state: 'connect',
},
// catalog.go, in CATEGORY_ORDER.
const sections: Section[] = [
{ label: 'CRM', entries: [
{ provider: 'hubspot', name: 'HubSpot', tagline: 'Push positive replies and new leads straight into your CRM.', auth: 'one-click', connected: true },
{ provider: 'salesforce', name: 'Salesforce', tagline: 'Sync leads, contacts, and email activity to Salesforce.', auth: 'one-click' },
{ provider: 'pipedrive', name: 'Pipedrive', tagline: 'Persons, deals, and an activity timeline that stays in sync.', auth: 'one-click' },
{ provider: 'close', name: 'Close', tagline: 'Leads, contacts, and inbox activity for Close.', auth: 'api_key' },
]},
{ label: 'Notifications', entries: [
{ provider: 'slack', name: 'Slack', tagline: 'Real-time alerts for positive replies, bounces, and deliverability.', auth: 'one-click' },
{ provider: 'discord', name: 'Discord', tagline: 'Webhook-based notifications to a server channel.', auth: 'webhook' },
]},
{ label: 'Automation', entries: [
{ provider: 'zapier', name: 'Zapier', tagline: 'Triggers and actions across 8,000+ apps.', auth: 'api_key', connected: true },
{ provider: 'make', name: 'Make', tagline: 'Visual automation scenarios.', auth: 'api_key' },
{ provider: 'n8n', name: 'n8n', tagline: 'Self-hosted automation workflows.', auth: 'api_key' },
]},
{ label: 'Meetings', entries: [
{ provider: 'calendly', name: 'Calendly', tagline: 'Booked, rescheduled, and canceled calls land on the contact, live.', auth: 'webhook', connected: true },
{ provider: 'cal_com', name: 'Cal.com', tagline: 'Open-source booking, same live meeting tracking.', auth: 'webhook' },
]},
{ label: 'Verification', entries: [
{ provider: 'millionverifier', name: 'MillionVerifier', tagline: 'Pay-as-you-go address verification for every contact you import.', auth: 'api_key' },
]},
];
const catalogCount = sections.reduce((n, s) => n + s.entries.length, 0);
// The connections rail: label, account, status.
const connections = [
{ provider: 'hubspot', name: 'HubSpot', label: 'HubSpot', account: 'Acme Inc' },
{ provider: 'zapier', name: 'Zapier', label: 'Zapier', account: 'Zapier' },
{ provider: 'calendly', name: 'Calendly', label: 'Calendly', account: 'maya@acme.com' },
];
// StatStrip cells (mirror page.tsx: Available / Connected / Needs attention / Meetings).
const stats = [
{ label: 'Available', value: '8', sub: 'providers', accent: false, last: false },
{ label: 'Connected', value: '3', sub: '3 total', accent: true, last: false },
{ label: 'Available', value: String(catalogCount), sub: 'providers', accent: false, last: false },
{ label: 'Connected', value: String(connections.length), sub: `${connections.length} total`, accent: true, last: false },
{ label: 'Needs attention', value: '0', sub: 'all healthy', accent: false, last: false },
{ label: 'Meetings', value: '14', sub: 'booked via integrations', accent: false, last: true },
];
---
<div class="relative w-full flex flex-col bg-white text-slate-900" style="min-height:500px;">
<!-- PageTopbar (h-12 px-5 border-b): eyebrow + divider + subtitle + search + refresh -->
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
// Meeting bookings rows (toLocaleString timestamps, as the app prints them).
const bookings = [
{ source: 'calendly', email: 'jenna@northwind.dev', event: 'Intro call', when: '9/4/2026, 3:00:00 PM' },
{ source: 'calendly', email: 'rowan@stratech.io', event: 'Discovery · 30 min', when: '9/3/2026, 11:30:00 AM' },
{ source: 'cal_com', email: 'priya@brightfold.com', event: 'Demo', when: '9/2/2026, 4:15:00 PM' },
{ source: 'calendly', email: 'sam@mapleco.com', event: 'Intro call', when: '9/1/2026, 10:00:00 AM' },
];
// Slack's catalog highlights and OAuth scopes (oauth.go).
const highlights = [
'One-click OAuth into your workspace',
'Ping a channel the moment a prospect replies',
'Warn the team when warmup health or deliverability dips',
];
const scopes = ['chat:write', 'channels:read', 'groups:read'];
const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
---
<div class="ig relative w-full flex flex-col bg-white overflow-hidden" style="min-height:500px;" data-demo="ig">
<!-- PageTopbar -->
<div class="min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Integrations</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">
Connect your stack: CRMs, alerts, automation, meetings, and data
</span>
<div class="ml-auto flex items-center gap-1.5">
<!-- SearchInput (w-48, h-7, hairline, sky focus) -->
<div class="w-48 flex h-7 pl-2 pr-1 rounded-md border border-slate-200 bg-white items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400 shrink-0" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="flex-1 min-w-0 text-[12.5px] text-slate-400">Search integrations</span>
<span class="text-[12.5px] text-slate-600 truncate">Connect your stack — CRMs, alerts, automation, meetings, and data</span>
<div class="ml-auto flex items-center gap-1.5 min-w-0 flex-wrap justify-end md:flex-nowrap">
<div class="flex items-center gap-2">
<div class="w-48 hidden sm:block">
<div class="h-7 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400 shrink-0" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span class="flex-1 min-w-0 h-full inline-flex items-center text-[12.5px] text-slate-400">Search integrations</span>
</div>
</div>
<button class="h-7 w-7 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center" aria-label="Refresh">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
</div>
<!-- Refresh -->
<button class="h-7 w-7 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center" aria-label="Refresh">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
</button>
</div>
</div>
<!-- StatStrip (4 cols, vertical-rule dividers, bottom hairline) -->
<div class="grid grid-cols-4 border-b border-slate-200 shrink-0 bg-white">
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
{stats.map((s) => (
<div class={`px-5 py-4 ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class={`group px-5 py-4 transition-colors ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
@@ -125,122 +133,191 @@ const stats = [
))}
</div>
<!-- SectionBar (h-9, lighter divider): directory label + count + trust note -->
<div class="h-9 px-5 border-b border-slate-200/60 flex items-center gap-2.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">App directory</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">8</span>
<div class="ml-auto inline-flex items-center gap-1.5 text-[10.5px] text-slate-400">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>
<span>OAuth tokens encrypted at rest. Webhooks HMAC-signed.</span>
<!-- PageBody -->
<div class="flex-1 min-h-0 overflow-hidden">
<!-- Connected rail -->
<section>
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Your connections</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{connections.length}</span>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{connections.map((c) => (
<div class="text-left bg-white p-4 flex items-center gap-3">
<Fragment set:html={glyph(c.provider, c.name)} />
<div class="min-w-0 flex-1">
<div class="text-[12.5px] font-semibold text-slate-900 truncate">{c.label}</div>
<div class="text-[11px] text-slate-400 truncate">{c.account}</div>
</div>
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-emerald-50 text-emerald-700"><span class="size-1.5 rounded-full bg-emerald-500"></span>connected</span>
</div>
))}
</div>
</section>
<!-- Catalog by category: hairline gaps, white cards, no radius (CatalogCard) -->
{sections.map((sec) => (
<section>
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{sec.label}</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{sec.entries.length}</span>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{sec.entries.map((t) => {
const isSlack = t.provider === 'slack';
return (
<div class="ig-tile group text-left bg-white p-5 flex flex-col min-h-[150px] transition-colors" data-tile={t.provider}>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<Fragment set:html={glyph(t.provider, t.name)} />
<div class="min-w-0">
<div class="text-[13px] font-semibold text-slate-900 truncate">{t.name}</div>
<div class="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">{t.auth}</div>
</div>
</div>
{t.connected && (
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-emerald-50 text-emerald-700">
<span class="size-1.5 rounded-full bg-emerald-500"></span>
connected
</span>
)}
</div>
<p class="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-2">{t.tagline}</p>
<div class="mt-auto pt-3 flex items-center justify-between gap-2">
<span class="text-[11px] text-slate-400 inline-flex items-center gap-1 underline decoration-dotted underline-offset-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
Docs
</span>
{t.connected ? (
<span class="h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors text-slate-600 group-hover:text-slate-900 group-hover:bg-slate-100">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={settingsIcon}></svg>
Manage
</span>
) : (
<span class={`ig-connect h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 bg-sky-600 text-white transition-colors ${isSlack ? 'ig-slack-connect' : ''}`}>
Connect
</span>
)}
</div>
</div>
);
})}
</div>
</section>
))}
<!-- Meeting bookings -->
<div class="min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Meeting bookings</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">14</span>
<div class="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400"><path d="M8 2v4"/><path d="M16 2v4"/><path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5"/><path d="M3 10h18"/><path d="m16 20 2 2 4-4"/></svg>
</div>
</div>
<div class="divide-y divide-slate-200/60 border-b border-slate-200/60">
{bookings.map((b) => (
<div class="px-5 h-12 flex items-center gap-3 text-[12.5px]">
<span class={`size-1.5 rounded-full shrink-0 ${b.source === 'calendly' ? 'bg-rose-400' : 'bg-indigo-400'}`}></span>
<span class="font-medium text-slate-900 truncate max-w-[40%] sm:max-w-none sm:w-60">{b.email}</span>
<span class="text-slate-500 truncate flex-1">{b.event}</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums shrink-0">{b.when}</span>
</div>
))}
</div>
</div>
<!-- Tile grid (grid-cols-3 gap-3 p-5) -->
<div class="grid grid-cols-3 gap-3 p-5">
{tiles.map((t) => {
const b = brand[t.key] ?? { bg: 'bg-sky-50', ring: 'ring-sky-100', text: 'text-sky-700' };
const isSlack = t.key === 'slack';
return (
<div class={`relative rounded-lg border bg-white p-4 flex flex-col ${isSlack ? 'ig-slack-tile border-slate-200' : 'border-slate-200'}`}>
<!-- top row: glyph + name/auth + status -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<div class={`w-9 h-9 text-[13px] rounded-md ring-1 inline-flex items-center justify-center font-semibold uppercase shrink-0 ${b.bg} ${b.ring} ${b.text}`}>{t.initial}</div>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-slate-900 truncate">{t.name}</div>
<div class="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">{t.auth}</div>
</div>
</div>
{t.state === 'connected' && (
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-emerald-50 text-emerald-700 whitespace-nowrap shrink-0">
<span class="size-1.5 rounded-full bg-emerald-500"></span>
connected
</span>
)}
{t.state === 'configured' && (
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-sky-50 text-sky-700 whitespace-nowrap shrink-0">
<span class="size-1.5 rounded-full bg-sky-500"></span>
configured
</span>
)}
</div>
<!-- one-line tagline -->
<p class="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-2">{t.tagline}</p>
<!-- bottom row: docs link + action -->
<div class="mt-auto pt-3 flex items-center justify-between gap-2">
<span class="text-[11px] text-slate-400 inline-flex items-center gap-1 underline decoration-dotted underline-offset-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
Docs
</span>
{t.state === 'connect' && !isSlack && (
<span class="h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 bg-sky-600 text-white whitespace-nowrap shrink-0">
Connect
</span>
)}
{t.state === 'configured' && (
<span class="h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 text-slate-600 whitespace-nowrap shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
Manage
</span>
)}
{isSlack && (
<span class="relative inline-flex items-center justify-end" style="min-width:78px;height:28px;">
<!-- default Slack Connect button (presses, then fades to the pill) -->
<span class="ig-slack-connect absolute right-0 h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 bg-sky-600 text-white whitespace-nowrap shrink-0">
Connect
</span>
<!-- reaction: emerald Connected pill revealed after press #1 -->
<span class="ig-react absolute right-0 inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-emerald-50 text-emerald-700 whitespace-nowrap shrink-0" style="top:50%;margin-top:-10px;">
<span class="size-1.5 rounded-full bg-emerald-500"></span>
connected
</span>
</span>
)}
</div>
<!-- ConnectDrawer for Slack: the reaction -->
<!-- Portaled to the body in the app: fixed over the whole window, the drawer on its right edge. -->
<div class="ig-react fixed inset-0 z-40 flex" aria-hidden="true">
<div class="ig-scrim absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"></div>
<div class="ig-drawer ml-auto h-full w-full sm:w-[480px] sm:max-w-[92vw] bg-white shadow-xl flex flex-col z-10 relative">
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
<Fragment set:html={glyph('slack', 'Slack', 7)} />
<div class="min-w-0 flex-1">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Connect</div>
<div class="text-[12.5px] text-slate-900 font-medium truncate">Slack</div>
</div>
);
})}
<span class="h-7 w-7 rounded border border-slate-200 text-slate-500 inline-flex items-center justify-center transition-colors [&.is-hover]:border-slate-300 [&.is-hover]:text-slate-900" data-ig="close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</span>
</div>
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-5">
<p class="text-[12.5px] text-slate-600 leading-relaxed">Real-time alerts for positive replies, bounces, and deliverability.</p>
<div class="space-y-2">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">What you get</div>
<ul class="space-y-1.5">
{highlights.map((h) => (
<li class="flex items-start gap-2 text-[12.5px] text-slate-700">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 mt-0.5 shrink-0"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
<span>{h}</span>
</li>
))}
</ul>
</div>
<div class="space-y-2">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Permissions requested</div>
<div class="rounded-md border border-slate-200 bg-slate-50/70 divide-y divide-slate-200">
{scopes.map((sc) => (
<div class="flex items-center gap-2 px-2.5 py-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
<code class="text-[10.5px] text-slate-600 font-mono truncate">{sc}</code>
</div>
))}
</div>
<p class="text-[10.5px] text-slate-400 leading-relaxed flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Tokens are encrypted at rest with your organization key. We never store your password.
</p>
</div>
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Connection label (optional)</span>
<!-- A bare TextInput: the browser's default input width. -->
<div class="h-7 w-[178px] px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-400 flex items-center">Slack</div>
<p class="text-[10.5px] text-slate-400 mt-1">Useful if you connect more than one Slack account.</p>
</div>
</div>
<div class="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2 shrink-0">
<span class="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center">Cancel</span>
<span class="h-7 px-3 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>
Connect with Slack
</span>
</div>
</div>
</div>
<!-- Demo scene: cursor presses Slack Connect (press #1) then the toast area
(press #2). The Slack tile flips to a Connected pill and a bottom-right
toast confirms. 11s loop. -->
<!-- cursor -->
<div class="ig-cursor" aria-hidden="true">
<span class="ig-ripple"></span>
<svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<!-- bottom-right toast (reaction) -->
<div class="ig-react" style="right:4%;bottom:5%;" aria-hidden="true">
<span class="inline-flex items-center gap-1.5 h-8 pl-2 pr-3 rounded-md bg-white border border-slate-200 shadow-md text-[12px] font-medium text-slate-700 whitespace-nowrap shrink-0">
<span class="size-4 rounded-full bg-emerald-50 text-emerald-600 inline-flex items-center justify-center">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</span>
Slack connected
</span>
</div>
<DemoCursor />
<!-- The drawer's own motion: scrim fades over 180ms, the panel slides in
from 28px right over 240ms on the app's ease. -->
<style>
.ig-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:ig-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.ig-cursor svg{display:block;transform-origin:3px 2px;animation:ig-press 11s ease-in-out infinite;}
.ig-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:ig-rip 11s ease-out infinite;}
.ig-react{position:absolute;z-index:40;opacity:0;animation:ig-react 11s ease-out infinite;}
/* Slack Connect button: active dip in sync with press #1, then fade out so the pill shows through. */
.ig-slack-connect{animation:ig-slack-btn 11s ease-out infinite;}
.ig-slack-tile{animation:ig-slack-ring 11s ease-out infinite;}
@keyframes ig-move{0%{left:24%;top:24%}22%{left:92%;top:46%}32%{left:92%;top:46%}58%{left:82%;top:91%}68%{left:82%;top:91%}88%{left:24%;top:24%}100%{left:24%;top:24%}}
@keyframes ig-press{0%,26%{transform:scale(1)}28%{transform:scale(.82)}31%,62%{transform:scale(1)}64%{transform:scale(.82)}67%,100%{transform:scale(1)}}
@keyframes ig-rip{0%,26%{transform:scale(0);opacity:0}29%{transform:scale(.25);opacity:.7}41%{transform:scale(1.9);opacity:0}60%{transform:scale(0);opacity:0}63%{transform:scale(.25);opacity:.7}75%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
@keyframes ig-react{0%,29%{opacity:0;transform:translateY(8px)}33%{opacity:1;transform:translateY(0)}88%{opacity:1;transform:translateY(0)}95%{opacity:0;transform:translateY(8px)}100%{opacity:0;transform:translateY(8px)}}
/* Connect button darkens at the press, then disappears once the pill is revealed. */
@keyframes ig-slack-btn{0%,27%{background-color:#0284c7;opacity:1}28%{background-color:#0369a1;opacity:1}30%{background-color:#0369a1;opacity:1}33%{opacity:0}88%{opacity:0}95%{background-color:#0284c7;opacity:1}100%{background-color:#0284c7;opacity:1}}
/* Slack tile briefly rings sky at the press to sell the click. */
@keyframes ig-slack-ring{0%,27%{box-shadow:0 0 0 0 rgba(186,230,253,0)}28%{box-shadow:0 0 0 2px rgba(186,230,253,1)}33%{box-shadow:0 0 0 2px rgba(186,230,253,1)}40%{box-shadow:0 0 0 0 rgba(186,230,253,0)}100%{box-shadow:0 0 0 0 rgba(186,230,253,0)}}
@media (prefers-reduced-motion: reduce){.ig-cursor{display:none}.ig-react{opacity:1;transform:none}.ig-slack-connect{display:none}.ig-slack-tile{box-shadow:none}}
.ig-react{pointer-events:none;}
.ig-scrim{opacity:0;transition:opacity .18s ease-out;}
.ig-drawer{opacity:0;transform:translateX(28px);transition:opacity .24s cubic-bezier(.16,1,.3,1),transform .24s cubic-bezier(.16,1,.3,1);}
.ig.is-open .ig-scrim{opacity:1;}
.ig.is-open .ig-drawer{opacity:1;transform:none;}
/* The card is the button: hovering it tints the card and darkens Connect. */
.ig-tile.is-hover{background-color:rgb(248 250 252 / 0.6);}
.ig-tile.is-hover .ig-connect,.ig-connect.is-pressed{background-color:#0369a1;}
.ig.is-static .ig-react{display:none;}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
document.querySelectorAll<HTMLElement>('[data-demo="ig"]').forEach((root) => {
playCursor(root, {
loop: 11000,
steps: [
{ at: 0, x: 24, y: 24 },
{ at: 2400, el: '.ig-slack-connect', ox: 0.5, oy: 0.55, hover: '[data-tile="slack"]' },
{ at: 2540, press: true, target: '.ig-slack-connect', add: ['is-open'] },
{ at: 6200, el: '[data-ig="close"]', ox: 0.5, oy: 0.55, hover: '[data-ig="close"]' },
{ at: 6340, press: true, remove: ['is-open'] },
{ at: 9000, x: 24, y: 24 },
],
});
});
</script>
+1 -1
View File
@@ -111,7 +111,7 @@ const links = [
<!-- ── Artifact: repo browser + floating terminal ────────────── -->
<div class="lg:col-span-7">
<div class="os-stage relative overflow-hidden h-[440px] rounded-[18px]"
<div class="os-stage round-clip h-[440px]"
style="background: linear-gradient(175deg, #fbfcfe 0%, var(--surface-2) 100%); box-shadow: inset 0 0 0 1px var(--border);">
<span class="os-texture absolute inset-0 pointer-events-none"></span>
+65 -63
View File
@@ -1,4 +1,5 @@
---
import DemoCursor from './DemoCursor.astro';
/**
* Mock of /app/campaigns/[id]/schedule - the weekly sending-window editor.
* Faithful to the real screen:
@@ -34,6 +35,7 @@ const hourLabel = (h: number) => {
return `${h12}${ampm}`;
};
const fmt = (min: number) => {
if (min === DAY) return 'midnight';
const h = Math.floor(min / 60);
const m = min % 60;
const ampm = h < 12 ? 'am' : 'pm';
@@ -45,7 +47,7 @@ const totalWindows = windows.reduce((s, d) => s + d.length, 0);
const activeDays = windows.filter((d) => d.length > 0).length;
const GUTTER = 46; // px (matches the real grid)
const BODY_H = 360; // px
const BODY_H = 420; // px (WeekScheduleGrid BODY_H)
// ── Reaction geometry (computed so the cursor + growing block land exactly
// on the Sat column between 9am and 5pm). All values in px relative to the
@@ -59,9 +61,9 @@ const COL_W = (COLS_W - 6) / 7; // 7 columns, 6 x 1px gaps
const SAT_LEFT = COLS_LEFT + 5 * (COL_W + 1); // left x of the Sat column (index 5)
// Vertical: the grid body top. Header chrome above the body:
// card header h-14 (56) + presets row (~41) + grid section pt-4 (16)
// + day headers row h-9 (36) = top of the grid body.
const BODY_TOP = 56 + 41 + 16 + 36;
// card header h-14 (56) + border + presets row py-2.5 around h-7 (48) + border
// + grid section pt-4 (16) + day headers row h-9 (36) = top of the grid body.
const BODY_TOP = 56 + 1 + 48 + 1 + 16 + 36;
const NINE = BODY_TOP + (540 / DAY) * BODY_H; // y of the 9am line
const FIVE = BODY_TOP + (1020 / DAY) * BODY_H; // y of the 5pm line
@@ -71,7 +73,7 @@ const FIVE = BODY_TOP + (1020 / DAY) * BODY_H; // y of the 5pm line
const cursorX = SAT_LEFT + COL_W / 2;
const cursorXPct = (cursorX / ROOT_W) * 100;
---
<div class="relative w-full bg-white">
<div class="sc relative w-full bg-white" data-demo="sc" data-x={cursorXPct} data-nine={NINE} data-five={FIVE}>
<!-- ── Hero scheduler card ─────────────────────────────────────────── -->
<section class="rounded-lg border border-slate-200 bg-white overflow-hidden">
<!-- header bar (h-14) -->
@@ -88,11 +90,11 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
</div>
<!-- timezone SelectButton -->
<div class="ml-auto shrink-0">
<button type="button" class="h-7 px-2 rounded-md border border-slate-200 bg-white text-slate-700 inline-flex items-center gap-1.5 text-[12px] font-medium">
<button type="button" class="h-7 px-2 rounded-md border border-slate-200 bg-white text-slate-700 inline-flex items-center gap-1.5 text-[12px] font-medium transition-colors justify-between max-w-[230px]">
<span class="text-slate-400 shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
</span>
<span class="truncate max-w-[160px]">America/New York (EST)</span>
<span class="truncate max-w-[160px]">(UTC-05:00) America/New_York</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="text-slate-400 shrink-0"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
@@ -102,9 +104,9 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
<div class="px-4 py-2.5 flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-200/70 bg-slate-50/30">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mr-0.5">Presets</span>
{['Mon–Fri 9–5', 'Every day 9–5', 'Clear'].map((p) => (
<button type="button" class="h-7 px-2.5 rounded-md border border-slate-200 text-[11.5px] text-slate-600 whitespace-nowrap shrink-0">{p}</button>
<button type="button" class="h-7 px-2.5 rounded-md border border-slate-200 text-[11.5px] text-slate-600 transition-colors">{p}</button>
))}
<span class="ml-auto text-[11px] text-slate-400 block">Drag to add &middot; drag a block to move &middot; edges to resize &middot; &#x29C9; copies a day to all</span>
<span class="ml-auto text-[11px] text-slate-400 hidden sm:block">Drag to add &middot; drag a block to move &middot; edges to resize &middot; &#x29C9; copies a day to all</span>
</div>
<!-- week grid -->
@@ -142,14 +144,14 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
const active = windows[i].length > 0;
const isToday = i === todayIdx;
return (
<div class={`relative flex-1 cursor-crosshair ${active ? 'bg-sky-50/30' : 'bg-slate-50/40'} ${isToday ? 'ring-1 ring-inset ring-sky-100' : ''}`}>
<div class={`relative flex-1 cursor-crosshair touch-pan-y ${active ? 'bg-sky-50/30' : 'bg-slate-50/40'} ${isToday ? 'ring-1 ring-inset ring-sky-100' : ''}`}>
{windows[i].length === 0 && (
<span class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center text-[9.5px] text-slate-300 leading-tight px-1">drag, or tap +</span>
)}
{windows[i].map((iv) => {
const tall = iv.end - iv.start >= 90;
return (
<div class="group absolute inset-x-[3px] rounded-md border border-sky-400/60 bg-sky-500/15 overflow-hidden" style={`top:${pct(iv.start)}; height:${pct(iv.end - iv.start)}`}>
<div class="group absolute inset-x-[3px] rounded-md border border-sky-400/60 bg-sky-500/15 overflow-hidden touch-pan-x cursor-grab" style={`top:${pct(iv.start)}; height:${pct(iv.end - iv.start)}`}>
<span class={`pointer-events-none absolute left-1.5 ${tall ? 'top-1' : 'top-1/2 -translate-y-1/2'} text-[9.5px] font-medium text-sky-700 tabular-nums leading-tight`}>
{fmt(iv.start)}{tall ? <br /> : '–'}{fmt(iv.end)}
</span>
@@ -163,7 +165,7 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
</div>
</div>
<p class="text-[11px] text-slate-400 mt-3">
Each day is independent. Set different windows per day, or several windows in one day. Sends are scheduled in America/New York; worker IPs spread distribution naturally.
Each day is independent — set different windows per day, or several windows in one day. Sends are scheduled in (UTC-05:00) America/New_York; worker IPs spread distribution naturally.
</p>
</div>
</section>
@@ -180,16 +182,22 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
</div>
<div class="flex items-end gap-3 flex-wrap">
<div class="w-[170px]">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5">Start date</div>
<button type="button" class="flex gap-2 items-center h-7 px-2.5 w-full rounded-md border border-slate-200 text-[12.5px] text-slate-700 whitespace-nowrap">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-0">Start date</span>
<span class="size-4 rounded text-slate-400 inline-flex items-center justify-center"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></span>
</div>
<button type="button" class="flex gap-2 items-center h-7 px-2.5 w-full rounded-md border border-slate-200 text-[12.5px] text-slate-700 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>
<span class="tabular-nums">09 Jun, 2026</span>
</button>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-300 shrink-0 self-end mb-1.5"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
<div class="w-[170px]">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5">End date</div>
<button type="button" class="flex gap-2 items-center h-7 px-2.5 w-full rounded-md border border-slate-200 text-[12.5px] text-slate-700 whitespace-nowrap">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-0">End date</span>
<span class="size-4 rounded text-slate-400 inline-flex items-center justify-center"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></span>
</div>
<button type="button" class="flex gap-2 items-center h-7 px-2.5 w-full rounded-md border border-slate-200 text-[12.5px] text-slate-700 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>
<span class="tabular-nums">07 Jul, 2026</span>
</button>
@@ -201,9 +209,10 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
</section>
<!-- ── Save / Reset ───────────────────────────────────────────────── -->
<div class="flex justify-end gap-2 pt-1 mt-4">
<button class="h-7 px-3 text-[12px] font-medium text-slate-600 border border-slate-200 rounded-md whitespace-nowrap shrink-0">Reset</button>
<button class="h-7 px-3 bg-sky-600 text-white rounded-md text-[12px] font-medium min-w-[110px] inline-flex items-center justify-center whitespace-nowrap shrink-0">Save changes</button>
<!-- Faded until the drag makes a change, as the app's hasChanges gate does. -->
<div class="sc-save flex justify-end gap-2 pt-1 mt-4 transition-opacity duration-100">
<button class="h-7 px-3 text-[12px] font-medium text-slate-600 border border-slate-200 rounded-md transition-colors">Reset</button>
<button class="h-7 px-3 bg-sky-600 text-white rounded-md text-[12px] font-medium transition-colors min-w-[110px] inline-flex items-center justify-center">Save changes</button>
</div>
<!-- ── Demo reaction: the real drag-to-create window growing on Sat ─── -->
@@ -216,55 +225,19 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
<span class="sc-chip absolute inline-flex items-center h-5 px-1.5 rounded-md border border-sky-300 bg-sky-50 text-[9px] font-medium text-sky-700 tabular-nums whitespace-nowrap shadow-sm" style={`left:${COL_W / 2 + 4}px;`}>9:00 - 17:00</span>
</div>
<div class="sc-cursor" aria-hidden="true"><span class="sc-ripple"></span><svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg></div>
<DemoCursor />
<style define:vars={{ ninePx: `${NINE}px`, fivePx: `${FIVE}px`, cursorXPct: `${cursorXPct}%`, growH: `${FIVE - NINE}px` }}>
.sc-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:sc-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.sc-cursor svg{display:block;transform-origin:3px 2px;animation:sc-press 11s ease-in-out infinite;}
.sc-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:sc-rip 11s ease-out infinite;}
/* The reaction overlay is anchored at the 9am press point of the Sat
column. Its inner block grows downward (scaleY 0 -> 1) locked to the
drag window, then rests until the loop resets. */
drag window, then rests until the loop resets. The keyframes run on
.is-playing so the engine restarts them with each cursor loop. */
.sc-react{position:absolute;z-index:40;}
.sc-grow{transform-origin:top;transform:scaleY(0);opacity:0;animation:sc-grow 11s ease-out infinite;}
.sc-chip{transform:translateY(-50%);opacity:0;animation:sc-chip 11s ease-out infinite;}
.sc-grow{transform-origin:top;transform:scaleY(0);opacity:0;}
.sc-chip{transform:translateY(-50%);opacity:0;}
.sc.is-playing .sc-grow{animation:sc-grow 11s cubic-bezier(.45,0,.2,1) infinite;}
.sc.is-playing .sc-chip{animation:sc-chip 11s cubic-bezier(.45,0,.2,1) infinite;}
/* Cursor path: travels in from the top-left, presses on the Sat column at
the 9am line (~22-28%), then DRAGS DOWN (stationary-x, moving-y) to the
5pm line between 28% and 44%, holding pressed. It rests, then on the
second beat nudges the finished block before the loop resets. */
@keyframes sc-move{
0%{left:18%;top:18%}
22%{left:var(--cursorXPct);top:var(--ninePx)}
28%{left:var(--cursorXPct);top:var(--ninePx)}
44%{left:var(--cursorXPct);top:var(--fivePx)}
58%{left:var(--cursorXPct);top:var(--fivePx)}
64%{left:var(--cursorXPct);top:var(--fivePx)}
68%{left:var(--cursorXPct);top:var(--fivePx)}
88%{left:62%;top:88%}
100%{left:18%;top:18%}
}
/* Pressed/grabbing through the whole drag (28% -> 44%), released after. */
@keyframes sc-press{
0%,26%{transform:scale(1)}
28%{transform:scale(.82)}
44%{transform:scale(.82)}
48%{transform:scale(1)}
62%{transform:scale(1)}
64%{transform:scale(.82)}
67%,100%{transform:scale(1)}
}
/* Ripple on the initial press, and again on the second beat. */
@keyframes sc-rip{
0%,26%{transform:scale(0);opacity:0}
29%{transform:scale(.25);opacity:.7}
41%{transform:scale(1.7);opacity:0}
60%{transform:scale(0);opacity:0}
63%{transform:scale(.25);opacity:.7}
75%{transform:scale(1.7);opacity:0}
100%{transform:scale(0);opacity:0}
}
/* Block grows from 0 height at the press point to full, tracking the drag
window (28% -> 44%), then rests as a finished block until ~90%. */
@keyframes sc-grow{
@@ -284,6 +257,35 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
96%{opacity:0;transform:translateY(-50%) translateY(var(--growH))}
100%{opacity:0;transform:translateY(-50%) translateY(var(--growH))}
}
@media (prefers-reduced-motion: reduce){.sc-cursor{display:none}.sc-react{display:none}}
.sc-save{opacity:.4;pointer-events:none;}
.sc.is-playing .sc-save{animation:sc-save 11s linear infinite;}
@keyframes sc-save{0%,43%{opacity:.4}45%,95%{opacity:1}97%,100%{opacity:.4}}
.sc.is-static .sc-react{display:none}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
// The press point and the 5pm line are design px (the mock is authored at
// a fixed width), so the whole scene runs in px.
document.querySelectorAll<HTMLElement>('[data-demo="sc"]').forEach((root) => {
const w = root.offsetWidth;
const h = root.offsetHeight;
const x = (w * Number(root.dataset.x)) / 100;
const nine = Number(root.dataset.nine);
const five = Number(root.dataset.five);
playCursor(root, {
loop: 11000,
unit: 'px',
steps: [
{ at: 0, x: w * 0.18, y: h * 0.18 },
{ at: 3000, x, y: nine },
{ at: 3080, down: true },
{ at: 4840, x, y: five, straight: true, slow: true },
{ at: 4900, up: true },
{ at: 9680, x: w * 0.62, y: h * 0.88 },
{ at: 10800, x: w * 0.18, y: h * 0.18 },
],
});
});
</script>
+1 -1
View File
@@ -98,7 +98,7 @@ const features = [
/>
<!-- ── Artifact: template -> three rendered emails ───────────── -->
<div class="tpl-stage relative overflow-hidden mt-12 md:mt-14 rounded-[18px] p-5 sm:p-7 lg:p-9"
<div class="tpl-stage round-clip mt-12 md:mt-14 p-5 sm:p-7 lg:p-9"
style="background: linear-gradient(175deg, #fbfcfe 0%, var(--surface-2) 100%); box-shadow: inset 0 0 0 1px var(--border);">
<span class="tpl-texture absolute inset-0 pointer-events-none"></span>
+167 -115
View File
@@ -1,4 +1,5 @@
---
import DemoCursor from './DemoCursor.astro';
/**
* Pixel-faithful mock of the Warmbly /app/unibox screen (unified inbox).
*
@@ -29,15 +30,26 @@ const stats = [
// ── Scope rail (left) ──────────────────────────────────────────────
const inboxScopes = [
{ icon: 'inbox', label: 'All', count: '342', active: false, tone: 'muted' },
{ icon: 'sparkle', label: 'Unread', count: '7', active: true, tone: 'accent' },
{ icon: 'inbox', label: 'All', count: '342', active: true, tone: 'muted' },
{ icon: 'sparkle', label: 'Unread', count: '7', active: false, tone: 'accent' },
{ icon: 'clock', label: 'Today', count: '24', active: false, tone: 'muted' },
{ icon: 'calendar', label: 'This week', count: '89', active: false, tone: 'muted' },
{ icon: 'reply', label: 'Awaiting reply', count: '3', active: false, tone: 'accent' },
{ icon: 'sparkles', label: 'Agent drafts', count: '2', active: false, tone: 'accent' },
{ icon: 'moon', label: 'Snoozed', count: '2', active: false, tone: 'muted' },
{ icon: 'send', label: 'Scheduled', count: '1', active: false, tone: 'accent' },
];
// Folders (ScopeRail FolderItem), the inbox selected.
const folders = [
{ label: 'Inbox', active: true, icon: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>' },
{ label: 'Drafts', icon: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>' },
{ label: 'Sent', icon: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>' },
{ label: 'Archive', icon: '<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>' },
{ label: 'Spam', icon: '<path d="M12 16h.01"/><path d="M12 8v4"/><path d="M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z"/>' },
{ label: 'Trash', icon: '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>' },
];
const mailboxScopes = [
{ email: 'hello@company.com', count: '2' },
{ email: 'support@company.com', count: '5' },
@@ -55,7 +67,7 @@ const conversations = [
initials: 'SC', hue: 14, sender: 'Sarah Chen',
subject: 'Follow up on Q1 proposal',
preview: "Thanks for sending over the docs. I've reviewed them and have a few…",
time: '5m', unread: true, selected: true,
time: '5m', unread: true, selected: true, count: 3,
mailbox: 'hello@company.com',
tags: [{ color: '#ef4444', label: 'Urgent' }, { color: '#f59e0b', label: 'Clients' }],
},
@@ -71,7 +83,7 @@ const conversations = [
initials: 'AM', hue: 265, sender: 'Alex Morgan',
subject: 'New partnership opportunity',
preview: "Hi there, I'm reaching out about a potential partnership between…",
time: '3h', unread: false, selected: false, muted: true,
time: '3h', unread: false, selected: false,
mailbox: 'sales@company.com',
tags: [{ color: '#3b82f6', label: 'Leads' }],
},
@@ -79,7 +91,7 @@ const conversations = [
initials: 'CR', hue: 150, sender: 'Casey Reynolds',
subject: '(no subject)',
preview: '(no preview)',
time: 'Jan 15', unread: false, selected: false, muted: true,
time: 'Jan 15', unread: false, selected: false,
mailbox: 'hello@company.com',
tags: [],
},
@@ -89,17 +101,17 @@ const conversations = [
const messages = [
{
initials: 'SC', name: 'Sarah Chen', addr: 'sarah@acme.io',
to: 'hello@company.com', time: 'Jan 18, 10:32', you: false,
to: 'hello@company.com', time: 'Jan 18, 10:32 AM', collapsed: true,
body: "Thanks for sending over the docs. I've reviewed them and have a few questions. Can we sync up tomorrow morning?",
},
{
initials: 'YO', name: 'You', addr: 'hello@company.com',
to: 'sarah@acme.io', time: '14:15', you: true,
initials: 'CO', name: 'Company', addr: 'hello@company.com',
to: 'sarah@acme.io', time: 'Jan 18, 02:15 PM', collapsed: true,
body: 'Absolutely! How does 10am EST work for you?',
},
{
initials: 'SC', name: 'Sarah Chen', addr: 'sarah@acme.io',
to: 'hello@company.com', time: '14:28', you: false,
to: 'hello@company.com', time: 'Jan 18, 02:28 PM', collapsed: false,
body: 'Perfect. See you then!',
},
];
@@ -110,11 +122,11 @@ const railToneClass = (tone: string, active: boolean) => {
return 'text-slate-400';
};
---
<div class="relative w-full flex flex-col bg-white text-slate-900" style="min-height:500px;">
<div class="ub relative w-full flex flex-col bg-white text-slate-900" style="min-height:500px;" data-demo="ub">
<!-- ─────────────────────────────────────────────────────────────
1) TOP STRIP - UniboxHeader (h-10)
────────────────────────────────────────────────────────────── -->
<header class="h-10 px-4 border-b border-slate-200 bg-white flex items-center gap-3 shrink-0">
<header class="h-10 px-3 sm:px-4 border-b border-slate-200 bg-white flex items-center gap-2 sm:gap-3 shrink-0 overflow-x-auto">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-semibold shrink-0">Inbox</span>
<div class="h-4 w-px bg-slate-200 shrink-0"></div>
<div class="flex items-center gap-3.5 min-w-0">
@@ -141,22 +153,44 @@ const railToneClass = (tone: string, active: boolean) => {
<!-- a) LEFT SCOPE RAIL (w-220, hidden on narrower viewports) -->
<nav class="flex flex-col w-[220px] shrink-0 border-r border-slate-200 bg-slate-50/60 overflow-y-auto py-2">
<!-- Inbox section -->
<!-- Compose -->
<div class="px-2 pb-2">
<span class="w-full h-8 rounded-lg bg-sky-600 text-white text-[12px] font-medium inline-flex items-center justify-center gap-1.5 shadow-sm shadow-sky-600/20 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
Compose
</span>
</div>
<!-- Folders -->
<div class="mb-1">
<div class="px-3 pt-3 pb-1 flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-semibold">Inbox</span>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-semibold">Folders</span>
</div>
<div class="px-1.5 space-y-px">
{folders.map((f) => (
<div class={`group/folder w-full h-7 pl-2 pr-1 rounded-md flex items-center gap-2 transition-colors text-left cursor-pointer ${f.active ? 'bg-slate-200/80 text-slate-900' : 'text-slate-600'}`}>
<span class="shrink-0 text-slate-500"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={f.icon}></svg></span>
<span class="truncate min-w-0 flex-1 text-[12px]">{f.label}</span>
</div>
))}
</div>
</div>
<!-- Views -->
<div class="mb-1">
<div class="px-3 pt-3 pb-1 flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-semibold">Views</span>
</div>
<div class="px-1.5 space-y-px">
{inboxScopes.map((it) => (
<div class={`w-full h-7 pl-2 pr-2 rounded-md flex items-center gap-2 text-left ${it.active ? 'bg-sky-100 text-sky-900 font-medium' : 'text-slate-600'}`}>
<div class={`w-full h-7 pl-2 pr-2 rounded-md flex items-center gap-2 transition-colors text-left ${it.active ? 'bg-sky-100 text-sky-900 font-medium' : 'text-slate-600'}`}>
<span class={`shrink-0 ${it.active ? 'text-sky-700' : 'text-slate-500'}`}>
{it.icon === 'inbox' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>}
{it.icon === 'sparkle' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/></svg>}
{it.icon === 'clock' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>}
{it.icon === 'calendar' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M17 14h-6"/><path d="M13 18H7"/><path d="M7 14h.01"/><path d="M17 18h.01"/></svg>}
{it.icon === 'reply' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>}
{it.icon === 'moon' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>}
{it.icon === 'send' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>}
{it.icon === 'inbox' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>}
{it.icon === 'sparkles' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>}
{it.icon === 'sparkle' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/></svg>}
{it.icon === 'clock' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>}
{it.icon === 'calendar' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M17 14h-6"/><path d="M13 18H7"/><path d="M7 14h.01"/><path d="M17 18h.01"/></svg>}
{it.icon === 'reply' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>}
{it.icon === 'moon' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>}
{it.icon === 'send' && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>}
</span>
<span class="truncate min-w-0 flex-1 text-[12px]">{it.label}</span>
{it.count && (
@@ -179,7 +213,7 @@ const railToneClass = (tone: string, active: boolean) => {
{mailboxScopes.map((m) => (
<div class="w-full h-7 pl-2 pr-2 rounded-md flex items-center gap-2 text-left text-slate-600">
<span class="shrink-0 text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z"/><polyline points="15,9 18,9 18,11"/><path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2"/><line x1="6" x2="7" y1="10" y2="10"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z"/><polyline points="15,9 18,9 18,11"/><path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2"/><line x1="6" x2="7" y1="10" y2="10"/></svg>
</span>
<span class="truncate min-w-0 flex-1 font-mono text-[11.5px]">{m.email}</span>
{m.count && (
@@ -215,11 +249,12 @@ const railToneClass = (tone: string, active: boolean) => {
</nav>
<!-- b) MIDDLE LIST (w-360) -->
<div class="w-[360px] shrink-0 border-r border-slate-200 flex flex-col bg-white min-h-0">
<div class="w-[360px] shrink-0 border-r border-slate-200 overflow-hidden flex flex-col bg-white min-h-0">
<!-- search bar -->
<div class="h-9 px-2 shrink-0 border-b border-slate-200 flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400 shrink-0" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="flex-1 min-w-0 text-[12.5px] text-slate-400">Search inbox… (/)</span>
<span class="flex-1 min-w-0 h-7 inline-flex items-center text-[12.5px] text-slate-400">Search all… (/)</span>
<span class="font-mono tabular-nums text-[10.5px] text-slate-400 shrink-0">4</span>
<span class="size-7 rounded text-slate-500 inline-flex items-center justify-center shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
</span>
@@ -228,14 +263,14 @@ const railToneClass = (tone: string, active: boolean) => {
<!-- scrollable list -->
<div class="flex-1 overflow-y-auto">
<!-- sticky time-bucket header -->
<div class="sticky top-0 z-10 px-3 py-1 bg-slate-50/95 border-b border-slate-200/60 flex items-center gap-2">
<div class="sticky top-0 z-10 px-3 py-1 bg-slate-50/95 backdrop-blur-sm border-b border-slate-200/60 flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-500 font-semibold">Today</span>
<span class="font-mono text-[10px] text-slate-400 tabular-nums">4</span>
</div>
<div class="divide-y divide-slate-200/60">
{conversations.map((c) => (
<div class={`group w-full text-left px-3 py-2.5 flex items-start gap-2.5 relative ${c.selected ? 'bg-sky-50/80' : ''}`}>
<div class={`group w-full text-left px-3 py-2 transition-colors flex items-start gap-2.5 relative ${c.selected ? 'bg-sky-50/80' : ''}`}>
{c.unread && (
<span class="absolute left-0 top-2 bottom-2 w-[3px] rounded-r bg-sky-500"></span>
)}
@@ -247,28 +282,34 @@ const railToneClass = (tone: string, active: boolean) => {
>{c.initials}</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 min-w-0">
<span class={`text-[12.5px] truncate min-w-0 ${c.unread ? 'text-slate-900 font-semibold' : c.muted ? 'text-slate-500 font-medium' : 'text-slate-800 font-medium'}`}>{c.sender}</span>
<span class={`text-[12.5px] truncate min-w-0 ${c.unread ? 'text-slate-900 font-semibold' : 'text-slate-800 font-medium'}`}>{c.sender}</span>
{'count' in c && <span class="shrink-0 font-mono tabular-nums text-[10px] text-slate-500 bg-slate-100 rounded-full px-1.5 h-4 inline-flex items-center">{c.count}</span>}
<span class="font-mono text-[10px] text-slate-400 tabular-nums shrink-0 ml-auto">{c.time}</span>
</div>
<div class={`text-[12px] truncate mt-0.5 ${c.unread ? 'text-slate-800 font-medium' : c.muted ? 'text-slate-500' : 'text-slate-600'}`}>{c.subject}</div>
<div class={`text-[12px] truncate mt-0.5 ${c.unread ? 'text-slate-800 font-medium' : 'text-slate-600'}`}>{c.subject}</div>
<div class="text-[11px] text-slate-400 truncate mt-0.5">{c.preview}</div>
<div class="mt-1 flex items-center gap-1 min-w-0 flex-wrap">
<span class="inline-flex items-center h-4 px-1.5 rounded-sm bg-slate-100 text-slate-500 text-[9.5px] font-mono truncate max-w-[160px]">{c.mailbox}</span>
{c.tags.map((t) => (
<span
class="inline-flex items-center gap-1 h-4 pl-1 pr-1.5 rounded-sm border bg-white text-[10px] font-medium text-slate-700 truncate max-w-[120px]"
class="inline-flex items-center gap-1 h-4 pl-1 pr-1.5 rounded-sm border bg-white text-[10px] font-medium text-slate-700 overflow-hidden max-w-[120px]"
style={`border-color:${t.color}60;background-color:${t.color}12;`}
>
<span class="block size-2 rounded-full shrink-0" style={`background-color:${t.color};`}></span>
{t.label}
<span class="truncate">{t.label}</span>
</span>
))}
<span class="inline-flex items-center h-4 px-1.5 rounded-sm bg-slate-100 text-slate-500 text-[9.5px] font-mono truncate max-w-[160px]">{c.mailbox}</span>
</div>
</div>
</div>
))}
</div>
</div>
<div class="h-6 px-2 shrink-0 border-t border-slate-200/80 bg-slate-50/60 hidden md:flex items-center gap-2 text-[10px] text-slate-500 overflow-x-auto">
<kbd class="px-1 h-3.5 rounded-sm bg-white border border-slate-200 text-slate-600 font-mono text-[9px] inline-flex items-center shrink-0">j</kbd>/<kbd class="px-1 h-3.5 rounded-sm bg-white border border-slate-200 text-slate-600 font-mono text-[9px] inline-flex items-center shrink-0">k</kbd>
<span class="text-slate-400">move</span> <kbd class="px-1 h-3.5 rounded-sm bg-white border border-slate-200 text-slate-600 font-mono text-[9px] inline-flex items-center shrink-0">↵</kbd> <span class="text-slate-400">open</span>
<kbd class="px-1 h-3.5 rounded-sm bg-white border border-slate-200 text-slate-600 font-mono text-[9px] inline-flex items-center shrink-0">esc</kbd> <span class="text-slate-400">close</span> <kbd class="px-1 h-3.5 rounded-sm bg-white border border-slate-200 text-slate-600 font-mono text-[9px] inline-flex items-center shrink-0">/</kbd> <span class="text-slate-400">search</span>
</div>
</div>
<!-- c) RIGHT THREAD PANE -->
@@ -279,20 +320,27 @@ const railToneClass = (tone: string, active: boolean) => {
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-900 font-medium truncate min-w-0">Follow up on Q1 proposal</span>
<span class="ml-1 inline-flex items-center gap-1 h-5 px-1.5 rounded bg-slate-100 text-slate-600 text-[10.5px] font-medium font-mono shrink-0">hello@company.com</span>
<span class="hidden md:inline-flex items-center gap-1 shrink-0">
<span class="inline-flex items-center gap-1 h-4 pl-1 pr-1 text-[10px] rounded font-medium" style="background-color:rgba(239,68,68,0.12);color:#ef4444;border:1px solid rgba(239,68,68,0.25);"><span class="size-1.5 rounded-full shrink-0" style="background-color:#ef4444;"></span><span class="truncate max-w-[72px] md:max-w-none">Urgent</span></span>
<span class="inline-flex items-center gap-1 h-4 pl-1 pr-1 text-[10px] rounded font-medium" style="background-color:rgba(245,158,11,0.12);color:#f59e0b;border:1px solid rgba(245,158,11,0.25);"><span class="size-1.5 rounded-full shrink-0" style="background-color:#f59e0b;"></span><span class="truncate max-w-[72px] md:max-w-none">Clients</span></span>
</span>
<div class="ml-auto flex items-center gap-1">
<span class="h-7 px-2 rounded-md text-slate-500 border border-slate-200 inline-flex items-center gap-1 text-[12px]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
<span class="inline-flex size-7 rounded-md items-center justify-center transition-colors text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span class="h-7 px-2 rounded-md text-slate-500 inline-flex items-center gap-1 transition-colors text-[12px]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
Snooze
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
<span class="size-7 rounded-md inline-flex items-center justify-center text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m16 19 2 2 4-4"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m16 19 2 2 4-4"/></svg>
</span>
<span class="size-7 rounded-md inline-flex items-center justify-center text-slate-500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>
</span>
<span class="size-7 rounded-md inline-flex items-center justify-center text-slate-500 hover:text-red-600 hover:bg-red-50">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</span>
</div>
</div>
@@ -306,9 +354,9 @@ const railToneClass = (tone: string, active: boolean) => {
<!-- messages -->
<div class="flex-1 overflow-y-auto divide-y divide-slate-200/60">
{messages.map((m) => (
<article class="px-5 py-4">
<header class="flex items-start gap-3 mb-3">
<div class={`size-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0 ${m.you ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}`}>{m.initials}</div>
<article class="group px-3 sm:px-5 py-4">
<header class="flex items-start gap-3 mb-3 cursor-pointer">
<div class="size-7 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center text-[10px] font-semibold shrink-0">{m.initials}</div>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<span class="text-[12.5px] font-semibold text-slate-900 truncate">{m.name}</span>
@@ -318,16 +366,24 @@ const railToneClass = (tone: string, active: boolean) => {
<span>to {m.to}</span>
</div>
</div>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums shrink-0">{m.time}</span>
<div class="flex items-center gap-1 shrink-0">
<div class="flex items-center gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<span class="size-6 rounded text-slate-500 inline-flex items-center justify-center"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg></span>
<span class="size-6 rounded text-slate-500 inline-flex items-center justify-center"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg></span>
</div>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{m.time}</span>
</div>
</header>
<div class="text-[13px] text-slate-800 leading-relaxed">{m.body}</div>
{m.collapsed
? <div class="w-full text-left text-[13px] text-slate-500 truncate">{m.body}</div>
: <div class="text-[13px] text-slate-800" style="line-height:1.6;">{m.body}</div>}
</article>
))}
</div>
<!-- reply rail footer -->
<div class="border-t border-slate-200 bg-white px-3 py-2 flex items-center gap-1.5 shrink-0">
<span class="ub-reply-btn h-7 px-2.5 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0">
<span class="ub-reply-btn h-7 px-2.5 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0 transition-colors [&.is-hover]:bg-sky-700 [&.is-pressed]:bg-sky-700">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>
Reply
</span>
@@ -341,87 +397,73 @@ const railToneClass = (tone: string, active: boolean) => {
</div>
<!-- ─────────────────────────────────────────────────────────────
DEMO SCENE - cursor presses Reply, the real ReplyComposer
slides up over the bottom of the thread pane, then Send.
DEMO SCENE: the cursor presses Reply and the real ReplyComposer
rises over the bottom of the thread pane (12px lift, 200ms), then
closes it from the composer's own X.
Mirrors web/src/components/app/unibox/ReplyComposer.tsx
────────────────────────────────────────────────────────────── -->
<!-- reaction: ReplyComposer anchored to the thread (right) area -->
<div class="ub-react absolute left-[580px] right-0 bottom-0 bg-white border-t border-slate-200 flex flex-col shadow-[0_-6px_24px_-12px_rgba(15,23,42,.12)]" aria-hidden="true">
<!-- target message preview : "Replying to" strip -->
<div class="px-4 pt-3 pb-2 flex items-start gap-3 bg-slate-50/60 border-b border-slate-200/70">
<span class="size-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0 bg-sky-100 text-sky-700">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>
</span>
<div class="min-w-0 flex-1">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Replying to</div>
<div class="flex items-baseline gap-2 mt-0.5 min-w-0">
<span class="text-[12.5px] font-semibold text-slate-900 truncate">Sarah Chen</span>
<span class="font-mono text-[10.5px] text-slate-500 truncate">sarah@acme.io</span>
</div>
<div class="text-[11.5px] text-slate-500 mt-0.5 truncate">Follow up on Q1 proposal</div>
</div>
<span class="size-7 inline-flex items-center justify-center rounded-md text-slate-400 shrink-0">
<div class="ub-react absolute left-[580px] right-0 bottom-0 z-40 border-t border-slate-200 bg-white shrink-0 flex flex-col shadow-[0_-6px_24px_-12px_rgba(15,23,42,0.10)]" aria-hidden="true">
<!-- target strip: what this composer is doing, plus the close handle -->
<div class="h-8 pl-3.5 pr-1.5 flex items-center gap-2 bg-slate-100/80 border-b border-slate-200 select-none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-slate-500"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>
<span class="min-w-0 flex-1 text-[11.5px] text-slate-500 truncate"><span class="font-semibold text-slate-800">Reply</span> to Sarah Chen<span class="text-slate-400"> · Follow up on Q1 proposal</span></span>
<span class="size-6 inline-flex items-center justify-center rounded-md text-slate-400 shrink-0 transition-colors [&.is-hover]:text-slate-700 [&.is-hover]:bg-slate-200/60" data-ub="close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</span>
</div>
<!-- header strip : From / To / Subject -->
<div class="px-4 py-2.5 border-b border-slate-200/70 divide-y divide-slate-200/40 bg-white">
<div class="flex items-start gap-3 py-1.5">
<span class="w-14 shrink-0 pt-[3px] text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">From</span>
<span class="self-stretch w-px bg-slate-200 shrink-0"></span>
<!-- header rows: To / From / Subject -->
<div class="shrink-0 bg-white">
<div class="flex items-start gap-2 px-4 py-[7px] border-b border-slate-100">
<span class="w-9 shrink-0 pt-[3px] text-[11px] text-slate-400">To</span>
<div class="flex-1 min-w-0 flex flex-wrap items-center gap-1.5">
<div class="inline-flex items-center gap-2 min-w-0">
<span class="text-[12.5px] text-slate-900 font-medium truncate">Company</span>
<span class="font-mono text-[10.5px] text-slate-500 bg-slate-50 px-1.5 h-4 inline-flex items-center rounded border border-slate-200 shrink-0 whitespace-nowrap">hello@company.com</span>
</div>
<div class="ml-auto flex items-center gap-1 shrink-0">
<span class="h-5 px-1.5 rounded text-[10.5px] text-slate-500 inline-flex items-center whitespace-nowrap shrink-0">+ Cc</span>
<span class="h-5 px-1.5 rounded text-[10.5px] text-slate-500 inline-flex items-center whitespace-nowrap shrink-0">+ Bcc</span>
</div>
</div>
</div>
<div class="flex items-start gap-3 py-1.5">
<span class="w-14 shrink-0 pt-[3px] text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">To</span>
<span class="self-stretch w-px bg-slate-200 shrink-0"></span>
<div class="flex-1 min-w-0 flex flex-wrap items-center gap-1.5">
<span class="inline-flex items-center gap-1 h-5 pl-1.5 pr-0.5 rounded-md text-[11px] font-medium shrink-0 whitespace-nowrap border bg-sky-50 text-sky-800 border-sky-200">
<span class="font-mono">sarah@acme.io</span>
<span class="size-4 inline-flex items-center justify-center rounded">
<span class="inline-flex items-center gap-1 h-5 pl-1.5 pr-0.5 rounded-md text-[11px] font-medium max-w-full min-w-0 border bg-sky-50 text-sky-800 border-sky-200">
<span class="font-mono truncate">sarah@acme.io</span>
<span class="size-4 shrink-0 inline-flex items-center justify-center rounded">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</span>
</span>
<span class="flex-1 min-w-[14ch] h-5 bg-transparent text-[11.5px] text-slate-900 font-mono"></span>
<span class="shrink-0 size-5 inline-flex items-center justify-center rounded text-slate-400 transition-colors"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6"/><path d="M22 11h-6"/></svg></span>
<div class="ml-auto flex items-center gap-0.5 shrink-0 self-start pt-px">
<span class="h-5 px-1 rounded text-[11px] text-slate-400 inline-flex items-center">Cc</span>
<span class="h-5 px-1 rounded text-[11px] text-slate-400 inline-flex items-center">Bcc</span>
</div>
</div>
</div>
<div class="flex items-start gap-3 py-1.5">
<span class="w-14 shrink-0 pt-[3px] text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Subject</span>
<span class="self-stretch w-px bg-slate-200 shrink-0"></span>
<div class="flex items-start gap-2 px-4 py-[7px] border-b border-slate-100">
<span class="w-9 shrink-0 pt-[3px] text-[11px] text-slate-400">From</span>
<div class="flex-1 min-w-0 flex flex-wrap items-center gap-1.5">
<span class="flex-1 min-w-0 h-6 inline-flex items-center text-[12.5px] text-slate-900">Re: Follow up on Q1 proposal</span>
<div class="inline-flex items-center gap-2 min-w-0">
<span class="text-[12.5px] text-slate-800 truncate">Company</span>
<span class="font-mono text-[10.5px] text-slate-400 min-w-0 truncate">hello@company.com</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 px-4 border-b border-slate-100">
<span class="flex-1 min-w-0 h-9 inline-flex items-center text-[13px] font-medium text-slate-900">Re: Follow up on Q1 proposal</span>
</div>
</div>
<!-- body -->
<div class="w-full min-h-[120px] px-5 py-3 text-[13px] text-slate-800 leading-relaxed">
Tomorrow at 10am EST works great for me. I'll send a calendar invite shortly.<span class="text-slate-400"> Write your reply. ⌘ + Enter to send.</span>
</div>
<!-- body (the textarea) -->
<div class="w-full min-h-[120px] max-h-72 px-4 py-3 text-[13px] text-slate-800 resize-y">Tomorrow at 10am EST works great for me. I'll send a calendar invite shortly.</div>
<!-- signature preview strip -->
<div class="mx-5 mb-2 rounded-md border border-emerald-200/60 bg-emerald-50/40 overflow-hidden">
<!-- signature preview -->
<div class="mx-4 mb-2 rounded-md border border-emerald-200/60 bg-emerald-50/40 overflow-hidden">
<div class="px-3 py-1.5 flex items-center gap-1.5 border-b border-emerald-200/40 bg-emerald-50/60">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="text-emerald-700" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-700" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
<span class="text-[10px] uppercase tracking-[0.14em] text-emerald-800 font-semibold">Signature appended on send</span>
<span class="ml-auto inline-flex items-center gap-1 text-[10px] text-emerald-700/80">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
from hello@company.com
</span>
</div>
<div class="px-3 py-2 font-sans text-[11.5px] text-slate-700 leading-relaxed">Best,<br />The Company Team</div>
<pre class="px-3 py-2 m-0 font-sans text-[11.5px] text-slate-700 whitespace-pre-wrap leading-relaxed">Best,
The Company Team</pre>
</div>
<!-- action bar : single row, no wrap at the 320px composer width -->
<div class="px-3 py-2 border-t border-slate-200/60 flex flex-nowrap items-center gap-1.5">
<!-- action bar -->
<div class="px-3 py-2 border-t border-slate-200/60 flex flex-wrap items-center gap-1.5">
<span class="h-7 px-2.5 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5 whitespace-nowrap shrink-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>
Send
@@ -436,29 +478,39 @@ const railToneClass = (tone: string, active: boolean) => {
Template
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
<span class="ml-auto font-mono text-[10px] text-slate-400 tabular-nums shrink-0">0 / 4000</span>
<span class="h-7 px-2 rounded-md text-slate-500 text-[12px] inline-flex items-center whitespace-nowrap shrink-0">Discard</span>
<span class="ml-auto inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700 whitespace-nowrap">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
Signature on
</span>
<span class="font-mono text-[10px] text-slate-400 tabular-nums">77/4000</span>
</div>
</div>
<!-- cursor -->
<div class="ub-cursor" aria-hidden="true"><span class="ub-ripple"></span><svg width="23" height="27" viewBox="0 0 23 27" fill="none"><path d="M3 2.2 L3 20.4 L7.7 16 L10.9 23.2 L14 21.8 L10.9 14.8 L17.2 14.6 Z" fill="#ffffff" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg></div>
<DemoCursor />
<!-- The composer's own motion: 12px lift and fade over 200ms on the
app's ease, driven by .is-open. -->
<style>
.ub-cursor{position:absolute;z-index:50;width:23px;height:27px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(15,23,42,.4));animation:ub-move 11s cubic-bezier(.5,0,.2,1) infinite;}
.ub-cursor svg{display:block;transform-origin:3px 2px;animation:ub-press 11s ease-in-out infinite;}
.ub-ripple{position:absolute;left:-6px;top:-6px;width:32px;height:32px;border-radius:9999px;background:rgba(2,132,199,.5);transform:scale(0);opacity:0;animation:ub-rip 11s ease-out infinite;}
.ub-react{position:absolute;z-index:40;opacity:0;animation:ub-react 11s ease-out infinite;}
/* press #1 (28%) lands on the Reply button center (x 626/900=70%, y 478/500=95.5%);
press #2 (64%) lands in the thread/messages area ABOVE the composer to dismiss it
(right pane center x 740/900=82%, thread-header y 65/500=13%) */
@keyframes ub-move{0%{left:30%;top:34%}22%{left:70%;top:95.5%}32%{left:70%;top:95.5%}58%{left:82%;top:13%}68%{left:82%;top:13%}88%{left:30%;top:34%}100%{left:30%;top:34%}}
@keyframes ub-press{0%,26%{transform:scale(1)}28%{transform:scale(.82)}31%,62%{transform:scale(1)}64%{transform:scale(.82)}67%,100%{transform:scale(1)}}
@keyframes ub-rip{0%,26%{transform:scale(0);opacity:0}29%{transform:scale(.25);opacity:.7}41%{transform:scale(1.9);opacity:0}60%{transform:scale(0);opacity:0}63%{transform:scale(.25);opacity:.7}75%{transform:scale(1.9);opacity:0}100%{transform:scale(0);opacity:0}}
/* dismiss timeline: composer opens after press #1, closes after press #2 (click-outside) */
@keyframes ub-react{0%,29%{opacity:0;transform:translateY(22px)}33%{opacity:1;transform:translateY(0)}60%{opacity:1;transform:translateY(0)}66%{opacity:0;transform:translateY(22px)}100%{opacity:0;transform:translateY(22px)}}
/* press feedback : Reply button darkens + rings on press #1 (opens composer) */
.ub-reply-btn{transition:none;animation:ub-reply-press 11s ease-in-out infinite;}
@keyframes ub-reply-press{0%,27%{background-color:#0284c7;box-shadow:none}28%,31%{background-color:#0369a1;box-shadow:0 0 0 2px rgba(186,230,253,1)}32%,100%{background-color:#0284c7;box-shadow:none}}
@media (prefers-reduced-motion: reduce){.ub-cursor{display:none}.ub-react{display:none}.ub-reply-btn{animation:none}}
.ub-react{opacity:0;transform:translateY(12px);pointer-events:none;transition:opacity .2s cubic-bezier(.16,1,.3,1),transform .2s cubic-bezier(.16,1,.3,1);}
.ub.is-open .ub-react{opacity:1;transform:none;}
.ub.is-static .ub-react{display:none;}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
document.querySelectorAll<HTMLElement>('[data-demo="ub"]').forEach((root) => {
playCursor(root, {
loop: 11000,
steps: [
{ at: 0, x: 30, y: 34 },
{ at: 2400, el: '.ub-reply-btn', ox: 0.5, oy: 0.55, hover: '.ub-reply-btn' },
{ at: 2540, press: true, target: '.ub-reply-btn', add: ['is-open'] },
{ at: 6200, el: '[data-ub="close"]', ox: 0.5, oy: 0.55, hover: '[data-ub="close"]' },
{ at: 6340, press: true, remove: ['is-open'] },
{ at: 9000, x: 30, y: 34 },
],
});
});
</script>
+39
View File
@@ -0,0 +1,39 @@
// Copied from web/src/app/app/integrations/_components/ProviderGlyph.tsx: single-path brand marks drawn in the brand colour on a white tile.
export const BRAND_ICON: Record<string, { hex: string; path: string }> = {
millionverifier: {
hex: "#1FB25A",
path: "M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1.2 14.4-4.2-4.2 1.7-1.7 2.5 2.5 6.1-6.1 1.7 1.7-7.8 7.8z",
},
discord: {
hex: "#5865F2",
path: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z",
},
hubspot: {
hex: "#FF7A59",
path: "M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z",
},
salesforce: {
hex: "#00A1E0",
path: "M10.006 5.415a4.195 4.195 0 013.045-1.306c1.56 0 2.954.9 3.69 2.205.63-.3 1.35-.45 2.1-.45 2.85 0 5.159 2.34 5.159 5.22s-2.31 5.22-5.176 5.22c-.345 0-.69-.044-1.02-.104a3.75 3.75 0 01-3.3 1.95c-.6 0-1.155-.15-1.65-.375A4.314 4.314 0 018.88 20.4a4.302 4.302 0 01-4.05-2.82c-.27.062-.54.076-.825.076-2.204 0-4.005-1.8-4.005-4.05 0-1.5.811-2.805 2.01-3.51-.255-.57-.39-1.2-.39-1.846 0-2.58 2.1-4.65 4.65-4.65 1.53 0 2.85.705 3.72 1.8",
},
zapier: {
hex: "#FF4F00",
path: "M4.157 0A4.151 4.151 0 0 0 0 4.161v15.678A4.151 4.151 0 0 0 4.157 24h15.682A4.152 4.152 0 0 0 24 19.839V4.161A4.152 4.152 0 0 0 19.839 0H4.157Zm10.61 8.761h.03a.577.577 0 0 1 .23.038.585.585 0 0 1 .201.124.63.63 0 0 1 .162.431.612.612 0 0 1-.162.435.58.58 0 0 1-.201.128.58.58 0 0 1-.23.042.529.529 0 0 1-.235-.042.585.585 0 0 1-.332-.328.559.559 0 0 1-.038-.235.613.613 0 0 1 .17-.431.59.59 0 0 1 .405-.162Zm2.853 1.572c.03.004.061.004.095.004.325-.011.646.064.937.219.238.144.431.355.552.609.128.279.189.582.185.888v.193a2 2 0 0 1 0 .219h-2.498c.003.227.075.45.204.642a.78.78 0 0 0 .646.265.714.714 0 0 0 .484-.136.642.642 0 0 0 .23-.318l.915.257a1.398 1.398 0 0 1-.28.537c-.14.159-.321.284-.521.355a2.234 2.234 0 0 1-.836.136 1.923 1.923 0 0 1-1.001-.245 1.618 1.618 0 0 1-.665-.703 2.221 2.221 0 0 1-.227-1.036 1.95 1.95 0 0 1 .48-1.398 1.9 1.9 0 0 1 1.3-.488Zm-9.607.023c.162.004.325.026.48.079.207.065.4.174.563.314.26.302.393.692.366 1.088v2.276H8.53l-.109-.711h-.065c-.064.163-.155.31-.272.439a1.122 1.122 0 0 1-.374.264 1.023 1.023 0 0 1-.453.083 1.334 1.334 0 0 1-.866-.264.965.965 0 0 1-.329-.801.993.993 0 0 1 .076-.431 1.02 1.02 0 0 1 .242-.363 1.478 1.478 0 0 1 1.043-.303h.952v-.181a.696.696 0 0 0-.136-.454.553.553 0 0 0-.438-.154.695.695 0 0 0-.378.086.48.48 0 0 0-.193.254l-.99-.144a1.26 1.26 0 0 1 .257-.563c.14-.174.321-.302.533-.378.261-.091.54-.136.82-.129.053-.003.106-.007.163-.007Zm4.384.007c.174 0 .347.038.506.114.182.083.34.211.458.374.257.423.377.911.351 1.406a2.53 2.53 0 0 1-.355 1.448 1.148 1.148 0 0 1-1.009.517c-.204 0-.401-.045-.582-.136a1.052 1.052 0 0 1-.48-.457 1.298 1.298 0 0 1-.114-.234h-.045l.004 1.784h-1.059v-4.713h.904l.117.805h.057c.068-.208.177-.401.328-.56a1.129 1.129 0 0 1 .843-.344h.076v-.004Zm7.559.084h.903l.113.805h.053a1.37 1.37 0 0 1 .235-.484.813.813 0 0 1 .313-.242.82.82 0 0 1 .39-.076h.234v1.051h-.401a.662.662 0 0 0-.313.008.623.623 0 0 0-.272.155.663.663 0 0 0-.174.26.683.683 0 0 0-.027.314v1.875h-1.054v-3.666Zm-17.515.003h3.262v.896L3.73 13.104l.034.113h1.973l.042.9H2.4v-.9l1.931-1.754-.045-.117H2.441v-.896Zm11.815 0h1.055v3.659h-1.055V10.45Zm3.443.684.019.016a.69.69 0 0 0-.351.045.756.756 0 0 0-.287.204c-.11.155-.174.336-.189.522h1.545c-.034-.526-.257-.787-.74-.787h.003Zm-5.718.163c-.026 0-.057 0-.083.004a.78.78 0 0 0-.31.053.746.746 0 0 0-.257.189 1.016 1.016 0 0 0-.204.695v.064c-.015.257.057.507.204.711a.634.634 0 0 0 .253.196.638.638 0 0 0 .314.061.644.644 0 0 0 .578-.265c.14-.223.204-.48.189-.74a1.216 1.216 0 0 0-.181-.711.677.677 0 0 0-.503-.257Zm-4.509 1.266a.464.464 0 0 0-.268.102.373.373 0 0 0-.114.276c0 .053.008.106.027.155a.375.375 0 0 0 .087.132.576.576 0 0 0 .397.11v.004a.863.863 0 0 0 .563-.182.573.573 0 0 0 .211-.457v-.14h-.903Z",
},
n8n: {
hex: "#EA4B71",
path: "M21.4737 5.6842c-1.1772 0-2.1663.8051-2.4468 1.8947h-2.8955c-1.235 0-2.289.893-2.492 2.111l-.1038.623a1.263 1.263 0 0 1-1.246 1.0555H11.289c-.2805-1.0896-1.2696-1.8947-2.4468-1.8947s-2.1663.8051-2.4467 1.8947H4.973c-.2805-1.0896-1.2696-1.8947-2.4468-1.8947C1.1311 9.4737 0 10.6047 0 12s1.131 2.5263 2.5263 2.5263c1.1772 0 2.1663-.8051 2.4468-1.8947h1.4223c.2804 1.0896 1.2696 1.8947 2.4467 1.8947 1.1772 0 2.1663-.8051 2.4468-1.8947h1.0008a1.263 1.263 0 0 1 1.2459 1.0555l.1038.623c.203 1.218 1.257 2.111 2.492 2.111h.3692c.2804 1.0895 1.2696 1.8947 2.4468 1.8947 1.3952 0 2.5263-1.131 2.5263-2.5263s-1.131-2.5263-2.5263-2.5263c-1.1772 0-2.1664.805-2.4468 1.8947h-.3692a1.263 1.263 0 0 1-1.246-1.0555l-.1037-.623A2.52 2.52 0 0 0 13.9607 12a2.52 2.52 0 0 0 .821-1.4794l.1038-.623a1.263 1.263 0 0 1 1.2459-1.0555h2.8955c.2805 1.0896 1.2696 1.8947 2.4468 1.8947 1.3952 0 2.5263-1.131 2.5263-2.5263s-1.131-2.5263-2.5263-2.5263m0 1.2632a1.263 1.263 0 0 1 1.2631 1.2631 1.263 1.263 0 0 1-1.2631 1.2632 1.263 1.263 0 0 1-1.2632-1.2632 1.263 1.263 0 0 1 1.2632-1.2631M2.5263 10.7368A1.263 1.263 0 0 1 3.7895 12a1.263 1.263 0 0 1-1.2632 1.2632A1.263 1.263 0 0 1 1.2632 12a1.263 1.263 0 0 1 1.2631-1.2632m6.3158 0A1.263 1.263 0 0 1 10.1053 12a1.263 1.263 0 0 1-1.2632 1.2632A1.263 1.263 0 0 1 7.579 12a1.263 1.263 0 0 1 1.2632-1.2632m10.1053 3.7895a1.263 1.263 0 0 1 1.2631 1.2632 1.263 1.263 0 0 1-1.2631 1.2631 1.263 1.263 0 0 1-1.2632-1.2631 1.263 1.263 0 0 1 1.2632-1.2632",
},
calendly: {
hex: "#006BFF",
path: "M19.655 14.262c.281 0 .557.023.828.064 0 .005-.005.01-.005.014-.105.267-.234.534-.381.786l-1.219 2.106c-1.112 1.936-3.177 3.127-5.411 3.127h-2.432c-2.23 0-4.294-1.191-5.412-3.127l-1.218-2.106a6.251 6.251 0 0 1 0-6.252l1.218-2.106C6.736 4.832 8.8 3.641 11.035 3.641h2.432c2.23 0 4.294 1.191 5.411 3.127l1.219 2.106c.147.252.271.519.381.786 0 .004.005.009.005.014-.267.041-.543.064-.828.064-1.816 0-2.501-.607-3.291-1.306-.764-.676-1.711-1.517-3.44-1.517h-1.029c-1.251 0-2.387.455-3.2 1.278-.796.805-1.233 1.904-1.233 3.099v1.411c0 1.196.437 2.295 1.233 3.099.813.823 1.949 1.278 3.2 1.278h1.034c1.729 0 2.676-.841 3.439-1.517.791-.703 1.471-1.306 3.287-1.301Zm.005-3.237c.399 0 .794-.036 1.179-.11-.002-.004-.002-.01-.002-.014-.073-.414-.193-.823-.349-1.218.731-.12 1.407-.396 1.986-.819 0-.004-.005-.013-.005-.018-.331-1.085-.832-2.101-1.489-3.03-.649-.915-1.435-1.719-2.331-2.395-1.867-1.398-4.088-2.138-6.428-2.138-1.448 0-2.855.28-4.175.841-1.273.543-2.423 1.315-3.407 2.299S2.878 6.552 2.341 7.83c-.557 1.324-.842 2.726-.842 4.175 0 1.448.281 2.855.842 4.174.542 1.274 1.314 2.423 2.298 3.407s2.129 1.761 3.407 2.299c1.324.556 2.727.841 4.175.841 2.34 0 4.561-.74 6.428-2.137a10.815 10.815 0 0 0 2.331-2.396c.652-.929 1.158-1.949 1.489-3.03 0-.004.005-.014.005-.018-.579-.423-1.255-.699-1.986-.819.161-.395.276-.804.349-1.218.005-.009.005-.014.005-.023.869.166 1.692.506 2.404 1.035.685.505.552 1.075.446 1.416C22.184 20.437 17.619 24 12.221 24c-6.625 0-12-5.375-12-12s5.37-12 12-12c5.398 0 9.963 3.563 11.471 8.464.106.341.239.915-.446 1.421-.717.529-1.535.873-2.404 1.034.128.716.128 1.45 0 2.166-.387-.074-.782-.11-1.182-.11-4.184 0-3.968 2.823-6.736 2.823h-1.029c-1.899 0-3.15-1.357-3.15-3.095v-1.411c0-1.738 1.251-3.094 3.15-3.094h1.034c2.768 0 2.552 2.823 6.731 2.827Z",
},
make: {
hex: "#6D00CC",
path: "M13.38 3.498c-.27 0-.511.19-.566.465L9.85 18.986a.578.578 0 0 0 .453.678l4.095.826a.58.58 0 0 0 .682-.455l2.963-15.021a.578.578 0 0 0-.453-.678l-4.096-.826a.589.589 0 0 0-.113-.012zm-5.876.098a.576.576 0 0 0-.516.318L.062 17.697a.575.575 0 0 0 .256.774l3.733 1.877a.578.578 0 0 0 .775-.258l6.926-13.781a.577.577 0 0 0-.256-.776L7.762 3.658a.571.571 0 0 0-.258-.062zm11.74.115a.576.576 0 0 0-.576.576v15.426c0 .318.258.578.576.578h4.178a.58.58 0 0 0 .578-.578V4.287a.578.578 0 0 0-.578-.576Z",
},
cal_com: {
hex: "#292929",
path: "M2.408 14.488C1.035 14.488 0 13.4 0 12.058c0-1.346.982-2.443 2.408-2.443.758 0 1.282.233 1.691.765l-.66.55a1.343 1.343 0 0 0-1.03-.442c-.93 0-1.44.711-1.44 1.57 0 .86.559 1.557 1.44 1.557.413 0 .765-.147 1.043-.443l.651.573c-.391.51-.929.743-1.695.743zM6.948 10.913h.89v3.49h-.89v-.51c-.185.362-.493.604-1.083.604-.943 0-1.695-.82-1.695-1.826 0-1.007.752-1.825 1.695-1.825.585 0 .898.241 1.083.604zm.026 1.758c0-.546-.374-.998-.964-.998-.568 0-.938.457-.938.998 0 .528.37.998.938.998.586 0 .964-.456.964-.998zM8.467 9.503h.89v4.895h-.89zM9.752 13.937a.53.53 0 0 1 .542-.528c.313 0 .533.242.533.528a.527.527 0 0 1-.533.537.534.534 0 0 1-.542-.537zM14.23 13.839c-.33.403-.832.658-1.426.658a1.806 1.806 0 0 1-1.84-1.826c0-1.007.778-1.825 1.84-1.825.572 0 1.07.241 1.4.622l-.687.577c-.172-.215-.396-.376-.713-.376-.568 0-.938.456-.938.998 0 .541.37.997.938.997.343 0 .58-.179.757-.42zM14.305 12.671c0-1.007.78-1.825 1.84-1.825 1.061 0 1.84.818 1.84 1.825 0 1.007-.779 1.826-1.84 1.826-1.06-.005-1.84-.82-1.84-1.826zm2.778 0c0-.546-.37-.998-.938-.998-.568-.004-.937.452-.937.998 0 .542.37.998.937.998.568 0 .938-.456.938-.998zM24 12.269v2.13h-.89v-1.911c0-.604-.281-.864-.704-.864-.396 0-.678.197-.678.864v1.91h-.89v-1.91c0-.604-.285-.864-.704-.864-.396 0-.744.197-.744.864v1.91h-.89v-3.49h.89v.484c.185-.376.52-.564 1.035-.564.489 0 .898.241 1.123.649.224-.417.554-.65 1.153-.65.731.005 1.299.56 1.299 1.442z",
},
};
+65
View File
@@ -0,0 +1,65 @@
// Multi-color official brand marks that can't be a single path. The raw inner
// SVG markup is rendered as-is inside a <svg viewBox> tile. Sourced from the
// brands' published logos (Slack: the four-color hash; Close: the faceted orb).
// fullBleed marks paint their own (non-white) background and fill the whole tile,
// so the glyph wrapper skips the white tile and clips them to the rounded corners.
export type RawBrandLogo = { viewBox: string; inner: string; fullBleed?: boolean };
const SLACK: RawBrandLogo = {
viewBox: "0 0 256 256",
inner: [
'<path fill="#E01E5A" d="M53.8412698,161.320635 C53.8412698,176.152381 41.8539683,188.139683 27.0222222,188.139683 C12.1904762,188.139683 0.203174603,176.152381 0.203174603,161.320635 C0.203174603,146.488889 12.1904762,134.501587 27.0222222,134.501587 L53.8412698,134.501587 L53.8412698,161.320635 Z M67.2507937,161.320635 C67.2507937,146.488889 79.2380952,134.501587 94.0698413,134.501587 C108.901587,134.501587 120.888889,146.488889 120.888889,161.320635 L120.888889,228.368254 C120.888889,243.2 108.901587,255.187302 94.0698413,255.187302 C79.2380952,255.187302 67.2507937,243.2 67.2507937,228.368254 L67.2507937,161.320635 Z"/>',
'<path fill="#36C5F0" d="M94.0698413,53.6380952 C79.2380952,53.6380952 67.2507937,41.6507937 67.2507937,26.8190476 C67.2507937,11.9873016 79.2380952,0 94.0698413,0 C108.901587,0 120.888889,11.9873016 120.888889,26.8190476 L120.888889,53.6380952 L94.0698413,53.6380952 Z M94.0698413,67.2507937 C108.901587,67.2507937 120.888889,79.2380952 120.888889,94.0698413 C120.888889,108.901587 108.901587,120.888889 94.0698413,120.888889 L26.8190476,120.888889 C11.9873016,120.888889 0,108.901587 0,94.0698413 C0,79.2380952 11.9873016,67.2507937 26.8190476,67.2507937 L94.0698413,67.2507937 Z"/>',
'<path fill="#2EB67D" d="M201.549206,94.0698413 C201.549206,79.2380952 213.536508,67.2507937 228.368254,67.2507937 C243.2,67.2507937 255.187302,79.2380952 255.187302,94.0698413 C255.187302,108.901587 243.2,120.888889 228.368254,120.888889 L201.549206,120.888889 L201.549206,94.0698413 Z M188.139683,94.0698413 C188.139683,108.901587 176.152381,120.888889 161.320635,120.888889 C146.488889,120.888889 134.501587,108.901587 134.501587,94.0698413 L134.501587,26.8190476 C134.501587,11.9873016 146.488889,0 161.320635,0 C176.152381,0 188.139683,11.9873016 188.139683,26.8190476 L188.139683,94.0698413 Z"/>',
'<path fill="#ECB22E" d="M161.320635,201.549206 C176.152381,201.549206 188.139683,213.536508 188.139683,228.368254 C188.139683,243.2 176.152381,255.187302 161.320635,255.187302 C146.488889,255.187302 134.501587,243.2 134.501587,228.368254 L134.501587,201.549206 L161.320635,201.549206 Z M161.320635,188.139683 C146.488889,188.139683 134.501587,176.152381 134.501587,161.320635 C134.501587,146.488889 146.488889,134.501587 161.320635,134.501587 L228.571429,134.501587 C243.403175,134.501587 255.390476,146.488889 255.390476,161.320635 C255.390476,176.152381 243.403175,188.139683 228.571429,188.139683 L161.320635,188.139683 Z"/>',
].join(""),
};
const CLOSE: RawBrandLogo = {
viewBox: "0 0 256 256",
inner: [
'<path fill="#49BC82" d="M194.210366,167.138117 C194.702444,163.603922 195.122802,160.002988 195.473432,156.342288 C191.673276,160.943315 187.635051,165.522428 183.371704,170.057712 C187.129027,169.173167 190.742911,168.196981 194.210366,167.138117 Z"/>',
'<path fill="#49BC82" d="M62.5665992,90.0532296 C62.061572,93.5605292 61.6222879,97.1285914 61.2607004,100.758412 C65.0040778,96.183284 68.9885136,91.6350506 73.1990661,87.1376187 C69.5164514,88.0251518 65.9762802,89.0023346 62.5665992,90.0532296 Z"/>',
'<path fill="#57B6DF" d="M196.616965,118.830817 C198.657992,115.52772 200.532669,112.261479 202.24,109.042054 C200.058521,106.159315 197.776436,103.28056 195.400716,100.409774 C196.005354,106.402366 196.413759,112.552342 196.616965,118.830817 Z"/>',
'<path fill="#57B6DF" d="M54.6455409,147.619362 C56.7363735,150.419424 58.9188482,153.215502 61.1939611,156.001619 C60.6441089,150.173385 60.2705681,144.197728 60.0803113,138.099549 C58.1129961,141.310008 56.3090428,144.484607 54.6455409,147.619362 Z"/>',
'<path fill="#49BC82" d="M166.666957,194.529121 C165.091113,199.548514 163.351907,204.264093 161.460296,208.626054 C158.456031,215.553992 155.08221,221.598381 151.440436,226.664591 C144.526444,236.285012 136.646226,242.344342 128.456218,243.934132 C119.829914,242.078381 111.822195,235.981198 104.926132,226.44744 C98.1436265,228.993494 91.5185058,230.800436 85.138428,231.807502 C87.1983813,234.659362 89.3768716,237.304031 91.6868482,239.695689 C101.616062,249.974537 110.287191,255.83365 128.349634,255.83365 C146.413074,255.83365 156.642117,249.20056 166.893074,237.665619 C168.436047,235.929401 169.913276,234.068669 171.338708,232.1153 C175.145837,226.897681 178.533603,220.935969 181.492047,214.328778 C184.777214,206.987455 187.534444,198.856218 189.763735,190.09544 C182.471222,191.940233 174.743409,193.419455 166.666957,194.529121 Z"/>',
'<path fill="#49BC82" d="M166.515549,19.4261167 C156.189883,7.62820233 146.199907,0.829758755 128.349634,0.829758755 C110.499362,0.829758755 104.15116,5.85313619 94.6811518,14.756358 C91.4497743,17.7944903 88.4355486,21.2978054 85.6305058,25.1945837 C81.9757821,30.2737432 78.6886226,36.0402179 75.7839689,42.3993774 C72.3314553,49.9558599 69.4188327,58.3421012 67.0700078,67.3957354 C74.303751,65.5081089 81.9608405,63.9800778 89.9615875,62.8255875 C91.5772763,57.591035 93.3722646,52.681214 95.3336031,48.1519066 C98.2522023,41.4122335 101.528405,35.5053074 105.065588,30.5068327 C112.054288,20.6314086 120.072965,14.3419767 128.456218,12.5181012 C136.767751,14.0610739 144.760529,20.2538833 151.748233,30.1243268 C158.281712,27.648 164.670755,25.8858833 170.848623,24.886786 C169.45407,22.9692763 168.014693,21.1394241 166.515549,19.4261167 Z"/>',
'<path fill="#276392" d="M166.933914,62.7269728 C163.485385,62.2488405 159.974101,61.8384436 156.406039,61.4957821 C160.91144,65.1893541 165.389946,69.1150195 169.818646,73.261821 C168.940078,69.6150661 167.972856,66.1057743 166.933914,62.7269728 Z"/>',
'<path fill="#276392" d="M89.8480311,194.369743 C93.4419922,194.890708 97.1007004,195.340949 100.825152,195.70951 C96.1534008,191.858553 91.5035642,187.762553 86.8985525,183.434459 C87.7920623,187.227642 88.7782101,190.873401 89.8480311,194.369743 Z"/>',
'<path fill="#57B6DF" d="M232.914179,178.388171 C232.781696,176.131984 232.520716,173.836949 232.164109,171.515019 C231.181946,165.125977 229.375004,158.506833 226.802054,151.733292 C223.906366,144.111066 220.04744,136.296591 215.348794,128.408405 C211.504809,134.874148 207.092047,141.390693 202.171268,147.892296 C204.678475,152.681588 206.834054,157.365292 208.616093,161.900576 C211.380296,168.931113 213.257961,175.599066 214.246101,181.757012 C216.106833,193.35172 214.805914,203.127533 210.168031,209.999689 C202.824716,214.742163 192.97021,216.115798 181.492047,214.328778 C178.533603,220.935969 175.145837,226.897681 171.338708,232.1153 C174.68165,232.638257 177.965821,232.947051 181.170304,233.002833 C195.460482,233.249868 205.735346,231.261634 218.507455,218.489525 C231.279564,205.717416 233.821634,193.792996 232.914179,178.388171 Z"/>',
'<path fill="#57B6DF" d="M78.3290272,24.3369339 C62.6851362,23.296 50.8145058,25.5521868 38.1918132,38.1748794 C25.5701167,50.7965759 24.6327782,58.8371673 24.2323424,71.8294163 C24.0938833,76.3228638 24.4494942,80.9976031 25.2443891,85.8038288 C26.2654008,91.9737276 28.0295097,98.3687471 30.4799377,104.910195 C33.3537121,112.583222 37.1678132,120.455471 41.8276109,128.39944 C45.6158132,121.954615 49.9558599,115.464965 54.80193,108.998226 C52.3186304,104.283642 50.1660389,99.6656809 48.3869883,95.1921556 C45.6735875,88.3688093 43.8018988,81.8811518 42.7500078,75.8457276 C40.6551907,63.8236887 41.8644669,53.6265214 46.5322335,46.3638911 C53.5657588,41.5327626 63.7171051,40.2796576 75.7839689,42.3993774 C78.6886226,36.0402179 81.9757821,30.2737432 85.6305058,25.1945837 C83.1601556,24.7921556 80.7216809,24.4963113 78.3290272,24.3369339 Z"/>',
'<path fill="#49BC82" d="M195.400716,100.409774 C195.051082,96.9492918 194.6367,93.5416031 194.156576,90.194677 C192.996109,82.1082646 191.453136,74.3724825 189.543595,67.0680156 C187.253541,58.304249 184.436545,50.1650428 181.108545,42.8087782 C175.084078,43.8258054 168.582475,45.6805603 161.736218,48.3790195 C163.628825,52.8176809 165.364047,57.6209183 166.933914,62.7269728 C167.972856,66.1057743 168.940078,69.6150661 169.818646,73.261821 C170.717136,76.9892607 171.525977,80.8521712 172.241183,84.8395953 C173.606848,92.4418988 174.620887,100.50042 175.240467,108.938459 C175.697681,115.173105 175.950693,121.602988 175.950693,128.226117 C175.950693,134.904031 175.693696,141.382724 175.228514,147.661198 C180.828638,141.198444 185.880903,134.748638 190.331518,128.391471 C192.581728,125.176031 194.666584,121.989479 196.616965,118.830817 C196.413759,112.552342 196.005354,106.402366 195.400716,100.409774 Z"/>',
'<path fill="#49BC82" d="M89.8480311,194.369743 C88.7782101,190.873401 87.7920623,187.227642 86.8985525,183.434459 C86.0508638,179.834521 85.2778833,176.116047 84.6005292,172.265089 C83.2388482,164.527315 82.2447315,156.289494 81.6400934,147.643268 C81.2018054,141.378739 80.9617432,134.906023 80.9617432,128.226117 C80.9617432,121.55916 81.215751,115.097401 81.6749572,108.84084 C76.0598911,115.324514 71.0096187,121.819144 66.5390817,128.23807 C64.2330895,131.551128 62.0765136,134.841276 60.0803113,138.099549 C60.2705681,144.197728 60.6441089,150.173385 61.1939611,156.001619 C61.5425992,159.695191 61.9569805,163.332981 62.4490584,166.898054 C63.5527471,174.88884 65.0210117,182.534973 66.8438911,189.757759 C69.0871284,198.640062 71.8732451,206.874895 75.1982568,214.304872 C81.3671595,213.279875 88.0191751,211.388265 95.0248093,208.609121 C93.1322023,204.206319 91.4019611,199.447907 89.8480311,194.369743 Z"/>',
'<path fill="#FABB66" d="M109.1327,54.600716 C106.19916,56.8180545 103.268607,59.1389883 100.347019,61.5565447 C106.464125,60.9389572 112.744591,60.5245759 119.159533,60.3253541 C115.775751,58.2424903 112.431813,56.3449027 109.1327,54.600716 Z"/>',
'<path fill="#FABB66" d="M46.3001401,210.425027 C41.4700078,203.391502 40.2159066,193.240156 42.3366226,181.173292 C35.977463,178.268638 30.2099922,174.981479 25.1318288,171.325759 C24.7294008,173.797105 24.4325603,176.23558 24.2731829,178.628233 C23.232249,194.272125 25.4894319,206.142755 38.1111284,218.764451 C50.7328249,231.386148 58.7734163,232.323486 71.7656654,232.724918 C76.0718444,232.857401 80.5433774,232.533665 85.138428,231.807502 C81.416965,226.656623 78.0999222,220.792529 75.1982568,214.304872 C63.4312218,216.259237 53.4452296,215.016093 46.3001401,210.425027 Z"/>',
'<path fill="#FABB66" d="M108.950412,202.146366 C104.193992,204.653572 99.5361868,206.819113 95.0248093,208.609121 C97.9643268,215.44442 101.290335,221.42207 104.926132,226.44744 C112.53242,223.592591 120.332949,219.813354 128.206195,215.204358 C121.814163,211.385276 115.375315,207.015346 108.950412,202.146366 Z"/>',
'<path fill="#FABB66" d="M147.712,202.194179 C150.460265,200.13821 153.203549,197.996576 155.938864,195.7633 C150.234148,196.301198 144.387984,196.668763 138.42428,196.863004 C141.557043,198.789479 144.654942,200.567533 147.712,202.194179 Z"/>',
'<path fill="#FABB66" d="M218.425774,38.4498054 C205.653665,25.6776965 193.730241,23.1346304 178.32442,24.0420856 C175.872996,24.1865214 173.376747,24.4783813 170.848623,24.886786 C164.670755,25.8858833 158.281712,27.648 151.748233,30.1243268 C144.057276,33.0399377 136.171082,36.936716 128.210179,41.6881556 C134.729712,45.5022568 141.297058,49.8811518 147.84249,54.7780233 C152.590942,52.2927315 157.236794,50.1520934 161.736218,48.3790195 C168.582475,45.6805603 175.084078,43.8258054 181.108545,42.8087782 C192.959253,40.8085914 202.94923,42.0736498 209.935938,46.7892296 C214.679409,54.1315486 216.052047,63.9870506 214.265027,75.465214 C220.872218,78.4236576 226.83393,81.810428 232.052545,85.6185525 C232.575502,82.2746148 232.884296,78.9904436 232.939082,75.7859611 C233.187113,61.4967782 231.198879,51.2219144 218.425774,38.4498054 Z"/>',
'<path fill="#FABB66" d="M208.562304,95.496965 C206.80716,99.8908016 204.693416,104.417121 202.24,109.042054 C200.532669,112.261479 198.657992,115.52772 196.616965,118.830817 C194.666584,121.989479 192.581728,125.176031 190.331518,128.391471 C185.880903,134.748638 180.828638,141.198444 175.228514,147.661198 C171.029914,152.507268 166.532482,157.361307 161.70235,162.190444 C157.01765,166.875144 152.299082,171.240093 147.579518,175.317167 C156.190879,174.714521 164.394833,173.725385 172.105712,172.371673 C175.990537,171.690335 179.740887,170.91337 183.371704,170.057712 C187.635051,165.522428 191.673276,160.943315 195.473432,156.342288 C197.795362,153.531268 200.035611,150.713276 202.171268,147.892296 C207.092047,141.390693 211.504809,134.874148 215.348794,128.408405 C219.951813,120.668638 223.735035,113.001588 226.60084,105.515829 C221.535626,101.875051 215.490241,98.5012296 208.562304,95.496965 Z"/>',
'<path fill="#FABB66" d="M84.6802179,84.7320156 C80.7266615,85.4432374 76.8966226,86.2470973 73.1990661,87.1376187 C68.9885136,91.6350506 65.0040778,96.183284 61.2607004,100.758412 C59.0204514,103.497712 56.8638755,106.244981 54.80193,108.998226 C49.9558599,115.464965 45.6158132,121.954615 41.8276109,128.39944 C37.1588482,136.343409 33.3288093,144.216654 30.4440778,151.890677 C35.4425525,155.428856 41.3484825,158.705058 48.0891518,161.623658 C49.9080467,157.088374 52.1074553,152.402677 54.6455409,147.619362 C56.3090428,144.484607 58.1129961,141.310008 60.0803113,138.099549 C62.0765136,134.841276 64.2330895,131.551128 66.5390817,128.23807 C71.0096187,121.819144 76.0598911,115.324514 81.6749572,108.84084 C85.6803113,104.216903 89.9585992,99.5999377 94.5347237,95.0238132 C99.2851673,90.2723735 104.078444,85.8486537 108.875704,81.7167938 C100.401805,82.3383658 92.3104125,83.3583813 84.6802179,84.7320156 Z"/>',
'<path fill="#276392" d="M237.601868,90.0631907 C235.86565,88.5202179 234.004918,87.0429883 232.052545,85.6185525 C226.83393,81.810428 220.872218,78.4236576 214.265027,75.465214 C206.788233,72.1182879 198.491642,69.3192218 189.543595,67.0680156 C191.453136,74.3724825 192.996109,82.1082646 194.156576,90.194677 C199.291518,91.7954241 204.110693,93.5665058 208.562304,95.496965 C215.490241,98.5012296 221.535626,101.875051 226.60084,105.515829 C236.221261,112.430817 242.280591,120.311035 243.870381,128.501043 C242.041525,137.002833 236.098739,144.904965 226.802054,151.733292 C229.375004,158.506833 231.181946,165.125977 232.164109,171.515019 C234.855595,169.544716 237.357821,167.466833 239.631938,165.270412 C249.911782,155.341198 255.770895,146.67007 255.770895,128.60663 C255.770895,110.544187 249.136809,100.315144 237.601868,90.0631907 Z"/>',
'<path fill="#276392" d="M62.4490584,166.898054 C57.3330428,165.307268 52.5278132,163.546148 48.0891518,161.623658 C41.3484825,158.705058 35.4425525,155.428856 30.4440778,151.890677 C20.5686537,144.901977 14.2782257,136.884296 12.4543502,128.501043 C14.0192374,120.070973 20.3624591,111.967626 30.4799377,104.910195 C28.0295097,98.3687471 26.2654008,91.9737276 25.2443891,85.8038288 C23.1714864,87.2910195 21.2011829,88.8329961 19.3633619,90.4417121 C7.56544747,100.767377 0.767003891,110.757354 0.767003891,128.60663 C0.767003891,146.457899 5.78938521,152.805105 14.692607,162.276109 C17.7307393,165.507486 21.2350506,168.521712 25.1318288,171.325759 C30.2099922,174.981479 35.977463,178.268638 42.3366226,181.173292 C49.755642,184.563051 57.9775253,187.431844 66.8438911,189.757759 C65.0210117,182.534973 63.5527471,174.88884 62.4490584,166.898054 Z"/>',
'<path fill="#276392" d="M128.539891,66.4763268 C125.390195,64.2888716 122.26042,62.2329027 119.159533,60.3253541 C112.744591,60.5245759 106.464125,60.9389572 100.347019,61.5565447 C96.8277665,61.9121556 93.3642957,62.3345058 89.9615875,62.8255875 C81.9608405,63.9800778 74.303751,65.5081089 67.0700078,67.3957354 C58.2833307,69.6887782 50.1232062,72.5107549 42.7500078,75.8457276 C43.8018988,81.8811518 45.6735875,88.3688093 48.3869883,95.1921556 C52.7788327,93.3224591 57.5242957,91.6061634 62.5665992,90.0532296 C65.9762802,89.0023346 69.5164514,88.0251518 73.1990661,87.1376187 C76.8966226,86.2470973 80.7266615,85.4432374 84.6802179,84.7320156 C92.3104125,83.3583813 100.401805,82.3383658 108.875704,81.7167938 C115.109354,81.2585837 121.539237,81.0065681 128.162366,81.0065681 C135.038506,81.0065681 141.703471,81.2795019 148.155268,81.7715798 C141.598879,76.0907704 135.029541,70.9857121 128.539891,66.4763268 Z"/>',
'<path fill="#276392" d="M208.616093,161.900576 C204.166475,163.818086 199.351284,165.568249 194.210366,167.138117 C190.742911,168.196981 187.129027,169.173167 183.371704,170.057712 C179.740887,170.91337 175.990537,171.690335 172.105712,172.371673 C164.394833,173.725385 156.190879,174.714521 147.579518,175.317167 C141.314988,175.755455 134.843268,175.995518 128.162366,175.995518 C121.693634,175.995518 115.417152,175.758444 109.333914,175.325136 C115.723953,180.859518 122.100047,185.856 128.388482,190.26677 C131.764296,192.635518 135.111222,194.824965 138.42428,196.863004 C144.387984,196.668763 150.234148,196.301198 155.938864,195.7633 C159.573665,195.419642 163.155673,195.012233 166.666957,194.529121 C174.743409,193.419455 182.471222,191.940233 189.763735,190.09544 C198.622132,187.855191 206.834054,185.073058 214.246101,181.757012 C213.257961,175.599066 211.380296,168.931113 208.616093,161.900576 Z"/>',
'<path fill="#57B6DF" d="M147.712,202.194179 C144.654942,200.567533 141.557043,198.789479 138.42428,196.863004 C135.111222,194.824965 131.764296,192.635518 128.388482,190.26677 C122.100047,185.856 115.723953,180.859518 109.333914,175.325136 C104.474895,171.116576 99.6089027,166.608187 94.765821,161.765105 C90.0821167,157.081401 85.7171673,152.362833 81.6400934,147.643268 C82.2447315,156.289494 83.2388482,164.527315 84.6005292,172.265089 C85.2778833,176.116047 86.0508638,179.834521 86.8985525,183.434459 C91.5035642,187.762553 96.1534008,191.858553 100.825152,195.70951 C103.527595,197.937805 106.238008,200.090397 108.950412,202.146366 C115.375315,207.015346 121.814163,211.385276 128.206195,215.204358 C136.062506,219.899019 143.845105,223.755953 151.440436,226.664591 C155.08221,221.598381 158.456031,215.553992 161.460296,208.626054 C157.001712,206.845012 152.406661,204.693416 147.712,202.194179 Z"/>',
'<path fill="#57B6DF" d="M172.241183,84.8395953 C171.525977,80.8521712 170.717136,76.9892607 169.818646,73.261821 C165.389946,69.1150195 160.91144,65.1893541 156.406039,61.4957821 C153.560156,59.1618988 150.703315,56.9196576 147.84249,54.7780233 C141.297058,49.8811518 134.729712,45.5022568 128.210179,41.6881556 C120.383751,37.1120311 112.628047,33.3497276 105.065588,30.5068327 C101.528405,35.5053074 98.2522023,41.4122335 95.3336031,48.1519066 C99.803144,49.9449027 104.420109,52.1094475 109.1327,54.600716 C112.431813,56.3449027 115.775751,58.2424903 119.159533,60.3253541 C122.26042,62.2329027 125.390195,64.2888716 128.539891,66.4763268 C135.029541,70.9857121 141.598879,76.0907704 148.155268,81.7715798 C152.765261,85.7669728 157.369276,90.0343035 161.933447,94.5984747 C166.683891,99.3489183 171.108607,104.141198 175.240467,108.938459 C174.620887,100.50042 173.606848,92.4418988 172.241183,84.8395953 Z"/>',
].join(""),
};
// Pipedrive's real monogram (the official "P" mark) in white on a Green Haze
// (#08A742) tile, matching its app icon. Full-bleed: the green fills the tile.
const PIPEDRIVE: RawBrandLogo = {
viewBox: "0 0 304 304",
fullBleed: true,
inner: [
'<rect width="304" height="304" fill="#08A742"/>',
'<g transform="translate(67,44)"><path fill="#fff" d="M59.6807,81.1772 C59.6807,101.5343 70.0078,123.4949 92.7336,123.4949 C109.5872,123.4949 126.6277,110.3374 126.6277,80.8785 C126.6277,55.0508 113.232,37.7119 93.2944,37.7119 C77.0483,37.7119 59.6807,49.1244 59.6807,81.1772 Z M101.3006,0 C142.0482,0 169.4469,32.2728 169.4469,80.3126 C169.4469,127.5978 140.584,160.60942 99.3224,160.60942 C79.6495,160.60942 67.0483,152.1836 60.4595,146.0843 C60.5063,147.5305 60.5374,149.1497 60.5374,150.8788 L60.5374,215 L18.32565,215 L18.32565,44.157 C18.32565,41.6732 17.53126,40.8873 15.07021,40.8873 L0.5531,40.8873 L0.5531,3.4741 L35.9736,3.4741 C52.282,3.4741 56.4564,11.7741 57.2508,18.1721 C63.8708,10.7524 77.5935,0 101.3006,0 Z"/></g>',
].join(""),
};
export const RAW_BRAND_LOGOS: Record<string, RawBrandLogo> = {
slack: SLACK,
close: CLOSE,
pipedrive: PIPEDRIVE,
};
+295
View File
@@ -0,0 +1,295 @@
/**
* Demo cursor engine shared by every product mock on the marketing site.
*
* A scene is a timeline: waypoints the cursor must reach by a given time,
* presses, and class toggles that switch the mock into its next state (a
* drawer open, a toast shown). The mock's own CSS turns those classes into
* the real app's transitions, so each reaction moves exactly like the
* dashboard component it mirrors.
*
* The motion itself is what makes it read as a hand and not a keyframe:
* the cursor rests, then leaves late and lands on time on a minimum-jerk
* velocity profile (the curve human reaching movements follow), along a
* slight arc that alternates sides, with a hover beat before every press.
*/
export type Step = {
// Milliseconds into the loop.
at: number;
// Waypoint the cursor arrives at by `at`, in percent of the root box
// (or px when the scene says so).
x?: number;
y?: number;
// Or an element inside the root; ox/oy pick the spot within its box
// (fractions, default centre). Measured live, so it survives scaling.
el?: string;
ox?: number;
oy?: number;
// Reach this waypoint in a straight line (a drag) and use the whole
// segment for the move instead of resting first.
straight?: boolean;
slow?: boolean;
// Element that gets `.is-hover` while the cursor rests on this waypoint.
hover?: string;
// Press here: the pointer dips, a ripple fires, `target` gets `.is-pressed`.
press?: boolean;
target?: string;
// Hold the button down from here (a drag) until a step with `up`.
down?: boolean;
up?: boolean;
// State switches on the root (or on the `on` element), applied at `at`
// and undone when the loop restarts.
add?: string[];
remove?: string[];
on?: string;
};
export type Scene = {
loop: number;
steps: Step[];
unit?: '%' | 'px';
};
// Minimum-jerk profile: 10u^3 - 15u^4 + 6u^5.
const jerk = (u: number) => u * u * u * (10 + u * (-15 + 6 * u));
const PRESS_MS = 110;
const PRESSED_MS = 160;
const HOVER_BEAT_MS = 140;
export type Player = { stop: () => void; restart: () => void };
const noop: Player = { stop() {}, restart() {} };
export function playCursor(root: HTMLElement, scene: Scene): Player {
const cursor = root.querySelector<HTMLElement>('.demo-cursor');
if (!cursor) return noop;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.classList.add('is-static');
return noop;
}
const steps = [...scene.steps].sort((a, b) => a.at - b.at);
const points = steps.filter((s) => s.el || (s.x != null && s.y != null));
const ripple = cursor.querySelector<HTMLElement>('.demo-ripple');
// The stage is the app window the mock sits in (the shell marks itself
// with data-stage); the cursor is fixed inside it, so it can cross the
// sidebar and header to reach a dialog that covers the whole screen.
const stage = root.closest<HTMLElement>('[data-stage]') ?? root;
// Stage px for a waypoint, or null while its element is not on screen yet
// (a close button inside a panel that has not mounted).
const toPx = (p: Step): [number, number] | null => {
const sr = stage.getBoundingClientRect();
// Undo the scale the page applies to the whole window.
const k = sr.width ? stage.offsetWidth / sr.width : 1;
if (p.el) {
const el = root.querySelector(p.el);
if (!el) return null;
const r = el.getBoundingClientRect();
if (!r.width && !r.height) return null;
return [
(r.left - sr.left + r.width * (p.ox ?? 0.5)) * k,
(r.top - sr.top + r.height * (p.oy ?? 0.5)) * k,
];
}
const rr = root.getBoundingClientRect();
const ox = (rr.left - sr.left) * k;
const oy = (rr.top - sr.top) * k;
return scene.unit === 'px'
? [ox + (p.x ?? 0), oy + (p.y ?? 0)]
: [ox + ((p.x ?? 0) / 100) * root.offsetWidth, oy + ((p.y ?? 0) / 100) * root.offsetHeight];
};
let last: [number, number] = [0, 0];
// Segment k runs from points[k] to points[k+1]: rest, then move so the
// landing happens on time with a beat to spare before any press.
function positionAt(t: number): [number, number, number] {
let k = 0;
while (k + 1 < points.length && points[k + 1].at <= t) k++;
const a = points[k];
const b = points[k + 1];
if (!a) return [0, 0, -1];
const [ax, ay] = toPx(a) ?? last;
if (!b) return [ax, ay, k];
const pb = toPx(b);
// Hold until the target exists; nothing to aim at yet.
if (!pb) return [ax, ay, k];
const [bx, by] = pb;
const dist = Math.hypot(bx - ax, by - ay);
const avail = b.at - a.at;
const dur = b.slow
? avail - HOVER_BEAT_MS
: Math.min(avail - HOVER_BEAT_MS, Math.max(420, 360 + dist * 1.1));
if (dur <= 0) return t >= b.at ? [bx, by, k + 1] : [ax, ay, k];
const start = b.at - HOVER_BEAT_MS - dur;
if (t <= start) return [ax, ay, k];
if (t >= start + dur) return [bx, by, k + 1];
const u = jerk((t - start) / dur);
// A gentle arc: control point off the chord, alternating sides.
const side = k % 2 ? 1 : -1;
const nx = dist ? -(by - ay) / dist : 0;
const ny = dist ? (bx - ax) / dist : 0;
const amp = b.straight ? 0 : Math.min(40, dist * 0.09) * side;
const cx = (ax + bx) / 2 + nx * amp;
const cy = (ay + by) / 2 + ny * amp;
const w = 1 - u;
return [w * w * ax + 2 * w * u * cx + u * u * bx, w * w * ay + 2 * w * u * cy + u * u * by, -1];
}
let raf = 0;
let t0 = 0;
let running = false;
let fired = new Set<Step>();
let hoverEl: Element | null = null;
let hoverIdx = -1;
const added: { el: Element; cls: string }[] = [];
const removed: { el: Element; cls: string }[] = [];
const timers: number[] = [];
const resolve = (sel?: string) => (sel ? root.querySelector(sel) : root);
function fire(s: Step) {
if (s.down) {
cursor.classList.add('is-down');
if (ripple) {
ripple.classList.remove('is-rip');
void ripple.offsetWidth;
ripple.classList.add('is-rip');
}
}
if (s.up) cursor.classList.remove('is-down');
if (s.press) {
cursor.classList.add('is-down');
timers.push(window.setTimeout(() => cursor.classList.remove('is-down'), PRESS_MS));
if (ripple) {
ripple.classList.remove('is-rip');
void ripple.offsetWidth;
ripple.classList.add('is-rip');
}
const target = s.target ? root.querySelector(s.target) : null;
if (target) {
target.classList.add('is-pressed');
timers.push(window.setTimeout(() => target.classList.remove('is-pressed'), PRESSED_MS));
}
}
const el = resolve(s.on);
if (!el) return;
// Book-keeping so the loop reset lands back on the markup's own state:
// a class added then removed in one loop needs no undo at all.
const drop = (list: { el: Element; cls: string }[], cls: string) => {
const i = list.findIndex((x) => x.el === el && x.cls === cls);
if (i >= 0) list.splice(i, 1);
return i >= 0;
};
for (const c of s.add ?? []) {
if (!drop(removed, c) && !el.classList.contains(c)) added.push({ el, cls: c });
el.classList.add(c);
}
for (const c of s.remove ?? []) {
if (!drop(added, c) && el.classList.contains(c)) removed.push({ el, cls: c });
el.classList.remove(c);
}
}
function reset() {
for (const t of timers) clearTimeout(t);
timers.length = 0;
for (const { el, cls } of added) el.classList.remove(cls);
for (const { el, cls } of removed) el.classList.add(cls);
added.length = 0;
removed.length = 0;
hoverEl?.classList.remove('is-hover');
hoverEl = null;
hoverIdx = -1;
cursor.classList.remove('is-down');
ripple?.classList.remove('is-rip');
fired = new Set();
}
function restartCssTimeline() {
// Mocks that keep keyframe reactions key them on .is-playing, so the
// remove-reflow-add restarts them in step with the cursor.
root.classList.remove('is-playing');
void root.offsetWidth;
root.classList.add('is-playing');
}
function frame(now: number) {
if (!running) return;
let t = now - t0;
if (t >= scene.loop) {
reset();
t0 = now;
t = 0;
restartCssTimeline();
}
for (const s of steps) {
if (s.at <= t && !fired.has(s)) {
fired.add(s);
fire(s);
}
}
const [x, y, atIdx] = positionAt(t);
last = [x, y];
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
if (atIdx !== hoverIdx) {
hoverEl?.classList.remove('is-hover');
hoverEl = null;
hoverIdx = atIdx;
const sel = atIdx >= 0 ? points[atIdx]?.hover : undefined;
if (sel) {
hoverEl = root.querySelector(sel);
hoverEl?.classList.add('is-hover');
}
}
raf = requestAnimationFrame(frame);
}
function start() {
if (running) return;
running = true;
reset();
t0 = performance.now();
restartCssTimeline();
raf = requestAnimationFrame(frame);
}
function stop() {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
reset();
root.classList.remove('is-playing');
}
let onScreen = false;
const sync = () => {
if (onScreen && !document.hidden) start();
else stop();
};
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((e) => e.isIntersecting);
sync();
},
{ threshold: 0.2 },
);
io.observe(root);
document.addEventListener('visibilitychange', sync);
return {
stop() {
io.disconnect();
document.removeEventListener('visibilitychange', sync);
stop();
},
restart() {
stop();
sync();
},
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Grain gradient palettes for the product frames.
*
* One blue family, held at a few close tints so the grain reads as light over
* a surface rather than as colour. The variants exist to CONTRAST with the
* band behind them, not to match it: a frame the same colour as its section
* disappears. Dark bands get the light variant, light and bright bands get
* the deeper ones, and on a whiteish band the frame stays blue.
*
* `frame` seeds the animation, so two frames sharing a palette start apart.
*/
export interface GrainToneSpec {
back: string;
/** Up to 7; one hue only. */
colors: string[];
frame: number;
/** CSS gradient painted underneath, for no-WebGL. */
css: string;
}
export const GRAIN_TONES = {
/** Light blue. For the dark bands (navy, wallpaper). */
light: {
back: '#4aa6d8',
colors: ['#66b9e6', '#87cdf1', '#a9def8', '#cdeefd'],
frame: 1200,
css: 'radial-gradient(90% 80% at 20% 0%, #a9def8 0%, rgba(169,222,248,0) 62%), linear-gradient(160deg, #87cdf1 0%, #5fb4e3 50%, #4aa6d8 100%)',
},
/** Mid blue. The default, and what sits on a whiteish band. */
mid: {
back: '#2f8ac6',
colors: ['#4aa1da', '#6cb9e9', '#8fcef4', '#b6e3fb'],
frame: 4800,
css: 'radial-gradient(85% 75% at 80% 4%, #8fcef4 0%, rgba(143,206,244,0) 58%), linear-gradient(165deg, #6cb9e9 0%, #4098d2 52%, #2f8ac6 100%)',
},
/** Deep blue. For the bright sky band, which the mid tone would blend into. */
deep: {
back: '#12456f',
colors: ['#1c5c92', '#2a76b2', '#3c90cd', '#55a9e0'],
frame: 9100,
css: 'radial-gradient(80% 70% at 76% 4%, #3c90cd 0%, rgba(60,144,205,0) 60%), linear-gradient(168deg, #2a76b2 0%, #17527f 55%, #12456f 100%)',
},
} as const satisfies Record<string, GrainToneSpec>;
export type GrainTone = keyof typeof GRAIN_TONES;
+11
View File
@@ -0,0 +1,11 @@
let n = 0;
/**
* Per-instance id suffix for components that carry inline SVG <defs>.
* Several app-shell mocks render on one page, and a repeated gradient id
* would make every later sparkline resolve to the first one's definition.
*/
export function nextId(prefix: string): string {
n += 1;
return `${prefix}-${n}`;
}
+131 -198
View File
@@ -8,10 +8,12 @@ import CampaignBuilderMock from '../components/CampaignBuilderMock.astro';
import ScheduleMock from '../components/ScheduleMock.astro';
import UniboxMock from '../components/UniboxMock.astro';
import AnalyticsMock from '../components/AnalyticsMock.astro';
import AIAssistantMock from '../components/AIAssistantMock.astro';
import IntegrationsMock from '../components/IntegrationsMock.astro';
import DeveloperMock from '../components/DeveloperMock.astro';
import CRMMock from '../components/CRMMock.astro';
import FormsMock from '../components/FormsMock.astro';
import FormBuilderMock from '../components/FormBuilderMock.astro';
import HostedFormMock from '../components/HostedFormMock.astro';
import TemplatingShowcase from '../components/TemplatingShowcase.astro';
import ChangelogShowcase from '../components/ChangelogShowcase.astro';
import OpenSourceShowcase from '../components/OpenSourceShowcase.astro';
@@ -19,8 +21,6 @@ import AutomationsShowcase from '../components/AutomationsShowcase.astro';
import MobileAppShowcase from '../components/MobileAppShowcase.astro';
import BentoGrid from '../components/BentoGrid.astro';
// "How we differ": warmup ramp bars for the guardrails card (10 -> 40, +1/day).
const diffRamp = [25, 32, 40, 47, 55, 62, 70, 77, 85, 92, 100];
// Mirrors web/src/lib/plans.ts — single source of truth.
const plans = [
@@ -177,24 +177,22 @@ const softwareJsonLd = {
Star on GitHub
</a>
</div>
<p class="mt-4 text-[12.5px] text-white/65">
Free forever when you self-host. When Warmbly Cloud launches, a hosted workspace starts free with 10 mailboxes and warmup, and linked instances get their warmup run for them.
</p>
<ul class="mt-5 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-[13.5px] md:text-[14px] text-white/85">
<li class="inline-flex items-center gap-1.5"><Icon name="check" size={14} class="shrink-0 text-emerald-300" />Free to self-host</li>
<li class="inline-flex items-center gap-1.5"><Icon name="check" size={14} class="shrink-0 text-emerald-300" />Free plan with 10 mailboxes</li>
<li class="inline-flex items-center gap-1.5"><Icon name="check" size={14} class="shrink-0 text-emerald-300" />No credit card</li>
</ul>
</div>
<!-- Dashboard mock — full width inside container, overlaps down -->
<!-- Dashboard mock — full width inside container, overlaps down. -->
<div class="container-page relative pt-6 md:pt-10 -mb-10 md:-mb-[200px] lg:-mb-[240px]">
<div
class="relative rounded-[14px] overflow-hidden ring-1 ring-black/[0.08]"
style="box-shadow: var(--shadow-xl);"
>
<!-- Desktop layout, scaled uniformly to fit its width: full-size on
desktop, a clean shrunk-down version on mobile (same scale-to-fit
the feature windows use), so it always shows the real desktop app. -->
<div class="hero-fit relative w-full overflow-hidden">
<div class="hero-screen w-[1360px] absolute top-0 left-0 origin-top-left md:static md:w-full">
<DashboardMock />
</div>
<!-- No mat and no chrome: the whole 1280x800 monitor at md+, uniformly
scaled, sitting straight on the sky. Below md the same desktop shot
bleeds off the right edge instead of shrinking to a thumbnail. -->
<div class="relative w-full rounded-[28px] ring-1 ring-white/20" style="box-shadow: var(--shadow-xl);">
<div class="hero-fit round-clip w-full">
<div class="hero-screen w-[1280px] absolute top-0 left-0 origin-top-left">
<DashboardMock />
</div>
<!-- Floating live notification on top of the mock -->
@@ -217,6 +215,7 @@ const softwareJsonLd = {
</div>
</div>
</div>
</div>
</div>
</section>
@@ -252,15 +251,19 @@ const softwareJsonLd = {
<FeatureSection
eyebrow="Campaigns"
title="Sequences that branch on real behavior."
description="Build a multi-step flow on the canvas, then drag a connector from any step to fork on opens, clicks, and replies. It's the exact builder you'll use in the app."
description="Build the flow on a canvas and fork it on opens, clicks and replies."
side="right"
bg="sky"
url="app.warmbly.com/campaigns/q1-sales/sequences"
nav="Campaigns"
crumb="Q1 sales"
anchor="tr"
zoom={1000}
cursor="a"
callouts={[
{ label: 'Drag to branch', text: 'Pull a link from a step to add an “if opened / clicked / replied” path. No match just stops.' },
{ label: 'A/B at every step', text: 'Weighted subject + body variants; the stronger one keeps sending.' },
{ label: 'Threaded follow-ups', text: 'Follow-ups reply on the previous subject, so they land in the same conversation.' },
{ label: 'Drag to branch', text: 'Pull a link from any step to add a path.' },
{ label: 'A/B at every step', text: 'Weighted variants, the stronger one keeps sending.' },
{ label: 'Threaded follow-ups', text: 'Replies land in the same conversation.' },
]}
cta={{ text: 'Explore campaigns', href: '/campaigns/' }}
>
@@ -270,15 +273,19 @@ const softwareJsonLd = {
<FeatureSection
eyebrow="Scheduling"
title="Send only inside their working hours."
description="A calendar-style editor with full per-day control. Draw a sending window on any weekday, drag to move it, grab an edge to resize. Everything snaps to 30 minutes."
description="Draw a sending window on any weekday. Drag to move it, grab an edge to resize."
side="left"
bg="light"
url="app.warmbly.com/campaigns/q1-sales/schedule"
nav="Campaigns"
crumb="Schedule"
anchor="tl"
zoom={1120}
cursor="b"
callouts={[
{ label: 'Per-day windows', text: 'Independent windows for each day: mornings only, or split shifts. Several per day is fine.' },
{ label: 'Timezone-aware', text: 'Sends follow the chosen timezone; worker IPs spread the volume naturally.' },
{ label: 'One-click presets', text: 'Mon–Fri 9–5 in a tap, then fine-tune by dragging the blocks.' },
{ label: 'Per-day windows', text: 'Mornings only, split shifts, several a day.' },
{ label: 'Timezone-aware', text: 'Sends follow the timezone you pick.' },
{ label: 'One-click presets', text: 'Mon to Fri, 9 to 5, in a tap.' },
]}
cta={{ text: 'See sending controls', href: '/sending/' }}
>
@@ -288,15 +295,18 @@ const softwareJsonLd = {
<FeatureSection
eyebrow="Unified inbox"
title="Every reply, triaged the moment it lands."
description="Replies from every connected mailbox stream into one searchable queue. The classifier tags each one on arrival so you act on the labels that matter."
description="Replies from every mailbox stream into one queue, labeled on arrival."
side="right"
bg="navy"
url="app.warmbly.com/unibox"
nav="Inbox"
anchor="br"
zoom={1000}
cursor="c"
callouts={[
{ label: 'One queue, every mailbox', text: 'Scope by inbox, tag, or status: unread, awaiting reply, snoozed, scheduled.' },
{ label: 'Classified on arrival', text: 'Positive, out-of-office, unsubscribe and bounce are labeled before you open them.' },
{ label: 'Reply in place', text: 'Snooze, schedule, and send from the same pane, keyboard-first.' },
{ label: 'One queue', text: 'Scope by inbox, tag or status.' },
{ label: 'Classified on arrival', text: 'Positive, out of office, unsubscribe, bounce.' },
{ label: 'Reply in place', text: 'Snooze, schedule and send from the same pane.' },
]}
cta={{ text: 'Tour the inbox', href: '/inbox/' }}
>
@@ -306,51 +316,71 @@ const softwareJsonLd = {
<FeatureSection
eyebrow="Analytics"
title="Proof of what actually landed."
description="Deliverability across the whole workspace: sent, opens, clicks, replies and bounces, with per-campaign breakdowns and account health on one screen."
description="Sent, opens, clicks, replies and bounces, with account health on one screen."
side="left"
bg="wallpaper"
url="app.warmbly.com/analytics"
nav="Analytics"
anchor="tl"
zoom={1280}
wide
slides={[{ url: 'app.warmbly.com/analytics?share=1' }]}
cursor="d"
callouts={[
{ label: 'Placement, not vanity', text: 'Track the metrics that move reputation, over 7, 30 or 90 days.' },
{ label: 'Best performers, surfaced', text: 'Top campaigns and account health rank themselves as sends roll in.' },
{ label: 'Shareable report card', text: 'Export a branded image of any range in a single tap.' },
{ label: 'Placement, not vanity', text: 'The metrics that move reputation, over 7, 30 or 90 days.' },
{ label: 'Best performers', text: 'Top campaigns rank themselves as sends roll in.' },
{ label: 'Shareable report', text: 'Export a branded image of any range.' },
]}
cta={{ text: 'View analytics', href: '/analytics/' }}
>
<AnalyticsMock />
<AnalyticsMock demo="off" />
<AnalyticsMock demo="open" slot="slide2" />
</FeatureSection>
<FeatureSection
eyebrow="AI assistant"
title="Draft the whole email from one line."
description="Describe what the email should do and the assistant writes it in your voice, merge fields and tone already in place. Edit inline, regenerate, or keep what works."
eyebrow="AI"
title="AI that writes the email and runs the steps."
description="Ask at the caret and the draft lands where you type. Put a Switch or an AI step in the flow and the model decides each contact's path."
side="right"
bg="sky"
url="app.warmbly.com/campaigns/q1-sales/steps"
nav="Campaigns"
crumb="Steps"
anchor="br"
zoom={1020}
slides={[
{ url: 'app.warmbly.com/campaigns/q1-sales/steps', crumb: 'Steps' },
{ url: 'app.warmbly.com/campaigns/q1-sales/steps', crumb: 'Steps' },
]}
cursor="a"
callouts={[
{ label: 'One-line briefs', text: 'Tell it the goal; get back a ready-to-send draft with variables filled in.' },
{ label: 'Tone presets', text: 'Friendly, professional, concise, persuasive. Switch and regenerate in a click.' },
{ label: 'Credits, no surprises', text: 'Each draft uses one writing credit, shown before you generate.' },
{ label: 'Where you type', text: 'A sparkle on the line, or ⌘J. Undo or run it again.' },
{ label: 'Switch on AI', text: 'A prompt reads each reply and picks a case, every case its own path.' },
{ label: 'AI step', text: 'An agent that tags, tasks and unsubscribes. Only the actions you allow.' },
]}
cta={{ text: 'Try the assistant', href: '/campaigns/' }}
>
<AIAssistantMock />
<CampaignBuilderMock demo="ai" />
<CampaignBuilderMock demo="switch" slot="slide2" />
<CampaignBuilderMock demo="agent" slot="slide3" />
</FeatureSection>
<FeatureSection
eyebrow="Integrations"
title="Plug into the stack you already run."
description="Connect Gmail, Microsoft 365, your CRM, Slack and Zapier over OAuth. Tokens are encrypted at rest and every webhook is HMAC-signed."
description="Gmail, Microsoft 365, your CRM, Slack and Zapier, connected over OAuth."
side="left"
bg="light"
url="app.warmbly.com/integrations"
nav="Integrations"
anchor="tl"
zoom={1280}
wide
cursor="b"
callouts={[
{ label: 'OAuth in two clicks', text: 'Authorize a provider and Warmbly handles the rest. Revoke anytime.' },
{ label: 'Signed webhooks', text: 'Every event is HMAC-signed and HTTPS by default, with SSRF protection.' },
{ label: 'Encrypted at rest', text: 'Secrets are sealed with your data key, never stored in plaintext.' },
{ label: 'OAuth in two clicks', text: 'Authorize once, revoke any time.' },
{ label: 'Signed webhooks', text: 'HMAC-signed and HTTPS by default.' },
{ label: 'Encrypted at rest', text: 'Secrets sealed with your data key.' },
]}
cta={{ text: 'Browse integrations', href: '/integrations/' }}
>
@@ -373,15 +403,19 @@ const softwareJsonLd = {
<FeatureSection
eyebrow="Developers"
title="An API you can build a product on."
description="Everything in the dashboard is a REST endpoint: scoped API keys, idempotent writes, opaque cursors, and HMAC-signed webhooks."
description="Everything in the dashboard is a REST endpoint."
side="right"
bg="navy"
url="app.warmbly.com/api-keys"
nav="API Keys"
anchor="tl"
zoom={1280}
wide
cursor="c"
callouts={[
{ label: 'Scoped keys', text: 'Mint read, write, or send-only keys per environment and rotate without downtime.' },
{ label: 'Idempotent by default', text: 'Send an Idempotency-Key and retries are always safe.' },
{ label: 'Webhooks that verify', text: 'Subscribe to events like contact.replied with signed payloads.' },
{ label: 'Scoped keys', text: 'Read, write or send-only, per environment.' },
{ label: 'Idempotent by default', text: 'Send an Idempotency-Key and retries are safe.' },
{ label: 'Webhooks that verify', text: 'Signed payloads for every event.' },
]}
cta={{ text: 'Read the API docs', href: '/developers/' }}
>
@@ -391,152 +425,57 @@ const softwareJsonLd = {
<FeatureSection
eyebrow="CRM"
title="From positive reply to closed won."
description="When a reply lands positive, Warmbly opens the deal and pauses the sequence on its own. From there you get one timeline that threads every send, open, reply, and stage change back to the exact sequence step that started it."
description="A positive reply opens the deal and pauses the sequence on its own."
side="left"
bg="wallpaper"
url="app.warmbly.com/crm/deals"
nav="Deals"
anchor="tr"
zoom={1280}
wide
cursor="d"
callouts={[
{ label: 'Born from the reply', text: 'A reply classified positive in the unibox opens the deal and pauses the sequence, so nothing keeps sending to someone who already said yes.' },
{ label: 'One timeline, traced to the send', text: 'Every send, open, reply, and stage change lands on the deal, each tagged with the sequence step and mailbox it came from.' },
{ label: 'Pipeline and forecast in one view', text: 'Move deals across your stages on the board while value, weighted total, and probability stay current on the record.' },
{ label: 'Tasks with a type and an owner', text: 'A positive reply spins up a typed follow-up task on the deal, assigned to a teammate with a due date, so the next step never slips.' },
{ label: 'Born from the reply', text: 'Nothing keeps sending to someone who said yes.' },
{ label: 'Board and value', text: 'Stages with their open value at a glance.' },
{ label: 'Edit in place', text: 'Stage, value and close date in one dialog.' },
]}
cta={{ text: 'See the CRM', href: '/crm/' }}
>
<CRMMock />
</FeatureSection>
<FeatureSection
eyebrow="Forms"
title="Lead capture that lands in the same pipeline."
description="Hosted forms whose submissions become contacts, attributed to the send that brought them."
side="right"
bg="sky"
url="app.warmbly.com/forms"
nav="Forms"
anchor="tl"
zoom={1100}
slides={[
{ url: 'app.warmbly.com/forms/book-a-demo', crumb: 'Book a demo' },
{ url: 'forms.northwind.dev/f/book-a-demo', shell: false },
]}
cursor="a"
callouts={[
{ label: 'Themes and layouts', text: 'Card, wide or split, with a brand cover.' },
{ label: 'Personalized links', text: 'Prefilled per contact, no second email ask.' },
{ label: 'Your own domain', text: 'Serve every form from a subdomain you own.' },
]}
cta={{ text: 'Read the forms guide', href: 'https://docs.warmbly.com/guides/forms/' }}
>
<FormsMock />
<FormBuilderMock slot="slide2" />
<HostedFormMock slot="slide3" />
</FeatureSection>
<!-- Changelog, open-source CTA, and Go templating sections after the feature showcase -->
<ChangelogShowcase />
<OpenSourceShowcase />
<TemplatingShowcase />
<!-- ============================================================
COMPARE — serious data table
============================================================ -->
<section class="container-page py-20 md:py-28">
<div class="grid lg:grid-cols-12 gap-12 lg:gap-16 mb-10">
<div class="lg:col-span-5">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">How we differ</div>
<h2 class="text-[34px] md:text-[44px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
Defaults that protect reputation.
</h2>
</div>
<div class="lg:col-span-7">
<p class="text-[15.5px] text-foreground/75 leading-relaxed">
Cold-email platforms typically optimise for volume. Warmbly optimises for sender reputation. The numbers below ship as defaults and the system enforces them.
</p>
</div>
</div>
<!-- Six guardrails, drawn instead of written, joined into one panel the
way pricing is: shared 1px dividers, one ring, no gaps. -->
<div class="diff-panel mt-4 rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_18px_50px_-28px_rgba(15,23,42,0.32)]">
<div class="grid sm:grid-cols-2 lg:grid-cols-3">
<!-- 1 · Daily cap: our fill stops at the cap; the rest of the track
is where others go. -->
<div class="p-5 md:p-6 border-[color:var(--border)]">
<div class="h-[88px] flex flex-col justify-center">
<div class="relative h-2 rounded-full bg-slate-200/70">
<span class="bar-fill absolute inset-y-0 left-0 rounded-full bg-[color:var(--brand)]" data-target="25%"></span>
<span class="absolute left-[25%] top-1/2 -translate-y-1/2 w-[3px] h-4 rounded-full bg-[color:var(--sky-6)]"></span>
</div>
<div class="relative mt-2 h-4 text-[10px] font-mono">
<span class="absolute left-[25%] -translate-x-1/2 text-[color:var(--brand-strong)] font-semibold whitespace-nowrap">50, our cap</span>
<span class="absolute right-0 text-muted-foreground/60">200+, elsewhere</span>
</div>
</div>
<div class="mt-3 text-[15px] font-semibold tracking-[-0.01em] text-heading">50 a day per mailbox</div>
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">The default cap. Raising it takes proof, not a request.</p>
</div>
<!-- 2 · Spacing: even dots vs a burst. -->
<div class="p-5 md:p-6 border-[color:var(--border)] border-t sm:border-t-0 sm:border-l">
<div class="h-[88px] flex flex-col justify-center gap-4">
<div>
<div class="flex items-center justify-between">
{[0, 1, 2, 3, 4, 5].map(() => <span class="w-2 h-2 rounded-full bg-[color:var(--brand)]"></span>)}
</div>
<div class="mt-1.5 text-[10px] font-mono text-[color:var(--brand-strong)] font-semibold">10 minutes apart, always</div>
</div>
<div>
<div class="flex items-center gap-[3px]">
{[0, 1, 2, 3, 4, 5].map(() => <span class="w-2 h-2 rounded-full bg-slate-300"></span>)}
</div>
<div class="mt-1.5 text-[10px] font-mono text-muted-foreground/60">bursts, elsewhere</div>
</div>
</div>
<div class="mt-3 text-[15px] font-semibold tracking-[-0.01em] text-heading">Sends are spaced out</div>
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">600 seconds minimum between two emails from one mailbox.</p>
</div>
<!-- 3 · Quarantine line: one axis, two markers. The whole argument. -->
<div class="p-5 md:p-6 border-[color:var(--border)] border-t lg:border-t-0 lg:border-l">
<div class="h-[88px] flex flex-col justify-center">
<div class="relative">
<div class="h-2 rounded-full" style="background: linear-gradient(90deg, #34d399 0%, #fbbf24 35%, #f43f5e 85%);"></div>
<span class="absolute left-[10%] -translate-x-1/2 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white ring-[2.5px] ring-[color:var(--sky-6)]"></span>
<span class="absolute left-[80%] -translate-x-1/2 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white ring-[2.5px] ring-slate-300"></span>
</div>
<div class="relative mt-2.5 h-8 text-[10px] font-mono">
<span class="absolute left-[10%] -translate-x-1/2 text-center text-[color:var(--brand-strong)] font-semibold leading-tight">10%<br />we quarantine</span>
<span class="absolute left-[80%] -translate-x-1/2 text-center text-muted-foreground/60 leading-tight">80%<br />others react</span>
</div>
</div>
<div class="mt-3 text-[15px] font-semibold tracking-[-0.01em] text-heading">Acts before it hurts</div>
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">A mailbox is pulled at 10% spam placement, not 80%.</p>
</div>
<!-- 4 · Warmup ramp: the staircase. -->
<div class="p-5 md:p-6 border-[color:var(--border)] border-t sm:border-l lg:border-l-0">
<div class="h-[88px] flex flex-col justify-end">
<div class="flex items-end gap-[4px] h-[60px]">
{diffRamp.map((v) => (
<span class="flex-1 rounded-t-[3px] bg-gradient-to-t from-sky-500/90 to-sky-300/90" style={`height:${v}%`}></span>
))}
</div>
<div class="mt-1.5 flex justify-between text-[10px] font-mono">
<span class="text-[color:var(--brand-strong)] font-semibold">10 / day</span>
<span class="text-[color:var(--brand-strong)] font-semibold">40 / day</span>
</div>
</div>
<div class="mt-3 text-[15px] font-semibold tracking-[-0.01em] text-heading">Warmup climbs by one</div>
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">One more email per day. Elsewhere: full volume on day one.</p>
</div>
<!-- 5 · Auth checks: three green pills. -->
<div class="p-5 md:p-6 border-[color:var(--border)] border-t lg:border-l">
<div class="h-[88px] flex flex-col justify-center gap-2">
<div class="flex flex-wrap gap-1.5">
{['SPF', 'DKIM', 'DMARC'].map((c) => (
<span class="inline-flex items-center gap-1 h-6 px-2 rounded-full text-[10.5px] font-mono font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200">
<Icon name="check" size={10} strokeWidth={2.5} />{c}
</span>
))}
</div>
<div class="text-[10px] font-mono text-muted-foreground/60">elsewhere: paste DNS records, hope</div>
</div>
<div class="mt-3 text-[15px] font-semibold tracking-[-0.01em] text-heading">Verified on connect</div>
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">A mailbox does not send until its auth records check out.</p>
</div>
<!-- 6 · Open source: the actual constant. -->
<div class="p-5 md:p-6 border-[color:var(--border)] border-t sm:border-l">
<div class="h-[88px] flex items-center">
<div class="w-full rounded-[10px] bg-[#0c1224] ring-1 ring-white/10 px-3.5 py-3 font-mono text-[11.5px] leading-[1.8]">
<div class="text-white/35">// constants.go</div>
<div><span class="text-sky-300">CampaignLimitDefault</span><span class="text-white/60"> = </span><span class="text-emerald-300">50</span></div>
</div>
</div>
<div class="mt-3 text-[15px] font-semibold tracking-[-0.01em] text-heading">Read the defaults</div>
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">Every number on this page is open source. Elsewhere: trust us.</p>
</div>
</div>
</div>
</section>
<!-- ============================================================
BENTO
@@ -875,7 +814,6 @@ const softwareJsonLd = {
// The panel reveals as one surface; staggering the joined columns would
// pop them in one by one inside their own shared border.
tag('.pricing-grid', 0);
tag('.diff-panel', 0);
tag('.step-card', 0.10);
tag('.feature-card', 0.10);
tag('.metric-cell', 0.06);
@@ -1009,28 +947,23 @@ const softwareJsonLd = {
</script>
<script>
// Hero dashboard mock: render the full desktop layout, uniformly scaled to
// fit its container at every width (full-size on desktop, a clean shrunk
// miniature on mobile) - the same scale-to-fit the feature windows use.
// Below md (768px) the desktop mock is shown at a readable scale and bleeds
// off the right edge - the way Linear / Stripe / Vercel show a desktop app
// shot on a phone, instead of shrinking the whole thing to a microscopic
// full-width thumbnail. HERO_MOBILE_VISIBLE = how many of the mock's design
// px fill the phone width; the rest crops off the right. At md+ it falls
// back to the fluid desktop layout (CSS: md:static md:w-full), so desktop
// stays full-size and overlaps the section below.
// Hero dashboard mock: the 1280x800 shell, uniformly scaled to the plinth.
// At md+ the whole monitor is shown, so the hero viewport inherits its
// 16:10. Below md that same desktop shot is scaled up and bleeds off the
// right edge - the way Linear / Stripe / Vercel show a desktop app on a
// phone, instead of shrinking it to a microscopic thumbnail.
// HERO_MOBILE_VISIBLE = how many design px fill the phone width.
const HERO_MONITOR = 1280;
const HERO_MOBILE_VISIBLE = 720;
function fitHero(screen: HTMLElement) {
const box = screen.parentElement as HTMLElement | null;
if (!box) return;
if (window.innerWidth >= 768) {
screen.style.transform = '';
box.style.height = '';
return;
}
const s = box.clientWidth / HERO_MOBILE_VISIBLE;
const visible = window.innerWidth >= 768 ? HERO_MONITOR : HERO_MOBILE_VISIBLE;
const s = box.clientWidth / visible;
screen.style.transform = `scale(${s})`;
box.style.height = `${screen.offsetHeight * s}px`;
// The shell is 1280x900; the hero shows it as a 16:9 monitor and lets
// the bottom rows run off, so the visual does not tower over the copy.
box.style.height = `${Math.min(screen.offsetHeight * s, box.clientWidth * (9 / 16))}px`;
}
function runHero() {
document.querySelectorAll<HTMLElement>('.hero-screen').forEach((s) => fitHero(s));
+198
View File
@@ -870,3 +870,201 @@ body {
animation: none !important;
}
}
/* ----------------------------------------------------------------
Grain frame.
Paper Shaders injects its own `@layer paper-shaders` at runtime, and a
layer registered after Tailwind's beats Tailwind at ANY specificity. Two
of its rules matter here:
:where([data-paper-shader]) { position: relative }
:where([data-paper-shader]) canvas { height: 100%; border-radius: inherit }
It puts that attribute on whatever element we mount to. So its `position:
relative` was overriding the `absolute inset-0` on our mount target, which
left the target auto-height (its only child is the absolute canvas), which
made the canvas 100% of zero. Declaring position here, unlayered, wins it
back: unlayered CSS beats every layer.
The radius travels as a custom property because the canvas is created by
the library at a depth we do not control, and `inherit` only reads one
level up. clip-path is the belt to that pair of braces: it clips
descendants whatever their overflow, z-index or containment.
---------------------------------------------------------------- */
/* The shadow lives on the shell, outside the clip, or the frame's own
clip-path would cut it off. */
.feat-frame-shell {
--frame-radius: 28px;
border-radius: var(--frame-radius);
}
/* The one clip every visual on the page goes through: a plain box, overflow
hidden, one radius. A transformed child (a scaled screen, a sliding track)
gets its own compositor layer, and Safari and Chrome both skip a rounded
overflow clip on those unless the box is a layer itself and masked. */
.round-clip {
position: relative;
overflow: hidden;
border-radius: 28px;
isolation: isolate;
transform: translateZ(0);
-webkit-mask-image: -webkit-radial-gradient(white, black);
mask-image: radial-gradient(white, black);
}
/* The outer clip. clip-path (not just overflow) because the shader canvas
carries `contain: strict` and a negative z-index from the library's own
layer; this clips it regardless. */
.feat-frame {
border-radius: var(--frame-radius);
clip-path: inset(0 round var(--frame-radius));
}
.feat-frame .grain-backdrop {
border-radius: var(--frame-radius);
}
.feat-frame .grain-canvas {
position: absolute;
inset: 0;
border-radius: var(--frame-radius);
}
.feat-frame .grain-canvas canvas {
border-radius: var(--frame-radius);
}
/* The demo pointer (DemoCursor.astro), moved by lib/cursor.ts. Hidden until
the engine takes over, so a mock never shows a pointer parked at 0,0. */
.demo-cursor {
position: absolute;
left: 0;
top: 0;
z-index: 50;
width: 23px;
height: 27px;
pointer-events: none;
opacity: 0;
filter: drop-shadow(0 4px 6px rgba(15, 23, 42, 0.4));
will-change: transform;
}
.is-playing > .demo-cursor,
.is-playing .demo-cursor {
opacity: 1;
}
.demo-cursor svg {
display: block;
transform-origin: 3px 2px;
transition: transform 90ms ease-out;
}
.demo-cursor.is-down svg {
transform: scale(0.82);
}
.demo-ripple {
position: absolute;
left: -6px;
top: -6px;
width: 32px;
height: 32px;
border-radius: 9999px;
background: rgba(2, 132, 199, 0.5);
transform: scale(0);
opacity: 0;
}
.demo-ripple.is-rip {
animation: demo-rip 520ms ease-out forwards;
}
@keyframes demo-rip {
from { transform: scale(0.25); opacity: 0.7; }
to { transform: scale(1.9); opacity: 0; }
}
.is-static .demo-cursor,
.is-static .demo-ripple {
display: none;
}
/* Inside an app window (the shell is the containing block via contain:paint)
the pointer is fixed, so it travels the whole screen, dialogs included. */
[data-stage] .demo-cursor {
position: fixed;
}
/* Inside an app window the dashboard's own theme applies: its radius scale
(--radius 0.5rem, so rounded-md is 6px), its neutral OKLCH text colours and
its system font stack (web/src/global.css). Lucide icons are round-capped. */
[data-stage] {
--radius: 0.5rem;
--foreground: oklch(0.15 0.01 260);
--muted-foreground: oklch(0.55 0.01 260);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
[data-stage] svg[stroke="currentColor"] {
stroke-linecap: round;
stroke-linejoin: round;
}
/* ── Sidebar activity motifs, copied from web/src/global.css ── */
.campaign-grid {
display: inline-block;
position: relative;
width: 14px;
height: 14px;
vertical-align: middle;
}
.campaign-grid::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 4px;
aspect-ratio: 1;
border-radius: 50%;
transform-origin: 0 0;
transform: scale(0.26) translateX(-38px);
box-shadow:
19px -19px 0 0px, 38px -19px 0 0px, 57px -19px 0 0px,
19px 0 0 5px, 38px 0 0 5px, 57px 0 0 5px,
19px 19px 0 0px, 38px 19px 0 0px, 57px 19px 0 0px;
animation: campaign-grid 3.5s infinite linear;
}
@keyframes campaign-grid {
12.5% { box-shadow:
19px -19px 0 0px, 38px -19px 0 0px, 57px -19px 0 5px,
19px 0 0 5px, 38px 0 0 0px, 57px 0 0 5px,
19px 19px 0 0px, 38px 19px 0 0px, 57px 19px 0 0px; }
25% { box-shadow:
19px -19px 0 5px, 38px -19px 0 0px, 57px -19px 0 5px,
19px 0 0 0px, 38px 0 0 0px, 57px 0 0 0px,
19px 19px 0 0px, 38px 19px 0 5px, 57px 19px 0 0px; }
50% { box-shadow:
19px -19px 0 5px, 38px -19px 0 5px, 57px -19px 0 0px,
19px 0 0 0px, 38px 0 0 0px, 57px 0 0 0px,
19px 19px 0 0px, 38px 19px 0 0px, 57px 19px 0 5px; }
62.5% { box-shadow:
19px -19px 0 0px, 38px -19px 0 0px, 57px -19px 0 0px,
19px 0 0 5px, 38px 0 0 0px, 57px 0 0 0px,
19px 19px 0 0px, 38px 19px 0 5px, 57px 19px 0 5px; }
75% { box-shadow:
19px -19px 0 0px, 38px -19px 0 5px, 57px -19px 0 0px,
19px 0 0 0px, 38px 0 0 0px, 57px 0 0 5px,
19px 19px 0 0px, 38px 19px 0 0px, 57px 19px 0 5px; }
87.5% { box-shadow:
19px -19px 0 0px, 38px -19px 0 5px, 57px -19px 0 0px,
19px 0 0 0px, 38px 0 0 5px, 57px 0 0 0px,
19px 19px 0 5px, 38px 19px 0 0px, 57px 19px 0 0px; }
}
.flame-flicker {
transform-origin: center bottom;
animation: flame-flicker 1.1s ease-in-out infinite;
}
@keyframes flame-flicker {
0%, 100% { transform: scale(1) rotate(-1.5deg); opacity: 0.82; }
25% { transform: scale(1.09) rotate(1.5deg); opacity: 1; }
50% { transform: scale(0.97) rotate(-1deg); opacity: 0.9; }
75% { transform: scale(1.05) rotate(1deg); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.campaign-grid::before, .flame-flicker { animation: none !important; }
}