Skip to content
1 change: 1 addition & 0 deletions src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const pillClass = (isActive: boolean) =>
<img src="/logos/wavekat-tight-dark.svg" alt="wavekat" class="h-9 hidden dark:block" />
</a>
<div class="flex items-center gap-3">
<a href="/voice" class={pillClass(active === 'voice')}>voice</a>
<a href="/docs" class={pillClass(active === 'docs')}>docs</a>
<a href="/blog" class={pillClass(active === 'blog')}>blog</a>
<button
Expand Down
151 changes: 151 additions & 0 deletions src/components/SiteBackground.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
// Site-wide animated backdrop — a flowing waveform field on a canvas, fitting
// WaveKat (waves) and the Voice product. Mounted once in Base.astro so it sits
// behind every page.
//
// At rest the waves gently undulate (multiple harmonics + drift), kept subtle
// so they never fight the content. Where the cursor hovers, the nearby waves
// grow taller AND brighter — the field comes alive around the mouse, like a
// finger trailed through water. Fixed (-z-10), pointer-events-none, edge-masked.
// Renders one still frame and ignores the mouse under prefers-reduced-motion.
---

<canvas class="wave-canvas" aria-hidden="true"></canvas>

<style>
.wave-canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: -10;
pointer-events: none;
-webkit-mask-image: radial-gradient(ellipse 130% 110% at 50% 45%, #000 55%, transparent 100%);
mask-image: radial-gradient(ellipse 130% 110% at 50% 45%, #000 55%, transparent 100%);
}
</style>

<script>
const canvas = document.querySelector<HTMLCanvasElement>('.wave-canvas');
const ctx = canvas?.getContext('2d');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (canvas && ctx) {
// Wave layers positioned as a fraction of viewport height. Mixed
// wavelengths/speeds keep the field from ever looking like it repeats.
// baseAmp/baseAlpha are the calm resting state — deliberately low.
const layers = [
{ y: 0.16, amp: 14, len: 0.0065, speed: 0.55, phase: 0.0, alpha: 0.05, width: 2 },
{ y: 0.34, amp: 18, len: 0.0042, speed: -0.4, phase: 1.7, alpha: 0.038, width: 2.5 },
{ y: 0.52, amp: 16, len: 0.0055, speed: 0.7, phase: 3.1, alpha: 0.045, width: 2 },
{ y: 0.7, amp: 20, len: 0.0035, speed: -0.5, phase: 4.6, alpha: 0.035, width: 3 },
{ y: 0.86, amp: 15, len: 0.006, speed: 0.45, phase: 5.9, alpha: 0.042, width: 2 },
];

let w = 0;
let h = 0;
let dpr = 1;

function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = window.innerWidth;
h = window.innerHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
window.addEventListener('resize', resize, { passive: true });

// Smoothed cursor — eased toward the raw pointer so the swell follows
// fluidly instead of snapping.
const mouse = { x: w / 2, y: -9999, tx: w / 2, ty: -9999, active: false };
if (!reduce) {
window.addEventListener(
'pointermove',
(e) => {
mouse.tx = e.clientX;
mouse.ty = e.clientY;
mouse.active = true;
},
{ passive: true },
);
window.addEventListener('pointerleave', () => (mouse.active = false), { passive: true });
}

const RX = 200; // horizontal reach of the cursor's influence, in px
const RY = 190; // vertical reach (how many layers it touches)
const AMP_GAIN = 2.0; // extra amplitude at the cursor (x2 taller)
const GLOW_R = 340; // radius of the brightness halo around the cursor

function render(t: number) {
ctx.clearRect(0, 0, w, h);

mouse.x += (mouse.tx - mouse.x) * 0.08;
mouse.y += (mouse.ty - mouse.y) * 0.08;

for (const L of layers) {
const baseY = h * L.y;
// Vertical proximity of this layer's baseline to the cursor.
const dyl = baseY - mouse.y;
const vInf = mouse.active ? Math.exp(-(dyl * dyl) / (2 * RY * RY)) : 0;

ctx.beginPath();
for (let x = 0; x <= w; x += 5) {
// Local amplitude — grows near the cursor so the waves there get bigger.
let amp = L.amp;
if (vInf > 0.001) {
const dx = x - mouse.x;
const xInf = Math.exp(-(dx * dx) / (2 * RX * RX));
amp *= 1 + AMP_GAIN * xInf * vInf;
}

// Two harmonics → organic, non-repeating curve.
const y =
Math.sin(x * L.len + t * L.speed + L.phase) * amp +
Math.sin(x * L.len * 0.5 - t * L.speed * 0.6 + L.phase) * amp * 0.35;

const yy = baseY + y;
if (x === 0) ctx.moveTo(x, yy);
else ctx.lineTo(x, yy);
}

// Brightness — flat and subtle at rest; a bright halo around the cursor
// when active, so the nearby waves stand out.
if (mouse.active) {
const g = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, GLOW_R);
g.addColorStop(0, 'rgba(255, 122, 26, 0.4)');
g.addColorStop(1, `rgba(255, 109, 0, ${L.alpha})`);
ctx.strokeStyle = g;
} else {
ctx.strokeStyle = `rgba(255, 109, 0, ${L.alpha})`;
}
ctx.lineWidth = L.width;
ctx.stroke();
}
}

if (reduce) {
render(0);
} else {
let raf = 0;
let running = true;
const loop = (ms: number) => {
render(ms * 0.001);
if (running) raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);

// Don't burn cycles when the tab is hidden.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!running) {
running = true;
raf = requestAnimationFrame(loop);
}
});
}
}
</script>
63 changes: 13 additions & 50 deletions src/components/TalkCTA.astro
Original file line number Diff line number Diff line change
@@ -1,69 +1,37 @@
---
import { Calendar } from '@lucide/astro';
import { Mail } from '@lucide/astro';

interface Props {
marginTop?: string;
}

const { marginTop = 'mt-16' } = Astro.props;

const bookingUrl = 'https://calendar.app.google/A9sGuckUm2pVpuKa9';
const emailHref = 'mailto:eason@wavekat.com?subject=wavekat-voice';
---

<section class={marginTop}>
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase mb-5">Talk to us</h2>
<div class="rounded p-5 bg-gray-50 dark:bg-wk-surface">
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed mb-5 max-w-xl">
We're looking for small businesses to build this with. Pick a time below — no pitch, no slide deck. We just want to learn how the phone works in your business today.
Questions, feedback, or a device you wish we supported? Email is the best way to reach us —
we read every message and reply as soon as we can.
</p>

<a
href={bookingUrl}
target="_blank"
rel="noopener noreferrer"
data-conversion="book_a_call"
href={emailHref}
data-conversion="mailto_click"
class="inline-flex items-center gap-2 rounded px-4 py-2.5 text-xs font-bold text-white transition-opacity hover:opacity-90"
style="background-color: #ff6d00"
>
<Calendar class="w-4 h-4" />
Book a call
<Mail class="w-4 h-4" />
Email us
</a>

<div class="mt-8">
<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mb-3">
Not ready to book? Drop your email and we'll keep you posted.
</p>
<form
action="https://buttondown.com/api/emails/embed-subscribe/wavekat"
method="post"
target="popupwindow"
onsubmit="window.open('https://buttondown.com/wavekat', 'popupwindow')"
data-conversion="email_signup"
class="flex flex-col sm:flex-row gap-2"
>
<input type="hidden" name="tag" value="voice-interest" />
<label for="talk-cta-email" class="sr-only">Email address</label>
<input
type="email"
name="email"
id="talk-cta-email"
required
placeholder="you@example.com"
class="flex-1 min-w-0 rounded border border-gray-200 dark:border-white/10 bg-white dark:bg-black px-3 py-2 text-xs text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 focus:outline-none focus:border-gray-400 dark:focus:border-white/30 transition-colors"
/>
<button
type="submit"
class="rounded border border-gray-300 dark:border-white/20 bg-white dark:bg-black px-4 py-2 text-xs font-bold text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-[#0d1520] transition-colors"
>
Keep me posted
</button>
</form>
</div>

<p class="text-xs text-gray-400 dark:text-gray-600 mt-5 leading-relaxed">
Or email{' '}
<p class="text-xs text-gray-400 dark:text-gray-600 mt-4 leading-relaxed">
Or write to{' '}
<a
href="mailto:eason@wavekat.com?subject=wavekat-voice"
href={emailHref}
data-conversion="mailto_click"
class="text-gray-600 dark:text-gray-300 underline underline-offset-2 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Expand All @@ -75,11 +43,9 @@ const bookingUrl = 'https://calendar.app.google/A9sGuckUm2pVpuKa9';
</section>

<script is:inline>
// Google Ads conversion labelsreplace placeholders with the value from
// Google Ads → Tools → Conversions → <action> → Tag setup (the part after the slash).
// Google Ads conversion labelthe value after the slash from
// Google Ads → Tools → Conversions → "mailto click" → Tag setup.
const CONVERSIONS = {
book_a_call: 'AW-18128547216/VGMpCM2OhKUcEJDbrsRD',
email_signup: 'AW-18128547216/KKS1CMi5hKUcEJDbrsRD',
mailto_click: 'AW-18128547216/wIcbCOfc66QcEJDbrsRD',
};

Expand All @@ -92,7 +58,4 @@ const bookingUrl = 'https://calendar.app.google/A9sGuckUm2pVpuKa9';
document.querySelectorAll('a[data-conversion]').forEach((el) => {
el.addEventListener('click', () => fireConversion(el.dataset.conversion));
});
document.querySelectorAll('form[data-conversion]').forEach((el) => {
el.addEventListener('submit', () => fireConversion(el.dataset.conversion));
});
</script>
2 changes: 2 additions & 0 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import '../styles/global.css';
import SiteBackground from '../components/SiteBackground.astro';

interface Props {
title?: string;
Expand Down Expand Up @@ -67,6 +68,7 @@ const ogImageURL = new URL(ogImage, Astro.site);
</script>
</head>
<body class="min-h-screen bg-white dark:bg-wk-bg text-gray-900 dark:text-gray-100 transition-colors duration-200">
<SiteBackground />
<slot />
</body>
</html>
1 change: 1 addition & 0 deletions src/layouts/Voice.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { title, description } = Astro.props;
const subNav = [
{ href: '/voice/', label: 'overview' },
{ href: '/voice/use-cases/', label: 'use cases' },
{ href: '/voice/download/', label: 'download' },
{ href: '/voice/talk/', label: 'talk to us' },
];

Expand Down
84 changes: 84 additions & 0 deletions src/lib/voice-download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// WaveKat Voice — macOS download metadata, read live from the release feed.
//
// The installers live on Cloudflare R2, served at https://dl.wavekat.com/voice/
// (the same origin the app polls for updates). Rather than hard-code a version
// that goes stale every release, we read the current one from the macOS release
// feed at BUILD TIME and derive the download link from it. Bump a release in
// wavekat-voice and the next site build picks it up automatically — nothing to
// edit here.
//
// `latest-mac.yml` looks like:
// version: 0.0.21
// files:
// - url: WaveKat Voice-0.0.21-arm64-mac.zip (the in-app update payload)
// size: 120567410
// - url: WaveKat Voice-0.0.21-arm64.dmg (the human download)
// size: 125294046
// We want the .dmg entry — the .zip is what the app uses to update itself.
//
// macOS only for now — that's the only platform the site surfaces.

const FEED = 'https://dl.wavekat.com/voice/latest-mac.yml';
const DL_BASE = 'https://dl.wavekat.com/voice';

// Used only if the feed can't be reached during a build, so a network blip
// never breaks the site. Reflects the last known-good release.
const FALLBACK = {
version: '0.0.21',
fileName: 'WaveKat Voice-0.0.21-arm64.dmg',
sizeBytes: 125294046,
};

export interface MacDownload {
/** Button label. */
label: string;
/** Human-friendly hardware requirement. */
arch: string;
/** Current version, e.g. "0.0.21". */
version: string;
/** Human-friendly size, e.g. "120 MB". */
size: string;
/** Full download URL (spaces percent-encoded). */
url: string;
}

function mb(bytes: number): string {
return `${Math.round(bytes / 1024 / 1024)} MB`;
}

// Memoize across pages so a single build does one fetch, not one per page.
let cache: Promise<MacDownload> | null = null;

export function getMacDownload(): Promise<MacDownload> {
if (!cache) cache = load();
return cache;
}

async function load(): Promise<MacDownload> {
let { version, fileName, sizeBytes } = FALLBACK;

try {
const res = await fetch(FEED, { signal: AbortSignal.timeout(8000) });
if (res.ok) {
const yml = await res.text();
const v = yml.match(/^version:\s*(.+)$/m);
if (v) version = v[1].trim();
// The .dmg file entry, plus the `size:` line that follows it.
const dmg = yml.match(/url:\s*(.+\.dmg)\s*\n\s*sha512:[^\n]*\n\s*size:\s*(\d+)/);
if (dmg) {
fileName = dmg[1].trim();
sizeBytes = parseInt(dmg[2], 10);
}
}
} catch {
// Network unavailable at build time — fall back to the constants above.
}

return {
label: 'Download for Mac',
arch: 'Macs with Apple chip (M1 or newer)',
version,
size: mb(sizeBytes),
url: `${DL_BASE}/${encodeURIComponent(fileName)}`,
};
}
Loading
Loading