From 6a7497033bc62025b4a6a8fbb08cfc1074c8d485 Mon Sep 17 00:00:00 2001
From: Eason WaveKat
Date: Sun, 26 Jul 2026 20:55:34 +1200
Subject: [PATCH 1/5] feat(brand): live wallpaper page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds /brand/wallpaper/ — a chrome-free, animated WaveKat wordmark on a
flowing waveform field, for leaving fullscreen on a spare monitor or
pointing a screen recorder / OBS browser source at.
- Aspect presets letterbox the stage (not the window), so a capture crop
can be framed to a real target: 16:9, 34-inch 3440x1440, 16:10,
MacBook, square, vertical. Each preset is a distinct aspect — a 4K chip
would letterbox identically to the 1080p one.
- Cursor reactivity, kept restrained: waves swell and brighten around the
pointer, the wordmark leans toward it, and a click sends a ripple out
through every layer. The influence decays to nothing ~1.8s after the
last move, so an unattended page never holds a bright spot where the
mouse was parked.
- Controls fade out after 3 idle seconds and return on pointer move.
H pins them hidden, F toggles fullscreen, 1-7 pick a preset. Preset and
motion persist; the theme toggle shares the site header's key.
- Motion off and prefers-reduced-motion both hold a still frame and
disable cursor reactivity.
Supporting changes:
- Base.astro takes a `siteBackground` prop so the page can skip the
shared backdrop rather than animating two canvases at once.
- The sitemap filter drops the page: it is noindex, and advertising a URL
we then tell crawlers to ignore is a contradictory signal.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EwxQJL43iApJRb4NS5p6vD
---
astro.config.mjs | 4 +
src/layouts/Base.astro | 9 +-
src/pages/brand/wallpaper.astro | 813 ++++++++++++++++++++++++++++++++
3 files changed, 825 insertions(+), 1 deletion(-)
create mode 100644 src/pages/brand/wallpaper.astro
diff --git a/astro.config.mjs b/astro.config.mjs
index a8e4c69..2a9aad3 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -159,6 +159,10 @@ export default defineConfig({
// pages that exist in multiple locales. Keys are URL slugs, values are the
// hreflang codes — so /zh/ is advertised as zh-Hans.
sitemap({
+ // Keep noindex pages out of the sitemap — advertising a URL we then tell
+ // crawlers to ignore is a contradictory signal. /brand/wallpaper/ is an
+ // internal brand tool, not a page we want indexed.
+ filter: (page) => !page.includes('/brand/wallpaper/'),
i18n: {
defaultLocale: 'en',
locales: {
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 4812fe7..91debd1 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -18,6 +18,12 @@ interface Props {
ogImageAlt?: string;
ogType?: 'website' | 'article';
noindex?: boolean;
+ /**
+ * Mount the shared animated waveform backdrop. Off only for pages that draw
+ * their own full-bleed field (the /brand/wallpaper/ live wallpaper), so two
+ * canvases never animate at once.
+ */
+ siteBackground?: boolean;
article?: {
publishedTime?: Date;
modifiedTime?: Date;
@@ -33,6 +39,7 @@ const {
ogImageAlt = 'WaveKat — open-source AI voice tools for small business',
ogType = 'website',
noindex = false,
+ siteBackground = true,
article,
} = Astro.props;
@@ -175,7 +182,7 @@ const orgSchema = {
-
+ {siteBackground && }
{bannerOffers.length > 0 && (
+
+
+
WaveKat live wallpaper
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ presets.map((p) => (
+
+ ))
+ }
+
+
+
+
+
+
+
+
+
F fullscreen · H hide controls · 1–7 presets
+
+
+
+
+
+
+
+
+
From 87d49378024a73b8923a1fad5e81734cd6812a0b Mon Sep 17 00:00:00 2001
From: Eason WaveKat
Date: Sun, 26 Jul 2026 21:00:25 +1200
Subject: [PATCH 2/5] feat(about): link the live wallpaper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One line at the end of "What we build", framed as what it is rather than
as a product. English only for now — the wallpaper page itself is
English-only, so a localized About page would be sending readers to an
English destination; the eight localized About pages are unchanged.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EwxQJL43iApJRb4NS5p6vD
---
src/pages/about.astro | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 333d5ac..13f4ff1 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -65,13 +65,18 @@ const aboutSchema = {
It's free during the public beta, and an AI assistant can
drive it for you.
-
+
Underneath it sits a family of open-source Rust libraries — voice activity detection, turn
detection, speech-to-text, text-to-speech, SIP/RTP transport — all published under the
WaveKat GitHub organization
and documented in the docs.
Building in the open keeps us honest: you can read the code that handles your calls.
+
+ Some of it is just for fun: there's a
+ live wallpaper
+ — the WaveKat mark on a moving waveform field — for a spare monitor or a recording backdrop.
+
From 4205ba2e765c0fc92dc75c14a3e631f1950bef51 Mon Sep 17 00:00:00 2001
From: Eason WaveKat
Date: Sun, 26 Jul 2026 21:07:53 +1200
Subject: [PATCH 3/5] feat(brand): optional clock on the wallpaper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A 24h HH:MM readout under the wordmark, off by default. Toggled from the
control panel or with C, and persisted alongside the preset.
- Lives inside .mark, so it floats and leans with the wordmark as one
lockup rather than drifting against it. Positioned at top:100%, so it
never affects .mark's own box.
- Sized in container units like the wordmark, so the pair holds its
proportions from a 9:16 phone shape to a 21:9 ultrawide. With the clock
showing, the lockup is lifted so it stays optically centred.
- Set in JetBrains Mono and muted rather than full-strength: the mark is
the subject, the clock is a readout.
- Not tied to the motion toggle — a clock isn't motion, and a stopped
clock on a wallpaper is worse than no clock. The tick is polled once a
second and only writes to the DOM on the minute, so it recovers on its
own from sleep, a timezone change, or the system clock being set.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EwxQJL43iApJRb4NS5p6vD
---
src/pages/brand/wallpaper.astro | 95 +++++++++++++++++++++++++++++++--
1 file changed, 91 insertions(+), 4 deletions(-)
diff --git a/src/pages/brand/wallpaper.astro b/src/pages/brand/wallpaper.astro
index d806648..5d856f3 100644
--- a/src/pages/brand/wallpaper.astro
+++ b/src/pages/brand/wallpaper.astro
@@ -14,7 +14,7 @@
// marketing page, so it's excluded from the sitemap (see astro.config.mjs) and
// isn't linked from the nav.
import Base from '../../layouts/Base.astro';
-import { Maximize2, Moon, Pause, Play, Sun } from '@lucide/astro';
+import { Clock, Maximize2, Moon, Pause, Play, Sun } from '@lucide/astro';
// `ratio` null = fill the whole viewport; otherwise the stage is letterboxed to
// that aspect. Labels name the device or the pixel size people actually think
@@ -68,6 +68,11 @@ const presets = [
SVG is used as an alpha mask, so the shimmer follows the letter
shapes instead of a rectangle. -->
+
+
+
@@ -93,6 +98,15 @@ const presets = [
+
-
F fullscreen · H hide controls · 1–7 presets
+
F fullscreen · H hide controls · C clock ·
+ 1–7 presets
@@ -215,6 +228,35 @@ const presets = [
display: block;
}
+ /* Sized against the frame, like the wordmark, so the lockup holds its
+ proportions from a 9:16 phone shape to a 21:9 ultrawide. Muted rather than
+ full-strength white: the mark is the subject, the clock is a readout. */
+ .clock {
+ position: absolute;
+ /* em-based, so the gap tracks the clock's own size across presets. The
+ wordmark SVG carries a little padding below the ribbon, so this is
+ tighter than it looks. */
+ top: calc(100% + 0.15em);
+ left: 50%;
+ translate: -50% 0;
+ font-family: var(--font-mono);
+ font-size: min(5.5cqw, 7.6cqh);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.06em;
+ line-height: 1;
+ white-space: nowrap;
+ color: rgb(17 24 39 / 0.55);
+ }
+ :global(.dark) .clock {
+ color: rgb(255 255 255 / 0.5);
+ }
+
+ /* With the clock showing, the lockup grew downward — lift it so the pair
+ still reads as optically centred in the frame. */
+ .stage[data-clock] .mark {
+ translate: calc(-50% + var(--px, 0px)) calc(-50% - 3cqh + var(--py, 0px));
+ }
+
.shimmer {
position: absolute;
inset: 0;
@@ -746,6 +788,47 @@ const presets = [
if (paused) render(lastT);
});
+ // ── Clock ───────────────────────────────────────────────────────────────
+ // 24h HH:MM. Not tied to `paused`: a clock isn't motion, and a stopped
+ // clock on a wallpaper is worse than no clock.
+ const clockEl = document.getElementById('clock') as HTMLTimeElement | null;
+ const clockBtn = document.getElementById('clock-toggle');
+ let clockOn = read().clock === 'on';
+ let clockTimer = 0;
+ let stamp = '';
+
+ function tick() {
+ const d = new Date();
+ const text = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+ if (text === stamp) return; // only touch the DOM on the minute
+ stamp = text;
+ if (clockEl) {
+ clockEl.textContent = text;
+ clockEl.dateTime = text;
+ }
+ }
+
+ function setClock(on: boolean, persist = true) {
+ clockOn = on;
+ if (clockEl) clockEl.hidden = !on;
+ if (on) stage!.dataset.clock = '';
+ else delete stage!.dataset.clock;
+ clockBtn?.setAttribute('aria-pressed', String(on));
+ clockBtn?.setAttribute('aria-label', on ? 'Hide clock' : 'Show clock');
+ clearInterval(clockTimer);
+ if (on) {
+ stamp = '';
+ tick();
+ // Polled rather than scheduled to the minute boundary: a second of
+ // string comparison is free, and it recovers on its own from a sleep,
+ // a timezone change, or the system clock being set.
+ clockTimer = window.setInterval(tick, 1000);
+ }
+ if (persist) write({ clock: on ? 'on' : 'off' });
+ }
+
+ clockBtn?.addEventListener('click', () => setClock(!clockOn));
+
// ── Fullscreen ──────────────────────────────────────────────────────────
const toggleFullscreen = () => {
if (document.fullscreenElement) document.exitFullscreen();
@@ -785,6 +868,9 @@ const presets = [
} else if (key === 'f') {
e.preventDefault();
toggleFullscreen();
+ } else if (key === 'c') {
+ setClock(!clockOn);
+ showControls();
} else if (key >= '1' && key <= String(chips.length)) {
applyPreset(chips[Number(key) - 1].dataset.preset!);
showControls();
@@ -800,6 +886,7 @@ const presets = [
}).observe(stage);
setMotion(!paused, false);
+ setClock(clockOn, false);
showControls();
// Don't burn a GPU on a hidden tab — but a wallpaper left on a second
From d320493877fc1e301bb0c69d8b0a263d10a4adbb Mon Sep 17 00:00:00 2001
From: Eason WaveKat
Date: Sun, 26 Jul 2026 21:18:03 +1200
Subject: [PATCH 4/5] refactor(brand): move wallpaper link to the footer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Swaps the About-page prose mention for a link in the footer's More
column, so it's reachable from any English page rather than only from
About. about.astro returns to its previous state byte for byte.
English-only, deliberately: the wallpaper page isn't translated, so the
link is offered where the reader is already reading English rather than
pointing a localized footer at an English destination — which also keeps
it from costing a ninth translation of a label for a brand extra. More is
the right column for it; Tools is for product surfaces.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EwxQJL43iApJRb4NS5p6vD
---
src/components/Footer.astro | 5 +++++
src/pages/about.astro | 7 +------
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
index fa1d288..a21441b 100644
--- a/src/components/Footer.astro
+++ b/src/components/Footer.astro
@@ -39,6 +39,11 @@ const groups = [
links: [
{ href: localeHref('/blog/'), label: ui.navBlog },
{ href: localeHref('/about/'), label: ui.footerAbout },
+ // English-only, deliberately: the wallpaper page itself isn't translated,
+ // so it's offered where the reader is already reading English rather than
+ // pointing a localized footer at an English destination. That also keeps
+ // it from costing a ninth translation of a label for a brand extra.
+ ...(lang === 'en' ? [{ href: '/brand/wallpaper/', label: 'Wallpaper' }] : []),
],
},
];
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 13f4ff1..333d5ac 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -65,18 +65,13 @@ const aboutSchema = {
It's free during the public beta, and an AI assistant can
drive it for you.
-
+
Underneath it sits a family of open-source Rust libraries — voice activity detection, turn
detection, speech-to-text, text-to-speech, SIP/RTP transport — all published under the
WaveKat GitHub organization
and documented in the docs.
Building in the open keeps us honest: you can read the code that handles your calls.
-
- Some of it is just for fun: there's a
- live wallpaper
- — the WaveKat mark on a moving waveform field — for a spare monitor or a recording backdrop.
-
From 6353c366136e8a2d423712e7721554efa4253840 Mon Sep 17 00:00:00 2001
From: Eason WaveKat
Date: Sun, 26 Jul 2026 21:22:19 +1200
Subject: [PATCH 5/5] feat(brand): export the wallpaper as a PNG
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a download button (and S) that writes the current wallpaper to a PNG
at the selected preset's real pixel size, so it can be saved and set as a
desktop background.
- Redrawn at the target resolution rather than scaled up from the
on-screen canvas, so a 3440x1440 wallpaper is genuinely 3440x1440 and
stays sharp on the display it's meant for. The stage's CSS radial
ground and the field's edge mask are reproduced on the canvas.
- Presets now carry pixel dimensions, not just an aspect, and drive both
the on-screen letterbox and the export size. Fit screen exports at the
display's real resolution including devicePixelRatio — which is also
why there's still no 4K chip: on a 4K monitor Fit screen already gives
3840x2160.
- The wave math moved into drawField(ctx, w, h, t, cursor), shared by the
live canvas and the export, so the file is the same artwork rather than
a lookalike that drifts as the on-screen version is tuned.
Three things are deliberately left out of the file: the cursor swell and
any in-flight ripples (a wallpaper shouldn't have a bright blob baked in
wherever the mouse was), the shimmer (a moving highlight, not a resting
state), and the clock (a desktop wallpaper frozen at 21:07 forever is a
bug, not a feature).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EwxQJL43iApJRb4NS5p6vD
---
src/pages/brand/wallpaper.astro | 253 +++++++++++++++++++++++++-------
1 file changed, 199 insertions(+), 54 deletions(-)
diff --git a/src/pages/brand/wallpaper.astro b/src/pages/brand/wallpaper.astro
index 5d856f3..b1d96ef 100644
--- a/src/pages/brand/wallpaper.astro
+++ b/src/pages/brand/wallpaper.astro
@@ -14,21 +14,25 @@
// marketing page, so it's excluded from the sitemap (see astro.config.mjs) and
// isn't linked from the nav.
import Base from '../../layouts/Base.astro';
-import { Clock, Maximize2, Moon, Pause, Play, Sun } from '@lucide/astro';
+import { Clock, Download, Maximize2, Moon, Pause, Play, Sun } from '@lucide/astro';
-// `ratio` null = fill the whole viewport; otherwise the stage is letterboxed to
-// that aspect. Labels name the device or the pixel size people actually think
-// in, not the bare ratio. Every entry is a *distinct* aspect — a 4K chip would
-// letterbox identically to the 1080p one, so pixel counts that only differ in
-// scale aren't worth a button on a live-only page.
+// Each preset does two jobs: it letterboxes the on-screen stage to that aspect,
+// and it's the pixel size the PNG export writes. Labels name the device or the
+// size people actually think in, not the bare ratio.
+//
+// `w: 0` means "fit the viewport" on screen and "match this display" on export —
+// the real screen resolution including devicePixelRatio. That's also why there's
+// no separate 4K chip: on a 4K monitor, Fit screen already exports 3840 × 2160,
+// and every fixed preset here is a distinct aspect rather than the same shape at
+// another scale.
const presets = [
- { id: 'fit', label: 'Fit screen', ratio: null },
- { id: '1080p', label: '16:9 (1920 × 1080)', ratio: 1920 / 1080 },
- { id: 'ultrawide', label: '34-inch (3440 × 1440)', ratio: 3440 / 1440 },
- { id: '16-10', label: '16:10 (2560 × 1600)', ratio: 2560 / 1600 },
- { id: 'macbook', label: 'MacBook (3024 × 1964)', ratio: 3024 / 1964 },
- { id: 'square', label: 'Square (1080 × 1080)', ratio: 1 },
- { id: 'vertical', label: 'Vertical (1080 × 1920)', ratio: 1080 / 1920 },
+ { id: 'fit', label: 'Fit screen', w: 0, h: 0 },
+ { id: '1080p', label: '16:9 (1920 × 1080)', w: 1920, h: 1080 },
+ { id: 'ultrawide', label: '34-inch (3440 × 1440)', w: 3440, h: 1440 },
+ { id: '16-10', label: '16:10 (2560 × 1600)', w: 2560, h: 1600 },
+ { id: 'macbook', label: 'MacBook (3024 × 1964)', w: 3024, h: 1964 },
+ { id: 'square', label: 'Square (1080 × 1080)', w: 1080, h: 1080 },
+ { id: 'vertical', label: 'Vertical (1080 × 1920)', w: 1080, h: 1920 },
];
---
@@ -87,7 +91,9 @@ const presets = [
type="button"
class="chip"
data-preset={p.id}
- data-ratio={p.ratio ?? ''}
+ data-ratio={p.w ? String(p.w / p.h) : ''}
+ data-w={String(p.w)}
+ data-h={String(p.h)}
aria-pressed="false"
>
{p.label}
@@ -118,9 +124,12 @@ const presets = [
+
F fullscreen · H hide controls · C clock ·
- 1–7 presets
+ S save PNG · 1–7 presets
@@ -583,51 +592,47 @@ const presets = [
let wroteX = 0;
let wroteY = 0;
- function render(t: number) {
- ctx!.clearRect(0, 0, w, h);
- // The palette can't just be re-alpha'd per theme: amber on white is
- // low-contrast at any opacity, so light mode drops to the deeper end of
- // the brand ramp and dark mode keeps the bright one.
- const dark = document.documentElement.classList.contains('dark');
- const ramp = dark
+ const isDark = () => document.documentElement.classList.contains('dark');
+
+ // The palette can't just be re-alpha'd per theme: amber on white is
+ // low-contrast at any opacity, so light mode drops to the deeper end of the
+ // brand ramp and dark mode keeps the bright one.
+ const rampFor = (dark: boolean) =>
+ dark
? { a: '255, 109, 0', b: '255, 160, 64', c: '255, 215, 64', boost: 1.9 }
: { a: '230, 81, 0', b: '255, 109, 0', c: '255, 145, 0', boost: 2.4 };
- // Let go of the cursor a beat after it stops, then ease back to calm.
- if (ptr.target > 0 && performance.now() - lastMove > 1800) ptr.target = 0;
- ptr.x += (ptr.tx - ptr.x) * 0.1;
- ptr.y += (ptr.ty - ptr.y) * 0.1;
- // Rises quickly, falls slowly — the swell should arrive with the cursor
- // and drain away like the water settling.
- ptr.energy += (ptr.target - ptr.energy) * (ptr.target > ptr.energy ? 0.14 : 0.035);
- const E = ptr.energy;
-
- // Reach scales with the stage so the effect reads the same on a phone-
- // shaped preset and on an ultrawide.
- const RX = Math.max(120, Math.min(w, h) * 0.3);
- const RY = Math.max(90, h * 0.2);
- const scale = h / 900;
-
- // Retire spent ripples before the per-x loop reads them.
- for (let i = ripples.length - 1; i >= 0; i--) {
- if (t - ripples[i].t0 > RIPPLE_LIFE) ripples.splice(i, 1);
- }
- const rippleSpeed = Math.max(w, h) * 0.62; // px/s the ring travels
+ /**
+ * Draws the wave field into any 2D context at any size. Shared by the live
+ * canvas and the PNG export, so an exported wallpaper is the same artwork
+ * rather than a lookalike that drifts as the on-screen version is tuned.
+ *
+ * `cursor` is off for the export: an exported wallpaper shouldn't have a
+ * swell baked in wherever the mouse happened to be.
+ */
+ function drawField(c: CanvasRenderingContext2D, W: number, H: number, t: number, cursor: boolean) {
+ const ramp = rampFor(isDark());
+ const E = cursor ? ptr.energy : 0;
+ const RX = Math.max(120, Math.min(W, H) * 0.3);
+ const RY = Math.max(90, H * 0.2);
+ const scale = H / 900;
+ const rippleSpeed = Math.max(W, H) * 0.62; // px/s the ring travels
const rippleWidth = 70 * scale; // thickness of the travelling crest
+ const active = cursor ? ripples : [];
for (const L of layers) {
// Amplitude and brightness breathe on their own slow, prime-ish cycles
// so no two layers peak together.
const breath = 1 + 0.28 * Math.sin(t * 0.19 + L.phase);
const glow = 0.75 + 0.45 * Math.sin(t * 0.13 + L.phase * 1.7);
- const baseY = h * L.y;
+ const baseY = H * L.y;
// How strongly the cursor reaches this layer's baseline vertically.
const dyl = baseY - ptr.y;
const vInf = E > 0.001 ? Math.exp(-(dyl * dyl) / (2 * RY * RY)) * E : 0;
- ctx!.beginPath();
- for (let x = 0; x <= w; x += 4) {
+ c.beginPath();
+ for (let x = 0; x <= W; x += 4) {
let amp = L.amp * breath * scale;
if (vInf > 0.001) {
const dx = x - ptr.x;
@@ -641,7 +646,7 @@ const presets = [
// Click ripples: a crest travelling out from the click point, fading
// with both distance behind the front and age.
- for (const r of ripples) {
+ for (const r of active) {
const age = t - r.t0;
const ddx = x - r.x;
const ddy = baseY - r.y;
@@ -657,8 +662,8 @@ const presets = [
}
const yy = baseY + y;
- if (x === 0) ctx!.moveTo(x, yy);
- else ctx!.lineTo(x, yy);
+ if (x === 0) c.moveTo(x, yy);
+ else c.lineTo(x, yy);
}
// Brand gradient along the wave, so a single stroke carries the full
@@ -667,24 +672,44 @@ const presets = [
// they light up.
const a = L.alpha * glow * ramp.boost;
if (vInf > 0.02) {
- const hot = ctx!.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, RX * 1.8);
+ const hot = c.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, RX * 1.8);
hot.addColorStop(0, `rgba(${ramp.c}, ${Math.min(0.5, a + 0.16 * vInf)})`);
hot.addColorStop(0.45, `rgba(${ramp.b}, ${a + 0.05 * vInf})`);
hot.addColorStop(1, `rgba(${ramp.a}, ${a * 0.8})`);
- ctx!.strokeStyle = hot;
+ c.strokeStyle = hot;
} else {
- const g = ctx!.createLinearGradient(0, 0, w, 0);
+ const g = c.createLinearGradient(0, 0, W, 0);
g.addColorStop(0, `rgba(${ramp.a}, ${a * 0.7})`);
g.addColorStop(0.5, `rgba(${ramp.b}, ${a})`);
g.addColorStop(1, `rgba(${ramp.c}, ${a * 0.8})`);
- ctx!.strokeStyle = g;
+ c.strokeStyle = g;
}
- ctx!.lineWidth = L.width;
- ctx!.stroke();
+ c.lineWidth = L.width * Math.max(1, scale);
+ c.stroke();
+ }
+ }
+
+ function render(t: number) {
+ ctx!.clearRect(0, 0, w, h);
+
+ // Let go of the cursor a beat after it stops, then ease back to calm.
+ if (ptr.target > 0 && performance.now() - lastMove > 1800) ptr.target = 0;
+ ptr.x += (ptr.tx - ptr.x) * 0.1;
+ ptr.y += (ptr.ty - ptr.y) * 0.1;
+ // Rises quickly, falls slowly — the swell should arrive with the cursor
+ // and drain away like the water settling.
+ ptr.energy += (ptr.target - ptr.energy) * (ptr.target > ptr.energy ? 0.14 : 0.035);
+
+ // Retire spent ripples before drawField reads them.
+ for (let i = ripples.length - 1; i >= 0; i--) {
+ if (t - ripples[i].t0 > RIPPLE_LIFE) ripples.splice(i, 1);
}
+ drawField(ctx!, w, h, t, true);
+
// The wordmark leans toward the cursor. Written as a custom property so
// it composes with the float animation's transform instead of fighting it.
+ const E = ptr.energy;
const tpx = E > 0.001 ? ((ptr.x - w / 2) / (w / 2)) * PARALLAX * E : 0;
const tpy = E > 0.001 ? ((ptr.y - h / 2) / (h / 2)) * PARALLAX * E : 0;
parallaxX += (tpx - parallaxX) * 0.08;
@@ -713,6 +738,7 @@ const presets = [
stage!.style.removeProperty('--ratio');
}
if (persist) write({ preset: chip.dataset.preset });
+ updateExportLabel();
// The stage just changed size — the canvas has to follow before the
// next frame, or one frame renders stretched.
resize();
@@ -829,6 +855,121 @@ const presets = [
clockBtn?.addEventListener('click', () => setClock(!clockOn));
+ // ── PNG export ──────────────────────────────────────────────────────────
+ // Redrawn at the target resolution rather than scaled up from the on-screen
+ // canvas, so a 3440 × 1440 wallpaper is genuinely 3440 × 1440 — sharp on the
+ // display it's meant for.
+ //
+ // Three things are deliberately left out of the file:
+ // • the cursor swell and any in-flight ripples — a wallpaper shouldn't
+ // have a bright blob baked in wherever the mouse was;
+ // • the shimmer, which is a moving highlight, not a resting state;
+ // • the clock, because a desktop wallpaper frozen at 21:07 forever is a
+ // bug, not a feature.
+ const exportBtn = document.getElementById('export-png');
+
+ /** Target pixel size: the preset's own, or this display's real resolution. */
+ function exportSize(): [number, number] {
+ const chip = chips.find((c) => c.getAttribute('aria-pressed') === 'true');
+ const pw = Number(chip?.dataset.w || 0);
+ const ph = Number(chip?.dataset.h || 0);
+ if (pw && ph) return [pw, ph];
+ const dpr = window.devicePixelRatio || 1;
+ return [Math.round(screen.width * dpr), Math.round(screen.height * dpr)];
+ }
+
+ /** CSS `radial-gradient(ellipse RX% RY% at CX% CY%, …)` on a 2D context. */
+ function ellipseGradient(
+ c: CanvasRenderingContext2D,
+ W: number,
+ H: number,
+ rx: number,
+ ry: number,
+ cx: number,
+ cy: number,
+ stops: [number, string][],
+ ) {
+ c.save();
+ c.translate(W * cx, H * cy);
+ const a = W * rx;
+ const b = H * ry;
+ c.scale(1, b / a);
+ const g = c.createRadialGradient(0, 0, 0, 0, 0, a);
+ for (const [at, color] of stops) g.addColorStop(at, color);
+ c.fillStyle = g;
+ // Generous rect: the vertical scale shrinks the paintable area, so cover
+ // well past the canvas in the transformed space.
+ c.fillRect(-W * 2, (-H * 2 * a) / b, W * 4, (H * 4 * a) / b);
+ c.restore();
+ }
+
+ function updateExportLabel() {
+ const [W, H] = exportSize();
+ const text = `Download PNG (${W} × ${H})`;
+ exportBtn?.setAttribute('aria-label', text);
+ exportBtn?.setAttribute('title', text);
+ }
+
+ async function exportPng() {
+ const [W, H] = exportSize();
+ const dark = isDark();
+
+ const out = document.createElement('canvas');
+ out.width = W;
+ out.height = H;
+ const c = out.getContext('2d');
+ if (!c) return;
+
+ // 1. Ground — the same radial the stage carries in CSS.
+ c.fillStyle = dark ? '#04070c' : '#eef0f4';
+ c.fillRect(0, 0, W, H);
+ ellipseGradient(c, W, H, 0.9, 0.8, 0.5, 0.45, [
+ [0, dark ? '#0d141f' : '#ffffff'],
+ [1, dark ? 'rgba(4, 7, 12, 0)' : 'rgba(238, 240, 244, 0)'],
+ ]);
+
+ // 2. Waves, drawn on their own canvas so the edge mask can be applied
+ // with destination-in without eating the background underneath.
+ const field = document.createElement('canvas');
+ field.width = W;
+ field.height = H;
+ const fc = field.getContext('2d');
+ if (fc) {
+ drawField(fc, W, H, lastT, false);
+ fc.globalCompositeOperation = 'destination-in';
+ ellipseGradient(fc, W, H, 1.2, 1.05, 0.5, 0.5, [
+ [0, '#000'],
+ [0.6, '#000'],
+ [1, 'rgba(0, 0, 0, 0)'],
+ ]);
+ fc.globalCompositeOperation = 'source-over';
+ c.drawImage(field, 0, 0);
+ }
+
+ // 3. Wordmark, at the same fraction of the frame the CSS gives it.
+ const img = document.querySelector(dark ? '.mark-dark' : '.mark-light');
+ if (img) {
+ if (!img.complete) await img.decode().catch(() => {});
+ const mw = Math.min(0.72 * W, 1.0 * H);
+ const mh = (mw * img.naturalHeight) / img.naturalWidth;
+ c.drawImage(img, (W - mw) / 2, (H - mh) / 2, mw, mh);
+ }
+
+ const blob: Blob | null = await new Promise((res) => out.toBlob(res, 'image/png'));
+ if (!blob) return;
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `wavekat-wallpaper-${W}x${H}${dark ? '' : '-light'}.png`;
+ a.click();
+ // Revoke on the next turn — revoking synchronously can beat the download.
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
+ }
+
+ exportBtn?.addEventListener('click', () => {
+ exportPng().catch(() => {});
+ });
+
// ── Fullscreen ──────────────────────────────────────────────────────────
const toggleFullscreen = () => {
if (document.fullscreenElement) document.exitFullscreen();
@@ -868,6 +1009,10 @@ const presets = [
} else if (key === 'f') {
e.preventDefault();
toggleFullscreen();
+ } else if (key === 's') {
+ e.preventDefault();
+ exportPng().catch(() => {});
+ showControls();
} else if (key === 'c') {
setClock(!clockOn);
showControls();