Merge pull request #290 from warmbly/fix/ios-safari-index-scroll-crash

fix: iOS Safari crash on the index page, and phones now show the desktop visuals
This commit is contained in:
Matthew Meszaros
2026-09-02 09:15:55 -07:00
committed by GitHub
20 changed files with 245 additions and 178 deletions
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 944" preserveAspectRatio="none">
<!-- SkyChrome's three ambient cloud blurs (web/src/components/layout/SkyChrome.tsx),
baked so the 15 app shells on a page do not each run three live blur layers. -->
<defs>
<filter id="b60" x="-100%" y="-200%" width="300%" height="500%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="b70" x="-100%" y="-200%" width="300%" height="500%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="70"/></filter>
</defs>
<path d="M200.0 -80.0 L200.0 -80.0 A260.0 120.0 0 0 1 460.0 40.0 L460.0 116.8 A93.6 43.2 0 0 1 366.4 160.0 L33.6 160.0 A93.6 43.2 0 0 1 -60.0 116.8 L-60.0 40.0 A260.0 120.0 0 0 1 200.0 -80.0 Z" fill="#fff" fill-opacity="0.6649999999999999" filter="url(#b60)"/>
<path d="M987.6 94.4 L987.6 94.4 A190.0 90.0 0 0 1 1177.6 184.4 L1177.6 242.0 A68.4 32.4 0 0 1 1109.2 274.4 L866.0 274.4 A68.4 32.4 0 0 1 797.6 242.0 L797.6 184.4 A190.0 90.0 0 0 1 987.6 94.4 Z" fill="#fff" fill-opacity="0.4675" filter="url(#b70)"/>
<path d="M482.8 302.1 L482.8 302.1 A150.0 70.0 0 0 1 632.8 372.1 L632.8 411.3 A66.0 30.8 0 0 1 566.8 442.1 L398.8 442.1 A66.0 30.8 0 0 1 332.8 411.3 L332.8 372.1 A150.0 70.0 0 0 1 482.8 302.1 Z" fill="#fff" fill-opacity="0.405" filter="url(#b70)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+12 -12
View File
@@ -134,11 +134,11 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
---
<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">
<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-1.5 min-w-0 flex-wrap justify-end md:flex-nowrap">
<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 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>
@@ -148,13 +148,13 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
<!-- 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>
<span class="hidden @sm:inline">Share image</span>
</button>
</div>
</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 @md:grid-cols-4">
{statStrip.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">
@@ -168,9 +168,9 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
</div>
<!-- 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">
<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 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) => (
@@ -257,9 +257,9 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
<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-2 md: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="hidden md:inline 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>
@@ -277,7 +277,7 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
<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 hidden md: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>
))}
@@ -286,7 +286,7 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
<!-- 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="an-modal w-full max-w-[min(1203px,860px)] max-h-[calc(900px-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>
@@ -306,7 +306,7 @@ const shareArea = `${shareLine} L ${sPts[sPts.length - 1][0].toFixed(1)} 314 L 4
</div>
<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));`}>
<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, 522px));`}>
<!-- 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">
+19 -22
View File
@@ -25,10 +25,10 @@
* 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).
* The shell is the same desktop screenshot at every viewport: a phone gets it
* scaled, never a phone layout. The root is a container query root, and the
* mocks inside use `@md:`-style variants, so their responsive classes answer
* to this 1280px window rather than to the visitor's screen.
*/
import { nextId } from '../lib/uid';
@@ -39,22 +39,16 @@ interface Props {
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 { crumb, active = crumb, sub } = 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';
const mainEdge = 'ml-64 rounded-tl-2xl border-t border-l border-slate-200/70';
// 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 bodyMin = '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"/>',
@@ -178,14 +172,14 @@ const online = [
{ 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>
<!-- SkyChrome: #f5f6f8 base + its three ambient cloud blurs, baked into one
SVG (public/backdrops/shell-ambient.svg). Fifteen shells on a page each
running three live 70px blur layers is what iOS Safari runs out of GPU
memory on. -->
<div class="@container relative overflow-hidden" style="background:#f5f6f8 url(/backdrops/shell-ambient.svg) 0 0 / 100% 100% no-repeat; contain: paint;" data-stage data-shell aria-hidden="true">
<!-- ── AppHeader (h-14) ── -->
<div class={`relative z-10 h-14 items-center shrink-0 ${headerShow}`}>
<div class="relative z-10 h-14 flex items-center shrink-0">
<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>
@@ -256,7 +250,7 @@ const online = [
<!-- ── 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}`}>
<aside class="absolute inset-y-0 left-0 w-64 overflow-hidden flex flex-col text-slate-900">
<!-- 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">
@@ -477,15 +471,18 @@ const online = [
};
let onScreen = false;
const sync = () => (onScreen && !document.hidden ? start() : stop());
// Reduced motion never ticks the counters; the observer still parks the shell.
const sync = () => (onScreen && !document.hidden && !REDUCED ? start() : stop());
new IntersectionObserver((entries) => {
onScreen = entries.some((e) => e.isIntersecting);
// Off screen, the pings and pulses inside stop too (see global.css).
shell.classList.toggle('is-off', !onScreen);
sync();
}, { threshold: 0.1 }).observe(shell);
document.addEventListener('visibilitychange', sync);
}
if (!REDUCED) document.querySelectorAll<HTMLElement>('[data-shell]').forEach(live);
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>
+4 -4
View File
@@ -78,7 +78,7 @@ const trace: Row[] = [
<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">
<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"><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>
@@ -86,7 +86,7 @@ const trace: Row[] = [
<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">app.warmbly.com<span class="text-slate-400 font-normal">/automations/reply-to-revenue</span></span>
</div>
<div class="hidden sm:flex items-center shrink-0 text-slate-300">
<div class="hidden @sm:flex items-center 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>
@@ -129,7 +129,7 @@ const trace: Row[] = [
</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>
<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="720" viewBox="0 0 1280 720" class="absolute inset-0 pointer-events-none" aria-hidden="true">
@@ -278,7 +278,7 @@ const trace: Row[] = [
</div>
<!-- ── 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="ad-panel absolute top-0 right-0 h-full w-80 max-w-[1126px] 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="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>
+10 -10
View File
@@ -99,12 +99,12 @@ const ic = {
</span>
</div>
<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="truncate max-w-[110px] @md:max-w-[200px]">Sales pipeline</span>
<span class="text-slate-400">▾</span>
</button>
<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>
<span class="w-[120px] @md:w-[160px] h-5 inline-flex items-center text-[12px] text-slate-400">Search deals…</span>
</div>
<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>
@@ -114,7 +114,7 @@ const ic = {
</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
<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">
@@ -127,7 +127,7 @@ const ic = {
</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">
<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>
@@ -135,7 +135,7 @@ const ic = {
<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-[300px] max-h-[calc(100dvh-230px)] transition-colors bg-slate-50 border-slate-200 border">
<div class="flex flex-col rounded-md min-h-[300px] max-h-[calc(900px-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>
@@ -180,7 +180,7 @@ const ic = {
<!-- 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="cd-dialog w-full max-w-[480px] max-h-[calc(900px-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>
@@ -201,7 +201,7 @@ const ic = {
<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 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">
@@ -219,8 +219,8 @@ const ic = {
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div class="sm:col-span-2">
<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>
@@ -229,7 +229,7 @@ const ic = {
<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>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<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">
@@ -247,7 +247,7 @@ const rootClass = ['cb relative w-full bg-white text-slate-900 flex flex-col', o
<div aria-hidden="true" class="absolute inset-0 pointer-events-none" style="background-image: radial-gradient(circle, #e9eef5 0.5px, transparent 0.6px); background-size: 24px 24px;"></div>
<!-- Panel top-left (15px margin): Add step split button, Tidy up, Stop on reply -->
<div class="absolute left-[15px] top-[15px] z-[5] flex max-w-[calc(100vw-1.5rem)] flex-col gap-1.5">
<div class="absolute left-[15px] top-[15px] z-[5] flex max-w-[calc(1280px-1.5rem)] flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-1.5">
<div class="relative inline-flex">
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-l-md bg-sky-600 px-3 text-[12px] font-medium text-white shadow-sm">
@@ -270,7 +270,7 @@ const rootClass = ['cb relative w-full bg-white text-slate-900 flex flex-col', o
<!-- Panel bottom-center: the mouse-and-keyboard hint -->
<div class="absolute bottom-[15px] left-1/2 -translate-x-1/2 z-[5] max-w-[calc(100%-30px)]">
<div class="hidden md:flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md bg-white/95 px-3 py-1.5 text-[11px] text-slate-500 shadow-sm">
<div class="hidden @md:flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md bg-white/95 px-3 py-1.5 text-[11px] text-slate-500 shadow-sm">
<span class="text-slate-400">drag a node’s bottom dot onto another node to connect, or onto empty space to pick what comes next (email, action, condition, or Stop) · click a line to set its condition · add Condition nodes to branch, and chain them for nested trees · click a line then press Delete to remove it · a step with no outgoing line just ends</span>
</div>
</div>
@@ -400,7 +400,7 @@ const rootClass = ['cb relative w-full bg-white text-slate-900 flex flex-col', o
<!-- Edit sheet: the builder's SidePanel (AutomationFlow.tsx), 320px,
springing in from the right over the whole window (fixed, contained
by the shell). -->
<div class="cb-edit fixed top-0 right-0 bottom-0 z-30 w-full md:w-80 md:max-w-[88vw] overflow-y-auto overflow-x-hidden bg-white border-l border-slate-200 shadow-xl" aria-hidden="true">
<div class="cb-edit fixed top-0 right-0 bottom-0 z-30 w-full @md:w-80 @md:max-w-[1126px] overflow-y-auto overflow-x-hidden bg-white border-l border-slate-200 shadow-xl" aria-hidden="true">
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white px-3 py-2">
<span class="truncate text-[12.5px] font-medium text-slate-700">Edit “{panelTitle}”</span>
<div class="flex items-center gap-1">
@@ -430,8 +430,8 @@ const rootClass = ['cb relative w-full bg-white text-slate-900 flex flex-col', o
<!-- Send email: StepEmailArms > SequenceView (embedded) -->
{(demo === 'flow' || demo === 'ai') && (
<div class="rounded-md border border-slate-200 bg-white">
<div class="flex flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap shrink-0 items-center gap-2 sm:ml-auto">
<div class="flex flex-col gap-3 px-3 py-2.5 @sm:flex-row @sm:items-center @sm:justify-between">
<div class="flex flex-wrap shrink-0 items-center gap-2 @sm:ml-auto">
<span class="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white text-[12px] font-medium text-slate-600">
<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.split}></svg>
A/B test
+8 -8
View File
@@ -36,15 +36,15 @@ const healthTone = (h: Row['health'], score: number) => {
return { dot: 'bg-rose-500', text: 'text-rose-600', label: `Issue ${score}`, pulse: true };
};
---
<AppShellMock crumb="Accounts" chrome="always">
<AppShellMock crumb="Accounts">
<!-- 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">
<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">
<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
@@ -53,7 +53,7 @@ const healthTone = (h: Row['health'], score: number) => {
</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
<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 },
@@ -72,7 +72,7 @@ const healthTone = (h: Row['health'], score: number) => {
</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">
<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">
@@ -95,7 +95,7 @@ const healthTone = (h: Row['health'], score: number) => {
<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 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>
@@ -105,7 +105,7 @@ const healthTone = (h: Row['health'], score: number) => {
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">
<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">
@@ -128,7 +128,7 @@ const healthTone = (h: Row['health'], score: number) => {
{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>
<span class="uppercase tracking-[0.08em] hidden md:inline">{t.label}</span>
<span class="uppercase tracking-[0.08em] hidden @md:inline">{t.label}</span>
</span>
</td>
<td class="px-3"></td>
+14 -14
View File
@@ -61,15 +61,15 @@ const perms = [
<div class="dv relative w-full bg-white flex flex-col" style="min-height:520px;" data-demo="dv">
<!-- 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">
<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">
<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>
<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">
@@ -78,13 +78,13 @@ const perms = [
<!-- 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>
<span class="hidden @sm:inline">Create key</span>
</button>
</div>
</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
<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">
@@ -98,7 +98,7 @@ const perms = [
</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">
<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>
@@ -133,7 +133,7 @@ const perms = [
</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">
<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">
@@ -161,7 +161,7 @@ const perms = [
</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">
<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>
@@ -184,10 +184,10 @@ const perms = [
{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">
<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>
<span class="hidden md:inline font-mono text-[10.5px] text-slate-400 tabular-nums w-32 text-right truncate">{k.last}</span>
<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>
);
@@ -198,7 +198,7 @@ const perms = [
<!-- 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%;">
<div class="dv-card absolute left-1/2 -translate-x-1/2 z-50 w-[560px] max-w-[1178px] bg-white rounded-lg border border-slate-200 shadow-[0_24px_60px_-12px_rgba(15,23,42,0.25)] overflow-hidden" style="top:10%;">
<!-- ConfigureStep header -->
<div class="h-11 px-4 border-b border-slate-200 flex items-center gap-2">
@@ -209,7 +209,7 @@ const perms = [
</span>
</div>
<div class="px-5 py-4 space-y-4 max-h-[70dvh] overflow-hidden">
<div class="px-5 py-4 space-y-4 max-h-[630px] overflow-hidden">
<!-- Name (autofocused, empty) -->
<div>
<div class="flex items-center gap-2 mb-1">
@@ -237,7 +237,7 @@ const perms = [
<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="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>
@@ -321,7 +321,7 @@ const perms = [
<!-- 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">
<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>
+60 -47
View File
@@ -26,8 +26,9 @@ import type { GrainTone } from '../lib/grain';
* 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.
* stretches). Below lg the copy sits above and the frame keeps a desktop
* frame's proportions, with the very same window, chrome and crop inside it,
* just smaller: a phone sees what a desktop sees.
*/
interface Callout { label: string; text: string }
type Anchor = 'tl' | 'tr' | 'bl' | 'br';
@@ -110,34 +111,34 @@ 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.
// at the anchored corner (16px below lg, 28px at lg+), flush on the two it
// bleeds off.
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',
tr: 'right-4 top-4 left-0 bottom-0 lg:right-7 lg:top-7',
bl: 'left-4 bottom-4 right-0 top-0 lg:left-7 lg:bottom-7',
br: 'right-4 bottom-4 left-0 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]',
tr: 'rounded-tr-[12px]',
bl: 'rounded-bl-[12px]',
br: '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)]',
tr: 'shadow-[8px_-8px_28px_-10px_rgba(2,10,32,0.55)]',
bl: 'shadow-[-8px_8px_28px_-10px_rgba(2,10,32,0.55)]',
br: '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',
tr: 'top-0 right-0 origin-top-right',
bl: 'bottom-0 left-0 origin-bottom-left',
br: 'bottom-0 right-0 origin-bottom-right',
};
const eyebrowColor = lightText ? 'text-white/60' : 'text-sky-600';
@@ -220,12 +221,12 @@ const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-
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">
<div class={`feat-screen absolute 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.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>
@@ -312,46 +313,44 @@ const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-
.feat-track { transition: none; }
.feat-bar.is-active .feat-fill { animation: none; transform: scaleX(1); }
}
/* Parked (a viewport or more away): the screens are not rendered at all. */
.feat-frame.is-parked .feat-track { content-visibility: hidden; }
@supports not (content-visibility: hidden) {
.feat-frame.is-parked .feat-track { display: none; }
}
</style>
<script>
// 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;
// The window is a 1280x944 browser pinned at the anchored corner at every
// width: `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. At lg+ the frame is as tall as the copy beside it;
// below lg nothing sits beside it, so it keeps a desktop frame's proportions.
const FRAME_ASPECT = 0.8;
function fit(screen: HTMLElement) {
const box = screen.parentElement as HTMLElement | null;
const frame = screen.closest<HTMLElement>('.feat-frame');
const shell = frame?.parentElement as HTMLElement | null;
if (!box || !frame || !shell) return;
// A parked screen has no layout to measure; its last sizes stay put.
if (frame.classList.contains('is-parked')) 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)})`;
const lg = window.innerWidth >= 1024;
const inset = lg ? 28 : 16;
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 on one edge), so nothing is cropped.
screen.style.transform = `scale(${byWidth})`;
const h = `${Math.round(screen.offsetHeight * byWidth + inset)}px`;
if (frame.style.height !== h) { frame.style.height = h; shell.style.height = h; }
return;
}
const s = box.clientWidth / DESIGN_SM;
screen.style.transform = `scale(${s})`;
// 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;
const h = lg ? '' : `${Math.round(shell.clientWidth * FRAME_ASPECT)}px`;
if (frame.style.height !== h) { frame.style.height = h; shell.style.height = h; }
const byHeight = box.clientHeight / Math.max(1, screen.offsetHeight);
screen.style.transform = `scale(${Math.max(byWidth, byHeight)})`;
}
function fitAll() {
@@ -445,6 +444,19 @@ const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-
paint();
}
// Sections more than a viewport away drop their screens from rendering
// (content-visibility), so iOS Safari holds the layers of two or three
// windows at a time instead of fourteen; the frame keeps its size, and the
// screens are fitted again as the section comes back within reach.
function park(frame: HTMLElement) {
if (typeof IntersectionObserver === 'undefined') return;
new IntersectionObserver((entries) => {
const near = entries.some((e) => e.isIntersecting);
if (frame.classList.toggle('is-parked', !near)) return;
frame.querySelectorAll<HTMLElement>('.feat-screen').forEach(fit);
}, { rootMargin: '100% 0px' }).observe(frame);
}
function init() {
document.querySelectorAll<HTMLElement>('.feat-screen').forEach((screen) => {
const run = () => fit(screen);
@@ -456,6 +468,7 @@ const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-
window.addEventListener('load', fitAll);
window.addEventListener('resize', fitAll);
document.querySelectorAll<HTMLElement>('.feat-frame').forEach(carousel);
document.querySelectorAll<HTMLElement>('.feat-frame').forEach(park);
// 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; });
}
+1 -1
View File
@@ -101,7 +101,7 @@ const year = new Date().getFullYear();
<p class="text-xs text-muted-foreground">
© {year} Warmbly. Mindroot Ltd · company no. 16543299 · 71-75 Shelton Street, London WC2H 9JQ.
</p>
<div class="flex items-center gap-4 shrink-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<a href="https://github.com/warmbly/warmbly" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.8 10.9.6.1.8-.2.8-.5v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2 1-.3 2-.4 3-.4s2 .1 3 .4c2.3-1.5 3.3-1.2 3.3-1.2.7 1.6.2 2.9.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.5 4.6-1.5 7.8-5.8 7.8-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
github.com/warmbly/warmbly
+2 -2
View File
@@ -51,7 +51,7 @@ const label = 'text-[10px] uppercase tracking-[0.14em] text-slate-400 font-mediu
<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">
<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>
@@ -114,7 +114,7 @@ const label = 'text-[10px] uppercase tracking-[0.14em] text-slate-400 font-mediu
<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 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>
+18 -18
View File
@@ -93,11 +93,11 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
---
<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">
<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">
<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
@@ -106,7 +106,7 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
</div>
<!-- StatStrip cols=5 -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-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 },
@@ -126,7 +126,7 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
</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">
<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">
@@ -159,15 +159,15 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
<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} 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 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`}>
<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>
@@ -182,7 +182,7 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
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">
<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>
@@ -196,7 +196,7 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
</div>
</div>
</td>
<td class="px-3 hidden lg:table-cell">
<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">
@@ -206,7 +206,7 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
))}
</div>
</td>
<td class="px-3 hidden lg:table-cell">
<td class="px-3 hidden @lg:table-cell">
{s ? (
<svg width={SW} height={SH} viewBox={`0 0 ${SW} ${SH}`} class="block">
<defs>
@@ -223,11 +223,11 @@ const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowra
)}
</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-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={`${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>
+22 -10
View File
@@ -4,9 +4,11 @@
*
* 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.
* static render. The canvas only exists while its frame is near the viewport:
* it is released once scrolled past, so a long page never holds more than a
* few GL contexts (iOS Safari kills the tab otherwise). 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.
@@ -48,6 +50,11 @@ const t = GRAIN_TONES[tone];
// to be grainy with, so we leave the CSS gradient standing instead.
const noise = getShaderNoiseTexture();
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Phones: a 3x canvas per frame is what pushes iOS Safari over its GPU
// memory limit while scrolling; 2x grain reads the same at that size.
const touch = window.matchMedia('(hover: none)').matches;
const noGl = new URLSearchParams(location.search).has('nogl');
const maxPixels = touch ? 1280 * 720 : 1920 * 1080 * 2;
// 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
@@ -69,7 +76,7 @@ const t = GRAIN_TONES[tone];
}
function mount(el: HTMLElement, i: number) {
if (el.dataset.grainReady) return;
if (noGl || el.dataset.grainReady) return;
el.dataset.grainReady = 'true';
const colors = (el.dataset.colors ?? '').split(',').filter(Boolean);
if (!noise || !colors.length) return;
@@ -107,13 +114,19 @@ const t = GRAIN_TONES[tone];
// 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,
maxPixels,
);
} 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.
}
}
// Frees the GL context and its canvas; the CSS gradient stays underneath.
function unmount(el: HTMLElement) {
const m = (el as HTMLElement & { paperShaderMount?: { dispose: () => void } }).paperShaderMount;
if (m) m.dispose();
delete el.dataset.grainReady;
}
const bands = [...document.querySelectorAll<HTMLElement>('[data-grain]')];
// Index by DOM order, not mount order, so the variant a frame gets does not
@@ -123,15 +136,14 @@ const t = GRAIN_TONES[tone];
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.
// Mount on approach and release again once scrolled well past, so the
// page holds two or three GL contexts at a time instead of nine.
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);
if (e.isIntersecting) mount(el, indexOf.get(el) ?? 0);
else if (el.dataset.grainReady) unmount(el);
}
},
{ rootMargin: '300px' },
+2 -2
View File
@@ -35,7 +35,7 @@ const tagClass = (t: string) => {
</div>
<!-- Thread list -->
<div class="col-span-12 md:col-span-5 border-r border-slate-200">
<div class="col-span-12 @md:col-span-5 border-r border-slate-200">
{threads.map((t) => (
<div class={`px-4 py-3 border-b border-slate-200/60 ${t.active ? 'bg-sky-50/50 border-l-2 border-l-sky-500' : ''}`}>
<div class="flex items-center justify-between gap-2">
@@ -50,7 +50,7 @@ const tagClass = (t: string) => {
</div>
<!-- Preview pane -->
<div class="hidden md:flex md:col-span-7 flex-col">
<div class="hidden @md:flex @md:col-span-7 flex-col">
<div class="px-5 py-3 border-b border-slate-200 flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-rose-400 to-amber-400"></div>
<div class="flex-1 min-w-0">
+11 -11
View File
@@ -100,13 +100,13 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
<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">
<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 min-w-0 flex-wrap justify-end md:flex-nowrap">
<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="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>
@@ -120,7 +120,7 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 md:grid-cols-4">
<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">
@@ -137,11 +137,11 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
<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">
<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">
<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)} />
@@ -158,11 +158,11 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
<!-- 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">
<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">
<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 (
@@ -209,7 +209,7 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
))}
<!-- 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">
<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">
@@ -220,7 +220,7 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
{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="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>
@@ -232,7 +232,7 @@ const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0
<!-- 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="ig-drawer ml-auto h-full w-full @sm:w-[480px] @sm:max-w-[1178px] 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">
+1 -1
View File
@@ -106,7 +106,7 @@ const cursorXPct = (cursorX / ROOT_W) * 100;
{['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 transition-colors">{p}</button>
))}
<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>
<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 -->
+7 -7
View File
@@ -126,7 +126,7 @@ const railToneClass = (tone: string, active: boolean) => {
<!-- ─────────────────────────────────────────────────────────────
1) TOP STRIP - UniboxHeader (h-10)
────────────────────────────────────────────────────────────── -->
<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">
<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">
@@ -305,7 +305,7 @@ const railToneClass = (tone: string, active: boolean) => {
))}
</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">
<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>
@@ -320,9 +320,9 @@ 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 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="inline-flex size-7 rounded-md items-center justify-center transition-colors text-slate-500">
@@ -354,7 +354,7 @@ 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="group px-3 sm:px-5 py-4">
<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">
@@ -367,7 +367,7 @@ const railToneClass = (tone: string, active: boolean) => {
</div>
</div>
<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">
<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>
+1 -1
View File
@@ -16,7 +16,7 @@ const ramp = [10, 11, 12, 14, 16, 18, 21, 24, 27, 30, 33, 36, 38, 40, 40];
</div>
<!-- StatStrip -->
<div class="grid grid-cols-2 md:grid-cols-4 border-b border-slate-200">
<div class="grid grid-cols-2 @md:grid-cols-4 border-b border-slate-200">
{[
{ label: 'Complaint rate', value: '0.02%', sub: '< 0.03 warn', last: false },
{ label: 'Bounce rate', value: '0.7%', sub: '< 1.0 warn', last: false },
+5
View File
@@ -142,6 +142,11 @@ const websiteJsonLd = {
{extraJsonLd.map((schema) => (
<script type="application/ld+json" is:inline set:html={JSON.stringify(schema)} />
))}
<!-- Phone diagnostics: ?noshell / ?noanim / ?nogl switch heavy subsystems off (global.css, GrainBackdrop). -->
<script is:inline>
var diagParams = new URLSearchParams(location.search);
document.documentElement.dataset.diag = ['noshell', 'noanim', 'nogl'].filter(function (f) { return diagParams.has(f); }).join(' ');
</script>
</head>
<body class="min-h-screen flex flex-col antialiased">
{!bare && <LaunchBanner />}
+32 -3
View File
@@ -7,6 +7,10 @@
@custom-variant dark (&:is(.dark *));
/* Nothing may widen the page: on iOS one overflowing row scales the whole
layout viewport. clip (not hidden) keeps the sticky header working. */
html, body { overflow-x: clip; }
/* ----------------------------------------------------------------
Color tokens — mirrors web/src/global.css OKLCH neutrals,
with brand sky/indigo accents for the marketing surface.
@@ -904,16 +908,41 @@ body {
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. */
/* Phones: a backdrop filter inside a scaled app shell makes iOS Safari keep a
snapshot of everything behind it per mock, and at that size the blur is
invisible anyway. Desktop keeps the real thing. */
@media (hover: none) {
[data-stage] [class*="backdrop-blur"] {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/* clip-path, not the translateZ + mask-image trick: that pairs a forced 3D
layer with a mask buffer per frame, and iOS Safari runs out of GPU memory
with fourteen of them on one page. clip-path clips composited children
(the WebGL canvas, scaled screens) just as well. */
.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);
clip-path: inset(0 round 28px);
}
/* A shell that is off screen (AppShellMock's observer) keeps its pings and
pulses still; a phone with fifteen shells cannot animate them all. */
[data-stage].is-off *,
[data-stage].is-off *::before,
[data-stage].is-off *::after {
animation-play-state: paused !important;
}
/* Diagnostics for the phone: ?noshell hides every app shell, ?noanim stops
every animation, ?nogl (GrainBackdrop) skips WebGL. */
html[data-diag~="noshell"] [data-stage] { display: none !important; }
html[data-diag~="noanim"] *, html[data-diag~="noanim"] *::before, html[data-diag~="noanim"] *::after { animation: none !important; transition: none !important; }
/* 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. */