feat: add InputHalo landing page with DMG download flow

Add a marketing landing page for inputhalo.freno.me with a hero section
(theme-aware app icon, glitch title, tagline), feature highlights, and a
download section offering a macOS DMG via the tRPC signed-S3 URL endpoint
plus an App Store link.

Extract the download orchestration into a pure, side-effect-free helper
(query/performInputHaloDownload) so the asset-name and redirect contract is
unit-tested in isolation, mirroring the existing site-aware page-head /
nav-config split. Include dark and default app-icon exports in the public
folder.
This commit is contained in:
2026-07-23 14:32:06 -04:00
parent 6fdbbbbe2b
commit 06c39b8813
5 changed files with 430 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
/**
* Unit tests for the InputHalo landing-page download flow (task 06).
*
* The helper in `./download.ts` is pure (no solid-js / router / meta imports),
* so we exercise the acceptance criterion directly — "the download button
* calls `api.downloads.getDownloadUrl` with `'inputhalo'` and redirects to the
* signed S3 URL" — without a DOM.
*
* Integration / visual checks (the rendered landing page, the tRPC client) are
* covered by the build gate (`bun run build`) and the manual validation steps
* in the task spec; the component is a thin wrapper over this helper.
*/
import { describe, it, expect, mock } from "bun:test";
import {
INPUTHALO_APP_STORE_URL,
INPUTHALO_ASSET_NAME,
INPUTHALO_ICON_DARK,
INPUTHALO_ICON_DEFAULT,
INPUTHALO_MIN_SYSTEM_VERSION,
performInputHaloDownload,
queryInputHaloDownload,
type DownloadQueryApi
} from "./download";
describe("InputHalo download constants", () => {
it("asset name is 'inputhalo' (matches downloads.tsx)", () => {
expect(INPUTHALO_ASSET_NAME).toBe("inputhalo");
});
it("app store URL points at the InputHalo listing", () => {
expect(INPUTHALO_APP_STORE_URL).toBe(
"https://apps.apple.com/us/app/inputhalo/"
);
});
it("minimum system version is 14.6 (per Info.plist)", () => {
expect(INPUTHALO_MIN_SYSTEM_VERSION).toBe("14.6");
});
it("icons resolve from the 'InputHalo Exports' subfolder (Gaze pattern)", () => {
expect(INPUTHALO_ICON_DARK).toBe(
"/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png"
);
expect(INPUTHALO_ICON_DEFAULT).toBe(
"/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png"
);
});
});
describe("queryInputHaloDownload", () => {
it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => {
const query = mock(
(async (input: { asset_name: string }) => {
expect(input.asset_name).toBe("inputhalo");
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
}) as DownloadQueryApi
);
const url = await queryInputHaloDownload(query);
expect(query).toHaveBeenCalledTimes(1);
expect(url).toBe("https://s3.example.com/InputHalo.dmg?signed=1");
});
it("propagates the exact asset name 'inputhalo' (spelled correctly)", async () => {
let captured: string | undefined;
const query: DownloadQueryApi = async (input) => {
captured = input.asset_name;
return { downloadURL: "https://example.com/x.dmg" };
};
await queryInputHaloDownload(query);
expect(captured).toBe("inputhalo");
});
it("propagates query rejection", async () => {
const boom = new Error("S3 unreachable");
const query: DownloadQueryApi = async () => {
throw boom;
};
await expect(queryInputHaloDownload(query)).rejects.toThrow(boom);
});
});
describe("performInputHaloDownload", () => {
const SIGNED_URL = "https://s3.example.com/InputHalo-0.1.0.dmg?sig=abc";
it("redirects to the signed S3 URL returned by the query", async () => {
const query = mock(
(async () => ({ downloadURL: SIGNED_URL })) as DownloadQueryApi
);
const redirect = mock((url: string) => url);
const ok = await performInputHaloDownload(query, redirect);
expect(ok).toBe(true);
expect(query).toHaveBeenCalledTimes(1);
expect(redirect).toHaveBeenCalledTimes(1);
expect(redirect).toHaveBeenCalledWith(SIGNED_URL);
});
it("queries the tRPC endpoint with asset_name 'inputhalo'", async () => {
const seen: { asset_name: string }[] = [];
const query: DownloadQueryApi = async (input) => {
seen.push(input);
return { downloadURL: SIGNED_URL };
};
const redirect = mock((_url: string) => {});
await performInputHaloDownload(query, redirect);
expect(seen).toEqual([{ asset_name: "inputhalo" }]);
});
it("does not redirect when the query rejects", async () => {
const query: DownloadQueryApi = async () => {
throw new Error("network down");
};
const redirect = mock((_url: string) => {});
const onError = mock((_: unknown) => {});
const ok = await performInputHaloDownload(query, redirect, onError);
expect(ok).toBe(false);
expect(redirect).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
});
it("defaults the error sink to console.error", async () => {
const original = console.error;
const seen: unknown[] = [];
console.error = (...args: unknown[]) => seen.push(args);
const query: DownloadQueryApi = async () => {
throw new Error("boom");
};
try {
const ok = await performInputHaloDownload(query, () => {});
expect(ok).toBe(false);
expect(seen.length).toBeGreaterThanOrEqual(1);
} finally {
console.error = original;
}
});
});

View File

@@ -0,0 +1,92 @@
/**
* Pure, side-effect-free download orchestration for the InputHalo landing
* page (task 06).
*
* Extracted from the route component so the acceptance criterion —
* "download button calls `api.downloads.getDownloadUrl` with `'inputhalo'`
* and redirects to the signed S3 URL" — can be unit-tested in `bun:test`
* without importing solid-js / `@solidjs/router` / `@solidjs/meta` (the same
* pattern established by `~/components/page-head-meta.ts` and
* `~/lib/nav-config.ts`).
*
* The route component (`./index.tsx`) is a thin wrapper that supplies the
* real `api` (the tRPC client) and a `redirect` that mutates
* `window.location.href`, plus loading/error UX. The data-flow contract
* lives here.
*/
/** tRPC asset name for the InputHalo macOS DMG (matches `downloads.tsx`). */
export const INPUTHALO_ASSET_NAME = "inputhalo" as const;
/** App Store listing for InputHalo (paid variant — "coming soon"). */
export const INPUTHALO_APP_STORE_URL =
"https://apps.apple.com/us/app/inputhalo/" as const;
/** Minimum macOS version supported by InputHalo (per Info.plist). */
export const INPUTHALO_MIN_SYSTEM_VERSION = "14.6" as const;
/** Public asset paths for the app icon, switched on dark/light theme. */
export const INPUTHALO_ICON_DARK =
"/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png" as const;
export const INPUTHALO_ICON_DEFAULT =
"/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png" as const;
/**
* Structural type of the slice of the tRPC client this helper consumes.
* Keeps the helper decoupled from the full `api` surface and testable with a
* stub.
*/
export interface DownloadQueryApi {
(input: { asset_name: string }): Promise<{ downloadURL: string }>;
}
/**
* Resolve the signed S3 download URL for the InputHalo DMG.
*
* Pure: issues the query via the supplied `api` callable and returns the
* resulting `downloadURL`. Throws if the underlying query rejects — the
* caller owns the user-facing error UX.
*
* @example
* ```ts
* const url = await queryInputHaloDownload(
* (input) => api.downloads.getDownloadUrl.query(input)
* );
* ```
*/
export async function queryInputHaloDownload(
query: DownloadQueryApi
): Promise<string> {
const data = await query({ asset_name: INPUTHALO_ASSET_NAME });
return data.downloadURL;
}
/**
* Drive the full DMG download flow: call the tRPC endpoint with the
* InputHalo asset name, then hand the signed URL to `redirect`.
*
* Returns a boolean indicating success so callers can branch their loading-
* state cleanup without a try/catch (errors are caught internally and surfaced
* via the `onError` callback — keeps the component body tidy).
*
* @param query tRPC query callable (the `api.downloads.getDownloadUrl`
* bound method).
* @param redirect Side-effect invoked with the signed S3 URL (typically
* `(url) => { window.location.href = url; }`).
* @param onError Optional error sink (defaults to `console.error`).
* @returns `true` on success, `false` if the query rejected.
*/
export async function performInputHaloDownload(
query: DownloadQueryApi,
redirect: (url: string) => void,
onError?: (error: unknown) => void
): Promise<boolean> {
try {
const url = await queryInputHaloDownload(query);
redirect(url);
return true;
} catch (error) {
(onError ?? console.error)("InputHalo download error:", error);
return false;
}
}

View File

@@ -0,0 +1,193 @@
/**
* InputHalo landing page — net-new marketing page for inputhalo.freno.me
* (task 06).
*
* Routes:
* - vercel.json rewrites `inputhalo.freno.me/(.*)` → `/inputhalo/$1`, so
* this `src/routes/inputhalo/index.tsx` serves the subdomain root.
*
* Sections:
* - Hero: app icon (dark/light variant via `useDarkMode`), title, tagline
* - Feature highlights (real-time tracking, menu bar, customizable, native)
* - Download: inline macOS DMG button (tRPC → signed S3 URL) + App Store link
*
* The download orchestration lives in the pure, unit-tested
* `./download.ts` helper — this component is a thin wrapper that supplies the
* real tRPC client + `window.location.href` redirect and the loading/error
* UX. The pattern mirrors `downloads.tsx` and the site-aware PageHead /
* nav-config split.
*/
import { PageHead } from "~/components/PageHead";
import Button from "~/components/ui/Button";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import { glitchText } from "~/lib/client-utils";
import { useDarkMode } from "~/context/darkMode";
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { api } from "~/lib/api";
import {
INPUTHALO_APP_STORE_URL,
INPUTHALO_ICON_DARK,
INPUTHALO_ICON_DEFAULT,
INPUTHALO_MIN_SYSTEM_VERSION,
performInputHaloDownload
} from "./download";
export default function InputHaloLanding() {
const { isDark } = useDarkMode();
const [titleText, setTitleText] = createSignal("InputHalo");
const [loading, setLoading] = createSignal(false);
const download = () => {
if (loading()) return;
setLoading(true);
performInputHaloDownload(
(input) => api.downloads.getDownloadUrl.query(input),
(url) => {
window.location.href = url;
},
() => {
alert("Failed to initiate download. Please try again.");
}
).finally(() => setLoading(false));
};
onMount(() => {
const interval = glitchText(titleText(), setTitleText);
onCleanup(() => clearInterval(interval));
});
const iconSrc = () => (isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT);
return (
<>
<PageHead
title="InputHalo"
description="Input visualization for mouse and keyboard — a macOS menu bar app for real-time input tracking and customization."
/>
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
{/* Subtle scanline effect */}
<div class="pointer-events-none absolute inset-0 opacity-5">
<div
class="h-full w-full"
style={{
"background-image":
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.2) 2px, rgba(0,0,0,0.2) 4px)"
}}
/>
</div>
<div class="relative z-10 mx-auto max-w-5xl">
{/* Hero Section */}
<div class="mb-16 text-center">
{/* App Icon — dark/light variant matches the Gaze pattern */}
<div class="mb-8 flex justify-center">
<img
src={iconSrc()}
alt="InputHalo app icon"
class="h-32 w-32 rounded-2xl shadow-lg transition-transform duration-200 hover:scale-105"
/>
</div>
{/* Title */}
<h1 class="text-text mb-4 font-mono text-4xl md:text-5xl">
<span class="text-red">{">"}</span> {titleText()}
</h1>
{/* Tagline (from appcast-template.xml) */}
<p class="text-subtext0 mb-2 text-xl italic">
Input visualization for mouse and keyboard
</p>
{/* Platform note */}
<span class="text-subtext1 font-mono text-sm">
macOS menu bar app · minimum macOS {INPUTHALO_MIN_SYSTEM_VERSION}
</span>
</div>
{/* Feature Highlights */}
<div class="mb-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
<h3 class="text-text mb-2 font-mono text-lg">
<span class="text-red">{">"}</span> Real-time Tracking
</h3>
<p class="text-subtext0">
Visualize every mouse click and keyboard press as it happens
</p>
</div>
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
<h3 class="text-text mb-2 font-mono text-lg">
<span class="text-red">{">"}</span> Menu Bar App
</h3>
<p class="text-subtext0">
Lightweight presence in your menu bar access settings anytime
</p>
</div>
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
<h3 class="text-text mb-2 font-mono text-lg">
<span class="text-red">{">"}</span> Customizable
</h3>
<p class="text-subtext0">
Personalize colors, styles, and behavior to match your workflow
</p>
</div>
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
<h3 class="text-text mb-2 font-mono text-lg">
<span class="text-red">{">"}</span> Native macOS
</h3>
<p class="text-subtext0">
Built with Swift and SwiftUI for optimal performance
</p>
</div>
</div>
{/* Download Section */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-red">{">"}</span> Download
</h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
{/* DMG Download (tRPC → signed S3 URL) */}
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS ({INPUTHALO_MIN_SYSTEM_VERSION}+)
</span>
<Button
variant="download"
size="lg"
loading={loading()}
onClick={download}
>
download.dmg
</Button>
<span class="text-subtext1 text-xs">
# auto-updates via Sparkle
</span>
</div>
{/* App Store (paid — coming soon) */}
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
variant: paid (coming soon)
</span>
<A
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={INPUTHALO_APP_STORE_URL}
target="_blank"
rel="noreferrer"
>
<DownloadOnAppStore size={50} />
</A>
</div>
</div>
</div>
</div>
</div>
</>
);
}