Compare commits
11 Commits
17bb43851e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ Thumbs.db
|
|||||||
# pygienium run-state and check artifacts
|
# pygienium run-state and check artifacts
|
||||||
.pygienium/
|
.pygienium/
|
||||||
scripts/
|
scripts/
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/cam-recording.mp4
Normal file
Binary file not shown.
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
Binary file not shown.
@@ -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
|
||||||
|
|||||||
33
src/app.css
33
src/app.css
@@ -722,3 +722,36 @@ a.hover-underline-animation:hover::after {
|
|||||||
display: none;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
29
src/components/EdgeCacheHeaders.tsx
Normal file
29
src/components/EdgeCacheHeaders.tsx
Normal 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}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"><3</span>
|
made with <span class="text-red-400"><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>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
import {
|
||||||
|
For,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
type JSX
|
||||||
|
} from "solid-js";
|
||||||
import {
|
import {
|
||||||
IslandPill,
|
IslandPill,
|
||||||
IslandExpanded,
|
|
||||||
AgentDotGrid,
|
AgentDotGrid,
|
||||||
STATUS,
|
STATUS,
|
||||||
CALM,
|
CALM,
|
||||||
|
WARM,
|
||||||
ACCENT,
|
ACCENT,
|
||||||
CRITICAL,
|
CRITICAL,
|
||||||
ModuleCard
|
ModuleCard
|
||||||
@@ -13,9 +20,9 @@ import { Campfire, type CampfireState } from "./Campfire";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
||||||
* Each demo animates when scrolled into view (IntersectionObserver +
|
* Each demo stages the real interaction loop: attention flares, approval
|
||||||
* motion's animate), staging the real interaction loop: attention
|
* resolves, fans throttle, the charge ceiling holds, meetings surface,
|
||||||
* flares, approval resolves, fans throttle, sessions stream.
|
* the camera wakes, the hook installs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
||||||
@@ -131,9 +138,15 @@ function FleetDemo() {
|
|||||||
<div
|
<div
|
||||||
class="rounded-xl px-4 py-3 text-center shadow-lg transition-all duration-500"
|
class="rounded-xl px-4 py-3 text-center shadow-lg transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
background: phase() === "flare" ? "rgba(231,167,98,0.12)" : "rgba(255,255,255,0.055)",
|
background:
|
||||||
|
phase() === "flare"
|
||||||
|
? "rgba(231,167,98,0.12)"
|
||||||
|
: "rgba(255,255,255,0.055)",
|
||||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)",
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)",
|
||||||
transform: phase() === "flare" ? "translateY(0) scale(1)" : "translateY(6px) scale(0.98)",
|
transform:
|
||||||
|
phase() === "flare"
|
||||||
|
? "translateY(0) scale(1)"
|
||||||
|
: "translateY(6px) scale(0.98)",
|
||||||
opacity: phase() === "watch" ? 0.55 : 1
|
opacity: phase() === "watch" ? 0.55 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -191,7 +204,9 @@ function ApprovalDemo() {
|
|||||||
<span
|
<span
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
style={{
|
style={{
|
||||||
background: allowed() ? "rgba(150,220,150,0.25)" : "rgba(234,179,8,0.25)"
|
background: allowed()
|
||||||
|
? "rgba(150,220,150,0.25)"
|
||||||
|
: "rgba(234,179,8,0.25)"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{allowed() ? "allowed" : "approval"}
|
{allowed() ? "allowed" : "approval"}
|
||||||
@@ -250,14 +265,25 @@ function ApprovalDemo() {
|
|||||||
|
|
||||||
/* ── Demo 3: fans throttle up under load, then back down ──────────── */
|
/* ── Demo 3: fans throttle up under load, then back down ──────────── */
|
||||||
|
|
||||||
|
/* Spark pool: deterministic pseudo-random, count gated by fan speed. */
|
||||||
|
const sparkRand = (i: number, k: number) => {
|
||||||
|
const x = Math.sin(i * 127.1 + k * 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
const SPARKS = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
sx: `${Math.round(10 + sparkRand(i, 1) * 26)}px`,
|
||||||
|
sy: `${Math.round(18 + sparkRand(i, 2) * 30)}px`,
|
||||||
|
sd: `${(0.4 + sparkRand(i, 3) * 0.25).toFixed(2)}s`,
|
||||||
|
sdel: `${(sparkRand(i, 4) * 1.2).toFixed(2)}s`,
|
||||||
|
size: sparkRand(i, 5) > 0.5 ? 3 : 2
|
||||||
|
}));
|
||||||
|
|
||||||
function FansDemo() {
|
function FansDemo() {
|
||||||
const [rpm, setRpm] = createSignal(1270);
|
const [rpm, setRpm] = createSignal(1270);
|
||||||
const [maxed, setMaxed] = createSignal(false);
|
const [maxed, setMaxed] = createSignal(false);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
/* Linear ramp toward `target` — close enough to the app's spring
|
|
||||||
at marketing-frame rates, with zero easing-library surface. */
|
|
||||||
const ramp = (target: number, durationMs: number) => {
|
const ramp = (target: number, durationMs: number) => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
const start = rpm();
|
const start = rpm();
|
||||||
@@ -293,48 +319,265 @@ function FansDemo() {
|
|||||||
{ rpm: Math.round(rpm() * 0.96), frac: (rpm() * 0.96) / 6000 }
|
{ rpm: Math.round(rpm() * 0.96), frac: (rpm() * 0.96) / 6000 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const shakeIntensity = () =>
|
||||||
|
Math.max(0, Math.min(1, (rpm() - 2500) / (6000 - 2500)));
|
||||||
|
/* 5 sparks when the shake starts, ~20 at max speed. */
|
||||||
|
const sparkCount = () =>
|
||||||
|
shakeIntensity() > 0 ? 5 + Math.floor(shakeIntensity() * 15) : 0;
|
||||||
|
return (
|
||||||
|
<div class="relative mx-auto w-full max-w-[320px]">
|
||||||
|
<ModuleCard
|
||||||
|
class="fan-card-shake relative z-10"
|
||||||
|
style={{
|
||||||
|
"--fan-amp": `${(shakeIntensity() * 2.6).toFixed(2)}deg`,
|
||||||
|
"--fan-speed": `${(0.4 - shakeIntensity() * 0.28).toFixed(3)}s`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="space-y-3 text-left">
|
||||||
|
<For each={fans()}>
|
||||||
|
{(f, i) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-[10px] font-medium text-white/50">
|
||||||
|
Fan {i() + 1}
|
||||||
|
</span>
|
||||||
|
<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: `${Math.min(f.frac, 1) * 100}%`,
|
||||||
|
background: maxed() ? CRITICAL : CALM,
|
||||||
|
transition: "width 0.2s linear, background 0.4s"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-mono text-white"
|
||||||
|
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||||
|
>
|
||||||
|
{Math.round(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 items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-md px-2 py-0.5 text-[11px] font-bold transition-colors"
|
||||||
|
style={{ color: maxed() ? CRITICAL : "rgba(255,255,255,0.7)" }}
|
||||||
|
>
|
||||||
|
{maxed() ? "MAX" : "Auto"}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-white/40">
|
||||||
|
{maxed() ? "cooling session active" : "curve in control"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute z-0"
|
||||||
|
style={{
|
||||||
|
right: "6px",
|
||||||
|
bottom: "-2px",
|
||||||
|
opacity: sparkCount() > 0 ? "1" : "0",
|
||||||
|
transition: "opacity 0.3s"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={SPARKS.slice(0, sparkCount())}>
|
||||||
|
{(s) => (
|
||||||
|
<span
|
||||||
|
class="fan-spark"
|
||||||
|
style={{
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
"--sx": s.sx,
|
||||||
|
"--sy": s.sy,
|
||||||
|
"--sd": s.sd,
|
||||||
|
"--sdel": s.sdel
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake cursor dot: rests on a target, presses, exits. */
|
||||||
|
function FakeCursor(props: {
|
||||||
|
phase: "idle" | "press" | "live";
|
||||||
|
at: { x: number; y: number };
|
||||||
|
exit?: { x: number; y: number };
|
||||||
|
}) {
|
||||||
|
const p = () =>
|
||||||
|
props.phase === "press"
|
||||||
|
? { ...props.at, scale: 0.62, o: 1 }
|
||||||
|
: props.phase === "idle"
|
||||||
|
? { ...props.at, scale: 1, o: 1 }
|
||||||
|
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute z-10 rounded-full bg-white"
|
||||||
|
style={{
|
||||||
|
width: "9px",
|
||||||
|
height: "9px",
|
||||||
|
"box-shadow":
|
||||||
|
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
|
||||||
|
left: `${p().x}%`,
|
||||||
|
top: `${p().y}%`,
|
||||||
|
opacity: p().o,
|
||||||
|
transform: `translate(-50%, -50%) scale(${p().scale})`,
|
||||||
|
transition:
|
||||||
|
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
|
||||||
|
|
||||||
|
function BatteryDemo() {
|
||||||
|
const [level, setLevel] = createSignal(62);
|
||||||
|
const [phase, setPhase] = createSignal<"charge" | "held" | "topup">("charge");
|
||||||
|
onMount(() => {
|
||||||
|
let frame = 0;
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const ramp = (target: number, durationMs: number) => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
const start = level();
|
||||||
|
const startTime = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min((now - startTime) / durationMs, 1);
|
||||||
|
setLevel(start + (target - start) * t);
|
||||||
|
if (t < 1) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
const loop = () => {
|
||||||
|
setLevel(62);
|
||||||
|
setPhase("charge");
|
||||||
|
ramp(80, 2600);
|
||||||
|
timers.push(setTimeout(() => setPhase("held"), 2800));
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
setPhase("topup");
|
||||||
|
ramp(100, 2600);
|
||||||
|
}, 5200)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
const interval = setInterval(loop, 9600);
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pct = () => Math.round(level());
|
||||||
|
const watts = () => (phase() === "held" ? null : pct() >= 100 ? 9 : 38);
|
||||||
|
const subline = () =>
|
||||||
|
phase() === "held"
|
||||||
|
? "charging paused — at the limit"
|
||||||
|
: pct() >= 100
|
||||||
|
? "topped up — re-arms on unplug"
|
||||||
|
: phase() === "topup"
|
||||||
|
? `${watts()} W · top-up past the limit`
|
||||||
|
: `${watts()} W · ${Math.max(1, Math.round((100 - pct()) * 1.5))} min to full`;
|
||||||
return (
|
return (
|
||||||
<ModuleCard class="mx-auto w-full max-w-[320px]">
|
<ModuleCard class="mx-auto w-full max-w-[320px]">
|
||||||
<div class="space-y-3 text-left">
|
<div class="space-y-3 text-left">
|
||||||
<For each={fans()}>
|
<div class="flex items-center gap-2">
|
||||||
{(f, i) => (
|
<span class="text-[10px] font-semibold tracking-wide text-white/40 uppercase">
|
||||||
<div class="flex items-center gap-3">
|
Battery
|
||||||
<span class="text-[10px] font-medium text-white/50">
|
</span>
|
||||||
Fan {i() + 1}
|
<span
|
||||||
</span>
|
class="ml-auto rounded-md px-2 py-0.5 font-mono text-[10px] font-semibold transition-transform duration-300"
|
||||||
<div
|
style={{
|
||||||
class="h-1 flex-1 overflow-hidden rounded-full"
|
color: "rgba(255,255,255,0.75)",
|
||||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
background: "rgba(255,255,255,0.08)",
|
||||||
>
|
transform: phase() === "held" ? "scale(1.12)" : "scale(1)"
|
||||||
<div
|
}}
|
||||||
class="h-full rounded-full"
|
>
|
||||||
style={{
|
Limit 80%
|
||||||
width: `${Math.min(f.frac, 1) * 100}%`,
|
</span>
|
||||||
background: maxed() ? CRITICAL : CALM,
|
</div>
|
||||||
transition: "width 0.2s linear, background 0.4s"
|
<div class="flex items-baseline gap-1.5">
|
||||||
}}
|
<span class="font-mono text-[22px] font-semibold text-white">
|
||||||
/>
|
{pct()}%
|
||||||
</div>
|
</span>
|
||||||
<span
|
<span class="text-[11px] text-white/50">{subline()}</span>
|
||||||
class="font-mono text-white"
|
</div>
|
||||||
style={{ "font-size": "13px", "font-weight": 500 }}
|
<div
|
||||||
>
|
class="relative h-[6px] overflow-hidden rounded-full"
|
||||||
{Math.round(f.rpm)}
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
</span>
|
>
|
||||||
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
<div
|
||||||
</div>
|
class="h-full rounded-full"
|
||||||
)}
|
style={{
|
||||||
</For>
|
width: `${pct()}%`,
|
||||||
|
background: pct() > 80 ? WARM : CALM,
|
||||||
|
transition: "background 0.4s"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="absolute top-0 h-full w-px"
|
||||||
|
style={{ left: "80%", background: "rgba(255,255,255,0.5)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
phase() === "topup" ? "rgba(110,167,255,0.10)" : "transparent"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-[11px] font-semibold text-white/85">Top-up mode</p>
|
||||||
|
<p class="text-[9px] text-white/40">
|
||||||
|
charge past the limit while plugged in
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="relative inline-flex h-[16px] w-[30px] items-center rounded-full transition-colors duration-300"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
phase() === "topup" ? ACCENT : "rgba(255,255,255,0.15)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute h-[12px] w-[12px] rounded-full bg-white transition-all duration-300"
|
||||||
|
style={{ left: phase() === "topup" ? "16px" : "2px" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<svg
|
||||||
class="rounded-md px-2 py-0.5 text-[11px] font-bold transition-colors"
|
width="10"
|
||||||
style={{ color: maxed() ? CRITICAL : "rgba(255,255,255,0.7)" }}
|
height="13"
|
||||||
|
viewBox="0 0 10 13"
|
||||||
|
style={{
|
||||||
|
animation: watts()
|
||||||
|
? "nook-pulse 1.1s ease-in-out infinite"
|
||||||
|
: "none",
|
||||||
|
opacity: watts() ? 1 : 0.3
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{maxed() ? "MAX" : "Auto"}
|
<path
|
||||||
</span>
|
d="M6.2 0 0.8 7h2.6l-1 5.2L7.8 5.4H5l1.2-5.4z"
|
||||||
<span class="text-[10px] text-white/40">
|
fill="#FFD23F"
|
||||||
{maxed() ? "cooling session active" : "curve in control"}
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-[10px] text-white/50">
|
||||||
|
{phase() === "held"
|
||||||
|
? "holding — the pack stays young longer"
|
||||||
|
: phase() === "topup"
|
||||||
|
? "topping up — re-arms when you unplug"
|
||||||
|
: "on the way to the ceiling"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,77 +585,392 @@ function FansDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Demo 4: remote sessions streaming into the island ────────────── */
|
/* ── Demo 5: the slot counts down, then opens the day ──────── */
|
||||||
const REMOTE_TICKS: {
|
|
||||||
name: string;
|
const WEEK_LETTERS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
via: string;
|
const DAY_EVENTS = [
|
||||||
state: keyof typeof STATUS;
|
{ time: "09:30–09:45", title: "Standup", loc: "Daily", meeting: true },
|
||||||
line: string;
|
{ time: "15:30–16:00", title: "Design review", loc: "4th floor", meeting: false }
|
||||||
}[] = [
|
|
||||||
{ name: "build-box", via: "ssh", state: "running", line: "You: fix flaky test" },
|
|
||||||
{ name: "gpu-rig", via: "tailscale", state: "running", line: "You: benchmark fp16" }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function RemoteDemo() {
|
function CalendarDemo() {
|
||||||
const [tick, setTick] = createSignal(0);
|
const [mins, setMins] = createSignal(14);
|
||||||
|
const [today, setToday] = createSignal(new Date());
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
setToday(new Date());
|
||||||
const interval = setInterval(
|
const interval = setInterval(
|
||||||
() => setTick((t) => (t + 1) % REMOTE_TICKS.length),
|
() => setMins((m) => (m <= 0 ? 14 : m - 1)),
|
||||||
2600
|
900
|
||||||
);
|
);
|
||||||
onCleanup(() => clearInterval(interval));
|
onCleanup(() => clearInterval(interval));
|
||||||
});
|
});
|
||||||
|
const imminent = () => mins() <= 5;
|
||||||
|
const dialDays = () => {
|
||||||
|
const base = today();
|
||||||
|
return [-4, -3, -2, -1, 0, 1, 2, 3, 4].map((off) => {
|
||||||
|
const d = new Date(
|
||||||
|
base.getFullYear(),
|
||||||
|
base.getMonth(),
|
||||||
|
base.getDate() + off
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
letter: WEEK_LETTERS[d.getDay()],
|
||||||
|
num: d.getDate(),
|
||||||
|
today: off === 0,
|
||||||
|
past: off < 0,
|
||||||
|
dots: (d.getDate() * 7 + d.getDay() * 3) % 4
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center gap-4">
|
/* Fixed height: the day panel expands into reserved space, never
|
||||||
<IslandExpanded
|
shifts the page (matters most on mobile's stacked layout). */
|
||||||
activeCount={2}
|
<div class="flex h-[316px] flex-col items-center gap-4">
|
||||||
tabs={[
|
<IslandPill
|
||||||
{ icon: "🔥", label: "Agents", active: true },
|
campfire="running"
|
||||||
{ icon: "♪", label: "Music" },
|
right={
|
||||||
{ icon: "◌", label: "System" }
|
<div class="flex items-center gap-1">
|
||||||
]}
|
<svg width="10" height="8" viewBox="0 0 12 9" class="shrink-0">
|
||||||
|
<path
|
||||||
|
d="M0 2A1.4 1.4 0 0 1 1.4 0.6h5.6A1.4 1.4 0 0 1 8.4 2v5A1.4 1.4 0 0 1 7 8.4H1.4A1.4 1.4 0 0 1 0 7z M12 2.6 9 4.5l3 1.9z"
|
||||||
|
fill="rgba(255,255,255,0.75)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap font-mono text-[9.5px] font-semibold text-white/90"
|
||||||
|
style={{ color: imminent() ? "#ff9e42" : "rgba(255,255,255,0.9)" }}
|
||||||
|
>
|
||||||
|
{mins() === 0 ? "now" : `${mins()}m`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* the island opening onto the day panel */}
|
||||||
|
<div
|
||||||
|
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
|
||||||
|
style={{
|
||||||
|
"max-height": imminent() ? "240px" : "0px",
|
||||||
|
opacity: imminent() ? 1 : 0,
|
||||||
|
transform: imminent() ? "translateY(0)" : "translateY(-8px)"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="space-y-2 text-left">
|
<div class="px-4 pt-3 pb-3 text-left">
|
||||||
<For each={REMOTE_TICKS}>
|
<div class="mb-2 flex items-center">
|
||||||
{(host, i) => (
|
<span class="text-[11px] font-semibold text-white/50">
|
||||||
<div
|
Calendar
|
||||||
class="rounded-[10px] p-2.5 transition-all duration-500"
|
</span>
|
||||||
style={{
|
<span class="ml-auto flex h-4 w-4 items-center justify-center text-[12px] font-semibold text-white/50">
|
||||||
background: "rgba(255,255,255,0.055)",
|
+
|
||||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)",
|
</span>
|
||||||
opacity: i() === tick() ? 1 : 0.55,
|
</div>
|
||||||
transform: i() === tick() ? "scale(1.01)" : "scale(1)"
|
<div class="mb-2 flex gap-1">
|
||||||
}}
|
<For each={dialDays()}>
|
||||||
>
|
{(d) => (
|
||||||
<div class="flex items-center gap-2">
|
<div
|
||||||
<span
|
class="flex flex-1 flex-col items-center gap-[3px]"
|
||||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
style={{ opacity: d.today ? 1 : d.past ? 0.55 : 0.8 }}
|
||||||
style={{ background: STATUS[host.state] }}
|
>
|
||||||
/>
|
<span class="text-[8px] font-semibold text-white/45">
|
||||||
<span class="text-[13px] font-semibold text-white">
|
{d.letter}
|
||||||
{host.name}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
class="flex h-[20px] w-[20px] items-center justify-center rounded-full text-[10px]"
|
||||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
style={{
|
||||||
|
background: d.today
|
||||||
|
? "rgb(255,158,66)"
|
||||||
|
: "rgba(255,255,255,0.06)",
|
||||||
|
color: "rgba(255,255,255,0.95)",
|
||||||
|
"font-weight": d.today ? 700 : 500
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{host.via}
|
{d.num}
|
||||||
|
</span>
|
||||||
|
<span class="flex h-[4px] gap-[2px]">
|
||||||
|
<For each={Array.from({ length: Math.min(d.dots, 3) })}>
|
||||||
|
{() => (
|
||||||
|
<span
|
||||||
|
class="rounded-full"
|
||||||
|
style={{
|
||||||
|
width: "3.5px",
|
||||||
|
height: "3.5px",
|
||||||
|
background: d.today
|
||||||
|
? "rgb(255,158,66)"
|
||||||
|
: "rgba(255,255,255,0.55)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 text-[11px] text-white/50">{host.line}</p>
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-1 h-px"
|
||||||
|
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||||
|
/>
|
||||||
|
<For each={DAY_EVENTS}>
|
||||||
|
{(ev) => (
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
ev.meeting && imminent()
|
||||||
|
? "rgba(72,151,178,0.16)"
|
||||||
|
: "transparent"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-[10px] text-white/45">{ev.time}</p>
|
||||||
|
<p class="text-[11px] font-semibold text-white">{ev.title}</p>
|
||||||
|
{ev.loc && <p class="text-[10px] text-white/35">{ev.loc}</p>}
|
||||||
|
</div>
|
||||||
|
{ev.meeting && (
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-colors duration-500"
|
||||||
|
style={{
|
||||||
|
background: imminent() ? ACCENT : "rgba(255,255,255,0.10)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<p class="pt-1 text-center text-[10px] text-white/40">
|
|
||||||
same island, same glance — agents two networks away
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</IslandExpanded>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Demo 5: campfire states ───────────────────────────────────────── */
|
/* ── Demo 6: the lens only wakes for a click ──────────────── */
|
||||||
|
|
||||||
|
function CameraDemo() {
|
||||||
|
const [phase, setPhase] = createSignal<"idle" | "press" | "live">("idle");
|
||||||
|
let videoRef: HTMLVideoElement | undefined;
|
||||||
|
onMount(() => {
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const loop = () => {
|
||||||
|
setPhase("idle");
|
||||||
|
timers.push(setTimeout(() => setPhase("press"), 2600));
|
||||||
|
timers.push(setTimeout(() => setPhase("live"), 3000));
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
const interval = setInterval(loop, 8800);
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
createEffect(() => {
|
||||||
|
if (phase() === "live" && videoRef) {
|
||||||
|
videoRef.currentTime = 0;
|
||||||
|
videoRef.play();
|
||||||
|
} else {
|
||||||
|
videoRef?.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ModuleCard class="relative mx-auto w-full max-w-[300px]">
|
||||||
|
<div class="space-y-2.5 text-left">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-[11px] font-semibold text-white/50">Camera</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-md px-2 py-0.5 text-[10px] text-white/60 transition-opacity duration-300"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.06)",
|
||||||
|
opacity: phase() === "live" ? 1 : 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
FaceTime HD Camera ▾
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative h-[120px] overflow-hidden rounded-lg">
|
||||||
|
{/* inert dashed start button */}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
border: "1px dashed rgba(255,255,255,0.14)",
|
||||||
|
"border-radius": "8px",
|
||||||
|
opacity: phase() === "live" ? 0 : 1,
|
||||||
|
transform: phase() === "press" ? "scale(0.96)" : "scale(1)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="14" viewBox="0 0 18 14">
|
||||||
|
<path
|
||||||
|
d="M2 2.8A1.8 1.8 0 0 1 3.8 1h2.2l1 1.4h4.2A1.8 1.8 0 0 1 13 4.2v6.6a1.8 1.8 0 0 1-1.8 1.8H3.8A1.8 1.8 0 0 1 2 10.8z"
|
||||||
|
fill="rgba(255,255,255,0.5)"
|
||||||
|
/>
|
||||||
|
<circle cx="7.5" cy="7.4" r="2.6" fill="rgba(0,0,0,0.35)" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-[12px] font-semibold text-white/80">
|
||||||
|
Start camera
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* live preview */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src="/nook/cam-recording.mp4"
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
onEnded={() => setPhase("idle")}
|
||||||
|
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
|
||||||
|
style={{ opacity: phase() === "live" ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
<FakeCursor phase={phase()} at={{ x: 50, y: 50 }} />
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-white/40">
|
||||||
|
{phase() === "live"
|
||||||
|
? "live — stops the moment you leave the panel"
|
||||||
|
: "inert — the lens stays dark until you click"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 7: one click copies, sets up, probes — pong ─────── */
|
||||||
|
|
||||||
|
const SSH_LINES = [
|
||||||
|
{ at: 1, text: "scp nook-hook gpu-rig:~/.nook/bin/" },
|
||||||
|
{ at: 2, text: "setup: bake NOOK_HOST, write ~/.nook/endpoint" },
|
||||||
|
{ at: 3, text: "nook-hook --probe → pong" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function SshInstallDemo() {
|
||||||
|
const [step, setStep] = createSignal(0);
|
||||||
|
onMount(() => {
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const loop = () => {
|
||||||
|
setStep(0);
|
||||||
|
timers.push(setTimeout(() => setStep(1), 1600));
|
||||||
|
timers.push(setTimeout(() => setStep(2), 3000));
|
||||||
|
timers.push(setTimeout(() => setStep(3), 4200));
|
||||||
|
timers.push(setTimeout(() => setStep(4), 5400));
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
const interval = setInterval(loop, 11000);
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const cursorPhase = () =>
|
||||||
|
step() === 0
|
||||||
|
? ("idle" as const)
|
||||||
|
: step() === 1
|
||||||
|
? ("press" as const)
|
||||||
|
: ("live" as const);
|
||||||
|
return (
|
||||||
|
<ModuleCard class="relative mx-auto w-full max-w-[330px]">
|
||||||
|
<div class="space-y-2.5 text-left">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
||||||
|
<p class="truncate font-mono text-[10px] text-white/40">
|
||||||
|
mike@192.168.64.3 · linux
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: step() === 0 ? ACCENT : "rgba(255,255,255,0.08)",
|
||||||
|
opacity: step() >= 4 ? 0 : 1,
|
||||||
|
transform: step() === 1 ? "scale(0.94)" : "scale(1)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step() === 0 ? "Install hook" : "installing…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||||
|
<div class="relative" style={{ "min-height": "86px" }}>
|
||||||
|
<div
|
||||||
|
class="space-y-1.5 transition-opacity duration-500"
|
||||||
|
style={{ opacity: step() >= 4 ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
{step() === 0 && (
|
||||||
|
<p class="pt-2 text-center text-[10px] text-white/35">
|
||||||
|
one click: copy → setup → probe
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<For each={SSH_LINES}>
|
||||||
|
{(l) => (
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
opacity: step() >= l.at ? 1 : 0,
|
||||||
|
transform:
|
||||||
|
step() >= l.at ? "translateY(0)" : "translateY(4px)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-mono text-[10px]"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
step() > l.at
|
||||||
|
? "rgba(150,220,150,0.9)"
|
||||||
|
: "rgba(255,255,255,0.45)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step() > l.at ? "✓" : "…"}
|
||||||
|
</span>
|
||||||
|
<span class="truncate font-mono text-[10px] text-white/70">
|
||||||
|
{l.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col justify-center transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
opacity: step() >= 4 ? 1 : 0,
|
||||||
|
transform: step() >= 4 ? "translateY(0)" : "translateY(6px)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
gpu-rig
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
|
||||||
|
style={{
|
||||||
|
background: "rgba(110,167,255,0.22)",
|
||||||
|
color: "#9cc3ff"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
remote
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-[11px] text-white/50">
|
||||||
|
You: benchmark fp16
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="pt-1.5 text-center text-[10px] text-white/40">
|
||||||
|
agents join the same dot grid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FakeCursor phase={cursorPhase()} at={{ x: 90, y: 14 }} />
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 8: campfire states ───────────────────────────────────────── */
|
||||||
|
|
||||||
function CampfireDemo() {
|
function CampfireDemo() {
|
||||||
const states: CampfireState[] = [
|
const states: CampfireState[] = [
|
||||||
@@ -440,7 +998,9 @@ function CampfireDemo() {
|
|||||||
>
|
>
|
||||||
<Campfire state={state} pixel={3} />
|
<Campfire state={state} pixel={3} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-[11px] font-medium text-white/60">{labels[i()]}</p>
|
<p class="text-center text-[11px] font-medium text-white/60">
|
||||||
|
{labels[i()]}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -458,7 +1018,8 @@ export default function FeatureBreakdowns() {
|
|||||||
bullets={[
|
bullets={[
|
||||||
"12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more",
|
"12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more",
|
||||||
"The campfire dances while work runs, simmers when all is calm",
|
"The campfire dances while work runs, simmers when all is calm",
|
||||||
"Slot rankings — you choose which data earns the notch"
|
"Slot rankings — you choose which data earns the notch",
|
||||||
|
"You don't need a notch — external displays get the same island, floating top-center"
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<FleetDemo />
|
<FleetDemo />
|
||||||
@@ -470,7 +1031,7 @@ export default function FeatureBreakdowns() {
|
|||||||
body="Gated tool calls hold right in the panel. Allow, deny, or demand a reason. Walking away? The same request lands on your phone and your tap flies back to the agent."
|
body="Gated tool calls hold right in the panel. Allow, deny, or demand a reason. Walking away? The same request lands on your phone and your tap flies back to the agent."
|
||||||
bullets={[
|
bullets={[
|
||||||
"Permission and question cards pin above the session list",
|
"Permission and question cards pin above the session list",
|
||||||
"Push-to-phone over your own network — no cloud middleman",
|
"Push-to-phone over your own network — no cloud middleman (coming soon)",
|
||||||
"Works across every hooked agent, local or remote"
|
"Works across every hooked agent, local or remote"
|
||||||
]}
|
]}
|
||||||
reversed
|
reversed
|
||||||
@@ -481,8 +1042,9 @@ export default function FeatureBreakdowns() {
|
|||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
kicker="Cooling"
|
kicker="Cooling"
|
||||||
title="Fans that answer to you, not the scheduler"
|
title="Fans that answer to you, not the scheduler"
|
||||||
body="Live RPM for every fan, a privileged helper for the curve. MAX before a compile, aggressive when the render queue spikes, auto when you walk away."
|
body="Live RPM for every fan, and the curve is yours. Apple's stock curve waits until you're already cooking — swap to Aggressive and it climbs earlier and harder, or slam MAX before the compile."
|
||||||
bullets={[
|
bullets={[
|
||||||
|
"Stock, Aggressive, and MAX — swap curves in one click",
|
||||||
"MAX and COOL sessions surface right in the pill",
|
"MAX and COOL sessions surface right in the pill",
|
||||||
"CPU/GPU die temps tier-color before things get loud",
|
"CPU/GPU die temps tier-color before things get loud",
|
||||||
"All local: your hardware data never leaves the device"
|
"All local: your hardware data never leaves the device"
|
||||||
@@ -492,17 +1054,61 @@ export default function FeatureBreakdowns() {
|
|||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
|
|
||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
kicker="Remote"
|
kicker="Power"
|
||||||
title="SSH boxes join the same island"
|
title="Wall power, on your terms"
|
||||||
body="Push the nook-hook to any Linux host — Tailscale or plain SSH with a reverse tunnel — and its agents appear beside your local ones, indistinguishable."
|
body="Live watts from the adapter to the battery to the chassis — and a charge ceiling you set yourself. On recent macOS The Nook drives the system's own limiter; below that it enforces the limit itself. One slider, 50–100%, any version."
|
||||||
bullets={[
|
bullets={[
|
||||||
"One-line install bakes the bridge endpoint for you",
|
"One slider, 50–100%, on any macOS version",
|
||||||
"Works for Claude Code, Codex, Gemini CLI and friends",
|
"Hooks the system limiter where it exists, enforces elsewhere",
|
||||||
"Sessions from both machines share one dot grid"
|
"Top-up mode charges past, then re-arms on unplug",
|
||||||
|
"Thermal protection pauses charging when the pack runs hot",
|
||||||
|
"Energy flow figure: adapter → battery → system, live watts"
|
||||||
]}
|
]}
|
||||||
reversed
|
reversed
|
||||||
>
|
>
|
||||||
<RemoteDemo />
|
<BatteryDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Your day"
|
||||||
|
title="Meetings come to the notch"
|
||||||
|
body="Today sits in a dial — days behind, days ahead, dots where things happen. The slot counts your next call down, and when it gets close the island opens the day on its own."
|
||||||
|
bullets={[
|
||||||
|
"Next meeting counts down right in the slot",
|
||||||
|
"Near a call, the island opens the day itself",
|
||||||
|
"Meeting links get an Open button in the row",
|
||||||
|
"Real calendars and reminders — no sync service"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CalendarDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Camera"
|
||||||
|
title="A lens that stays asleep"
|
||||||
|
body="A live camera preview in the island for the seconds before a call. It never wakes on its own: permission-gated, inert until you click Start, and dead the moment you leave the panel."
|
||||||
|
bullets={[
|
||||||
|
"Nothing watches until Start camera gets a click",
|
||||||
|
"Live only while the panel sits open",
|
||||||
|
"A device picker for every connected camera",
|
||||||
|
"Opt-in module — in no default panel"
|
||||||
|
]}
|
||||||
|
reversed
|
||||||
|
>
|
||||||
|
<CameraDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Remote"
|
||||||
|
title="Any SSH box, one click away"
|
||||||
|
body="Point at any SSH-reachable box and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
|
||||||
|
bullets={[
|
||||||
|
"One click: copy the hook, run setup, probe answers pong",
|
||||||
|
"Remote agents wear a blue badge in the same dot grid",
|
||||||
|
"Works for Claude, Codex, Pi and many more"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SshInstallDemo />
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
|
|
||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ function CollageCell(props: {
|
|||||||
*/
|
*/
|
||||||
function InkStage(props: { children: JSX.Element; class?: string }) {
|
function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||||
return (
|
return (
|
||||||
<div class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`} style={{ background: "#0d0d0f" }}>
|
<div
|
||||||
|
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
|
||||||
|
style={{ background: "#0d0d0f" }}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -153,7 +156,10 @@ function RemoteMock() {
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<div
|
<div
|
||||||
class="rounded-[10px] p-2.5"
|
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)" }}
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -172,7 +178,10 @@ function RemoteMock() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-[10px] p-2.5"
|
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)" }}
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -184,7 +193,7 @@ function RemoteMock() {
|
|||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
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)" }}
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
>
|
>
|
||||||
tailscale
|
ssh
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
|
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
|
||||||
@@ -237,7 +246,7 @@ function GaugesMock() {
|
|||||||
export default function FeatureCollage() {
|
export default function FeatureCollage() {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
class="bg-base relative z-20 -mt-28 px-4 pb-16 pt-40 md:px-8"
|
class="bg-base relative z-20 px-4 pt-40 pb-16 md:px-8"
|
||||||
id="feature-collage"
|
id="feature-collage"
|
||||||
>
|
>
|
||||||
<div class="mx-auto max-w-5xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
@@ -250,9 +259,7 @@ export default function FeatureCollage() {
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||||
{/* Hero: real collapsed pill on the island surface */}
|
{/* Hero: real collapsed pill on the island surface */}
|
||||||
<div
|
<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">
|
||||||
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">
|
<div class="mb-5 self-start">
|
||||||
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
The island
|
The island
|
||||||
@@ -261,9 +268,9 @@ export default function FeatureCollage() {
|
|||||||
Every agent, one glance
|
Every agent, one glance
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||||
A pill tucked into your notch — a campfire for your whole
|
A pill tucked into your notch — a campfire for your whole fleet
|
||||||
fleet on the left, live data on the right. Blue running,
|
on the left, live data on the right. Blue running, green ready,
|
||||||
green ready, amber needs you.
|
amber needs you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-10 flex w-full justify-center">
|
<div class="my-10 flex w-full justify-center">
|
||||||
@@ -304,7 +311,7 @@ export default function FeatureCollage() {
|
|||||||
<CollageCell
|
<CollageCell
|
||||||
kicker="Approvals"
|
kicker="Approvals"
|
||||||
title="Unblock agents, anywhere"
|
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."
|
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>
|
<InkStage>
|
||||||
<div class="w-full max-w-sm px-3">
|
<div class="w-full max-w-sm px-3">
|
||||||
@@ -330,7 +337,7 @@ export default function FeatureCollage() {
|
|||||||
<CollageCell
|
<CollageCell
|
||||||
kicker="Remote"
|
kicker="Remote"
|
||||||
title="Agents on other machines"
|
title="Agents on other machines"
|
||||||
body="Run agents on Linux boxes and SSH servers — a one-line install pipes their sessions back to the same island, over Tailscale or a reverse tunnel."
|
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">
|
<InkStage class="w-full">
|
||||||
<div class="w-full max-w-[280px] text-left">
|
<div class="w-full max-w-[280px] text-left">
|
||||||
|
|||||||
@@ -33,13 +33,18 @@ export const CARD_STROKE = "rgba(255,255,255,0.07)";
|
|||||||
* One module-card surface shared by every mock panel — the app's
|
* One module-card surface shared by every mock panel — the app's
|
||||||
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
||||||
*/
|
*/
|
||||||
export function ModuleCard(props: { children: JSX.Element; class?: string }) {
|
export function ModuleCard(props: {
|
||||||
|
children: JSX.Element;
|
||||||
|
class?: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`rounded-xl ${props.class ?? ""}`}
|
class={`rounded-xl ${props.class ?? ""}`}
|
||||||
style={{
|
style={{
|
||||||
background: CARD_FILL,
|
background: CARD_FILL,
|
||||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
|
||||||
|
...props.style
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="p-3">{props.children}</div>
|
<div class="p-3">{props.children}</div>
|
||||||
|
|||||||
@@ -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
23
src/routes/api/health.ts
Normal 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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ export default function NookCheckout() {
|
|||||||
|
|
||||||
{error() && <p class="text-red mt-4 text-sm">{error()}</p>}
|
{error() && <p class="text-red mt-4 text-sm">{error()}</p>}
|
||||||
<p class="text-subtext1 mt-4 text-xs">
|
<p class="text-subtext1 mt-4 text-xs">
|
||||||
Billed once through Stripe. License delivered immediately after
|
Billed once through Stripe. License delivered immediately and by
|
||||||
payment.
|
email after payment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 FeatureCollage from "~/components/nook/FeatureCollage";
|
||||||
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
|
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
|
||||||
@@ -8,7 +9,20 @@ 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() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/TheNook/appcast.xml");
|
||||||
|
const xml = await res.text();
|
||||||
|
const url =
|
||||||
|
xml.match(/<enclosure url="([^"]+\.dmg)"/)?.[1] ??
|
||||||
|
xml.match(/<enclosure url="([^"]+\.zip)"/)?.[1];
|
||||||
|
if (!url) throw new Error("no enclosure");
|
||||||
|
window.location.href = url;
|
||||||
|
} catch {
|
||||||
|
console.error("Could not resolve latest Nook download");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function NookLanding() {
|
export default function NookLanding() {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
@@ -44,6 +58,7 @@ export default function NookLanding() {
|
|||||||
|
|
||||||
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."
|
||||||
@@ -55,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={{
|
||||||
@@ -64,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"
|
||||||
@@ -81,7 +98,7 @@ 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 and Agents, under 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">
|
||||||
@@ -97,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>
|
||||||
@@ -154,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>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
*/
|
*/
|
||||||
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
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."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main-site privacy policy — `freno.me/privacy-policy`.
|
* Main-site privacy policy — `freno.me/privacy-policy`.
|
||||||
@@ -14,6 +15,7 @@ import { PageHead } from "~/components/PageHead";
|
|||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for the freno.me blog and personal site, covering accounts, comments, and contact forms."
|
description="Privacy policy for the freno.me blog and personal site, covering accounts, comments, and contact forms."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user