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 · 17 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 · 17 presets

+

F fullscreen · H hide controls · C clock · + 17 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 · - 17 presets

+ S save PNG · 17 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();