fix: subdomain routing fixed, favicon bundles

This commit is contained in:
2026-07-27 14:01:50 -04:00
parent 5ddbfbc429
commit 44b6e7595c
50 changed files with 376 additions and 149 deletions

View File

@@ -1,9 +1,14 @@
import { Show, type JSX } from "solid-js";
import { Show, Switch, Match, type JSX } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router";
import RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm";
import { buildSubdomainUrl } from "~/lib/site-context";
import { useSite } from "~/context/SiteContext";
import NessaContactPage from "./nessa/contact";
import LineageContactPage from "./lineage/contact";
import GazeContactPage from "./gaze/contact";
import InputHaloContactPage from "./inputhalo/contact";
/**
* Main-site contact page (`freno.me/contact`).
@@ -115,7 +120,7 @@ export function LineageContactQuestions(): JSX.Element {
);
}
export default function ContactPage() {
function MainContactPage() {
const [searchParams] = useSearchParams();
const viewer = () => searchParams.viewer ?? "default";
@@ -131,3 +136,38 @@ export default function ContactPage() {
</ContactForm>
);
}
/**
* Host-aware contact route handler.
*
* SolidStart's file router cannot match on host, and the previous in-app
* middleware rewrite (`/contact` → `/<prefix>/contact`, server-only) diverged
* the SSR path from the browser URL — SolidStart's client `Router` derives its
* route purely from `window.location.pathname`, so the client matched the root
* route while the server rendered the subdomain route → hydration mismatch.
*
* The fix mirrors `src/routes/index.tsx`: resolve the public path (`/contact`)
* on BOTH server and client, then dispatch to the right subdomain contact
* component via `useSite()`. Server and client render the same component for
* the same path → hydration matches; nav links (public paths) and the
* canonical URL (public path) keep working unchanged.
*/
export default function ContactPage(): JSX.Element {
const site = useSite();
return (
<Switch fallback={<MainContactPage />}>
<Match when={site().id === "nessa"}>
<NessaContactPage />
</Match>
<Match when={site().id === "lineage"}>
<LineageContactPage />
</Match>
<Match when={site().id === "gaze"}>
<GazeContactPage />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloContactPage />
</Match>
</Switch>
);
}

41
src/routes/deletion.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NessaDeletionPage from "./nessa/deletion";
import LineageDeletionPage from "./lineage/deletion";
/**
* Host-aware account-deletion route — `/deletion`.
*
* The public browser path `/deletion` serves each subdomain's per-product
* account-deletion flow. This mirrors `src/routes/index.tsx`'s host-aware
* dispatch: the server and client both resolve the public path (`/deletion`)
* and select the matching subdomain component via `useSite()`, so the two
* render the same component for the same path and hydration matches. This
* replaces the previous server-only path rewrite (`/deletion` →
* `/<prefix>/deletion`), which diverged the SSR path from the browser URL and
* caused hydration mismatches (SolidStart's client `Router` matches on
* `window.location.pathname`, not the rewritten server path).
*
* Only Nessa and Lineage ship a dedicated deletion page. Gaze, InputHalo, and
* the main site fall back to the 404 handler — preserving their pre-rewrite
* behavior (Gaze/InputHalo have no account system; the main site never had a
* `/deletion` route).
*
* Legacy `/deletion/life-and-lineage` 308-redirects to the Lineage subdomain's
* `https://lineage.freno.me/deletion` (see
* `src/routes/lineage/deletion-content.ts` → `LEGACY_DELETION_REDIRECT_TARGET`).
*/
export default function DeletionPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nessa"}>
<NessaDeletionPage />
</Match>
<Match when={site().id === "lineage"}>
<LineageDeletionPage />
</Match>
</Switch>
);
}

View File

@@ -1,12 +1,16 @@
import { PageHead } from "~/components/PageHead";
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import { glitchText } from "~/lib/client-utils";
import { buildSubdomainUrl } from "~/lib/subdomain-url";
import Button from "~/components/ui/Button";
import { useSite } from "~/context/SiteContext";
import LineageDownloadsPage from "./lineage/downloads";
import GazeDownloadsPage from "./gaze/downloads";
import InputHaloDownloadsPage from "./inputhalo/downloads";
export default function DownloadsPage() {
function MainDownloadsPage() {
const [LaLText, setLaLText] = createSignal("Life and Lineage");
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
const [corkText, setCorkText] = createSignal("Cork");
@@ -300,3 +304,30 @@ export default function DownloadsPage() {
</>
);
}
/**
* Host-aware downloads route handler.
*
* Dispatches to the per-subdomain download surface based on the resolved
* site (see `src/routes/index.tsx` for the pattern). Nessa has no dedicated
* downloads page, so it falls back to the unified downloads listing —
* matching the pre-middleware behavior. Resolving the public path on both
* server and client avoids the hydration mismatch the path-rewriting
* middleware caused.
*/
export default function DownloadsPage() {
const site = useSite();
return (
<Switch fallback={<MainDownloadsPage />}>
<Match when={site().id === "lineage"}>
<LineageDownloadsPage />
</Match>
<Match when={site().id === "gaze"}>
<GazeDownloadsPage />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloDownloadsPage />
</Match>
</Switch>
);
}

View File

@@ -14,6 +14,7 @@ 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 { downloadAsset } from "~/lib/download-asset";
@@ -22,8 +23,12 @@ const GAZE_MIN_MACOS = "14.6";
export default function GazeDownloadsPage() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const handleDownload = () => {
if (loading()) return;
setLoading(true);
@@ -85,6 +90,7 @@ export default function GazeDownloadsPage() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={handleDownload}
>

View File

@@ -4,6 +4,7 @@ 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 { downloadAsset } from "~/lib/download-asset";
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
@@ -33,9 +34,13 @@ const FEATURES = [
] as const;
export default function GazeLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const handleDownload = () => {
if (loading()) return;
setLoading(true);
@@ -56,7 +61,7 @@ export default function GazeLanding() {
return (
<>
<PageHead
title="Eye & posture health reminder for macOS"
title="Home"
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
ogImage="/look-away.png"
ogTitle="Gaze — Eye and posture health reminder for macOS"
@@ -100,6 +105,7 @@ export default function GazeLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={handleDownload}
>
@@ -157,6 +163,7 @@ export default function GazeLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={handleDownload}
>

View File

@@ -33,6 +33,8 @@ export default function InputHaloDownloadsPage() {
const iconSrc = () =>
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
const brandColor = () => site().brandColor;
const download = () => {
if (loading()) return;
setLoading(true);
@@ -89,6 +91,7 @@ export default function InputHaloDownloadsPage() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={download}
>

View File

@@ -100,7 +100,7 @@ export default function InputHaloLanding() {
return (
<>
<PageHead
title="InputHalo"
title="Home"
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."
/>
@@ -152,6 +152,7 @@ export default function InputHaloLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={download}
>
@@ -250,6 +251,7 @@ export default function InputHaloLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={download}
>

View File

@@ -1,5 +1,6 @@
import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
import { LineageContactQuestions } from "../contact";
/**
* Life and Lineage contact page (`lineage.freno.me/contact`).
@@ -24,7 +25,9 @@ export default function LineageContactPage() {
return (
<>
<SubdomainHeader />
<ContactForm />
<ContactForm>
<LineageContactQuestions />
</ContactForm>
</>
);
}

View File

@@ -73,7 +73,7 @@ describe("Lineage landing — asset paths", () => {
describe("Lineage landing — PageHead inputs", () => {
it("passes the base title (suffix is appended by PageHead)", () => {
expect(PAGE_META.title).toBe("Life and Lineage");
expect(PAGE_META.title).toBe("Home");
});
it("does not pre-bake the site suffix into the title", () => {

View File

@@ -54,7 +54,7 @@ export const SCREENSHOT_ASSETS = {
/** Base page title (site suffix appended by PageHead). */
export const PAGE_META = {
title: "Life and Lineage",
title: "Home",
description:
"A dark fantasy adventure mobile game. Download Life and Lineage on the App Store and Google Play."
} as const;

View File

@@ -13,13 +13,13 @@ import { SITE_CONFIG } from "~/lib/site-context";
import { NESSA_LANDING_META } from "./meta";
describe("Nessa landing page — PageHead metadata", () => {
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => {
it("title composes to 'Home | Nessa' (base title + nessa titleSuffix)", () => {
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.title).toBe("Nessa | Nessa");
expect(meta.title).toBe("Home | Nessa");
});
it("title contains the substring 'Nessa'", () => {

View File

@@ -23,7 +23,7 @@ import type { PageHeadProps } from "~/components/page-head-meta";
* no-suffix fallback) — this matches the marketing-copy intent of the card.
*/
export const NESSA_LANDING_META: PageHeadProps = {
title: "Nessa",
title: "Home",
description:
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
ogTitle: "Nessa — The fitness app that puts you first",

46
src/routes/privacy.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NessaPrivacyPolicy from "./nessa/privacy";
import LineagePrivacyPolicy from "./lineage/privacy";
import GazePrivacyPolicy from "./gaze/privacy";
import InputHaloPrivacyPolicy from "./inputhalo/privacy";
/**
* Host-aware privacy policy route — `/privacy`.
*
* The public browser path `/privacy` serves each subdomain's own privacy
* policy. This mirrors `src/routes/index.tsx`'s host-aware dispatch: the
* server and client both resolve the public path (`/privacy`) and select the
* matching subdomain component via `useSite()`, so the two render the same
* component for the same path and hydration matches. This replaces the
* previous server-only path rewrite (`/privacy` → `/<prefix>/privacy`), which
* diverged the SSR path from the browser URL and caused hydration mismatches
* (SolidStart's client `Router` matches on `window.location.pathname`, not the
* rewritten server path).
*
* The main site has no `/privacy` page (it uses `/privacy-policy/*`), so
* `main` falls back to the 404 handler — preserving its pre-rewrite behavior.
*
* Legacy `/privacy-policy/<product>` routes 308-redirect to the corresponding
* subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
*/
export default function PrivacyPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nessa"}>
<NessaPrivacyPolicy />
</Match>
<Match when={site().id === "lineage"}>
<LineagePrivacyPolicy />
</Match>
<Match when={site().id === "gaze"}>
<GazePrivacyPolicy />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloPrivacyPolicy />
</Match>
</Switch>
);
}