Compare commits

...

2 Commits

Author SHA1 Message Date
23f86681af better feature design 2026-09-05 16:06:27 -04:00
8492821f75 better fan ui 2026-09-05 15:08:36 -04:00
6 changed files with 747 additions and 108 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

Binary file not shown.

View File

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

View File

@@ -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,386 @@ 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:3009:45", title: "Standup", loc: "Daily", meeting: true },
line: string; { time: "15:3016: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"> <div class="flex flex-col items-center gap-4">
<IslandExpanded <IslandPill
activeCount={2} campfire="running"
tabs={[ right={
{ icon: "🔥", label: "Agents", active: true }, <div class="flex items-center gap-1">
{ icon: "♪", label: "Music" }, <svg width="10" height="8" viewBox="0 0 12 9" class="shrink-0">
{ icon: "◌", label: "System" } <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?.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
loop
playsinline
preload="metadata"
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 +992,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 +1012,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 />
@@ -481,8 +1036,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 +1048,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, 50100%, any version."
bullets={[ bullets={[
"One-line install bakes the bridge endpoint for you", "One slider, 50100%, 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

View File

@@ -184,7 +184,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>
@@ -330,7 +330,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">

View File

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