From 17bb43851eb6037acc477ac76ab32f2d0f39433c Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 5 Sep 2026 10:33:26 -0400 Subject: [PATCH] midway --- src/app.css | 148 ++++++ src/components/nook/Campfire.tsx | 367 +++++++++++++++ src/components/nook/FeatureBreakdowns.tsx | 522 ++++++++++++++++++++++ src/components/nook/FeatureCollage.tsx | 363 +++++++++++++++ src/components/nook/IslandMock.tsx | 258 +++++++++++ src/routes/nook/index.tsx | 66 +-- 6 files changed, 1679 insertions(+), 45 deletions(-) create mode 100644 src/components/nook/Campfire.tsx create mode 100644 src/components/nook/FeatureBreakdowns.tsx create mode 100644 src/components/nook/FeatureCollage.tsx create mode 100644 src/components/nook/IslandMock.tsx diff --git a/src/app.css b/src/app.css index 4843bbe..09abb51 100644 --- a/src/app.css +++ b/src/app.css @@ -574,3 +574,151 @@ a.hover-underline-animation:hover::after { .shaker:hover { animation: shaker 0.5s ease; } + +/* ── Nook landing: campfire sprite frame cycling ──────────────────── */ +/* Each frame layer is stacked; its animation holds opacity 1 during its + slot and 0 otherwise, so the stack reads as stop-motion at the app's + sprite cadence (frame time = total duration / frame count). The phase + lives inside the keyframes and every layer animates delay-free from the + same clock WebKit quantizes animation starts per cycle wrap, so + delay-offset layers can drop a wrap frame and show BOTH transparent + for a frame (the "blank frame" flicker). Complementary holds mean any + moment sums to exactly one opaque layer. Linear timing: steps() + holds mis-swap at boundary frames under WebKit. */ +/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */ +@keyframes campfire-slot-2-a { + 0%, + 49.99% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +@keyframes campfire-slot-2-b { + 0%, + 49.99% { + opacity: 0; + } + 50%, + 100% { + opacity: 1; + } +} + +/* 6-frame sprites (question): six slot phases. */ +@keyframes campfire-slot-6-a { + 0%, + 16.66% { + opacity: 1; + } + 16.67%, + 100% { + opacity: 0; + } +} +@keyframes campfire-slot-6-b { + 0%, + 16.66% { + opacity: 0; + } + 16.67%, + 33.32% { + opacity: 1; + } + 33.33%, + 100% { + opacity: 0; + } +} +@keyframes campfire-slot-6-c { + 0%, + 33.32% { + opacity: 0; + } + 33.33%, + 49.99% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} +@keyframes campfire-slot-6-d { + 0%, + 49.99% { + opacity: 0; + } + 50%, + 66.66% { + opacity: 1; + } + 66.67%, + 100% { + opacity: 0; + } +} +@keyframes campfire-slot-6-e { + 0%, + 66.66% { + opacity: 0; + } + 66.67%, + 83.32% { + opacity: 1; + } + 83.33%, + 100% { + opacity: 0; + } +} +@keyframes campfire-slot-6-f { + 0%, + 83.32% { + opacity: 0; + } + 83.33%, + 100% { + opacity: 1; + } +} + +.campfire-frame { + animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s) + linear infinite; + opacity: 0; +} + +/* ── Nook landing: breakdown section reveal ───────────────────────── */ +.reveal { + opacity: 0; + transform: translateY(24px); + transition: + opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); +} + +.reveal.in { + opacity: 1; + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + transition: none; + } + + .campfire-frame { + animation: none; + opacity: 1 !important; + } + + .campfire-frame + .campfire-frame { + display: none; + } +} diff --git a/src/components/nook/Campfire.tsx b/src/components/nook/Campfire.tsx new file mode 100644 index 0000000..de2f9cd --- /dev/null +++ b/src/components/nook/Campfire.tsx @@ -0,0 +1,367 @@ +import { For, type JSX } from "solid-js"; + +/** + * Pixel-art campfire sprite, faithful to the app's CampfireFrames: a 14×14 + * grid where the log base never moves and only the flame and sparks animate. + * `state` picks the frame set and cadence — idle simmers, running dances, + * ready exhales, error pulses dead embers. + */ + +const FIRE_PALETTE: Record = { + o: "#e7873a", + y: "#ffce70", + w: "#ffffff", + b: "#8b5a2b", + d: "#5a3a1b", + s: "#ffd166", + r: "#c43a30", + R: "#f25240", + x: "#ff7864", + m: "rgba(255,255,255,0.5)", + k: "rgba(255,255,255,0.26)", + e: "#a8582a", + E: "#d87636", + B: "#4890fc", + L: "#92c7ff", + C: "#e9f8ff", + S: "#bfe0ff" +}; + +interface CampfireFrame { + rows: string[]; +} + +const IDLE_FRAMES: CampfireFrame[] = [ + { + rows: [ + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + ".....oyo......", + "....oyyyo.....", + "..dddeeeeeEd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "..............", + "..............", + ".........k....", + "..............", + "..............", + "..............", + "..............", + "......oyo.....", + "....oyyyo.....", + "..dddEEEEEEd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + } +]; + +const RUN_FRAMES: CampfireFrame[] = [ + { + rows: [ + "..............", + "..............", + ".....s.yy.....", + ".....oyyo.....", + "....oywwyo....", + "...oyywwyyo...", + "...oyywwyyo...", + "...oyywwyyo...", + "....oywwyo....", + "..dddddddddd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "..............", + ".....s........", + ".....yy.......", + ".....oyyo.....", + "....oywwyo....", + "...oyywwyyo...", + "...oyywwyyo...", + "...oyywwyyo...", + "....oywwyo....", + "..dddddddddd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + } +]; + +const READY_FRAMES: CampfireFrame[] = [ + { + rows: [ + "..............", + "..............", + "..............", + "..............", + "..............", + "......yy......", + ".....oyyo.....", + "....oyyyyo....", + "....oyyyyo....", + "..dddyyyyyyd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "..............", + "..............", + "..............", + "..............", + "........m.....", + ".....yy.......", + ".....oyyo.....", + "....oyyyyo....", + "....oyyyyo....", + "..dddyyyyyyd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + } +]; + +/* App CampfireFrames.ques0-5: cool blue flame, tip dips, sparks spit. */ +const QUES_FRAMES: CampfireFrame[] = [ + { + rows: [ + "..............", + "..............", + ".....S.LL.....", + ".....BLLB.....", + "....BLCCLB....", + "...BLLCCLLB...", + "...BLLCCLLB...", + "...BLLCCLLB...", + "....BLCCLB....", + "..dddccccccd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "..............", + ".....S........", + ".....LL.......", + ".....BLLB.....", + "....BLCCLB....", + "...BLLCCLLB...", + "...BLLCCLLB...", + "...BLLCCLLB...", + "....BLCCLB....", + "..dddccccccd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "..............", + ".....S........", + "......LL......", + ".....BLLB.....", + "....BLCCLB....", + "...BLLCCLLB...", + "...BLLCCLLB...", + "...BLLCCLLB...", + "....BLCCLB....", + "..dddccccccd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + ".....S........", + "..............", + "......LL......", + ".....BLLB.....", + "....BLCCLB....", + "...BLLCCLLB...", + "...BLLCCLLB...", + "...BLLCCLLB...", + "....BLCCLB....", + "..dddccccccd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + ".....S........", + "..............", + ".......LL.....", + ".....BLLB.....", + "....BLCCLB....", + "...BLLCCLLB...", + "...BLLCCLLB...", + "...BLLCCLLB...", + "....BLCCLB....", + "..dddccccccd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "........S.....", + "..............", + ".......LL.....", + ".....BLLB.....", + "....BLCCLB....", + "...BLLCCLLB...", + "...BLLCCLLB...", + "...BLLCCLLB...", + "....BLCCLB....", + "..dddccccccd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + } +]; + +const ERROR_FRAMES: CampfireFrame[] = [ + { + rows: [ + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + "..dddrrrrrrd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + }, + { + rows: [ + "........x.....", + "..............", + ".....x........", + "..............", + "..............", + "..............", + "..............", + "..............", + "..............", + "..dddRRRRRRd..", + "..dbbbbbbbbd..", + "..............", + "..............", + ".............." + ] + } +]; + +export type CampfireState = "idle" | "running" | "ready" | "question" | "error"; + +const STATE_FRAMES: Record = { + idle: IDLE_FRAMES, + running: RUN_FRAMES, + ready: READY_FRAMES, + question: QUES_FRAMES, + error: ERROR_FRAMES +}; + +const STATE_FPS: Record = { + idle: 2, + running: 9, + ready: 4, + question: 4.5, + error: 4 +}; + +export function Campfire(props: { state: CampfireState; pixel?: number }) { + const pixel = () => props.pixel ?? 3; + return ( +
+ {/* Slot phase lives in the keyframes per layer (campfire-slot-N-x); + delay-offset layers blank a wrap frame under WebKit. */} + + {(frame, i) => { + const n = STATE_FRAMES[props.state].length; + const phase = String.fromCharCode(97 + (i() % 26)); + return ( +
+ + {(row) => ( +
+ + {(ch) => ( + + )} + +
+ )} +
+
+ ); + }} +
+
+ ); +} diff --git a/src/components/nook/FeatureBreakdowns.tsx b/src/components/nook/FeatureBreakdowns.tsx new file mode 100644 index 0000000..f20cea6 --- /dev/null +++ b/src/components/nook/FeatureBreakdowns.tsx @@ -0,0 +1,522 @@ +import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"; +import { + IslandPill, + IslandExpanded, + AgentDotGrid, + STATUS, + CALM, + ACCENT, + CRITICAL, + ModuleCard +} from "./IslandMock"; +import { Campfire, type CampfireState } from "./Campfire"; + +/** + * Detailed feature breakdowns: alternating copy + live-demo sections. + * Each demo animates when scrolled into view (IntersectionObserver + + * motion's animate), staging the real interaction loop: attention + * flares, approval resolves, fans throttle, sessions stream. + */ + +/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */ +function useReveal() { + const [el, setEl] = createSignal(); + onMount(() => { + const node = el(); + if (!node) return; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + node.classList.add("in"); + observer.disconnect(); + } + } + }, + { threshold: 0.25 } + ); + observer.observe(node); + onCleanup(() => observer.disconnect()); + }); + return [setEl, el] as const; +} + +function BreakdownSection(props: { + kicker: string; + title: string; + body: string; + bullets: string[]; + reversed?: boolean; + children: JSX.Element; +}) { + const [ref] = useReveal(); + return ( +
+
+

+ {props.kicker} +

+

+ {props.title} +

+

{props.body}

+
    + + {(bullet) => ( +
  • + + {bullet} +
  • + )} +
    +
+
+
+ {props.children} +
+
+ ); +} + +/* ── Demo 1: the fleet loop — attention flares, then resolves ─────── */ + +function FleetDemo() { + const [phase, setPhase] = createSignal<"watch" | "flare" | "resolve">( + "watch" + ); + onMount(() => { + const timers: ReturnType[] = []; + const loop = () => { + setPhase("watch"); + timers.push(setTimeout(() => setPhase("flare"), 3200)); + timers.push(setTimeout(() => setPhase("resolve"), 6400)); + }; + loop(); + const interval = setInterval(loop, 9600); + onCleanup(() => { + clearInterval(interval); + timers.forEach(clearTimeout); + }); + }); + + const states = () => { + switch (phase()) { + case "watch": + return ["running", "running", "ready", "running", "idle"] as const; + case "flare": + return ["running", "attention", "ready", "running", "idle"] as const; + case "resolve": + return ["running", "ready", "ready", "running", "idle"] as const; + } + }; + + return ( +
+ } + /> +
+

+ {phase() === "flare" + ? "Codex needs your input" + : phase() === "resolve" + ? "Answer delivered — agents back to work" + : "Fleet running calmly"} +

+

+ {phase() === "flare" ? "question · in the island now" : "\u00a0"} +

+
+
+ ); +} + +/* ── Demo 2: permission card resolves, phone push follows ─────────── */ + +function ApprovalDemo() { + const [allowed, setAllowed] = createSignal(false); + onMount(() => { + const timers: ReturnType[] = []; + const loop = () => { + setAllowed(false); + timers.push(setTimeout(() => setAllowed(true), 4200)); + }; + loop(); + const interval = setInterval(loop, 8400); + onCleanup(() => { + clearInterval(interval); + timers.forEach(clearTimeout); + }); + }); + + return ( +
+ {/* island card */} +
+
+ + + the-nook · bridge + + + {allowed() ? "allowed" : "approval"} + +
+

Run Edit

+

+ server/routes.ts +12 −4 +

+
+ + Allow + + + Deny + +
+
+ {/* phone */} +
+
+
+

+ The Nook +

+
+

+ {allowed() ? "Allowed ✓" : "Claude Code wants to edit"} +

+

+ {allowed() ? "resumed" : "tap to Allow or Deny"} +

+
+
+
+
+ ); +} + +/* ── Demo 3: fans throttle up under load, then back down ──────────── */ + +function FansDemo() { + const [rpm, setRpm] = createSignal(1270); + const [maxed, setMaxed] = createSignal(false); + onMount(() => { + let frame = 0; + const timers: ReturnType[] = []; + /* 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) => { + cancelAnimationFrame(frame); + const start = rpm(); + const startTime = performance.now(); + const step = (now: number) => { + const t = Math.min((now - startTime) / durationMs, 1); + setRpm(Math.round(start + (target - start) * t)); + if (t < 1) frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + }; + const loop = () => { + setMaxed(true); + ramp(5960, 1200); + timers.push( + setTimeout(() => { + setMaxed(false); + ramp(1270, 1600); + }, 5200) + ); + }; + loop(); + const interval = setInterval(loop, 10400); + onCleanup(() => { + clearInterval(interval); + timers.forEach(clearTimeout); + cancelAnimationFrame(frame); + }); + }); + + const fans = () => [ + { rpm: rpm(), frac: rpm() / 6000 }, + { rpm: Math.round(rpm() * 0.96), frac: (rpm() * 0.96) / 6000 } + ]; + + return ( + +
+ + {(f, i) => ( +
+ + Fan {i() + 1} + +
+
+
+ + {Math.round(f.rpm)} + + RPM +
+ )} + +
+
+ + {maxed() ? "MAX" : "Auto"} + + + {maxed() ? "cooling session active" : "curve in control"} + +
+
+ + ); +} + +/* ── Demo 4: remote sessions streaming into the island ────────────── */ +const REMOTE_TICKS: { + name: string; + via: string; + state: keyof typeof STATUS; + line: string; +}[] = [ + { 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() { + const [tick, setTick] = createSignal(0); + onMount(() => { + const interval = setInterval( + () => setTick((t) => (t + 1) % REMOTE_TICKS.length), + 2600 + ); + onCleanup(() => clearInterval(interval)); + }); + return ( +
+ +
+ + {(host, i) => ( +
+
+ + + {host.name} + + + {host.via} + +
+

{host.line}

+
+ )} +
+

+ same island, same glance — agents two networks away +

+
+
+
+ ); +} + +/* ── Demo 5: campfire states ───────────────────────────────────────── */ + +function CampfireDemo() { + const states: CampfireState[] = [ + "running", + "question", + "ready", + "idle", + "error" + ]; + const labels = [ + "fleet running", + "pending question", + "ready to review", + "all quiet", + "something failed" + ]; + return ( +
+ + {(state, i) => ( +
+
+ +
+

{labels[i()]}

+
+ )} +
+
+ ); +} + +export default function FeatureBreakdowns() { + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/components/nook/FeatureCollage.tsx b/src/components/nook/FeatureCollage.tsx new file mode 100644 index 0000000..004fc9c --- /dev/null +++ b/src/components/nook/FeatureCollage.tsx @@ -0,0 +1,363 @@ +import { For, type JSX } from "solid-js"; +import { + IslandPill, + AgentDotGrid, + STATUS, + CALM, + CRITICAL, + ACCENT, + ModuleCard +} from "./IslandMock"; + +/** + * Feature collage: five cells sold as the app's actual UI. The hero cell + * shows the real collapsed pill (campfire slot, camera housing, live data) + * dressed in the island's black surface; supporting cells reuse the same + * module chrome, status palette, and metric language. + */ + +function CollageCell(props: { + class?: string; + kicker: string; + title: string; + body: string; + children?: JSX.Element; +}) { + return ( +
+

+ {props.kicker} +

+

+ {props.title} +

+

{props.body}

+
{props.children}
+
+ ); +} + +/** + * Stage for app-UI mocks: the island's black ink ground. The real island + * is always dark (preferredColorScheme .dark), so the mocks read correctly + * in both site themes. + */ +function InkStage(props: { children: JSX.Element; class?: string }) { + return ( +
+ {props.children} +
+ ); +} + +/** The approvals mock rebuilt on the app's PermissionCard anatomy. */ +function ApprovalCardMock() { + return ( +
+
+ + + the-nook · bridge + + + approval + +
+

Run Edit

+

+ server/routes.ts +12 −4 +

+
+ + Allow + + + Deny + + + Deny with reason… + +
+
+ ); +} + +/** Fans panel mock: RPM gauge rows in the app's card chrome. */ +function FansPanelMock() { + const fans = [ + { rpm: 1270, frac: 0.26 }, + { rpm: 1219, frac: 0.25 } + ]; + return ( + +
+ + {(f) => ( +
+
+
+
+ + {f.rpm} + + RPM +
+ )} + +
+
+ + Auto + + + MAX + +
+
+ + ); +} + +/** Remote agents: two session rows like the expanded agents panel. */ +function RemoteMock() { + return ( +
+
+
+ + build-box + + ssh + +
+

You: fix flaky test

+
+
+
+ + gpu-rig + + tailscale + +
+

Ready

+
+
+ ); +} + +/** System gauges: temp die blocks + memory, in module cards. */ +function GaugesMock() { + return ( +
+ +
+ CPU + + 72° + +
+
+
+
+ + +
+ GPU + + 58° + +
+
+
+
+ +
+ ); +} +export default function FeatureCollage() { + return ( +
+
+

+ Why the Nook +

+

+ One glance. Everything your agents are doing. +

+ +
+ {/* Hero: real collapsed pill on the island surface */} +
+
+

+ The island +

+

+ Every agent, one glance +

+

+ A pill tucked into your notch — a campfire for your whole + fleet on the left, live data on the right. Blue running, + green ready, amber needs you. +

+
+
+ +
+ + +
+
+ + +
+
+ } + /> +
+

+ expand for the full panel ↓ +

+
+ + {/* Approvals */} + + +
+ +
+
+
+ + {/* Fans */} + + +
+ +
+
+
+ + {/* Remote agents */} + + +
+ +
+
+
+ + {/* Gauges */} + + +
+ +
+
+
+
+ +

+ Plus calendar, reminders, calls, and Now Playing — all in the same + island, all one-time purchase. +

+
+ + ); +} diff --git a/src/components/nook/IslandMock.tsx b/src/components/nook/IslandMock.tsx new file mode 100644 index 0000000..9d8ee7d --- /dev/null +++ b/src/components/nook/IslandMock.tsx @@ -0,0 +1,258 @@ +import { For, createMemo, onMount, onCleanup, type JSX } from "solid-js"; +import { Campfire, type CampfireState } from "./Campfire"; + +/** + * Faithful mock of the island's collapsed pill and expanded panel. + * Geometry follows the app's IslandSurfaceShape: the top edge spans the + * full width, the notch housing sits centered, and each corner is a + * concave scoop (quadratic curve with the control ON the top edge). + */ + +/* The app's island indicator palette (AgentsModuleView.statusColor). */ +export const STATUS = { + running: "#6EA7FF", + ready: "#6FB982", + attention: "#E7A762", + error: "#E5484D", + idle: "rgba(255,255,255,0.35)" +}; + +/** MetricPalette.calm — the desaturated sky of every metric bar. */ +export const CALM = "#6EA7FF"; +export const WARM = "#E7A762"; +export const CRITICAL = "rgba(229,72,77,0.9)"; + +/* NookPalette.accent — brand + live interactive state, never status. */ +export const ACCENT = "#4897b2"; + +/** The expanded panel's card chrome: white 5.5% fill, white 7% stroke. */ +export const CARD_FILL = "rgba(255,255,255,0.055)"; +export const CARD_STROKE = "rgba(255,255,255,0.07)"; + +/** + * One module-card surface shared by every mock panel — the app's + * cardChrome(): rounded 12, hairline stroke, white-on-black fill. + */ +export function ModuleCard(props: { children: JSX.Element; class?: string }) { + return ( +
+
{props.children}
+
+ ); +} + +/** + * Island surface, faithful to the app's IslandSurfaceShape: the top edge + * spans the full outer width while the body is inset, each corner joining + * them with a concave quad flare (control point ON the top edge), the + * bottom a convex 14px round. Rendered as an inline SVG with + * foreignObject-clip via CSS `clip-path: path(...)`. + */ +const FLARE = 22; // the app's topRadius — how far the wings taper inward +const BOTTOM_R = 14; + +function islandPath(w: number, h: number): string { + const tr = Math.min(FLARE, w / 2); + const br = Math.min(BOTTOM_R, tr); + // Mirrors IslandSurfaceShape.path exactly (same node order, same controls). + return ( + `M0,0 L${w},0 ` + + `Q${w - tr},0 ${w - tr},${tr} ` + + `L${w - tr},${h - br} ` + + `Q${w - tr},${h} ${w - tr - br},${h} ` + + `L${tr + br},${h} ` + + `Q${tr},${h} ${tr},${h - br} ` + + `L${tr},${tr} ` + + `Q${tr},0 0,0 Z` + ); +} + +function IslandSurface(props: { children: JSX.Element; class?: string }) { + let el: HTMLDivElement | undefined; + // The path must track the element's real size; a fixed viewBox would + // stretch the flare. Measure on mount + resize. + onMount(() => { + const node = el; + if (!node) return; + const apply = () => { + const r = node.getBoundingClientRect(); + node.style.clipPath = `path("${islandPath(r.width, r.height)}")`; + }; + apply(); + const observer = new ResizeObserver(apply); + observer.observe(node); + onCleanup(() => observer.disconnect()); + }); + return ( +
+ {props.children} +
+ ); +} + +/** ISLAND … */ +const NOTCH_NAME = "The Nook"; + +/* ── Collapsed pill ─────────────────────────────────────────────────── */ + +/** + * The collapsed pill: campfire slot left, camera housing center, live + * data slot right. Slot squares are native (no menu-bar stub — the wings + * hang from nothing, like a floating notch). + */ +export function IslandPill(props: { + campfire?: CampfireState; + right?: JSX.Element; +}) { + return ( + +
+
+ +
+ {/* camera housing: lens dot centered like the real notch */} +
+ +
+
+ {props.right} +
+
+
+ ); +} + +/** The agents dot-grid slot indicator: balanced rows of status squares. */ +export function AgentDotGrid(props: { states: (keyof typeof STATUS)[] }) { + // Balanced rows: 3+3+2 for 8, 2x2 for 4, 3 for 3 … app: 1,2,3,4=2x2, + // 5=[3,2], 6=[3,3], 7=[4,3], 8=[4,4], 9=[3,3,3] + const rows = (n: number): number[] => { + switch (n) { + case 1: + return [1]; + case 2: + return [2]; + case 3: + return [3]; + case 4: + return [2, 2]; + case 5: + return [3, 2]; + case 6: + return [3, 3]; + case 7: + return [4, 3]; + case 8: + return [4, 4]; + default: + return [4, 4]; + } + }; + const sizes = rows(props.states.length); + const groups = createMemo(() => { + let cursor = 0; + return sizes.map((count) => props.states.slice(cursor, (cursor += count))); + }); + return ( +
+ + {(group) => ( +
+ + {(s) => ( + + )} + +
+ )} +
+
+ ); +} + + +/* ── Expanded panel ─────────────────────────────────────────────────── */ + +/** + * The expanded island: header ("The Nook" + "N active" + controls), + * tab strip, divider, then one panel page. Width ~ the app's expanded + * footprint on a 14" display. + */ +export function IslandExpanded(props: { + activeCount: number; + tabs: { icon: string; label: string; active?: boolean }[]; + children: JSX.Element; +}) { + return ( +
+ + {/* header */} +
+ {NOTCH_NAME} +
+ + {props.activeCount} active + + + +
+ {/* tab strip: one icon button per panel; active wears accent 22% */} +
+ + {(tab) => ( + + {tab.icon} + + )} + +
+
+
{props.children}
+ +
+ ); +} diff --git a/src/routes/nook/index.tsx b/src/routes/nook/index.tsx index f883838..9ea515a 100644 --- a/src/routes/nook/index.tsx +++ b/src/routes/nook/index.tsx @@ -1,5 +1,7 @@ import { PageHead } from "~/components/PageHead"; import SubdomainHeader from "~/components/SubdomainHeader"; +import FeatureCollage from "~/components/nook/FeatureCollage"; +import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns"; import Button from "~/components/ui/Button"; import Input from "~/components/ui/Input"; import { createSignal, Show } from "solid-js"; @@ -8,29 +10,6 @@ import { useSite } from "~/context/SiteContext"; const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip"; -const FEATURES = [ - { - title: "Agent orchestration", - body: "Orchestrate your coding agents in one native panel, with sessions that keep working while you do." - }, - { - title: "A beautiful native UI", - body: "Animated panels and buttery SwiftUI transitions, tuned to feel right at home on your Mac." - }, - { - title: "Fan & thermal insight", - body: "Read and control system fans, watch temperatures, and keep performance predictable under load." - }, - { - title: "Private by design", - body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device." - }, - { - title: "One-time license", - body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals." - } -] as const; - export default function NookLanding() { const site = useSite(); const { isDark } = useDarkMode(); @@ -103,14 +82,14 @@ export default function NookLanding() { The Nook

- Your Mac, under your control + Your Mac and Agents, under control

- Agent orchestration, fan and thermal control — native macOS, - one-time license. + Agent orchestration, fan and thermal control in a beautiful native + UI.

- macOS 14+ · 14-day free trial · 3 devices + macOS 14+ · 14-day free trial · up to 3 devices

@@ -136,29 +115,25 @@ export default function NookLanding() {

$15{" "} - $10 one-time · 3 devices · - 14-day free trial + $10 one-time

- {/* ── Feature highlights ───────────────────────────────────────── */} -
-
-

- One panel for your agents and your Mac + {/* ── Feature collage ─────────────────────────────────────────── */} + + + {/* ── Feature breakdowns: the features in action ─────────────── */} +
+
+

+ In action +

+

+ Built for the way you actually run agents

-
- {FEATURES.map((feature) => ( -
-

- {feature.title} -

-

{feature.body}

-
- ))} -
+
@@ -187,7 +162,8 @@ export default function NookLanding() { class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80" href="/checkout" > - Buy a license → $10 $15 + Buy a license → $10{" "} + $15