Compare commits

..

11 Commits

89 changed files with 5064 additions and 146 deletions

View File

@@ -70,7 +70,6 @@ INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
GITEA_URL="https://gitea.example.com" GITEA_URL="https://gitea.example.com"
GITEA_TOKEN="<rotate-in-gitea>" GITEA_TOKEN="<rotate-in-gitea>"
GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / github_pat_... GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / github_pat_...
REDIS_URL="redis://localhost:6379"
# ── Sentry (error tracking + source maps) ── # ── Sentry (error tracking + source maps) ──
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token # Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token

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.

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

@@ -4,7 +4,10 @@ import SunIcon from "./icons/SunIcon";
import { Typewriter } from "./Typewriter"; import { Typewriter } from "./Typewriter";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
export function DarkModeToggle() { export function DarkModeToggle(
props: { shouldScale?: boolean } = { 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);
@@ -15,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 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

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

@@ -11,14 +11,18 @@
import { For, Show } from "solid-js"; 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 { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config"; import { useDarkMode } from "~/context/darkMode";
import { NAV_CONFIG } from "~/lib/nav-config";
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 } = useDarkMode();
const brandName = () => site().displayName; const brandName = () => site().displayName;
const brandColor = () => site().brandColor; const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const navItems = () => const navItems = () =>
NAV_CONFIG[site().id].filter((item) => item.label !== "Home"); NAV_CONFIG[site().id].filter((item) => item.label !== "Home");
@@ -71,15 +75,7 @@ export default function SubdomainHeader() {
</Show> </Show>
)} )}
</For> </For>
<DarkModeToggle shouldScale={false} />
<a
href={BACK_TO_FRENO.href}
target="_blank"
rel="noopener noreferrer"
class="text-text/60 hover:text-text text-xs transition-colors"
>
{BACK_TO_FRENO.label}
</a>
</nav> </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

@@ -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;

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}

2
src/env/server.ts vendored
View File

@@ -53,7 +53,6 @@ const serverEnvSchema = z.object({
VITE_WEBSOCKET: z.string().min(1), VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1),
INFILL_BEARER_TOKEN: z.string().min(1), INFILL_BEARER_TOKEN: z.string().min(1),
REDIS_URL: z.string().min(1),
NESSA_DB_URL: z.string().min(1), NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1), NESSA_DB_TOKEN: z.string().min(1),
// Clerk authentication — Nessa auth is now Clerk-backed. The // Clerk authentication — Nessa auth is now Clerk-backed. The
@@ -171,7 +170,6 @@ export const getMissingEnvVars = (): string[] => {
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET", "VITE_WEBSOCKET",
"REDIS_URL",
"NESSA_DB_URL", "NESSA_DB_URL",
"NESSA_DB_TOKEN", "NESSA_DB_TOKEN",
"NESSA_CLERK_SECRET", "NESSA_CLERK_SECRET",

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

@@ -33,6 +33,8 @@ export interface Site {
titleSuffix: string; titleSuffix: string;
/** Hex brand color used for theming accents / OG image backgrounds. */ /** Hex brand color used for theming accents / OG image backgrounds. */
brandColor: string; brandColor: string;
/** Dark mode variant of the brand color (used when dark mode is active). */
brandColorDark?: string;
/** 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. */
@@ -83,9 +85,10 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/nessa", baseRoutePrefix: "/nessa",
displayName: "Nessa", displayName: "Nessa",
titleSuffix: " | Nessa", titleSuffix: " | Nessa",
brandColor: "#cba6f7", brandColor: "#527640",
brandColorDark: "#6CA86C",
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",
@@ -94,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",
@@ -105,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",
@@ -116,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

@@ -0,0 +1,525 @@
{
"childhood_events": [
{
"id": "school_presentation",
"ageRange": { "min": 6, "max": 12 },
"category": "school",
"icon": "📚",
"title": "A Big Day at School",
"description": "It's your turn to give a presentation at school. All the other kids are watching.",
"weight": 1.0,
"cooldownTicks": 12,
"outcomes": [
{
"label": "Speak with confidence",
"description": "Stand tall and give an excellent presentation.",
"effects": [
{ "kind": "sanity", "amount": 10 },
{ "kind": "experience", "amount": 5 },
{ "kind": "notification", "message": "The teacher praises your bravery!" }
]
},
{
"label": "Mumble through it nervously",
"effects": [
{ "kind": "sanity", "amount": -2 },
{ "kind": "experience", "amount": 2 },
{ "kind": "notification", "message": "It's over... You can breathe again." }
]
}
]
},
{
"id": "playground_friends",
"ageRange": { "min": 5, "max": 12 },
"category": "friendship",
"icon": "👫",
"title": "Making New Friends",
"description": "Some kids at the playground wave you over to join their game.",
"weight": 1.2,
"cooldownTicks": 10,
"outcomes": [
{
"label": "Join them eagerly",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "notification", "message": "You make friends and have a wonderful time!" }
]
},
{
"label": "Watch from afar",
"effects": [
{ "kind": "sanity", "amount": 3 },
{ "kind": "notification", "message": "You enjoy the game from the sidelines." }
]
}
]
},
{
"id": "family_picnic",
"ageRange": { "min": 4, "max": 14 },
"category": "family",
"icon": "🧺",
"title": "A Family Picnic",
"description": "Your family has organized a picnic at the park. Everyone brings their favorite foods.",
"weight": 0.9,
"cooldownTicks": 16,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "A perfect day with family, laughing and eating by the river." }
]
}
},
{
"id": "lost_toy",
"ageRange": { "min": 4, "max": 10 },
"category": "mishap",
"icon": "🧸",
"title": "Oh No, Lost My Toy!",
"description": "You can't find your favorite stuffed animal anywhere.",
"weight": 0.7,
"cooldownTicks": 20,
"outcomes": [
{
"label": "Search everywhere",
"effects": [
{ "kind": "sanity", "amount": 5 },
{ "kind": "notification", "message": "Found it behind the bed! You're so relieved." }
]
},
{
"label": "Ask family for help",
"effects": [
{ "kind": "sanity", "amount": 8 },
{ "kind": "notification", "message": "Mom finds it in the closet. What a relief!" }
]
}
]
},
{
"id": "birthday_party",
"ageRange": { "min": 5, "max": 15 },
"category": "milestone",
"icon": "🎂",
"title": "Your Birthday!",
"description": "Today is your special day! There's cake, presents, and all your friends.",
"weight": 1.5,
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 25 },
{ "kind": "health", "amount": 10 },
{ "kind": "gold", "amount": 15 },
{ "kind": "notification", "message": "The best day of the year! Cake, presents, and all your friends cheering!" }
]
}
},
{
"id": "reading_first_book",
"ageRange": { "min": 6, "max": 9 },
"category": "achievement",
"icon": "📖",
"title": "Reading Your First Book!",
"description": "You've learned to read! You can finally read your first book by yourself.",
"weight": 1.0,
"cooldownTicks": 30,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "experience", "amount": 10 },
{ "kind": "notification", "message": "The world of stories is now yours!" }
]
}
},
{
"id": "garden_discovery",
"ageRange": { "min": 4, "max": 12 },
"category": "exploration",
"icon": "🌻",
"title": "A Secret Garden",
"description": "You find a hidden garden behind the old barn. Butterflies flutter everywhere.",
"weight": 0.8,
"cooldownTicks": 14,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "notification", "message": "Your own secret world, filled with flowers and butterflies." }
]
}
},
{
"id": "learning_riding",
"ageRange": { "min": 8, "max": 14 },
"category": "skill",
"icon": "🐴",
"title": "Learning to Ride",
"description": "Your parents sign you up for riding lessons. The horse is bigger than you thought!",
"weight": 0.9,
"cooldownTicks": 18,
"outcomes": [
{
"label": "Be brave and ride",
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "experience", "amount": 8 },
{ "kind": "notification", "message": "You learn to sit tall and control the horse!" }
]
},
{
"label": "Just pet the horse",
"effects": [
{ "kind": "sanity", "amount": 5 },
{ "kind": "notification", "message": "The horse is gentle and friendly. Maybe next time." }
]
}
]
},
{
"id": "first_pet",
"ageRange": { "min": 6, "max": 12 },
"category": "family",
"icon": "🐱",
"title": "You Get a Pet!",
"description": "Your parents bring home a new kitten for you!",
"weight": 1.3,
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 30 },
{ "kind": "notification", "message": "A fluffy new friend to love and care for!" }
]
}
},
{
"id": "rain_puddles",
"ageRange": { "min": 3, "max": 10 },
"category": "play",
"icon": "🌧️",
"title": "Jumping in Puddles",
"description": "It's raining! The street is full of puddles to jump in.",
"weight": 1.5,
"seasons": ["spring", "autumn"],
"cooldownTicks": 8,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 18 },
{ "kind": "health", "amount": 3 },
{ "kind": "notification", "message": "SPLASH! The best feeling in the world!" }
]
}
},
{
"id": "snowman_building",
"ageRange": { "min": 4, "max": 14 },
"category": "play",
"icon": "⛄",
"title": "Building a Snowman",
"description": "It's snowing! Your friends want to build the biggest snowman ever.",
"weight": 1.2,
"seasons": ["winter"],
"cooldownTicks": 12,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "You build an enormous snowman with a carrot nose!" }
]
}
},
{
"id": "firefly_catching",
"ageRange": { "min": 5, "max": 12 },
"category": "play",
"icon": "✨",
"title": "Catching Fireflies",
"description": "On a warm summer evening, fireflies light up the garden.",
"weight": 1.0,
"seasons": ["summer"],
"cooldownTicks": 14,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "notification", "message": "Little lights dance around you in the darkness." }
]
}
},
{
"id": "scarecrow_encounter",
"ageRange": { "min": 5, "max": 12 },
"category": "mystery",
"icon": "🌾",
"title": "The Scarecrow Seems to Move",
"description": "You're walking past the fields and you think the scarecrow has turned.",
"weight": 0.6,
"cooldownTicks": 20,
"outcomes": [
{
"label": "Investigate bravely",
"effects": [
{ "kind": "sanity", "amount": 5 },
{ "kind": "notification", "message": "It was just the wind. Phew!" }
]
},
{
"label": "Run home quickly",
"effects": [
{ "kind": "sanity", "amount": -5 },
{ "kind": "notification", "message": "Better safe than sorry!" }
]
}
]
},
{
"id": "sharing_treats",
"ageRange": { "min": 4, "max": 12 },
"category": "friendship",
"icon": "🍪",
"title": "Sharing Treats",
"description": "You have a special cookie. There's one other kid who looks hungry.",
"weight": 1.0,
"cooldownTicks": 10,
"outcomes": [
{
"label": "Share the cookie",
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "notification", "message": "The other kid thanks you and smiles. Sharing is nice." }
]
},
{
"label": "Eat it yourself",
"effects": [
{ "kind": "health", "amount": 3 },
{ "kind": "notification", "message": "Cookies are delicious!" }
]
}
]
},
{
"id": "helping_elder",
"ageRange": { "min": 8, "max": 14 },
"category": "kindness",
"icon": "👴",
"title": "An Elder Needs Help",
"description": "An old man is struggling to carry his groceries. He looks like he needs some help.",
"weight": 0.9,
"cooldownTicks": 16,
"outcomes": [
{
"label": "Help carry the groceries",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "gold", "amount": 5 },
{ "kind": "notification", "message": "The old man thanks you and gives you a coin." }
]
},
{
"label": "Keep walking",
"effects": [
{ "kind": "sanity", "amount": -3 },
{ "kind": "notification", "message": "Maybe next time..." }
]
}
]
},
{
"id": "lost_dog",
"ageRange": { "min": 6, "max": 14 },
"category": "encounter",
"icon": "🐕",
"title": "A Lost Dog",
"description": "You find a puppy wandering alone, looking sad.",
"weight": 0.8,
"cooldownTicks": 18,
"outcomes": [
{
"label": "Find the owner",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "gold", "amount": 10 },
{ "kind": "notification", "message": "The happy puppy's family thanks you!" }
]
},
{
"label": "Give it some food",
"effects": [
{ "kind": "sanity", "amount": 8 },
{ "kind": "notification", "message": "The puppy wags its tail gratefully." }
]
}
]
},
{
"id": "first_job_internship",
"ageRange": { "min": 13, "max": 16 },
"category": "work",
"icon": "🏪",
"title": "Helping at the Shop",
"description": "The shopkeeper lets you help out for a few hours. It's your first real work experience.",
"weight": 0.8,
"cooldownTicks": 20,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 8 },
{ "kind": "gold", "amount": 15 },
{ "kind": "experience", "amount": 10 },
{ "kind": "notification", "message": "You earn your first real wages!" }
]
}
},
{
"id": "dungeon_adventure",
"ageRange": { "min": 13, "max": 17 },
"category": "adventure",
"icon": "⚔️",
"title": "The Old Dungeon",
"description": "The older kids challenge you to peek into the old dungeon entrance.",
"weight": 0.7,
"cooldownTicks": 22,
"outcomes": [
{
"label": "Enter the dungeon",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "experience", "amount": 15 },
{ "kind": "notification", "message": "You explore the dark passages and find treasure!" }
]
},
{
"label": "Stay outside and watch",
"effects": [
{ "kind": "sanity", "amount": 3 },
{ "kind": "notification", "message": "A little too scary for now." }
]
}
]
},
{
"id": "reading_library",
"ageRange": { "min": 12, "max": 17 },
"category": "learning",
"icon": "📚",
"title": "Reading at the Library",
"description": "You spend an afternoon reading books at the town library.",
"weight": 0.9,
"cooldownTicks": 14,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 10 },
{ "kind": "experience", "amount": 15 },
{ "kind": "notification", "message": "The world expands through the power of reading." }
]
}
},
{
"id": "first_crush",
"ageRange": { "min": 13, "max": 17 },
"category": "romance",
"icon": "💕",
"title": "A Butterflies in Your Stomach",
"description": "You see someone at school and suddenly your heart starts racing.",
"weight": 1.0,
"cooldownTicks": 24,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "notification", "message": "Oh no... Is this what people mean by 'crushes'?" }
]
}
},
{
"id": "harvest_festival",
"ageRange": { "min": 8, "max": 17 },
"category": "community",
"icon": "🎪",
"title": "The Autumn Harvest",
"description": "The whole town celebrates the harvest festival with games and food.",
"weight": 1.2,
"seasons": ["autumn"],
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 25 },
{ "kind": "health", "amount": 10 },
{ "kind": "notification", "message": "Pumpkin pie, hayrides, and apple cider! The best festival!" }
]
}
},
{
"id": "midsummer_fire",
"ageRange": { "min": 12, "max": 17 },
"category": "community",
"icon": "🔥",
"title": "The Midsummer Bonfire",
"description": "All the teens gather around the bonfire, telling stories and roasting marshmallows.",
"weight": 1.0,
"seasons": ["summer"],
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "Stories of monsters and adventure by the crackling fire." }
]
}
},
{
"id": "first_dance",
"ageRange": { "min": 14, "max": 17 },
"category": "social",
"icon": "💃",
"title": "The First Dance",
"description": "You're invited to the teen dance at the community hall.",
"weight": 0.9,
"cooldownTicks": 40,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "notification", "message": "Twirling and laughter under the fairy lights!" }
]
}
},
{
"id": "winter_solstice",
"ageRange": { "min": 6, "max": 17 },
"category": "community",
"icon": "🕯️",
"title": "Winter Solstice",
"description": "The family gathers to celebrate the shortest day with warm food and stories.",
"weight": 1.0,
"seasons": ["winter"],
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "Candles, warmth, and the promise of spring to come." }
]
}
}
],
"coming_of_age_event": {
"id": "coming_of_age",
"age": 18,
"icon": "🎉",
"title": "Coming of Age!",
"description": "You are now a full adult with access to everything the world offers — jobs, dungeons, PVP, leadership roles, and more!",
"permanent_effects": {
"sanity": 30,
"health": 15,
"gold": 100,
"experience": 25,
"skillPoint": 2,
"notification": "You step into adulthood with promise and adventure ahead!"
},
"unlocks": [
"Full Adult Status",
"All Jobs",
"All Activities",
"PVP Arena",
"Leadership Roles"
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
// Shared REST handler for the Lineage REST shim.
//
// Each route file calls `rest()` with a function that receives a typed tRPC
// caller (scoped to `caller.lineage.*`) and the SolidStart APIEvent. The
// handler maps `TRPCError` codes → HTTP status + `{ message }` body (the
// legacy client parses `result.message` on non-OK responses), and returns
// the procedure's return value as JSON on success.
import type { APIEvent } from "@solidjs/start/server";
import { TRPCError } from "@trpc/server";
import { createServerCaller } from "~/server/api/root";
const codeToStatus: Record<string, number> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
TIMEOUT: 408,
PAYLOAD_TOO_LARGE: 413,
METHOD_NOT_SUPPORTED: 405,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500
};
type Caller = Awaited<ReturnType<typeof createServerCaller>>;
export async function rest(
fn: (caller: Caller, event: APIEvent) => Promise<unknown>,
event: APIEvent
): Promise<Response> {
try {
const caller = await createServerCaller(event);
const result = await fn(caller, event);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "content-type": "application/json" }
});
} catch (e) {
if (e instanceof TRPCError) {
const status = codeToStatus[e.code] ?? 500;
return new Response(JSON.stringify({ message: e.message }), {
status,
headers: { "content-type": "application/json" }
});
}
console.error("Lineage REST shim error:", e);
return new Response(JSON.stringify({ message: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" }
});
}
}
/** Extract the Bearer token from the Authorization header. */
export function bearerToken(event: APIEvent): string | null {
const auth = event.request.headers.get("authorization") ?? "";
const m = auth.match(/^Bearer\s+(.+)$/i);
return m?.[1]?.trim() ?? null;
}
/** Parse the JSON request body. */
export async function jsonBody<T = any>(event: APIEvent): Promise<T> {
return await event.request.json();
}

View File

@@ -0,0 +1,8 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "./_lib";
export const POST = (event: APIEvent) =>
rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.misc.analytics(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.appleGetEmail(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.appleRegistration(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.databaseCreds(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.deletionCancel(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.deletionCheck(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.deletionInit(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.emailLogin(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest, bearerToken } from "../../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
const token = bearerToken(event);
return caller.lineage.auth.refreshToken({ token: token ?? "" });
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.refreshVerification(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.emailRegistration(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.emailVerification(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.googleRegistration(input);
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.attacks();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.conditions();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.dungeons();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.enemies();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.items();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.misc();
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) =>
rest(async (caller) => {
return caller.lineage.jsonService.quest_events();
}, event);

View File

@@ -0,0 +1,22 @@
// Plain-text route for the Lineage offline secret.
//
// `misc.offlineSecret` is a tRPC `.query()` returning `{ secret: "…" }`. But
// the Lineage client's `IAPStore.fetchOfflineSecret` does
// `await response.text()` and uses the raw string directly as the decryption
// key. trpc-openapi always JSON-encodes responses, so exposing it via the openapi
// shim would yield `'{"secret":"…"}'` as text and break decryption.
//
// This dedicated route returns the secret verbatim as `text/plain`, matching
// exactly what the legacy endpoint returned. Auth: none (the legacy endpoint
// had none either — the secret is a server-side decryption key shared to all
// clients; it rotates with `LINEAGE_OFFLINE_SERIALIZATION_SECRET`).
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
export const GET = (event: APIEvent) => {
return new Response(env.LINEAGE_OFFLINE_SERIALIZATION_SECRET, {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" }
});
};

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.pvp.battleResult(input);
}, event);

View File

@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
// GET /api/lineage/pvp — retrieve opponents
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.pvp.getOpponents();
}, event);
// POST /api/lineage/pvp — register/update player character
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.pvp.registerCharacter(input);
}, event);

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

@@ -54,7 +54,8 @@ export default function NessaLanding() {
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT); const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT);
const brandColor = () => site().brandColor; const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
return ( return (
<> <>
@@ -106,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>
@@ -342,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>
);
}

View File

@@ -3,6 +3,11 @@ import { auditRouter } from "./routers/audit";
import { analyticsRouter } from "./routers/analytics"; import { analyticsRouter } from "./routers/analytics";
import { databaseRouter } from "./routers/database"; import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage"; import { lineageRouter } from "./routers/lineage";
// Re-exported so the REST shim (src/routes/api/lineage/[...path].ts) can mount
// ONLY the lineage sub-router as the trpc-openapi REST surface — not the
// whole appRouter. Keeps nessa/community/auth/audit routers unreachable via
// `/api/lineage/*`.
export { lineageRouter };
import { miscRouter } from "./routers/misc"; import { miscRouter } from "./routers/misc";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { blogRouter } from "./routers/blog"; import { blogRouter } from "./routers/blog";

View File

@@ -6,7 +6,11 @@ import {
} from "~/server/utils"; } from "~/server/utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure, csrfProtectedProcedure } from "~/server/api/utils"; import {
createTRPCRouter,
publicProcedure,
csrfProtectedProcedure
} from "~/server/api/utils";
import { import {
fetchWithTimeout, fetchWithTimeout,
checkResponse, checkResponse,
@@ -16,8 +20,78 @@ import {
} from "~/server/fetch-utils"; } from "~/server/fetch-utils";
export const lineageDatabaseRouter = createTRPCRouter({ export const lineageDatabaseRouter = createTRPCRouter({
// credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients. // Per-user DB credentials endpoint.
// Database access should be proxied through tRPC server-side procedures. //
// Returns the caller's OWN remote Turso DB name + token so the client can
// open a direct libsql connection (game data syncs across devices). This is
// safe to expose to the authenticated user: the token grants full access only
// to that user's own DB, equivalent to an on-device SQLite that travels
// between devices. The generic p8-008 "exposing persistent DB tokens"
// concern does not apply — there is no cross-user escalation possible.
//
// REST surface: POST /api/lineage/database/creds (auth via Bearer header).
databaseCreds: csrfProtectedProcedure
.input(
z.object({
email: z.string().email(),
provider: z.enum(["email", "apple", "google"])
})
)
.mutation(async ({ input, ctx }) => {
const { email, provider } = input;
// Bearer auth token comes from the Authorization header, exactly as the
// legacy REST endpoint read it. For `email` it's a Lineage JWT; for
// `apple` the apple_user_string; for `google` a Google id token.
const authHeader =
(ctx.event.nativeEvent.node?.req?.headers?.authorization as
| string
| undefined) ??
(ctx.event.request?.headers?.get("authorization") as
| string
| undefined);
const authToken = authHeader?.replace(/^Bearer\s+/i, "").trim();
if (!authToken) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing Authorization header"
});
}
const conn = LineageConnectionFactory();
const res = await conn.execute({
sql: `SELECT * FROM User WHERE email = ? AND provider = ? LIMIT 1`,
args: [email, provider]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
const userRow = res.rows[0];
const valid = await validateLineageRequest({
auth_token: authToken,
userRow
});
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Verification"
});
}
// The legacy endpoint returned `{ db_name, db_token }`; the User row
// stores these as `database_name` / `database_token`.
return {
db_name: userRow.database_name,
db_token: userRow.database_token
};
}),
deletionInit: csrfProtectedProcedure deletionInit: csrfProtectedProcedure
.input( .input(

View File

@@ -51,6 +51,10 @@ import healthOptions from "~/lineage-json/misc-route/healthOptions.json";
import sanityOptions from "~/lineage-json/misc-route/sanityOptions.json"; import sanityOptions from "~/lineage-json/misc-route/sanityOptions.json";
import pvpRewards from "~/lineage-json/misc-route/pvpRewards.json"; import pvpRewards from "~/lineage-json/misc-route/pvpRewards.json";
import questTemplates from "~/lineage-json/quest-event-route/questTemplates.json";
import childhoodEvents from "~/lineage-json/quest-event-route/childhoodEvents.json";
import overworldEvents from "~/lineage-json/quest-event-route/overworldEvents.json";
export const lineageJsonServiceRouter = createTRPCRouter({ export const lineageJsonServiceRouter = createTRPCRouter({
attacks: publicProcedure.query(() => { attacks: publicProcedure.query(() => {
return { return {
@@ -131,5 +135,14 @@ export const lineageJsonServiceRouter = createTRPCRouter({
sanityOptions, sanityOptions,
pvpRewards pvpRewards
}; };
}),
quest_events: publicProcedure.query(() => {
return {
ok: true,
questTemplates,
childhoodEvents,
overworldEvents
};
}) })
}); });

View File

@@ -1,4 +1,9 @@
import { createTRPCRouter, publicProcedure, adminProcedure, csrfProtectedProcedure } from "../../utils"; import {
createTRPCRouter,
publicProcedure,
adminProcedure,
csrfProtectedProcedure
} from "../../utils";
import { z } from "zod"; import { z } from "zod";
import { LineageConnectionFactory } from "~/server/utils"; import { LineageConnectionFactory } from "~/server/utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
@@ -15,7 +20,7 @@ export const lineageMiscRouter = createTRPCRouter({
proficiencies: z.record(z.unknown()), proficiencies: z.record(z.unknown()),
jobs: z.record(z.unknown()), jobs: z.record(z.unknown()),
resistanceTable: z.record(z.unknown()), resistanceTable: z.record(z.unknown()),
damageTable: z.record(z.unknown()), damageTable: z.record(z.unknown())
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -27,7 +32,7 @@ export const lineageMiscRouter = createTRPCRouter({
proficiencies, proficiencies,
jobs, jobs,
resistanceTable, resistanceTable,
damageTable, damageTable
} = input; } = input;
const conn = LineageConnectionFactory(); const conn = LineageConnectionFactory();
@@ -47,8 +52,8 @@ export const lineageMiscRouter = createTRPCRouter({
JSON.stringify(proficiencies), JSON.stringify(proficiencies),
JSON.stringify(jobs), JSON.stringify(jobs),
JSON.stringify(resistanceTable), JSON.stringify(resistanceTable),
JSON.stringify(damageTable), JSON.stringify(damageTable)
], ]
}); });
return { success: true, status: 200 }; return { success: true, status: 200 };
@@ -56,7 +61,7 @@ export const lineageMiscRouter = createTRPCRouter({
console.error("Analytics error:", e); console.error("Analytics error:", e);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to store analytics", message: "Failed to store analytics"
}); });
} }
}), }),
@@ -69,7 +74,7 @@ export const lineageMiscRouter = createTRPCRouter({
if (!token) { if (!token) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Missing token in body", message: "Missing token in body"
}); });
} }
@@ -80,11 +85,17 @@ export const lineageMiscRouter = createTRPCRouter({
if (res.rows.length > 0) { if (res.rows.length > 0) {
const queryUpdate = const queryUpdate =
"UPDATE Token SET last_updated_at = datetime('now') WHERE token = ?"; "UPDATE Token SET last_updated_at = datetime('now') WHERE token = ?";
const resUpdate = await conn.execute({ sql: queryUpdate, args: [token] }); const resUpdate = await conn.execute({
sql: queryUpdate,
args: [token]
});
return { success: true, action: "updated", result: resUpdate }; return { success: true, action: "updated", result: resUpdate };
} else { } else {
const queryInsert = "INSERT INTO Token (token) VALUES (?)"; const queryInsert = "INSERT INTO Token (token) VALUES (?)";
const resInsert = await conn.execute({ sql: queryInsert, args: [token] }); const resInsert = await conn.execute({
sql: queryInsert,
args: [token]
});
return { success: true, action: "inserted", result: resInsert }; return { success: true, action: "inserted", result: resInsert };
} }
}), }),
@@ -99,7 +110,7 @@ export const lineageMiscRouter = createTRPCRouter({
try { try {
const res = await conn.execute({ const res = await conn.execute({
sql: `SELECT * FROM Analytics`, sql: `SELECT * FROM Analytics`,
args: [], args: []
}); });
const rows = res.rows.map((row: any) => ({ const rows = res.rows.map((row: any) => ({
@@ -110,7 +121,7 @@ export const lineageMiscRouter = createTRPCRouter({
proficiencies: safeJsonParse(row.proficiencies), proficiencies: safeJsonParse(row.proficiencies),
jobs: safeJsonParse(row.jobs), jobs: safeJsonParse(row.jobs),
resistanceTable: safeJsonParse(row.resistanceTable), resistanceTable: safeJsonParse(row.resistanceTable),
damageTable: safeJsonParse(row.damageTable), damageTable: safeJsonParse(row.damageTable)
})); }));
return { success: true, players: rows }; return { success: true, players: rows };
@@ -118,7 +129,7 @@ export const lineageMiscRouter = createTRPCRouter({
console.error("Failed to fetch lineage analytics:", e); console.error("Failed to fetch lineage analytics:", e);
return { success: false, players: [] }; return { success: false, players: [] };
} }
}), })
}); });
function safeJsonParse(val: unknown): unknown { function safeJsonParse(val: unknown): unknown {

View File

@@ -1,4 +1,8 @@
import { createTRPCRouter, publicProcedure, csrfProtectedProcedure } from "../../utils"; import {
createTRPCRouter,
publicProcedure,
csrfProtectedProcedure
} from "../../utils";
import { z } from "zod"; import { z } from "zod";
import { LineageConnectionFactory } from "~/server/utils"; import { LineageConnectionFactory } from "~/server/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -17,7 +21,7 @@ const characterSchema = z.object({
resistanceTable: z.string(), resistanceTable: z.string(),
damageTable: z.string(), damageTable: z.string(),
attackStrings: z.string(), attackStrings: z.string(),
knownSpells: z.string(), knownSpells: z.string()
}); });
export const lineagePvpRouter = createTRPCRouter({ export const lineagePvpRouter = createTRPCRouter({
@@ -27,7 +31,7 @@ export const lineagePvpRouter = createTRPCRouter({
character: characterSchema, character: characterSchema,
linkID: z.string(), linkID: z.string(),
pushToken: z.string().optional(), pushToken: z.string().optional(),
pushCurrentlyEnabled: z.boolean().optional(), pushCurrentlyEnabled: z.boolean().optional()
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -37,7 +41,7 @@ export const lineagePvpRouter = createTRPCRouter({
const conn = LineageConnectionFactory(); const conn = LineageConnectionFactory();
const res = await conn.execute({ const res = await conn.execute({
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`, sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
args: [linkID], args: [linkID]
}); });
if (res.rows.length === 0) { if (res.rows.length === 0) {
@@ -78,8 +82,8 @@ export const lineagePvpRouter = createTRPCRouter({
character.attackStrings, character.attackStrings,
character.knownSpells, character.knownSpells,
pushToken, pushToken,
pushCurrentlyEnabled, pushCurrentlyEnabled
], ]
}); });
return { return {
@@ -87,7 +91,7 @@ export const lineagePvpRouter = createTRPCRouter({
winCount: 0, winCount: 0,
lossCount: 0, lossCount: 0,
tokenRedemptionCount: 0, tokenRedemptionCount: 0,
status: 201, status: 201
}; };
} else { } else {
await conn.execute({ await conn.execute({
@@ -126,8 +130,8 @@ export const lineagePvpRouter = createTRPCRouter({
character.knownSpells, character.knownSpells,
pushToken, pushToken,
pushCurrentlyEnabled, pushCurrentlyEnabled,
linkID, linkID
], ]
}); });
return { return {
@@ -135,19 +139,20 @@ export const lineagePvpRouter = createTRPCRouter({
winCount: res.rows[0].winCount as number, winCount: res.rows[0].winCount as number,
lossCount: res.rows[0].lossCount as number, lossCount: res.rows[0].lossCount as number,
tokenRedemptionCount: res.rows[0].tokenRedemptionCount as number, tokenRedemptionCount: res.rows[0].tokenRedemptionCount as number,
status: 200, status: 200
}; };
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to register character", message: "Failed to register character"
}); });
} }
}), }),
getOpponents: publicProcedure.query(async () => { getOpponents: publicProcedure
.query(async () => {
const conn = LineageConnectionFactory(); const conn = LineageConnectionFactory();
try { try {
@@ -179,13 +184,13 @@ export const lineagePvpRouter = createTRPCRouter({
return { return {
ok: true, ok: true,
characters: res.rows, characters: res.rows,
status: 200, status: 200
}; };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to get opponents", message: "Failed to get opponents"
}); });
} }
}), }),
@@ -194,7 +199,7 @@ export const lineagePvpRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
winnerLinkID: z.string(), winnerLinkID: z.string(),
loserLinkID: z.string(), loserLinkID: z.string()
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -211,19 +216,19 @@ export const lineagePvpRouter = createTRPCRouter({
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
WHERE linkID IN (?, ?) WHERE linkID IN (?, ?)
`, `,
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID], args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
}); });
return { return {
ok: true, ok: true,
status: 200, status: 200
}; };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to record battle result", message: "Failed to record battle result"
}); });
} }
}), })
}); });

View File

@@ -181,4 +181,3 @@ const csrfProtection = t.middleware(async ({ ctx, next }) => {
// CSRF-protected procedure // CSRF-protected procedure
export const csrfProtectedProcedure = t.procedure.use(csrfProtection); export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
export { csrfProtection }; export { csrfProtection };