feat(gaze): add Gaze subdomain landing page with direct download

Move the Gaze marketing home from the legacy /marketing/gaze route to
a dedicated gaze subdomain landing page (src/routes/gaze). The new page
adds a richer hero, feature highlights, and a direct .dmg download flow
that resolves a signed S3 URL via the tRPC downloads.getDownloadUrl
endpoint.

Extract the download-resolution logic into a shared, testable
downloadAsset helper (src/lib/download-asset) so current and future
subdomain landing pages (InputHalo, Lineage, …) share one code path.
The helper is pure over an injected DownloadApi + redirect sink, keeping
it unit-testable without importing solid-js / CSRF cookie code.

Replace the old marketing/gaze route with a permanent 308 redirect to
https://gaze.freno.me so existing inbound links keep resolving to the
canonical home.

Add unit tests covering the tRPC call shape, redirect sink, and error
handling.
This commit is contained in:
2026-07-23 12:15:04 -04:00
parent 6fdbbbbe2b
commit f2ff75e6df
4 changed files with 359 additions and 56 deletions

View File

@@ -0,0 +1,88 @@
/**
* Unit tests for the shared `downloadAsset` helper (task 05).
*
* The helper is a pure function over an injected `DownloadApi` + redirect sink,
* so these tests verify the tRPC call shape, redirect, and error handling
* without importing `~/lib/api` (which pulls in solid-js / CSRF cookie code).
*/
import { describe, it, expect, mock } from "bun:test";
import { downloadAsset, type DownloadApi } from "~/lib/download-asset";
function makeFakeApi(
url: string,
seen: { input: { asset_name: string } }[] = []
): DownloadApi {
return {
downloads: {
getDownloadUrl: {
query: async (input: { asset_name: string }) => {
seen.push({ input });
return { downloadURL: url };
}
}
}
};
}
describe("downloadAsset", () => {
it("queries getDownloadUrl with the provided asset_name", async () => {
const seen: { input: { asset_name: string } }[] = [];
const api = makeFakeApi("https://s3/gaze.dmg", seen);
await downloadAsset({ api, assetName: "gaze", redirect: () => {} });
expect(seen).toHaveLength(1);
expect(seen[0]!.input.asset_name).toBe("gaze");
});
it("redirects to the returned signed URL", async () => {
const api = makeFakeApi("https://s3/gaze.dmg");
const sink = mock((u: string) => {});
await downloadAsset({ api, assetName: "gaze", redirect: sink });
expect(sink).toHaveBeenCalledTimes(1);
expect(sink).toHaveBeenCalledWith("https://s3/gaze.dmg");
});
it("routes failures to onError without throwing", async () => {
const api: DownloadApi = {
downloads: {
getDownloadUrl: {
query: async () => {
throw new Error("boom");
}
}
}
};
const sink = mock((u: string) => {});
const errSink = mock((e: unknown) => {});
await expect(
downloadAsset({ api, assetName: "gaze", redirect: sink, onError: errSink })
).resolves.toBeUndefined();
expect(sink).not.toHaveBeenCalled();
expect(errSink).toHaveBeenCalledTimes(1);
});
it("swallows errors silently when no onError is provided", async () => {
const api: DownloadApi = {
downloads: {
getDownloadUrl: {
query: async () => {
throw new Error("boom");
}
}
}
};
const sink = mock((u: string) => {});
await expect(
downloadAsset({ api, assetName: "gaze", redirect: sink })
).resolves.toBeUndefined();
expect(sink).not.toHaveBeenCalled();
});
it("works for any asset name (not hard-coded to gaze)", async () => {
const seen: { input: { asset_name: string } }[] = [];
const api = makeFakeApi("https://s3/inputhalo.dmg", seen);
const sink = mock((u: string) => {});
await downloadAsset({ api, assetName: "inputhalo", redirect: sink });
expect(seen[0]!.input.asset_name).toBe("inputhalo");
expect(sink).toHaveBeenCalledWith("https://s3/inputhalo.dmg");
});
});

76
src/lib/download-asset.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* Pure, testable helper for triggering a signed-S3 download via the tRPC
* `downloads.getDownloadUrl` endpoint.
*
* Extracted (task 05) so the Gaze landing page's download button — and any
* other subdomain landing page that needs the same flow (InputHalo, Lineage,
* …) — can share a single code path AND be unit-tested without importing
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
*
* The component layer supplies the concrete `api` (dynamic-imported at click
* time) and the `redirect` sink (defaults to `window.location.href = url`).
* Tests inject a fake `api` and a capture sink.
*/
/** Structural shape of the tRPC downloads proxy this helper depends on. */
export interface DownloadApi {
downloads: {
getDownloadUrl: {
query: (input: { asset_name: string }) => Promise<{
downloadURL: string;
}>;
};
};
}
/** A `(url) => void` sink the helper calls with the signed S3 URL. */
export type DownloadRedirect = (url: string) => void;
export interface DownloadAssetOptions {
/** tRPC proxy (or fake) exposing `downloads.getDownloadUrl.query`. */
api: DownloadApi;
/** Asset key known to the downloads router (e.g. `"gaze"`). */
assetName: string;
/** Called with the signed URL. Defaults to `window.location.href = url`. */
redirect?: DownloadRedirect;
/** Invoked on failure; defaults to a no-op (component shows its own UI). */
onError?: (error: unknown) => void;
}
/** Default redirect sink — browser navigation to the signed S3 URL. */
const defaultRedirect: DownloadRedirect = (url) => {
if (typeof window !== "undefined") {
window.location.href = url;
}
};
/**
* Resolve the latest signed download URL for `assetName` and redirect the
* browser to it. Never throws — failures are routed to `onError`.
*
* @example
* ```ts
* import("~/lib/api").then(({ api }) => {
* downloadAsset({ api, assetName: "gaze" });
* });
* ```
*/
export async function downloadAsset(
options: DownloadAssetOptions
): Promise<void> {
const {
api,
assetName,
redirect = defaultRedirect,
onError
} = options;
try {
const data = await api.downloads.getDownloadUrl.query({
asset_name: assetName
});
redirect(data.downloadURL);
} catch (error) {
if (onError) onError(error);
}
}

175
src/routes/gaze/index.tsx Normal file
View File

@@ -0,0 +1,175 @@
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode";
import { downloadAsset } from "~/lib/download-asset";
const GAZE_APP_STORE_URL =
"https://apps.apple.com/us/app/gaze/id6757759498";
const GAZE_MIN_MACOS = "13.0";
const FEATURES = [
{
title: "Take regular breaks",
body: "Gentle, dismissable reminders nudge you to step away from the screen so you can rest and reset."
},
{
title: "Eye health reminders",
body: "Follow the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds."
},
{
title: "Posture reminders",
body: "Periodic check-ins help you catch slouching before it becomes a habit."
},
{
title: "Lives in your menu bar",
body: "A lightweight menu bar app — no dock icon, no intrusive overlays. Just a quiet, reliable companion."
}
] as const;
export default function GazeLanding() {
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const handleDownload = () => {
if (loading()) return;
setLoading(true);
import("~/lib/api")
.then(({ api }) =>
downloadAsset({
api,
assetName: "gaze",
onError: (error) => {
console.error("Gaze download error:", error);
alert("Failed to initiate download. Please try again.");
}
})
)
.finally(() => setLoading(false));
};
return (
<>
<PageHead
title="Eye & posture health reminder for macOS"
description="Gaze is a macOS menu bar app for eye and posture health — gentle reminders to take breaks, rest your eyes, and sit up straight. Download Gaze for macOS."
ogImage="/look-away.png"
ogTitle="Gaze — Eye and posture health reminder for macOS"
ogDescription="A macOS menu bar app that helps you remember to take breaks and rest your eyes."
/>
{/* ── Hero ─────────────────────────────────────────────────────── */}
<div class="relative flex min-h-screen flex-col">
<div class="fixed inset-0 z-0 overflow-hidden brightness-75">
<img
src="/look-away.png"
alt="Look away — Gaze hero background"
class="h-full w-full select-none object-cover"
style={{ "pointer-events": "none" }}
/>
</div>
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center text-white backdrop-blur-sm">
<img
src={
isDark()
? "/Gaze Exports/Gaze-iOS-Dark-1024x1024@1x.png"
: "/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png"
}
alt="Gaze App Icon"
height={128}
width={128}
class="h-32 w-32 rounded-[22%] object-cover object-center shadow-2xl"
/>
<h1 class="py-4 text-5xl font-bold tracking-tight">Gaze</h1>
<p class="mb-2 max-w-xl text-xl text-white/90">
Eye and posture health reminder for macOS
</p>
<p class="mb-8 text-sm text-white/60">
macOS {GAZE_MIN_MACOS}+ · menu bar app
</p>
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
loading={loading()}
onClick={handleDownload}
>
download.dmg
</Button>
<a
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={GAZE_APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</a>
</div>
<p class="mt-3 text-xs text-white/50">
Direct download serves the latest signed macOS build.
</p>
</div>
</div>
{/* ── Feature highlights ───────────────────────────────────────── */}
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
<div class="mx-auto max-w-4xl">
<h2 class="text-text mb-12 text-center text-3xl font-bold">
Small reminders, healthier habits
</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
{FEATURES.map((feature) => (
<div
class="border-overlay0 bg-surface0 rounded-lg border p-6"
>
<h3 class="text-text mb-2 text-xl font-semibold">
{feature.title}
</h3>
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
</div>
))}
</div>
</div>
</section>
{/* ── App preview / CTA ──────────────────────────────────────── */}
<section class="bg-surface0 relative z-20 px-4 py-20 md:px-8">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-text mb-4 text-3xl font-bold">
A quieter way to look after yourself
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
Gaze runs quietly in your menu bar, surfacing a gentle, dismissable
reminder when it's time to look away, stretch, or reset your
posture. No accounts, no clunky dashboards just a steady rhythm
that helps you build better habits.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
loading={loading()}
onClick={handleDownload}
>
download.dmg
</Button>
<a
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={GAZE_APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</a>
</div>
<p class="text-subtext1 mt-6 text-xs">
Requires macOS {GAZE_MIN_MACOS} or later.
</p>
</div>
</section>
</>
);
}

View File

@@ -1,57 +1,21 @@
import { PageHead } from "~/components/PageHead"; /**
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; * Legacy `/marketing/gaze` route — redirected (task 05) to the new Gaze
import { useDarkMode } from "~/context/darkMode"; * subdomain landing page at `gaze.freno.me`.
*
export default function GazeMarketing() { * Kept as a permanent 308 redirect so existing inbound links keep resolving
const { isDark } = useDarkMode(); * to the canonical Gaze marketing home.
*
return ( * Implemented as a thrown `Response` (rather than `@solidjs/router`'s
<> * `redirect()`) because the target is a *cross-origin* absolute URL; throwing
<PageHead * a `Response` from a SolidStart page component propagates as the actual HTTP
title="Gaze - Eye Health Reminder" * response, with no router base-path rewriting.
description="A macOS menu bar app that helps you remember to take breaks and rest your eyes. Download Gaze today." */
/> export default function GazeMarketingRedirect(): never {
<div class="relative h-full"> throw new Response(null, {
<div class="fixed inset-0 z-0 overflow-hidden brightness-75"> status: 308,
<img headers: {
src="/look-away.png" Location: "https://gaze.freno.me",
alt="background" "Cache-Control": "public, max-age=86400"
class="h-full w-full object-cover select-none" }
style={{ });
"pointer-events": "none"
}}
/>
</div>
<div class="relative z-10 flex h-full flex-col items-center justify-center text-white backdrop-blur">
<div>
<img
src={
isDark()
? "/Gaze Exports/Gaze-iOS-Dark-1024x1024@1x.png"
: "/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png"
}
alt="Gaze App Icon"
height={128}
width={128}
class="object-cover object-center"
/>
</div>
<h1 class="py-4 text-center text-5xl font-bold">Gaze</h1>
<p class="text-text mb-8 text-xl">
Eye and posture health reminder for macOS
</p>
<div class="flex space-x-4">
<a
class="my-auto transition-all duration-200 ease-out active:scale-95"
href="https://apps.apple.com"
target="_blank"
rel="noopener noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</a>
</div>
</div>
</div>
</>
);
} }