From 269c9b00070c0814890303cf1281e17a441fa889 Mon Sep 17 00:00:00 2001 From: stuffbucket <231133237+stuffbucket@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:49:04 -0700 Subject: [PATCH 1/2] design(site): god-rays in both themes, Windows installer pickup - God rays now render in light mode too: a transparent (premultiplied-alpha) canvas lets the warm-paper page show through the gaps, and a uLight-adapted shader draws deeper, softer shafts on paper while keeping the dark-mode glow unchanged - Pick up the release's Windows *-setup.exe from the resolved asset list: the Get Started and hero download buttons light up and link to the installer when one exists, and stay an inert 'coming soon' chip otherwise - Disable the cursor-reflection coat interaction for now (gated behind a flag, code left in place) --- site/src/components/GodRays.astro | 58 +++++++++++--------- site/src/components/Paint.astro | 4 +- site/src/components/markdoc/GetStarted.astro | 51 +++++++++++------ site/src/components/markdoc/Hero.astro | 20 ++++++- site/src/lib/downloads.ts | 17 +++++- site/src/lib/version.ts | 48 +++++++++++++--- 6 files changed, 144 insertions(+), 54 deletions(-) diff --git a/site/src/components/GodRays.astro b/site/src/components/GodRays.astro index d151856..7ae35c8 100644 --- a/site/src/components/GodRays.astro +++ b/site/src/components/GodRays.astro @@ -3,9 +3,9 @@ // // - Inlined GLSL (no third-party shader library) + raw WebGL (no Three.js) to // keep the page lean. -// - DARK MODE ONLY: light shafts read on the black dark-theme background; on the -// light (warm paper) theme the canvas is hidden via CSS and the render loop -// stays paused. +// - BOTH THEMES: the canvas is transparent (premultiplied alpha), so the page +// background shows through the gaps between shafts. Dark mode glows on the +// near-black page; light mode shows softer, deeper shafts on the warm paper. // - Reduced motion is a contract: under prefers-reduced-motion we draw a single // static frame, never an animation loop. // - Pauses when the tab is hidden. pointer-events:none, z-index 0 — it sits @@ -21,11 +21,12 @@ function initGodRays(canvas: HTMLCanvasElement): void { const gl = canvas.getContext("webgl", { antialias: false, - alpha: false, + alpha: true, + premultipliedAlpha: true, depth: false, powerPreference: "low-power", }) - if (!gl) return // No WebGL → the black page background is a fine fallback. + if (!gl) return // No WebGL → the page background is a fine fallback. const vert = ` attribute vec2 p; @@ -43,6 +44,7 @@ uniform float uTime; uniform float uIntensity; uniform float uLightY; + uniform float uLight; uniform vec3 uColorA; uniform vec3 uColorB; uniform vec3 uColorC; @@ -77,12 +79,24 @@ // Cyclic blend across the brand jewel tones by ray angle, so adjacent // shafts differ in hue; drifts slowly over time. float g = fract(ang / 6.28318 * 1.2 + uTime * 0.06); - vec3 col; - if (g < 0.33333) col = mix(uColorA, uColorB, g * 3.0); - else if (g < 0.66667) col = mix(uColorB, uColorC, (g - 0.33333) * 3.0); - else col = mix(uColorC, uColorA, (g - 0.66667) * 3.0); - col *= v; - gl_FragColor = vec4(col, 1.0); + vec3 hue; + if (g < 0.33333) hue = mix(uColorA, uColorB, g * 3.0); + else if (g < 0.66667) hue = mix(uColorB, uColorC, (g - 0.33333) * 3.0); + else hue = mix(uColorC, uColorA, (g - 0.66667) * 3.0); + + // Premultiplied-alpha output so the page background shows through the + // gaps in both themes. Dark: bright shafts glowing on the near-black + // page. Light: deeper, slightly stronger shafts that read on warm paper. + vec3 rgb; + float a; + if (uLight > 0.5) { + rgb = hue * 0.82; + a = clamp(v * 1.15, 0.0, 0.55); + } else { + rgb = hue; + a = clamp(v, 0.0, 1.0); + } + gl_FragColor = vec4(rgb * a, a); } ` @@ -131,6 +145,7 @@ const uColorB = gl.getUniformLocation(prog, "uColorB") const uColorC = gl.getUniformLocation(prog, "uColorC") const uLightY = gl.getUniformLocation(prog, "uLightY") + const uLight = gl.getUniformLocation(prog, "uLight") gl.uniform1f(uIntensity, 0.62) // Brand jewel tones (dark-mode tints): turquoise → indigo → magenta. No // gold — these coordinate with the teal cards and complement the red hero. @@ -176,6 +191,7 @@ function frame(now: number): void { gl!.uniform1f(uTime, (now - start) / 1000) gl!.uniform1f(uLightY, lightY()) + gl!.uniform1f(uLight, darkMM.matches ? 0 : 1) gl!.drawArrays(gl!.TRIANGLES, 0, 3) if (running) rafId = requestAnimationFrame(frame) } @@ -192,18 +208,16 @@ const darkMM = window.matchMedia("(prefers-color-scheme: dark)") const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches - // Active only in dark mode. In light mode CSS hides the canvas and we keep - // the loop paused. + // Active in both themes (the shader adapts via uLight). Pauses only for + // reduced motion (single static frame) and hidden tabs. function sync(): void { - if (!darkMM.matches) { - pause() - return - } if (reduced) { pause() frame(performance.now()) // single static frame } else if (document.visibilityState === "visible") { play() + } else { + pause() } } @@ -212,7 +226,7 @@ "resize", () => { resize() - if (!running && darkMM.matches) frame(performance.now()) + if (!running) frame(performance.now()) }, { passive: true }, ) @@ -229,7 +243,7 @@ window.addEventListener( "scroll", () => { - if (running || !reduced || !darkMM.matches || scrollPending) return + if (running || !reduced || scrollPending) return scrollPending = true requestAnimationFrame((now) => { scrollPending = false @@ -251,10 +265,4 @@ pointer-events: none; display: block; } - /* Light theme keeps its warm-paper background — no shafts there. */ - @media (prefers-color-scheme: light) { - canvas.godrays { - display: none; - } - } diff --git a/site/src/components/Paint.astro b/site/src/components/Paint.astro index ee13054..f1a372f 100644 --- a/site/src/components/Paint.astro +++ b/site/src/components/Paint.astro @@ -415,8 +415,10 @@ const CFG = { // the imaginary pointer's reflection follows the mouse. The canvas is // pointer-events:none, so listen on the host card. rAF-throttled; skipped // under reduced motion. Live frames already re-read the uniforms each tick. + // DISABLED FOR NOW — flip CURSOR_REFLECTION to re-enable. + const CURSOR_REFLECTION = false const host = canvas.parentElement - if (cfg.coat === 1 && host && !reduced) { + if (CURSOR_REFLECTION && cfg.coat === 1 && host && !reduced) { let queued = false host.addEventListener( "pointermove", diff --git a/site/src/components/markdoc/GetStarted.astro b/site/src/components/markdoc/GetStarted.astro index 7b194eb..ffdcc5a 100644 --- a/site/src/components/markdoc/GetStarted.astro +++ b/site/src/components/markdoc/GetStarted.astro @@ -6,14 +6,17 @@ import Paint from "../Paint.astro"; import Spark from "../Spark.astro"; import { getDownloadInfo } from "../../lib/downloads"; -const { macDmg, versionLabel, releasesUrl, repoUrl } = await getDownloadInfo(); +const { macDmg, winSetup, hasWindows, versionLabel, releasesUrl, repoUrl } = + await getDownloadInfo(); const readmeUrl = `${repoUrl}#readme`; const M = `maximal`; const steps = [ { title: "Install maximal", - body: "Grab the macOS app. It lives quietly in your menu bar; Windows is coming soon.", + body: hasWindows + ? "Grab the app for macOS or Windows; it sits quietly in your menu bar or system tray." + : "Grab the macOS app. It sits quietly in your menu bar; Windows is coming soon.", }, { title: "Sign in with GitHub", @@ -63,20 +66,36 @@ const steps = [ {versionLabel && {versionLabel}} -
- - - Windows - - coming soon -
+ {hasWindows ? ( + + + + + Windows + + {versionLabel && {versionLabel}} + + ) : ( +
+ + + Windows + + coming soon +
+ )}

Homebrew and other options are in the{" "} diff --git a/site/src/components/markdoc/Hero.astro b/site/src/components/markdoc/Hero.astro index f437485..ddbc8bb 100644 --- a/site/src/components/markdoc/Hero.astro +++ b/site/src/components/markdoc/Hero.astro @@ -13,7 +13,7 @@ interface Props { const { tagline, downloadLabel = "Download for macOS" } = Astro.props; // Markdoc doesn't forward $variables into tag attributes, so pull the resolved // release straight from the shared (memoized) build-time module. -const { macDmg: downloadUrl, versionLabel } = await getDownloadInfo(); +const { macDmg: downloadUrl, versionLabel, winSetup } = await getDownloadInfo(); ---

@@ -28,7 +28,12 @@ const { macDmg: downloadUrl, versionLabel } = await getDownloadInfo();
{ downloadUrl && ( - + @@ -149,13 +154,22 @@ const { macDmg: downloadUrl, versionLabel } = await getDownloadInfo(); if (!isWindows) return; var btn = document.querySelector(".hero-cta__primary"); if (!btn) return; + var label = btn.querySelector(".btn-label span:last-child"); + var winSetup = btn.getAttribute("data-win-setup"); + if (winSetup) { + // A Windows installer exists in the release → keep the primary affordance + // lit and point it at the .exe. + btn.setAttribute("href", winSetup); + if (label) label.textContent = "Download for Windows"; + return; + } + // No Windows build yet → an honest inert "coming soon" chip. btn.classList.remove("btn--primary"); btn.classList.add("btn--soon"); btn.removeAttribute("href"); btn.setAttribute("aria-disabled", "true"); var paint = btn.querySelector(".btn-paint"); if (paint) paint.style.display = "none"; - var label = btn.querySelector(".btn-label span:last-child"); if (label) label.textContent = "Windows"; var meta = btn.querySelector(".btn-meta"); if (meta) meta.textContent = "coming soon"; diff --git a/site/src/lib/downloads.ts b/site/src/lib/downloads.ts index 00f719f..e1d02d8 100644 --- a/site/src/lib/downloads.ts +++ b/site/src/lib/downloads.ts @@ -17,6 +17,11 @@ export interface DownloadInfo { /** Direct .dmg URL when a release exists, else the releases listing. */ macDmg: string; macDmgFile: string; + /** Direct Windows installer URL when the release ships a *-setup.exe, else null. */ + winSetup: string | null; + winSetupFile: string | null; + /** True when the release carries a Windows *-setup.exe artifact. */ + hasWindows: boolean; /** Human label, e.g. "v0.4.32" or "see /releases". */ versionLabel: string; tag: string | null; @@ -31,19 +36,29 @@ export function getDownloadInfo(): Promise { } async function compute(): Promise { - const { tag, hasRelease } = await resolveLatestRelease(); + const { tag, hasRelease, assets } = await resolveLatestRelease(); const assetUrl = (filename: string): string => hasRelease && tag ? `${REPO_URL}/releases/download/${tag}/${filename}` : RELEASES_URL; const versionForAsset = tag ?? "latest"; const macDmgFile = `maximal-${versionForAsset}-darwin-arm64.dmg`; + + // Windows: only advertise the installer when the release actually ships a + // *-setup.exe (the Tauri NSIS artifact). We pick it up from the resolved + // asset list rather than guessing a filename, so the button stays "coming + // soon" until a real Windows build is attached. + const winAsset = assets.find((a) => /-setup\.exe$/i.test(a.name)) ?? null; + return { repo: REPO, repoUrl: REPO_URL, releasesUrl: RELEASES_URL, macDmg: assetUrl(macDmgFile), macDmgFile, + winSetup: winAsset?.url ?? null, + winSetupFile: winAsset?.name ?? null, + hasWindows: winAsset !== null, versionLabel: hasRelease && tag ? tag : "see /releases", tag, hasRelease, diff --git a/site/src/lib/version.ts b/site/src/lib/version.ts index f8d2b4e..b69836b 100644 --- a/site/src/lib/version.ts +++ b/site/src/lib/version.ts @@ -5,10 +5,34 @@ const REPO = "stuffbucket/maximal"; +export interface ReleaseAsset { + /** Asset filename, e.g. "maximal_0.4.34_x64-setup.exe". */ + name: string; + /** Direct browser download URL for the asset. */ + url: string; +} + export interface ReleaseInfo { /** The latest release tag, e.g. "v0.4.32", or null when no release exists. */ tag: string | null; hasRelease: boolean; + /** Release assets (empty for a pinned version — we don't fetch its assets). */ + assets: ReleaseAsset[]; +} + +function parseAssets(raw: unknown): ReleaseAsset[] { + if (!Array.isArray(raw)) return []; + const out: ReleaseAsset[] = []; + for (const item of raw) { + const asset = item as { name?: unknown; browser_download_url?: unknown }; + if ( + typeof asset.name === "string" && + typeof asset.browser_download_url === "string" + ) { + out.push({ name: asset.name, url: asset.browser_download_url }); + } + } + return out; } function githubHeaders(): Record { @@ -47,23 +71,27 @@ async function fetchGitHubReleaseJson(path: string): Promise { async function fetchLatestTag(): Promise { const body = await fetchGitHubReleaseJson("releases/latest"); - if (!body) return { tag: null, hasRelease: false }; - const release = body as { tag_name?: string }; + if (!body) return { tag: null, hasRelease: false, assets: [] }; + const release = body as { tag_name?: string; assets?: unknown }; if (typeof release.tag_name === "string" && release.tag_name.length > 0) { - return { tag: release.tag_name, hasRelease: true }; + return { + tag: release.tag_name, + hasRelease: true, + assets: parseAssets(release.assets), + }; } throw new Error("GitHub releases API response missing tag_name"); } async function fetchLatestPrereleaseTag(): Promise { const body = await fetchGitHubReleaseJson("releases?per_page=100"); - if (!body) return { tag: null, hasRelease: false }; + if (!body) return { tag: null, hasRelease: false, assets: [] }; if (!Array.isArray(body)) { throw new Error("GitHub releases API response was not a release list"); } const release = body.find( - (item): item is { tag_name: string } => { + (item): item is { tag_name: string; assets?: unknown } => { const release = item as { tag_name?: unknown; prerelease?: unknown; @@ -76,8 +104,12 @@ async function fetchLatestPrereleaseTag(): Promise { ); }, ); - if (!release) return { tag: null, hasRelease: false }; - return { tag: release.tag_name, hasRelease: true }; + if (!release) return { tag: null, hasRelease: false, assets: [] }; + return { + tag: release.tag_name, + hasRelease: true, + assets: parseAssets(release.assets), + }; } /** @@ -89,7 +121,7 @@ async function fetchLatestPrereleaseTag(): Promise { */ export async function resolveLatestRelease(): Promise { const pinned = (import.meta.env.VITE_SITE_PIN_VERSION ?? "").trim(); - if (pinned) return { tag: pinned, hasRelease: true }; + if (pinned) return { tag: pinned, hasRelease: true, assets: [] }; return fetchLatestTag(); } From e46840b5fb909005f1c124d61cae8d5056e848d2 Mon Sep 17 00:00:00 2001 From: stuffbucket <231133237+stuffbucket@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:52:59 -0700 Subject: [PATCH 2/2] fix(site): bypass stale releases/latest CDN cache at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deploy fired minutes after a publish could resolve a release whose just-attached assets (notably the Windows *-setup.exe) were missing from a CDN-cached releases/latest body — the runner's region kept serving a stale response well past its 60s s-maxage. The page then shipped a 'coming soon' Windows chip for a build that actually exists. Force revalidation with no-cache headers + a per-build cache-buster query param so the build always sees current release state. --- site/src/lib/version.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/site/src/lib/version.ts b/site/src/lib/version.ts index b69836b..0c373f7 100644 --- a/site/src/lib/version.ts +++ b/site/src/lib/version.ts @@ -45,13 +45,28 @@ function githubHeaders(): Record { Accept: "application/vnd.github+json", "User-Agent": "maximal-site-build", "X-GitHub-Api-Version": "2022-11-28", + // The releases API is CDN-cached (s-maxage=60). On a release-day rebuild + // the runner's region can keep serving a stale `releases/latest` body for + // far longer than 60s — long enough that a deploy fired minutes after a + // publish resolves a release whose just-attached assets (e.g. the Windows + // *-setup.exe) are missing, silently shipping a "coming soon" button for a + // build that actually exists. Force revalidation so the build always sees + // current release state. + "Cache-Control": "no-cache", + Pragma: "no-cache", }; if (token) headers.Authorization = `Bearer ${token}`; return headers; } async function fetchGitHubReleaseJson(path: string): Promise { - const res = await fetch(`https://api.github.com/repos/${REPO}/${path}`, { + // Per-build cache-buster: the GitHub API cache key includes the query + // string, so a unique param guarantees a MISS and a fresh body — belt-and- + // suspenders alongside the no-cache headers above, since the CDN doesn't + // always honor request cache directives. + const sep = path.includes("?") ? "&" : "?"; + const url = `https://api.github.com/repos/${REPO}/${path}${sep}_cb=${Date.now()}`; + const res = await fetch(url, { headers: githubHeaders(), }); // 404 is the *legitimate* "no published release yet" state (first launch) —