Compare commits

...

22 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
93 changed files with 3617 additions and 272 deletions

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ 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: {

View File

@@ -11,7 +11,8 @@
"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",

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

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

@@ -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/"

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

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

@@ -101,7 +101,8 @@ function handleChunkError(source: string): void {
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");
@@ -111,9 +112,8 @@ window.addEventListener("error", (event) => {
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");

View File

@@ -19,13 +19,14 @@ import { SITE_CONFIG, type SiteId } from "./site-context";
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"
]);

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/",

View File

@@ -130,8 +130,8 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/nook",
displayName: "The Nook",
titleSuffix: " | The Nook",
brandColor: "#8b5cf6",
brandColorDark: "#a78bfa",
brandColor: "#4C9FBC",
brandColorDark: "#4C9FBC",
ogDefaultImage: "/nook/og-default.png",
faviconPath: "/nook/favicon/favicon.ico"
}

View File

@@ -40,14 +40,15 @@ 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");
expect(locs.length).toBe(6);
expect(locs.length).toBe(7);
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", () => {

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 ──────────────────────────────────────────────────

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}

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

@@ -0,0 +1,137 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
// The route's core contracts:
// 1. A valid report is emailed to the owner with the machine snapshot
// and description, HTML-escaped.
// 2. Invalid payloads never reach the mailer.
// 3. The rate limiter halts floods per fingerprint (falling back to IP).
// sendEmail and the limiter are mocked so the test sends no real mail.
const sentEmails: { to: string; subject: string; html: string }[] = [];
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
mock.module("~/server/email", () => ({
default: async (to: string, subject: string, html: string) => {
sentEmails.push({ to, subject, html });
return { success: true, messageId: "test" };
}
}));
mock.module("~/server/bug-report-rate-limit", () => ({
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
}));
const { POST } = await import("./report");
/** A minimal APIEvent double: JSON body plus optional headers. */
function request(body: unknown, headers: Record<string, string> = {}) {
return {
request: {
json: async () => body,
headers: new Headers(headers)
}
};
}
const validPayload = {
appVersion: "The Nook 0.2.0 (12)",
title: "Island flickers",
description: "It happened <twice> & stayed",
contact: "",
machine: {
macOS: "macOS 15.5",
model: "Mac15,6",
cpu: "Apple M3 Pro",
memoryGB: 36,
freeDiskGB: 100,
locale: "en_US"
},
displays: [
{
name: "Built-in",
boundsPx: "3456×2234 pt",
scale: "2.0x",
hz: "120",
builtin: true
},
{
name: "DELL U2723QE",
boundsPx: "3008×1692 pt",
scale: "1.0x",
hz: "120",
builtin: false
}
]
};
describe("POST /api/the-nook/report", () => {
beforeEach(() => {
sentEmails.length = 0;
tokenAnswers.length = 0;
});
it("emails the owner a report containing the snapshot, escaped", async () => {
const res = await POST(request(validPayload));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
expect(sentEmails).toHaveLength(1);
const email = sentEmails[0]!;
expect(email.subject).toBe("The Nook bug report: Island flickers");
expect(email.to).toBe("michael@freno.me");
for (const fact of ["Mac15,6", "Apple M3 Pro", "macOS 15.5", "36"]) {
expect(email.html).toContain(fact);
}
expect(email.html).toContain("Built-in");
expect(email.html).toContain("3456×2234 pt");
expect(email.html).toContain("DELL U2723QE");
expect(email.html).toContain("3008×1692 pt");
expect(email.html).toContain("<b>builtin:</b> false");
expect(email.html).toContain("It happened &lt;twice&gt; &amp; stayed");
});
it("rejects a missing, empty, or oversized title", async () => {
const empty = await POST(request({ ...validPayload, title: " " }));
expect(empty.status).toBe(400);
const missing = await POST(
request({ ...validPayload, title: undefined })
);
expect(missing.status).toBe(400);
const tooLong = await POST(
request({ ...validPayload, title: "x".repeat(201) })
);
expect(tooLong.status).toBe(400);
expect(sentEmails).toHaveLength(0);
});
it("rejects a malformed reply-to contact", async () => {
const res = await POST(
request({ ...validPayload, contact: "not-an-email" })
);
expect(res.status).toBe(400);
expect(sentEmails).toHaveLength(0);
});
it("rejects more than eight displays", async () => {
const displays = Array.from({ length: 9 }, () => validPayload.displays[0]!);
const res = await POST(request({ ...validPayload, displays }));
expect(res.status).toBe(400);
expect(sentEmails).toHaveLength(0);
});
it("returns 429 with Retry-After when the limiter denies the token", async () => {
tokenAnswers.push({ allowed: false, retryAfterSec: 3600 });
const res = await POST(request(validPayload));
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("3600");
expect(sentEmails).toHaveLength(0);
});
it("still succeeds when no fingerprint header exists", async () => {
const res = await POST(
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
);
expect(res.status).toBe(200);
expect(sentEmails).toHaveLength(1);
});
});

View File

@@ -0,0 +1,131 @@
import type { APIEvent } from "@solidjs/start/server";
import { z } from "zod";
import sendEmail from "~/server/email";
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
import { json, error } from "./_lib";
/**
* POST /api/the-nook/report
* Body: { appVersion, title, description, contact?, machine, displays }
*
* Emails the developer a Nook bug report in plain prose — each field on
* its own line. The Mac client collects the machine facts itself (model,
* CPU, memory, every display); styling is irrelevant, information is the
* product here.
*/
const displaySchema = z.object({
name: z.string().max(200).default(""),
boundsPx: z.string().max(100).default(""),
scale: z.string().max(20).default(""),
hz: z.string().max(20).default(""),
builtin: z.boolean().default(false)
});
const reportSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(20_000).default(""),
contact: z.string().trim().max(254).default(""),
appVersion: z.string().trim().max(100).default(""),
machine: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.default({}),
displays: z.array(displaySchema).max(8).default([])
});
type Report = z.infer<typeof reportSchema>;
const contactRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// One escaping contract for every interpolated string (many call sites).
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderBody(report: Report): string {
const machineLines = Object.entries(report.machine).map(
([key, value]) => `<b>${escapeHtml(key)}:</b> ${escapeHtml(String(value))}`
);
const displayLines = report.displays.map((display) =>
[
`<b>name:</b> ${escapeHtml(display.name)}`,
`<b>boundsPx:</b> ${escapeHtml(display.boundsPx)}`,
`<b>scale:</b> ${escapeHtml(display.scale)}`,
`<b>hz:</b> ${escapeHtml(display.hz)}`,
`<b>builtin:</b> ${escapeHtml(String(display.builtin))}`
].join(" · ")
);
return [
`<h2>The Nook bug report</h2>`,
`<h3>“${escapeHtml(report.title)}”</h3>`,
report.description
? `<h3>What happened</h3><div>${escapeHtml(report.description).replace(/\r\n|\n|\r/g, "<br>")}</div>`
: "",
report.contact && contactRe.test(report.contact)
? `<p><b>Reply-to:</b> ${escapeHtml(report.contact)}</p>`
: "",
`<h3>Snapshot</h3>`,
report.appVersion ? `<p><b>${escapeHtml(report.appVersion)}</b></p>` : "",
machineLines.length > 0 ? `<p>${machineLines.join("<br>")}</p>` : "",
displayLines.length > 0
? `<h3>Displays</h3><div>${displayLines.map((line) => `<div>${line}</div>`).join("")}</div>`
: ""
]
.filter((chunk) => chunk !== "")
.join("\n");
}
export async function POST(event: APIEvent): Promise<Response> {
let raw: unknown;
try {
raw = await event.request.json();
} catch {
return error("Invalid JSON", 400);
}
const parsed = reportSchema.safeParse(raw);
if (!parsed.success) {
return error("Invalid report", 400);
}
const report = parsed.data;
if (report.contact && !contactRe.test(report.contact)) {
return error("Invalid reply-to email", 400);
}
const fingerprint = (
event.request.headers.get("x-nook-fingerprint") ?? ""
)
.trim()
.toLowerCase();
const forwarded = event.request.headers.get("x-forwarded-for");
const rateKey =
fingerprint ||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
const token = takeBugReportToken(rateKey);
if (!token.allowed) {
return new Response(JSON.stringify({ error: "Too many reports" }), {
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(token.retryAfterSec ?? 3600)
}
});
}
const subject = `The Nook bug report: ${report.title.slice(0, 100)}`;
const result = await sendEmail(
CONTACT_RECIPIENT_EMAIL,
subject,
renderBody(report)
);
if (!result.success) {
return error("Failed to send report", 500);
}
return json({ success: true });
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
// The route's two core contracts:
// 1. A license key is emailed only when the requested email owns one.
// 2. The HTTP response is identical whether or not a license exists —
// callers can't learn whether an email is registered.
// NookConnectionFactory and the nook helper module are mocked so the test
// needs no real DB, env, or outbound email.
let licenseRows: { key: string }[] = [];
const sentEmails: { to: string; key: string }[] = [];
const queries: string[] = [];
const fakeConn = {
execute: async (q: { sql: string; args?: unknown[] }) => {
queries.push(q.sql);
if (q.sql.includes("FROM licenses")) return { rows: licenseRows };
return { rows: [] };
}
};
mock.module("~/server/db-connections", () => ({
NookConnectionFactory: () => fakeConn
}));
mock.module("~/server/nook", () => ({
nookSchemaBootstrap: Promise.resolve(),
emailLicenseKey: async (to: string, key: string) => {
sentEmails.push({ to, key });
}
}));
const { POST } = await import("./resend-license");
function request(body: unknown): any {
return { request: { json: async () => body } };
}
describe("POST /api/the-nook/resend-license", () => {
beforeEach(() => {
licenseRows = [];
sentEmails.length = 0;
queries.length = 0;
});
it("rejects an invalid email", async () => {
const res = await POST(request({ email: "not-an-email" }));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid email" });
expect(sentEmails).toHaveLength(0);
});
it("emails the stored key when a license exists for the email", async () => {
licenseRows = [{ key: "NOOK-ABC123" }];
const res = await POST(request({ email: "USER@Example.com " }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
expect(sentEmails).toEqual([{ to: "user@example.com", key: "NOOK-ABC123" }]);
expect(queries.some((q) => q.toLowerCase().includes("lower(email)"))).toBe(true);
});
it("sends nothing when no license exists, but still succeeds", async () => {
const res = await POST(request({ email: "nobody@example.com" }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
expect(sentEmails).toHaveLength(0);
});
it("returns an identical response whether or not a license exists", async () => {
const withLicense = await POST(request({ email: "buyer@example.com" }));
licenseRows = [{ key: "NOOK-XYZ" }];
const withoutLicense = await POST(request({ email: "buyer@example.com" }));
expect(withLicense.status).toBe(withoutLicense.status);
expect(await withLicense.text()).toBe(await withoutLicense.text());
});
});

View File

@@ -0,0 +1,42 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, emailLicenseKey } from "~/server/nook";
import { json, error } from "./_lib";
/**
* POST /api/the-nook/resend-license
* Body: { email }
*
* Re-sends the most recent license key to a buyer who lost it. If the email
* owns a license, the key is emailed; otherwise nothing is sent. The response
* is identical either way so callers can't learn whether an email has a
* license — only validation failures differ.
*/
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 email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return error("Invalid email", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const res = await conn.execute({
sql: "SELECT key FROM licenses WHERE lower(email) = lower(?) ORDER BY created_at DESC LIMIT 1",
args: [email]
});
if (res.rows.length > 0) {
const { key } = res.rows[0] as { key: string };
await emailLicenseKey(email, key);
}
return json({ success: true });
}

View File

@@ -1,7 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, issueLicense } from "~/server/nook";
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
import { json } from "../_lib";
import { createHmac, timingSafeEqual } from "node:crypto";
@@ -20,7 +20,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
interface StripeSessionCompleted {
id: string;
type: string;
customer_details?: { email?: string };
data?: { object?: { id?: string; customer_details?: { email?: string } } };
}
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
@@ -46,31 +46,6 @@ function verifyStripeSignature(rawBody: string, signatureHeader: string): boolea
return timingSafeEqual(provided, expectedBuffer);
}
async function emailLicenseKey(to: string, licenseKey: string): Promise<void> {
try {
await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: {
"api-key": env.SENDINBLUE_KEY,
"content-type": "application/json",
accept: "application/json"
},
body: JSON.stringify({
sender: { email: env.EMAIL_FROM },
to: [{ email: to }],
subject: "Your The Nook license key",
textContent:
`Your The Nook license key is:\n\n${licenseKey}\n\n` +
`Open The Nook, go to Settings, and enter this key to activate.\n` +
`You can activate up to 3 devices.\n\n— The Nook`
})
});
} catch (error) {
// Purchase must not fail because email delivery did.
console.error("Failed to email The Nook license key:", error);
}
}
export async function POST(event: APIEvent) {
const rawBody = await event.request.text();
const signatureHeader = event.request.headers.get("Stripe-Signature");
@@ -94,8 +69,8 @@ export async function POST(event: APIEvent) {
return json({ received: true });
}
const sessionId = body.id;
const email = body.customer_details?.email;
const sessionId = body.data?.object?.id;
const email = body.data?.object?.customer_details?.email;
if (!sessionId || !email) {
console.error("checkout.session.completed missing id or email:", rawBody);
return json({ received: true });

View File

@@ -7,6 +7,7 @@ import {
useSearchParams
} from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
@@ -327,6 +328,7 @@ export default function PostPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<PageHead
title={p().title.replaceAll("_", " ")}
description={

View File

@@ -1,6 +1,7 @@
import { Show } from "solid-js";
import { useSearchParams, A, query } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { createAsync } from "@solidjs/router";
import PostSortingSelect from "~/components/blog/PostSortingSelect";
import TagSelector from "~/components/blog/TagSelector";
@@ -90,6 +91,7 @@ export default function BlogIndex() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<PageHead
title="Blog"
description="Technical blog posts about web development, programming, and software engineering."

21
src/routes/checkout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NookCheckout from "./nook/checkout";
/**
* Host-aware checkout route — `/checkout`.
*
* Only The Nook has a checkout flow; all other sites fall back to 404.
* Mirrors the dispatch pattern in `src/routes/index.tsx`.
*/
export default function CheckoutPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nook"}>
<NookCheckout />
</Match>
</Switch>
);
}

View File

@@ -3,6 +3,7 @@ import { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router";
import RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { buildSubdomainUrl } from "~/lib/site-context";
import { useSite } from "~/context/SiteContext";
import NessaContactPage from "./nessa/contact";
@@ -125,7 +126,9 @@ function MainContactPage() {
const viewer = () => searchParams.viewer ?? "default";
return (
<ContactForm
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<ContactForm
subline={
<Show when={viewer() !== "lineage"}>
(for this website or any of my apps...)
@@ -134,6 +137,7 @@ function MainContactPage() {
>
<LineageContactQuestions />
</ContactForm>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
@@ -13,16 +14,17 @@ import InputHaloDownloadsPage from "./inputhalo/downloads";
function MainDownloadsPage() {
const [LaLText, setLaLText] = createSignal("Life and Lineage");
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
const [corkText, setCorkText] = createSignal("Cork");
//const [corkText, setCorkText] = createSignal("Cork");
const [gazeText, setGazeText] = createSignal("Gaze");
const [nookText, setNookText] = createSignal("The Nook");
const [inputHaloText, setInputHaloText] = createSignal("InputHalo");
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
{
lineage: false,
cork: false,
gaze: false,
"shapes-with-abigail": false,
thenook: false,
inputhalo: false
}
);
@@ -53,24 +55,27 @@ function MainDownloadsPage() {
onMount(() => {
const lalInterval = glitchText(LaLText(), setLaLText);
const swaInterval = glitchText(SwAText(), setSwAText);
const corkInterval = glitchText(corkText(), setCorkText);
//const corkInterval = glitchText(corkText(), setCorkText);
const gazeInterval = glitchText(gazeText(), setGazeText);
const nookInterval = glitchText(nookText(), setNookText);
const inputHaloInterval = glitchText(inputHaloText(), setInputHaloText);
onCleanup(() => {
clearInterval(lalInterval);
clearInterval(swaInterval);
clearInterval(corkInterval);
//clearInterval(corkInterval);
clearInterval(gazeInterval);
clearInterval(nookInterval);
clearInterval(inputHaloInterval);
});
});
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Downloads"
description="Download InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on iOS, Android, and macOS."
description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android."
/>
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
@@ -89,7 +94,34 @@ function MainDownloadsPage() {
<div class="text-center text-xl italic">
Ordered by date of initial release
</div>
<div class="mx-auto max-w-5xl space-y-16">
{/* The Nook */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("nook")}
class="text-text hover:text-yellow transition-colors"
>
{nookText()}
</A>
</h2>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+)
</span>
<Button
variant="download"
size="lg"
loading={loadingState()["thenook"]}
onClick={() => download("thenook")}
>
download.dmg
</Button>
</div>
</div>
{/* InputHalo */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
@@ -193,6 +225,30 @@ function MainDownloadsPage() {
</div>
</div>
</div>
{/* Cork */}
{/* <div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {corkText()}
</h2>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (13.0+)
</span>
<Button
variant="download"
size="lg"
loading={loadingState()["cork"]}
onClick={() => download("cork")}
>
download.zip
</Button>
<span class="text-subtext1 text-xs">
# unzip → drag to /Applications
</span>
</div>
</div> */}
{/* Lineage */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span>{" "}
@@ -235,29 +291,6 @@ function MainDownloadsPage() {
</div>
</div>
</div>
{/* Cork */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {corkText()}
</h2>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (13.0+)
</span>
<Button
variant="download"
size="lg"
loading={loadingState()["cork"]}
onClick={() => download("cork")}
>
download.zip
</Button>
<span class="text-subtext1 text-xs">
# unzip drag to /Applications
</span>
</div>
</div>
{/* Shapes with Abigail */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
export default function GazeContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm />
</>

View File

@@ -11,6 +11,7 @@
import { A } from "@solidjs/router";
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
@@ -48,6 +49,7 @@ export default function GazeDownloadsPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Download Gaze"
description="Download Gaze for macOS — menu bar app for eye and posture health."

View File

@@ -1,5 +1,6 @@
import { createSignal, For } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
@@ -60,6 +61,7 @@ export default function GazeLanding() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."

View File

@@ -15,11 +15,13 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function GazePrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for Gaze, a macOS eye health reminder app."

View File

@@ -1,12 +1,15 @@
import { Switch, Match, type JSX } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { DarkModeToggle } from "~/components/DarkModeToggle";
import { Typewriter } from "~/components/Typewriter";
import { useSite } from "~/context/SiteContext";
import { buildSubdomainUrl } from "~/lib/site-context";
import NessaLanding from "./nessa";
import LineageLanding from "./lineage";
import GazeLanding from "./gaze";
import InputHaloLanding from "./inputhalo";
import NookLanding from "./nook";
/**
* Root route handler.
@@ -44,6 +47,9 @@ export default function Home(): JSX.Element {
<Match when={site().id === "inputhalo"}>
<InputHaloLanding />
</Match>
<Match when={site().id === "nook"}>
<NookLanding />
</Match>
</Switch>
);
}
@@ -52,6 +58,7 @@ export default function Home(): JSX.Element {
function MainHome(): JSX.Element {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="Michael Freno - Software Engineer based in Brooklyn, NY"
@@ -97,6 +104,32 @@ function MainHome(): JSX.Element {
</Typewriter>
<div class="pt-8 text-center">
<div class="pb-4">Some of my recent projects:</div>
{/* The Nook */}
<div class="border-surface0 mb-2 flex w-full flex-col gap-2 rounded-md border-2 p-4 text-center">
<div>My macOS notch utility:</div>
<a
href={buildSubdomainUrl("nook")}
class="text-blue hover-underline-animation mx-auto w-fit"
>
The Nook
</a>
<div class="mx-auto w-full max-w-4xl overflow-hidden rounded-lg">
<video
src="/nook/demo-expansion.mp4"
class="h-full w-full object-cover"
autoplay
loop
muted
playsinline
/>
</div>
<div class="pt-2 text-left text-sm">
A native macOS island that lives in the notch: orchestration for
a fleet of coding agents, fan curve control, thermal gauges,
calendar and camera modules. One-time purchase - no
subscription, ever.
</div>
</div>
<div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
{/* FlexLöve */}
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center">

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
export default function InputHaloContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm />
</>

View File

@@ -11,6 +11,7 @@
import { A } from "@solidjs/router";
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
@@ -51,6 +52,7 @@ export default function InputHaloDownloadsPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Download InputHalo"
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."

View File

@@ -13,6 +13,7 @@
import { For, createSignal } from "solid-js";
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
@@ -98,6 +99,7 @@ export default function InputHaloLanding() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."

View File

@@ -23,11 +23,13 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function InputHaloPrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for InputHalo, a macOS menu bar productivity app."

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { LineageContactQuestions } from "../contact";
@@ -24,6 +25,7 @@ import { LineageContactQuestions } from "../contact";
export default function LineageContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm>
<LineageContactQuestions />

View File

@@ -33,6 +33,7 @@
* the form posts to the correct tRPC mutation (Lineage-branded email).
*/
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DeletionForm from "~/components/DeletionForm";
import {
@@ -45,6 +46,7 @@ import {
export default function LineageDeletionPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="pt-20">

View File

@@ -30,6 +30,7 @@
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import Button from "~/components/ui/Button";
@@ -71,6 +72,7 @@ export default function LineageDownloadsPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />

View File

@@ -11,6 +11,7 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import SimpleParallax from "~/components/SimpleParallax";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
@@ -25,6 +26,7 @@ import {
export default function LineageLandingPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<SimpleParallax>

View File

@@ -17,11 +17,13 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function LineagePrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
@@ -20,6 +21,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
export default function NessaContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm />
</>

View File

@@ -24,6 +24,7 @@
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
*/
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DeletionForm from "~/components/DeletionForm";
import {
@@ -36,6 +37,7 @@ import {
export default function NessaDeletionPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="pt-20">

View File

@@ -12,6 +12,7 @@
*/
import { For, Show } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
@@ -59,6 +60,7 @@ export default function NessaLanding() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead {...NESSA_LANDING_META} />
<SubdomainHeader />

View File

@@ -29,11 +29,13 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function NessaPrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."

View File

@@ -49,7 +49,10 @@ export default function NookCheckout() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ turnstileToken: turnstileToken() })
});
const data = (await res.json()) as { checkoutUrl?: string; error?: string };
const data = (await res.json()) as {
checkoutUrl?: string;
error?: string;
};
if (!res.ok || !data.checkoutUrl) {
setError(data.error ?? "Unable to start checkout. Please try again.");
return;
@@ -66,20 +69,28 @@ export default function NookCheckout() {
<>
<PageHead
title="Checkout"
description="Buy a The Nook license — $10 one-time for up to 3 devices."
description="Buy a The Nook license — $10 beta price (33% off) for up to 3 devices."
/>
<SubdomainHeader />
<div class="bg-base mx-auto max-w-lg px-4 py-16">
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
<h1 class="text-text mb-1 text-3xl font-bold">The Nook</h1>
<p class="text-subtext0 mb-6">Lifetime License 3 devices</p>
<div class="text-text mb-6 text-4xl font-extrabold">
$10<span class="text-subtext0 text-base font-normal"> one-time</span>
<div class="mb-2 flex items-end gap-3">
<div class="text-text text-4xl font-extrabold">
$10
<span class="text-subtext0 text-base font-normal"> one-time</span>
</div>
<div class="text-subtext0 mb-1 text-2xl font-medium line-through">
$15
</div>
</div>
<p class="mb-6 text-sm font-medium">
<span class="border-overlay0 bg-surface0 text-subtext0 inline-block rounded-full border px-3 py-1 text-xs font-semibold tracking-wide">
Beta pricing 33% off
</span>
</p>
<ul class="text-subtext0 mb-6 space-y-2 text-sm">
<li> Activate on up to 3 of your own Macs</li>
<li> Activate on up to 3 devices</li>
<li> No subscription, ever</li>
<li> Key delivered by email + on this page</li>
</ul>
@@ -97,11 +108,10 @@ export default function NookCheckout() {
Pay $10
</Button>
{error() && (
<p class="text-red mt-4 text-sm">{error()}</p>
)}
{error() && <p class="text-red mt-4 text-sm">{error()}</p>}
<p class="text-subtext1 mt-4 text-xs">
Billed once through Stripe. License delivered immediately after payment.
Billed once through Stripe. License delivered immediately and by
email after payment.
</p>
</div>
</div>

View File

@@ -1,71 +1,112 @@
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import FeatureCollage from "~/components/nook/FeatureCollage";
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
import Button from "~/components/ui/Button";
import Input from "~/components/ui/Input";
import { createSignal, Show } from "solid-js";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
const FEATURES = [
{
title: "Unified device control",
body: "Monitor and control the hardware and services behind your Mac from one fast, native panel."
},
{
title: "Fan & thermal insight",
body: "Read and control system fans, watch temperatures, and keep performance predictable under load."
},
{
title: "Private by design",
body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device."
},
{
title: "One-time license",
body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals."
/* The appcast's first enclosure always names the latest release dmg. */
async function downloadNook() {
try {
const res = await fetch("/api/TheNook/appcast.xml");
const xml = await res.text();
const url =
xml.match(/<enclosure url="([^"]+\.dmg)"/)?.[1] ??
xml.match(/<enclosure url="([^"]+\.zip)"/)?.[1];
if (!url) throw new Error("no enclosure");
window.location.href = url;
} catch {
console.error("Could not resolve latest Nook download");
}
] as const;
}
export default function NookLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const [resendEmail, setResendEmail] = createSignal("");
const [resendSending, setResendSending] = createSignal(false);
const [resendSent, setResendSent] = createSignal(false);
const [resendError, setResendError] = createSignal(false);
const resendLicense = async () => {
if (resendSending()) return;
const email = resendEmail().trim();
if (!email) return;
setResendSending(true);
setResendSent(false);
setResendError(false);
try {
const res = await fetch("/api/the-nook/resend-license", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email })
});
if (!res.ok) throw new Error("resend failed");
setResendSent(true);
} catch {
setResendError(true);
} finally {
setResendSending(false);
}
};
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="The Nook — a native macOS utility for hardware control, fan and thermal insight, and quiet system services."
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
ogImage="/nook/og-default.png"
ogTitle="The Nook — native macOS hardware control"
ogDescription="A one-time-purchase macOS app for fan control, thermal insight, and quiet system services."
ogTitle="The Nook — native macOS agent orchestration"
ogDescription="A one-time-purchase macOS app for coding-agent orchestration, fan control, and thermal insight."
/>
<SubdomainHeader />
{/* ── Hero ─────────────────────────────────────────────────────── */}
<div class="relative flex min-h-screen flex-col overflow-hidden">
{/* -mt-14 pulls the hero under the sticky header; pt-14 re-clears
content, so the gradient fills the full viewport incl. the bar */}
<div class="relative -mt-14 flex min-h-svh flex-col overflow-hidden pt-14">
<div
class="fixed inset-0 z-0"
style={{
background: isDark()
? "radial-gradient(ellipse at top, #241a38 0%, #0b0b10 70%)"
: "radial-gradient(ellipse at top, #efe9ff 0%, #f5f5f5 70%)"
? "radial-gradient(ellipse at top, #123945 0%, #0b0b10 70%)"
: "radial-gradient(ellipse at top, #d7eef5 0%, #f5f5f5 70%)"
}}
/>
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center">
<div class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
style={{ border: "1px solid var(--color-overlay0)", background: "var(--color-surface0)" }}>
<div class="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-12 text-center sm:py-24">
<img
src="/nook/icon.png"
alt="The Nook App Icon"
height={128}
width={128}
class="mb-4 h-24 w-24 rounded-[22%] object-cover object-center shadow-2xl sm:mb-6 sm:h-32 sm:w-32"
/>
<div
class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
style={{
border: "1px solid var(--color-overlay0)",
background: "var(--color-surface0)"
}}
>
The Nook
</div>
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
Your Mac, under your control
<h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
Your Mac and Agents, under control
</h1>
<p class="text-subtext0 mb-2 max-w-xl text-xl">
Fan control, thermal insight, and quiet system services native macOS, one-time license.
Agent orchestration, fan and thermal control in a beautiful native
UI.
</p>
<p class="text-subtext1 mb-8 text-sm">
macOS 14+ · 14-day free trial · 3 devices
macOS 14+ · 14-day free trial · up to 3 devices
</p>
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
@@ -73,7 +114,7 @@ export default function NookLanding() {
variant="download"
size="lg"
color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
onClick={downloadNook}
>
Download trial
</Button>
@@ -85,28 +126,31 @@ export default function NookLanding() {
Buy
</Button>
</div>
<p class="text-subtext1 mt-3 text-xs">
$10 one-time · 3 devices · 14-day free trial
</p>
<div class="mt-3 flex flex-col items-center gap-1.5">
<span class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
Beta pricing · 33% off
</span>
<p class="text-subtext1 text-xs">
<span class="text-subtext0 line-through">$15</span>{" "}
<span class="text-text font-bold">$10</span> one-time
</p>
</div>
</div>
</div>
{/* ── Feature highlights ───────────────────────────────────────── */}
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
<div class="mx-auto max-w-4xl">
<h2 class="text-text mb-12 text-center text-3xl font-bold">
Quiet power, right where it belongs
{/* ── Feature collage ─────────────────────────────────────────── */}
<FeatureCollage />
{/* ── Feature breakdowns: the features in action ─────────────── */}
<section class="bg-base relative z-20 px-4 py-16 md:px-8">
<div class="mx-auto max-w-5xl">
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
In action
</p>
<h2 class="text-text mb-16 text-center text-3xl font-bold">
Built for the way you actually run agents
</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-2">
{FEATURES.map((feature) => (
<div class="border-overlay0 bg-surface0 rounded-lg border p-6">
<h3 class="text-text mb-2 text-xl font-semibold">
{feature.title}
</h3>
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
</div>
))}
</div>
<FeatureBreakdowns />
</div>
</section>
@@ -117,16 +161,17 @@ export default function NookLanding() {
Try it free for 14 days
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
Watch temperatures, take control of your fans, and keep background
services quiet. When the trial ends, unlock everything with a
single one-time payment no subscription, ever.
Orchestrate your coding agents, watch temperatures, take control of
your fans, and keep background services quiet. When the trial ends,
unlock everything with a single one-time payment no subscription,
ever.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
onClick={downloadNook}
>
Download trial
</Button>
@@ -134,14 +179,64 @@ export default function NookLanding() {
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
href="/checkout"
>
Buy a license $10
Buy a license $10{" "}
<span class="text-subtext0 line-through">$15</span>
</a>
</div>
<p class="text-subtext1 mt-6 text-xs">
One license covers up to 3 of your own Macs.
Beta pricing full price will be $15. One license covers up to 3 of
your own Macs.
</p>
</div>
</section>
{/* ── Resend license ─────────────────────────────────────────── */}
<section class="bg-base relative z-20 px-4 py-16 md:px-8">
<div class="border-overlay0 bg-surface0 mx-auto max-w-md rounded-xl border p-8">
<h2 class="text-text mb-2 text-center text-2xl font-bold">
Lost your license?
</h2>
<p class="text-subtext0 mb-6 text-center text-sm">
Enter the email you purchased with and we'll resend your key.
</p>
<form
class="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
resendLicense();
}}
>
<Input
type="email"
required
label="Email"
placeholder="you@example.com"
disabled={resendSending()}
value={resendEmail()}
onInput={(e) => setResendEmail(e.currentTarget.value)}
/>
<Button
type="submit"
variant="primary"
color={brandColor()}
loading={resendSending()}
>
Resend license
</Button>
</form>
<Show when={resendSent()}>
<p class="text-subtext1 mt-4 text-center text-sm">
Check your inbox if a license is registered to that email, your
key is on its way.
</p>
</Show>
<Show when={resendError()}>
<p class="mt-4 text-center text-sm text-red-500">
Something went wrong. Please try again.
</p>
</Show>
</div>
</section>
</>
);
}

View File

@@ -5,16 +5,18 @@
* UUID + device name for trial/activation limits, the email used for purchase,
* and Stripe payment processing.
*/
import { A } from "@solidjs/router";
import { buildMainSiteUrl } from "~/lib/subdomain-url";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function NookPrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for The Nook, a native macOS hardware and system control app."
description="Privacy policy for The Nook, a coding-agent orchestration and hardware control app."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
@@ -94,9 +96,14 @@ export default function NookPrivacyPolicy() {
</div>
<div class="pb-2 pl-4">
If you have any questions about this policy, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
<a
href={buildMainSiteUrl("/contact")}
target="_blank"
rel="noopener noreferrer"
class="text-blue hover-underline-animation"
>
here
</A>
</a>
.
</div>
</div>

View File

@@ -55,10 +55,7 @@ export default function NookSuccess() {
return (
<>
<PageHead
title="Order complete"
description="Your The Nook license key."
/>
<PageHead title="Order complete" description="Your license key." />
<SubdomainHeader />
<div class="bg-base mx-auto max-w-xl px-4 py-16">
@@ -88,16 +85,24 @@ export default function NookSuccess() {
</Show>
}
>
<h1 class="text-text mb-2 text-2xl font-bold">Thanks for buying The Nook</h1>
<h1 class="text-text mb-2 text-2xl font-bold">
Thanks for buying The Nook
</h1>
<p class="text-subtext0 mb-6 text-sm">
Here is your license key. Open The Nook, go to Settings, and enter it to activate.
Here is your license key. Open The Nook, go to Settings, and enter
it to activate.
</p>
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4">
<code class="text-text break-all text-sm">{key()}</code>
<code class="text-text text-sm break-all">{key()}</code>
</div>
<Button variant="download" size="md" color={brandColor()} onClick={copyKey}>
<Button
variant="download"
size="md"
color={brandColor()}
onClick={copyKey}
>
{copied() ? "Copied" : "Copy key"}
</Button>

View File

@@ -0,0 +1,158 @@
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
/**
* Main-site privacy policy — `freno.me/privacy-policy`.
*
* Covers the personal site (freno.me): accounts, blog comments & likes, the
* contact form, transactional email, and error monitoring. Product
* subdomains (Nessa, Lineage, Gaze, InputHalo, The Nook) each publish their
* own policy at `<product>.freno.me/privacy`.
*
* The legacy `/privacy-policy/shapes-with-abigail` 308-redirects here.
*/
export default function PrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for the freno.me blog and personal site, covering accounts, comments, and contact forms."
/>
<div class="bg-base">
<div class="min-h-screen px-[8vw] py-[8vh]">
<div class="py-4 text-xl">
freno.me&apos;s Privacy Policy
</div>
<div class="py-2">Last Updated: August 28, 2026</div>
<div class="py-2">
Welcome to freno.me (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
This site is a personal site and blog. This policy explains what
we collect, why, and how it is handled.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Data We Collect
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Account:</div> If you create an
account with an email and password, we store your email, a
hash of your password, and your profile name. If you sign in
with Google or GitHub, we store your name, email, and avatar
as provided by the provider.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Blog:</div> If you comment on a post,
we store your username, your comment, and its date. If you
like a post, we store that you liked it, so the like can be
removed.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Contact:</div> If you submit the
contact form, we store your name, email, and message so we
can reply.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> How We Use It
</div>
<div class="pb-2 pl-4">
We use your data only to operate the site: to authenticate
you, to publish your comments, and to reply to your messages.
We do not use it for advertising and we do not sell it to
anyone.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Cookies
</div>
<div class="pb-2 pl-4">
We use a session cookie to keep you signed in and short-lived
cookies to rate-limit the login and contact forms against
abuse. No tracking or advertising cookies are set.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Third-Party Services
</div>
<div class="pb-2 pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Email:</div> Transactional email
(login links, password resets, contact replies) is sent by
Sendinblue. We share only the email address needed to
deliver the message.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Error monitoring:</div> We use Sentry
to collect crash reports, which include your browser type
and page URL. Your account details are never sent.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Data Retention &amp;
Deletion
</div>
<div class="pb-2 pl-4">
You can delete your account and your comments or messages any
time by contacting us. We delete the data when we receive your
request.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">6.</span> Security
</div>
<div class="pb-2 pl-4">
All connections use TLS. Passwords are stored as salted
hashes. Access to the database is restricted to the site
operator.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">7.</span> Changes to the Privacy
Policy
</div>
<div class="pb-2 pl-4">
We may update this policy periodically. Any changes are posted
on this page, so please review it from time to time.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">8.</span> Contact Us
</div>
<div class="pb-2 pl-4">
If you have any questions about this policy, you can contact
us{" "}
<A
href="/contact"
class="text-blue hover-underline-animation"
>
here
</A>
.
</div>
</div>
</ol>
</div>
</div>
</>
);
}

View File

@@ -1,9 +1,11 @@
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
export default function PrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy - Shapes with Abigail"
description="Privacy policy for Shapes with Abigail app, explaining our commitment to child safety and non-collection of personal data."

View File

@@ -5,7 +5,8 @@ import NessaPrivacyPolicy from "./nessa/privacy";
import LineagePrivacyPolicy from "./lineage/privacy";
import GazePrivacyPolicy from "./gaze/privacy";
import InputHaloPrivacyPolicy from "./inputhalo/privacy";
import NookPrivacyPolicy from "./nook/privacy";
import MainPrivacyPolicy from "./privacy-policy/index";
/**
* Host-aware privacy policy route — `/privacy`.
*
@@ -19,16 +20,20 @@ import InputHaloPrivacyPolicy from "./inputhalo/privacy";
* (SolidStart's client `Router` matches on `window.location.pathname`, not the
* rewritten server path).
*
* The main site has no `/privacy` page (it uses `/privacy-policy/*`), so
* `main` falls back to the 404 handler — preserving its pre-rewrite behavior.
* The main site's policy lives at `/privacy-policy` (a legacy path, so the
* page stays there for SEO); `main` renders it here at the public path
* `/privacy`, so both URLs serve the same content on `freno.me`.
*
* Legacy `/privacy-policy/<product>` routes 308-redirect to the corresponding
* subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
* Legacy `/privacy-policy/<product>` routes 308-redirect to the
* corresponding subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
*/
export default function PrivacyPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "main"}>
<MainPrivacyPolicy />
</Match>
<Match when={site().id === "nessa"}>
<NessaPrivacyPolicy />
</Match>
@@ -41,6 +46,9 @@ export default function PrivacyPage() {
<Match when={site().id === "inputhalo"}>
<InputHaloPrivacyPolicy />
</Match>
<Match when={site().id === "nook"}>
<NookPrivacyPolicy />
</Match>
</Switch>
);
}

View File

@@ -1,5 +1,6 @@
import { onCleanup, onMount } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
export default function Resume() {
let iframeRef: HTMLIFrameElement | undefined;
@@ -25,6 +26,7 @@ export default function Resume() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Resume"
description="View Michael Freno's resume - Software Engineer."

21
src/routes/success.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NookSuccess from "./nook/success";
/**
* Host-aware success route — `/success`.
*
* Only The Nook has a post-checkout success page; all other sites fall
* back to 404. Mirrors the dispatch pattern in `src/routes/index.tsx`.
*/
export default function SuccessPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nook"}>
<NookSuccess />
</Match>
</Switch>
);
}

View File

@@ -16,12 +16,13 @@ const assets: Record<string, string> = {
};
/**
* Get the latest DMG from S3 by finding the most recent file with the given prefix
* Find the most recent file in S3 matching a prefix and extension.
*/
async function getLatestDMG(
async function getLatestFile(
client: S3Client,
bucket: string,
prefix: string
prefix: string,
ext: string
): Promise<string> {
try {
const listCommand = new ListObjectsV2Command({
@@ -33,49 +34,41 @@ async function getLatestDMG(
const response = await client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
throw new Error(`No DMG files found in S3 with prefix ${prefix}`);
throw new Error(`No files found in S3 with prefix ${prefix}`);
}
const dmgFiles = response.Contents.filter((obj) =>
obj.Key?.endsWith(".dmg")
const files = response.Contents.filter((obj) =>
obj.Key?.endsWith(ext)
).sort((a, b) => {
const dateA = a.LastModified?.getTime() || 0;
const dateB = b.LastModified?.getTime() || 0;
return dateB - dateA; // Descending order (newest first)
return dateB - dateA;
});
if (dmgFiles.length === 0) {
throw new Error(`No .dmg files found in ${prefix} prefix`);
if (files.length === 0) {
throw new Error(`No ${ext} files found in ${prefix} prefix`);
}
const latestFile = dmgFiles[0].Key!;
console.log(`Latest DMG: ${latestFile}`);
const latestFile = files[0].Key!;
console.log(`Latest file: ${latestFile}`);
return latestFile;
} catch (error) {
console.error(`Error finding latest DMG for ${prefix}:`, error);
console.error(`Error finding latest file for ${prefix}:`, error);
throw error;
}
}
/**
* Get the latest Gaze DMG from S3
* Per-asset S3 lookup config for macOS apps that auto-resolve latest version.
*/
async function getLatestGazeDMG(
client: S3Client,
bucket: string
): Promise<string> {
return getLatestDMG(client, bucket, "downloads/Gaze-");
}
/**
* Get the latest InputHalo DMG from S3
*/
async function getLatestInputHaloDMG(
client: S3Client,
bucket: string
): Promise<string> {
return getLatestDMG(client, bucket, "downloads/InputHalo-");
}
const latestAssets: Record<
string,
{ prefix: string; ext: string }
> = {
gaze: { prefix: "downloads/Gaze-", ext: ".dmg" },
inputhalo: { prefix: "downloads/InputHalo-", ext: ".dmg" },
thenook: { prefix: "downloads/TheNook-", ext: ".dmg" }
};
export const downloadsRouter = createTRPCRouter({
getDownloadUrl: publicProcedure
@@ -96,11 +89,9 @@ export const downloadsRouter = createTRPCRouter({
try {
let fileKey: string;
// Special handling for macOS apps - find latest version automatically
if (input.asset_name === "gaze") {
fileKey = await getLatestGazeDMG(client, bucket);
} else if (input.asset_name === "inputhalo") {
fileKey = await getLatestInputHaloDMG(client, bucket);
const latest = latestAssets[input.asset_name];
if (latest) {
fileKey = await getLatestFile(client, bucket, latest.prefix, latest.ext);
} else {
fileKey = assets[input.asset_name];

View File

@@ -0,0 +1,57 @@
/**
* Bug-report rate limiting.
*
* Unauthenticated endpoint → in-memory limiter. Vercel lambdas are
* per-instance, so this is best-effort abuse resistance, not a hard
* guarantee; the goal is "one scripted spam loop per IP/fingerprint
* doesn't mail-bomb the owner," which it achieves everywhere but
* under a distributed attacker.
*
* NB: `report.ts` (and only it) mutates the map. Tests reset it.
*/
interface RateWindow {
count: number;
expires: number;
}
const buckets = new Map<string, RateWindow>();
/** Keep the map tiny even under foreign-key pressure. */
const MAX_BUCKETS = 10_000;
export const BUG_REPORT_WINDOW_MS = 60 * 60 * 1000;
export const BUG_REPORT_LIMIT = 10;
/**
* Increments the client's counter and reports whether it may submit.
* `expiresIn`/`now` are injectable for tests.
*/
export function takeBugReportToken(
key: string,
expiresIn: number = BUG_REPORT_WINDOW_MS,
now: number = Date.now()
): { allowed: boolean; retryAfterSec?: number } {
const existing = buckets.get(key);
if (existing && existing.expires > now) {
if (existing.count >= BUG_REPORT_LIMIT) {
return {
allowed: false,
retryAfterSec: Math.ceil(existing.expires - now) / 1000
};
}
existing.count += 1;
return { allowed: true };
}
if (buckets.size >= MAX_BUCKETS) {
buckets.clear();
}
buckets.set(key, { count: 1, expires: now + expiresIn });
return { allowed: true };
}
/** Test seam: wipes all buckets. */
export function resetBugReportLimiter(): void {
buckets.clear();
}

View File

@@ -10,15 +10,11 @@ import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
* tool is needed.
*
* The license key is a compact printable string:
* key = "NOOK-" + hyphen-grouped base32(payload || ed25519-signature)
*
* key = payloadJson + "." + base64url(ed25519-signature(payloadJson))
*
* Canonicalization is load-bearing. The Swift client verifies the EXACT
* payload bytes (the substring before the last "."), never a re-serialization
* of the decoded JSON — key order must stay stable. Without a compact,
* deterministic payload this breaks, so `issueLicense` builds the payload as
* a hand-ordered literal and `JSON.stringify`s it in place.
* Payload layout (v1) is defined below in `buildPayload`. The Swift client
* decodes the base32 and verifies the EXACT payload bytes against the
* Ed25519 signature, so the layout and signing must stay stable.
*/
interface IssueLicenseResult {
@@ -76,24 +72,90 @@ function privateKeyObject() {
});
}
function signPayload(payload: string): string {
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject());
return Buffer.from(signature).toString("base64url");
const KEY_PREFIX = "NOOK-";
const SIGNATURE_LENGTH = 64;
const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
// Binary license payload (v1), signed with Ed25519:
// [0] version (1 byte)
// [1..17) lid (16 bytes, UUID decoded)
// [17..25) iat (8 bytes, big-endian uint64)
// [25] emailLen (1 byte)
// [26..) email (UTF-8)
// A key is a "NOOK-" prefixed, hyphen-grouped base32 of payload || 64-byte sig.
function uuidToBytes(uuid: string): Buffer | null {
const hex = uuid.replace(/-/g, "");
return /^[0-9a-fA-F]{32}$/.test(hex) ? Buffer.from(hex, "hex") : null;
}
function buildPayload(lid: string, email: string, iat: number): Buffer {
const lidBytes = uuidToBytes(lid);
if (!lidBytes) throw new Error("Invalid license id");
const emailBytes = Buffer.from(email, "utf8");
const header = Buffer.allocUnsafe(26);
header[0] = PAYLOAD_VERSION;
lidBytes.copy(header, 1);
header.writeBigUInt64BE(BigInt(iat), 17);
header[25] = emailBytes.length;
return Buffer.concat([header, emailBytes]);
}
function base32Encode(data: Buffer): string {
let bits = 0;
let value = 0;
let out = "";
for (const byte of data) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31];
return out;
}
function base32Decode(input: string): Buffer | null {
const cleaned = input
.trim()
.replace(/^NOOK-/i, "")
.replace(/[^A-Z2-7]/g, "");
if (cleaned.length === 0) return null;
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of cleaned) {
const idx = B32_ALPHABET.indexOf(char);
if (idx < 0) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
function encodeKey(data: Buffer): string {
const encoded = base32Encode(data);
const groups: string[] = [];
for (let i = 0; i < encoded.length; i += 5)
groups.push(encoded.slice(i, i + 5));
return `${KEY_PREFIX}${groups.join("-")}`;
}
/** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
export function verifyLicenseKey(key: string): boolean {
const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/);
if (!token) return false;
const [, payload, sigB64] = token;
const token = base32Decode(key);
if (!token || token.length <= SIGNATURE_LENGTH) return false;
const payload = token.subarray(0, token.length - SIGNATURE_LENGTH);
if (payload[0] !== PAYLOAD_VERSION || payload.length < 27) return false;
const signature = token.subarray(token.length - SIGNATURE_LENGTH);
const publicKey = createPublicKey(privateKeyObject());
let signature: Buffer;
try {
signature = Buffer.from(sigB64!, "base64url");
} catch {
return false;
}
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
return verify(null, payload as Buffer, publicKey, signature);
}
/**
@@ -106,17 +168,21 @@ async function insertLicense(
maxDevices: number
): Promise<IssueLicenseResult> {
const id = crypto.randomUUID();
const payload = JSON.stringify({
v: PAYLOAD_VERSION,
lid: id,
email: email,
iat: Math.floor(Date.now() / 1000)
});
const key = `${payload}.${signPayload(payload)}`;
const iat = Math.floor(Date.now() / 1000);
const payload = buildPayload(id, email, iat);
const signature = sign(null, payload, privateKeyObject());
const key = encodeKey(Buffer.concat([payload, signature]));
await NookConnectionFactory().execute({
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
args: [id, key, email, stripeSessionId, new Date().toISOString(), maxDevices]
args: [
id,
key,
email,
stripeSessionId,
new Date().toISOString(),
maxDevices
]
});
return { key, id };
}
@@ -147,3 +213,34 @@ export async function grantLicense(
): Promise<IssueLicenseResult> {
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
}
/**
* Sends a license key to the buyer. Best-effort — failures are logged, never
* thrown, so the caller (checkout webhook / resend) isn't interrupted.
*/
export async function emailLicenseKey(
to: string,
licenseKey: string
): Promise<void> {
try {
await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: {
"api-key": env.SENDINBLUE_KEY,
"content-type": "application/json",
accept: "application/json"
},
body: JSON.stringify({
sender: { name: "The Nook", email: "support@freno.me" },
to: [{ email: to }],
subject: "Your license key",
textContent:
`Your license key is:\n\n${licenseKey}\n\n` +
`Open The Nook, go to Settings, and enter this key to activate.\n` +
`You can activate up to 3 devices.`
})
});
} catch (error) {
console.error("Failed to email The Nook license key:", error);
}
}