Compare commits
7 Commits
641d3ed0ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 33ca9213f2 | |||
| 34db684fe1 | |||
| 2f1b5f35c6 | |||
| 44b6e7595c | |||
| 5ddbfbc429 | |||
| ad5221dc35 | |||
| 5eecb8e7d1 |
@@ -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.
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
BIN
public/gaze/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/gaze/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
public/gaze/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
17
public/gaze/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.9 MiB |
21
public/gaze/favicon/site.webmanifest
Executable 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"
|
||||||
|
}
|
||||||
BIN
public/gaze/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/gaze/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/inputhalo/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/inputhalo/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/inputhalo/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
17
public/inputhalo/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.0 MiB |
21
public/inputhalo/favicon/site.webmanifest
Executable 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"
|
||||||
|
}
|
||||||
BIN
public/inputhalo/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/inputhalo/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/lineage/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/lineage/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/lineage/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
3
public/lineage/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 860 KiB |
21
public/lineage/favicon/site.webmanifest
Executable 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"
|
||||||
|
}
|
||||||
BIN
public/lineage/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/lineage/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 115 KiB |
BIN
public/nessa/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/nessa/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 845 B |
BIN
public/nessa/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
17
public/nessa/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 64 KiB |
21
public/nessa/favicon/site.webmanifest
Executable 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"
|
||||||
|
}
|
||||||
BIN
public/nessa/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/nessa/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 9.1 KiB |
14
src/app.css
@@ -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 {
|
||||||
|
|||||||
24
src/app.tsx
@@ -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,21 +200,24 @@ 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>
|
||||||
<ErrorBoundary
|
<div class="flex-1">
|
||||||
fallback={(error, reset) => (
|
<ErrorBoundary
|
||||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
fallback={(error, reset) => (
|
||||||
)}
|
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||||
>
|
)}
|
||||||
<Suspense fallback={<TerminalSplash inverse />}>
|
>
|
||||||
{props.children}
|
<Suspense fallback={<TerminalSplash inverse />}>
|
||||||
</Suspense>
|
{props.children}
|
||||||
</ErrorBoundary>
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
<SubdomainFooter />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { Typewriter } from "./Typewriter";
|
|||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
|
||||||
export function DarkModeToggle(
|
export function DarkModeToggle(
|
||||||
props: { shouldScale: boolean },
|
props: { shouldScale?: boolean } = { shouldScale: true }
|
||||||
{ shouldScale = true }
|
|
||||||
) {
|
) {
|
||||||
|
const shouldScale = props.shouldScale ?? true;
|
||||||
const { isDark, toggleDarkMode } = useDarkMode();
|
const { isDark, toggleDarkMode } = useDarkMode();
|
||||||
const [mounted, setMounted] = createSignal(false);
|
const [mounted, setMounted] = createSignal(false);
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function DarkModeToggle(
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
class={`hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out ${props.shouldScale ? "hover:scale-105" : ""}`}
|
class={`hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out ${shouldScale ? "hover:scale-105" : ""}`}
|
||||||
aria-label="Toggle dark mode"
|
aria-label="Toggle dark mode"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
23
src/components/SubdomainFooter.tsx
Normal 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"><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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
* Rules:
|
* Rules:
|
||||||
* - `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}`. The pathname is the *browser* path
|
* `https://${site.domain}${pathname}` where `pathname` is the *public*
|
||||||
* (from `useLocation`), which is correct because vercel.json host rewrites
|
* browser path. Subdomain routing is host-aware (root routes dispatch by
|
||||||
* target internal route prefixes (`/nessa`, `/lineage`, …) while leaving
|
* `useSite()`, see `src/routes/index.tsx`/`privacy.tsx`/`deletion.tsx`),
|
||||||
* the public URL intact — so `nessa.freno.me/contact` reports pathname
|
* so `useLocation()` already reports the public path. The defensive
|
||||||
* `/contact`, and the canonical is `https://nessa.freno.me/contact`.
|
* prefix-strip below also handles any legacy prefixed form, so the canonical
|
||||||
|
* 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).
|
||||||
@@ -54,7 +55,20 @@ export function resolvePageHeadMeta(
|
|||||||
pathname: string
|
pathname: string
|
||||||
): ResolvedPageHeadMeta {
|
): ResolvedPageHeadMeta {
|
||||||
const title = `${props.title}${site.titleSuffix}`;
|
const title = `${props.title}${site.titleSuffix}`;
|
||||||
const canonical = props.canonical ?? `https://${site.domain}${pathname}`;
|
/**
|
||||||
|
* The canonical URL is the *public* browser URL, never any internal route
|
||||||
|
* prefix. Subdomain routing is host-aware (root routes dispatch by
|
||||||
|
* `useSite()`), so `useLocation()` returns the public path. The strip below
|
||||||
|
* is a defensive no-op for the public path and still yields the canonical
|
||||||
|
* `https://lineage.freno.me/privacy` if a prefixed form ever appears.
|
||||||
|
*/
|
||||||
|
const publicPath =
|
||||||
|
site.baseRoutePrefix &&
|
||||||
|
(pathname === site.baseRoutePrefix ||
|
||||||
|
pathname.startsWith(site.baseRoutePrefix + "/"))
|
||||||
|
? pathname.slice(site.baseRoutePrefix.length) || "/"
|
||||||
|
: pathname;
|
||||||
|
const canonical = props.canonical ?? `https://${site.domain}${publicPath}`;
|
||||||
const ogTitle = props.ogTitle ?? props.title;
|
const ogTitle = props.ogTitle ?? props.title;
|
||||||
const ogDescription = props.ogDescription ?? props.description;
|
const ogDescription = props.ogDescription ?? props.description;
|
||||||
const ogImage = props.ogImage ?? site.ogDefaultImage;
|
const ogImage = props.ogImage ?? site.ogDefaultImage;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'", () => {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||