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

@@ -1,6 +1,7 @@
# Agent Guidelines for freno-dev # Agent Guidelines for freno-dev
### Tech Stack ### Tech Stack
- **Framework**: SolidJS with SolidStart (Vinxi) - **Framework**: SolidJS with SolidStart (Vinxi)
- **Routing**: @solidjs/router - **Routing**: @solidjs/router
- **API**: tRPC v10 with Zod validation - **API**: tRPC v10 with Zod validation
@@ -9,20 +10,33 @@
- **Runtime**: Bun (Node >=22) - **Runtime**: Bun (Node >=22)
- **Deployment**: Vercel preset - **Deployment**: Vercel preset
## Content Rules
### No Competitor Mentions
- **Never mention a competitor's product by name** in any user-facing content (landing pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, etc.).
- This includes but is not limited to products like Strava, Garmin, etc.
- Describe the product's own merits and positioning on its own terms. Use generic descriptions instead of naming competitors.
- This applies to ALL products (Nessa, Lineage, Gaze, InputHalo) and the main site.
- If existing content already names a competitor, remove or generalize it when working in that file.
## Code Style ## Code Style
### Naming Conventions ### Naming Conventions
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`) - **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`) - **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`) - **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants - **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
### Imports ### Imports
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"` - Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"` - Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
- Group imports: external deps → solid-js → local (~/) - Group imports: external deps → solid-js → local (~/)
### SolidJS Patterns (NOT React!) ### SolidJS Patterns (NOT React!)
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read - **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array) - **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary - **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
@@ -31,6 +45,7 @@
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs - **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
### TypeScript ### TypeScript
- **Strict mode enabled** - always type function params and returns - **Strict mode enabled** - always type function params and returns
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>` - Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
- Use `splitProps()` for component prop destructuring - Use `splitProps()` for component prop destructuring
@@ -38,6 +53,7 @@
- Database types: Cast with `as unknown as User` for SQL results - Database types: Cast with `as unknown as User` for SQL results
### API/Server Patterns ### API/Server Patterns
- **tRPC routers**: Export from `src/server/api/routers/*.ts` - **tRPC routers**: Export from `src/server/api/routers/*.ts`
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes - **Procedures**: Use `.query()` for reads, `.mutation()` for writes
- **Validation**: Use Zod schemas in `.input()` - validate all user input - **Validation**: Use Zod schemas in `.input()` - validate all user input
@@ -46,17 +62,20 @@
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only - **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
### Error Handling ### Error Handling
- Use TRPCError with semantic codes on server - Use TRPCError with semantic codes on server
- Validate inputs with Zod schemas before processing - Validate inputs with Zod schemas before processing
- Check auth state before mutations: throw UNAUTHORIZED if missing userId - Check auth state before mutations: throw UNAUTHORIZED if missing userId
- Return structured responses: `{ success: boolean, message?: string }` - Return structured responses: `{ success: boolean, message?: string }`
### Comments ### Comments
- **Minimal comments** - prefer self-documenting code - **Minimal comments** - prefer self-documenting code
- JSDoc for exported functions/components only - JSDoc for exported functions/components only
- Inline comments for non-obvious logic only - Inline comments for non-obvious logic only
### File Organization ### File Organization
- Routes in `src/routes/` (file-based routing) - Routes in `src/routes/` (file-based routing)
- Components in `src/components/` (reusable) or co-located with routes - Components in `src/components/` (reusable) or co-located with routes
- API routers in `src/server/api/routers/` - API routers in `src/server/api/routers/`
@@ -73,7 +92,9 @@ This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me
- **Auth:** Host-scoped only — no cookie domain broadening. - **Auth:** Host-scoped only — no cookie domain broadening.
## Key Differences from React ## Key Differences from React
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas: See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
- Signals must be called with `()` to read value - Signals must be called with `()` to read value
- `onChange``onInput` for real-time input updates - `onChange``onInput` for real-time input updates
- `useEffect``createEffect` (auto-tracking, no deps array) - `useEffect``createEffect` (auto-tracking, no deps array)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

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 * - Client (hydration): reads the SSR-injected `window.__SITE__` id (written
* into the document shell by `entry-server.tsx`) so the post-hydration * into the document shell by `entry-server.tsx`) so the post-hydration
* value matches the `data-site` attribute on `<html>`. Falls back to * value matches the `data-site` attribute on `<html>`. Falls back to
* `resolveSiteFromHost(window.location.hostname)` if the injected id is * `resolveSiteFromLocation(window.location.hostname, window.location.pathname)`
* missing (e.g. client-side navigation / hard refresh quirks). * 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 * 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 * practice; the module-level holder is adequate here. Server functions that
@@ -29,6 +31,7 @@ import { isServer } from "solid-js/web";
import { import {
resolveSiteFromHost, resolveSiteFromHost,
resolveSiteFromLocation, resolveSiteFromLocation,
resolveSiteFromPath,
SITE_CONFIG, SITE_CONFIG,
MAIN_SITE, MAIN_SITE,
type Site, type Site,
@@ -59,7 +62,13 @@ export function resolveClientSite(): Site {
if (typeof window === "undefined") return MAIN_SITE; if (typeof window === "undefined") return MAIN_SITE;
const injected = window.__SITE__; const injected = window.__SITE__;
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected]; 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 ────────────────────────────────────────────────────────────── // ── Context ──────────────────────────────────────────────────────────────
@@ -101,4 +110,4 @@ export function useSite(): Accessor<Site> {
export { SITE_CONFIG, MAIN_SITE }; export { SITE_CONFIG, MAIN_SITE };
export type { Site, SiteId }; export type { Site, SiteId };
export { resolveSiteFromHost, resolveSiteFromLocation }; export { resolveSiteFromHost, resolveSiteFromLocation, resolveSiteFromPath };

View File

@@ -16,13 +16,7 @@ import {
} from "./nav-config"; } from "./nav-config";
import { SITE_CONFIG, type SiteId } from "./site-context"; import { SITE_CONFIG, type SiteId } from "./site-context";
const ALL_SITES: SiteId[] = [ const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
"main",
"nessa",
"lineage",
"gaze",
"inputhalo"
];
describe("NAV_CONFIG — per-site link sets", () => { describe("NAV_CONFIG — per-site link sets", () => {
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => { 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", () => { it("gaze → Home, Contact, Privacy, Downloads", () => {
expect(navLabelsFor("gaze")).toEqual(["Home", "Contact", "Privacy"]); expect(navLabelsFor("gaze")).toEqual([
"Home",
"Contact",
"Privacy",
"Downloads"
]);
}); });
it("inputhalo → Home, Contact, Privacy", () => { it("inputhalo → Home, Contact, Privacy, Downloads", () => {
expect(navLabelsFor("inputhalo")).toEqual(["Home", "Contact", "Privacy"]); 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 * - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
* - nessa: Home, Contact, Privacy * - nessa: Home, Contact, Privacy
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion * - lineage: Home, Downloads, Contact, Privacy, Account Deletion
* - gaze: Home, Contact, Privacy * - gaze: Home, Contact, Privacy, Downloads
* - inputhalo: Home, Contact, Privacy * - inputhalo: Home, Contact, Privacy, Downloads
*/ */
export const NAV_CONFIG: Record<SiteId, NavItem[]> = { export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
main: [ main: [
@@ -107,12 +107,14 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
gaze: [ gaze: [
{ label: "Home", href: "/", icon: "home" }, { label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" }, { 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: [ inputhalo: [
{ label: "Home", href: "/", icon: "home" }, { label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" }, { 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 { describe, it, expect } from "bun:test";
import { import {
resolveSiteFromHost, resolveSiteFromHost,
resolveSiteFromPath,
resolveSiteFromLocation,
SITE_CONFIG, SITE_CONFIG,
type SiteId type SiteId
} from "./site-context"; } from "./site-context";
@@ -80,15 +82,12 @@ describe("resolveSiteFromHost", () => {
}); });
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => { it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
for (const id of [ for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
"nessa",
"lineage",
"gaze",
"inputhalo"
] as SiteId[]) {
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`); expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
expect(SITE_CONFIG[id].subdomain).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(""); 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; 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 * Resolve the active site from a client `window.location`, used by the
* SolidJS `SiteContext` provider during hydration. Server codepaths should * SolidJS `SiteContext` provider during hydration. Server codepaths should
* use `getSiteFromEvent` / `getSiteFromRequest` instead. * 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( export function resolveSiteFromLocation(
hostname: string | null | undefined hostname: string | null | undefined,
pathname?: string | null | undefined
): Site { ): 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 { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* Gaze contact page (`gaze.freno.me/contact`). * Gaze contact page (`gaze.freno.me/contact`).
@@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm";
* `[Gaze] Contact Request`. * `[Gaze] Contact Request`.
*/ */
export default function GazeContactPage() { 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 { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button"; import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
import { downloadAsset } from "~/lib/download-asset"; import { downloadAsset } from "~/lib/download-asset";
const GAZE_APP_STORE_URL = const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
"https://apps.apple.com/us/app/gaze/id6757759498"; const GAZE_MIN_MACOS = "14.6";
const GAZE_MIN_MACOS = "13.0";
const FEATURES = [ const FEATURES = [
{ {
title: "Take regular breaks", title: "Blink reminders",
body: "Gentle, dismissable reminders nudge you to step away from the screen so you can rest and reset." 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." body: "Follow the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds."
}, },
{ {
title: "Posture reminders", title: "Posture check-ins",
body: "Periodic check-ins help you catch slouching before it becomes a habit." 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", title: "Lives in your menu bar",
@@ -53,19 +57,21 @@ export default function GazeLanding() {
<> <>
<PageHead <PageHead
title="Eye & posture health reminder for macOS" 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" ogImage="/look-away.png"
ogTitle="Gaze — Eye and posture health reminder for macOS" 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 ─────────────────────────────────────────────────────── */} {/* ── Hero ─────────────────────────────────────────────────────── */}
<div class="relative flex min-h-screen flex-col"> <div class="relative flex min-h-screen flex-col">
<div class="fixed inset-0 z-0 overflow-hidden brightness-75"> <div class="fixed inset-0 z-0 overflow-hidden brightness-75">
<img <img
src="/look-away.png" src="/look-away.png"
alt="Look away — Gaze hero background" 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" }} style={{ "pointer-events": "none" }}
/> />
</div> </div>
@@ -120,17 +126,17 @@ export default function GazeLanding() {
<h2 class="text-text mb-12 text-center text-3xl font-bold"> <h2 class="text-text mb-12 text-center text-3xl font-bold">
Small reminders, healthier habits Small reminders, healthier habits
</h2> </h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((feature) => ( <For each={FEATURES}>
<div {(feature) => (
class="border-overlay0 bg-surface0 rounded-lg border p-6" <div class="border-overlay0 bg-surface0 rounded-lg border p-6">
> <h3 class="text-text mb-2 text-xl font-semibold">
<h3 class="text-text mb-2 text-xl font-semibold"> {feature.title}
{feature.title} </h3>
</h3> <p class="text-subtext0 leading-relaxed">{feature.body}</p>
<p class="text-subtext0 leading-relaxed">{feature.body}</p> </div>
</div> )}
))} </For>
</div> </div>
</div> </div>
</section> </section>

View File

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

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm"; import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* InputHalo contact page (`inputhalo.freno.me/contact`). * InputHalo contact page (`inputhalo.freno.me/contact`).
@@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm";
* `[InputHalo] Contact Request`. * `[InputHalo] Contact Request`.
*/ */
export default function InputHaloContactPage() { 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 * InputHalo landing page — net-new marketing page for inputhalo.freno.me.
* (task 06).
* *
* Routes: * InputHalo is a polished macOS menu bar app that visualizes mouse and
* - vercel.json rewrites `inputhalo.freno.me/(.*)` → `/inputhalo/$1`, so * keyboard input on screen: keyboard indicators, cursor halos, click ripples,
* this `src/routes/inputhalo/index.tsx` serves the subdomain root. * 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: * The download orchestration stays in the testable pure `./download.ts` module;
* - Hero: app icon (dark/light variant via `useDarkMode`), title, tagline * this component is a thin wrapper around it plus the landing-page UX.
* - 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 { For, createSignal } from "solid-js";
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 { 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 { api } from "~/lib/api";
import { import {
INPUTHALO_APP_STORE_URL, INPUTHALO_APP_STORE_URL,
@@ -33,9 +27,55 @@ import {
performInputHaloDownload performInputHaloDownload
} from "./download"; } 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() { export default function InputHaloLanding() {
const site = useSite();
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
const [titleText, setTitleText] = createSignal("InputHalo");
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const download = () => { const download = () => {
@@ -52,142 +92,196 @@ export default function InputHaloLanding() {
).finally(() => setLoading(false)); ).finally(() => setLoading(false));
}; };
onMount(() => { const iconSrc = () =>
const interval = glitchText(titleText(), setTitleText); isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
onCleanup(() => clearInterval(interval)); const brandColor = () => site().brandColor;
});
const iconSrc = () => (isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT);
return ( return (
<> <>
<PageHead <PageHead
title="InputHalo" 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"> <SubdomainHeader />
{/* Subtle scanline effect */}
<div class="pointer-events-none absolute inset-0 opacity-5"> <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 <div
class="h-full w-full" class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32"
style={{ style={{ color: brandColor() }}
"background-image": >
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.2) 2px, rgba(0,0,0,0.2) 4px)" <img
}} src={iconSrc()}
/> alt="InputHalo app icon"
</div> width={128}
height={128}
<div class="relative z-10 mx-auto max-w-5xl"> class="h-full w-full rounded-[1.75rem] object-cover"
{/* 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> </div>
{/* Feature Highlights */} <h1 class="text-5xl font-bold tracking-tight md:text-7xl">
<div class="mb-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> InputHalo
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105"> </h1>
<h3 class="text-text mb-2 font-mono text-lg"> <p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
<span class="text-red">{">"}</span> Real-time Tracking Show every keystroke, click, and scroll.
</h3> </p>
<p class="text-subtext0"> <p class="text-text/70 mt-3 max-w-xl text-base md:text-lg">
Visualize every mouse click and keyboard press as it happens A polished macOS menu bar app for input visualization built for
</p> streamers, presenters, developers, and anyone who wants their inputs
</div> seen.
</p>
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105"> <div class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
<h3 class="text-text mb-2 font-mono text-lg"> <Button
<span class="text-red">{">"}</span> Menu Bar App variant="download"
</h3> size="lg"
<p class="text-subtext0"> loading={loading()}
Lightweight presence in your menu bar access settings anytime onClick={download}
</p> >
</div> download.dmg
</Button>
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105"> <A
<h3 class="text-text mb-2 font-mono text-lg"> class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
<span class="text-red">{">"}</span> Customizable href={INPUTHALO_APP_STORE_URL}
</h3> target="_blank"
<p class="text-subtext0"> rel="noreferrer"
Personalize colors, styles, and behavior to match your workflow >
</p> <DownloadOnAppStoreDark size={50} />
</div> </A>
<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> </div>
<p class="text-text/50 mt-3 text-sm">
macOS {INPUTHALO_MIN_SYSTEM_VERSION}+ · auto-updates via Sparkle
</p>
</section>
{/* Download Section */} {/* ─── Feature highlights ─────────────────────────────────── */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8"> <section class="bg-surface0/30 relative z-10 px-4 py-20">
<h2 class="text-text mb-6 font-mono text-2xl"> <div class="mx-auto max-w-6xl">
<span class="text-red">{">"}</span> Download <h2 class="text-text mb-12 text-center text-3xl font-bold md:text-4xl">
Made for showing your inputs
</h2> </h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <For each={FEATURES}>
{/* DMG Download (tRPC → signed S3 URL) */} {(feature) => (
<div class="flex flex-col items-center gap-3"> <div class="border-surface0 bg-base/80 flex flex-col gap-3 rounded-2xl border-2 p-6 backdrop-blur-sm">
<span class="text-subtext0 font-mono text-sm"> <div
platform: macOS ({INPUTHALO_MIN_SYSTEM_VERSION}+) class="mb-1 flex h-8 w-8 items-center justify-center rounded-full text-white"
</span> style={{ background: brandColor() }}
<Button >
variant="download" <svg
size="lg" xmlns="http://www.w3.org/2000/svg"
loading={loading()} viewBox="0 0 24 24"
onClick={download} class="h-5 w-5"
> fill="none"
download.dmg stroke="currentColor"
</Button> stroke-width="2"
<span class="text-subtext1 text-xs"> stroke-linecap="round"
# auto-updates via Sparkle stroke-linejoin="round"
</span> >
</div> <circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
{/* App Store (paid — coming soon) */} </svg>
<div class="flex flex-col items-center gap-3"> </div>
<span class="text-subtext0 font-mono text-sm"> <h3 class="text-lg font-semibold">{feature.title}</h3>
variant: paid (coming soon) <p class="text-text/80 text-sm leading-relaxed">
</span> {feature.body}
<A </p>
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95" </div>
href={INPUTHALO_APP_STORE_URL} )}
target="_blank" </For>
rel="noreferrer"
>
<DownloadOnAppStore size={50} />
</A>
</div>
</div> </div>
</div> </div>
</div> </section>
</div>
{/* ─── 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 { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function InputHaloPrivacyPolicy() { export default function InputHaloPrivacyPolicy() {
return ( return (
@@ -32,15 +33,15 @@ export default function InputHaloPrivacyPolicy() {
title="Privacy Policy" title="Privacy Policy"
description="Privacy policy for InputHalo, a macOS menu bar productivity app." description="Privacy policy for InputHalo, a macOS menu bar productivity app."
/> />
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]"> <div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">InputHalo&apos;s Privacy Policy</div> <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"> <div class="py-2">
Welcome to InputHalo (&apos;We&apos;, &apos;Us&apos;, Welcome to InputHalo (&apos;We&apos;, &apos;Us&apos;,
&apos;Our&apos;). Your privacy is important to us. This privacy &apos;Our&apos;). Your privacy is important to us. This privacy policy
policy will help you understand our policies and procedures related will help you understand our policies and procedures related to the
to the collection, use, and storage of personal information from our collection, use, and storage of personal information from our users.
users.
</div> </div>
<ol> <ol>
<div class="py-2"> <div class="py-2">
@@ -52,17 +53,17 @@ export default function InputHaloPrivacyPolicy() {
<div class="-ml-6">(a) Collection of Personal Data:</div>{" "} <div class="-ml-6">(a) Collection of Personal Data:</div>{" "}
InputHalo is designed with privacy as a core principle. We InputHalo is designed with privacy as a core principle. We
currently do not collect, store, or share any personal currently do not collect, store, or share any personal
information from our users. The app runs entirely on your information from our users. The app runs entirely on your device
device as a menu bar utility and does not require any account as a menu bar utility and does not require any account creation
creation or data transmission to external servers. or data transmission to external servers.
</div> </div>
<div class="pb-2"> <div class="pb-2">
<div class="-ml-6">(b) Local-Only Settings:</div> Any <div class="-ml-6">(b) Local-Only Settings:</div> Any
preferences, configuration, or cached state InputHalo maintains preferences, configuration, or cached state InputHalo maintains
is stored locally on your device using standard macOS is stored locally on your device using standard macOS mechanisms
mechanisms (such as the user defaults system). This data never (such as the user defaults system). This data never leaves your
leaves your device and is not transmitted to us or to any device and is not transmitted to us or to any third-party
third-party service. service.
</div> </div>
<div class="pb-2"> <div class="pb-2">
<div class="-ml-6">(c) Future Data Collection:</div> We may in <div class="-ml-6">(c) Future Data Collection:</div> We may in

View File

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

View File

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

View File

@@ -31,6 +31,7 @@
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js"; import { createSignal, onMount, onCleanup } from "solid-js";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import Button from "~/components/ui/Button"; import Button from "~/components/ui/Button";
import { glitchText } from "~/lib/client-utils"; import { glitchText } from "~/lib/client-utils";
@@ -71,10 +72,9 @@ export default function LineageDownloadsPage() {
return ( return (
<> <>
<PageHead <PageHead title={PAGE_META.title} description={PAGE_META.description} />
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"> <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. */} {/* Subtle scanline effect — consistent with the unified downloads page. */}
@@ -131,7 +131,7 @@ export default function LineageDownloadsPage() {
</div> </div>
{/* Secondary CTA → landing page */} {/* 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 <A
href={LINEAGE_HOME_HREF} href={LINEAGE_HOME_HREF}
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105" 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 * This intentionally preserves the original marketing-page structure: a single
* `lineage.freno.me` root (vercel.json rewrites `lineage.freno.me/*` → * full-viewport `SimpleParallax` Cave background with the app icon, title,
* `/lineage/*`), and enriches it with feature highlights + a game-art strip. * tagline, and store badges centered on screen. No sidebars, no scrollable
* * feature sections, and no extra content — the parallax effect depends on the
* Site-awareness: * page being exactly one viewport tall. The persistent `SubdomainHeader`
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix` * sits sticky above the parallax backdrop so navigation is reachable on every
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/` are * subdomain page.
* 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.
*/ */
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import SimpleParallax from "~/components/SimpleParallax"; import SimpleParallax from "~/components/SimpleParallax";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import { For } from "solid-js";
import { import {
APP_STORE_URL, APP_STORE_URL,
DOWNLOADS_HREF, DOWNLOADS_HREF,
APP_ICON_SRC, APP_ICON_SRC,
GOOGLE_PLAY_BADGE_SRC, GOOGLE_PLAY_BADGE_SRC,
SCREENSHOT_ASSETS, PAGE_META
PAGE_META,
FEATURES
} from "~/routes/lineage/landing-content"; } from "~/routes/lineage/landing-content";
export default function LineageLandingPage() { export default function LineageLandingPage() {
return ( return (
<> <>
<PageHead title={PAGE_META.title} description={PAGE_META.description} /> <PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<SimpleParallax> <SimpleParallax>
<div class="flex h-full flex-col items-center justify-center px-4 text-white"> <div class="flex h-full flex-col items-center justify-center px-4 text-white">
{/* Hero */} <div>
<img <img
src={APP_ICON_SRC} src={APP_ICON_SRC}
alt="Life and Lineage App Icon" alt="Life and Lineage App Icon"
height={128} height={128}
width={128} width={128}
class="object-cover object-center" class="object-cover object-center"
/> />
<h1 class="mb-4 mt-4 text-center text-5xl font-bold"> </div>
<h1 class="mt-4 mb-4 text-center text-5xl font-bold">
Life and Lineage Life and Lineage
</h1> </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="mt-8 flex flex-wrap items-center justify-center gap-4">
<div class="flex flex-wrap items-center justify-center gap-4">
<a <a
class="my-auto transition-all duration-200 ease-out active:scale-95" class="my-auto transition-all duration-200 ease-out active:scale-95"
href={APP_STORE_URL} href={APP_STORE_URL}
@@ -75,68 +64,6 @@ export default function LineageLandingPage() {
/> />
</A> </A>
</div> </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> </div>
</SimpleParallax> </SimpleParallax>
</> </>

View File

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

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm"; import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* Nessa contact page (`nessa.freno.me/contact`). * Nessa contact page (`nessa.freno.me/contact`).
@@ -17,5 +18,10 @@ import { ContactForm } from "~/components/ContactForm";
* `[Nessa] Contact Request`. * `[Nessa] Contact Request`.
*/ */
export default function NessaContactPage() { 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. * Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
*/ */
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DeletionForm from "~/components/DeletionForm"; import DeletionForm from "~/components/DeletionForm";
import { import {
DELETION_PRODUCT_KEY, DELETION_PRODUCT_KEY,
@@ -36,6 +37,7 @@ export default function NessaDeletionPage() {
return ( return (
<> <>
<PageHead title={PAGE_META.title} description={PAGE_META.description} /> <PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="pt-20"> <div class="pt-20">
<div class="mx-auto p-4 md:p-6 lg:p-12"> <div class="mx-auto p-4 md:p-6 lg:p-12">
<div class="text-text w-full justify-center"> <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 * Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s
* URL `/` (vercel.json rewrites `nessa.freno.me/*` → the internal `/nessa/*` * `useSite()` branch in dev). Reflects Nessa's actual product: a
* route prefix — see `src/lib/site-context.ts` + `vercel.json`). * privacy-first Strava-alternative fitness app with segment leaderboards,
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
* *
* Design contract: * Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
* - Public — NO freno.me web-auth UI (Nessa authenticates via Clerk; the * and kept in a pure `./content.ts` module so the acceptance matrix is
* subdomain nav in `Bars.tsx` already omits the web-auth widgets). * testable without spinning up the router.
* - 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.
*/ */
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). */ /** Small SVG checkmark used in pricing cards. */
function FeatureIcon(props: { name: string }): JSX.Element { function CheckIcon() {
const common = (viewBox: string, path: JSX.Element) => ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
height={28} viewBox="0 0 24 24"
width={28} class="mt-0.5 h-5 w-5 shrink-0"
viewBox={viewBox}
class="h-full w-full"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width={2} stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
aria-hidden="true" aria-hidden="true"
> >
{path} <path d="M20 6 9 17l-5-5" />
</svg> </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() { export default function NessaLanding() {
const site = useSite(); const site = useSite();
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT);
const brandColor = () => site().brandColor;
return ( return (
<> <>
<PageHead {...NESSA_LANDING_META} /> <PageHead {...NESSA_LANDING_META} />
<SubdomainHeader />
<main <main
class="relative min-h-screen w-full overflow-x-hidden" class="relative min-h-screen w-full overflow-x-hidden"
// Drive the per-site brand accent via a CSS custom property so child style={{ "--brand-color": brandColor() }}
// elements (feature icons, CTA) can reference `var(--brand-color)`
// without re-reading the site accessor on every render.
style={{ "--brand-color": site().brandColor }}
> >
{/* {/* Soft brand-tinted backdrop */}
* 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.
*/}
<div <div
class="pointer-events-none fixed inset-0 z-0" class="pointer-events-none fixed inset-0 z-0"
aria-hidden="true" aria-hidden="true"
style={{ style={{
background: isDark() 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%, ${brandColor()}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${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()}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}12 0%, transparent 70%)`
}} }}
/> />
{/* ───────────────────────── Hero ───────────────────────── */} {/* ─── Hero ───────────────────────────────────────────────── */}
<section class="relative z-10 flex flex-col items-center px-4 pb-16 pt-24 text-center md:pt-32"> <section class="relative z-10 flex flex-col items-center px-4 pt-24 pb-16 text-center md:pt-32">
{/* Brand mark — a CSS gradient circle standing in for the icon <div class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32">
until Nessa ships a real icon asset. */} <img
<div src={iconSrc()}
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" alt="Nessa app icon"
style={{ width={128}
background: `linear-gradient(135deg, ${site().brandColor} 0%, var(--color-mauve, #cba6f7) 100%)` height={128}
}} class="h-full w-full rounded-[1.75rem] object-cover"
aria-hidden="true" />
>
N
</div> </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"> <p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
Build community. Run challenges. Stay connected. {SUBTITLE}
</p> </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"> <div class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
<button <A
type="button" href="/contact"
disabled class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95"
class="cursor-not-allowed rounded-full px-8 py-3 text-base font-semibold text-white opacity-95 shadow-md transition-transform" style={{ background: brandColor() }}
style={{ background: site().brandColor }}
title="Nessa is coming soon"
> >
Coming soon Join the waitlist
</button> </A>
<a <A
href="/contact" href="/contact"
class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors" class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors"
> >
Get in touch Request beta access
</a> </A>
</div> </div>
<p class="text-text/50 mt-4 text-sm"> <p class="text-text/60 mt-4 text-sm">
Nessa is in active development. Reach out to learn more. Launching soon on the App Store.
</p> </p>
</section> </section>
{/* ─────────────────────── Features ─────────────────────── */} {/* ─── Free-tier feature highlights ───────────────────────── */}
<section class="relative z-10 px-4 py-16" id="features"> <section class="relative z-10 px-4 py-16" id="features">
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="mb-12 text-center"> <div class="mb-12 text-center">
<h2 class="text-3xl font-bold md:text-4xl"> <h2 class="text-3xl font-bold md:text-4xl">
Everything your club needs Everything you need, free forever
</h2> </h2>
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg"> <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 Nessa gives away the features other apps lock behind
shared space no more juggling chats, spreadsheets, and subscriptions because your workouts shouldn't cost extra.
half-built trackers.
</p> </p>
</div> </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}> <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> </For>
</div> </div>
</div> </div>
</section> </section>
{/* ──────────────────── Secondary CTA band ──────────────────── */} {/* ─── Pricing tiers ───────────────────────────────────────── */}
<section class="relative z-10 px-4 py-20"> <section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-4xl text-center"> <div class="mx-auto max-w-6xl">
<h2 class="text-3xl font-bold md:text-4xl"> <div class="mb-12 text-center">
Ready to grow your community? <h2 class="text-3xl font-bold md:text-4xl">
</h2> Premium features, half the price of Strava
<p class="text-text/70 mx-auto mt-3 max-w-xl text-base md:text-lg"> </h2>
Nessa is being built for clubs that want structure without the <p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
friction. We&apos;ll share more as it takes shape. Choose the plan that fits your training.
</p> </p>
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row"> </div>
<a
href="/contact" <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95" <For each={PRICING}>
style={{ background: site().brandColor }} {(tier) => {
> const highlighted = tier.key === "plus";
Contact us return (
</a> <div
<a class="border-surface0 bg-base/80 flex flex-col rounded-2xl border-2 p-6 backdrop-blur-sm"
href="/privacy" classList={{
class="text-text/80 hover:text-text underline-offset-4 hover:underline" "scale-[1.02]": highlighted
> }}
Privacy policy style={{
</a> "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>
</div> </div>
</section> </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"> <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"> <div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<Show <span>{site().displayName}</span>
when={site().subdomain} <A
fallback={<span>{site().displayName}</span>}
>
<span>{site().displayName}</span>
</Show>
<a
href="https://freno.me" href="https://freno.me"
class="hover:text-text underline-offset-4 hover:underline" class="hover:text-text underline-offset-4 hover:underline"
> >
freno.me freno.me
</a> </A>
</div> </div>
</footer> </footer>
</main> </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 * Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
* correct title suffix and canonical." Mirrors the `page-head-meta.ts` * pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
* testability pattern — `nessa/meta.ts` is a pure module (no solid-js / * `bun:test` can resolve it. Asserts the metadata matches Nessa's actual
* @solidjs/router / @solidjs/meta imports) so `bun:test` can resolve it. * product positioning as a privacy-first fitness / Strava-alternative app
* * (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
* 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/`.
*/ */
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { resolvePageHeadMeta } from "~/components/page-head-meta"; import { resolvePageHeadMeta } from "~/components/page-head-meta";
@@ -17,35 +14,71 @@ import { NESSA_LANDING_META } from "./meta";
describe("Nessa landing page — PageHead metadata", () => { describe("Nessa landing page — PageHead metadata", () => {
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => { 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"); expect(meta.title).toBe("Nessa | Nessa");
}); });
it("title contains the substring '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"); expect(meta.title).toContain("Nessa");
}); });
it("canonical is https://nessa.freno.me/ for the landing route", () => { 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/"); expect(meta.canonical).toBe("https://nessa.freno.me/");
}); });
it("ogImage falls back to the nessa site default", () => { 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); expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
}); });
it("ogTitle uses the explicit marketing copy, not the bare title", () => { 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).toBe(NESSA_LANDING_META.ogTitle);
expect(meta.ogTitle).not.toBe("Nessa"); expect(meta.ogTitle).not.toBe("Nessa");
}); });
it("description mentions community capabilities", () => { it("description positions Nessa as a fitness app with community features", () => {
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.description).toBe(NESSA_LANDING_META.description); 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("community");
expect(meta.description?.toLowerCase()).toContain("challenges"); 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 — * Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
* so the landing-page metadata can be unit-tested in `bun:test` without * so the landing-page metadata can be unit-tested in `bun:test` without
* spinning up the router / MetaProvider / DOM, mirroring the * spinning up the router / MetaProvider / DOM, mirroring the
* `page-head-meta.ts` / `nav-config.ts` testability pattern. * `page-head-meta.ts` / `nav-config.ts` testability pattern.
* *
* `index.tsx` imports `NESSA_LANDING_META` and feeds it to `<PageHead …/>`; * Nessa is positioned as a privacy-first fitness app and Strava alternative
* `meta.test.ts` asserts the *resolved* title / canonical / OG values against * (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
* the site-aware `resolvePageHeadMeta` helper (task 02) for the `nessa` site. * The description still mentions community features (clubs, challenges) because
* * those are real free-tier capabilities, but it now leads with the product's
* Nessa-specific metadata contract: * actual purpose: fitness tracking.
* - `<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.
*/ */
import type { PageHeadProps } from "~/components/page-head-meta"; 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 = { export const NESSA_LANDING_META: PageHeadProps = {
title: "Nessa", title: "Nessa",
description: description:
"Nessa is a community platform for building clubs, running challenges, sharing in a social feed, and organizing events.", "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 — Build community. Run challenges. Stay connected.", ogTitle: "Nessa — The fitness app that puts you first",
ogDescription: 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 { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function NessaPrivacyPolicy() { export default function NessaPrivacyPolicy() {
return ( return (
@@ -38,9 +39,10 @@ export default function NessaPrivacyPolicy() {
title="Privacy Policy" title="Privacy Policy"
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features." 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="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Nessa&apos;s Privacy Policy</div> <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"> <div class="py-2">
Welcome to Nessa (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;). Welcome to Nessa (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
Your privacy is important to us. This privacy policy will help you 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 <div class="-ml-6">(d) Data Removal:</div> You can request the
removal of your Nessa account and all associated content by removal of your Nessa account and all associated content by
contacting us{" "} contacting us{" "}
<A <A href="/contact" class="text-blue hover-underline-animation">
href="/contact"
class="text-blue hover-underline-animation"
>
here here
</A> </A>
. On receipt of your request we will remove your account . On receipt of your request we will remove your account record,
record, your club memberships, your challenges and your club memberships, your challenges and participation data,
participation data, and your posts, comments, and likes from the and your posts, comments, and likes from the Nessa database, and
Nessa database, and we will request that Clerk delete the we will request that Clerk delete the corresponding user record.
corresponding user record. A short grace period may apply while A short grace period may apply while the deletion is processed.
the deletion is processed.
</div> </div>
</div> </div>
</div> </div>
@@ -120,13 +118,13 @@ export default function NessaPrivacyPolicy() {
</div> </div>
<div class="pb-2"> <div class="pb-2">
<div class="-ml-6">(b) Clerk as Identity Provider:</div> Your <div class="-ml-6">(b) Clerk as Identity Provider:</div> Your
authentication credentials (such as your password, if you use authentication credentials (such as your password, if you use an
an email/password sign-in) are handled exclusively by Clerk email/password sign-in) are handled exclusively by Clerk under
under Clerk&apos;s own privacy policy. Nessa never receives, Clerk&apos;s own privacy policy. Nessa never receives, stores,
stores, or transmits your password. When you sign in, Nessa or transmits your password. When you sign in, Nessa receives a
receives a session token from Clerk that lets us identify you; session token from Clerk that lets us identify you; this token
this token is verified on each request and is not persisted in is verified on each request and is not persisted in our
our database. database.
</div> </div>
</div> </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 { z } from "zod";
import { import {
S3Client, S3Client,
@@ -42,7 +47,7 @@ export function sanitizeS3PathComponent(value: string): string {
// Strip path traversal characters and normalize whitespace // Strip path traversal characters and normalize whitespace
return value return value
.replace(/\s+/g, "-") .replace(/\s+/g, "-")
.replace(/[\/\\]/g, "-") .replace(/[/\\]/g, "-")
.replace(/\.\./g, "") .replace(/\.\./g, "")
.replace(/[^a-zA-Z0-9_-]/g, "") .replace(/[^a-zA-Z0-9_-]/g, "")
.replace(/-+/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- // 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 // tested in `bun:test` without a populated `.env`. Re-exported here for the
// tRPC mutation below + for callers that already import from `misc`. // 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, DELETION_PRODUCT_SCHEMA,
deletionCookieName, deletionCookieName,
deletionEmailContent deletionEmailContent
} from "./deletion-email"; } from "./deletion-email";
export type { export { DELETION_PRODUCT_SCHEMA, deletionCookieName, deletionEmailContent };
DeletionProduct, export type { DeletionProduct, DeletionEmailContent } from "./deletion-email";
DeletionEmailContent
} from "./deletion-email";
const assets: Record<string, string> = { const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk", "shapes-with-abigail": "shapes-with-abigail.apk",
@@ -378,10 +385,13 @@ export const miscRouter = createTRPCRouter({
); );
if (!turnstileValid) { if (!turnstileValid) {
console.error("Turnstile verification failed for contact form submission"); console.error(
"Turnstile verification failed for contact form submission"
);
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", 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 type { H3Event } from "vinxi/http";
import { import {
resolveSiteFromHost, resolveSiteFromHost,
resolveSiteFromPath,
type Site, type Site,
MAIN_SITE MAIN_SITE
} from "~/lib/site-context"; } from "~/lib/site-context";
@@ -53,7 +54,9 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
try { try {
const nodeReq = ( const nodeReq = (
event as { event as {
nativeEvent?: { node?: { req?: { headers?: Record<string, string | string[]> } } }; nativeEvent?: {
node?: { req?: { headers?: Record<string, string | string[]> } };
};
} | null } | null
)?.nativeEvent?.node?.req; )?.nativeEvent?.node?.req;
const raw = nodeReq?.headers?.host; const raw = nodeReq?.headers?.host;
@@ -78,16 +81,76 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
return 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 * Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a
* vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape, * vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape,
* so the SSR document handler can pass its `PageEvent` directly. Falls back * 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( export function getSiteFromEvent(
event: APIEvent | H3Event | ServerSiteEvent event: APIEvent | H3Event | ServerSiteEvent
): Site { ): 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`. * functions that receive a `Request` directly). Falls back to `main`.
*/ */
export function getSiteFromRequest(request: Request): Site { 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 }; export { MAIN_SITE };