Compare commits

..

2 Commits

Author SHA1 Message Date
641d3ed0ed fix: prop sig fixed 2026-07-24 22:20:23 -04:00
b6b2bc8ef6 fix: attempt to fix subdomain capture 2026-07-24 22:11:47 -04:00
193 changed files with 2909 additions and 6809 deletions

View File

@@ -75,11 +75,3 @@ GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / g
# 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
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/ # or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>" SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
# ── The Nook licensing ──
NOOK_DB_URL="libsql://<nook-db>.turso.io"
NOOK_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts)
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"

4
.gitignore vendored
View File

@@ -31,7 +31,3 @@ perf-results-*.json
# System Files # System Files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# pygienium run-state and check artifacts
.pygienium/
scripts/
.cache

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/...`) 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.) - **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.
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host. - **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters). - **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
- **Auth:** Host-scoped only — no cookie domain broadening. - **Auth:** Host-scoped only — no cookie domain broadening.

View File

@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin"; import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
export default defineConfig({ export default defineConfig({
middleware: "./src/middleware.ts",
vite: { vite: {
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
@@ -10,7 +11,21 @@ export default defineConfig({
org: "mikefreno", org: "mikefreno",
project: "freno-dev", project: "freno-dev",
authToken: process.env.SENTRY_AUTH_TOKEN, authToken: process.env.SENTRY_AUTH_TOKEN,
telemetry: false telemetry: false,
sourcemaps: {
assets: [
{
type: "bundle",
path: "dist/client/assets/",
urlPrefix: "~/assets/"
},
{
type: "sourcemap",
path: "dist/client/assets/",
urlPrefix: "~/assets/"
}
]
}
}) })
], ],
build: { build: {
@@ -38,6 +53,6 @@ export default defineConfig({
} }
}, },
server: { server: {
preset: "vercel" preset: "node-server"
} }
}); });

BIN
bun.lockb

Binary file not shown.

View File

@@ -5,20 +5,21 @@
"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/",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
"perf": "bun run scripts/perf-test.ts", "perf": "bun run scripts/perf-test.ts",
"perf:compare": "bun run scripts/perf-compare.ts", "perf:compare": "bun run scripts/perf-compare.ts"
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/client-s3": "^3.953.0",
"@aws-sdk/s3-request-presigner": "^3.953.0", "@aws-sdk/s3-request-presigner": "^3.953.0",
"@clerk/backend": "^3.12.0", "@clerk/backend": "^3.12.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@motionone/solid": "^10.16.4",
"@sentry/solidstart": "^10.67.0", "@sentry/solidstart": "^10.67.0",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
@@ -26,11 +27,13 @@
"@tailwindcss/vite": "^4.0.7", "@tailwindcss/vite": "^4.0.7",
"@tiptap/core": "^3.14.0", "@tiptap/core": "^3.14.0",
"@tiptap/extension-code-block-lowlight": "^3.14.0", "@tiptap/extension-code-block-lowlight": "^3.14.0",
"@tiptap/extension-color": "^3.14.0",
"@tiptap/extension-details": "^3.14.0", "@tiptap/extension-details": "^3.14.0",
"@tiptap/extension-details-content": "^2.26.2", "@tiptap/extension-details-content": "^2.26.2",
"@tiptap/extension-details-summary": "^2.26.2", "@tiptap/extension-details-summary": "^2.26.2",
"@tiptap/extension-image": "^3.14.0", "@tiptap/extension-image": "^3.14.0",
"@tiptap/extension-link": "^3.14.0", "@tiptap/extension-link": "^3.14.0",
"@tiptap/extension-list-item": "^3.14.0",
"@tiptap/extension-subscript": "^3.14.0", "@tiptap/extension-subscript": "^3.14.0",
"@tiptap/extension-superscript": "^3.14.0", "@tiptap/extension-superscript": "^3.14.0",
"@tiptap/extension-table": "^3.14.0", "@tiptap/extension-table": "^3.14.0",
@@ -40,11 +43,13 @@
"@tiptap/extension-task-item": "^3.14.0", "@tiptap/extension-task-item": "^3.14.0",
"@tiptap/extension-task-list": "^3.14.0", "@tiptap/extension-task-list": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0", "@tiptap/extension-text-align": "^3.14.0",
"@tiptap/extension-text-style": "^3.14.0",
"@tiptap/pm": "^3.14.0", "@tiptap/pm": "^3.14.0",
"@tiptap/starter-kit": "^3.14.0", "@tiptap/starter-kit": "^3.14.0",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"@tursodatabase/api": "^1.9.2", "@tursodatabase/api": "^1.9.2",
"@typeschema/valibot": "^0.13.4",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"fast-diff": "^1.3.0", "fast-diff": "^1.3.0",
@@ -65,11 +70,18 @@
"node": "24.x" "node": "24.x"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0",
"@sentry/vite-plugin": "^5.4.0", "@sentry/vite-plugin": "^5.4.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/fast-diff": "^1.2.2",
"chrome-launcher": "^1.2.1",
"lighthouse": "^13.0.1",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2" "prettier-plugin-tailwindcss": "^0.7.2",
"rollup-plugin-visualizer": "^6.0.5",
"trpc-panel": "^1.3.4",
"vite-bundle-visualizer": "^1.2.1"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 MiB

View File

@@ -1,21 +0,0 @@
{
"name": "Gaze",
"short_name": "Gaze",
"icons": [
{
"src": "/gaze/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/gaze/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#002cff",
"background_color": "#002cff",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.0 MiB

View File

@@ -1,21 +0,0 @@
{
"name": "InputHalo",
"short_name": "InputHalo",
"icons": [
{
"src": "/inputhalo/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/inputhalo/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#41a5ff",
"background_color": "#41a5ff",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 860 KiB

View File

@@ -1,21 +0,0 @@
{
"name": "Life and Lineage",
"short_name": "Lineage",
"icons": [
{
"src": "/lineage/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/lineage/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#a13536",
"background_color": "#a13536",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,21 +0,0 @@
{
"name": "The Nook",
"short_name": "The Nook",
"icons": [
{
"src": "/nook/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/nook/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#4C9FBC",
"background_color": "#4C9FBC",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

View File

@@ -1,21 +1,9 @@
User-agent: * User-agent: *
Allow: / Allow: /
Allow: /blog Allow: /blog
Allow: /projects
# Private / dynamic paths — not for indexing.
# Keeps crawlers off auth, admin, and API pages so they don't burn
# uncached SSR function invocations.
Disallow: /account
Disallow: /analytics
Disallow: /api/
Disallow: /blog/create
Disallow: /blog/edit
Disallow: /checkout
Disallow: /databaseMGMT
Disallow: /debug/
Disallow: /error-test
Disallow: /login Disallow: /login
Disallow: /success Disallow: /debug/
Disallow: /test Disallow: /databaseMGMT
Sitemap: https://www.freno.me/sitemap.xml Sitemap: https://www.freno.me/sitemap.xml

View File

@@ -1,45 +0,0 @@
/**
* One-shot The Nook license Ed25519 keypair generator.
*
* Run with: bun scripts/generate-license-keys.ts
*
* Prints:
* - The Ed25519 PRIVATE key as base64 PKCS8 DER → NOOK_LICENSE_PRIVATE_KEY
* (freno-dev env).
* - The raw 32-byte Ed25519 public key (the X coordinate) as base64 SPKI DER
* suffix → compiled into the Swift `LicenseVerifier.PublicKeyConstant`.
*
* The private key lives ONLY in env — never in git.
* Paste the public key into Sources/NookCore/Licensing/LicenseVerifier.swift
* (step 4 of the distribution plan) after running this once.
*/
import { generateKeyPairSync, createPrivateKey } from "node:crypto";
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
// Raw 32-byte X coordinate: tail of the SPKI DER public key.
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
const rawPublic = publicKeySpki.subarray(-32);
console.log("── The Nook license keypair ──────────────────────────────");
console.log("NOOK_LICENSE_PRIVATE_KEY (base64 PKCS8 DER):");
console.log(privateKeyDer.toString("base64"));
console.log("");
console.log("Private key PEM (reference, for license signing only):");
console.log(privateKeyPem);
console.log("");
console.log("LicenseVerifier public key base64 (raw 32-byte X, step 4):");
console.log(rawPublic.toString("base64"));
console.log("──────────────────────────────────────────────────────────");
// Sanity check: sign + verify round trip with the exported artifacts.
const publicKeyFromPem = createPrivateKey(privateKeyPem)
.export({ format: "der", type: "pkcs8" });
if (Buffer.compare(Buffer.from(privateKeyDer), Buffer.from(publicKeyFromPem)) !== 0) {
console.error("Keypair export sanity check failed.");
process.exit(1);
}
console.log("Sanity check passed.");

View File

@@ -1,28 +0,0 @@
import { nookSchemaBootstrap, grantLicense } from "~/server/nook";
// Mint a free The Nook license (gifting / comps). Defaults to 1 device.
//
// bun --env-file=.env scripts/grant-nook-license.ts \
// --email friend@example.com [--devices 1]
const arg = (name: string) => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? undefined : process.argv[i + 1];
};
const email = arg("email");
const devices = Number(arg("devices") ?? "1");
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
console.error(
"Usage: bun --env-file=.env scripts/grant-nook-license.ts --email you@example.com [--devices 1]"
);
process.exit(1);
}
await nookSchemaBootstrap;
const { key } = await grantLicense(email, devices);
console.log(`Granted The Nook license for ${email} (${devices} device(s)):`);
console.log(key);
console.log("Send it to them; they enter it in Settings > License.");

View File

@@ -96,6 +96,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
"───────────────────────────────────────────────────────────────────\n" "───────────────────────────────────────────────────────────────────\n"
); );
// Compare each page
for (const baseResult of baseline.results) { for (const baseResult of baseline.results) {
const optResult = optimized.results.find((r) => r.page === baseResult.page); const optResult = optimized.results.find((r) => r.page === baseResult.page);
if (!optResult) continue; if (!optResult) continue;
@@ -106,6 +107,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
console.log(`\n📄 ${baseResult.page}`); console.log(`\n📄 ${baseResult.page}`);
console.log("─".repeat(70)); console.log("─".repeat(70));
// Core Web Vitals
console.log("\n Core Web Vitals:"); console.log("\n Core Web Vitals:");
const fcpDiff = opt.fcp - base.fcp; const fcpDiff = opt.fcp - base.fcp;
@@ -119,6 +121,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})` ` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
); );
// Loading Metrics
console.log("\n Loading Metrics:"); console.log("\n Loading Metrics:");
const ttfbDiff = opt.ttfb - base.ttfb; const ttfbDiff = opt.ttfb - base.ttfb;
@@ -145,6 +148,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}` ` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
); );
// Resource Loading
console.log("\n Resources:"); console.log("\n Resources:");
const reqDiff = opt.totalRequests - base.totalRequests; const reqDiff = opt.totalRequests - base.totalRequests;
@@ -181,6 +185,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
); );
} }
// Overall Summary
console.log( console.log(
"\n\n═══════════════════════════════════════════════════════════════════" "\n\n═══════════════════════════════════════════════════════════════════"
); );
@@ -333,6 +338,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
); );
} }
// Specific findings
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests); const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
if (reqPercent < -5) { if (reqPercent < -5) {
console.log( console.log(

View File

@@ -61,6 +61,7 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10); const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
const WARMUP_RUNS = 1; const WARMUP_RUNS = 1;
// Pages to test
const TEST_PAGES: PageTestConfig[] = [ const TEST_PAGES: PageTestConfig[] = [
{ name: "Home", path: "/" }, { name: "Home", path: "/" },
{ name: "Blog Index", path: "/blog" }, { name: "Blog Index", path: "/blog" },
@@ -77,6 +78,7 @@ const TEST_PAGES: PageTestConfig[] = [
{ name: "404", path: "/404" } { name: "404", path: "/404" }
]; ];
// Add additional blog post path if provided
if (process.env.TEST_BLOG_POST) { if (process.env.TEST_BLOG_POST) {
TEST_PAGES.push({ TEST_PAGES.push({
name: "Custom Blog Post", name: "Custom Blog Post",
@@ -100,6 +102,7 @@ async function setupPerformanceObservers(page: Page) {
interactions: [] as number[] interactions: [] as number[]
}; };
// Observe LCP
if ("PerformanceObserver" in window) { if ("PerformanceObserver" in window) {
try { try {
const lcpObserver = new PerformanceObserver((entryList) => { const lcpObserver = new PerformanceObserver((entryList) => {
@@ -115,8 +118,10 @@ async function setupPerformanceObservers(page: Page) {
buffered: true buffered: true
}); });
} catch (e) { } catch (e) {
// LCP not supported
} }
// Observe CLS
try { try {
const clsObserver = new PerformanceObserver((entryList) => { const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) { for (const entry of entryList.getEntries()) {
@@ -133,8 +138,10 @@ async function setupPerformanceObservers(page: Page) {
}); });
clsObserver.observe({ type: "layout-shift", buffered: true }); clsObserver.observe({ type: "layout-shift", buffered: true });
} catch (e) { } catch (e) {
// CLS not supported
} }
// Observe FID (first input)
try { try {
const fidObserver = new PerformanceObserver((entryList) => { const fidObserver = new PerformanceObserver((entryList) => {
const firstInput = entryList.getEntries()[0] as any; const firstInput = entryList.getEntries()[0] as any;
@@ -147,8 +154,10 @@ async function setupPerformanceObservers(page: Page) {
}); });
fidObserver.observe({ type: "first-input", buffered: true }); fidObserver.observe({ type: "first-input", buffered: true });
} catch (e) { } catch (e) {
// FID not supported
} }
// Observe long tasks
try { try {
const longTaskObserver = new PerformanceObserver((entryList) => { const longTaskObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) { for (const entry of entryList.getEntries()) {
@@ -157,8 +166,10 @@ async function setupPerformanceObservers(page: Page) {
}); });
longTaskObserver.observe({ type: "longtask", buffered: true }); longTaskObserver.observe({ type: "longtask", buffered: true });
} catch (e) { } catch (e) {
// Long tasks not supported
} }
// Observe INP (event timing for interactions)
try { try {
const inpObserver = new PerformanceObserver((entryList) => { const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) { for (const entry of entryList.getEntries()) {
@@ -183,6 +194,7 @@ async function setupPerformanceObservers(page: Page) {
}); });
inpObserver.observe({ type: "event", buffered: true }); inpObserver.observe({ type: "event", buffered: true });
} catch (e) { } catch (e) {
// Event timing not supported
} }
} }
}); });
@@ -191,14 +203,18 @@ async function setupPerformanceObservers(page: Page) {
async function collectPerformanceMetrics( async function collectPerformanceMetrics(
page: Page page: Page
): Promise<PerformanceMetrics> { ): Promise<PerformanceMetrics> {
// Wait for page to be loaded
await page.waitForLoadState("load"); await page.waitForLoadState("load");
// Wait a bit longer for LCP to settle (it can change as content loads)
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
// Additional wait for any remaining network activity
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
// Ignore timeout - networkidle may never happen for some pages // Ignore timeout - networkidle may never happen for some pages
}); });
// Collect comprehensive performance metrics
const metrics = await page.evaluate(() => { const metrics = await page.evaluate(() => {
const perf = performance.getEntriesByType( const perf = performance.getEntriesByType(
"navigation" "navigation"
@@ -206,6 +222,7 @@ async function collectPerformanceMetrics(
const paint = performance.getEntriesByType("paint"); const paint = performance.getEntriesByType("paint");
const fcp = paint.find((entry) => entry.name === "first-contentful-paint"); const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
// Get metrics from our observers
const observedMetrics = (window as any).__perfMetrics || { const observedMetrics = (window as any).__perfMetrics || {
lcp: 0, lcp: 0,
cls: 0, cls: 0,
@@ -215,6 +232,7 @@ async function collectPerformanceMetrics(
interactions: [] interactions: []
}; };
// Fallback to direct API if observers didn't capture anything
let lcp = observedMetrics.lcp; let lcp = observedMetrics.lcp;
let cls = observedMetrics.cls; let cls = observedMetrics.cls;
let fid = observedMetrics.fid; let fid = observedMetrics.fid;
@@ -240,6 +258,7 @@ async function collectPerformanceMetrics(
.reduce((sum: number, entry: any) => sum + entry.value, 0); .reduce((sum: number, entry: any) => sum + entry.value, 0);
} }
// Calculate INP from event timing entries if not already captured
if (inp === 0) { if (inp === 0) {
const eventEntries = performance.getEntriesByType("event") as any[]; const eventEntries = performance.getEntriesByType("event") as any[];
const interactionLatencies = eventEntries const interactionLatencies = eventEntries
@@ -256,6 +275,7 @@ async function collectPerformanceMetrics(
} }
} }
// Get resource timing
const resources = performance.getEntriesByType( const resources = performance.getEntriesByType(
"resource" "resource"
) as PerformanceResourceTiming[]; ) as PerformanceResourceTiming[];
@@ -298,6 +318,7 @@ async function collectPerformanceMetrics(
} }
}); });
// Calculate long task duration
let taskDuration = 0; let taskDuration = 0;
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) { if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
taskDuration = observedMetrics.longTasks.reduce( taskDuration = observedMetrics.longTasks.reduce(
@@ -306,6 +327,7 @@ async function collectPerformanceMetrics(
); );
} }
// Get more granular performance entries
let jsExecutionTime = 0; let jsExecutionTime = 0;
let layoutDuration = 0; let layoutDuration = 0;
let paintDuration = 0; let paintDuration = 0;
@@ -317,6 +339,7 @@ async function collectPerformanceMetrics(
} }
}); });
// Check for script evaluation entries
const entries = performance.getEntries(); const entries = performance.getEntries();
entries.forEach((entry: any) => { entries.forEach((entry: any) => {
if (entry.entryType === "measure") { if (entry.entryType === "measure") {
@@ -376,6 +399,7 @@ async function testPagePerformance(
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n` ` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
); );
// Warmup runs (not counted)
for (let i = 0; i < WARMUP_RUNS; i++) { for (let i = 0; i < WARMUP_RUNS; i++) {
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
@@ -386,16 +410,20 @@ async function testPagePerformance(
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`); console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
} }
// Measured runs
for (let i = 0; i < RUNS_PER_PAGE; i++) { for (let i = 0; i < RUNS_PER_PAGE; i++) {
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`); console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
// Create new context for each run to ensure clean state
const context = await browser.newContext({ const context = await browser.newContext({
viewport: { width: 1920, height: 1080 } viewport: { width: 1920, height: 1080 }
}); });
const page = await context.newPage(); const page = await context.newPage();
// Setup performance observers before navigation
await setupPerformanceObservers(page); await setupPerformanceObservers(page);
// Navigate and collect metrics
await page.goto(url, { waitUntil: "load" }); await page.goto(url, { waitUntil: "load" });
const metrics = await collectPerformanceMetrics(page); const metrics = await collectPerformanceMetrics(page);
@@ -408,6 +436,7 @@ async function testPagePerformance(
); );
} }
// Calculate statistics
const average = calculateAverage(runs); const average = calculateAverage(runs);
const median = calculateMedian(runs); const median = calculateMedian(runs);
const p95 = calculatePercentile(runs, 95); const p95 = calculatePercentile(runs, 95);
@@ -619,6 +648,7 @@ function printResults(results: TestResult[]) {
"═══════════════════════════════════════════════════════════════════\n" "═══════════════════════════════════════════════════════════════════\n"
); );
// Overall averages
const overallAverage = { const overallAverage = {
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length, lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length, fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
@@ -666,6 +696,7 @@ function printResults(results: TestResult[]) {
console.log("\n Optimization Opportunities:"); console.log("\n Optimization Opportunities:");
// Find pages with highest JS bytes
const highestJS = [...results].sort( const highestJS = [...results].sort(
(a, b) => b.median.jsBytes - a.median.jsBytes (a, b) => b.median.jsBytes - a.median.jsBytes
)[0]; )[0];
@@ -676,6 +707,7 @@ function printResults(results: TestResult[]) {
); );
} }
// Find pages with slow LCP
const slowLCP = results.filter((r) => r.median.lcp > 2500); const slowLCP = results.filter((r) => r.median.lcp > 2500);
if (slowLCP.length > 0) { if (slowLCP.length > 0) {
console.log( console.log(
@@ -683,6 +715,7 @@ function printResults(results: TestResult[]) {
); );
} }
// Find pages with high CLS
const highCLS = results.filter((r) => r.median.cls > 0.1); const highCLS = results.filter((r) => r.median.cls > 0.1);
if (highCLS.length > 0) { if (highCLS.length > 0) {
console.log( console.log(
@@ -690,6 +723,7 @@ function printResults(results: TestResult[]) {
); );
} }
// Find pages with high INP
const highINP = results.filter((r) => r.median.inp > 200); const highINP = results.filter((r) => r.median.inp > 200);
if (highINP.length > 0) { if (highINP.length > 0) {
console.log( console.log(
@@ -706,6 +740,7 @@ async function main() {
console.log(`Pages to test: ${TEST_PAGES.length}`); console.log(`Pages to test: ${TEST_PAGES.length}`);
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`); console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
// Check if server is running
try { try {
const response = await fetch(BASE_URL); const response = await fetch(BASE_URL);
if (!response.ok) { if (!response.ok) {
@@ -735,8 +770,10 @@ async function main() {
await browser.close(); await browser.close();
// Print results
printResults(results); printResults(results);
// Save results to JSON file
const timestamp = new Date() const timestamp = new Date()
.toISOString() .toISOString()
.replace(/[:.]/g, "-") .replace(/[:.]/g, "-")

View File

@@ -74,11 +74,6 @@
--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) {
@@ -109,11 +104,6 @@
--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;
@@ -173,8 +163,6 @@
--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 {
@@ -204,8 +192,6 @@
--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 {
@@ -574,184 +560,3 @@ a.hover-underline-animation:hover::after {
.shaker:hover { .shaker:hover {
animation: shaker 0.5s ease; animation: shaker 0.5s ease;
} }
/* ── Nook landing: campfire sprite frame cycling ──────────────────── */
/* Each frame layer is stacked; its animation holds opacity 1 during its
slot and 0 otherwise, so the stack reads as stop-motion at the app's
sprite cadence (frame time = total duration / frame count). The phase
lives inside the keyframes and every layer animates delay-free from the
same clock WebKit quantizes animation starts per cycle wrap, so
delay-offset layers can drop a wrap frame and show BOTH transparent
for a frame (the "blank frame" flicker). Complementary holds mean any
moment sums to exactly one opaque layer. Linear timing: steps()
holds mis-swap at boundary frames under WebKit. */
/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */
@keyframes campfire-slot-2-a {
0%,
49.99% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@keyframes campfire-slot-2-b {
0%,
49.99% {
opacity: 0;
}
50%,
100% {
opacity: 1;
}
}
/* 6-frame sprites (question): six slot phases. */
@keyframes campfire-slot-6-a {
0%,
16.66% {
opacity: 1;
}
16.67%,
100% {
opacity: 0;
}
}
@keyframes campfire-slot-6-b {
0%,
16.66% {
opacity: 0;
}
16.67%,
33.32% {
opacity: 1;
}
33.33%,
100% {
opacity: 0;
}
}
@keyframes campfire-slot-6-c {
0%,
33.32% {
opacity: 0;
}
33.33%,
49.99% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@keyframes campfire-slot-6-d {
0%,
49.99% {
opacity: 0;
}
50%,
66.66% {
opacity: 1;
}
66.67%,
100% {
opacity: 0;
}
}
@keyframes campfire-slot-6-e {
0%,
66.66% {
opacity: 0;
}
66.67%,
83.32% {
opacity: 1;
}
83.33%,
100% {
opacity: 0;
}
}
@keyframes campfire-slot-6-f {
0%,
83.32% {
opacity: 0;
}
83.33%,
100% {
opacity: 1;
}
}
.campfire-frame {
animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s)
linear infinite;
opacity: 0;
}
/* ── Nook landing: breakdown section reveal ───────────────────────── */
.reveal {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.in {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
.campfire-frame {
animation: none;
opacity: 1 !important;
}
.campfire-frame + .campfire-frame {
display: none;
}
}
/* ── Nook landing: fan card shake, builds with fan speed ───────── */
.fan-card-shake {
transform-origin: left center;
animation-name: fan-shake;
animation-duration: var(--fan-speed, 0.4s);
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
@keyframes fan-shake {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(calc(var(--fan-amp, 0deg) * -1)); }
}
/* Sputtering sparks off the fan card at max speed */
.fan-spark {
position: absolute;
border-radius: 9999px;
background: #ffd23f;
box-shadow: 0 0 4px 1px rgba(255, 140, 0, 0.9);
animation: fan-spark-fly var(--sd, 0.5s) ease-out var(--sdel, 0s) infinite;
}
@keyframes fan-spark-fly {
0% { transform: translate(0, 0) scale(1); opacity: 0; }
10% { opacity: 1; }
100% { transform: translate(var(--sx, 20px), var(--sy, 30px)) scale(0.4); opacity: 0; }
}
/* Demo pulse: battery bolt */
@keyframes nook-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}

View File

@@ -11,7 +11,6 @@ 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";
@@ -35,8 +34,10 @@ function AppLayout(props: { children: any }) {
let lastScrollY = 0; let lastScrollY = 0;
onMount(() => { onMount(() => {
// Initialize performance tracking
initPerformanceTracking(); initPerformanceTracking();
// Start monitoring for new deployments
startDeploymentMonitoring(); startDeploymentMonitoring();
const windowWidth = createWindowWidth(); const windowWidth = createWindowWidth();
@@ -198,24 +199,21 @@ function AppLayout(props: { children: any }) {
</Show> </Show>
<Show when={!isMainSite()}> <Show when={!isMainSite()}>
<div class="bg-base flex min-h-screen w-full flex-col overflow-x-hidden"> <div class="bg-base min-h-screen w-full overflow-x-hidden">
<noscript> <noscript>
<div class="bg-yellow text-crust border-text fixed top-0 z-150 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} /> )}
)} >
> <Suspense fallback={<TerminalSplash inverse />}>
<Suspense fallback={<TerminalSplash inverse />}> {props.children}
{props.children} </Suspense>
</Suspense> </ErrorBoundary>
</ErrorBoundary>
</div>
<SubdomainFooter />
</div> </div>
</Show> </Show>
</> </>

View File

@@ -366,11 +366,6 @@ function MainRightBarContent() {
Contact Me Contact Me
</a> </a>
</li> </li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/privacy-policy" onClick={handleLinkClick}>
Privacy
</a>
</li>
<li> <li>
<a <a
href="https://github.com/MikeFreno/" href="https://github.com/MikeFreno/"
@@ -465,7 +460,7 @@ function MainRightBarContent() {
); );
} }
function RightBarContent() { export function RightBarContent() {
const site = useSite(); const site = useSite();
return ( return (
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}> <Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>

View File

@@ -144,6 +144,7 @@ const sendContactEmail = action(async (formData: FormData) => {
const { env } = await import("~/env/server"); const { env } = await import("~/env/server");
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken( const turnstileValid = await verifyTurnstileToken(
turnstileToken, turnstileToken,
env.TURNSTILE_SECRET_KEY, env.TURNSTILE_SECRET_KEY,
@@ -251,6 +252,7 @@ export function ContactForm(props: ContactFormProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Load server data using createAsync
const contactData = createAsync(() => getContactData(), { const contactData = createAsync(() => getContactData(), {
deferStream: true deferStream: true
}); });
@@ -348,6 +350,7 @@ export function ContactForm(props: ContactFormProps) {
const message = formData.get("message") as string; const message = formData.get("message") as string;
if (name && email && message) { if (name && email && message) {
// Get fresh Turnstile token
let currentToken = turnstileToken(); let currentToken = turnstileToken();
if ( if (
!currentToken && !currentToken &&
@@ -385,6 +388,7 @@ export function ContactForm(props: ContactFormProps) {
setError(""); setError("");
form.reset(); form.reset();
// Reset Turnstile widget
if (typeof window !== "undefined" && (window as any).turnstile) { if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1"); const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) { if (widgetEl) {

View File

@@ -1,29 +0,0 @@
import { HttpHeader } from "@solidjs/start";
/**
* Renders edge-cache response headers for a page route.
*
* Sets `CDN-Cache-Control` so Vercel serves the rendered HTML from the edge
* (no origin re-render) for `maxAge` seconds, then stale-while-revalidate up
* to `staleSeconds`. `Cache-Control: public, max-age=0` keeps browsers
* revalidating, so visitors always get the latest version — only the CDN
* holds a cached copy. Function/CND-Cache-Control overrides the Vercel
* default, so repeated visits and bot crawls stop re-rendering at origin.
*/
export function EdgeCacheHeaders(props: {
/** Seconds the CDN may serve the response fresh. */
maxAge: number;
/** Seconds the CDN serves stale while revalidating (default: 1 day). */
staleSeconds?: number;
}) {
const stale = props.staleSeconds ?? 86400;
return (
<>
<HttpHeader name="Cache-Control" value="public, max-age=0" />
<HttpHeader
name="CDN-Cache-Control"
value={`public, s-maxage=${props.maxAge}, stale-while-revalidate=${stale}`}
/>
</>
);
}

View File

@@ -60,42 +60,6 @@ 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

@@ -1,23 +0,0 @@
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 flex-col items-center gap-2 text-sm sm:flex-row sm:justify-center">
<A
href={buildMainSiteUrl()}
class="text-text/60 hover:text-text/80 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 underline underline-offset-4 transition-colors sm:absolute sm:right-4"
>
see more products
</A>
</div>
</footer>
);
}

View File

@@ -12,13 +12,13 @@ import { For, Show } from "solid-js";
import { A, useLocation } from "@solidjs/router"; import { A, useLocation } from "@solidjs/router";
import { useSite } from "~/context/SiteContext"; import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
import { NAV_CONFIG } from "~/lib/nav-config"; import { NAV_CONFIG, BACK_TO_FRENO } 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 } = useDarkMode(); const { isDark, toggleDarkMode } = useDarkMode();
const brandName = () => site().displayName; const brandName = () => site().displayName;
const brandColor = () => const brandColor = () =>
@@ -32,7 +32,10 @@ export default function SubdomainHeader() {
}; };
return ( return (
<header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md"> <header
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="/"
@@ -75,7 +78,7 @@ export default function SubdomainHeader() {
</Show> </Show>
)} )}
</For> </For>
<DarkModeToggle shouldScale={false} /> <DarkModeToggle />
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -138,6 +138,9 @@ export function Typewriter(props: {
entries.forEach((entry) => { entries.forEach((entry) => {
// If component leaves viewport while animating, we could pause // If component leaves viewport while animating, we could pause
// For now, we just ensure it starts when visible // For now, we just ensure it starts when visible
if (!entry.isIntersecting && cleanupAnimation) {
// Component is off-screen - could add pause logic here if needed
}
}); });
}, },
{ {

View File

@@ -63,6 +63,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
// Refresh the S3 file list
await loadAttachments(); await loadAttachments();
} }
} catch (err) { } catch (err) {
@@ -80,6 +81,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
body: JSON.stringify({ key }) body: JSON.stringify({ key })
}); });
// Refresh the S3 file list
await loadAttachments(); await loadAttachments();
} catch (err) { } catch (err) {
console.error("Failed to delete file:", err); console.error("Failed to delete file:", err);

View File

@@ -259,6 +259,7 @@ export default function CommentSectionWrapper(
const newComment = async (commentBody: string, parentCommentID?: number) => { const newComment = async (commentBody: string, parentCommentID?: number) => {
setCommentSubmitLoading(true); setCommentSubmitLoading(true);
// Clear any existing timeout
if (commentSubmitTimeoutId) { if (commentSubmitTimeoutId) {
clearTimeout(commentSubmitTimeoutId); clearTimeout(commentSubmitTimeoutId);
} }
@@ -427,6 +428,7 @@ export default function CommentSectionWrapper(
const editComment = async (body: string, comment_id: number) => { const editComment = async (body: string, comment_id: number) => {
setCommentEditLoading(true); setCommentEditLoading(true);
// Clear any existing timeout
if (editCommentTimeoutId) { if (editCommentTimeoutId) {
clearTimeout(editCommentTimeoutId); clearTimeout(editCommentTimeoutId);
} }
@@ -525,6 +527,7 @@ export default function CommentSectionWrapper(
setCommentDeletionLoading(true); setCommentDeletionLoading(true);
// Clear any existing timeout
if (deleteCommentTimeoutId) { if (deleteCommentTimeoutId) {
clearTimeout(deleteCommentTimeoutId); clearTimeout(deleteCommentTimeoutId);
} }
@@ -620,6 +623,7 @@ export default function CommentSectionWrapper(
setOperationError(""); setOperationError("");
if (data.commentBody) { if (data.commentBody) {
// Soft delete (replace body with deletion message)
setAllComments((prev) => setAllComments((prev) =>
prev.map((comment) => { prev.map((comment) => {
if (comment.id === data.commentID) { if (comment.id === data.commentID) {
@@ -648,6 +652,7 @@ export default function CommentSectionWrapper(
}) })
); );
} else { } else {
// Hard delete (remove from list)
setAllComments((prev) => setAllComments((prev) =>
prev.filter((comment) => comment.id !== data.commentID) prev.filter((comment) => comment.id !== data.commentID)
); );
@@ -662,6 +667,7 @@ export default function CommentSectionWrapper(
}, 300); }, 300);
}; };
// Deletion/edit prompt toggle
const toggleModification = ( const toggleModification = (
commentID: number, commentID: number,
commenterID: string, commenterID: string,
@@ -702,6 +708,7 @@ export default function CommentSectionWrapper(
setCommentBodyForModification(""); setCommentBodyForModification("");
}; };
// Reaction handling
const commentReaction = (reactionType: ReactionType, commentID: number) => { const commentReaction = (reactionType: ReactionType, commentID: number) => {
if (!props.currentUserID) { if (!props.currentUserID) {
console.warn("Cannot react to comment: user not authenticated"); console.warn("Cannot react to comment: user not authenticated");
@@ -793,6 +800,7 @@ export default function CommentSectionWrapper(
} }
}; };
// Click outside handlers (SolidJS version)
createEffect(() => { createEffect(() => {
const handleClickOutsideDelete = (e: MouseEvent) => { const handleClickOutsideDelete = (e: MouseEvent) => {
if ( if (

View File

@@ -8,10 +8,12 @@ function sanitizeMermaidSvg(svgString: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "text/html"); const doc = parser.parseFromString(svgString, "text/html");
// Remove dangerous elements
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => { doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
el.remove(); el.remove();
}); });
// Remove event handlers and dangerous attributes from all elements
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => { doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
const attrs = Array.from(el.attributes); const attrs = Array.from(el.attributes);
attrs.forEach((attr) => { attrs.forEach((attr) => {

View File

@@ -11,17 +11,15 @@ function sanitizeHtml(html: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html"); const doc = parser.parseFromString(html, "text/html");
// Remove dangerous elements
doc doc
.querySelectorAll( .querySelectorAll(
"script, iframe, object, embed, form, link, meta, base, svg script" "script, iframe, object, embed, form, link, meta, base, svg script"
) )
.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
// NOTE: attribute-name wildcards (e.g. [on*]) are not valid CSS selectors and doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => {
// 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;
@@ -130,6 +128,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const processVideos = () => { const processVideos = () => {
if (!contentRef) return; if (!contentRef) return;
// Handle direct video elements
const videoElements = contentRef.querySelectorAll("video"); const videoElements = contentRef.querySelectorAll("video");
videoElements.forEach((video) => { videoElements.forEach((video) => {
@@ -137,14 +136,18 @@ export default function PostBodyClient(props: PostBodyClientProps) {
video.setAttribute("playsinline", ""); video.setAttribute("playsinline", "");
video.setAttribute("controls", ""); video.setAttribute("controls", "");
// Remove download attribute if present
video.removeAttribute("download"); video.removeAttribute("download");
// Ensure proper MIME types on source elements
const sources = video.querySelectorAll("source"); const sources = video.querySelectorAll("source");
sources.forEach((source) => { sources.forEach((source) => {
const src = source.getAttribute("src"); const src = source.getAttribute("src");
if (src) { if (src) {
// Remove download attribute from sources
source.removeAttribute("download"); source.removeAttribute("download");
// Set correct type attribute if missing
if (!source.hasAttribute("type")) { if (!source.hasAttribute("type")) {
if (src.endsWith(".mp4")) { if (src.endsWith(".mp4")) {
source.setAttribute("type", "video/mp4"); source.setAttribute("type", "video/mp4");
@@ -157,6 +160,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}); });
// If video has direct src attribute, ensure type is set
const videoSrc = video.getAttribute("src"); const videoSrc = video.getAttribute("src");
if (videoSrc && !video.hasAttribute("type")) { if (videoSrc && !video.hasAttribute("type")) {
if (videoSrc.endsWith(".mp4")) { if (videoSrc.endsWith(".mp4")) {
@@ -169,6 +173,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}); });
// Handle iframes with video sources - replace with proper video tags
const iframes = contentRef.querySelectorAll("iframe"); const iframes = contentRef.querySelectorAll("iframe");
iframes.forEach((iframe) => { iframes.forEach((iframe) => {
const src = iframe.getAttribute("src"); const src = iframe.getAttribute("src");
@@ -179,6 +184,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
src.endsWith(".webm") || src.endsWith(".webm") ||
src.endsWith(".ogg")) src.endsWith(".ogg"))
) { ) {
// Create a proper video element
const video = document.createElement("video"); const video = document.createElement("video");
video.setAttribute("controls", ""); video.setAttribute("controls", "");
video.setAttribute("playsinline", ""); video.setAttribute("playsinline", "");
@@ -186,6 +192,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
video.style.maxWidth = "100%"; video.style.maxWidth = "100%";
video.style.height = "auto"; video.style.height = "auto";
// Set appropriate type based on file extension
let videoType = "video/mp4"; let videoType = "video/mp4";
if (src.endsWith(".mov")) { if (src.endsWith(".mov")) {
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4 videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4
@@ -198,6 +205,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
video.setAttribute("type", videoType); video.setAttribute("type", videoType);
video.src = src; video.src = src;
// Replace the iframe with the video element
const parent = iframe.parentElement; const parent = iframe.parentElement;
if (parent) { if (parent) {
parent.replaceChild(video, iframe); parent.replaceChild(video, iframe);
@@ -205,6 +213,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
} }
}); });
// Also check for any anchor tags wrapping videos that might have download attribute
const videoLinks = contentRef.querySelectorAll("a"); const videoLinks = contentRef.querySelectorAll("a");
videoLinks.forEach((link) => { videoLinks.forEach((link) => {
const hasVideo = link.querySelector("video"); const hasVideo = link.querySelector("video");

View File

@@ -58,6 +58,7 @@ export default function PostForm(props: PostFormProps) {
props.postId props.postId
); );
// Mark initial load as complete after data is loaded (for edit mode)
// Use setTimeout to ensure this runs after all signals are initialized // Use setTimeout to ensure this runs after all signals are initialized
createEffect(() => { createEffect(() => {
if (props.mode === "edit" && props.initialData) { if (props.mode === "edit" && props.initialData) {
@@ -72,10 +73,12 @@ export default function PostForm(props: PostFormProps) {
}, 5000); }, 5000);
}; };
// Helper to ensure post exists (create if needed)
const ensurePostExists = async (): Promise<number> => { const ensurePostExists = async (): Promise<number> => {
const existingId = createdPostId() || props.postId; const existingId = createdPostId() || props.postId;
if (existingId) return existingId; if (existingId) return existingId;
// Create minimal post if it doesn't exist yet
const result = await api.database.createPost.mutate({ const result = await api.database.createPost.mutate({
category: "blog", category: "blog",
title: title().replaceAll(" ", "_") || "Untitled", title: title().replaceAll(" ", "_") || "Untitled",
@@ -92,6 +95,7 @@ export default function PostForm(props: PostFormProps) {
return newId; return newId;
}; };
// Individual autosave functions for each field
const autoSaveTitle = async () => { const autoSaveTitle = async () => {
const currentTitle = title(); const currentTitle = title();
if (!currentTitle || currentTitle === props.initialData?.title) return; if (!currentTitle || currentTitle === props.initialData?.title) return;
@@ -244,6 +248,7 @@ export default function PostForm(props: PostFormProps) {
} }
}; };
// Debounced versions
const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500); const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500); const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500); const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
@@ -251,6 +256,7 @@ export default function PostForm(props: PostFormProps) {
const debouncedAutoSavePublished = debounce(autoSavePublished, 1000); const debouncedAutoSavePublished = debounce(autoSavePublished, 1000);
const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500); const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500);
// Individual effects for each field
createEffect(() => { createEffect(() => {
const titleVal = title(); const titleVal = title();
if (isInitialLoad()) return; if (isInitialLoad()) return;
@@ -399,6 +405,7 @@ export default function PostForm(props: PostFormProps) {
author_id: props.userID author_id: props.userID
}); });
} else { } else {
// Create new post
const result = await api.database.createPost.mutate({ const result = await api.database.createPost.mutate({
category: "blog", category: "blog",
title: title().replaceAll(" ", "_"), title: title().replaceAll(" ", "_"),

View File

@@ -100,7 +100,7 @@ export default function PostSorting(props: PostSortingProps) {
case "newest": case "newest":
break; // Posts already come newest first from DB (DESC order) break; // Posts already come newest first from DB (DESC order)
case "oldest": case "oldest":
sorted.reverse(); sorted.reverse(); // Reverse to get oldest first
break; break;
case "most_liked": case "most_liked":
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0)); sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));

View File

@@ -1547,6 +1547,7 @@ export default function TextEditor(props: TextEditorProps) {
}, },
handleDOMEvents: { handleDOMEvents: {
touchstart: (view, event) => { touchstart: (view, event) => {
// Only handle touch events on mobile in fullscreen with active suggestion
if ( if (
!hasSuggestion() || !hasSuggestion() ||
!isFullscreen() || !isFullscreen() ||
@@ -1561,6 +1562,7 @@ export default function TextEditor(props: TextEditorProps) {
return false; return false;
}, },
touchend: (view, event) => { touchend: (view, event) => {
// Only handle touch events on mobile in fullscreen with active suggestion
if ( if (
!hasSuggestion() || !hasSuggestion() ||
!isFullscreen() || !isFullscreen() ||
@@ -1858,6 +1860,7 @@ export default function TextEditor(props: TextEditorProps) {
const node = allSuperscriptNodes[i]; const node = allSuperscriptNodes[i];
const text = node.text; const text = node.text;
// Check if this is a complete reference (with optional whitespace)
const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/); const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/);
if (completeMatch) { if (completeMatch) {
const hasOtherMarks = node.marks.some( const hasOtherMarks = node.marks.some(
@@ -1874,6 +1877,7 @@ export default function TextEditor(props: TextEditorProps) {
continue; continue;
} }
// Check if this might be the start of a split reference
if (text === "[" && i + 2 < allSuperscriptNodes.length) { if (text === "[" && i + 2 < allSuperscriptNodes.length) {
const nextNode = allSuperscriptNodes[i + 1]; const nextNode = allSuperscriptNodes[i + 1];
const afterNode = allSuperscriptNodes[i + 2]; const afterNode = allSuperscriptNodes[i + 2];
@@ -1954,6 +1958,7 @@ export default function TextEditor(props: TextEditorProps) {
allRefs.sort((a, b) => a.pos - b.pos); allRefs.sort((a, b) => a.pos - b.pos);
// Check if renumbering is needed (if any ref doesn't match its expected number)
let needsRenumbering = false; let needsRenumbering = false;
for (let i = 0; i < allRefs.length; i++) { for (let i = 0; i < allRefs.length; i++) {
if (allRefs[i].refNum !== i + 1) { if (allRefs[i].refNum !== i + 1) {

View File

@@ -69,6 +69,7 @@ export const Mermaid = Node.create({
getAttrs: (element) => { getAttrs: (element) => {
if (typeof element === "string") return false; if (typeof element === "string") return false;
// Skip if already has data-type or data-mermaid-diagram attribute
if ( if (
element.hasAttribute("data-type") || element.hasAttribute("data-type") ||
element.hasAttribute("data-mermaid-diagram") element.hasAttribute("data-mermaid-diagram")
@@ -82,6 +83,7 @@ export const Mermaid = Node.create({
const content = code.textContent || ""; const content = code.textContent || "";
const trimmedContent = content.trim(); const trimmedContent = content.trim();
// Check if this looks like a mermaid diagram
const mermaidKeywords = [ const mermaidKeywords = [
"graph ", "graph ",
"sequenceDiagram", "sequenceDiagram",
@@ -172,10 +174,12 @@ export const Mermaid = Node.create({
code.textContent = node.attrs.content || ""; code.textContent = node.attrs.content || "";
pre.appendChild(code); pre.appendChild(code);
// Validation status indicator
const statusIndicator = document.createElement("div"); const statusIndicator = document.createElement("div");
statusIndicator.className = statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200"; "absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
// Validate syntax asynchronously
const validateSyntax = async () => { const validateSyntax = async () => {
const content = node.attrs.content || ""; const content = node.attrs.content || "";
if (!content.trim()) { if (!content.trim()) {
@@ -246,6 +250,7 @@ export const Mermaid = Node.create({
(p: any) => p.spec?.key === "mermaidSelection" (p: any) => p.spec?.key === "mermaidSelection"
); );
// Use intersection observer to trigger update when visible
let updateInterval: ReturnType<typeof setInterval> | null = null; let updateInterval: ReturnType<typeof setInterval> | null = null;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {

View File

@@ -0,0 +1,29 @@
const BackArrow = (props: {
height: number;
width: number;
stroke: string;
strokeWidth: number;
class?: string;
}) => {
return (
<div class={props.class}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={props.strokeWidth}
stroke={props.stroke}
height={props.height}
width={props.width}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
</div>
);
};
export default BackArrow;

View File

@@ -0,0 +1,39 @@
function MenuBars() {
return (
<svg
width="36"
height="30"
viewBox="0 0 120 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Mask group">
<g id="Frame 1">
<rect width="120" height="100" />
<line
id="LineA"
x1="11.5"
y1="31.5"
x2="108.5"
y2="31.5"
strokeWidth="6"
strokeLinecap="round"
class="stroke-black dark:stroke-white"
/>
<line
id="LineB"
x1="11.5"
y1="64.5"
x2="108.5"
y2="64.5"
strokeWidth="6"
strokeLinecap="round"
class="stroke-black dark:stroke-white"
/>
</g>
</g>
</svg>
);
}
export default MenuBars;

View File

@@ -1,367 +0,0 @@
import { For, type JSX } from "solid-js";
/**
* Pixel-art campfire sprite, faithful to the app's CampfireFrames: a 14×14
* grid where the log base never moves and only the flame and sparks animate.
* `state` picks the frame set and cadence — idle simmers, running dances,
* ready exhales, error pulses dead embers.
*/
const FIRE_PALETTE: Record<string, string> = {
o: "#e7873a",
y: "#ffce70",
w: "#ffffff",
b: "#8b5a2b",
d: "#5a3a1b",
s: "#ffd166",
r: "#c43a30",
R: "#f25240",
x: "#ff7864",
m: "rgba(255,255,255,0.5)",
k: "rgba(255,255,255,0.26)",
e: "#a8582a",
E: "#d87636",
B: "#4890fc",
L: "#92c7ff",
C: "#e9f8ff",
S: "#bfe0ff"
};
interface CampfireFrame {
rows: string[];
}
const IDLE_FRAMES: CampfireFrame[] = [
{
rows: [
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
".....oyo......",
"....oyyyo.....",
"..dddeeeeeEd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"..............",
"..............",
".........k....",
"..............",
"..............",
"..............",
"..............",
"......oyo.....",
"....oyyyo.....",
"..dddEEEEEEd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
}
];
const RUN_FRAMES: CampfireFrame[] = [
{
rows: [
"..............",
"..............",
".....s.yy.....",
".....oyyo.....",
"....oywwyo....",
"...oyywwyyo...",
"...oyywwyyo...",
"...oyywwyyo...",
"....oywwyo....",
"..dddddddddd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"..............",
".....s........",
".....yy.......",
".....oyyo.....",
"....oywwyo....",
"...oyywwyyo...",
"...oyywwyyo...",
"...oyywwyyo...",
"....oywwyo....",
"..dddddddddd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
}
];
const READY_FRAMES: CampfireFrame[] = [
{
rows: [
"..............",
"..............",
"..............",
"..............",
"..............",
"......yy......",
".....oyyo.....",
"....oyyyyo....",
"....oyyyyo....",
"..dddyyyyyyd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"..............",
"..............",
"..............",
"..............",
"........m.....",
".....yy.......",
".....oyyo.....",
"....oyyyyo....",
"....oyyyyo....",
"..dddyyyyyyd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
}
];
/* App CampfireFrames.ques0-5: cool blue flame, tip dips, sparks spit. */
const QUES_FRAMES: CampfireFrame[] = [
{
rows: [
"..............",
"..............",
".....S.LL.....",
".....BLLB.....",
"....BLCCLB....",
"...BLLCCLLB...",
"...BLLCCLLB...",
"...BLLCCLLB...",
"....BLCCLB....",
"..dddccccccd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"..............",
".....S........",
".....LL.......",
".....BLLB.....",
"....BLCCLB....",
"...BLLCCLLB...",
"...BLLCCLLB...",
"...BLLCCLLB...",
"....BLCCLB....",
"..dddccccccd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"..............",
".....S........",
"......LL......",
".....BLLB.....",
"....BLCCLB....",
"...BLLCCLLB...",
"...BLLCCLLB...",
"...BLLCCLLB...",
"....BLCCLB....",
"..dddccccccd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
".....S........",
"..............",
"......LL......",
".....BLLB.....",
"....BLCCLB....",
"...BLLCCLLB...",
"...BLLCCLLB...",
"...BLLCCLLB...",
"....BLCCLB....",
"..dddccccccd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
".....S........",
"..............",
".......LL.....",
".....BLLB.....",
"....BLCCLB....",
"...BLLCCLLB...",
"...BLLCCLLB...",
"...BLLCCLLB...",
"....BLCCLB....",
"..dddccccccd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"........S.....",
"..............",
".......LL.....",
".....BLLB.....",
"....BLCCLB....",
"...BLLCCLLB...",
"...BLLCCLLB...",
"...BLLCCLLB...",
"....BLCCLB....",
"..dddccccccd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
}
];
const ERROR_FRAMES: CampfireFrame[] = [
{
rows: [
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
"..dddrrrrrrd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
},
{
rows: [
"........x.....",
"..............",
".....x........",
"..............",
"..............",
"..............",
"..............",
"..............",
"..............",
"..dddRRRRRRd..",
"..dbbbbbbbbd..",
"..............",
"..............",
".............."
]
}
];
export type CampfireState = "idle" | "running" | "ready" | "question" | "error";
const STATE_FRAMES: Record<CampfireState, CampfireFrame[]> = {
idle: IDLE_FRAMES,
running: RUN_FRAMES,
ready: READY_FRAMES,
question: QUES_FRAMES,
error: ERROR_FRAMES
};
const STATE_FPS: Record<CampfireState, number> = {
idle: 2,
running: 9,
ready: 4,
question: 4.5,
error: 4
};
export function Campfire(props: { state: CampfireState; pixel?: number }) {
const pixel = () => props.pixel ?? 3;
return (
<div
class="animate-campfire grid"
style={{
"grid-template-columns": `repeat(14, ${pixel()}px)`,
/* One full cycle: every frame exactly one frame-interval long. */
"--campfire-duration": `${(1000 * STATE_FRAMES[props.state].length) / STATE_FPS[props.state] / 1000}s`
}}
>
{/* Slot phase lives in the keyframes per layer (campfire-slot-N-x);
delay-offset layers blank a wrap frame under WebKit. */}
<For each={STATE_FRAMES[props.state]}>
{(frame, i) => {
const n = STATE_FRAMES[props.state].length;
const phase = String.fromCharCode(97 + (i() % 26));
return (
<div
class="campfire-frame col-span-full row-start-1"
style={{ "--slot-kf": `campfire-slot-${n}-${phase}` }}
>
<For each={frame.rows}>
{(row) => (
<div class="flex" style={{ height: `${pixel()}px` }}>
<For each={row.split("")}>
{(ch) => (
<span
class="inline-block"
style={{
width: `${pixel()}px`,
height: `${pixel()}px`,
background:
ch === "." ? "transparent" : FIRE_PALETTE[ch]
}}
/>
)}
</For>
</div>
)}
</For>
</div>
);
}}
</For>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,370 +0,0 @@
import { For, type JSX } from "solid-js";
import {
IslandPill,
AgentDotGrid,
STATUS,
CALM,
CRITICAL,
ACCENT,
ModuleCard
} from "./IslandMock";
/**
* Feature collage: five cells sold as the app's actual UI. The hero cell
* shows the real collapsed pill (campfire slot, camera housing, live data)
* dressed in the island's black surface; supporting cells reuse the same
* module chrome, status palette, and metric language.
*/
function CollageCell(props: {
class?: string;
kicker: string;
title: string;
body: string;
children?: JSX.Element;
}) {
return (
<div
class={`border-overlay1 bg-surface1 relative flex flex-col overflow-hidden rounded-2xl border p-6 ${props.class ?? ""}`}
>
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
{props.kicker}
</p>
<h3 class="text-text mb-2 text-lg font-bold tracking-tight">
{props.title}
</h3>
<p class="text-subtext0 text-sm leading-relaxed">{props.body}</p>
<div class="mt-5 flex-1">{props.children}</div>
</div>
);
}
/**
* Stage for app-UI mocks: the island's black ink ground. The real island
* is always dark (preferredColorScheme .dark), so the mocks read correctly
* in both site themes.
*/
function InkStage(props: { children: JSX.Element; class?: string }) {
return (
<div
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
style={{ background: "#0d0d0f" }}
>
{props.children}
</div>
);
}
/** The approvals mock rebuilt on the app's PermissionCard anatomy. */
function ApprovalCardMock() {
return (
<div
class="mx-auto w-full max-w-sm rounded-[10px] p-2.5 text-left"
style={{ background: "rgba(234,179,8,0.08)" }}
>
<div class="mb-1.5 flex items-center gap-1.5">
<span
class="inline-block h-[13px] w-[13px] rounded-[3px]"
style={{ background: "#D97742" }}
/>
<span class="text-[12px] font-semibold text-white">
the-nook · bridge
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: "rgba(234,179,8,0.25)" }}
>
approval
</span>
</div>
<p class="text-[13px] font-medium text-white">Run Edit</p>
<p class="mt-0.5 font-mono text-[11px] text-white/50">
server/routes.ts +12 −4
</p>
<div class="mt-2.5 flex gap-2">
<span
class="rounded-md px-3 py-1 text-[11px] font-semibold"
style={{ background: ACCENT }}
>
Allow
</span>
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
Deny
</span>
<span class="px-1 py-1 text-[11px] text-white/50">
Deny with reason…
</span>
</div>
</div>
);
}
/** Fans panel mock: RPM gauge rows in the app's card chrome. */
function FansPanelMock() {
const fans = [
{ rpm: 1270, frac: 0.26 },
{ rpm: 1219, frac: 0.25 }
];
return (
<ModuleCard>
<div class="space-y-2.5">
<For each={fans}>
{(f) => (
<div class="flex items-center gap-3">
<div
class="h-1 flex-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${f.frac * 100}%`,
background: CALM
}}
/>
</div>
<span
class="font-mono text-white"
style={{ "font-size": "13px", "font-weight": 500 }}
>
{f.rpm}
</span>
<span class="text-[9px] font-semibold text-white/50">RPM</span>
</div>
)}
</For>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<div class="flex gap-2">
<span class="rounded-md border border-white/15 px-2 py-0.5 text-[11px] text-white/70">
Auto
</span>
<span
class="rounded-md px-2 py-0.5 text-[11px] font-bold"
style={{ color: CRITICAL }}
>
MAX
</span>
</div>
</div>
</ModuleCard>
);
}
/** Remote agents: two session rows like the expanded agents panel. */
function RemoteMock() {
return (
<div class="space-y-1.5">
<div
class="rounded-[10px] p-2.5"
style={{
background: "rgba(255,255,255,0.055)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
}}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.running }}
/>
<span class="text-[13px] font-semibold text-white">build-box</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
style={{ background: "rgba(255,255,255,0.08)" }}
>
ssh
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">You: fix flaky test</p>
</div>
<div
class="rounded-[10px] p-2.5"
style={{
background: "rgba(255,255,255,0.055)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
}}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.ready }}
/>
<span class="text-[13px] font-semibold text-white">gpu-rig</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
style={{ background: "rgba(255,255,255,0.08)" }}
>
ssh
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
</div>
</div>
);
}
/** System gauges: temp die blocks + memory, in module cards. */
function GaugesMock() {
return (
<div class="grid grid-cols-2 gap-2">
<ModuleCard>
<div class="flex items-baseline justify-between">
<span class="text-[9px] font-semibold text-white/50">CPU</span>
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
72°
</span>
</div>
<div
class="mt-1.5 h-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{ width: "64%", background: CALM }}
/>
</div>
</ModuleCard>
<ModuleCard>
<div class="flex items-baseline justify-between">
<span class="text-[9px] font-semibold text-white/50">GPU</span>
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
58°
</span>
</div>
<div
class="mt-1.5 h-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{ width: "31%", background: CALM }}
/>
</div>
</ModuleCard>
</div>
);
}
export default function FeatureCollage() {
return (
<section
class="bg-base relative z-20 px-4 pt-40 pb-16 md:px-8"
id="feature-collage"
>
<div class="mx-auto max-w-5xl">
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
Why the Nook
</p>
<h2 class="text-text mb-12 text-center text-3xl font-bold">
One glance. Everything your agents are doing.
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
{/* Hero: real collapsed pill on the island surface */}
<div class="border-overlay1 bg-surface1 relative col-span-1 flex flex-col items-center overflow-hidden rounded-2xl border p-6 md:col-span-2">
<div class="mb-5 self-start">
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
The island
</p>
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
Every agent, one glance
</h3>
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
A pill tucked into your notch — a campfire for your whole fleet
on the left, live data on the right. Blue running, green ready,
amber needs you.
</p>
</div>
<div class="my-10 flex w-full justify-center">
<IslandPill
campfire="running"
right={
<div class="flex flex-col items-center gap-1 px-1">
<div class="flex gap-1.5">
<span
class="inline-block h-2.5 w-2.5 rounded-[3px]"
style={{ background: STATUS.running }}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-[3px]"
style={{ background: STATUS.ready }}
/>
</div>
<div class="flex gap-1.5">
<span
class="inline-block h-2.5 w-2.5 rounded-[3px]"
style={{ background: STATUS.attention }}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-[3px]"
style={{ background: STATUS.idle }}
/>
</div>
</div>
}
/>
</div>
<p class="text-subtext0 -mb-1 text-center text-xs">
expand for the full panel ↓
</p>
</div>
{/* Approvals */}
<CollageCell
kicker="Approvals"
title="Unblock agents, anywhere"
body="Permission prompts surface as cards in the panel — Allow once, always, or deny. Away from the desk, push them to your phone and answer from there (coming soon)."
>
<InkStage>
<div class="w-full max-w-sm px-3">
<ApprovalCardMock />
</div>
</InkStage>
</CollageCell>
{/* Fans */}
<CollageCell
kicker="Cooling"
title="Silence it or max it"
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
>
<InkStage class="w-full">
<div class="w-full max-w-[260px] text-left">
<FansPanelMock />
</div>
</InkStage>
</CollageCell>
{/* Remote agents */}
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
>
<InkStage class="w-full">
<div class="w-full max-w-[280px] text-left">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
{/* Gauges */}
<CollageCell
kicker="Your Mac"
title="The gauges that matter"
body="CPU and GPU die temps, memory pressure, live network throughput — the same tier palette the island uses, warning before a compile cooks your lap."
>
<InkStage class="w-full">
<div class="w-full max-w-[300px] text-left">
<GaugesMock />
</div>
</InkStage>
</CollageCell>
</div>
<p class="text-subtext1 mt-6 text-center text-xs">
Plus calendar, reminders, calls, and Now Playing — all in the same
island, all one-time purchase.
</p>
</div>
</section>
);
}

View File

@@ -1,263 +0,0 @@
import { For, createMemo, onMount, onCleanup, type JSX } from "solid-js";
import { Campfire, type CampfireState } from "./Campfire";
/**
* Faithful mock of the island's collapsed pill and expanded panel.
* Geometry follows the app's IslandSurfaceShape: the top edge spans the
* full width, the notch housing sits centered, and each corner is a
* concave scoop (quadratic curve with the control ON the top edge).
*/
/* The app's island indicator palette (AgentsModuleView.statusColor). */
export const STATUS = {
running: "#6EA7FF",
ready: "#6FB982",
attention: "#E7A762",
error: "#E5484D",
idle: "rgba(255,255,255,0.35)"
};
/** MetricPalette.calm — the desaturated sky of every metric bar. */
export const CALM = "#6EA7FF";
export const WARM = "#E7A762";
export const CRITICAL = "rgba(229,72,77,0.9)";
/* NookPalette.accent — brand + live interactive state, never status. */
export const ACCENT = "#4897b2";
/** The expanded panel's card chrome: white 5.5% fill, white 7% stroke. */
export const CARD_FILL = "rgba(255,255,255,0.055)";
export const CARD_STROKE = "rgba(255,255,255,0.07)";
/**
* One module-card surface shared by every mock panel — the app's
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
*/
export function ModuleCard(props: {
children: JSX.Element;
class?: string;
style?: Record<string, string>;
}) {
return (
<div
class={`rounded-xl ${props.class ?? ""}`}
style={{
background: CARD_FILL,
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
...props.style
}}
>
<div class="p-3">{props.children}</div>
</div>
);
}
/**
* Island surface, faithful to the app's IslandSurfaceShape: the top edge
* spans the full outer width while the body is inset, each corner joining
* them with a concave quad flare (control point ON the top edge), the
* bottom a convex 14px round. Rendered as an inline SVG with
* foreignObject-clip via CSS `clip-path: path(...)`.
*/
const FLARE = 22; // the app's topRadius — how far the wings taper inward
const BOTTOM_R = 14;
function islandPath(w: number, h: number): string {
const tr = Math.min(FLARE, w / 2);
const br = Math.min(BOTTOM_R, tr);
// Mirrors IslandSurfaceShape.path exactly (same node order, same controls).
return (
`M0,0 L${w},0 ` +
`Q${w - tr},0 ${w - tr},${tr} ` +
`L${w - tr},${h - br} ` +
`Q${w - tr},${h} ${w - tr - br},${h} ` +
`L${tr + br},${h} ` +
`Q${tr},${h} ${tr},${h - br} ` +
`L${tr},${tr} ` +
`Q${tr},0 0,0 Z`
);
}
function IslandSurface(props: { children: JSX.Element; class?: string }) {
let el: HTMLDivElement | undefined;
// The path must track the element's real size; a fixed viewBox would
// stretch the flare. Measure on mount + resize.
onMount(() => {
const node = el;
if (!node) return;
const apply = () => {
const r = node.getBoundingClientRect();
node.style.clipPath = `path("${islandPath(r.width, r.height)}")`;
};
apply();
const observer = new ResizeObserver(apply);
observer.observe(node);
onCleanup(() => observer.disconnect());
});
return (
<div
ref={el}
class={`bg-black ${props.class ?? ""}`}
style={{ "border-radius": "0 0 14px 14px" }}
>
{props.children}
</div>
);
}
/** ISLAND … */
const NOTCH_NAME = "The Nook";
/* ── Collapsed pill ─────────────────────────────────────────────────── */
/**
* The collapsed pill: campfire slot left, camera housing center, live
* data slot right. Slot squares are native (no menu-bar stub — the wings
* hang from nothing, like a floating notch).
*/
export function IslandPill(props: {
campfire?: CampfireState;
right?: JSX.Element;
}) {
return (
<IslandSurface class="mx-auto w-fit">
<div class="flex items-end" style={{ height: "60px" }}>
<div
class="flex items-center justify-center"
style={{ width: "86px", height: "56px" }}
>
<Campfire state={props.campfire ?? "running"} pixel={4.2} />
</div>
{/* camera housing: lens dot centered like the real notch */}
<div
class="flex items-center justify-center"
style={{ width: "150px", height: "56px" }}
>
<span
class="rounded-full"
style={{
width: "12px",
height: "12px",
background: "radial-gradient(circle at 40% 35%, #2a3a4a 0%, #0a0c10 70%)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.06)"
}}
/>
</div>
<div
class="flex items-center justify-center"
style={{ width: "86px", height: "56px" }}
>
{props.right}
</div>
</div>
</IslandSurface>
);
}
/** The agents dot-grid slot indicator: balanced rows of status squares. */
export function AgentDotGrid(props: { states: (keyof typeof STATUS)[] }) {
// Balanced rows: 3+3+2 for 8, 2x2 for 4, 3 for 3 … app: 1,2,3,4=2x2,
// 5=[3,2], 6=[3,3], 7=[4,3], 8=[4,4], 9=[3,3,3]
const rows = (n: number): number[] => {
switch (n) {
case 1:
return [1];
case 2:
return [2];
case 3:
return [3];
case 4:
return [2, 2];
case 5:
return [3, 2];
case 6:
return [3, 3];
case 7:
return [4, 3];
case 8:
return [4, 4];
default:
return [4, 4];
}
};
const sizes = rows(props.states.length);
const groups = createMemo(() => {
let cursor = 0;
return sizes.map((count) => props.states.slice(cursor, (cursor += count)));
});
return (
<div class="flex flex-col items-center gap-[1.5px]">
<For each={groups()}>
{(group) => (
<div class="flex gap-[1.5px]">
<For each={group}>
{(s) => (
<span
class="rounded-[1.5px]"
style={{
width: "7px",
height: "7px",
background: STATUS[s]
}}
/>
)}
</For>
</div>
)}
</For>
</div>
);
}
/* ── Expanded panel ─────────────────────────────────────────────────── */
/**
* The expanded island: header ("The Nook" + "N active" + controls),
* tab strip, divider, then one panel page. Width ~ the app's expanded
* footprint on a 14" display.
*/
export function IslandExpanded(props: {
activeCount: number;
tabs: { icon: string; label: string; active?: boolean }[];
children: JSX.Element;
}) {
return (
<div class="w-[560px] max-w-full">
<IslandSurface>
{/* header */}
<div class="flex items-center px-[18px] py-2.5">
<span class="text-[14px] font-semibold text-white">{NOTCH_NAME}</span>
<div class="flex-1" />
<span class="text-[11px] text-white/50">
{props.activeCount} active
</span>
<span class="ml-3 text-[11px] font-semibold text-white/60">⚙</span>
<span class="ml-3 text-[11px] font-semibold text-white/60">✕</span>
</div>
{/* tab strip: one icon button per panel; active wears accent 22% */}
<div class="flex gap-2 px-[18px] pb-1.5">
<For each={props.tabs}>
{(tab) => (
<span
title={tab.label}
class="flex items-center justify-center rounded-[5px]"
style={{
width: "22px",
height: "22px",
"font-size": "13px",
background: tab.active ? `${ACCENT}38` : "transparent",
color: tab.active ? "rgba(255,255,255,0.95)" : "rgba(255,255,255,0.5)"
}}
>
{tab.icon}
</span>
)}
</For>
</div>
<div class="h-px bg-white/10" />
<div class="px-[18px] py-4">{props.children}</div>
</IslandSurface>
</div>
);
}

View File

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

View File

@@ -1,113 +1,11 @@
import { import { JSX, splitProps, Show, createSignal, createEffect } from "solid-js";
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) {
@@ -118,8 +16,7 @@ export default function Button(props: ButtonProps) {
"fullWidth", "fullWidth",
"class", "class",
"children", "children",
"disabled", "disabled"
"color"
]); ]);
let contentRef: HTMLSpanElement | undefined; let contentRef: HTMLSpanElement | undefined;
@@ -128,6 +25,7 @@ export default function Button(props: ButtonProps) {
height: number; height: number;
} | null>(null); } | null>(null);
// Measure content dimensions when not loading
createEffect(() => { createEffect(() => {
if (!local.loading && contentRef) { if (!local.loading && contentRef) {
const rect = contentRef.getBoundingClientRect(); const rect = contentRef.getBoundingClientRect();
@@ -155,8 +53,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
? "cursor-not-allowed brightness-75" ? "bg-green text-base cursor-not-allowed brightness-75"
: "hover:brightness-125 active:scale-90"; : "bg-green text-base 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"
@@ -170,20 +68,6 @@ 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":
@@ -199,25 +83,11 @@ export default function Button(props: ButtonProps) {
const widthClass = () => (local.fullWidth ? "w-full" : ""); const widthClass = () => (local.fullWidth ? "w-full" : "");
const buttonStyle = (): JSX.CSSProperties => {
if (variant() === "download") {
const { bg, text } = downloadColors();
return { background: bg, color: text };
}
// Theme-aware text: white on dark bg variants (primary/danger),
// theme text on light bg variants (secondary). The CSS variables
// flip between light/dark modes so the contrast stays good in both.
if (variant() === "secondary")
return { color: "var(--color-button-text-alt)" };
return { color: "var(--color-button-text)" };
};
return ( return (
<button <button
{...others} {...others}
disabled={local.disabled || local.loading} disabled={local.disabled || local.loading}
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`} class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
style={buttonStyle()}
> >
<Show <Show
when={local.loading} when={local.loading}

View File

@@ -23,6 +23,7 @@ export const AUTH_CONFIG = {
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
// Other Auth Settings
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const, EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const, EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
@@ -73,6 +74,9 @@ export const RATE_LIMITS = {
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 } EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
} as const; } as const;
/** Rate limit store cleanup interval (5 minutes) */
export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// ============================================================ // ============================================================
// ACCOUNT SECURITY // ACCOUNT SECURITY
// ============================================================ // ============================================================
@@ -132,6 +136,22 @@ export const NETWORK_CONFIG = {
RETRY_DELAY_MS: 1000 RETRY_DELAY_MS: 1000
} as const; } as const;
// ============================================================
// UI/UX - TYPEWRITER COMPONENT
// ============================================================
export const TYPEWRITER_CONFIG = {
DEFAULT_SPEED: 30,
FAST_SPEED: 80,
SLOW_SPEED: 10,
VERY_SLOW_SPEED: 100,
EXTRA_SLOW_SPEED: 120,
DEFAULT_KEEP_ALIVE_MS: 2000,
LONG_KEEP_ALIVE_MS: 10000,
DEFAULT_DELAY_MS: 500,
CURSOR_FADE_DELAY_MS: 1000
} as const;
// ============================================================ // ============================================================
// UI/UX - COUNTDOWN TIMER COMPONENT // UI/UX - COUNTDOWN TIMER COMPONENT
// ============================================================ // ============================================================
@@ -157,6 +177,41 @@ export const BREAKPOINTS = {
DESKTOP_MIN_WIDTH: 1025 DESKTOP_MIN_WIDTH: 1025
} as const; } as const;
// ============================================================
// UI/UX - ANIMATIONS & TRANSITIONS
// ============================================================
export const ANIMATION_CONFIG = {
TRANSITION_DURATION_MS: 300,
FAST_TRANSITION_MS: 200,
SLOW_TRANSITION_MS: 500,
EXTRA_SLOW_TRANSITION_MS: 600,
SIDEBAR_DURATION_MS: 500,
MENU_TYPING_DELAY_MS: 140,
MENU_INITIAL_DELAY_MS: 500,
SUCCESS_MESSAGE_DURATION_MS: 3000,
ERROR_MESSAGE_DURATION_MS: 5000,
REDIRECT_DELAY_MS: 500
} as const;
// ============================================================
// UI/UX - PDF VIEWER
// ============================================================
export const PDF_CONFIG = {
RENDER_SCALE: 1.5
} as const;
// ============================================================
// UI/UX - 401 ERROR PAGE
// ============================================================
export const ERROR_PAGE_CONFIG = {
GLITCH_INTERVAL_MS: 300,
GLITCH_DURATION_MS: 100,
PARTICLE_COUNT: 45
} as const;
// ============================================================ // ============================================================
// UI/UX - MOBILE CONFIG // UI/UX - MOBILE CONFIG
// ============================================================ // ============================================================
@@ -229,4 +284,22 @@ export const LINEAGE_CONFIG = {
JWT_AUDIENCE: "lineage-app" as const JWT_AUDIENCE: "lineage-app" as const
} as const; } as const;
// ============================================================
// AUDIT & LOGGING
// ============================================================
export const AUDIT_CONFIG = {
DEFAULT_QUERY_LIMIT: 100,
MAX_RETENTION_DAYS: 90
} as const;
// ============================================================
// SESSION CLEANUP
// ============================================================
export const SESSION_CLEANUP_CONFIG = {
ENABLED: true,
INTERVAL_HOURS: 24,
RETENTION_DAYS: 90,
RUN_ON_STARTUP: true
} as const;

View File

@@ -58,7 +58,7 @@ declare global {
} }
/** Resolve the client-side active site, preferring the SSR-injected id. */ /** Resolve the client-side active site, preferring the SSR-injected id. */
function resolveClientSite(): Site { export function resolveClientSite(): Site {
if (typeof window === "undefined") return MAIN_SITE; if (typeof window === "undefined") return MAIN_SITE;
const injected = window.__SITE__; const injected = window.__SITE__;
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected]; if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];

View File

@@ -53,7 +53,9 @@ export const AuthProvider: ParentComponent = (props) => {
// Get server state using createAsync which works with cache() // Get server state using createAsync which works with cache()
const serverAuth = createAsync(() => getUserState(), { deferStream: true }); const serverAuth = createAsync(() => getUserState(), { deferStream: true });
// Refresh callback that forces re-fetch
const refreshAuth = () => { const refreshAuth = () => {
// Manually trigger a re-fetch by calling the revalidate function
revalidate(["user-auth-state"]); revalidate(["user-auth-state"]);
}; };
@@ -68,6 +70,7 @@ export const AuthProvider: ParentComponent = (props) => {
// Server handles all token refresh logic // Server handles all token refresh logic
// Client just displays the current auth state from server // Client just displays the current auth state from server
// Listen for auth refresh events from external sources (token refresh, etc.)
onMount(() => { onMount(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;

160
src/db/create.ts Normal file
View File

@@ -0,0 +1,160 @@
export const model: { [key: string]: string } = {
User: `
CREATE TABLE User
(
id TEXT NOT NULL PRIMARY KEY,
email TEXT UNIQUE,
email_verified INTEGER DEFAULT 0,
password_hash TEXT,
display_name TEXT,
provider TEXT,
image TEXT,
is_admin INTEGER DEFAULT 0,
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
failed_attempts INTEGER DEFAULT 0,
locked_until TEXT
);
`,
UserProvider: `
CREATE TABLE UserProvider
(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
provider_user_id TEXT,
email TEXT,
display_name TEXT,
image TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email);
CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id);
CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider);
CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email);
`,
PasswordResetToken: `
CREATE TABLE PasswordResetToken
(
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_password_reset_token ON PasswordResetToken (token);
CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON PasswordResetToken (user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_expires_at ON PasswordResetToken (expires_at);
`,
Post: `
CREATE TABLE Post
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL UNIQUE,
subtitle TEXT,
body TEXT NOT NULL,
banner_photo TEXT,
date TEXT,
published INTEGER NOT NULL,
category TEXT,
author_id TEXT NOT NULL,
reads INTEGER NOT NULL DEFAULT 0,
attachments TEXT,
last_edited_date TEXT
);
CREATE INDEX IF NOT EXISTS idx_posts_category ON Post (category);
CREATE INDEX IF NOT EXISTS idx_posts_published ON Post (published);
CREATE INDEX IF NOT EXISTS idx_posts_date ON Post (date);
CREATE INDEX IF NOT EXISTS idx_posts_published_date ON Post (published, date);
`,
PostLike: `
CREATE TABLE PostLike
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
post_id INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_likes_user_post ON PostLike (user_id, post_id);
CREATE INDEX IF NOT EXISTS idx_likes_post_id ON PostLike (post_id);
`,
Comment: `
CREATE TABLE Comment
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
post_id INTEGER,
parent_comment_id INTEGER,
date TEXT NOT NULL DEFAULT (datetime('now')),
edited INTEGER NOT NULL DEFAULT 0,
commenter_id TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_comment_commenter_id ON Comment (commenter_id);
CREATE INDEX IF NOT EXISTS idx_comment_parent_comment_id ON Comment (parent_comment_id);
CREATE INDEX IF NOT EXISTS idx_comment_post_id ON Comment (post_id);
`,
CommentReaction: `
CREATE TABLE CommentReaction
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
comment_id INTEGER NOT NULL,
user_id TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_user_type_comment ON CommentReaction (user_id, type, comment_id);
`,
Connection: `
CREATE TABLE Connection
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
connection_id TEXT NOT NULL,
post_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_connection_post_id ON Connection (post_id);
`,
Tag: `
CREATE TABLE Tag
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
value TEXT NOT NULL,
post_id INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tag_post_id ON Tag (post_id);
CREATE INDEX IF NOT EXISTS idx_tag_value ON Tag (value);
CREATE INDEX IF NOT EXISTS idx_tag_post_value ON Tag (post_id, value);
`,
PostHistory: `
CREATE TABLE PostHistory
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
parent_id INTEGER,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
is_saved INTEGER DEFAULT 0,
FOREIGN KEY (post_id) REFERENCES Post(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_history_post_id ON PostHistory (post_id);
CREATE INDEX IF NOT EXISTS idx_history_parent_id ON PostHistory (parent_id);
`,
RateLimit: `
CREATE TABLE RateLimit
(
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
reset_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Unique constraint on identifier so ON CONFLICT(identifier) atomic upserts
-- (see src/server/security.ts checkRateLimit) are well-defined. This makes
-- the rate-limit state shared across all instances (p8-010).
CREATE UNIQUE INDEX IF NOT EXISTS idx_ratelimit_identifier_unique ON RateLimit (identifier);
CREATE INDEX IF NOT EXISTS idx_ratelimit_reset_at ON RateLimit (reset_at);
`
};

View File

@@ -34,12 +34,14 @@ function shouldAttemptReload(): boolean {
10 10
); );
// Reset counter if outside the time window
if (now - lastReloadTime > RELOAD_WINDOW_MS) { if (now - lastReloadTime > RELOAD_WINDOW_MS) {
sessionStorage.setItem(RELOAD_STORAGE_KEY, "0"); sessionStorage.setItem(RELOAD_STORAGE_KEY, "0");
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString()); sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
return true; return true;
} }
// Check if we've exceeded max reloads
if (reloadCount >= MAX_RELOADS) { if (reloadCount >= MAX_RELOADS) {
console.error( console.error(
`Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.` `Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.`
@@ -47,10 +49,12 @@ function shouldAttemptReload(): boolean {
return false; return false;
} }
// Increment counter and allow reload
sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString()); sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString());
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString()); sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
return true; return true;
} catch (e) { } catch (e) {
// If sessionStorage fails, allow reload but log error
console.warn("Failed to access sessionStorage:", e); console.warn("Failed to access sessionStorage:", e);
return true; return true;
} }
@@ -98,29 +102,33 @@ function handleChunkError(source: string): void {
} }
} }
// Handle runtime chunk loading errors
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
if ( if (
event.message?.includes("Importing a module script failed") || event.message?.includes("Importing a module script failed") ||
event.message?.includes("Failed to fetch dynamically imported module") || event.message?.includes("Failed to fetch dynamically imported module")
event.message?.includes("error loading dynamically imported module")
) { ) {
event.preventDefault(); event.preventDefault();
handleChunkError("error event"); handleChunkError("error event");
} }
}); });
// Handle promise-based chunk loading errors
window.addEventListener("unhandledrejection", (event) => { window.addEventListener("unhandledrejection", (event) => {
if ( if (
event.reason?.message?.includes("Importing a module script failed") || event.reason?.message?.includes("Importing a module script failed") ||
event.reason?.message?.includes("Failed to fetch dynamically imported module") || event.reason?.message?.includes(
event.reason?.message?.includes("error loading dynamically imported module") "Failed to fetch dynamically imported module"
)
) { ) {
event.preventDefault(); event.preventDefault();
handleChunkError("unhandled rejection"); handleChunkError("unhandled rejection");
} }
}); });
// Clear reload counter on successful page load
window.addEventListener("load", () => { window.addEventListener("load", () => {
// Only clear if we successfully loaded (we're past the critical chunk loading phase)
setTimeout(() => { setTimeout(() => {
sessionStorage.removeItem(RELOAD_STORAGE_KEY); sessionStorage.removeItem(RELOAD_STORAGE_KEY);
sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY); sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY);

11
src/env/client.ts vendored
View File

@@ -38,7 +38,16 @@ export const validateClientEnv = (
return envVars as unknown as ClientEnv; return envVars as unknown as ClientEnv;
}; };
export const env = validateClientEnv(import.meta.env); const validateAndExportEnv = (): ClientEnv => {
try {
const validated = validateClientEnv(import.meta.env);
return validated;
} catch (error) {
throw error;
}
};
export const env = validateAndExportEnv();
export const isMissingEnvVar = (varName: string): boolean => { export const isMissingEnvVar = (varName: string): boolean => {
return !import.meta.env[varName] || import.meta.env[varName]?.trim() === ""; return !import.meta.env[varName] || import.meta.env[varName]?.trim() === "";

8
src/env/server.ts vendored
View File

@@ -67,13 +67,7 @@ const serverEnvSchema = z.object({
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(), APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(), APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
VITE_TURNSTILE_SITE_KEY: z.string().min(1), VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_KEY: z.string().min(1), TURNSTILE_SECRET_KEY: z.string().min(1)
NOOK_DB_URL: z.string().min(1),
NOOK_DB_TOKEN: z.string().min(1),
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
NOOK_STRIPE_SK: z.string().min(1),
NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1),
NOOK_STRIPE_PRICE_ID: z.string().min(1)
}); });
export type ServerEnv = z.infer<typeof serverEnvSchema>; export type ServerEnv = z.infer<typeof serverEnvSchema>;

View File

@@ -80,6 +80,7 @@ export const getUserState = query(async (): Promise<UserState> => {
* Call this after login, logout, token refresh, email verification * Call this after login, logout, token refresh, email verification
*/ */
export function revalidateAuth() { export function revalidateAuth() {
// Revalidate the cache
revalidateKey("user-auth-state"); revalidateKey("user-auth-state");
// Dispatch event to trigger UI updates (client-side only) // Dispatch event to trigger UI updates (client-side only)

View File

@@ -3,6 +3,21 @@
* Note: These utilities should only run in the browser * Note: These utilities should only run in the browser
*/ */
/**
* Fetch wrapper for auth checks where 401s are expected and should not trigger console errors
*/
export async function safeFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
try {
const response = await fetch(input, init);
return response;
} catch (error) {
throw error;
}
}
/** /**
* Decode JWT payload without verification (client-side only) * Decode JWT payload without verification (client-side only)
* @param token - JWT token string * @param token - JWT token string

111
src/lib/cookies.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Cookie utilities for SolidStart
* Provides client and server-side cookie management
*/
import { getCookie as getServerCookie, setCookie as setServerCookie } from "vinxi/http";
import type { H3Event } from "vinxi/http";
/**
* Get cookie value on the server
*/
export function getCookie(event: H3Event, name: string): string | undefined {
return getServerCookie(event, name);
}
/**
* Set cookie on the server
*/
export function setCookie(
event: H3Event,
name: string,
value: string,
options?: {
maxAge?: number;
expires?: Date;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
path?: string;
}
) {
setServerCookie(event, name, value, options);
}
/**
* Delete cookie on the server
*/
export function deleteCookie(event: H3Event, name: string) {
setServerCookie(event, name, "", {
maxAge: 0,
expires: new Date("2016-10-05"),
});
}
/**
* Get cookie value on the client (browser)
*/
export function getClientCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift();
}
return undefined;
}
/**
* Set cookie on the client (browser)
*/
export function setClientCookie(
name: string,
value: string,
options?: {
maxAge?: number;
expires?: Date;
path?: string;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
}
) {
if (typeof document === "undefined") return;
let cookieString = `${name}=${value}`;
if (options?.maxAge) {
cookieString += `; max-age=${options.maxAge}`;
}
if (options?.expires) {
cookieString += `; expires=${options.expires.toUTCString()}`;
}
if (options?.path) {
cookieString += `; path=${options.path}`;
} else {
cookieString += "; path=/";
}
if (options?.secure) {
cookieString += "; secure";
}
if (options?.sameSite) {
cookieString += `; samesite=${options.sameSite}`;
}
document.cookie = cookieString;
}
/**
* Delete cookie on the client (browser)
*/
export function deleteClientCookie(name: string) {
if (typeof document === "undefined") return;
document.cookie = `${name}=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}

View File

@@ -81,6 +81,7 @@ export function formatRelativeTime(
return `${diffDay}d ago`; return `${diffDay}d ago`;
} }
} else { } else {
// style === "long"
if (includeSeconds && diffSec < 60) { if (includeSeconds && diffSec < 60) {
return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`; return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
} }

View File

@@ -12,12 +12,14 @@ const VERSION_STORAGE_KEY = "app-version-hash";
*/ */
function getCurrentVersionHash(): string { function getCurrentVersionHash(): string {
try { try {
// Use a combination of script tags to detect version
const scripts = Array.from(document.querySelectorAll("script[src]")) const scripts = Array.from(document.querySelectorAll("script[src]"))
.map((s) => (s as HTMLScriptElement).src) .map((s) => (s as HTMLScriptElement).src)
.filter((src) => src.includes("/_build/")) .filter((src) => src.includes("/_build/"))
.sort() .sort()
.join(","); .join(",");
// Simple hash function
let hash = 0; let hash = 0;
for (let i = 0; i < scripts.length; i++) { for (let i = 0; i < scripts.length; i++) {
const char = scripts.charCodeAt(i); const char = scripts.charCodeAt(i);
@@ -37,6 +39,7 @@ function getCurrentVersionHash(): string {
*/ */
async function checkForNewVersion(): Promise<boolean> { async function checkForNewVersion(): Promise<boolean> {
try { try {
// Fetch current page HTML
const response = await fetch(window.location.pathname, { const response = await fetch(window.location.pathname, {
method: "HEAD", method: "HEAD",
cache: "no-cache" cache: "no-cache"
@@ -61,6 +64,7 @@ async function checkForNewVersion(): Promise<boolean> {
return true; return true;
} }
// Store current ETag for future checks
if (newEtag) { if (newEtag) {
sessionStorage.setItem("app-etag", newEtag); sessionStorage.setItem("app-etag", newEtag);
} }
@@ -76,6 +80,7 @@ async function checkForNewVersion(): Promise<boolean> {
* Show update notification to user * Show update notification to user
*/ */
function showUpdateNotification(): void { function showUpdateNotification(): void {
// Only show once per session
if (sessionStorage.getItem("update-notification-shown")) { if (sessionStorage.getItem("update-notification-shown")) {
return; return;
} }
@@ -142,6 +147,7 @@ function showUpdateNotification(): void {
document.body.appendChild(notification); document.body.appendChild(notification);
// Auto-remove after 30 seconds
setTimeout(() => { setTimeout(() => {
if (notification.parentElement) { if (notification.parentElement) {
notification.style.animation = "slideIn 0.3s ease-out reverse"; notification.style.animation = "slideIn 0.3s ease-out reverse";
@@ -156,9 +162,11 @@ function showUpdateNotification(): void {
export function startDeploymentMonitoring(): void { export function startDeploymentMonitoring(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// Store initial version
const initialVersion = getCurrentVersionHash(); const initialVersion = getCurrentVersionHash();
sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion); sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion);
// Periodic version check
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
const hasNewVersion = await checkForNewVersion(); const hasNewVersion = await checkForNewVersion();
if (hasNewVersion) { if (hasNewVersion) {
@@ -166,6 +174,7 @@ export function startDeploymentMonitoring(): void {
} }
}, VERSION_CHECK_INTERVAL); }, VERSION_CHECK_INTERVAL);
// Check on visibility change (user returns to tab)
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
const hasNewVersion = await checkForNewVersion(); const hasNewVersion = await checkForNewVersion();
@@ -177,6 +186,7 @@ export function startDeploymentMonitoring(): void {
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
// Cleanup function
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
(window as any).__cleanupDeploymentMonitoring = () => { (window as any).__cleanupDeploymentMonitoring = () => {
clearInterval(intervalId); clearInterval(intervalId);

View File

@@ -16,17 +16,16 @@ import {
} from "./nav-config"; } from "./nav-config";
import { SITE_CONFIG, type SiteId } from "./site-context"; import { SITE_CONFIG, type SiteId } from "./site-context";
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"]; const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
describe("NAV_CONFIG — per-site link sets", () => { describe("NAV_CONFIG — per-site link sets", () => {
it("main → Home, Blog, Downloads, Resume, Contact, Privacy, GitHub, LinkedIn", () => { it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
expect(navLabelsFor("main")).toEqual([ expect(navLabelsFor("main")).toEqual([
"Home", "Home",
"Blog", "Blog",
"Downloads", "Downloads",
"Resume", "Resume",
"Contact", "Contact",
"Privacy",
"GitHub", "GitHub",
"LinkedIn" "LinkedIn"
]); ]);
@@ -81,7 +80,7 @@ describe("NAV_CONFIG — href correctness", () => {
}); });
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => { it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) { for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const item of NAV_CONFIG[id]) { for (const item of NAV_CONFIG[id]) {
// No subdomain-prefixed paths leak into the public nav. // No subdomain-prefixed paths leak into the public nav.
expect(item.href.startsWith(`/${id}/`)).toBe(false); expect(item.href.startsWith(`/${id}/`)).toBe(false);
@@ -112,7 +111,7 @@ describe("NAV_CONFIG — href correctness", () => {
describe("NAV_CONFIG — auth-scoping by construction", () => { describe("NAV_CONFIG — auth-scoping by construction", () => {
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => { it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) { for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const item of NAV_CONFIG[id]) { for (const item of NAV_CONFIG[id]) {
expect(item.showLoggedIn).toBeUndefined(); expect(item.showLoggedIn).toBeUndefined();
expect(item.showLoggedOut).toBeUndefined(); expect(item.showLoggedOut).toBeUndefined();

View File

@@ -80,7 +80,6 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
{ label: "Downloads", href: "/downloads", icon: "downloads" }, { label: "Downloads", href: "/downloads", icon: "downloads" },
{ label: "Resume", href: "/resume", icon: "resume" }, { label: "Resume", href: "/resume", icon: "resume" },
{ label: "Contact", href: "/contact", icon: "contact" }, { label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy-policy", icon: "privacy" },
{ {
label: "GitHub", label: "GitHub",
href: "https://github.com/MikeFreno/", href: "https://github.com/MikeFreno/",
@@ -117,10 +116,6 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
{ label: "Contact", href: "/contact", icon: "contact" }, { label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }, { label: "Privacy", href: "/privacy", icon: "privacy" },
{ label: "Downloads", href: "/downloads", icon: "downloads" } { label: "Downloads", href: "/downloads", icon: "downloads" }
],
nook: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
] ]
}; };

View File

@@ -26,6 +26,7 @@ export function initPerformanceTracking() {
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []); const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
// Observe LCP
if (supported.has("largest-contentful-paint")) { if (supported.has("largest-contentful-paint")) {
try { try {
const lcpObserver = new PerformanceObserver((entryList) => { const lcpObserver = new PerformanceObserver((entryList) => {
@@ -39,6 +40,7 @@ export function initPerformanceTracking() {
} }
} }
// Observe CLS
if (supported.has("layout-shift")) { if (supported.has("layout-shift")) {
try { try {
const clsObserver = new PerformanceObserver((entryList) => { const clsObserver = new PerformanceObserver((entryList) => {
@@ -57,6 +59,7 @@ export function initPerformanceTracking() {
} }
} }
// Observe FID
if (supported.has("first-input")) { if (supported.has("first-input")) {
try { try {
const fidObserver = new PerformanceObserver((entryList) => { const fidObserver = new PerformanceObserver((entryList) => {
@@ -71,6 +74,7 @@ export function initPerformanceTracking() {
} }
} }
// Observe INP (event timing)
if (supported.has("event")) { if (supported.has("event")) {
try { try {
const interactions: number[] = []; const interactions: number[] = [];
@@ -92,6 +96,7 @@ export function initPerformanceTracking() {
} }
} }
// Get navigation timing metrics
window.addEventListener("load", () => { window.addEventListener("load", () => {
setTimeout(() => { setTimeout(() => {
const navTiming = performance.getEntriesByType( const navTiming = performance.getEntriesByType(
@@ -105,6 +110,7 @@ export function initPerformanceTracking() {
metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart; metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart;
} }
// Get FCP
const paintEntries = performance.getEntriesByType("paint"); const paintEntries = performance.getEntriesByType("paint");
const fcpEntry = paintEntries.find( const fcpEntry = paintEntries.find(
(entry) => entry.name === "first-contentful-paint" (entry) => entry.name === "first-contentful-paint"
@@ -129,6 +135,7 @@ export function initPerformanceTracking() {
} }
function sendMetrics() { function sendMetrics() {
// Only send if we have at least one metric
if (Object.keys(metrics).length === 0) { if (Object.keys(metrics).length === 0) {
return; return;
} }
@@ -150,6 +157,7 @@ function sendMetrics() {
const blob = new Blob([payload], { type: "application/json" }); const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon(apiUrl, blob); navigator.sendBeacon(apiUrl, blob);
} else { } else {
// Fallback to fetch with keepalive
fetch(apiUrl, { fetch(apiUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -160,5 +168,6 @@ function sendMetrics() {
); );
} }
// Clear metrics after sending
metrics = {}; metrics = {};
} }

View File

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

View File

@@ -61,6 +61,7 @@ export default async function AddImageToS3(
throw new Error("Failed to upload file to S3"); throw new Error("Failed to upload file to S3");
} }
// Create thumbnails for images (blog posts only)
if (type === "blog" && isImage) { if (type === "blog" && isImage) {
try { try {
const thumbnail = await resizeImage(file, 200, 200, 0.8); const thumbnail = await resizeImage(file, 200, 200, 0.8);

View File

@@ -81,7 +81,7 @@ describe("resolveSiteFromHost", () => {
}); });
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => { it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) { for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`); expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
expect(SITE_CONFIG[id].subdomain).toBe(id); expect(SITE_CONFIG[id].subdomain).toBe(id);
expect(SITE_CONFIG[id].titleSuffix).toBe( expect(SITE_CONFIG[id].titleSuffix).toBe(

View File

@@ -12,7 +12,7 @@
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`. * `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
*/ */
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo" | "nook"; export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
export interface Site { export interface Site {
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */ /** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
@@ -35,6 +35,8 @@ 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. */
@@ -87,8 +89,9 @@ 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/favicon.ico" faviconPath: "/nessa/favicon.ico"
}, },
lineage: { lineage: {
id: "lineage", id: "lineage",
@@ -97,9 +100,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: "#a13536", brandColor: "#a6e3a1",
ogDefaultImage: "/lineage/og-default.png", ogDefaultImage: "/lineage/og-default.png",
faviconPath: "/lineage/favicon/favicon.ico" faviconPath: "/lineage/favicon.ico"
}, },
gaze: { gaze: {
id: "gaze", id: "gaze",
@@ -108,9 +111,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/gaze", baseRoutePrefix: "/gaze",
displayName: "Gaze", displayName: "Gaze",
titleSuffix: " | Gaze", titleSuffix: " | Gaze",
brandColor: "#002cff", brandColor: "#f9e2af",
ogDefaultImage: "/gaze/og-default.png", ogDefaultImage: "/gaze/og-default.png",
faviconPath: "/gaze/favicon/favicon.ico" faviconPath: "/gaze/favicon.ico"
}, },
inputhalo: { inputhalo: {
id: "inputhalo", id: "inputhalo",
@@ -119,21 +122,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/inputhalo", baseRoutePrefix: "/inputhalo",
displayName: "InputHalo", displayName: "InputHalo",
titleSuffix: " | InputHalo", titleSuffix: " | InputHalo",
brandColor: "#41a5ff", brandColor: "#f38ba8",
ogDefaultImage: "/inputhalo/og-default.png", ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon/favicon.ico" faviconPath: "/inputhalo/favicon.ico"
},
nook: {
id: "nook",
subdomain: "nook",
domain: `nook.${BASE_DOMAIN}`,
baseRoutePrefix: "/nook",
displayName: "The Nook",
titleSuffix: " | The Nook",
brandColor: "#4C9FBC",
brandColorDark: "#4C9FBC",
ogDefaultImage: "/nook/og-default.png",
faviconPath: "/nook/favicon/favicon.ico"
} }
}; };
@@ -142,8 +133,7 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
SITE_CONFIG.nessa, SITE_CONFIG.nessa,
SITE_CONFIG.lineage, SITE_CONFIG.lineage,
SITE_CONFIG.gaze, SITE_CONFIG.gaze,
SITE_CONFIG.inputhalo, SITE_CONFIG.inputhalo
SITE_CONFIG.nook
]; ];
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */ /** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */

View File

@@ -22,7 +22,7 @@ function xmlEscape(s: string): string {
/** /**
* Generate a single `<url>` element for a given entry on a site. * Generate a single `<url>` element for a given entry on a site.
*/ */
function urlElement(site: Site, entry: SitemapEntry): string { export function urlElement(site: Site, entry: SitemapEntry): string {
const loc = `https://${site.domain}${entry.path}`; const loc = `https://${site.domain}${entry.path}`;
return ` <url> return ` <url>
<loc>${xmlEscape(loc)}</loc> <loc>${xmlEscape(loc)}</loc>

View File

@@ -28,11 +28,13 @@ describe("generateSitemap", () => {
it("generates valid XML for main site with all expected routes", () => { it("generates valid XML for main site with all expected routes", () => {
const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main); const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main);
// Basic structure
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>'); expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain( expect(xml).toContain(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
); );
// All main site paths present with freno.me domain
const locs = extractLocs(xml); const locs = extractLocs(xml);
expect(locs).toContain("https://freno.me/"); expect(locs).toContain("https://freno.me/");
expect(locs).toContain("https://freno.me/blog"); expect(locs).toContain("https://freno.me/blog");
@@ -40,19 +42,21 @@ describe("generateSitemap", () => {
expect(locs).toContain("https://freno.me/login"); expect(locs).toContain("https://freno.me/login");
expect(locs).toContain("https://freno.me/resume"); expect(locs).toContain("https://freno.me/resume");
expect(locs).toContain("https://freno.me/downloads"); expect(locs).toContain("https://freno.me/downloads");
expect(locs).toContain("https://freno.me/privacy-policy");
expect(locs.length).toBe(7); // Exactly 6 entries
expect(locs.length).toBe(6);
// Verify well-formedness by checking balanced tags
expect(xml).toContain("</urlset>"); expect(xml).toContain("</urlset>");
const urlOpens = (xml.match(/<url>/g) || []).length; const urlOpens = (xml.match(/<url>/g) || []).length;
const urlCloses = (xml.match(/<\/url>/g) || []).length; const urlCloses = (xml.match(/<\/url>/g) || []).length;
expect(urlOpens).toBe(urlCloses); expect(urlOpens).toBe(urlCloses);
expect(urlOpens).toBe(7); expect(urlOpens).toBe(6);
}); });
it("generates valid parseable XML for lineage site", () => { it("generates valid parseable XML for lineage site", () => {
const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage); const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage);
// Verify balanced tags
expect(xml).toContain("</urlset>"); expect(xml).toContain("</urlset>");
const urlOpens = (xml.match(/<url>/g) || []).length; const urlOpens = (xml.match(/<url>/g) || []).length;
const urlCloses = (xml.match(/<\/url>/g) || []).length; const urlCloses = (xml.match(/<\/url>/g) || []).length;
@@ -69,6 +73,7 @@ describe("generateSitemap", () => {
expect(locs).toContain("https://nessa.freno.me/privacy"); expect(locs).toContain("https://nessa.freno.me/privacy");
expect(locs.length).toBe(3); expect(locs.length).toBe(3);
// No leakage from main site
for (const loc of locs) { for (const loc of locs) {
expect(loc).not.toContain("://freno.me/"); expect(loc).not.toContain("://freno.me/");
expect(loc).not.toContain("://freno.me/blog"); expect(loc).not.toContain("://freno.me/blog");
@@ -147,7 +152,7 @@ describe("SITEMAP_ROUTES validation", () => {
}); });
it("each site has at least the home page entry", () => { it("each site has at least the home page entry", () => {
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"]; const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
for (const id of siteIds) { for (const id of siteIds) {
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true); expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
} }

View File

@@ -47,8 +47,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
{ path: "/contact", changefreq: "monthly", priority: 0.7 }, { path: "/contact", changefreq: "monthly", priority: 0.7 },
{ path: "/login", changefreq: "monthly", priority: 0.5 }, { path: "/login", changefreq: "monthly", priority: 0.5 },
{ path: "/resume", changefreq: "yearly", priority: 0.6 }, { path: "/resume", changefreq: "yearly", priority: 0.6 },
{ path: "/downloads", changefreq: "weekly", priority: 0.8 }, { path: "/downloads", changefreq: "weekly", priority: 0.8 }
{ path: "/privacy-policy", changefreq: "yearly", priority: 0.4 }
], ],
// ── Subdomain sites ────────────────────────────────────────────────── // ── Subdomain sites ──────────────────────────────────────────────────
@@ -78,11 +77,5 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
{ path: "/", changefreq: "weekly", priority: 1.0 }, { path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/contact", changefreq: "monthly", priority: 0.6 }, { path: "/contact", changefreq: "monthly", priority: 0.6 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 } { path: "/privacy", changefreq: "yearly", priority: 0.4 }
],
nook: [
{ path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
] ]
}; };

View File

@@ -45,12 +45,15 @@ export function useCountdown(options: UseCountdownOptions = {}) {
}; };
const startCountdown = (expiresAt: string | Date) => { const startCountdown = (expiresAt: string | Date) => {
// Clear any existing interval
if (intervalId !== null) { if (intervalId !== null) {
clearInterval(intervalId); clearInterval(intervalId);
} }
// Calculate immediately
calculateRemaining(expiresAt); calculateRemaining(expiresAt);
// Then update every second
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000); intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
}; };
@@ -61,6 +64,7 @@ export function useCountdown(options: UseCountdownOptions = {}) {
} }
}; };
// Cleanup on unmount
onCleanup(() => { onCleanup(() => {
stopCountdown(); stopCountdown();
}); });

102
src/middleware.ts Normal file
View File

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

View File

@@ -1,5 +1,5 @@
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { HttpHeader, HttpStatusCode } from "@solidjs/start"; import { HttpStatusCode } from "@solidjs/start";
import { useLocation, useNavigate } from "@solidjs/router"; import { useLocation, useNavigate } from "@solidjs/router";
import { createSignal, onCleanup, onMount, Show } from "solid-js"; import { createSignal, onCleanup, onMount, Show } from "solid-js";
import { TerminalErrorPage } from "~/components/TerminalErrorPage"; import { TerminalErrorPage } from "~/components/TerminalErrorPage";
@@ -90,14 +90,6 @@ export default function NotFound() {
description="404 - Page not found. The page you're looking for doesn't exist." description="404 - Page not found. The page you're looking for doesn't exist."
/> />
<HttpStatusCode code={404} /> <HttpStatusCode code={404} />
{/* Cache 404/fallback responses at the edge (Vercel caches 404s) so
bots/monitors that probe dead paths don't re-render the full page
on every request. Function headers override vercel.json here. */}
<HttpHeader name="Cache-Control" value="public, max-age=0" />
<HttpHeader
name="CDN-Cache-Control"
value="public, s-maxage=300, stale-while-revalidate=86400"
/>
<TerminalErrorPage <TerminalErrorPage
errorContent={errorContent} errorContent={errorContent}
quickActions={quickActions} quickActions={quickActions}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ export async function GET(_event: APIEvent) {
}); });
} }
// Stream the XML content from S3
const body = await response.Body.transformToString(); const body = await response.Body.transformToString();
return new Response(body, { return new Response(body, {

Some files were not shown because too many files have changed in this diff Show More