Compare commits

..

18 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
59 changed files with 3407 additions and 171 deletions

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ Thumbs.db
# pygienium run-state and check artifacts # pygienium run-state and check artifacts
.pygienium/ .pygienium/
scripts/ scripts/
.cache

View File

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

View File

@@ -11,7 +11,8 @@
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
"perf": "bun run scripts/perf-test.ts", "perf": "bun run scripts/perf-test.ts",
"perf:compare": "bun run scripts/perf-compare.ts" "perf:compare": "bun run scripts/perf-compare.ts",
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/client-s3": "^3.953.0",

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@@ -366,6 +366,11 @@ function MainRightBarContent() {
Contact Me Contact Me
</a> </a>
</li> </li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/privacy-policy" onClick={handleLinkClick}>
Privacy
</a>
</li>
<li> <li>
<a <a
href="https://github.com/MikeFreno/" href="https://github.com/MikeFreno/"

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() { export default function SubdomainFooter() {
return ( return (
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8"> <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 <A
href={buildMainSiteUrl()} 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> made with <span class="text-red-400">&lt;3</span>
</A> </A>
<A <A
href={buildMainSiteUrl("/downloads")} 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 see more products
</A> </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) => { window.addEventListener("error", (event) => {
if ( if (
event.message?.includes("Importing a module script failed") || event.message?.includes("Importing a module script failed") ||
event.message?.includes("Failed to fetch dynamically imported module") event.message?.includes("Failed to fetch dynamically imported module") ||
event.message?.includes("error loading dynamically imported module")
) { ) {
event.preventDefault(); event.preventDefault();
handleChunkError("error event"); handleChunkError("error event");
@@ -111,9 +112,8 @@ window.addEventListener("error", (event) => {
window.addEventListener("unhandledrejection", (event) => { window.addEventListener("unhandledrejection", (event) => {
if ( if (
event.reason?.message?.includes("Importing a module script failed") || event.reason?.message?.includes("Importing a module script failed") ||
event.reason?.message?.includes( event.reason?.message?.includes("Failed to fetch dynamically imported module") ||
"Failed to fetch dynamically imported module" event.reason?.message?.includes("error loading dynamically imported module")
)
) { ) {
event.preventDefault(); event.preventDefault();
handleChunkError("unhandled rejection"); 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"]; const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
describe("NAV_CONFIG — per-site link sets", () => { 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([ expect(navLabelsFor("main")).toEqual([
"Home", "Home",
"Blog", "Blog",
"Downloads", "Downloads",
"Resume", "Resume",
"Contact", "Contact",
"Privacy",
"GitHub", "GitHub",
"LinkedIn" "LinkedIn"
]); ]);

View File

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

View File

@@ -40,14 +40,15 @@ describe("generateSitemap", () => {
expect(locs).toContain("https://freno.me/login"); expect(locs).toContain("https://freno.me/login");
expect(locs).toContain("https://freno.me/resume"); expect(locs).toContain("https://freno.me/resume");
expect(locs).toContain("https://freno.me/downloads"); expect(locs).toContain("https://freno.me/downloads");
expect(locs).toContain("https://freno.me/privacy-policy");
expect(locs.length).toBe(6); expect(locs.length).toBe(7);
expect(xml).toContain("</urlset>"); expect(xml).toContain("</urlset>");
const urlOpens = (xml.match(/<url>/g) || []).length; const urlOpens = (xml.match(/<url>/g) || []).length;
const urlCloses = (xml.match(/<\/url>/g) || []).length; const urlCloses = (xml.match(/<\/url>/g) || []).length;
expect(urlOpens).toBe(urlCloses); expect(urlOpens).toBe(urlCloses);
expect(urlOpens).toBe(6); expect(urlOpens).toBe(7);
}); });
it("generates valid parseable XML for lineage site", () => { 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: "/contact", changefreq: "monthly", priority: 0.7 },
{ path: "/login", changefreq: "monthly", priority: 0.5 }, { path: "/login", changefreq: "monthly", priority: 0.5 },
{ path: "/resume", changefreq: "yearly", priority: 0.6 }, { path: "/resume", changefreq: "yearly", priority: 0.6 },
{ path: "/downloads", changefreq: "weekly", priority: 0.8 } { path: "/downloads", changefreq: "weekly", priority: 0.8 },
{ path: "/privacy-policy", changefreq: "yearly", priority: 0.4 }
], ],
// ── Subdomain sites ────────────────────────────────────────────────── // ── Subdomain sites ──────────────────────────────────────────────────

View File

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

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 type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { NookConnectionFactory } from "~/server/db-connections"; import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, issueLicense } from "~/server/nook"; import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
import { json } from "../_lib"; import { json } from "../_lib";
import { createHmac, timingSafeEqual } from "node:crypto"; import { createHmac, timingSafeEqual } from "node:crypto";
@@ -20,7 +20,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
interface StripeSessionCompleted { interface StripeSessionCompleted {
id: string; id: string;
type: string; type: string;
customer_details?: { email?: string }; data?: { object?: { id?: string; customer_details?: { email?: string } } };
} }
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean { function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
@@ -46,31 +46,6 @@ function verifyStripeSignature(rawBody: string, signatureHeader: string): boolea
return timingSafeEqual(provided, expectedBuffer); 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) { export async function POST(event: APIEvent) {
const rawBody = await event.request.text(); const rawBody = await event.request.text();
const signatureHeader = event.request.headers.get("Stripe-Signature"); const signatureHeader = event.request.headers.get("Stripe-Signature");
@@ -94,8 +69,8 @@ export async function POST(event: APIEvent) {
return json({ received: true }); return json({ received: true });
} }
const sessionId = body.id; const sessionId = body.data?.object?.id;
const email = body.customer_details?.email; const email = body.data?.object?.customer_details?.email;
if (!sessionId || !email) { if (!sessionId || !email) {
console.error("checkout.session.completed missing id or email:", rawBody); console.error("checkout.session.completed missing id or email:", rawBody);
return json({ received: true }); return json({ received: true });

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js"; import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
@@ -71,6 +72,7 @@ function MainDownloadsPage() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Downloads" title="Downloads"
description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android." description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android."

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import { Switch, Match, type JSX } from "solid-js"; import { Switch, Match, type JSX } from "solid-js";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { DarkModeToggle } from "~/components/DarkModeToggle"; import { DarkModeToggle } from "~/components/DarkModeToggle";
import { Typewriter } from "~/components/Typewriter"; import { Typewriter } from "~/components/Typewriter";
import { useSite } from "~/context/SiteContext"; import { useSite } from "~/context/SiteContext";
import { buildSubdomainUrl } from "~/lib/site-context";
import NessaLanding from "./nessa"; import NessaLanding from "./nessa";
import LineageLanding from "./lineage"; import LineageLanding from "./lineage";
import GazeLanding from "./gaze"; import GazeLanding from "./gaze";
@@ -56,6 +58,7 @@ export default function Home(): JSX.Element {
function MainHome(): JSX.Element { function MainHome(): JSX.Element {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Home" title="Home"
description="Michael Freno - Software Engineer based in Brooklyn, NY" description="Michael Freno - Software Engineer based in Brooklyn, NY"
@@ -101,6 +104,32 @@ function MainHome(): JSX.Element {
</Typewriter> </Typewriter>
<div class="pt-8 text-center"> <div class="pt-8 text-center">
<div class="pb-4">Some of my recent projects:</div> <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"> <div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
{/* FlexLöve */} {/* FlexLöve */}
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center"> <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 { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
export default function InputHaloContactPage() { export default function InputHaloContactPage() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader /> <SubdomainHeader />
<ContactForm /> <ContactForm />
</> </>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,11 +29,13 @@
*/ */
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
export default function NessaPrivacyPolicy() { export default function NessaPrivacyPolicy() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Privacy Policy" title="Privacy Policy"
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features." 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" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ turnstileToken: turnstileToken() }) 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) { if (!res.ok || !data.checkoutUrl) {
setError(data.error ?? "Unable to start checkout. Please try again."); setError(data.error ?? "Unable to start checkout. Please try again.");
return; return;
@@ -66,20 +69,28 @@ export default function NookCheckout() {
<> <>
<PageHead <PageHead
title="Checkout" 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 /> <SubdomainHeader />
<div class="bg-base mx-auto max-w-lg px-4 py-16"> <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"> <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> <div class="mb-2 flex items-end gap-3">
<p class="text-subtext0 mb-6">Lifetime License 3 devices</p> <div class="text-text text-4xl font-extrabold">
$10
<div class="text-text mb-6 text-4xl font-extrabold"> <span class="text-subtext0 text-base font-normal"> one-time</span>
$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> </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"> <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> No subscription, ever</li>
<li> Key delivered by email + on this page</li> <li> Key delivered by email + on this page</li>
</ul> </ul>
@@ -97,11 +108,10 @@ export default function NookCheckout() {
Pay $10 Pay $10
</Button> </Button>
{error() && ( {error() && <p class="text-red mt-4 text-sm">{error()}</p>}
<p class="text-red mt-4 text-sm">{error()}</p>
)}
<p class="text-subtext1 mt-4 text-xs"> <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> </p>
</div> </div>
</div> </div>

View File

@@ -1,42 +1,64 @@
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
import FeatureCollage from "~/components/nook/FeatureCollage";
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
import Button from "~/components/ui/Button"; import Button from "~/components/ui/Button";
import Input from "~/components/ui/Input";
import { createSignal, Show } from "solid-js";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext"; import { useSite } from "~/context/SiteContext";
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip"; /* The appcast's first enclosure always names the latest release dmg. */
async function downloadNook() {
const FEATURES = [ try {
{ const res = await fetch("/api/TheNook/appcast.xml");
title: "Agent orchestration", const xml = await res.text();
body: "Orchestrate your coding agents in one native panel, with sessions that keep working while you do." const url =
}, xml.match(/<enclosure url="([^"]+\.dmg)"/)?.[1] ??
{ xml.match(/<enclosure url="([^"]+\.zip)"/)?.[1];
title: "A beautiful native UI", if (!url) throw new Error("no enclosure");
body: "Animated panels and buttery SwiftUI transitions, tuned to feel right at home on your Mac." window.location.href = url;
}, } catch {
{ console.error("Could not resolve latest Nook download");
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."
} }
] as const; }
export default function NookLanding() { export default function NookLanding() {
const site = useSite(); const site = useSite();
const { isDark } = useDarkMode(); const { isDark } = useDarkMode();
const brandColor = () => const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().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 ( return (
<> <>
<EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Home" title="Home"
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control." description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
@@ -48,7 +70,9 @@ export default function NookLanding() {
<SubdomainHeader /> <SubdomainHeader />
{/* ── Hero ─────────────────────────────────────────────────────── */} {/* ── 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 <div
class="fixed inset-0 z-0" class="fixed inset-0 z-0"
style={{ style={{
@@ -57,13 +81,13 @@ export default function NookLanding() {
: "radial-gradient(ellipse at top, #d7eef5 0%, #f5f5f5 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="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-12 text-center sm:py-24">
<img <img
src="/nook/icon.png" src="/nook/icon.png"
alt="The Nook App Icon" alt="The Nook App Icon"
height={128} height={128}
width={128} width={128}
class="mb-6 h-32 w-32 rounded-[22%] object-cover object-center shadow-2xl" 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 <div
class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm" class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
@@ -74,15 +98,15 @@ export default function NookLanding() {
> >
The Nook The Nook
</div> </div>
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight"> <h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
Your Mac, under your control Your Mac and Agents, under control
</h1> </h1>
<p class="text-subtext0 mb-2 max-w-xl text-xl"> <p class="text-subtext0 mb-2 max-w-xl text-xl">
Agent orchestration, fan and thermal control native macOS, Agent orchestration, fan and thermal control in a beautiful native
one-time license. UI.
</p> </p>
<p class="text-subtext1 mb-8 text-sm"> <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> </p>
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4"> <div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
@@ -90,7 +114,7 @@ export default function NookLanding() {
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()} color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)} onClick={downloadNook}
> >
Download trial Download trial
</Button> </Button>
@@ -102,28 +126,31 @@ export default function NookLanding() {
Buy Buy
</Button> </Button>
</div> </div>
<p class="text-subtext1 mt-3 text-xs"> <div class="mt-3 flex flex-col items-center gap-1.5">
$10 one-time · 3 devices · 14-day free trial <span class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
</p> 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>
</div> </div>
{/* ── Feature highlights ───────────────────────────────────────── */} {/* ── Feature collage ─────────────────────────────────────────── */}
<section class="bg-base relative z-20 px-4 py-20 md:px-8"> <FeatureCollage />
<div class="mx-auto max-w-4xl">
<h2 class="text-text mb-12 text-center text-3xl font-bold"> {/* ── Feature breakdowns: the features in action ─────────────── */}
One panel for your agents and your Mac <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> </h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-2"> <FeatureBreakdowns />
{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>
</div> </div>
</section> </section>
@@ -144,7 +171,7 @@ export default function NookLanding() {
variant="download" variant="download"
size="lg" size="lg"
color={brandColor()} color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)} onClick={downloadNook}
> >
Download trial Download trial
</Button> </Button>
@@ -152,14 +179,64 @@ export default function NookLanding() {
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80" class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
href="/checkout" href="/checkout"
> >
Buy a license $10 Buy a license $10{" "}
<span class="text-subtext0 line-through">$15</span>
</a> </a>
</div> </div>
<p class="text-subtext1 mt-6 text-xs"> <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> </p>
</div> </div>
</section> </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,13 +5,15 @@
* UUID + device name for trial/activation limits, the email used for purchase, * UUID + device name for trial/activation limits, the email used for purchase,
* and Stripe payment processing. * and Stripe payment processing.
*/ */
import { A } from "@solidjs/router"; import { buildMainSiteUrl } from "~/lib/subdomain-url";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
export default function NookPrivacyPolicy() { export default function NookPrivacyPolicy() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Privacy Policy" title="Privacy Policy"
description="Privacy policy for The Nook, a coding-agent orchestration and hardware control app." description="Privacy policy for The Nook, a coding-agent orchestration and hardware control app."
@@ -94,9 +96,14 @@ export default function NookPrivacyPolicy() {
</div> </div>
<div class="pb-2 pl-4"> <div class="pb-2 pl-4">
If you have any questions about this policy, you can contact us{" "} 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 here
</A> </a>
. .
</div> </div>
</div> </div>

View File

@@ -55,10 +55,7 @@ export default function NookSuccess() {
return ( return (
<> <>
<PageHead <PageHead title="Order complete" description="Your license key." />
title="Order complete"
description="Your The Nook license key."
/>
<SubdomainHeader /> <SubdomainHeader />
<div class="bg-base mx-auto max-w-xl px-4 py-16"> <div class="bg-base mx-auto max-w-xl px-4 py-16">
@@ -88,16 +85,24 @@ export default function NookSuccess() {
</Show> </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"> <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> </p>
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4"> <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> </div>
<Button variant="download" size="md" color={brandColor()} onClick={copyKey}> <Button
variant="download"
size="md"
color={brandColor()}
onClick={copyKey}
>
{copied() ? "Copied" : "Copy key"} {copied() ? "Copied" : "Copy key"}
</Button> </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 { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Privacy Policy - Shapes with Abigail" 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." description="Privacy policy for Shapes with Abigail app, explaining our commitment to child safety and non-collection of personal data."

View File

@@ -4,7 +4,9 @@ import NotFound from "./[...404]";
import NessaPrivacyPolicy from "./nessa/privacy"; import NessaPrivacyPolicy from "./nessa/privacy";
import LineagePrivacyPolicy from "./lineage/privacy"; import LineagePrivacyPolicy from "./lineage/privacy";
import GazePrivacyPolicy from "./gaze/privacy"; import GazePrivacyPolicy from "./gaze/privacy";
import InputHaloPrivacyPolicy from "./inputhalo/privacy";
import NookPrivacyPolicy from "./nook/privacy"; import NookPrivacyPolicy from "./nook/privacy";
import MainPrivacyPolicy from "./privacy-policy/index";
/** /**
* Host-aware privacy policy route — `/privacy`. * Host-aware privacy policy route — `/privacy`.
* *
@@ -18,16 +20,20 @@ import NookPrivacyPolicy from "./nook/privacy";
* (SolidStart's client `Router` matches on `window.location.pathname`, not the * (SolidStart's client `Router` matches on `window.location.pathname`, not the
* rewritten server path). * rewritten server path).
* *
* The main site has no `/privacy` page (it uses `/privacy-policy/*`), so * The main site's policy lives at `/privacy-policy` (a legacy path, so the
* `main` falls back to the 404 handler — preserving its pre-rewrite behavior. * 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 * Legacy `/privacy-policy/<product>` routes 308-redirect to the
* subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`). * corresponding subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
*/ */
export default function PrivacyPage() { export default function PrivacyPage() {
const site = useSite(); const site = useSite();
return ( return (
<Switch fallback={<NotFound />}> <Switch fallback={<NotFound />}>
<Match when={site().id === "main"}>
<MainPrivacyPolicy />
</Match>
<Match when={site().id === "nessa"}> <Match when={site().id === "nessa"}>
<NessaPrivacyPolicy /> <NessaPrivacyPolicy />
</Match> </Match>

View File

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

View File

@@ -67,7 +67,7 @@ const latestAssets: Record<
> = { > = {
gaze: { prefix: "downloads/Gaze-", ext: ".dmg" }, gaze: { prefix: "downloads/Gaze-", ext: ".dmg" },
inputhalo: { prefix: "downloads/InputHalo-", ext: ".dmg" }, inputhalo: { prefix: "downloads/InputHalo-", ext: ".dmg" },
thenook: { prefix: "downloads/TheNook-", ext: ".zip" } thenook: { prefix: "downloads/TheNook-", ext: ".dmg" }
}; };
export const downloadsRouter = createTRPCRouter({ export const downloadsRouter = createTRPCRouter({

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 * is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
* tool is needed. * 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)) * Payload layout (v1) is defined below in `buildPayload`. The Swift client
* * decodes the base32 and verifies the EXACT payload bytes against the
* Canonicalization is load-bearing. The Swift client verifies the EXACT * Ed25519 signature, so the layout and signing must stay stable.
* 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.
*/ */
interface IssueLicenseResult { interface IssueLicenseResult {
@@ -76,24 +72,90 @@ function privateKeyObject() {
}); });
} }
function signPayload(payload: string): string { const KEY_PREFIX = "NOOK-";
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject()); const SIGNATURE_LENGTH = 64;
return Buffer.from(signature).toString("base64url"); 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). */ /** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
export function verifyLicenseKey(key: string): boolean { export function verifyLicenseKey(key: string): boolean {
const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/); const token = base32Decode(key);
if (!token) return false; if (!token || token.length <= SIGNATURE_LENGTH) return false;
const [, payload, sigB64] = token; 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()); const publicKey = createPublicKey(privateKeyObject());
let signature: Buffer; return verify(null, payload as Buffer, publicKey, signature);
try {
signature = Buffer.from(sigB64!, "base64url");
} catch {
return false;
}
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
} }
/** /**
@@ -106,17 +168,21 @@ async function insertLicense(
maxDevices: number maxDevices: number
): Promise<IssueLicenseResult> { ): Promise<IssueLicenseResult> {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const payload = JSON.stringify({ const iat = Math.floor(Date.now() / 1000);
v: PAYLOAD_VERSION, const payload = buildPayload(id, email, iat);
lid: id, const signature = sign(null, payload, privateKeyObject());
email: email, const key = encodeKey(Buffer.concat([payload, signature]));
iat: Math.floor(Date.now() / 1000)
});
const key = `${payload}.${signPayload(payload)}`;
await NookConnectionFactory().execute({ await NookConnectionFactory().execute({
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices) sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
VALUES (?, ?, ?, ?, ?, 0, ?)`, VALUES (?, ?, ?, ?, ?, 0, ?)`,
args: [id, key, email, stripeSessionId, new Date().toISOString(), maxDevices] args: [
id,
key,
email,
stripeSessionId,
new Date().toISOString(),
maxDevices
]
}); });
return { key, id }; return { key, id };
} }
@@ -147,3 +213,34 @@ export async function grantLicense(
): Promise<IssueLicenseResult> { ): Promise<IssueLicenseResult> {
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices); 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);
}
}