diff --git a/public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png b/public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png new file mode 100644 index 0000000..138a202 Binary files /dev/null and b/public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png differ diff --git a/public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png b/public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..28018e8 Binary files /dev/null and b/public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png differ diff --git a/src/routes/inputhalo/download.test.ts b/src/routes/inputhalo/download.test.ts new file mode 100644 index 0000000..9c20d2a --- /dev/null +++ b/src/routes/inputhalo/download.test.ts @@ -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; + } + }); +}); diff --git a/src/routes/inputhalo/download.ts b/src/routes/inputhalo/download.ts new file mode 100644 index 0000000..cbd89f0 --- /dev/null +++ b/src/routes/inputhalo/download.ts @@ -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 { + 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 { + try { + const url = await queryInputHaloDownload(query); + redirect(url); + return true; + } catch (error) { + (onError ?? console.error)("InputHalo download error:", error); + return false; + } +} diff --git a/src/routes/inputhalo/index.tsx b/src/routes/inputhalo/index.tsx new file mode 100644 index 0000000..f0084be --- /dev/null +++ b/src/routes/inputhalo/index.tsx @@ -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 ( + <> + + +
+ {/* Subtle scanline effect */} +
+
+
+ +
+ {/* Hero Section */} +
+ {/* App Icon — dark/light variant matches the Gaze pattern */} +
+ InputHalo app icon +
+ + {/* Title */} +

+ {">"} {titleText()} +

+ + {/* Tagline (from appcast-template.xml) */} +

+ Input visualization for mouse and keyboard +

+ + {/* Platform note */} + + macOS menu bar app · minimum macOS {INPUTHALO_MIN_SYSTEM_VERSION} + +
+ + {/* Feature Highlights */} +
+
+

+ {">"} Real-time Tracking +

+

+ Visualize every mouse click and keyboard press as it happens +

+
+ +
+

+ {">"} Menu Bar App +

+

+ Lightweight presence in your menu bar — access settings anytime +

+
+ +
+

+ {">"} Customizable +

+

+ Personalize colors, styles, and behavior to match your workflow +

+
+ +
+

+ {">"} Native macOS +

+

+ Built with Swift and SwiftUI for optimal performance +

+
+
+ + {/* Download Section */} +
+

+ {">"} Download +

+ +
+ {/* DMG Download (tRPC → signed S3 URL) */} +
+ + platform: macOS ({INPUTHALO_MIN_SYSTEM_VERSION}+) + + + + # auto-updates via Sparkle + +
+ + {/* App Store (paid — coming soon) */} +
+ + variant: paid (coming soon) + + + + +
+
+
+
+
+ + ); +}