Compare commits

..

7 Commits

56 changed files with 588 additions and 198 deletions

View File

@@ -86,7 +86,7 @@
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration. This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`). The `vercel.json` host-based rewrites map each subdomain to its prefix. - **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy``/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.)
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host. - **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters). - **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
- **Auth:** Host-scoped only — no cookie domain broadening. - **Auth:** Host-scoped only — no cookie domain broadening.

View File

@@ -3,7 +3,6 @@ import tailwindcss from "@tailwindcss/vite";
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin"; import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
export default defineConfig({ export default defineConfig({
middleware: "./src/middleware.ts",
vite: { vite: {
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
@@ -53,6 +52,6 @@ export default defineConfig({
} }
}, },
server: { server: {
preset: "node-server" preset: "vercel"
} }
}); });

View File

@@ -5,7 +5,6 @@
"dev": "vinxi dev", "dev": "vinxi dev",
"dev-flush": "vinxi dev --env-file=.env", "dev-flush": "vinxi dev --env-file=.env",
"build": "vinxi build", "build": "vinxi build",
"vercel-build": "rm -rf .vinxi .output node_modules/.vite && vinxi build",
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start", "start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
"test": "bun test", "test": "bun test",
"test:security": "bun test src/server/security/", "test:security": "bun test src/server/security/",

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
public/gaze/favicon/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

17
public/gaze/favicon/favicon.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 MiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 MiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 860 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

BIN
public/nessa/favicon/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -74,6 +74,11 @@
--color-base: #fbf1c7; --color-base: #fbf1c7;
--color-mantle: #f3eac1; --color-mantle: #f3eac1;
--color-crust: #e7deb7; --color-crust: #e7deb7;
/* Button text colors: white on dark bg variants (primary/danger),
theme text on light bg variants (secondary). */
--color-button-text: #ffffff;
--color-button-text-alt: var(--color-text);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -104,6 +109,11 @@
--color-base: #1e1e2e; --color-base: #1e1e2e;
--color-mantle: #141620; --color-mantle: #141620;
--color-crust: #0e0f16; --color-crust: #0e0f16;
/* Dark mode: dark text on lighter bg variants (primary/danger),
white text on dark bg variants (secondary). */
--color-button-text: var(--color-crust);
--color-button-text-alt: #ffffff;
} }
@theme { @theme {
--color-rosewater: #efc9c2; --color-rosewater: #efc9c2;
@@ -163,6 +173,8 @@
--color-base: #fbf1c7; --color-base: #fbf1c7;
--color-mantle: #f3eac1; --color-mantle: #f3eac1;
--color-crust: #e7deb7; --color-crust: #e7deb7;
--color-button-text: #ffffff;
--color-button-text-alt: var(--color-text);
} }
:root.dark { :root.dark {
@@ -192,6 +204,8 @@
--color-base: #1e1e2e; --color-base: #1e1e2e;
--color-mantle: #141620; --color-mantle: #141620;
--color-crust: #0e0f16; --color-crust: #0e0f16;
--color-button-text: var(--color-crust);
--color-button-text-alt: #ffffff;
} }
:root { :root {

View File

@@ -11,6 +11,7 @@ import {
import "./app.css"; import "./app.css";
import { LeftBar, RightBar } from "./components/Bars"; import { LeftBar, RightBar } from "./components/Bars";
import { TerminalSplash } from "./components/TerminalSplash"; import { TerminalSplash } from "./components/TerminalSplash";
import SubdomainFooter from "./components/SubdomainFooter";
import { MetaProvider } from "@solidjs/meta"; import { MetaProvider } from "@solidjs/meta";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
import { BarsProvider, useBars } from "./context/bars"; import { BarsProvider, useBars } from "./context/bars";
@@ -199,12 +200,13 @@ function AppLayout(props: { children: any }) {
</Show> </Show>
<Show when={!isMainSite()}> <Show when={!isMainSite()}>
<div class="bg-base min-h-screen w-full overflow-x-hidden"> <div class="bg-base flex min-h-screen w-full flex-col overflow-x-hidden">
<noscript> <noscript>
<div class="bg-yellow text-crust border-text fixed top-0 z-150 w-full border-b-2 p-4 text-center font-semibold"> <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>
<div class="flex-1">
<ErrorBoundary <ErrorBoundary
fallback={(error, reset) => ( fallback={(error, reset) => (
<ErrorBoundaryFallback error={error} reset={reset} /> <ErrorBoundaryFallback error={error} reset={reset} />
@@ -215,6 +217,8 @@ function AppLayout(props: { children: any }) {
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
<SubdomainFooter />
</div>
</Show> </Show>
</> </>
); );

View File

@@ -60,6 +60,42 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
expect(meta.canonical).toBe("https://nessa.freno.me/contact"); expect(meta.canonical).toBe("https://nessa.freno.me/contact");
}); });
// Subdomain routing is host-aware (root routes dispatch by useSite()), so
// useLocation() normally reports the public path. resolvePageHeadMeta also
// defensively strips a subdomain prefix from a prefixed pathname, so the
// canonical stays public even if the prefixed form ever appears.
it("nessa prefixed /nessa/contact → https://nessa.freno.me/contact", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.nessa,
"/nessa/contact"
);
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
});
it("lineage prefixed /lineage/privacy → https://lineage.freno.me/privacy", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.lineage,
"/lineage/privacy"
);
expect(meta.canonical).toBe("https://lineage.freno.me/privacy");
});
it("nessa prefixed root /nessa/ → https://nessa.freno.me/", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/nessa/");
expect(meta.canonical).toBe("https://nessa.freno.me/");
});
it("main path that happens to start with /nessa is NOT stripped (main site has no prefix)", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.main,
"/nessa/contact"
);
expect(meta.canonical).toBe("https://freno.me/nessa/contact");
});
it("lineage → canonical starts with https://lineage.freno.me", () => { it("lineage → canonical starts with https://lineage.freno.me", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/"); const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/");
expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true); expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true);

View File

@@ -0,0 +1,23 @@
import { A } from "@solidjs/router";
import { buildMainSiteUrl } from "~/lib/site-context";
export default function SubdomainFooter() {
return (
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
<div class="relative flex items-center text-sm">
<A
href={buildMainSiteUrl()}
class="text-text/60 hover:text-text/80 mx-auto text-center underline underline-offset-4 transition-colors"
>
made with <span class="text-red-400">&lt;3</span>
</A>
<A
href={buildMainSiteUrl("/downloads")}
class="text-text/80 hover:text-text absolute right-4 underline underline-offset-4 transition-colors"
>
see more products
</A>
</div>
</footer>
);
}

View File

@@ -12,13 +12,13 @@ import { For, Show } from "solid-js";
import { A, useLocation } from "@solidjs/router"; import { A, useLocation } from "@solidjs/router";
import { useSite } from "~/context/SiteContext"; import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config"; import { NAV_CONFIG } from "~/lib/nav-config";
import { DarkModeToggle } from "~/components/DarkModeToggle"; import { DarkModeToggle } from "~/components/DarkModeToggle";
export default function SubdomainHeader() { export default function SubdomainHeader() {
const site = useSite(); const site = useSite();
const location = useLocation(); const location = useLocation();
const { isDark, toggleDarkMode } = useDarkMode(); const { isDark } = useDarkMode();
const brandName = () => site().displayName; const brandName = () => site().displayName;
const brandColor = () => const brandColor = () =>
@@ -32,10 +32,7 @@ export default function SubdomainHeader() {
}; };
return ( return (
<header <header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md">
class="sticky top-0 z-50 w-full border-b backdrop-blur-md"
classList={{ "bg-base/80 border-surface0": site().headerOpaque }}
>
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"> <div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
<A <A
href="/" href="/"
@@ -78,7 +75,7 @@ export default function SubdomainHeader() {
</Show> </Show>
)} )}
</For> </For>
<DarkModeToggle /> <DarkModeToggle shouldScale={false} />
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -18,8 +18,11 @@ function sanitizeHtml(html: string): string {
) )
.forEach((el) => el.remove()); .forEach((el) => el.remove());
// Remove event handler attributes and dangerous URLs from all elements // Remove event handler attributes and dangerous URLs from all elements.
doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => { // NOTE: attribute-name wildcards (e.g. [on*]) are not valid CSS selectors and
// throw a SyntaxError on querySelectorAll, so we match all elements here and
// the per-attribute checks below handle on*/href/style/action filtering.
doc.querySelectorAll("*").forEach((el) => {
const attrs = Array.from(el.attributes); const attrs = Array.from(el.attributes);
attrs.forEach((attr) => { attrs.forEach((attr) => {
const name = attr.name; const name = attr.name;

View File

@@ -10,11 +10,11 @@
* - `title` → `props.title + site.titleSuffix` * - `title` → `props.title + site.titleSuffix`
* - `canonical` → explicit `props.canonical` override wins; otherwise * - `canonical` → explicit `props.canonical` override wins; otherwise
* `https://${site.domain}${pathname}` where `pathname` is the *public* * `https://${site.domain}${pathname}` where `pathname` is the *public*
* browser path. Because the subdomain prefix (`/lineage`, `/nessa`, …) is * browser path. Subdomain routing is host-aware (root routes dispatch by
* an internal-only rewrite (applied by `vercel.json` host rewrites or, in * `useSite()`, see `src/routes/index.tsx`/`privacy.tsx`/`deletion.tsx`),
* their absence, by `src/middleware.ts`), `useLocation()` reports the * so `useLocation()` already reports the public path. The defensive
* prefixed path (`/lineage/privacy`) and we strip the prefix back off so * prefix-strip below also handles any legacy prefixed form, so the canonical
* the canonical reflects the public URL (`https://lineage.freno.me/privacy`). * is always the public URL.
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`. * - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall * - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
* back to the base title (no suffix) / description (existing behavior). * back to the base title (no suffix) / description (existing behavior).
@@ -56,13 +56,11 @@ export function resolvePageHeadMeta(
): ResolvedPageHeadMeta { ): ResolvedPageHeadMeta {
const title = `${props.title}${site.titleSuffix}`; const title = `${props.title}${site.titleSuffix}`;
/** /**
* The canonical URL is the *public* browser URL, never the internal route * The canonical URL is the *public* browser URL, never any internal route
* prefix. SolidStart's file router is host-blind, so subdomain routes live * prefix. Subdomain routing is host-aware (root routes dispatch by
* under `src/routes/<prefix>/*` and are served either by `vercel.json` host * `useSite()`), so `useLocation()` returns the public path. The strip below
* rewrites OR by `src/middleware.ts` (the in-app host rewrite). Both append * is a defensive no-op for the public path and still yields the canonical
* the prefix to the internal request path (`/privacy` → `/lineage/privacy`), * `https://lineage.freno.me/privacy` if a prefixed form ever appears.
* so `useLocation()` reports the prefixed path — which we strip back off so
* the canonical stays `https://lineage.freno.me/privacy`.
*/ */
const publicPath = const publicPath =
site.baseRoutePrefix && site.baseRoutePrefix &&

View File

@@ -1,11 +1,113 @@
import { JSX, splitProps, Show, createSignal, createEffect } from "solid-js"; import {
type JSX,
splitProps,
Show,
createSignal,
createEffect
} from "solid-js";
import { Spinner } from "~/components/Spinner"; import { Spinner } from "~/components/Spinner";
/** Parse a hex color string like `#f9e2af` into `[r, g, b]` in 0..255. */
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace(/^#/, "");
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16)
];
}
/** Convert `[r, g, b]` 0..255 to `[h, s, l]` — h in 0..360, s and l in 0..1. */
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rn:
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
break;
case gn:
h = ((bn - rn) / d + 2) / 6;
break;
case bn:
h = ((rn - gn) / d + 4) / 6;
break;
}
}
return [h * 360, s, l];
}
/** Convert `[h, s, l]` (h in 0..360, s and l in 0..1) to `[r, g, b]` 0..255. */
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return Math.round(255 * (l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)));
};
return [f(0), f(8), f(4)];
}
/**
* Compute the relative luminance (WCAG) of an sRGB color, given linear
* channel values in 0..1.
*/
function luminance(r: number, g: number, b: number): number {
return (
0.2126 * (r > 0.04045 ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92) +
0.7152 * (g > 0.04045 ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92) +
0.0722 * (b > 0.04045 ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92)
);
}
/**
* Given a background color, return a pair of `{ bg, text }` CSS color
* strings that guarantee WCAG AA contrast.
*
* Light backgrounds (luminance > 0.4) are darkened in HSL space so the
* result is dark enough for white text. Dark backgrounds are kept as-is
* with white text. This is used by the download variant whose background
* is a product brand color that can be arbitrarily light.
*/
function adjustForContrast(color: string): { bg: string; text: string } {
const [r, g, b] = hexToRgb(color);
const [h, s, l] = rgbToHsl(r, g, b);
const lum = luminance(r / 255, g / 255, b / 255);
if (lum > 0.4) {
// Darken: keep hue and saturation, clamp lightness so the result is
// dark enough for white text (l ~ 0.35 → luminance ~ 0.17, contrast
// with white ≈ 6:1).
const dl = Math.min(l, 0.35);
const [dr, dg, db] = hslToRgb(h, s, dl);
return {
bg: `rgb(${dr}, ${dg}, ${db})`,
text: "#ffffff"
};
}
return {
bg: `rgb(${r}, ${g}, ${b})`,
text: "#ffffff"
};
}
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost" | "download"; variant?: "primary" | "secondary" | "danger" | "ghost" | "download";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
loading?: boolean; loading?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
/**
* Override the variant's background color with an explicit CSS color.
* Used by download pages to theme the button with the product brand color.
*/
color?: string;
} }
export default function Button(props: ButtonProps) { export default function Button(props: ButtonProps) {
@@ -16,7 +118,8 @@ export default function Button(props: ButtonProps) {
"fullWidth", "fullWidth",
"class", "class",
"children", "children",
"disabled" "disabled",
"color"
]); ]);
let contentRef: HTMLSpanElement | undefined; let contentRef: HTMLSpanElement | undefined;
@@ -53,8 +156,8 @@ export default function Button(props: ButtonProps) {
: "bg-surface0 hover:brightness-125 active:scale-90"; : "bg-surface0 hover:brightness-125 active:scale-90";
case "download": case "download":
return isDisabledOrLoading return isDisabledOrLoading
? "bg-green text-base cursor-not-allowed brightness-75" ? "cursor-not-allowed brightness-75"
: "bg-green text-base hover:brightness-125 active:scale-90"; : "hover:brightness-125 active:scale-90";
case "danger": case "danger":
return isDisabledOrLoading return isDisabledOrLoading
? "bg-red cursor-not-allowed brightness-75" ? "bg-red cursor-not-allowed brightness-75"
@@ -68,6 +171,20 @@ export default function Button(props: ButtonProps) {
} }
}; };
/** Compute background + text color for the download variant. */
const downloadColors = () => {
const isDisabledOrLoading = local.disabled || local.loading;
if (isDisabledOrLoading) {
return { bg: "var(--color-base)", text: "#ffffff" };
}
// When no explicit color is given, fall back to the theme blue
// (a dark color whose contrast with white is already fine). Only
// pass a hex string to adjustForContrast — CSS variable values can't
// be parsed numerically.
if (!local.color) return { bg: "var(--color-blue)", text: "#ffffff" };
return adjustForContrast(local.color);
};
const sizeClasses = () => { const sizeClasses = () => {
switch (size()) { switch (size()) {
case "sm": case "sm":
@@ -83,11 +200,25 @@ export default function Button(props: ButtonProps) {
const widthClass = () => (local.fullWidth ? "w-full" : ""); const widthClass = () => (local.fullWidth ? "w-full" : "");
const buttonStyle = (): JSX.CSSProperties => {
if (variant() === "download") {
const { bg, text } = downloadColors();
return { background: bg, color: text };
}
// Theme-aware text: white on dark bg variants (primary/danger),
// theme text on light bg variants (secondary). The CSS variables
// flip between light/dark modes so the contrast stays good in both.
if (variant() === "secondary")
return { color: "var(--color-button-text-alt)" };
return { color: "var(--color-button-text)" };
};
return ( return (
<button <button
{...others} {...others}
disabled={local.disabled || local.loading} disabled={local.disabled || local.loading}
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`} class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
style={buttonStyle()}
> >
<Show <Show
when={local.loading} when={local.loading}

View File

@@ -1,4 +1,4 @@
import { createSignal, onMount, onCleanup, Accessor } from "solid-js"; import { createSignal, onMount, onCleanup, type Accessor } from "solid-js";
export const MOBILE_BREAKPOINT = 768; export const MOBILE_BREAKPOINT = 768;
@@ -8,8 +8,9 @@ export const MOBILE_BREAKPOINT = 768;
* @returns Accessor for current window width * @returns Accessor for current window width
*/ */
export function createWindowWidth(debounceMs?: number): Accessor<number> { export function createWindowWidth(debounceMs?: number): Accessor<number> {
const initialWidth = typeof window !== "undefined" ? window.innerWidth : 1024; // Use a static default so SSR and initial client render agree; the
const [width, setWidth] = createSignal(initialWidth); // real value is set in onMount (after hydration) to avoid mismatches.
const [width, setWidth] = createSignal(1024);
onMount(() => { onMount(() => {
setWidth(window.innerWidth); setWidth(window.innerWidth);

View File

@@ -35,8 +35,6 @@ export interface Site {
brandColor: string; brandColor: string;
/** Dark mode variant of the brand color (used when dark mode is active). */ /** Dark mode variant of the brand color (used when dark mode is active). */
brandColorDark?: string; brandColorDark?: string;
/** Whether the subdomain header should use a solid background (default: false for transparent). */
headerOpaque?: boolean;
/** Default OpenGraph image path (resolved against the site root). */ /** Default OpenGraph image path (resolved against the site root). */
ogDefaultImage: string; ogDefaultImage: string;
/** Favicon path for this site. */ /** Favicon path for this site. */
@@ -89,9 +87,8 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
titleSuffix: " | Nessa", titleSuffix: " | Nessa",
brandColor: "#527640", brandColor: "#527640",
brandColorDark: "#6CA86C", brandColorDark: "#6CA86C",
headerOpaque: true,
ogDefaultImage: "/nessa/og-default.png", ogDefaultImage: "/nessa/og-default.png",
faviconPath: "/nessa/favicon.ico" faviconPath: "/nessa/favicon/favicon.ico"
}, },
lineage: { lineage: {
id: "lineage", id: "lineage",
@@ -100,9 +97,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/lineage", baseRoutePrefix: "/lineage",
displayName: "Life and Lineage", displayName: "Life and Lineage",
titleSuffix: " | Life and Lineage", titleSuffix: " | Life and Lineage",
brandColor: "#a6e3a1", brandColor: "#a13536",
ogDefaultImage: "/lineage/og-default.png", ogDefaultImage: "/lineage/og-default.png",
faviconPath: "/lineage/favicon.ico" faviconPath: "/lineage/favicon/favicon.ico"
}, },
gaze: { gaze: {
id: "gaze", id: "gaze",
@@ -111,9 +108,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/gaze", baseRoutePrefix: "/gaze",
displayName: "Gaze", displayName: "Gaze",
titleSuffix: " | Gaze", titleSuffix: " | Gaze",
brandColor: "#f9e2af", brandColor: "#002cff",
ogDefaultImage: "/gaze/og-default.png", ogDefaultImage: "/gaze/og-default.png",
faviconPath: "/gaze/favicon.ico" faviconPath: "/gaze/favicon/favicon.ico"
}, },
inputhalo: { inputhalo: {
id: "inputhalo", id: "inputhalo",
@@ -122,9 +119,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/inputhalo", baseRoutePrefix: "/inputhalo",
displayName: "InputHalo", displayName: "InputHalo",
titleSuffix: " | InputHalo", titleSuffix: " | InputHalo",
brandColor: "#f38ba8", brandColor: "#41a5ff",
ogDefaultImage: "/inputhalo/og-default.png", ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon.ico" faviconPath: "/inputhalo/favicon/favicon.ico"
} }
}; };

View File

@@ -1,102 +0,0 @@
// @refresh reload
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* In-app host-based subdomain path rewrite.
*
* ## Why this exists
*
* `vercel.json` declares host `rewrites` that map each subdomain onto its
* internal `src/routes/<prefix>/*` file route (e.g.
* `lineage.freno.me/privacy` → `/lineage/privacy`). When Vercel builds with
* the Nitro `vercel` preset, however, it emits a Build Output API
* `.vercel/output/config.json` with a `routes` array — and per Vercel's
* contract, **a `routes` array in `config.json` fully replaces
* `vercel.json` `rewrites`/`redirects`/`headers`**. The host rewrites were
* therefore silently never applied, which is why subdomain-only routes
* (`/privacy`, `/deletion`) 404'd on production while routes that also
* exist at the root (`/`, `/contact`, `/downloads`) appeared to work (they
* were actually served by the root route, not the subdomain-branded one).
*
* ## What this does
*
* This middleware (registered via `app.config.ts` → `middleware`) runs as
* an H3 `onRequest` hook BEFORE the SolidStart file router matches the path.
* For a request whose `Host` resolves to a subdomain `Site`, it prefixes the
* request URL with the site's `baseRoutePrefix` — e.g. `/privacy` on
* `lineage.freno.me` becomes `/lineage/privacy` internally, exactly as the
* `vercel.json` rewrite intended — so the file router resolves the correct
* subdomain route file (`src/routes/lineage/privacy.tsx`).
*
* The browser URL is untouched (this is an internal rewrite, not a
* redirect). `useLocation()` will report the prefixed internal path; the
* canonical-URL derivation in `src/components/page-head-meta.ts` strips the
* prefix so the public canonical stays `https://lineage.freno.me/privacy`.
*
* ## Scope / skip conditions
*
* - Main site / unknown host → no rewrite (no prefix to add).
* - `/api/*` → shared API pool (already routed correctly on all hosts; the
* `vercel.json` `/api/(.*)` rules are pure pass-throughs, so no prefix).
* - Paths already carrying a subdomain prefix (dev path-based URLs like
* `localhost:3000/lineage/privacy`, or a request already rewritten) → no
* double-prefix.
* - `/_build/*` build assets → served statically, not routed.
*
* ## Dev vs prod
*
* In dev, the Host is usually `localhost` (→ main → no rewrite); subdomain
* pages are reached via their path prefix (`localhost:3000/lineage/privacy`)
* which the skip-condition above leaves untouched. Dev subdomains reached
* via `lineage.localhost:3000/privacy` (browsers resolve `*.localhost`) ARE
* rewritten, keeping dev and prod behavior consistent.
*/
import { resolveSiteFromHost } from "~/lib/site-context";
/**
* Minimal H3-event shape this middleware touches. Avoids importing
* `vinxi/http` (a type-only subpath that some environments can't resolve) —
* the real H3Event satisfies this structure at runtime.
*/
interface RewriteEvent {
node?: {
req?: {
url?: string;
originalUrl?: string;
headers?: { host?: string };
};
};
_path?: string;
}
export default {
onRequest(event: RewriteEvent) {
const req = event?.node?.req;
if (!req?.headers) return;
const site = resolveSiteFromHost(req.headers.host);
// No prefix to add for the main site / unknown host.
if (!site.baseRoutePrefix) return;
const url = req.url || "/";
// Shared API pool — pass through unchanged on every host.
if (url === "/api" || url.startsWith("/api/")) return;
// Already prefixed (dev path-based URLs, or a request already rewritten).
if (
url === site.baseRoutePrefix ||
url.startsWith(site.baseRoutePrefix + "/")
) {
return;
}
// Static build assets are served as files, not via the router.
if (url.startsWith("/_build/")) return;
// Internal rewrite: /privacy → /lineage/privacy (browser URL unchanged).
// H3's toWebRequest derives the URL from `originalUrl ?? event.path`, so
// we must set originalUrl too (not just _path/req.url) or the SolidStart
// router keeps seeing the unprefixed browser path.
const rewritten = site.baseRoutePrefix + url;
req.originalUrl = rewritten;
req.url = rewritten;
event._path = rewritten;
}
};

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 { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import RevealDropDown from "~/components/RevealDropDown"; import RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm"; import { ContactForm } from "~/components/ContactForm";
import { buildSubdomainUrl } from "~/lib/site-context"; 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`). * 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 [searchParams] = useSearchParams();
const viewer = () => searchParams.viewer ?? "default"; const viewer = () => searchParams.viewer ?? "default";
@@ -131,3 +136,38 @@ export default function ContactPage() {
</ContactForm> </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 { PageHead } from "~/components/PageHead";
import { A } from "@solidjs/router"; 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 DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import { glitchText } from "~/lib/client-utils"; import { glitchText } from "~/lib/client-utils";
import { buildSubdomainUrl } from "~/lib/subdomain-url"; import { buildSubdomainUrl } from "~/lib/subdomain-url";
import Button from "~/components/ui/Button"; 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 [LaLText, setLaLText] = createSignal("Life and Lineage");
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!"); const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
const [corkText, setCorkText] = createSignal("Cork"); const [corkText, setCorkText] = createSignal("Cork");
@@ -103,7 +107,7 @@ export default function DownloadsPage() {
</A> </A>
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm"> <span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+) platform: macOS (14.6+)
@@ -155,7 +159,7 @@ export default function DownloadsPage() {
</A> </A>
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm"> <span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+) platform: macOS (14.6+)
@@ -205,7 +209,7 @@ export default function DownloadsPage() {
</A> </A>
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm"> <span class="text-subtext0 font-mono text-sm">
platform: android platform: android
@@ -266,7 +270,7 @@ export default function DownloadsPage() {
<span class="text-yellow">{">"}</span> {SwAText()} <span class="text-yellow">{">"}</span> {SwAText()}
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm"> <span class="text-subtext0 font-mono text-sm">
platform: android platform: android
@@ -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 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 { useSite } from "~/context/SiteContext"; import { useSite } from "~/context/SiteContext";
import { downloadAsset } from "~/lib/download-asset"; import { downloadAsset } from "~/lib/download-asset";
@@ -22,8 +23,12 @@ const GAZE_MIN_MACOS = "14.6";
export default function GazeDownloadsPage() { export default function GazeDownloadsPage() {
const site = useSite(); const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const handleDownload = () => { const handleDownload = () => {
if (loading()) return; if (loading()) return;
setLoading(true); setLoading(true);
@@ -85,6 +90,7 @@ export default function GazeDownloadsPage() {
<Button <Button
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()}
loading={loading()} loading={loading()}
onClick={handleDownload} onClick={handleDownload}
> >

View File

@@ -4,6 +4,7 @@ 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 { useSite } from "~/context/SiteContext";
import { downloadAsset } from "~/lib/download-asset"; import { downloadAsset } from "~/lib/download-asset";
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498"; const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
@@ -33,9 +34,13 @@ const FEATURES = [
] as const; ] as const;
export default function GazeLanding() { export default function GazeLanding() {
const site = useSite();
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const handleDownload = () => { const handleDownload = () => {
if (loading()) return; if (loading()) return;
setLoading(true); setLoading(true);
@@ -56,7 +61,7 @@ export default function GazeLanding() {
return ( return (
<> <>
<PageHead <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." 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"
@@ -100,6 +105,7 @@ export default function GazeLanding() {
<Button <Button
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()}
loading={loading()} loading={loading()}
onClick={handleDownload} onClick={handleDownload}
> >
@@ -157,6 +163,7 @@ export default function GazeLanding() {
<Button <Button
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()}
loading={loading()} loading={loading()}
onClick={handleDownload} onClick={handleDownload}
> >

View File

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

View File

@@ -14,7 +14,6 @@ import { For, createSignal } from "solid-js";
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 SubdomainHeader from "~/components/SubdomainHeader";
import { buildMainSiteUrl } from "~/lib/site-context";
import Button from "~/components/ui/Button"; import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
@@ -100,7 +99,7 @@ export default function InputHaloLanding() {
return ( return (
<> <>
<PageHead <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." 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 +151,7 @@ export default function InputHaloLanding() {
<Button <Button
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()}
loading={loading()} loading={loading()}
onClick={download} onClick={download}
> >
@@ -250,6 +250,7 @@ export default function InputHaloLanding() {
<Button <Button
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()}
loading={loading()} loading={loading()}
onClick={download} onClick={download}
> >
@@ -269,19 +270,6 @@ export default function InputHaloLanding() {
</p> </p>
</div> </div>
</section> </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={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me
</A>
</div>
</footer>
</main> </main>
</> </>
); );

View File

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

View File

@@ -73,7 +73,7 @@ describe("Lineage landing — asset paths", () => {
describe("Lineage landing — PageHead inputs", () => { describe("Lineage landing — PageHead inputs", () => {
it("passes the base title (suffix is appended by PageHead)", () => { 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", () => { 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). */ /** Base page title (site suffix appended by PageHead). */
export const PAGE_META = { export const PAGE_META = {
title: "Life and Lineage", title: "Home",
description: description:
"A dark fantasy adventure mobile game. Download Life and Lineage on the App Store and Google Play." "A dark fantasy adventure mobile game. Download Life and Lineage on the App Store and Google Play."
} as const; } as const;

View File

@@ -107,7 +107,7 @@ export default function NessaLanding() {
</A> </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 text-accent rounded-full border-2 px-8 py-3 font-semibold transition-colors"
> >
Request beta access Request beta access
</A> </A>
@@ -343,19 +343,6 @@ export default function NessaLanding() {
</div> </div>
</div> </div>
</section> </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={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me
</A>
</div>
</footer>
</main> </main>
</> </>
); );

View File

@@ -13,13 +13,13 @@ import { SITE_CONFIG } from "~/lib/site-context";
import { NESSA_LANDING_META } from "./meta"; 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 'Home | Nessa' (base title + nessa titleSuffix)", () => {
const meta = resolvePageHeadMeta( const meta = resolvePageHeadMeta(
NESSA_LANDING_META, NESSA_LANDING_META,
SITE_CONFIG.nessa, SITE_CONFIG.nessa,
"/" "/"
); );
expect(meta.title).toBe("Nessa | Nessa"); expect(meta.title).toBe("Home | Nessa");
}); });
it("title contains the substring '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. * no-suffix fallback) — this matches the marketing-copy intent of the card.
*/ */
export const NESSA_LANDING_META: PageHeadProps = { export const NESSA_LANDING_META: PageHeadProps = {
title: "Nessa", title: "Home",
description: 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.", "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", 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>
);
}