Compare commits

...

28 Commits

Author SHA1 Message Date
3fe3516b9e nook helper 2026-09-22 22:22:13 -04:00
bef8f8414b utilization reduction 2026-09-22 16:38:43 -04:00
f3e9cdf0bd fix sentry + note email delivery 2026-09-06 13:53:49 -04:00
7ecc0f68d7 increase width cap 2026-09-06 01:08:52 -04:00
00a26a9e49 resized+compressed 2026-09-06 01:06:58 -04:00
163c68fcb8 new with temp demo 2026-09-05 20:02:10 -04:00
3149eba196 fix missing height 2026-09-05 17:20:12 -04:00
bc65789431 fix mobile overlap 2026-09-05 16:59:36 -04:00
21f1f07a99 fix download routing, fix layout shift 2026-09-05 16:45:40 -04:00
23f86681af better feature design 2026-09-05 16:06:27 -04:00
8492821f75 better fan ui 2026-09-05 15:08:36 -04:00
17bb43851e midway 2026-09-05 10:33:26 -04:00
fd68efdb78 nook bug reporting handler 2026-09-01 17:44:20 -04:00
1ee61d8fe4 remove "The Nook" in license key phrasing 2026-08-28 14:15:40 -04:00
6c024342ba fixed privacy routing 2026-08-28 13:16:28 -04:00
9860213760 remove - Michael 2026-08-27 23:29:01 -04:00
cb584f8b68 license resend, license pretified 2026-08-27 23:03:33 -04:00
8c5fe25c2b fix email trigger 2026-08-27 20:42:17 -04:00
22f40b2284 favicons 2026-08-27 14:18:12 -04:00
b10a81068d nook icon 2026-08-27 13:38:19 -04:00
6d3315c71f fix nook subdomain routing 2026-08-27 13:06:41 -04:00
20944bf46e update nook brand color 2026-08-27 11:58:54 -04:00
a161c96b05 nook: surface per-license maxDevices in activate + status responses 2026-08-27 09:26:14 -04:00
082e503d4f nook: per-license device caps + grant CLI for free licenses 2026-08-27 09:16:31 -04:00
0031055315 nook: fix idempotent reactivation SQL bind (row object -> id) 2026-08-27 08:55:51 -04:00
9ae1b8a2e5 nook: commit license keygen script (blind-spot scripts/ ignore) 2026-08-27 08:55:45 -04:00
e4a9be4899 prep for the nook 2026-08-26 17:41:57 -04:00
898c891bd5 chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars,
cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and
12 unused dependencies removed
Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose
blocks tightened; dead commented-out config removed
Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10);
login formHandler split into 3 submitters (CCN 63->~5); account page render
split into 8 section components (CCN 40); updatePost SQL builder rebuilt;
assert*Owned consolidated behind generic assertOwnedBy
Defensive guards: 4 redundant rethrow/nullish guards removed
2026-08-11 13:36:18 -04:00
178 changed files with 6261 additions and 2751 deletions

View File

@@ -75,3 +75,11 @@ 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
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
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,3 +31,7 @@ perf-results-*.json
# System Files
.DS_Store
Thumbs.db
# pygienium run-state and check artifacts
.pygienium/
scripts/
.cache

View File

@@ -10,21 +10,7 @@ export default defineConfig({
org: "mikefreno",
project: "freno-dev",
authToken: process.env.SENTRY_AUTH_TOKEN,
telemetry: false,
sourcemaps: {
assets: [
{
type: "bundle",
path: "dist/client/assets/",
urlPrefix: "~/assets/"
},
{
type: "sourcemap",
path: "dist/client/assets/",
urlPrefix: "~/assets/"
}
]
}
telemetry: false
})
],
build: {

BIN
bun.lockb

Binary file not shown.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -0,0 +1,21 @@
{
"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.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
public/nook/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View File

@@ -1,9 +1,21 @@
User-agent: *
Allow: /
Allow: /blog
Allow: /projects
Disallow: /login
Disallow: /debug/
# 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: /success
Disallow: /test
Sitemap: https://www.freno.me/sitemap.xml

View File

@@ -0,0 +1,45 @@
/**
* 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

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

View File

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

View File

@@ -574,3 +574,184 @@ a.hover-underline-animation:hover::after {
.shaker:hover {
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

@@ -35,10 +35,8 @@ function AppLayout(props: { children: any }) {
let lastScrollY = 0;
onMount(() => {
// Initialize performance tracking
initPerformanceTracking();
// Start monitoring for new deployments
startDeploymentMonitoring();
const windowWidth = createWindowWidth();

View File

@@ -366,6 +366,11 @@ function MainRightBarContent() {
Contact Me
</a>
</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>
<a
href="https://github.com/MikeFreno/"
@@ -460,7 +465,7 @@ function MainRightBarContent() {
);
}
export function RightBarContent() {
function RightBarContent() {
const site = useSite();
return (
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>

View File

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

View File

@@ -0,0 +1,29 @@
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

@@ -4,16 +4,16 @@ import { buildMainSiteUrl } from "~/lib/site-context";
export default function SubdomainFooter() {
return (
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
<div class="relative flex items-center text-sm">
<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 mx-auto text-center underline underline-offset-4 transition-colors"
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 absolute right-4 underline underline-offset-4 transition-colors"
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors sm:absolute sm:right-4"
>
see more products
</A>

View File

@@ -138,9 +138,6 @@ export function Typewriter(props: {
entries.forEach((entry) => {
// If component leaves viewport while animating, we could pause
// 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,7 +63,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
};
reader.readAsDataURL(file);
// Refresh the S3 file list
await loadAttachments();
}
} catch (err) {
@@ -81,7 +80,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
body: JSON.stringify({ key })
});
// Refresh the S3 file list
await loadAttachments();
} catch (err) {
console.error("Failed to delete file:", err);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,6 @@ export const Mermaid = Node.create({
getAttrs: (element) => {
if (typeof element === "string") return false;
// Skip if already has data-type or data-mermaid-diagram attribute
if (
element.hasAttribute("data-type") ||
element.hasAttribute("data-mermaid-diagram")
@@ -83,7 +82,6 @@ export const Mermaid = Node.create({
const content = code.textContent || "";
const trimmedContent = content.trim();
// Check if this looks like a mermaid diagram
const mermaidKeywords = [
"graph ",
"sequenceDiagram",
@@ -174,12 +172,10 @@ export const Mermaid = Node.create({
code.textContent = node.attrs.content || "";
pre.appendChild(code);
// Validation status indicator
const statusIndicator = document.createElement("div");
statusIndicator.className =
"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 content = node.attrs.content || "";
if (!content.trim()) {
@@ -250,7 +246,6 @@ export const Mermaid = Node.create({
(p: any) => p.spec?.key === "mermaidSelection"
);
// Use intersection observer to trigger update when visible
let updateInterval: ReturnType<typeof setInterval> | null = null;
const observer = new IntersectionObserver(
(entries) => {

View File

@@ -1,29 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -0,0 +1,367 @@
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

@@ -0,0 +1,370 @@
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

@@ -0,0 +1,263 @@
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

@@ -128,7 +128,6 @@ export default function Button(props: ButtonProps) {
height: number;
} | null>(null);
// Measure content dimensions when not loading
createEffect(() => {
if (!local.loading && contentRef) {
const rect = contentRef.getBoundingClientRect();

View File

@@ -23,7 +23,6 @@ export const AUTH_CONFIG = {
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
// Other Auth Settings
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
@@ -74,9 +73,6 @@ export const RATE_LIMITS = {
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
} as const;
/** Rate limit store cleanup interval (5 minutes) */
export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// ============================================================
// ACCOUNT SECURITY
// ============================================================
@@ -136,22 +132,6 @@ export const NETWORK_CONFIG = {
RETRY_DELAY_MS: 1000
} 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
// ============================================================
@@ -177,41 +157,6 @@ export const BREAKPOINTS = {
DESKTOP_MIN_WIDTH: 1025
} 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
// ============================================================
@@ -284,22 +229,4 @@ export const LINEAGE_CONFIG = {
JWT_AUDIENCE: "lineage-app" 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. */
export function resolveClientSite(): Site {
function resolveClientSite(): Site {
if (typeof window === "undefined") return MAIN_SITE;
const injected = window.__SITE__;
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];

View File

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

View File

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

11
src/env/client.ts vendored
View File

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

8
src/env/server.ts vendored
View File

@@ -67,7 +67,13 @@ const serverEnvSchema = z.object({
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
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>;

View File

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

View File

@@ -3,21 +3,6 @@
* 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)
* @param token - JWT token string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,6 @@ export default async function AddImageToS3(
throw new Error("Failed to upload file to S3");
}
// Create thumbnails for images (blog posts only)
if (type === "blog" && isImage) {
try {
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", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
expect(SITE_CONFIG[id].subdomain).toBe(id);
expect(SITE_CONFIG[id].titleSuffix).toBe(

View File

@@ -12,7 +12,7 @@
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
*/
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo" | "nook";
export interface Site {
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
@@ -122,6 +122,18 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
brandColor: "#41a5ff",
ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon/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"
}
};
@@ -130,7 +142,8 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
SITE_CONFIG.nessa,
SITE_CONFIG.lineage,
SITE_CONFIG.gaze,
SITE_CONFIG.inputhalo
SITE_CONFIG.inputhalo,
SITE_CONFIG.nook
];
/** 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.
*/
export function urlElement(site: Site, entry: SitemapEntry): string {
function urlElement(site: Site, entry: SitemapEntry): string {
const loc = `https://${site.domain}${entry.path}`;
return ` <url>
<loc>${xmlEscape(loc)}</loc>

View File

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

View File

@@ -47,7 +47,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
{ path: "/contact", changefreq: "monthly", priority: 0.7 },
{ path: "/login", changefreq: "monthly", priority: 0.5 },
{ 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 ──────────────────────────────────────────────────
@@ -77,5 +78,11 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
{ path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
{ 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,15 +45,12 @@ export function useCountdown(options: UseCountdownOptions = {}) {
};
const startCountdown = (expiresAt: string | Date) => {
// Clear any existing interval
if (intervalId !== null) {
clearInterval(intervalId);
}
// Calculate immediately
calculateRemaining(expiresAt);
// Then update every second
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
};
@@ -64,7 +61,6 @@ export function useCountdown(options: UseCountdownOptions = {}) {
}
};
// Cleanup on unmount
onCleanup(() => {
stopCountdown();
});

View File

@@ -1,5 +1,5 @@
import { PageHead } from "~/components/PageHead";
import { HttpStatusCode } from "@solidjs/start";
import { HttpHeader, HttpStatusCode } from "@solidjs/start";
import { useLocation, useNavigate } from "@solidjs/router";
import { createSignal, onCleanup, onMount, Show } from "solid-js";
import { TerminalErrorPage } from "~/components/TerminalErrorPage";
@@ -90,6 +90,14 @@ export default function NotFound() {
description="404 - Page not found. The page you're looking for doesn't exist."
/>
<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
errorContent={errorContent}
quickActions={quickActions}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import type { APIEvent } from "@solidjs/start/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
/**
* Serves the TheNook appcast.xml file from S3
* This endpoint is used by Sparkle updater to check for new versions
*
* URL: https://freno.me/api/TheNook/appcast.xml
*/
export async function GET(_event: APIEvent) {
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
const key = "api/TheNook/appcast.xml";
const credentials = {
accessKeyId: env.MY_AWS_ACCESS_KEY,
secretAccessKey: env.MY_AWS_SECRET_KEY
};
try {
const client = new S3Client({
region: env.AWS_REGION,
credentials: credentials
});
const command = new GetObjectCommand({
Bucket: bucket,
Key: key
});
const response = await client.send(command);
if (!response.Body) {
return new Response("Appcast not found", {
status: 404,
headers: {
"Content-Type": "text/plain"
}
});
}
const body = await response.Body.transformToString();
return new Response(body, {
status: 200,
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
"Access-Control-Allow-Origin": "*" // Allow CORS for Sparkle appcast
}
});
} catch (error) {
console.error("Failed to fetch appcast:", error);
return new Response("Internal Server Error", {
status: 500,
headers: {
"Content-Type": "text/plain"
}
});
}
}

View File

@@ -20,7 +20,6 @@ export async function GET(event: APIEvent) {
"emailLogin",
(caller, params) => caller.auth.emailLogin(params),
(error) => {
// Check for token expiration
const message = error instanceof Error ? error.message : "";
const isTokenError =
message.includes("expired") || message.includes("invalid");

View File

@@ -62,17 +62,14 @@ export async function GET(event: APIEvent) {
}
try {
// Create tRPC caller to invoke the emailVerification procedure
const caller = await createServerCaller(event);
// Call the email verification handler
const result = await caller.auth.emailVerification({
email,
token
});
if (result.success) {
// Show success page
return new Response(
`
<!DOCTYPE html>
@@ -136,7 +133,6 @@ export async function GET(event: APIEvent) {
} catch (error) {
console.error("Email verification callback error:", error);
// Check if it's a token expiration error
const errorMessage =
error instanceof Error ? error.message : "server_error";
const isTokenError =

View File

@@ -24,12 +24,13 @@ export async function GET(event: APIEvent) {
});
}
// Validate filename format (only allow Gaze or InputHalo files)
const validPrefixes = ["Gaze", "InputHalo"];
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
if (
!isValidPrefix ||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
(!filename.endsWith(".dmg") &&
!filename.endsWith(".delta") &&
!filename.endsWith(".zip"))
) {
return new Response("Invalid file format", {
status: 400,
@@ -70,12 +71,12 @@ export async function GET(event: APIEvent) {
});
}
// Get content type based on file extension
const contentType = filename.endsWith(".dmg")
? "application/x-apple-diskimage"
: "application/octet-stream";
const contentType = filename.endsWith(".zip")
? "application/zip"
: filename.endsWith(".dmg")
? "application/x-apple-diskimage"
: "application/octet-stream";
// Stream the file content from S3
const body = await response.Body.transformToByteArray();
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
@@ -93,7 +94,6 @@ export async function GET(event: APIEvent) {
} catch (error) {
console.error(`Failed to fetch ${filename} from S3:`, error);
// Check if it's a not found error
if (error instanceof Error && error.name === "NoSuchKey") {
return new Response("File not found in storage", {
status: 404,

23
src/routes/api/health.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { APIEvent } from "@solidjs/start/server";
/**
* Lightweight uptime/monitoring probe.
*
* Returns a tiny 200 that the edge CDN caches (`CDN-Cache-Control`), so
* health checks (Sentry uptime monitors, external monitors, `curl`) cost a
* fraction of a rendered page instead of a full SSR response. Point monitors
* at `https://freno.me/api/health`.
*/
export async function GET(_event: APIEvent) {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
// Browser: always revalidate (tiny body, no reason to cache).
"Cache-Control": "public, max-age=0",
// Edge CDN: serve from cache for 1 min, then stale-while-revalidate
// for a day so repeated probe hits never touch the origin.
"CDN-Cache-Control": "public, s-maxage=60, stale-while-revalidate=86400"
}
});
}

View File

@@ -58,8 +58,3 @@ export function bearerToken(event: APIEvent): string | null {
const m = auth.match(/^Bearer\s+(.+)$/i);
return m?.[1]?.trim() ?? null;
}
/** Parse the JSON request body. */
export async function jsonBody<T = any>(event: APIEvent): Promise<T> {
return await event.request.json();
}

View File

@@ -0,0 +1,21 @@
/**
* Shared JSON response helpers for the The Nook license API routes.
* Underscore-prefixed file — not a route.
*/
export function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" }
});
}
export function error(message: string, status: number): Response {
return json({ error: message }, status);
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUuid(value: unknown): value is string {
return typeof value === "string" && UUID_RE.test(value);
}

View File

@@ -0,0 +1,103 @@
import type { Client } from "@libsql/client/web";
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
import { json, error, isUuid } from "./_lib";
/**
* POST /api/the-nook/activate
* Body: { key, deviceFingerprint, deviceName }
*
* Re-verifies the Ed25519 license signature server-side (defense in depth —
* the app already verified it offline), enforces the 3-device activation cap,
* and records/refreshes an activation row. Re-activating an already-activated
* fingerprint is idempotent and does NOT consume an extra seat.
*/
export async function POST(event: APIEvent) {
let body: unknown;
try {
body = await event.request.json();
} catch {
return error("Invalid JSON", 400);
}
const b = (body ?? {}) as Record<string, unknown>;
const key = typeof b.key === "string" ? b.key : "";
const fingerprint = b.deviceFingerprint;
const deviceName =
typeof b.deviceName === "string" && b.deviceName.length > 0
? b.deviceName
: "Mac";
if (!key || !isUuid(fingerprint)) {
return error("Invalid request", 400);
}
if (!verifyLicenseKey(key)) {
return error("Invalid license key", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const licenseRes = await conn.execute({
sql: "SELECT id, email, revoked, max_devices FROM licenses WHERE key = ?",
args: [key]
});
if (licenseRes.rows.length === 0) {
return error("License not found", 404);
}
const license = licenseRes.rows[0] as {
id: string;
email: string;
revoked: number;
maxDevices: number;
};
if (license.revoked === 1) {
return error("License revoked", 403);
}
// Idempotent re-activation: already-active fingerprint does not consume a seat.
const existingRes = await conn.execute({
sql: `SELECT id FROM activations
WHERE license_id = ? AND device_fingerprint = ? AND deactivated_at IS NULL`,
args: [license.id, fingerprint]
});
if (existingRes.rows.length > 0) {
const existing = existingRes.rows[0] as { id: string }; // row we inserted as an activation
await conn.execute({
sql: "UPDATE activations SET activated_at = ? WHERE id = ?",
args: [new Date().toISOString(), existing.id]
});
const count = await activeCount(conn, license.id);
return json({ ok: true, email: license.email, activatedCount: count, maxDevices: license.maxDevices });
}
const count = await activeCount(conn, license.id);
if (count >= license.maxDevices) {
return error(`Activation limit reached (${license.maxDevices} devices)`, 409);
}
await conn.execute({
sql: `INSERT INTO activations
(id, license_id, device_fingerprint, device_name, activated_at, deactivated_at)
VALUES (?, ?, ?, ?, ?, NULL)`,
args: [
crypto.randomUUID(),
license.id,
fingerprint,
deviceName,
new Date().toISOString()
]
});
return json({ ok: true, email: license.email, activatedCount: count + 1, maxDevices: license.maxDevices });
}
async function activeCount(
conn: Client,
licenseId: string
): Promise<number> {
const res = await conn.execute({
sql: "SELECT COUNT(*) AS n FROM activations WHERE license_id = ? AND deactivated_at IS NULL",
args: [licenseId]
});
return Number((res.rows[0] as { n: number | bigint }).n);
}

View File

@@ -0,0 +1,32 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap } from "~/server/nook";
import { json, error } from "./_lib";
/**
* GET /api/the-nook/by-session?session_id=cs_...
*
* Used by the success page (nook.freno.me/success) to retrieve the license
* key once the `checkout.session.completed` webhook has landed. The page
* polls this endpoint until the license row exists.
*/
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const sessionId = url.searchParams.get("session_id");
if (!sessionId || !sessionId.startsWith("cs_")) {
return error("Invalid session id", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const res = await conn.execute({
sql: "SELECT key, email FROM licenses WHERE stripe_session_id = ?",
args: [sessionId]
});
if (res.rows.length === 0) {
return error("Not found", 404);
}
const license = res.rows[0] as { key: string; email: string };
return json({ key: license.key, email: license.email });
}

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