mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-30 00:15:48 +00:00
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:
@@ -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 |
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · IF block: right dot = yes, bottom dot = no · drag to empty canvas to pick what comes next · 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 · IF block: right dot = yes, bottom dot = no · drag to empty canvas to pick what comes next · 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 · drag a block to move · edges to resize · ⧉ copies a day to all</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400 hidden @sm:block">Drag to add · drag a block to move · edges to resize · ⧉ copies a day to all</span>
|
||||
</div>
|
||||
|
||||
<!-- week grid -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user