Merge branch 'inputhalo-landing-page'
This commit is contained in:
BIN
public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png
Normal file
BIN
public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png
Normal file
BIN
public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
145
src/routes/inputhalo/download.test.ts
Normal file
145
src/routes/inputhalo/download.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/routes/inputhalo/download.ts
Normal file
92
src/routes/inputhalo/download.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/routes/inputhalo/index.tsx
Normal file
193
src/routes/inputhalo/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user