fix: product subdomain cleanup

This commit is contained in:
2026-07-23 20:52:49 -04:00
parent a6ef4d2955
commit 8e33af43a5
39 changed files with 1597 additions and 630 deletions

View File

@@ -5,6 +5,7 @@ import {
ErrorBoundary,
onMount,
onCleanup,
Show,
Suspense
} from "solid-js";
import "./app.css";
@@ -15,7 +16,7 @@ import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
import { BarsProvider, useBars } from "./context/bars";
import { DarkModeProvider } from "./context/darkMode";
import { AuthProvider } from "./context/auth";
import { SiteProvider } from "./context/SiteContext";
import { SiteProvider, useSite } from "./context/SiteContext";
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
import { MOBILE_CONFIG } from "./config";
import CustomScrollbar from "./components/CustomScrollbar";
@@ -155,16 +156,52 @@ function AppLayout(props: { children: any }) {
}
};
const site = useSite();
const isMainSite = () => site().id === "main";
return (
<>
<div class="flex max-w-screen flex-row overflow-x-hidden">
<LeftBar />
<div
id="center-body"
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-[250px] md:w-[calc(100vw-500px)]"
>
<Show when={isMainSite()}>
<div class="flex max-w-screen flex-row overflow-x-hidden">
<LeftBar />
<div
id="center-body"
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-[250px] md:w-[calc(100vw-500px)]"
>
<noscript>
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
JavaScript is disabled. Features will be limited.
</div>
</noscript>
<ErrorBoundary
fallback={(error, reset) => (
<ErrorBoundaryFallback error={error} reset={reset} />
)}
>
<div
onMouseUp={handleCenterTapRelease}
onTouchEnd={handleCenterTapRelease}
>
<Suspense fallback={<TerminalSplash inverse />}>
<CustomScrollbar
autoHide={true}
autoHideDelay={1500}
rightOffset={250}
>
{props.children}
</CustomScrollbar>
</Suspense>
</div>
</ErrorBoundary>
</div>
<RightBar />
</div>
</Show>
<Show when={!isMainSite()}>
<div class="bg-base min-h-screen w-full overflow-x-hidden">
<noscript>
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
<div class="bg-yellow text-crust border-text fixed top-0 z-150 w-full border-b-2 p-4 text-center font-semibold">
JavaScript is disabled. Features will be limited.
</div>
</noscript>
@@ -173,24 +210,12 @@ function AppLayout(props: { children: any }) {
<ErrorBoundaryFallback error={error} reset={reset} />
)}
>
<div
onMouseUp={handleCenterTapRelease}
onTouchEnd={handleCenterTapRelease}
>
<Suspense fallback={<TerminalSplash inverse />}>
<CustomScrollbar
autoHide={true}
autoHideDelay={1500}
rightOffset={250}
>
{props.children}
</CustomScrollbar>
</Suspense>
</div>
<Suspense fallback={<TerminalSplash inverse />}>
{props.children}
</Suspense>
</ErrorBoundary>
</div>
<RightBar />
</div>
</Show>
</>
);
}

View File

@@ -0,0 +1,87 @@
/**
* Minimal top navigation for product subdomains.
*
* Replaces the dual-left/right sidebar used on `freno.me` for each product
* subdomain (`nessa.*`, `lineage.*`, `gaze.*`, `inputhalo.*`). The header is
* sticky, site-aware, and derives its links from `NAV_CONFIG` so the nav set
* remains the single source of truth. `lineage.freno.me/` intentionally does
* not use this component so the original full-bleed parallax landing page is
* preserved.
*/
import { For, Show } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { useSite } from "~/context/SiteContext";
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config";
export default function SubdomainHeader() {
const site = useSite();
const location = useLocation();
const brandName = () => site().displayName;
const brandColor = () => site().brandColor;
const navItems = () =>
NAV_CONFIG[site().id].filter((item) => item.label !== "Home");
const isActive = (href: string) => {
const path = location.pathname;
return href === "/" ? path === "/" : path === href;
};
return (
<header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md">
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
<A
href="/"
class="text-lg font-bold tracking-tight transition-opacity hover:opacity-80"
style={{ color: brandColor() }}
>
{brandName()}
</A>
<nav
aria-label={`${brandName()} navigation`}
class="flex items-center gap-4 overflow-x-auto text-sm whitespace-nowrap"
>
<For each={navItems()}>
{(item) => (
<Show
when={item.external}
fallback={
<A
href={item.href}
end
class="transition-opacity hover:opacity-80"
classList={{
"font-semibold": isActive(item.href),
"opacity-70": !isActive(item.href)
}}
>
{item.label}
</A>
}
>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
class="opacity-70 transition-opacity hover:opacity-100"
>
{item.label}
</a>
</Show>
)}
</For>
<a
href={BACK_TO_FRENO.href}
target="_blank"
rel="noopener noreferrer"
class="text-text/60 hover:text-text text-xs transition-colors"
>
{BACK_TO_FRENO.label}
</a>
</nav>
</div>
</header>
);
}

View File

@@ -8,8 +8,10 @@
* - Client (hydration): reads the SSR-injected `window.__SITE__` id (written
* into the document shell by `entry-server.tsx`) so the post-hydration
* value matches the `data-site` attribute on `<html>`. Falls back to
* `resolveSiteFromHost(window.location.hostname)` if the injected id is
* missing (e.g. client-side navigation / hard refresh quirks).
* `resolveSiteFromLocation(window.location.hostname, window.location.pathname)`
* if the injected id is missing — the pathname fallback covers localhost
* dev where the host is `localhost` but the URL still carries a subdomain
* prefix (`/nessa/contact`).
*
* NOTE on race-safety: SSR of a personal site is single-render-per-request in
* practice; the module-level holder is adequate here. Server functions that
@@ -29,6 +31,7 @@ import { isServer } from "solid-js/web";
import {
resolveSiteFromHost,
resolveSiteFromLocation,
resolveSiteFromPath,
SITE_CONFIG,
MAIN_SITE,
type Site,
@@ -59,7 +62,13 @@ export function resolveClientSite(): Site {
if (typeof window === "undefined") return MAIN_SITE;
const injected = window.__SITE__;
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
return resolveSiteFromLocation(window.location.hostname);
// Fall back to hostname + URL-path prefix. On localhost (dev) the host is
// `localhost` (→ main) but the path carries the subdomain prefix
// (`/nessa/contact` → nessa), so we must consider both.
return resolveSiteFromLocation(
window.location.hostname,
window.location.pathname
);
}
// ── Context ──────────────────────────────────────────────────────────────
@@ -101,4 +110,4 @@ export function useSite(): Accessor<Site> {
export { SITE_CONFIG, MAIN_SITE };
export type { Site, SiteId };
export { resolveSiteFromHost, resolveSiteFromLocation };
export { resolveSiteFromHost, resolveSiteFromLocation, resolveSiteFromPath };

View File

@@ -16,13 +16,7 @@ import {
} from "./nav-config";
import { SITE_CONFIG, type SiteId } from "./site-context";
const ALL_SITES: SiteId[] = [
"main",
"nessa",
"lineage",
"gaze",
"inputhalo"
];
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
describe("NAV_CONFIG — per-site link sets", () => {
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
@@ -55,12 +49,22 @@ describe("NAV_CONFIG — per-site link sets", () => {
]);
});
it("gaze → Home, Contact, Privacy", () => {
expect(navLabelsFor("gaze")).toEqual(["Home", "Contact", "Privacy"]);
it("gaze → Home, Contact, Privacy, Downloads", () => {
expect(navLabelsFor("gaze")).toEqual([
"Home",
"Contact",
"Privacy",
"Downloads"
]);
});
it("inputhalo → Home, Contact, Privacy", () => {
expect(navLabelsFor("inputhalo")).toEqual(["Home", "Contact", "Privacy"]);
it("inputhalo → Home, Contact, Privacy, Downloads", () => {
expect(navLabelsFor("inputhalo")).toEqual([
"Home",
"Contact",
"Privacy",
"Downloads"
]);
});
});

View File

@@ -69,8 +69,8 @@ export const BACK_TO_FRENO: NavItem = {
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
* - nessa: Home, Contact, Privacy
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
* - gaze: Home, Contact, Privacy
* - inputhalo: Home, Contact, Privacy
* - gaze: Home, Contact, Privacy, Downloads
* - inputhalo: Home, Contact, Privacy, Downloads
*/
export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
main: [
@@ -107,12 +107,14 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
gaze: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
{ label: "Privacy", href: "/privacy", icon: "privacy" },
{ label: "Downloads", href: "/downloads", icon: "downloads" }
],
inputhalo: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
{ label: "Privacy", href: "/privacy", icon: "privacy" },
{ label: "Downloads", href: "/downloads", icon: "downloads" }
]
};

View File

@@ -8,6 +8,8 @@
import { describe, it, expect } from "bun:test";
import {
resolveSiteFromHost,
resolveSiteFromPath,
resolveSiteFromLocation,
SITE_CONFIG,
type SiteId
} from "./site-context";
@@ -80,15 +82,12 @@ describe("resolveSiteFromHost", () => {
});
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
for (const id of [
"nessa",
"lineage",
"gaze",
"inputhalo"
] as SiteId[]) {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
expect(SITE_CONFIG[id].subdomain).toBe(id);
expect(SITE_CONFIG[id].titleSuffix).toBe(` | ${SITE_CONFIG[id].displayName}`);
expect(SITE_CONFIG[id].titleSuffix).toBe(
` | ${SITE_CONFIG[id].displayName}`
);
}
});
@@ -97,3 +96,67 @@ describe("resolveSiteFromHost", () => {
expect(SITE_CONFIG.main.baseRoutePrefix).toBe("");
});
});
describe("resolveSiteFromPath", () => {
it("maps a subdomain-prefixed path to the right site", () => {
expect(resolveSiteFromPath("/nessa/contact")?.id).toBe("nessa");
expect(resolveSiteFromPath("/lineage/downloads")?.id).toBe("lineage");
expect(resolveSiteFromPath("/gaze/")?.id).toBe("gaze");
expect(resolveSiteFromPath("/inputhalo/privacy")?.id).toBe("inputhalo");
});
it("matches the exact prefix (landing page)", () => {
expect(resolveSiteFromPath("/nessa")?.id).toBe("nessa");
expect(resolveSiteFromPath("/gaze")?.id).toBe("gaze");
});
it("returns null for non-subdomain paths", () => {
expect(resolveSiteFromPath("/")).toBeNull();
expect(resolveSiteFromPath("/contact")).toBeNull();
expect(resolveSiteFromPath("/blog/post")).toBeNull();
expect(resolveSiteFromPath("/downloads")).toBeNull();
});
it("does not match prefix substrings (no false positives)", () => {
// `/nessa-extra` must NOT match `/nessa`.
expect(resolveSiteFromPath("/nessa-extra")).toBeNull();
expect(resolveSiteFromPath("/gazette")).toBeNull();
});
it("handles empty / null / undefined", () => {
expect(resolveSiteFromPath("")).toBeNull();
expect(resolveSiteFromPath(null)).toBeNull();
expect(resolveSiteFromPath(undefined)).toBeNull();
});
it("returns the full SITE_CONFIG entry", () => {
expect(resolveSiteFromPath("/nessa/contact")).toEqual(SITE_CONFIG.nessa);
expect(resolveSiteFromPath("/gaze")).toEqual(SITE_CONFIG.gaze);
});
});
describe("resolveSiteFromLocation", () => {
it("prefers the host when it identifies a subdomain", () => {
expect(resolveSiteFromLocation("nessa.freno.me", "/contact").id).toBe(
"nessa"
);
expect(resolveSiteFromLocation("gaze.localhost", "/").id).toBe("gaze");
});
it("falls back to the path prefix when the host is localhost/main", () => {
expect(resolveSiteFromLocation("localhost", "/nessa/contact").id).toBe(
"nessa"
);
expect(resolveSiteFromLocation("localhost", "/lineage/").id).toBe(
"lineage"
);
expect(resolveSiteFromLocation("freno.me", "/gaze/downloads").id).toBe(
"gaze"
);
});
it("returns main when neither host nor path identifies a subdomain", () => {
expect(resolveSiteFromLocation("localhost", "/contact").id).toBe("main");
expect(resolveSiteFromLocation("freno.me", "/").id).toBe("main");
});
});

View File

@@ -159,13 +159,54 @@ export function resolveSiteFromHost(host: string | null | undefined): Site {
return MAIN_SITE;
}
/**
* Resolve a `Site` from a URL pathname by matching a known subdomain route
* prefix (e.g. `/nessa/contact` → nessa, `/gaze/downloads` → gaze).
*
* Returns `null` when the path does not begin with a subdomain prefix so the
* caller can distinguish "no match" from "main" and decide whether to fall
* back to the host-based result.
*
* This is the dev-server safety net: on `localhost:3000/nessa/contact` the
* Host header is `localhost` (→ main), but the URL path still carries the
* subdomain prefix because vercel.json host rewrites do not run locally.
* Without this fallback, `useSite()` returns `main` on every subdomain page
* in dev, causing `<SubdomainHeader>` to render the main-site nav.
*
* In production the host rewrite strips the prefix, so the path is `/contact`
* (no prefix) and this returns `null` — the host-based result already
* resolved correctly.
*
* Pure & synchronous — no I/O, no env access.
*/
export function resolveSiteFromPath(
pathname: string | null | undefined
): Site | null {
if (!pathname) return null;
for (const site of SUBDOMAIN_SITES) {
const prefix = site.baseRoutePrefix; // e.g. "/nessa"
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
return site;
}
}
return null;
}
/**
* Resolve the active site from a client `window.location`, used by the
* SolidJS `SiteContext` provider during hydration. Server codepaths should
* use `getSiteFromEvent` / `getSiteFromRequest` instead.
*
* When the hostname does not identify a subdomain (e.g. `localhost` in dev),
* falls back to checking the URL pathname for a subdomain route prefix so
* that `localhost:3000/nessa/contact` resolves to nessa.
*/
export function resolveSiteFromLocation(
hostname: string | null | undefined
hostname: string | null | undefined,
pathname?: string | null | undefined
): Site {
return resolveSiteFromHost(hostname);
const hostResult = resolveSiteFromHost(hostname);
if (hostResult.id !== "main") return hostResult;
return resolveSiteFromPath(pathname) ?? hostResult;
}

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
* Gaze contact page (`gaze.freno.me/contact`).
@@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm";
* `[Gaze] Contact Request`.
*/
export default function GazeContactPage() {
return <ContactForm />;
return (
<>
<SubdomainHeader />
<ContactForm />
</>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Gaze per-subdomain downloads page — `gaze.freno.me/downloads`.
*
* Public browser path `/downloads`; vercel.json host rewrites serve this
* from the internal `/gaze/*` route prefix while keeping the URL clean.
*
* Mirrors the download surface already exposed on the Gaze landing page:
* - Direct signed-S3 DMG download via `downloads.getDownloadUrl({ asset_name: "gaze" })`.
* - macOS App Store listing link.
*/
import { A } from "@solidjs/router";
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
import { useSite } from "~/context/SiteContext";
import { downloadAsset } from "~/lib/download-asset";
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
const GAZE_MIN_MACOS = "14.6";
export default function GazeDownloadsPage() {
const site = useSite();
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="Download Gaze"
description="Download Gaze for macOS — menu bar app for eye and posture health."
/>
<SubdomainHeader />
<main class="bg-base relative min-h-screen w-full overflow-hidden px-4 pb-16">
<div
class="pointer-events-none fixed inset-0 z-0"
aria-hidden="true"
style={{
background: `radial-gradient(50% 40% at 50% 0%, ${site().brandColor}18 0%, transparent 70%)`
}}
/>
<div class="relative z-10 mx-auto flex max-w-2xl flex-col items-center pt-[12vh] text-center">
<img
src={
site().id === "gaze"
? "/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png"
: ""
}
alt="Gaze app icon"
width={128}
height={128}
class="mb-6 h-28 w-28 rounded-[22%] object-cover shadow-2xl"
/>
<h1 class="mb-2 text-4xl font-bold tracking-tight">Download Gaze</h1>
<p class="text-text/70 mb-10 max-w-md text-lg">
Eye and posture health reminders for your Mac menu bar.
</p>
<div class="flex w-full flex-col items-center justify-center gap-8 sm:flex-row">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 text-sm tracking-wider uppercase">
Direct download
</span>
<Button
variant="download"
size="lg"
loading={loading()}
onClick={handleDownload}
>
gaze.dmg
</Button>
<span class="text-subtext1 max-w-[240px] text-center text-xs">
macOS {GAZE_MIN_MACOS}+ · signed macOS build
</span>
</div>
<div class="bg-surface0/40 hidden h-24 w-px sm:block" />
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 text-sm tracking-wider uppercase">
App Store
</span>
<A
class="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>
<span class="text-subtext1 max-w-[240px] text-center text-xs">
Also available on the Mac App Store.
</span>
</div>
</div>
<p class="text-subtext0 mt-16 text-center text-sm">
<A
href="/"
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors"
>
back to Gaze
</A>
</p>
</div>
</main>
</>
);
}

View File

@@ -1,26 +1,30 @@
import { createSignal } from "solid-js";
import { createSignal, For } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
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 GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
const GAZE_MIN_MACOS = "14.6";
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: "Blink reminders",
body: "Subtle prompts help you remember to blink more often, reducing dry-eye strain during long sessions."
},
{
title: "Eye health reminders",
title: "20-20-20 eye breaks",
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: "Posture check-ins",
body: "Periodic reminders help you catch slouching before it becomes a habit."
},
{
title: "Customizable intervals",
body: "Set the reminder cadence that fits your workflow, from gentle nudges to strict schedules."
},
{
title: "Lives in your menu bar",
@@ -53,19 +57,21 @@ export default function GazeLanding() {
<>
<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."
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
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."
ogDescription="A macOS menu bar app that helps you remember to blink, take breaks, and sit up straight."
/>
<SubdomainHeader />
{/* ── 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"
class="h-full w-full object-cover select-none"
style={{ "pointer-events": "none" }}
/>
</div>
@@ -120,17 +126,17 @@ export default function GazeLanding() {
<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 class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
<For each={FEATURES}>
{(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>
)}
</For>
</div>
</div>
</section>

View File

@@ -15,6 +15,7 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function GazePrivacyPolicy() {
return (
@@ -23,9 +24,10 @@ export default function GazePrivacyPolicy() {
title="Privacy Policy"
description="Privacy policy for Gaze, a macOS eye health reminder app."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Gaze&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: February 9, 2026</div>
<div class="py-2">Last Updated: July 23, 2026</div>
<div class="py-2">
Welcome to Gaze (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
Your privacy is important to us. This privacy policy will help you

View File

@@ -1,8 +1,55 @@
import { Switch, Match, type JSX } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { DarkModeToggle } from "~/components/DarkModeToggle";
import { Typewriter } from "~/components/Typewriter";
import { useSite } from "~/context/SiteContext";
import NessaLanding from "./nessa";
import LineageLanding from "./lineage";
import GazeLanding from "./gaze";
import InputHaloLanding from "./inputhalo";
export default function Home() {
/**
* Root route handler.
*
* SolidStart's file router cannot match on host, so `vercel.json` host
* rewrites (`nessa.freno.me/(.*) → /nessa/$1`) map each subdomain onto its
* `src/routes/<prefix>/*` file route in production. The vinxi dev server
* ignores `vercel.json`, however — so in dev a subdomain root (`nessa.localhost/`)
* would otherwise fall through to this `index.tsx` and render Mike's personal
* page with only the sidebar changing.
*
* The AGENTS.md-sanctioned remedy is to branch on the resolved `Site` (via
* the `useSite()` accessor from `src/lib/site-context.ts`, NOT raw host
* sifting) and render the matching subdomain landing component here. This
* makes the subdomain roots render their unique landing pages in dev *and*
* acts as a production safety net should a Vercel rewrite ever fail to fire.
* In production the vercel rewrite still wins — `nessa.freno.me/` →
* `/nessa/index.tsx` directly — so the two paths render the same component:
* `NessaLanding`.
*/
export default function Home(): JSX.Element {
const site = useSite();
return (
<Switch fallback={<MainHome />}>
<Match when={site().id === "nessa"}>
<NessaLanding />
</Match>
<Match when={site().id === "lineage"}>
<LineageLanding />
</Match>
<Match when={site().id === "gaze"}>
<GazeLanding />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloLanding />
</Match>
</Switch>
);
}
/** The freno.me personal site landing page (default/main host only). */
function MainHome(): JSX.Element {
return (
<>
<PageHead

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
* InputHalo contact page (`inputhalo.freno.me/contact`).
@@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm";
* `[InputHalo] Contact Request`.
*/
export default function InputHaloContactPage() {
return <ContactForm />;
return (
<>
<SubdomainHeader />
<ContactForm />
</>
);
}

View File

@@ -0,0 +1,134 @@
/**
* InputHalo per-subdomain downloads page — `inputhalo.freno.me/downloads`.
*
* Public browser path `/downloads`; vercel.json host rewrites serve this
* from the internal `/inputhalo/*` route prefix while keeping the URL clean.
*
* Mirrors the download surface already exposed on the InputHalo landing page:
* - Direct signed-S3 DMG download via `downloads.getDownloadUrl({ asset_name: "inputhalo" })`.
* - macOS App Store listing link (paid variant — currently a placeholder URL).
*/
import { A } from "@solidjs/router";
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
import { api } from "~/lib/api";
import {
INPUTHALO_APP_STORE_URL,
INPUTHALO_ICON_DARK,
INPUTHALO_ICON_DEFAULT,
INPUTHALO_MIN_SYSTEM_VERSION,
performInputHaloDownload
} from "~/routes/inputhalo/download";
export default function InputHaloDownloadsPage() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const iconSrc = () =>
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
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));
};
return (
<>
<PageHead
title="Download InputHalo"
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."
/>
<SubdomainHeader />
<main class="bg-base relative min-h-screen w-full overflow-hidden px-4 pb-16">
<div
class="pointer-events-none fixed inset-0 z-0"
aria-hidden="true"
style={{
background: `radial-gradient(50% 40% at 50% 0%, ${site().brandColor}16 0%, transparent 70%)`
}}
/>
<div class="relative z-10 mx-auto flex max-w-2xl flex-col items-center pt-[12vh] text-center">
<img
src={iconSrc()}
alt="InputHalo app icon"
width={128}
height={128}
class="mb-6 h-28 w-28 rounded-[22%] object-cover shadow-2xl"
/>
<h1 class="mb-2 text-4xl font-bold tracking-tight">
Download InputHalo
</h1>
<p class="text-text/70 mb-10 max-w-md text-lg">
Show every keystroke, click, and scroll on macOS.
</p>
<div class="flex w-full flex-col items-center justify-center gap-8 sm:flex-row">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 text-sm tracking-wider uppercase">
Direct download
</span>
<Button
variant="download"
size="lg"
loading={loading()}
onClick={download}
>
inputhalo.dmg
</Button>
<span class="text-subtext1 max-w-[240px] text-center text-xs">
macOS {INPUTHALO_MIN_SYSTEM_VERSION}+ · auto-updates via Sparkle
</span>
</div>
<div class="bg-surface0/40 hidden h-24 w-px sm:block" />
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 text-sm tracking-wider uppercase">
App Store
</span>
<A
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={INPUTHALO_APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</A>
<span class="text-subtext1 max-w-[240px] text-center text-xs">
Paid App Store variant coming soon.
</span>
</div>
</div>
<p class="text-subtext0 mt-16 text-center text-sm">
<A
href="/"
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors"
>
back to InputHalo
</A>
</p>
</div>
</main>
</>
);
}

View File

@@ -1,29 +1,23 @@
/**
* InputHalo landing page — net-new marketing page for inputhalo.freno.me
* (task 06).
* InputHalo landing page — net-new marketing page for inputhalo.freno.me.
*
* Routes:
* - vercel.json rewrites `inputhalo.freno.me/(.*)` → `/inputhalo/$1`, so
* this `src/routes/inputhalo/index.tsx` serves the subdomain root.
* InputHalo is a polished macOS menu bar app that visualizes mouse and
* keyboard input on screen: keyboard indicators, cursor halos, click ripples,
* scroll indicators, and sensitive-input detection. It is built with SwiftUI
* and distributed via direct DMG (Sparkle auto-updates) with a paid App Store
* variant planned.
*
* 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.
* The download orchestration stays in the testable pure `./download.ts` module;
* this component is a thin wrapper around it plus the landing-page UX.
*/
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 { For, createSignal } from "solid-js";
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
import { api } from "~/lib/api";
import {
INPUTHALO_APP_STORE_URL,
@@ -33,9 +27,55 @@ import {
performInputHaloDownload
} from "./download";
const FEATURES = [
{
title: "Keyboard indicator overlay",
body: "Display the keys you press in a clean, configurable on-screen overlay — perfect for demos and tutorials."
},
{
title: "Mouse halo & click ripples",
body: "Add a subtle halo around your cursor and visual click feedback so viewers never lose track of your pointer."
},
{
title: "Scroll indicators",
body: "Show scroll direction and intensity in real time, useful for screencasts and accessibility demos."
},
{
title: "Sensitive input detection",
body: "InputHalo automatically hides visual feedback when you type into password or secure fields."
},
{
title: "Fully customizable",
body: "Choose colors, sizes, animations, position, and behavior to match your setup and brand."
},
{
title: "Native macOS menu bar app",
body: "Built with SwiftUI, runs quietly in the menu bar, and uses system accessibility APIs responsibly."
}
] as const;
const USE_CASES = [
{
title: "Streamers & creators",
body: "Let your audience see exactly what you're clicking and typing without cluttering your scene."
},
{
title: "Presenters & educators",
body: "Make keyboard shortcuts and cursor movement crystal clear during demos and recordings."
},
{
title: "Developers",
body: "Show input interactions in bug reports, design reviews, and pair-programming sessions."
},
{
title: "Accessibility",
body: "Visualize inputs to make workflows easier to follow for users who benefit from clear feedback."
}
] as const;
export default function InputHaloLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const [titleText, setTitleText] = createSignal("InputHalo");
const [loading, setLoading] = createSignal(false);
const download = () => {
@@ -52,142 +92,196 @@ export default function InputHaloLanding() {
).finally(() => setLoading(false));
};
onMount(() => {
const interval = glitchText(titleText(), setTitleText);
onCleanup(() => clearInterval(interval));
});
const iconSrc = () => (isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT);
const iconSrc = () =>
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
const brandColor = () => site().brandColor;
return (
<>
<PageHead
title="InputHalo"
description="Input visualization for mouse and keyboard — a macOS menu bar app for real-time input tracking and customization."
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
/>
<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">
<SubdomainHeader />
<main
class="relative min-h-screen w-full overflow-x-hidden"
style={{ "--brand-color": brandColor() }}
>
{/* Soft pink halos in the background */}
<div
class="pointer-events-none fixed inset-0 z-0"
aria-hidden="true"
style={{
background: isDark()
? `radial-gradient(60% 50% at 80% 0%, ${brandColor()}20 0%, transparent 70%), radial-gradient(50% 40% at 20% 100%, ${brandColor()}16 0%, transparent 70%)`
: `radial-gradient(60% 50% at 80% 0%, ${brandColor()}16 0%, transparent 70%), radial-gradient(50% 40% at 20% 100%, ${brandColor()}12 0%, transparent 70%)`
}}
/>
{/* ─── Hero ───────────────────────────────────────────────── */}
<section class="relative z-10 flex flex-col items-center px-4 pt-24 pb-16 text-center md:pt-32">
<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>
class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32"
style={{ color: brandColor() }}
>
<img
src={iconSrc()}
alt="InputHalo app icon"
width={128}
height={128}
class="h-full w-full rounded-[1.75rem] object-cover"
/>
</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>
<h1 class="text-5xl font-bold tracking-tight md:text-7xl">
InputHalo
</h1>
<p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
Show every keystroke, click, and scroll.
</p>
<p class="text-text/70 mt-3 max-w-xl text-base md:text-lg">
A polished macOS menu bar app for input visualization built for
streamers, presenters, developers, and anyone who wants their inputs
seen.
</p>
<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 class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
<Button
variant="download"
size="lg"
loading={loading()}
onClick={download}
>
download.dmg
</Button>
<A
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={INPUTHALO_APP_STORE_URL}
target="_blank"
rel="noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</A>
</div>
<p class="text-text/50 mt-3 text-sm">
macOS {INPUTHALO_MIN_SYSTEM_VERSION}+ · auto-updates via Sparkle
</p>
</section>
{/* 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
{/* ─── Feature highlights ─────────────────────────────────── */}
<section class="bg-surface0/30 relative z-10 px-4 py-20">
<div class="mx-auto max-w-6xl">
<h2 class="text-text mb-12 text-center text-3xl font-bold md:text-4xl">
Made for showing your inputs
</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 class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={FEATURES}>
{(feature) => (
<div class="border-surface0 bg-base/80 flex flex-col gap-3 rounded-2xl border-2 p-6 backdrop-blur-sm">
<div
class="mb-1 flex h-8 w-8 items-center justify-center rounded-full text-white"
style={{ background: brandColor() }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3 class="text-lg font-semibold">{feature.title}</h3>
<p class="text-text/80 text-sm leading-relaxed">
{feature.body}
</p>
</div>
)}
</For>
</div>
</div>
</div>
</div>
</section>
{/* ─── Use cases ──────────────────────────────────────────── */}
<section class="relative z-10 px-4 py-20">
<div class="mx-auto max-w-6xl">
<h2 class="text-text mb-12 text-center text-3xl font-bold md:text-4xl">
Who it's for
</h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<For each={USE_CASES}>
{(use) => (
<div class="border-surface0 hover:bg-surface0/30 flex flex-col gap-2 rounded-2xl border-2 p-6 transition-colors">
<h3
class="text-lg font-semibold"
style={{ color: brandColor() }}
>
{use.title}
</h3>
<p class="text-text/80 text-sm leading-relaxed">
{use.body}
</p>
</div>
)}
</For>
</div>
</div>
</section>
{/* ─── Download CTA ───────────────────────────────────────── */}
<section class="bg-surface0/30 relative z-10 px-4 py-20">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-text mb-4 text-3xl font-bold">
Ready to visualize your inputs?
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
Download the latest signed macOS build directly, or grab the App
Store version once it launches.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Button
variant="download"
size="lg"
loading={loading()}
onClick={download}
>
download.dmg
</Button>
<A
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={INPUTHALO_APP_STORE_URL}
target="_blank"
rel="noreferrer"
>
<DownloadOnAppStoreDark size={50} />
</A>
</div>
<p class="text-subtext1 mt-6 text-xs">
Requires macOS {INPUTHALO_MIN_SYSTEM_VERSION} or later.
</p>
</div>
</section>
{/* ─── Footer ─────────────────────────────────────────────── */}
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<span>{site().displayName}</span>
<A
href="https://freno.me"
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me
</A>
</div>
</footer>
</main>
</>
);
}

View File

@@ -24,6 +24,7 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function InputHaloPrivacyPolicy() {
return (
@@ -32,15 +33,15 @@ export default function InputHaloPrivacyPolicy() {
title="Privacy Policy"
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">InputHalo&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: February 9, 2026</div>
<div class="py-2">Last Updated: July 23, 2026</div>
<div class="py-2">
Welcome to InputHalo (&apos;We&apos;, &apos;Us&apos;,
&apos;Our&apos;). Your privacy is important to us. This privacy
policy will help you understand our policies and procedures related
to the collection, use, and storage of personal information from our
users.
&apos;Our&apos;). Your privacy is important to us. This privacy policy
will help you understand our policies and procedures related to the
collection, use, and storage of personal information from our users.
</div>
<ol>
<div class="py-2">
@@ -52,17 +53,17 @@ export default function InputHaloPrivacyPolicy() {
<div class="-ml-6">(a) Collection of Personal Data:</div>{" "}
InputHalo is designed with privacy as a core principle. We
currently do not collect, store, or share any personal
information from our users. The app runs entirely on your
device as a menu bar utility and does not require any account
creation or data transmission to external servers.
information from our users. The app runs entirely on your device
as a menu bar utility and does not require any account creation
or data transmission to external servers.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Local-Only Settings:</div> Any
preferences, configuration, or cached state InputHalo maintains
is stored locally on your device using standard macOS
mechanisms (such as the user defaults system). This data never
leaves your device and is not transmitted to us or to any
third-party service.
is stored locally on your device using standard macOS mechanisms
(such as the user defaults system). This data never leaves your
device and is not transmitted to us or to any third-party
service.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Future Data Collection:</div> We may in

View File

@@ -1,5 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { LineageContactQuestions } from "~/routes/contact";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
* Life and Lineage contact page (`lineage.freno.me/contact`).
@@ -9,9 +9,9 @@ import { LineageContactQuestions } from "~/routes/contact";
* — is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.lineage`.
*
* Renders the Life-and-Lineage FAQ accordion (re-imported from the main-site
* contact route so the canonical definition lives in one place) above the
* form — the lineage subdomain is the natural home for product support Q&A.
* Subdomain contact pages intentionally render only the shared contact form —
* no Life-and-Lineage FAQ accordion and no main-site disclaimer subline
* (those remain exclusive to the main `freno.me/contact` page).
*
* vercel.json rewrites `lineage.freno.me/*` → the internal `/lineage/*` route
* prefix; the browser URL stays `lineage.freno.me/contact`.
@@ -22,8 +22,9 @@ import { LineageContactQuestions } from "~/routes/contact";
*/
export default function LineageContactPage() {
return (
<ContactForm>
<LineageContactQuestions />
</ContactForm>
<>
<SubdomainHeader />
<ContactForm />
</>
);
}

View File

@@ -33,6 +33,7 @@
* the form posts to the correct tRPC mutation (Lineage-branded email).
*/
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DeletionForm from "~/components/DeletionForm";
import {
DELETION_PRODUCT_KEY,
@@ -45,6 +46,7 @@ export default function LineageDeletionPage() {
return (
<>
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="pt-20">
<div class="mx-auto p-4 md:p-6 lg:p-12">
<div class="text-text w-full justify-center">

View File

@@ -31,6 +31,7 @@
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import Button from "~/components/ui/Button";
import { glitchText } from "~/lib/client-utils";
@@ -71,10 +72,9 @@ export default function LineageDownloadsPage() {
return (
<>
<PageHead
title={PAGE_META.title}
description={PAGE_META.description}
/>
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
{/* Subtle scanline effect — consistent with the unified downloads page. */}
@@ -131,7 +131,7 @@ export default function LineageDownloadsPage() {
</div>
{/* Secondary CTA → landing page */}
<p class="mt-12 text-center text-sm text-subtext0">
<p class="text-subtext0 mt-12 text-center text-sm">
<A
href={LINEAGE_HOME_HREF}
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"

View File

@@ -1,60 +1,49 @@
/**
* Life and Lineage — lineage subdomain landing page (task 08).
* Life and Lineage — lineage subdomain landing page.
*
* Migrates the legacy `src/routes/marketing/life-and-lineage.tsx` page to the
* `lineage.freno.me` root (vercel.json rewrites `lineage.freno.me/*` →
* `/lineage/*`), and enriches it with feature highlights + a game-art strip.
*
* Site-awareness:
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/` are
* derived automatically (task 02); we pass only the base title here.
* - The Google Play badge links to the **public browser path** `/downloads`
* (subdomain-relative), which vercel rewrites to `/lineage/downloads`
* (task 11). The App Store link is an absolute external URL, unchanged.
* - `SimpleParallax` reuses the existing Cave parallax background — a
* fitting "dark fantasy" ambience — and continues to work under the
* subdomain because assets are served from the shared `/public` root.
*
* Auth: this page performs NO auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`)
* is for the mobile app's API calls, not the web landing page.
* This intentionally preserves the original marketing-page structure: a single
* full-viewport `SimpleParallax` Cave background with the app icon, title,
* tagline, and store badges centered on screen. No sidebars, no scrollable
* feature sections, and no extra content — the parallax effect depends on the
* page being exactly one viewport tall. The persistent `SubdomainHeader`
* sits sticky above the parallax backdrop so navigation is reachable on every
* subdomain page.
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import SimpleParallax from "~/components/SimpleParallax";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import { For } from "solid-js";
import {
APP_STORE_URL,
DOWNLOADS_HREF,
APP_ICON_SRC,
GOOGLE_PLAY_BADGE_SRC,
SCREENSHOT_ASSETS,
PAGE_META,
FEATURES
PAGE_META
} from "~/routes/lineage/landing-content";
export default function LineageLandingPage() {
return (
<>
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<SimpleParallax>
<div class="flex h-full flex-col items-center justify-center px-4 text-white">
{/* Hero */}
<img
src={APP_ICON_SRC}
alt="Life and Lineage App Icon"
height={128}
width={128}
class="object-cover object-center"
/>
<h1 class="mb-4 mt-4 text-center text-5xl font-bold">
<div>
<img
src={APP_ICON_SRC}
alt="Life and Lineage App Icon"
height={128}
width={128}
class="object-cover object-center"
/>
</div>
<h1 class="mt-4 mb-4 text-center text-5xl font-bold">
Life and Lineage
</h1>
<p class="mb-8 text-xl">A dark fantasy adventure</p>
<p class="text-xl">A dark fantasy adventure</p>
{/* Store badges — App Store (external) + Google Play → /downloads */}
<div class="flex flex-wrap items-center justify-center gap-4">
<div class="mt-8 flex flex-wrap items-center justify-center gap-4">
<a
class="my-auto transition-all duration-200 ease-out active:scale-95"
href={APP_STORE_URL}
@@ -75,68 +64,6 @@ export default function LineageLandingPage() {
/>
</A>
</div>
{/* Feature highlights */}
<section class="mt-16 w-full max-w-4xl">
<h2 class="mb-6 text-center text-2xl font-semibold">
Why players return
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<For each={FEATURES}>
{(feature) => (
<div class="bg-base/70 rounded-lg p-4 backdrop-blur-sm">
<h3 class="mb-1 text-lg font-bold">{feature.title}</h3>
<p class="text-sm text-subtext0">
{feature.description}
</p>
</div>
)}
</For>
</div>
</section>
{/* Game art / screenshots strip */}
<section class="mt-16 w-full max-w-4xl">
<h2 class="mb-6 text-center text-2xl font-semibold">
glimpses of the world
</h2>
<div class="flex flex-col items-center gap-6">
<div class="flex flex-wrap items-center justify-center gap-6">
<img
src={SCREENSHOT_ASSETS.home}
alt="Life and Lineage home"
class="h-auto w-full max-w-sm rounded-lg shadow-lg"
loading="lazy"
/>
<img
src={SCREENSHOT_ASSETS.shops}
alt="Life and Lineage shops"
class="h-auto w-full max-w-sm rounded-lg shadow-lg"
loading="lazy"
/>
</div>
<video
src={SCREENSHOT_ASSETS.preview}
class="w-full max-w-2xl rounded-lg shadow-lg"
controls
muted
playsinline
preload="metadata"
/>
</div>
</section>
{/* Secondary CTA → per-subdomain downloads page */}
<p class="mt-16 text-sm text-subtext0">
Prefer a direct download?{" "}
<A
href={DOWNLOADS_HREF}
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
>
Visit the downloads page
</A>
.
</p>
</div>
</SimpleParallax>
</>

View File

@@ -17,6 +17,7 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function LineagePrivacyPolicy() {
return (
@@ -25,6 +26,7 @@ export default function LineagePrivacyPolicy() {
title="Privacy Policy"
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Life and Lineage&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: October 22, 2024</div>
@@ -52,10 +54,7 @@ export default function LineagePrivacyPolicy() {
<div class="pb-2">
<div class="-ml-6">(b) Data Removal:</div> Users can request the
removal of all information related to them by visiting{" "}
<A
href="/deletion"
class="text-blue hover-underline-animation"
>
<A href="/deletion" class="text-blue hover-underline-animation">
this page
</A>{" "}
and filling out the provided form.

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
* Nessa contact page (`nessa.freno.me/contact`).
@@ -17,5 +18,10 @@ import { ContactForm } from "~/components/ContactForm";
* `[Nessa] Contact Request`.
*/
export default function NessaContactPage() {
return <ContactForm />;
return (
<>
<SubdomainHeader />
<ContactForm />
</>
);
}

193
src/routes/nessa/content.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Pure content + metadata for the Nessa subdomain landing page.
*
* Sourced from the real product positioning doc in
* `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
* and the profitability plan (`nessa_profitability_plan_2026-03-09.md`).
*
* Intentionally imports NOTHING from solid-js / @solidjs /
* @solidjs/meta so it can be unit-tested with `bun:test` and referenced from
* the route render layer without dragging the router into pure tests.
*/
export const TAGLINE = "The fitness app that puts you first." as const;
export const SUBTITLE =
"Tired of Strava's paywalls and price hikes? Track, train, and connect — without the paywall." as const;
export const ICON_DEFAULT =
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;
export const SCREENSHOTS = {
home: {
src: "/Nessa Exports/01-home-tab.png",
alt: "Nessa home dashboard with activity metrics"
},
segments: {
src: "/Nessa Exports/40-segment-list.png",
alt: "Segment leaderboards and KOM/QOM contenders"
},
clubs: {
src: "/Nessa Exports/09-clubs-list.png",
alt: "Clubs and social feed"
},
workoutSummary: {
src: "/Nessa Exports/38-workout-summary.png",
alt: "Workout summary and training log"
},
plans: {
src: "/Nessa Exports/04-plans-tab-strength.png",
alt: "AI-powered training plans and structured workouts"
},
appleHealth: {
src: "/Nessa Exports/29-apple-health.png",
alt: "Apple Health integration"
}
} as const;
export interface Feature {
title: string;
description: string;
free: boolean;
}
export const FEATURES: readonly Feature[] = [
{
title: "Track every sport",
description:
"Activity tracking for running, cycling, swimming, and 8+ sports with full GPS, heart-rate, and duration metrics.",
free: true
},
{
title: "Segment leaderboards — free forever",
description:
"Create segments and compete on leaderboards without paying a subscription. Nessa keeps the features other apps gate behind paywalls free.",
free: true
},
{
title: "Clubs & community challenges",
description:
"Join clubs, run monthly challenges, post to the social feed, and cheer friends on with kudos and comments.",
free: true
},
{
title: "Native Apple Watch companion",
description:
"Start, follow, and finish workouts from your wrist. Built from the ground up for the Apple ecosystem with Apple Health integration.",
free: true
},
{
title: "Route planning & offline maps",
description:
"Plan routes with turn-by-turn navigation and download maps so you stay on track when coverage drops.",
free: false
},
{
title: "AI training plans",
description:
"Get personalized training plans shaped around your goals, schedule, and fitness history.",
free: false
}
] as const;
export interface PricingTier {
key: "free" | "plus" | "pro";
header: string;
price: string;
badge?: string;
headline: string;
features: readonly string[];
cta: string;
}
export const PRICING: readonly PricingTier[] = [
{
key: "free",
header: "Everything You Need",
price: "$0 — Always Free",
headline:
"Most fitness apps charge for the basics. We don't. Track your activities, compete on segments, and connect with friends — completely free.",
features: [
"Activity tracking for 8+ sports",
"Segment creation + leaderboards",
"Social feed, kudos & comments",
"Training log & activity history",
"Customizable heart-rate zones",
"Clubs & community challenges",
"Apple Watch companion app",
"Apple Health integration"
],
cta: "Get Started Free"
},
{
key: "plus",
header: "Take It Further",
price: "$4.99/month or $49.99/year",
badge: "17% savings with annual",
headline:
"Plan your routes, go offline, and dive deeper into your performance data. Everything you need to train smarter.",
features: [
"Route planning with turn-by-turn navigation",
"Offline maps for areas without coverage",
"Advanced segment analytics",
"Personal heatmaps showing all your adventures"
],
cta: "Start Free Trial"
},
{
key: "pro",
header: "Train Smarter",
price: "$9.99/month or $99.99/year",
badge: "17% savings with annual",
headline:
"AI-powered training plans, advanced analytics, and premium challenges. For athletes who mean business.",
features: [
"AI-powered personalized training plans",
"Premium challenges with rewards",
"Fitness & freshness tracking",
"Matched activities for route comparison",
"Priority customer support"
],
cta: "Start Free Trial"
}
] as const;
export interface ComparisonRow {
feature: string;
strava: string;
nessa: string;
}
export const COMPARISON: readonly ComparisonRow[] = [
{
feature: "Segment leaderboards",
strava: "Paywalled",
nessa: "Free forever"
},
{ feature: "Privacy", strava: "Server-side data", nessa: "On-device first" },
{ feature: "Premium price", strava: "$23.99/mo", nessa: "From $4.99/mo" },
{
feature: "Apple Watch",
strava: "Companion app",
nessa: "Native experience"
}
] as const;
export const WHY_NESSA = [
{
title: "Segment leaderboards free forever",
body: "Strava's most complained-about paywall is included in Nessa's free tier."
},
{
title: "Half the price of Strava",
body: "Premium tiers at 5060% of Strava's cost, with no surprise paywalls."
},
{
title: "Privacy-first",
body: "Your fitness data stays on your device. No data mining, no targeted ads."
},
{
title: "Built for Apple Watch",
body: "A native watch experience, not an afterthought. Apple Health included."
}
] as const;

View File

@@ -24,6 +24,7 @@
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
*/
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DeletionForm from "~/components/DeletionForm";
import {
DELETION_PRODUCT_KEY,
@@ -36,6 +37,7 @@ export default function NessaDeletionPage() {
return (
<>
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="pt-20">
<div class="mx-auto p-4 md:p-6 lg:p-12">
<div class="text-text w-full justify-center">

View File

@@ -1,298 +1,358 @@
import { Show, For, type JSX } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode";
import { NESSA_LANDING_META } from "./meta";
/**
* Nessa landing page (task 07).
* Nessa landing page.
*
* Net-new branded marketing home for `nessa.freno.me`. Served at the public
* URL `/` (vercel.json rewrites `nessa.freno.me/*` → the internal `/nessa/*`
* route prefix — see `src/lib/site-context.ts` + `vercel.json`).
* Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s
* `useSite()` branch in dev). Reflects Nessa's actual product: a
* privacy-first Strava-alternative fitness app with segment leaderboards,
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
*
* Design contract:
* - Public — NO freno.me web-auth UI (Nessa authenticates via Clerk; the
* subdomain nav in `Bars.tsx` already omits the web-auth widgets).
* - Site-aware metadata via `<PageHead>` — title suffix, canonical, and OG
* image all derive from `useSite()` (`nessa` site config).
* - Themed via `useDarkMode()` + the nessa `brandColor` (#cba6f7) accent.
* - Feature copy is derived from the real server-side capabilities in
* `nessaCommunityRouter` (clubs, challenges, social feed, events) — not
* fabricated. Nessa has no app-store presence or downloadable artifacts
* yet, so the CTA is "Coming soon" rather than a fake download link.
*
* Acceptance: `nessa.localhost:3000/` renders the landing page with hero,
* feature highlights, and a CTA; `<html data-site="nessa">` and a `<title>`
* containing "Nessa" are emitted by task-01 SSR + task-02 PageHead.
* Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
* and kept in a pure `./content.ts` module so the acceptance matrix is
* testable without spinning up the router.
*/
import { For, Show } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
import { A } from "@solidjs/router";
import { NESSA_LANDING_META } from "./meta";
import {
TAGLINE,
SUBTITLE,
ICON_DEFAULT,
ICON_DARK,
SCREENSHOTS,
FEATURES,
PRICING,
COMPARISON,
WHY_NESSA
} from "./content";
/** Inline SVG icon set — kept local to the page (no shared icon key needed). */
function FeatureIcon(props: { name: string }): JSX.Element {
const common = (viewBox: string, path: JSX.Element) => (
/** Small SVG checkmark used in pricing cards. */
function CheckIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={28}
width={28}
viewBox={viewBox}
class="h-full w-full"
viewBox="0 0 24 24"
class="mt-0.5 h-5 w-5 shrink-0"
fill="none"
stroke="currentColor"
stroke-width={2}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{path}
<path d="M20 6 9 17l-5-5" />
</svg>
);
switch (props.name) {
case "clubs":
return common(
"0 0 24 24",
<>
<circle cx={9} cy={8} r={3} />
<circle cx={17} cy={10} r={2.5} />
<path d="M3 21c0-3.314 2.686-6 6-6s6 2.686 6 6" />
<path d="M15 21c0-2.21 1.343-4 3-4s3 1.79 3 4" />
</>
);
case "challenges":
return common(
"0 0 24 24",
<>
<path d="M12 2 3 7v6c0 4.97 3.806 8.74 9 9 5.194-.26 9-4.03 9-9V7l-9-5Z" />
<path d="m9 12 2 2 4-4" />
</>
);
case "social":
return common(
"0 0 24 24",
<>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
</>
);
case "events":
return common(
"0 0 24 24",
<>
<rect x={3} y={4} width={18} height={18} rx={2} />
<path d="M16 2v4M8 2v4M3 10h18" />
</>
);
default:
return <></>;
}
}
/** A single feature highlight card. */
function FeatureCard(props: {
icon: string;
title: string;
description: string;
}): JSX.Element {
return (
<div class="border-surface0 bg-surface0/30 hover:bg-surface0/50 flex flex-col gap-3 rounded-2xl border-2 p-6 transition-colors duration-200">
<div
class="flex h-14 w-14 items-center justify-center rounded-xl"
style={{ color: "var(--brand-color, #cba6f7)" }}
>
<FeatureIcon name={props.icon} />
</div>
<h3 class="text-xl font-semibold">{props.title}</h3>
<p class="text-text/80 text-base leading-relaxed">{props.description}</p>
</div>
);
}
interface Feature {
icon: string;
title: string;
description: string;
}
/**
* Feature set derived from `nessaCommunityRouter` procedures:
* - clubs.{list,get,create,…} → "Clubs"
* - challenges.{list,get,…} → "Challenges"
* - social.{feed,getPost,likes,…} → "Social Feed"
* - events.{list,create,rsvp,…} → "Events"
*/
const FEATURES: Feature[] = [
{
icon: "clubs",
title: "Clubs",
description:
"Create and join clubs around shared interests. Public or private, with member rosters, rules, and rich profiles that keep your community organized."
},
{
icon: "challenges",
title: "Challenges",
description:
"Run goal-driven challenges with start and end dates, track progress, and celebrate completions. Members stay motivated with live participation."
},
{
icon: "social",
title: "Social Feed",
description:
"Share posts inside your clubs, react with likes, and reply in threaded comments. A social layer that lives right where your community already gathers."
},
{
icon: "events",
title: "Events",
description:
"Organize events and let members RSVP — going, maybe, or not going. Coordinate meetups and activities without leaving the club."
}
];
export default function NessaLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT);
const brandColor = () => site().brandColor;
return (
<>
<PageHead {...NESSA_LANDING_META} />
<SubdomainHeader />
<main
class="relative min-h-screen w-full overflow-x-hidden"
// Drive the per-site brand accent via a CSS custom property so child
// elements (feature icons, CTA) can reference `var(--brand-color)`
// without re-reading the site accessor on every render.
style={{ "--brand-color": site().brandColor }}
style={{ "--brand-color": brandColor() }}
>
{/*
* Soft brand-tinted backdrop. Two layered radial gradients give the
* page depth on both light and dark themes without competing with the
* content. Fixed so it stays put while the page scrolls.
*/}
{/* Soft brand-tinted backdrop */}
<div
class="pointer-events-none fixed inset-0 z-0"
aria-hidden="true"
style={{
background: isDark()
? `radial-gradient(60% 50% at 50% 0%, ${site().brandColor}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${site().brandColor}1a 0%, transparent 70%)`
: `radial-gradient(60% 50% at 50% 0%, ${site().brandColor}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${site().brandColor}12 0%, transparent 70%)`
? `radial-gradient(60% 50% at 50% 0%, ${brandColor()}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}1a 0%, transparent 70%)`
: `radial-gradient(60% 50% at 50% 0%, ${brandColor()}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}12 0%, transparent 70%)`
}}
/>
{/* ───────────────────────── Hero ───────────────────────── */}
<section class="relative z-10 flex flex-col items-center px-4 pb-16 pt-24 text-center md:pt-32">
{/* Brand mark — a CSS gradient circle standing in for the icon
until Nessa ships a real icon asset. */}
<div
class="mb-8 flex h-24 w-24 items-center justify-center rounded-[1.75rem] text-5xl font-black text-white shadow-lg md:h-28 md:w-28"
style={{
background: `linear-gradient(135deg, ${site().brandColor} 0%, var(--color-mauve, #cba6f7) 100%)`
}}
aria-hidden="true"
>
N
{/* ─── Hero ───────────────────────────────────────────────── */}
<section class="relative z-10 flex flex-col items-center px-4 pt-24 pb-16 text-center md:pt-32">
<div class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32">
<img
src={iconSrc()}
alt="Nessa app icon"
width={128}
height={128}
class="h-full w-full rounded-[1.75rem] object-cover"
/>
</div>
<h1 class="text-5xl font-bold tracking-tight md:text-7xl">Nessa</h1>
<h1 class="max-w-3xl text-4xl font-bold tracking-tight md:text-6xl">
{TAGLINE}
</h1>
<p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
Build community. Run challenges. Stay connected.
{SUBTITLE}
</p>
<p class="text-text/70 mt-3 max-w-xl text-base md:text-lg">
A home for your clubs bring members together with challenges, a
social feed, and events, all in one place.
</p>
{/*
* CTA — Nessa authenticates via Clerk and runs entirely server-side;
* there is no public app-store listing or downloadable artifact yet,
* so this is a "Coming soon" affordance rather than a fabricated
* download button. (Per task notes: don't fabricate download links.)
*/}
<div class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
<button
type="button"
disabled
class="cursor-not-allowed rounded-full px-8 py-3 text-base font-semibold text-white opacity-95 shadow-md transition-transform"
style={{ background: site().brandColor }}
title="Nessa is coming soon"
<A
href="/contact"
class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95"
style={{ background: brandColor() }}
>
Coming soon
</button>
<a
Join the waitlist
</A>
<A
href="/contact"
class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors"
>
Get in touch
</a>
Request beta access
</A>
</div>
<p class="text-text/50 mt-4 text-sm">
Nessa is in active development. Reach out to learn more.
<p class="text-text/60 mt-4 text-sm">
Launching soon on the App Store.
</p>
</section>
{/* ─────────────────────── Features ─────────────────────── */}
{/* ─── Free-tier feature highlights ───────────────────────── */}
<section class="relative z-10 px-4 py-16" id="features">
<div class="mx-auto max-w-6xl">
<div class="mb-12 text-center">
<h2 class="text-3xl font-bold md:text-4xl">
Everything your club needs
Everything you need, free forever
</h2>
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
Nessa brings the tools communities actually use into one
shared space no more juggling chats, spreadsheets, and
half-built trackers.
Nessa gives away the features other apps lock behind
subscriptions because your workouts shouldn't cost extra.
</p>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={FEATURES}>
{(feature) => <FeatureCard {...feature} />}
{(feature) => (
<div class="border-surface0 bg-surface0/30 hover:bg-surface0/50 flex flex-col gap-3 rounded-2xl border-2 p-6 transition-colors">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold">{feature.title}</h3>
<Show when={feature.free}>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
style={{ background: brandColor() }}
>
free
</span>
</Show>
</div>
<p class="text-text/80 text-base leading-relaxed">
{feature.description}
</p>
</div>
)}
</For>
</div>
</div>
</section>
{/* ──────────────────── Secondary CTA band ──────────────────── */}
<section class="relative z-10 px-4 py-20">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-3xl font-bold md:text-4xl">
Ready to grow your community?
</h2>
<p class="text-text/70 mx-auto mt-3 max-w-xl text-base md:text-lg">
Nessa is being built for clubs that want structure without the
friction. We&apos;ll share more as it takes shape.
</p>
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="/contact"
class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95"
style={{ background: site().brandColor }}
>
Contact us
</a>
<a
href="/privacy"
class="text-text/80 hover:text-text underline-offset-4 hover:underline"
>
Privacy policy
</a>
{/* ─── Pricing tiers ───────────────────────────────────────── */}
<section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-6xl">
<div class="mb-12 text-center">
<h2 class="text-3xl font-bold md:text-4xl">
Premium features, half the price of Strava
</h2>
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
Choose the plan that fits your training.
</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<For each={PRICING}>
{(tier) => {
const highlighted = tier.key === "plus";
return (
<div
class="border-surface0 bg-base/80 flex flex-col rounded-2xl border-2 p-6 backdrop-blur-sm"
classList={{
"scale-[1.02]": highlighted
}}
style={{
"border-color": highlighted ? brandColor() : undefined
}}
>
<div class="mb-4">
<h3 class="text-xl font-semibold">{tier.header}</h3>
<div class="mt-1 text-2xl font-bold">{tier.price}</div>
<Show when={tier.badge}>
<div
class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-semibold text-white"
style={{ background: brandColor() }}
>
{tier.badge}
</div>
</Show>
</div>
<p class="text-text/80 mb-6 leading-relaxed">
{tier.headline}
</p>
<ul class="mb-8 flex flex-col gap-3">
<For each={tier.features}>
{(item) => (
<li class="flex items-start gap-3 text-sm">
<CheckIcon />
<span>{item}</span>
</li>
)}
</For>
</ul>
<A
href="/contact"
class="mt-auto w-full rounded-full py-3 text-center text-base font-semibold transition-transform active:scale-95"
classList={{
"text-white": highlighted,
"border-surface2 border-2 hover:bg-surface0/40":
!highlighted
}}
style={{
background: highlighted ? brandColor() : undefined
}}
>
{tier.cta}
</A>
</div>
);
}}
</For>
</div>
</div>
</section>
{/* ───────────────────────── Footer ───────────────────────── */}
{/* ─── Comparison vs Strava ─────────────────────────────────── */}
<section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-4xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">
See how we compare
</h2>
<p class="text-text/70 mt-3 text-center text-base md:text-lg">
Get more for less with Nessa.
</p>
<div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2">
<div class="bg-surface0/60 grid grid-cols-3 px-6 py-4 text-sm font-semibold">
<span>Feature</span>
<span class="text-center">Strava</span>
<span class="text-center" style={{ color: brandColor() }}>
Nessa
</span>
</div>
<For each={COMPARISON}>
{(row, idx) => (
<div
class="grid grid-cols-3 px-6 py-4 text-sm"
classList={{
"bg-surface0/20": idx() % 2 === 1
}}
>
<span>{row.feature}</span>
<span class="text-text/70 text-center">{row.strava}</span>
<span
class="text-center font-medium"
style={{ color: brandColor() }}
>
{row.nessa}
</span>
</div>
)}
</For>
</div>
</div>
</section>
{/* ─── Why Nessa ───────────────────────────────────────────── */}
<section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-6xl">
<h2 class="mb-10 text-center text-3xl font-bold md:text-4xl">
Why athletes are switching
</h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<For each={WHY_NESSA}>
{(item) => (
<div class="border-surface0 bg-surface0/30 rounded-2xl border-2 p-6">
<h3 class="mb-2 text-lg font-semibold">{item.title}</h3>
<p class="text-text/80">{item.body}</p>
</div>
)}
</For>
</div>
</div>
</section>
{/* ─── Screenshots ─────────────────────────────────────────── */}
<section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-6xl">
<h2 class="mb-10 text-center text-3xl font-bold md:text-4xl">
Built for your whole training life
</h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={Object.values(SCREENSHOTS)}>
{(shot) => (
<div class="border-surface0 overflow-hidden rounded-2xl border-2">
<img
src={shot.src}
alt={shot.alt}
class="h-auto w-full object-cover"
loading="lazy"
/>
</div>
)}
</For>
</div>
</div>
</section>
{/* ─── Final CTA ───────────────────────────────────────────── */}
<section class="relative z-10 px-4 py-20">
<div
class="mx-auto max-w-4xl rounded-3xl px-8 py-16 text-center text-white"
style={{ background: brandColor() }}
>
<h2 class="text-3xl font-bold md:text-4xl">
Ready to put your fitness first?
</h2>
<p class="mx-auto mt-3 max-w-xl text-base text-white/90 md:text-lg">
Join the waitlist and be the first to know when Nessa lands on the
App Store.
</p>
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<A
href="/contact"
class="rounded-full bg-white px-8 py-3 text-base font-semibold transition-transform hover:scale-[1.02] active:scale-95"
style={{ color: brandColor() }}
>
Join the waitlist
</A>
<A
href="/privacy"
class="text-white/90 underline-offset-4 hover:underline"
>
Privacy policy
</A>
</div>
</div>
</section>
{/* ─── Footer ───────────────────────────────────────────────── */}
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm text-text/60 sm:flex-row">
<Show
when={site().subdomain}
fallback={<span>{site().displayName}</span>}
>
<span>{site().displayName}</span>
</Show>
<a
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<span>{site().displayName}</span>
<A
href="https://freno.me"
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me
</a>
</A>
</div>
</footer>
</main>

View File

@@ -1,14 +1,11 @@
/**
* Unit tests for the Nessa landing page metadata (task 07).
* Unit tests for the Nessa landing page metadata.
*
* Asserts the task-spec unit criterion: "PageHead on nessa site produces
* correct title suffix and canonical." Mirrors the `page-head-meta.ts`
* testability pattern — `nessa/meta.ts` is a pure module (no solid-js /
* @solidjs/router / @solidjs/meta imports) so `bun:test` can resolve it.
*
* The resolved values are produced by the (task-02) `resolvePageHeadMeta`
* helper over `NESSA_LANDING_META` + `SITE_CONFIG.nessa` + the browser
* pathname `/` — exactly what `<PageHead>` emits on `nessa.freno.me/`.
* Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
* pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
* `bun:test` can resolve it. Asserts the metadata matches Nessa's actual
* product positioning as a privacy-first fitness / Strava-alternative app
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
*/
import { describe, it, expect } from "bun:test";
import { resolvePageHeadMeta } from "~/components/page-head-meta";
@@ -17,35 +14,71 @@ import { NESSA_LANDING_META } from "./meta";
describe("Nessa landing page — PageHead metadata", () => {
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.title).toBe("Nessa | Nessa");
});
it("title contains the substring 'Nessa'", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.title).toContain("Nessa");
});
it("canonical is https://nessa.freno.me/ for the landing route", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.canonical).toBe("https://nessa.freno.me/");
});
it("ogImage falls back to the nessa site default", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
});
it("ogTitle uses the explicit marketing copy, not the bare title", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.ogTitle).toBe(NESSA_LANDING_META.ogTitle);
expect(meta.ogTitle).not.toBe("Nessa");
});
it("description mentions community capabilities", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
it("description positions Nessa as a fitness app with community features", () => {
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.description).toBe(NESSA_LANDING_META.description);
expect(meta.description?.toLowerCase()).toContain("fitness");
expect(meta.description?.toLowerCase()).toContain("segment");
expect(meta.description?.toLowerCase()).toContain("community");
expect(meta.description?.toLowerCase()).toContain("challenges");
});
it("ogDescription mentions free leaderboards and Strava comparison", () => {
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards");
expect(meta.ogDescription?.toLowerCase()).toContain("strava");
});
});

View File

@@ -1,23 +1,16 @@
/**
* Deterministic `PageHead` metadata for the Nessa landing page (task 07).
* Deterministic `PageHead` metadata for the Nessa landing page.
*
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
* so the landing-page metadata can be unit-tested in `bun:test` without
* spinning up the router / MetaProvider / DOM, mirroring the
* `page-head-meta.ts` / `nav-config.ts` testability pattern.
*
* `index.tsx` imports `NESSA_LANDING_META` and feeds it to `<PageHead …/>`;
* `meta.test.ts` asserts the *resolved* title / canonical / OG values against
* the site-aware `resolvePageHeadMeta` helper (task 02) for the `nessa` site.
*
* Nessa-specific metadata contract:
* - `<title>` resolves to `"Nessa | Nessa"` (props.title + nessa.titleSuffix).
* - canonical resolves to `https://nessa.freno.me/` (auto-derived from
* `site.domain` + the browser pathname `/` — the vercel.json host rewrite
* targets the internal `/nessa` prefix but leaves the public URL clean).
* - `og:image` falls back to the site's `ogDefaultImage` (`/nessa/og-default.png`).
* - description emphasizes Nessa's community capabilities (clubs, challenges,
* social feed, events) — derived from `nessaCommunityRouter`, not fabricated.
* Nessa is positioned as a privacy-first fitness app and Strava alternative
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
* The description still mentions community features (clubs, challenges) because
* those are real free-tier capabilities, but it now leads with the product's
* actual purpose: fitness tracking.
*/
import type { PageHeadProps } from "~/components/page-head-meta";
@@ -32,8 +25,8 @@ import type { PageHeadProps } from "~/components/page-head-meta";
export const NESSA_LANDING_META: PageHeadProps = {
title: "Nessa",
description:
"Nessa is a community platform for building clubs, running challenges, sharing in a social feed, and organizing events.",
ogTitle: "Nessa — Build community. Run challenges. Stay connected.",
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
ogTitle: "Nessa — The fitness app that puts you first",
ogDescription:
"Nessa brings clubs, challenges, social posts, and events together in one community platform."
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and premium tiers for less than Strava."
};

View File

@@ -30,6 +30,7 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function NessaPrivacyPolicy() {
return (
@@ -38,9 +39,10 @@ export default function NessaPrivacyPolicy() {
title="Privacy Policy"
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Nessa&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: February 9, 2026</div>
<div class="py-2">Last Updated: July 23, 2026</div>
<div class="py-2">
Welcome to Nessa (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
Your privacy is important to us. This privacy policy will help you
@@ -86,18 +88,14 @@ export default function NessaPrivacyPolicy() {
<div class="-ml-6">(d) Data Removal:</div> You can request the
removal of your Nessa account and all associated content by
contacting us{" "}
<A
href="/contact"
class="text-blue hover-underline-animation"
>
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
. On receipt of your request we will remove your account
record, your club memberships, your challenges and
participation data, and your posts, comments, and likes from the
Nessa database, and we will request that Clerk delete the
corresponding user record. A short grace period may apply while
the deletion is processed.
. On receipt of your request we will remove your account record,
your club memberships, your challenges and participation data,
and your posts, comments, and likes from the Nessa database, and
we will request that Clerk delete the corresponding user record.
A short grace period may apply while the deletion is processed.
</div>
</div>
</div>
@@ -120,13 +118,13 @@ export default function NessaPrivacyPolicy() {
</div>
<div class="pb-2">
<div class="-ml-6">(b) Clerk as Identity Provider:</div> Your
authentication credentials (such as your password, if you use
an email/password sign-in) are handled exclusively by Clerk
under Clerk&apos;s own privacy policy. Nessa never receives,
stores, or transmits your password. When you sign in, Nessa
receives a session token from Clerk that lets us identify you;
this token is verified on each request and is not persisted in
our database.
authentication credentials (such as your password, if you use an
email/password sign-in) are handled exclusively by Clerk under
Clerk&apos;s own privacy policy. Nessa never receives, stores,
or transmits your password. When you sign in, Nessa receives a
session token from Clerk that lets us identify you; this token
is verified on each request and is not persisted in our
database.
</div>
</div>
</div>

View File

@@ -1,4 +1,9 @@
import { createTRPCRouter, publicProcedure, protectedProcedure, csrfProtectedProcedure } from "../utils";
import {
createTRPCRouter,
publicProcedure,
protectedProcedure,
csrfProtectedProcedure
} from "../utils";
import { z } from "zod";
import {
S3Client,
@@ -42,7 +47,7 @@ export function sanitizeS3PathComponent(value: string): string {
// Strip path traversal characters and normalize whitespace
return value
.replace(/\s+/g, "-")
.replace(/[\/\\]/g, "-")
.replace(/[/\\]/g, "-")
.replace(/\.\./g, "")
.replace(/[^a-zA-Z0-9_-]/g, "")
.replace(/-+/g, "-")
@@ -70,15 +75,17 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
// tested in `bun:test` without a populated `.env`. Re-exported here for the
// tRPC mutation below + for callers that already import from `misc`.
export {
// Import into local scope FIRST — `sendDeletionRequestEmail` below uses
// these names directly. A bare `export { ... } from` re-export does NOT make
// the bindings available locally, which caused a ReferenceError that crashed
// the entire tRPC router (503 on every /api/trpc call).
import {
DELETION_PRODUCT_SCHEMA,
deletionCookieName,
deletionEmailContent
} from "./deletion-email";
export type {
DeletionProduct,
DeletionEmailContent
} from "./deletion-email";
export { DELETION_PRODUCT_SCHEMA, deletionCookieName, deletionEmailContent };
export type { DeletionProduct, DeletionEmailContent } from "./deletion-email";
const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk",
@@ -378,10 +385,13 @@ export const miscRouter = createTRPCRouter({
);
if (!turnstileValid) {
console.error("Turnstile verification failed for contact form submission");
console.error(
"Turnstile verification failed for contact form submission"
);
throw new TRPCError({
code: "FORBIDDEN",
message: "Security verification failed. Please refresh the page and try again."
message:
"Security verification failed. Please refresh the page and try again."
});
}

View File

@@ -14,6 +14,7 @@ import type { APIEvent } from "@solidjs/start/server";
import type { H3Event } from "vinxi/http";
import {
resolveSiteFromHost,
resolveSiteFromPath,
type Site,
MAIN_SITE
} from "~/lib/site-context";
@@ -53,7 +54,9 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
try {
const nodeReq = (
event as {
nativeEvent?: { node?: { req?: { headers?: Record<string, string | string[]> } } };
nativeEvent?: {
node?: { req?: { headers?: Record<string, string | string[]> } };
};
} | null
)?.nativeEvent?.node?.req;
const raw = nodeReq?.headers?.host;
@@ -78,16 +81,76 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
return null;
}
/**
* Best-effort extraction of the URL pathname from any request-shaped event.
*
* SolidStart's FetchEvent exposes `event.request.url` (a full URL string);
* nitro/H3 exposes the raw Node request URL via `event.nativeEvent.node.req.url`.
* We try both so this works for API route handlers, the SSR document handler,
* and tRPC procedures operating on the underlying `H3Event`.
*/
function pathFromEventLike(event: ServerSiteEvent | unknown): string | null {
// 1) Web FetchEvent / APIEvent / PageEvent → standard Request URL.
try {
const req = (event as { request?: Request } | null)?.request;
const url = req?.url;
if (url) return new URL(url).pathname;
} catch {
/* noop */
}
// 2) vinxi/nitro H3Event → raw Node request URL.
try {
const nodeReq = (
event as {
nativeEvent?: { node?: { req?: { url?: string } } };
} | null
)?.nativeEvent?.node?.req;
const raw = nodeReq?.url;
if (typeof raw === "string" && raw) {
// Node request URLs may be path-only (`/nessa/contact`) or full URLs.
return raw.startsWith("/") ? raw : new URL(raw).pathname;
}
} catch {
/* noop */
}
// 3) Some H3 shapes expose `event.node.req` directly.
try {
const nodeReq = (
event as {
node?: { req?: { url?: string } };
} | null
)?.node?.req;
const raw = nodeReq?.url;
if (typeof raw === "string" && raw) {
return raw.startsWith("/") ? raw : new URL(raw).pathname;
}
} catch {
/* noop */
}
return null;
}
/**
* Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a
* vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape,
* so the SSR document handler can pass its `PageEvent` directly. Falls back
* to `main` if no host can be determined.
* to `main` if no host or path can be determined.
*/
export function getSiteFromEvent(
event: APIEvent | H3Event | ServerSiteEvent
): Site {
return resolveSiteFromHost(hostFromEventLike(event));
const hostResult = resolveSiteFromHost(hostFromEventLike(event));
// Subdomain host (incl. `*.localhost` dev) → authoritative, use it.
if (hostResult.id !== "main") return hostResult;
// Host resolved to main — fall back to the URL path prefix. On localhost
// the vercel.json host rewrites don't run, so `localhost:3000/nessa/contact`
// has host `localhost` (→ main) but path `/nessa/contact` (→ nessa).
// Without this, every subdomain page in dev renders the main-site nav.
return resolveSiteFromPath(pathFromEventLike(event)) ?? hostResult;
}
/**
@@ -95,7 +158,9 @@ export function getSiteFromEvent(
* functions that receive a `Request` directly). Falls back to `main`.
*/
export function getSiteFromRequest(request: Request): Site {
return resolveSiteFromHost(request.headers.get("host"));
const hostResult = resolveSiteFromHost(request.headers.get("host"));
if (hostResult.id !== "main") return hostResult;
return resolveSiteFromPath(new URL(request.url).pathname) ?? hostResult;
}
export { MAIN_SITE };