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..0c373f7 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 {
@@ -21,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) —
@@ -47,23 +86,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 +119,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 +136,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();
}