From 9508aee8e1b720a0bc65c6d28e2460e010cf6a2c Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 13 Oct 2026 23:07:10 -0400 Subject: [PATCH] nook: show the full feature set on the landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page sold eight features from around 0.6 and summed up the rest in one line, while 0.6–0.10 shipped most of the island's modules. It also said twelve agents (Muse made thirteen) and marked phone approvals "coming soon" although the iPhone companion is in TestFlight beta. Adds live demos for terminal replies, the volume mixer, Capture with its Wings, and floating actions; a bento of module tiles behind a two-way feature marquee; and an iPhone/Watch beta section whose request form posts the same email-only report the app's Settings > Phone card sends. The approval flow, remote sessions and setup, timer, temperatures and Capture mocks are rebuilt from the app's own views, so the site shows what the island actually draws. Tests: report.test.ts pins the landing page's beta request against the /report schema (red when the schema demands a machine snapshot); nook and subdomain suites pass. NOOK-334 Co-Authored-By: Claude Opus 5.5 --- public/nook/brands/claude.svg | 4 + public/nook/brands/codex.svg | 4 + src/app.css | 111 +++ src/components/nook/FeatureBreakdowns.tsx | 538 +++++++------ src/components/nook/FeatureCollage.tsx | 214 ++--- src/components/nook/IslandExtras.tsx | 900 ++++++++++++++++++++++ src/components/nook/IslandMock.tsx | 36 +- src/components/nook/ModuleMocks.tsx | 405 ++++++++++ src/components/nook/MoreDemos.tsx | 617 +++++++++++++++ src/components/nook/PhoneBeta.tsx | 286 +++++++ src/components/nook/betaRequest.ts | 12 + src/components/nook/useDemoLoop.ts | 28 + src/routes/api/the-nook/report.test.ts | 17 + src/routes/nook/index.tsx | 16 +- 14 files changed, 2769 insertions(+), 419 deletions(-) create mode 100644 public/nook/brands/claude.svg create mode 100644 public/nook/brands/codex.svg create mode 100644 src/components/nook/IslandExtras.tsx create mode 100644 src/components/nook/ModuleMocks.tsx create mode 100644 src/components/nook/MoreDemos.tsx create mode 100644 src/components/nook/PhoneBeta.tsx create mode 100644 src/components/nook/betaRequest.ts create mode 100644 src/components/nook/useDemoLoop.ts diff --git a/public/nook/brands/claude.svg b/public/nook/brands/claude.svg new file mode 100644 index 0000000..62f97ef --- /dev/null +++ b/public/nook/brands/claude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/nook/brands/codex.svg b/public/nook/brands/codex.svg new file mode 100644 index 0000000..2e38a88 --- /dev/null +++ b/public/nook/brands/codex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app.css b/src/app.css index af27226..553ce1b 100644 --- a/src/app.css +++ b/src/app.css @@ -755,3 +755,114 @@ a.hover-underline-animation:hover::after { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ── Nook landing: newer demos ─────────────────────────────────── */ +.nook-caret { + animation: nook-pulse 0.8s steps(2, jump-none) infinite; +} + +/* Live signal inside a mixer fader */ +.nook-meter { + transform-origin: left center; + animation: nook-meter 0.6s ease-in-out infinite alternate; +} +@keyframes nook-meter { + 0% { transform: scaleX(0.35); } + 40% { transform: scaleX(0.8); } + 100% { transform: scaleX(0.55); } +} + +/* Waveform slot bars */ +.nook-wave { + height: 100%; + transform-origin: center; + animation: nook-wave 0.8s ease-in-out infinite alternate; +} +@keyframes nook-wave { + 0% { transform: scaleY(0.25); } + 100% { transform: scaleY(1); } +} + +/* Now Playing progress and artwork sheen */ +.nook-progress { + animation: nook-progress 14s linear infinite; +} +@keyframes nook-progress { + from { width: 8%; } + to { width: 100%; } +} +.nook-art { + animation: nook-art 9s linear infinite; +} +@keyframes nook-art { + to { filter: hue-rotate(360deg); } +} + +/* A ringing timer's tape breathes between 35 % and full */ +.nook-tape-ring { + animation: nook-tape-ring 0.9s ease-in-out infinite alternate; +} +@keyframes nook-tape-ring { + from { opacity: 0.35; } + to { opacity: 1; } +} + +/* Keep Awake coffee steam */ +.nook-steam path { + animation: nook-steam 1.8s ease-in-out infinite; +} +@keyframes nook-steam { + 0% { transform: translateY(2px); opacity: 0; } + 40% { opacity: 1; } + 100% { transform: translateY(-4px); opacity: 0; } +} + +/* Two-way feature marquee; edges fade, hover pauses */ +.nook-marquee { + overflow: hidden; + mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); +} +.nook-marquee-track { + display: flex; + gap: 0.625rem; + width: max-content; + padding-right: 0.625rem; + animation: nook-marquee 60s linear infinite; +} +.nook-marquee-track.reverse { + animation-direction: reverse; +} +.nook-marquee:hover .nook-marquee-track { + animation-play-state: paused; +} +@keyframes nook-marquee { + to { transform: translateX(-50%); } +} + +@media (prefers-reduced-motion: reduce) { + .nook-meter, + .nook-wave, + .nook-progress, + .nook-art, + .nook-tape-ring, + .nook-steam path, + .nook-caret { + animation: none; + } + + .nook-marquee { + mask-image: none; + } + + .nook-marquee-track { + animation: none; + flex-wrap: wrap; + justify-content: center; + width: auto; + padding: 0 1rem; + } + + .nook-marquee-track > [aria-hidden="true"] { + display: none; + } +} diff --git a/src/components/nook/FeatureBreakdowns.tsx b/src/components/nook/FeatureBreakdowns.tsx index af0d9eb..752c341 100644 --- a/src/components/nook/FeatureBreakdowns.tsx +++ b/src/components/nook/FeatureBreakdowns.tsx @@ -1,5 +1,6 @@ import { For, + Show, createEffect, createSignal, onCleanup, @@ -9,20 +10,35 @@ import { import { IslandPill, AgentDotGrid, - STATUS, CALM, WARM, ACCENT, CRITICAL, - ModuleCard + ModuleCard, + FakeCursor } from "./IslandMock"; import { Campfire, type CampfireState } from "./Campfire"; +import { + ApprovalCardMock, + SessionRowMock, + Glyph, + CLAUDE, + CODEX +} from "./ModuleMocks"; +import { useDemoLoop } from "./useDemoLoop"; +import { + TerminalReplyDemo, + MixerDemo, + CaptureDemo, + FloatingActionsDemo +} from "./MoreDemos"; /** * Detailed feature breakdowns: alternating copy + live-demo sections. * Each demo stages the real interaction loop: attention flares, approval * resolves, fans throttle, the charge ceiling holds, meetings surface, - * the camera wakes, the hook installs. + * the camera wakes, a remote host goes live. Newer demos live in + * MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx. */ /* Scroll-triggered reveal: adds `in` once the element crosses 25%. */ @@ -165,100 +181,88 @@ function FleetDemo() { ); } -/* ── Demo 2: permission card resolves, phone push follows ─────────── */ +/* ── Demo 2: an approval surfaces the island; Allow resumes the run ── */ 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); - }); - }); - + const [phase, setPhase] = createSignal<"calm" | "ask" | "press" | "resumed">("calm"); + useDemoLoop(10400, [ + [0, () => setPhase("calm")], + [1400, () => setPhase("ask")], + [4600, () => setPhase("press")], + [4950, () => setPhase("resumed")], + [8800, () => setPhase("calm")] + ]); + const asking = () => phase() === "ask" || phase() === "press"; + const open = () => phase() !== "calm"; return ( -
- {/* island card */} -
-
- + - - the-nook · bridge - - - {allowed() ? "allowed" : "approval"} - -
-

Run Edit

-

- server/routes.ts +12 −4 -

-
- - Allow - - - Deny - -
-
- {/* phone */} + } + /> + {/* the island opens: approval pinned above the session rows */}
-
-
-

- The Nook -

+
-

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

-

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

+
+
+ +
+
+
+
+ +
+
+ +
); } @@ -406,37 +410,6 @@ function FansDemo() { ); } -/** 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 ( - - ); -} - /* ── Demo 4: the charge ceiling holds, top-up overrules ───── */ function BatteryDemo() { @@ -834,142 +807,109 @@ function CameraDemo() { ); } -/* ── Demo 7: one click copies, sets up, probes — pong ─────── */ +/* ── Demo 7: Settings ▸ Remote hosts — one click to Live ──────────── */ -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" } +/** RemoteMirror's progress steps, in order. */ +const SETUP_STEPS = [ + "connecting", + "pushing the hook binary", + "configuring agents", + "checking the host can reach this Mac" ]; -function SshInstallDemo() { - const [step, setStep] = createSignal(0); - onMount(() => { - const timers: ReturnType[] = []; - 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); +function RemoteHostDemo() { + /* -1 not set up · 0…3 setting up · 4 live */ + const [step, setStep] = createSignal(-1); + const [pressed, setPressed] = createSignal(false); + useDemoLoop(11000, [ + [0, () => (setStep(-1), setPressed(false))], + [1400, () => setPressed(true)], + [1700, () => (setPressed(false), setStep(0))], + [2700, () => setStep(1)], + [3700, () => setStep(2)], + [4700, () => setStep(3)], + [5800, () => setStep(4)] + ]); + const live = () => step() === 4; + const working = () => step() >= 0 && step() < 4; + const chip = () => + live() + ? { color: "rgb(48,209,88)", label: "Live" } + : working() + ? { color: "rgba(255,255,255,0.55)", label: "Setting up" } + : { color: "rgba(255,255,255,0.35)", label: "Not set up" }; + return ( - -
-
-
-

gpu-rig

-

- mike@192.168.64.3 · linux -

-
- = 4 ? 0 : 1, - transform: step() === 1 ? "scale(0.94)" : "scale(1)" - }} - > - {step() === 0 ? "Install hook" : "installing…"} - -
-
-
-
= 4 ? 0 : 1 }} - > - {step() === 0 && ( -

- one click: copy → setup → probe -

- )} - - {(l) => ( -
= l.at ? 1 : 0, - transform: - step() >= l.at ? "translateY(0)" : "translateY(4px)" - }} - > - l.at - ? "rgba(150,220,150,0.9)" - : "rgba(255,255,255,0.45)" - }} - > - {step() > l.at ? "✓" : "…"} - - - {l.text} - -
- )} -
-
-
= 4 ? 1 : 0, - transform: step() >= 4 ? "translateY(0)" : "translateY(6px)" - }} - > -
-
- - - gpu-rig - - - remote - -
-

- You: benchmark fp16 -

+
+ {/* the host's card in Settings ▸ Agents ▸ Remote hosts */} + +
+
+
+

gpu-rig

+

tailnet peer

-

- agents join the same dot grid -

+ + + + + + {chip().label} + + + Set up + + } + > + + + Remove + +
+

+ {working() ? SETUP_STEPS[step()] : " "} +

+
+ + {/* its agents arrive as ordinary rows, the machine named in the chip */} +
+
- - + +
); } @@ -1019,8 +959,8 @@ export default function FeatureBreakdowns() { title="Your whole fleet, in one pixel strip" body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back." bullets={[ - "12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more", - "The campfire dances while work runs, simmers when all is calm", + "13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more", + "Custom agents: your own coding agent can wire itself in from the published contract", "Slot rankings — you choose which data earns the notch", "You don't need a notch — external displays get the same island, floating top-center" ]} @@ -1031,10 +971,11 @@ export default function FeatureBreakdowns() { + + + + + + + + + + + + @@ -1067,11 +1049,38 @@ export default function FeatureBreakdowns() { "Thermal protection pauses charging when the pack runs hot", "Energy flow figure: adapter → battery → system, live watts" ]} - reversed > + + + + + + + + @@ -1096,35 +1106,23 @@ export default function FeatureBreakdowns() { "A device picker for every connected camera", "Opt-in module — in no default panel" ]} - reversed > - - - - - +
); diff --git a/src/components/nook/FeatureCollage.tsx b/src/components/nook/FeatureCollage.tsx index 759c167..a2076d4 100644 --- a/src/components/nook/FeatureCollage.tsx +++ b/src/components/nook/FeatureCollage.tsx @@ -1,13 +1,12 @@ -import { For, type JSX } from "solid-js"; +import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"; +import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock"; import { - IslandPill, - AgentDotGrid, - STATUS, - CALM, - CRITICAL, - ACCENT, - ModuleCard -} from "./IslandMock"; + ApprovalCardMock, + SessionRowMock, + DieLine, + CLAUDE, + CODEX +} from "./ModuleMocks"; /** * Feature collage: five cells sold as the app's actual UI. The hero cell @@ -16,7 +15,7 @@ import { * module chrome, status palette, and metric language. */ -function CollageCell(props: { +export function CollageCell(props: { class?: string; kicker: string; title: string; @@ -44,7 +43,7 @@ function CollageCell(props: { * is always dark (preferredColorScheme .dark), so the mocks read correctly * in both site themes. */ -function InkStage(props: { children: JSX.Element; class?: string }) { +export function InkStage(props: { children: JSX.Element; class?: string }) { 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 = [ @@ -150,99 +105,62 @@ function FansPanelMock() { ); } -/** Remote agents: two session rows like the expanded agents panel. */ +/** + * Remote agents as the panel draws them: ordinary session rows whose agent + * chip names the machine ("Claude Code · build-box"). + */ function RemoteMock() { return (
-
-
- - build-box - - ssh - -
-

You: fix flaky test

-
-
-
- - gpu-rig - - ssh - -
-

Ready

-
+ +
); } -/** System gauges: temp die blocks + memory, in module cards. */ +/* Deterministic usage wander for the traces. */ +const wander = (n: number, k: number) => + 0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k); + +/** + * The Temperatures module: one line per die — label, its 60 s usage trace, + * its temperature. The GPU climbs past the 70 ° warn line and tints amber. + */ function GaugesMock() { + const [n, setN] = createSignal(0); + onMount(() => { + const interval = setInterval(() => setN((v) => v + 1), 700); + onCleanup(() => clearInterval(interval)); + }); + const trace = (k: number) => + Array.from({ length: 30 }, (_, i) => Math.max(0.04, Math.min(0.97, wander(n() + i, k)))); + const gpuCelsius = () => 62 + 12 * (0.5 + 0.5 * Math.sin(n() * 0.18)); return ( -
- -
- CPU - - 72° - -
-
-
-
- - -
- GPU - - 58° - -
-
-
-
- -
+ +
+ + +
+
); } + export default function FeatureCollage() { return (
- +
@@ -340,7 +263,7 @@ export default function FeatureCollage() { body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island." > -
+
@@ -350,7 +273,7 @@ export default function FeatureCollage() {
@@ -361,8 +284,13 @@ export default function FeatureCollage() {

- Plus calendar, reminders, calls, and Now Playing — all in the same - island, all one-time purchase. + Plus a mixer, capture, timers, weather, and a dozen more modules —{" "} + + see them all ↓ +

diff --git a/src/components/nook/IslandExtras.tsx b/src/components/nook/IslandExtras.tsx new file mode 100644 index 0000000..cfde9d5 --- /dev/null +++ b/src/components/nook/IslandExtras.tsx @@ -0,0 +1,900 @@ +import { + For, + Index, + Show, + createSignal, + onCleanup, + onMount, + type JSX +} from "solid-js"; +import { + ModuleCard, + STATUS, + ACCENT, + CALM, + WARM, + CRITICAL, + CARD_FILL, + CARD_STROKE +} from "./IslandMock"; +import { CollageCell, InkStage } from "./FeatureCollage"; +import { useDemoLoop } from "./useDemoLoop"; +import { Glyph, TimerTapeMock, TIMER_ORANGE, type GlyphName } from "./ModuleMocks"; + +/** + * "Everything else": a bento of small live tiles for the modules that do + * not get a full breakdown, headed by a two-way marquee naming the rest. + */ + +/** Steady tick for tiles that animate continuously rather than in steps. */ +function useTick(everyMs: number) { + const [tick, setTick] = createSignal(0); + onMount(() => { + const interval = setInterval(() => setTick((t) => t + 1), everyMs); + onCleanup(() => clearInterval(interval)); + }); + return tick; +} + +/** A compact black island strip for tiles that show a slot reading. */ +function MiniPill(props: { children: JSX.Element; class?: string }) { + return ( +
+ {props.children} +
+ ); +} + +/* ── Now Playing + the waveform slot ─────────────────────────────── */ + +function NowPlayingMock() { + return ( +
+ +
+
+
+

+ Night Drive +

+

+ Synthwave Radio · Browser +

+
+
+
+
+
+
+ ⏮ + ⏸ + ⏭ +
+ +
+ + + + {(i) => ( + + )} + + + + waveform slot +
+
+ ); +} + +/* ── Timers: the module's face — readout over the tick tape ──────── */ + +/** TimerService.format: m:ss, rounding up like the app. */ +function clock(seconds: number) { + const s = Math.ceil(seconds); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} + +/** The face's capsule control: 26 px, mono 11, white 8 %. */ +function TimerPill(props: { glyph?: GlyphName; label?: string }) { + return ( + + + + + {props.label} + + ); +} + +/* + * A 3:00 lead timer drains (in fast-forward), rings with its tape pulsing, + * and a 25:00 Focus timer keeps running in the stacked row beneath. + */ +const LEAD_SECONDS = 180; +const LEAD_MS = 9000; +const TIMER_PERIOD = 14000; + +function TimersMock() { + const tick = useTick(50); + const t = () => (tick() * 50) % TIMER_PERIOD; + const leadLeft = () => Math.max(0, 1 - t() / LEAD_MS); + const ringing = () => t() >= LEAD_MS; + const focusLeft = () => 0.64 - (t() / TIMER_PERIOD) * 0.06; + return ( + +
+
+ + {clock(leadLeft() * LEAD_SECONDS)} + + } + > + + + Time's up + + +
+
+ +
+

+ {ringing() ? "3:00 timer" : "Remaining of 3:00"} +

+
+ + + + + } + > + + + + +
+ {/* a further timer: readout, slim tape, icon controls */} +
+ {clock(focusLeft() * 1500)} +
+ +
+ + +
+
+ {(m) => } +
+
+
+ ); +} + +/* ── Weather: saved places rotate through the slot ───────────────── */ + +type Sky = "sun" | "cloud" | "rain"; + +function SkyGlyph(props: { sky: Sky; size?: number }) { + const s = () => props.size ?? 14; + return ( + + + + + + + + + + + + + + + ); +} + +const PLACES: { place: string; temp: number; sky: Sky; hourly: [Sky, number][] }[] = [ + { + place: "Portland", + temp: 13, + sky: "rain", + hourly: [["rain", 13], ["rain", 13], ["cloud", 14], ["cloud", 15], ["sun", 16], ["sun", 15]] + }, + { + place: "Lisbon", + temp: 24, + sky: "sun", + hourly: [["sun", 24], ["sun", 25], ["sun", 26], ["cloud", 25], ["cloud", 23], ["sun", 21]] + } +]; + +function WeatherMock() { + const [i, setI] = createSignal(0); + useDemoLoop(7000, [ + [0, () => setI(0)], + [3500, () => setI(1)] + ]); + const p = () => PLACES[i()]; + return ( +
+ + + + {p().temp}° + + + +
+
+ {p().place} + + {i() + 1} of {PLACES.length} + +
+
+ + {(h, hour) => ( +
+ + {hour === 0 ? "Now" : `${(13 + hour) % 24}`} + + + + {h()[1]}° + +
+ )} +
+
+
+
+
+ ); +} + +/* ── Interceptions: volume & brightness peek below the island ────── */ + +const PEEKS = [ + { kind: "volume", label: "Speakers", from: 0.35, to: 0.7 }, + { kind: "bright", label: "Built-in display", from: 0.6, to: 1 }, + { kind: "xdr", label: "Extra brightness", from: 1, to: 1.3 }, + { kind: "bright", label: "External display", from: 0.3, to: 0.65 } +] as const; + +function InterceptionMock() { + const [peek, setPeek] = createSignal(0); + const [level, setLevel] = createSignal(PEEKS[0].from); + const [shown, setShown] = createSignal(false); + const steps: [number, () => void][] = []; + PEEKS.forEach((pk, n) => { + const base = n * 2600; + steps.push([base, () => (setPeek(n), setLevel(pk.from), setShown(true))]); + for (let k = 1; k <= 4; k++) { + steps.push([ + base + 250 + k * 280, + () => setLevel(pk.from + ((pk.to - pk.from) * k) / 4) + ]); + } + steps.push([base + 2300, () => setShown(false)]); + }); + useDemoLoop(PEEKS.length * 2600, steps); + + const pk = () => PEEKS[peek()]; + const xdr = () => level() > 1; + return ( +
+
+
+ + + + + + } + > + + + + +
+

{pk().label}

+
+
+ + +
+ +
+
+ + {Math.round(level() * 100)}% + +
+
+
+ ); +} + +/* ── Network: speed test on demand + identity rows ───────────────── */ + +function NetworkMock() { + const [down, setDown] = createSignal(0); + const [up, setUp] = createSignal(0); + const [running, setRunning] = createSignal(false); + const steps: [number, () => void][] = [ + [0, () => (setDown(0), setUp(0), setRunning(false))], + [1200, () => setRunning(true)] + ]; + for (let k = 1; k <= 12; k++) { + steps.push([1200 + k * 120, () => setDown(Math.round((842 * k) / 12))]); + steps.push([2800 + k * 90, () => setUp(Math.round((96 * k) / 12))]); + } + steps.push([4000, () => setRunning(false)]); + useDemoLoop(8400, steps); + + return ( + +
+
+
+

DOWN

+

+ {down()} + Mbps +

+
+
+

UP

+

+ {up()} + Mbps +

+
+ + {running() ? "testing…" : "Run"} + +
+
+ + {([k, v]) => ( +
+ {k} + {v} + ⧉ +
+ )} +
+ + VPN · default route + +
+ + ); +} + +/* ── Disk: throughput trace, capacity, SMART ─────────────────────── */ + +function DiskMock() { + const tick = useTick(450); + const sample = (n: number, k: number) => { + const x = Math.sin(n * 0.9 + k * 2.1) * 0.5 + Math.sin(n * 0.37 + k) * 0.5; + return Math.max(0.05, Math.min(1, 0.45 + x * 0.45)); + }; + const path = (k: number) => { + const pts = Array.from({ length: 24 }, (_, i) => { + const v = sample(tick() + i, k) * (k === 0 ? 1 : 0.45); + return `${(i / 23) * 200},${40 - v * 36}`; + }); + return `M${pts.join(" L")}`; + }; + const read = () => Math.round(sample(tick() + 23, 0) * 900); + const write = () => Math.round(sample(tick() + 23, 1) * 0.45 * 900); + return ( + +
+
+ R {read()} MB/s + W {write()} MB/s +
+ + + + +
+ Macintosh HD + 612 GB free of 1 TB +
+
+
+
+ + SMART · Verified + +
+ + ); +} + +/* ── Keep Awake: four ways to hold off sleep ─────────────────────── */ + +const AWAKE_MODES = [ + { label: "1 hour", note: "awake for 59:12 more" }, + { label: "On power", note: "awake while plugged in" }, + { label: "Lid closed", note: "keeps running with the lid shut" }, + { label: "Agents", note: "awake only while agents work" } +]; + +function KeepAwakeMock() { + const [mode, setMode] = createSignal(0); + useDemoLoop( + 9600, + AWAKE_MODES.map((_, n) => [n * 2400, () => setMode(n)] as [number, () => void]) + ); + return ( + +
+ + + + + + + + +
+ + {(m, n) => ( + + {m.label} + + )} + +
+

{AWAKE_MODES[mode()].note}

+
+
+ ); +} + +/* ── Approval modes: Auto · Yolo · Verify ────────────────────────── */ + +const MODES = [ + { name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" }, + { name: "Yolo", note: "gated tools pass straight through", card: "passed", tint: "rgba(111,185,130,0.12)" }, + { name: "Verify", note: "every gated tool waits for you", card: "held", tint: "rgba(229,72,77,0.12)" } +]; + +function ApprovalModesMock() { + const [mode, setMode] = createSignal(0); + useDemoLoop(8400, [ + [0, () => setMode(0)], + [2800, () => setMode(1)], + [5600, () => setMode(2)] + ]); + const m = () => MODES[mode()]; + return ( +
+
+ + + {(x, n) => ( + + {x.name} + + )} + +
+
+
+ Bash + + rm -rf build/ + + + {m().card} + +
+
+

{m().note}

+
+ ); +} + +/* ── Lock Screen: counts without unlocking ───────────────────────── */ + +function LockScreenMock() { + const [counts, setCounts] = createSignal({ working: 3, waiting: 0 }); + useDemoLoop(7200, [ + [0, () => setCounts({ working: 3, waiting: 0 })], + [2400, () => setCounts({ working: 2, waiting: 1 })], + [4800, () => setCounts({ working: 1, waiting: 0 })] + ]); + return ( +
+
+ + + {counts().working} + + + + + {counts().waiting} + +
+
+

+ 9:41 +

+ +
+
+ ); +} + +/* ── Layout: the mosaic reflows ──────────────────────────────────── */ + +type Box = [l: number, t: number, w: number, h: number]; +const LAYOUTS: Box[][] = [ + [[0, 0, 66, 58], [68, 0, 32, 58], [0, 62, 32, 38], [34, 62, 32, 38], [68, 62, 32, 38]], + [[0, 0, 32, 100], [34, 0, 66, 40], [34, 44, 32, 56], [68, 44, 32, 26], [68, 74, 32, 26]], + [[0, 0, 49, 48], [51, 0, 49, 48], [0, 52, 100, 22], [0, 78, 49, 22], [51, 78, 49, 22]] +]; +const TILE_COLORS = [CALM, STATUS.ready, WARM, "#fa5c7c", "rgba(255,255,255,0.5)"]; + +function LayoutMock() { + const [n, setN] = createSignal(0); + useDemoLoop(7500, [ + [0, () => setN(0)], + [2500, () => setN(1)], + [5000, () => setN(2)] + ]); + return ( +
+
+ + {(color, i) => { + const b = () => LAYOUTS[n()][i()]; + return ( +
+ ); + }} + +
+
+ ); +} + +/* ── Marquee ─────────────────────────────────────────────────────── */ + +const CHIPS_A = [ + "13 agents", + "Custom agents", + "Six chiptunes", + "Focus-aware slots", + "Per-app output routing", + "Low Power Mode toggle", + "Reminders", + "Battery health", + "Energy flow in watts", + "Thermal charge protection", + "Top-up charging", + "Memory by process" +]; +const CHIPS_B = [ + "Jump to the terminal", + "tmux · iTerm2 · kitty", + "Markdown replies", + "Tailscale hosts", + "One-click SSH setup", + "iPhone widgets", + "Watch-face widgets", + "Screenshots to clipboard", + "System audio + mic", + "Hourly forecast", + "External display brightness", + "Lid-closed keep-awake" +]; + +function MarqueeRow(props: { chips: string[]; reverse?: boolean }) { + return ( +
+
+ + {(chip, i) => ( + = props.chips.length ? "true" : undefined} + class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-3.5 py-1.5 text-xs font-semibold whitespace-nowrap" + > + {chip} + + )} + +
+
+ ); +} + +export default function IslandExtras() { + return ( +
+
+

+ And everything else +

+

+ One island instead of a menu bar full of utilities +

+

+ Every module below lives in the same notch, arranged however you + like, under the same one-time license. +

+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/src/components/nook/IslandMock.tsx b/src/components/nook/IslandMock.tsx index 2fec0f7..53ebbbb 100644 --- a/src/components/nook/IslandMock.tsx +++ b/src/components/nook/IslandMock.tsx @@ -85,9 +85,10 @@ function IslandSurface(props: { children: JSX.Element; class?: string }) { onMount(() => { const node = el; if (!node) return; + // Layout size, not getBoundingClientRect: a scaled-down mock (the + // Capture demo's island row) would otherwise clip itself to a corner. const apply = () => { - const r = node.getBoundingClientRect(); - node.style.clipPath = `path("${islandPath(r.width, r.height)}")`; + node.style.clipPath = `path("${islandPath(node.offsetWidth, node.offsetHeight)}")`; }; apply(); const observer = new ResizeObserver(apply); @@ -261,3 +262,34 @@ export function IslandExpanded(props: {
); } + +/** Fake cursor dot: rests on a target, presses, exits. */ +export 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 ( + + ); +} diff --git a/src/components/nook/ModuleMocks.tsx b/src/components/nook/ModuleMocks.tsx new file mode 100644 index 0000000..92e0e18 --- /dev/null +++ b/src/components/nook/ModuleMocks.tsx @@ -0,0 +1,405 @@ +import { For, Show, type JSX } from "solid-js"; +import { STATUS, ACCENT, CALM, WARM, CARD_FILL, CARD_STROKE } from "./IslandMock"; + +/** + * Module-level mocks rebuilt from the app's SwiftUI views, so the site shows + * the same anatomy the island does: the session row (SessionRowView), the + * approval card (AgentsActionCards), the timer tape (TimerTape), a + * temperature line (TemperaturesPanelView), and the Wings (WingPanels). + */ + +/* ── Glyphs: the SF Symbols the mocked views use ─────────────────── */ + +/* Factories, not shared nodes: a Solid JSX node can mount in only one place. */ +const GLYPHS: Record JSX.Element> = { + speaker: () => ( + <> + + + + ), + speakerOff: () => ( + <> + + + + ), + mic: () => ( + <> + + + + ), + micOff: () => ( + <> + + + + ), + pause: () => ( + <> + + + + ), + play: () => , + stop: () => , + viewfinder: () => ( + + ), + record: () => ( + <> + + + + ), + dashed: () => ( + + ), + display: () => ( + + ), + window: () => ( + <> + + + + ), + chevron: () => , + film: () => ( + + ), + xmark: () => , + bell: () => ( + + ), + restart: () => ( + + ), + plus: () => , + refresh: () => ( + + ) +}; + +export type GlyphName = keyof typeof GLYPHS; + +export function Glyph(props: { name: GlyphName; size?: number; class?: string; style?: JSX.CSSProperties }) { + return ( + + ); +} + +/* ── Agents ──────────────────────────────────────────────────────── */ + +export interface AgentLook { + name: string; + /** brandColorHex from the app's AgentRegistry row. */ + color: string; + /** The brand mark the app bundles (AgentBrands/*.svg). */ + icon: string; +} + +export const CLAUDE: AgentLook = { + name: "Claude Code", + color: "#D97742", + icon: "/nook/brands/claude.svg" +}; +export const CODEX: AgentLook = { + name: "Codex", + color: "#4AA3DF", + icon: "/nook/brands/codex.svg" +}; + +/** A row badge: brand-tinted for the agent chip, neutral otherwise. */ +function Badge(props: { text: string; tint?: string }) { + return ( + + {props.text} + + ); +} + +/** + * SessionRowView: "project · branch", the latest prompt, a status-colored + * state line; on the right the agent chip (named "Agent · host" for a + * remote session), the terminal origin, the status dot and age. + */ +export function SessionRowMock(props: { + title: string; + prompt: string; + state: string; + status: keyof typeof STATUS; + agent: AgentLook; + /** Remote sessions name their machine in the agent chip. */ + host?: string; + origin?: string; + age: string; +}) { + return ( +
+ {/* The app's layout (badges in a right-hand third) wherever the row + is wide enough; narrow site cells stack the badges beneath. */} +
+
+

{props.title}

+

User: {props.prompt}

+

+ {props.state} +

+
+
+
+ +
+
+ + + + + + + {props.age} + + +
+
+
+
+ ); +} + +/** SwiftUI's dark-mode .yellow, which the approval card tints with. */ +const YELLOW = "255,214,10"; + +/** + * ApprovalCard: brand mark, session title, "approval" capsule; "Run ", + * the input in mono; Allow (accent, the island's tint), Allow for session, + * Deny, and Deny with reason…. + */ +export function ApprovalCardMock(props: { + agent: AgentLook; + title: string; + tool: string; + input: string; + pressed?: "allow" | "session" | "deny"; +}) { + const button = (key: "allow" | "session" | "deny", label: string, prominent = false) => ( + + {label} + + ); + return ( +
+
+ + {props.title} + + approval + +
+

Run {props.tool}

+

{props.input}

+
+ {button("allow", "Allow", true)} + {button("session", "Allow for session")} + {button("deny", "Deny")} + Deny with reason… +
+
+ ); +} + +/* ── Timer tape ──────────────────────────────────────────────────── */ + +/** TimerPalette.accent. */ +export const TIMER_ORANGE = "#FA9E4D"; + +/** + * TimerTapeDrawing.ticks, ported: fixed-pitch 1.5 px ticks resting at 40 % + * of the tape height, swelling to a 72 % crest (raised cosine) that leads + * into the remaining side and trails a shorter shoulder behind. + */ +function tapeTicks(remaining: number, w: number, h: number) { + const span = w - 2; + const count = Math.floor(span / 6) + 1; + const pitch = span / Math.max(1, count - 1); + const boundary = 1 + span * remaining; + const baseline = h * 0.4; + const peak = h * 0.72; + const lead = Math.min(84, w * 0.4); + const trail = lead * 0.5; + const swell = (d: number) => (d < 1 ? (1 + Math.cos(Math.PI * d)) / 2 : 0); + return Array.from({ length: count }, (_, i) => { + const cx = 1 + i * pitch; + const offset = boundary - cx; + const rise = remaining > 0 ? swell(Math.abs(offset) / (offset >= 0 ? lead : trail)) : 0; + const height = baseline + (peak - baseline) * rise; + const fill = remaining > 0 ? Math.min(1, Math.max(0, offset / pitch + 1)) : 0; + return { x: cx - 0.75, y: (h - height) / 2, height, fill }; + }); +} + +export function TimerTapeMock(props: { + remaining: number; + width: number; + height: number; + color?: string; + opacity?: number; +}) { + return ( + + ); +} + +/* ── Temperatures ────────────────────────────────────────────────── */ + +/** MetricPalette.usage: calm below 70 %, warm to 90 %, critical above. */ +function usageColor(fraction: number) { + if (fraction >= 0.9) return "rgba(229,72,77,0.9)"; + if (fraction >= 0.7) return WARM; + return CALM; +} + +/** Tier color at the app's default thresholds (warn 70 °C, alert 85 °C). */ +function tierColor(celsius: number) { + if (celsius >= 85) return "rgba(229,72,77,0.9)"; + if (celsius >= 70) return WARM; + return "white"; +} + +/** + * One die line: label, its 60 s usage trace (filled at 20 %, 1.5 px line, + * over a hairline floor), and the temperature in mono, tier-tinted. + */ +export function DieLine(props: { label: string; usage: number[]; celsius: number }) { + const path = () => { + const n = props.usage.length; + return props.usage + .map((u, i) => `${i === 0 ? "M" : "L"}${((i / (n - 1)) * 100).toFixed(2)},${(26 - u * 26).toFixed(2)}`) + .join(" "); + }; + const color = () => usageColor(props.usage[props.usage.length - 1] ?? 0); + return ( +
+ {props.label} + + + {Math.round(props.celsius)}° + +
+ ); +} + +/* ── Wings ───────────────────────────────────────────────────────── */ + +export interface WingControlMock { + glyph: GlyphName; + on?: boolean; + pressed?: boolean; +} + +/** + * One edge's capsule: black at 94 %, a 1 px white-18 % stroke, and each + * control a circle as tall as the Compact island, icon at 42 %. + */ +export function WingCapsule(props: { controls: WingControlMock[]; height: number }) { + return ( +
+ + {(c) => ( + + + + )} + +
+ ); +} diff --git a/src/components/nook/MoreDemos.tsx b/src/components/nook/MoreDemos.tsx new file mode 100644 index 0000000..b2bd870 --- /dev/null +++ b/src/components/nook/MoreDemos.tsx @@ -0,0 +1,617 @@ +import { For, Show, createSignal, type JSX } from "solid-js"; +import { + ModuleCard, + FakeCursor, + IslandPill, + AgentDotGrid, + STATUS, + ACCENT, + CALM, + CRITICAL, + CARD_STROKE +} from "./IslandMock"; +import { useDemoLoop } from "./useDemoLoop"; +import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks"; + +/** + * Live demos for the breakdowns added with the 0.6–0.10 feature set: + * terminal replies, the volume mixer, Capture (module + Wings), and + * floating actions. Same ink chrome and status palette as the originals. + */ + +/* ── Reply lands in the agent's own terminal ──────────────────────── */ + +const REPLY = "yes — and run them"; + +export function TerminalReplyDemo() { + const [typed, setTyped] = createSignal(0); + const [phase, setPhase] = createSignal< + "asking" | "typing" | "sent" | "working" | "jump" + >("asking"); + + useDemoLoop(10000, [ + [0, () => (setTyped(0), setPhase("asking"))], + [900, () => setPhase("typing")], + ...REPLY.split("").map( + (_, i) => [1000 + i * 70, () => setTyped(i + 1)] as [number, () => void] + ), + [2900, () => setPhase("sent")], + [3300, () => setPhase("working")], + [6600, () => setPhase("jump")] + ]); + + const delivered = () => phase() === "working" || phase() === "jump"; + const cursorPhase = () => + phase() === "sent" || phase() === "jump" + ? ("press" as const) + : phase() === "typing" || phase() === "asking" + ? ("idle" as const) + : ("live" as const); + const cursorAt = () => + phase() === "jump" ? { x: 88, y: 14 } : { x: 88, y: 80 }; + + return ( +
+ +
+
+ + the-nook + main + + Jump ↗ + +
+

+ {delivered() + ? "Running the test suite…" + : "Done. Want me to update the tests too?"} +

+
+ + {delivered() ? "" : REPLY.slice(0, typed())} + + ▍ + + + Reply… + + + + Send + +
+
+ +
+ + {/* the terminal the session actually runs in */} +
+
+ + + + + tmux · the-nook:2 + + + focused + +
+
+

⏺ Updated 3 files

+

+ >{" "} + + {REPLY} + +

+

+ ⏺ Bash(swift test) +

+
+
+
+ ); +} + +/* ── Volume mixer: every app its own level and output ─────────────── */ + +interface MixerApp { + name: string; + color: string; + glyph: string; +} + +const MIXER_APPS: MixerApp[] = [ + { name: "Music", color: "#fa5c7c", glyph: "♫" }, + { name: "Browser", color: "#6EA7FF", glyph: "◎" }, + { name: "Meeting", color: "#6FB982", glyph: "●" }, + { name: "podcast-cli", color: "#E7A762", glyph: ">_" } +]; + +export function MixerDemo() { + const [step, setStep] = createSignal(0); + useDemoLoop(9600, [ + [0, () => setStep(0)], + [1800, () => setStep(1)], + [3800, () => setStep(2)], + [5800, () => setStep(3)] + ]); + + const level = (i: number) => { + if (i === 0) return step() >= 3 ? 0.38 : 0.72; + if (i === 1) return 0.55; + if (i === 2) return 0.86; + return 0.46; + }; + const muted = (i: number) => i === 1 && step() >= 1; + const route = (i: number) => + i === 0 && step() >= 2 ? "Headphones" : i === 2 ? "Display" : "Speakers"; + + return ( + +
+
+ + Volume mixer + + + out · Speakers + +
+ + {(app, i) => ( +
+ + {app.glyph} + +
+
+ + {app.name} + + + {route(i())} + +
+
+
+ {/* live signal riding inside the level */} +
+
+
+ + {muted(i()) ? "✕" : "♪"} + +
+ )} + +
+

+ { + [ + "every app on its own fader", + "browser muted — the rest keep playing", + "music routed to headphones, meeting stays on the display", + "music down, nothing else moved" + ][step()] + } +

+
+ + ); +} + +/* ── Capture: the module, and the Wings while a recording runs ────── */ + +/** CapturePalette.recording and the NookPalette paper/ink pair. */ +const RECORDING_RED = "rgb(242,69,69)"; +const PAPER = "#f1ead9"; +const INK = "#0d0d0f"; + +function CaptureChip(props: { + glyph: GlyphName; + label: string; + menu?: boolean; + pressed?: boolean; +}) { + return ( + + + {props.label} + + + + + ); +} + +/** CaptureAudioToggles: paper capsule when on, faint when off. */ +function AudioToggles(props: { system: boolean; mic: boolean }) { + const toggle = (on: boolean, glyph: GlyphName, label: string) => ( + + + {label} + + ); + return ( +
+ {toggle(props.system, props.system ? "speaker" : "speakerOff", "System audio")} + {toggle(props.mic, props.mic ? "mic" : "micOff", "Microphone")} +
+ ); +} + +function RowLabel(props: { glyph: GlyphName; label: string }) { + return ( +

+ + {props.label} +

+ ); +} + +export function CaptureDemo() { + const [phase, setPhase] = createSignal<"idle" | "press" | "rec" | "paused" | "saved">("idle"); + const [secs, setSecs] = createSignal(0); + const [mic, setMic] = createSignal(true); + const [wingPress, setWingPress] = createSignal<"" | "mic" | "pause" | "stop">(""); + + useDemoLoop(12400, [ + [0, () => (setPhase("idle"), setSecs(0), setMic(true), setWingPress(""))], + [1500, () => setPhase("press")], + [1800, () => setPhase("rec")], + ...[1, 2, 3, 4, 5].map((s) => [1800 + s * 1000, () => setSecs(s)] as [number, () => void]), + [4300, () => setWingPress("mic")], + [4550, () => (setMic(false), setWingPress(""))], + [7100, () => setWingPress("pause")], + [7350, () => (setPhase("paused"), setWingPress(""))], + [9000, () => setWingPress("stop")], + [9250, () => (setPhase("saved"), setWingPress(""))] + ]); + + const recording = () => phase() === "rec" || phase() === "paused"; + const paused = () => phase() === "paused"; + /* Island row is drawn at half size; Wing controls are as tall as it. */ + const wingWidth = 2 * 60 + 12; + const cursor = () => { + switch (wingPress()) { + case "mic": + return { phase: "press" as const, at: { x: 21.8, y: 7 } }; + case "pause": + return { phase: "press" as const, at: { x: 78.2, y: 7 } }; + case "stop": + return { phase: "press" as const, at: { x: 86.5, y: 7 } }; + } + if (phase() === "idle" || phase() === "press") + return { phase: phase() === "press" ? ("press" as const) : ("idle" as const), at: { x: 50, y: 58 } }; + if (phase() === "saved") return { phase: "live" as const, at: { x: 86.5, y: 7 } }; + return { phase: "idle" as const, at: { x: 21.8, y: 30 } }; + }; + + return ( +
+ {/* the Compact island, flanked by the Wings while recording */} +
+
+
+ + +
+ } + /> +
+ + +
+
+
+ + {/* the Capture module */} + +
+ +
+ +
+ + + +
+
+
+ +
+ + + +
+ +
+ +
+ + + Screen Recording 2026-10-13 at 9.41.12.mov + + Show in Finder +
+
+
+ } + > +
+
+ + + 0:0{secs()} + + + {paused() ? "Paused" : "Recording"} + +
+
+ + +
+ +
+ +
+ + + {/* cursor plotted on a fixed 360 px frame centered over the demo */} +
+ +
+
+ ); +} + +/* ── Floating actions bloom around the open island ────────────────── */ + +/* Tiny monochrome glyphs for the action kinds the app offers. */ +const GLYPH: Record JSX.Element> = { + leaf: () => ( + + ), + bolt: () => , + timer: () => ( + + + + + ), + speaker: () => ( + + + + + ), + shell: () => ( + + ), + stack: () => ( + + + + + ), + link: () => ( + + ) +}; + +interface FloatAction { + glyph: keyof typeof GLYPH; + label: string; + x: number; + y: number; +} + +/* Positions mirror the app's slots: Left ·1/·2, Right ·1/·2, Bottom ·1–3. */ +const ACTIONS: FloatAction[] = [ + { glyph: "leaf", label: "Low Power Mode on", x: -128, y: -18 }, + { glyph: "bolt", label: "Top-up to 100%", x: -128, y: 26 }, + { glyph: "speaker", label: "Output → Headphones", x: 128, y: -18 }, + { glyph: "stack", label: "Shortcut: Focus Time", x: 128, y: 26 }, + { glyph: "timer", label: "Timer · 25 min", x: -50, y: 78 }, + { glyph: "shell", label: "$ make deploy", x: 0, y: 78 }, + { glyph: "link", label: "Opened dashboard", x: 50, y: 78 } +]; + +export function FloatingActionsDemo() { + const [open, setOpen] = createSignal(false); + const [pressed, setPressed] = createSignal(-1); + const [lit, setLit] = createSignal([]); + + useDemoLoop(9600, [ + [0, () => (setOpen(false), setPressed(-1), setLit([]))], + [400, () => setOpen(true)], + [2200, () => setPressed(0)], + [2500, () => setLit([0])], + [4000, () => setPressed(4)], + [4300, () => setLit([0, 4])], + [5800, () => setPressed(2)], + [6100, () => setLit([0, 4, 2])], + [8800, () => (setOpen(false), setPressed(-1))] + ]); + + const lastLit = () => lit()[lit().length - 1]; + + return ( +
+ {/* the expanded island */} +
+
+
+
+
+
+ + {(a, i) => ( + + + {GLYPH[a.glyph]()} + + + )} + +
+ + {/* what the last press did */} +
+ {lastLit() !== undefined ? ACTIONS[lastLit()].label : " "} +
+
+ ); +} diff --git a/src/components/nook/PhoneBeta.tsx b/src/components/nook/PhoneBeta.tsx new file mode 100644 index 0000000..3d80e13 --- /dev/null +++ b/src/components/nook/PhoneBeta.tsx @@ -0,0 +1,286 @@ +import { For, Show, createSignal } from "solid-js"; +import Button from "~/components/ui/Button"; +import Input from "~/components/ui/Input"; +import { STATUS, ACCENT } from "./IslandMock"; +import { useDemoLoop } from "./useDemoLoop"; +import { betaRequestPayload } from "./betaRequest"; + +/** + * iPhone + Apple Watch companion, in TestFlight beta. The request form + * mirrors Settings ▸ Phone's BetaAccessCard in the app: it sends only the + * email, over the same `/api/the-nook/report` pipeline (see betaRequest.ts). + */ + +type RequestState = "idle" | "sending" | "sent" | "limited" | "failed"; + +function requestBetaAccess(email: string): Promise { + return fetch("/api/the-nook/report", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(betaRequestPayload(email)) + }); +} + +/* ── Demo: a request lands on the phone, a question on the wrist ─── */ + +function CompanionDemo() { + const [phase, setPhase] = createSignal< + "quiet" | "ask" | "allowed" | "question" | "answered" + >("quiet"); + useDemoLoop(11000, [ + [0, () => setPhase("quiet")], + [900, () => setPhase("ask")], + [3600, () => setPhase("allowed")], + [5400, () => setPhase("question")], + [8200, () => setPhase("answered")] + ]); + + const askVisible = () => phase() !== "quiet"; + const allowed = () => phase() !== "quiet" && phase() !== "ask"; + const working = () => (allowed() ? 3 : 2); + const waiting = () => (phase() === "ask" ? 1 : 0); + + return ( +
+ {/* iPhone */} +
+
+

+ 9:41 +

+ {/* Lock Screen widget: what the numbers count */} +
+ + + {working()} working + + + + {waiting()} waiting + +
+ {/* notification */} +
+
+ + + The Nook · now + +
+

+ {allowed() ? "Allowed ✓" : "Claude Code · the-nook"} +

+

+ {allowed() ? "Back to work." : "Run Bash: swift test"} +

+ +
+ + Allow + + + Deny + +
+
+
+
+ + {/* Apple Watch */} +
+
+
+ + + {working()} + + agents working +
+ } + > +

Codex asks

+

+ Ship it to staging? +

+
+ + {(option, i) => ( + + {phase() === "answered" && i() === 0 ? "Sent ✓" : option} + + )} + +
+ +
+
+
+
+ ); +} + +export default function PhoneBeta(props: { brandColor: string }) { + const [email, setEmail] = createSignal(""); + const [state, setState] = createSignal("idle"); + + const submit = async () => { + const contact = email().trim(); + if (!contact || state() === "sending") return; + setState("sending"); + try { + const res = await requestBetaAccess(contact); + if (res.status === 429) return setState("limited"); + if (!res.ok) return setState("failed"); + setEmail(""); + setState("sent"); + } catch { + setState("failed"); + } + }; + + return ( +
+
+
+ + TestFlight beta + +

+ Your fleet, in your pocket +

+

+ The iPhone and Apple Watch companion is in beta. Approvals, + questions, and replies reach you away from the desk, and your + answer goes straight back to the agent. +

+
    + + {(bullet) => ( +
  • + + {bullet} +
  • + )} +
    +
+ +
+

Request beta access

+

+ Leave your email to get a TestFlight invite. We only receive your + email address. +

+
{ + e.preventDefault(); + void submit(); + }} + > + setEmail(e.currentTarget.value)} + /> + +
+ +

+ Thanks. Watch your inbox for a TestFlight invite. +

+
+ +

+ Too many requests from this network. Try again later. +

+
+ +

+ Couldn't send the request. Check your connection and try again. +

+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/nook/betaRequest.ts b/src/components/nook/betaRequest.ts new file mode 100644 index 0000000..4e970b7 --- /dev/null +++ b/src/components/nook/betaRequest.ts @@ -0,0 +1,12 @@ +/** + * The iPhone beta-access request the landing page posts to + * `/api/the-nook/report` — the same title the app's Settings ▸ Phone card + * sends, so requests from both land together. Email only, no device facts. + */ +export function betaRequestPayload(email: string) { + return { + title: "iPhone app beta access request", + description: "Beta access requested from nook.freno.me.", + contact: email + }; +} diff --git a/src/components/nook/useDemoLoop.ts b/src/components/nook/useDemoLoop.ts new file mode 100644 index 0000000..ed5ec61 --- /dev/null +++ b/src/components/nook/useDemoLoop.ts @@ -0,0 +1,28 @@ +import { onCleanup, onMount } from "solid-js"; + +/** + * Drives a looping landing-page demo: each `[atMs, step]` pair fires at its + * offset, and the whole schedule restarts every `periodMs`. Timers are torn + * down with the owning component. + */ +export function useDemoLoop( + periodMs: number, + steps: [atMs: number, step: () => void][] +): void { + onMount(() => { + const timers: ReturnType[] = []; + const run = () => { + timers.length = 0; + for (const [at, step] of steps) { + if (at === 0) step(); + else timers.push(setTimeout(step, at)); + } + }; + run(); + const interval = setInterval(run, periodMs); + onCleanup(() => { + clearInterval(interval); + timers.forEach(clearTimeout); + }); + }); +} diff --git a/src/routes/api/the-nook/report.test.ts b/src/routes/api/the-nook/report.test.ts index 04bad4f..9271566 100644 --- a/src/routes/api/the-nook/report.test.ts +++ b/src/routes/api/the-nook/report.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, mock, beforeEach } from "bun:test"; // and description, HTML-escaped. // 2. Invalid payloads never reach the mailer. // 3. The rate limiter halts floods per fingerprint (falling back to IP). +// 4. The landing page's iPhone beta request (email only) gets through. // sendEmail and the limiter are mocked so the test sends no real mail. const sentEmails: { to: string; subject: string; html: string }[] = []; @@ -22,6 +23,9 @@ mock.module("~/server/bug-report-rate-limit", () => ({ })); const { POST } = await import("./report"); +const { betaRequestPayload } = await import( + "~/components/nook/betaRequest" +); /** A minimal APIEvent double: JSON body plus optional headers. */ function request(body: unknown, headers: Record = {}) { @@ -134,4 +138,17 @@ describe("POST /api/the-nook/report", () => { expect(res.status).toBe(200); expect(sentEmails).toHaveLength(1); }); + + it("delivers the landing page's beta request with only the email", async () => { + const res = await POST(request(betaRequestPayload("tester@example.com"))); + expect(res.status).toBe(200); + + expect(sentEmails).toHaveLength(1); + const email = sentEmails[0]!; + expect(email.subject).toBe( + "The Nook bug report: iPhone app beta access request" + ); + expect(email.html).toContain("Reply-to: tester@example.com"); + expect(email.html).not.toContain("

Displays

"); + }); }); diff --git a/src/routes/nook/index.tsx b/src/routes/nook/index.tsx index fcd624e..a21dea4 100644 --- a/src/routes/nook/index.tsx +++ b/src/routes/nook/index.tsx @@ -3,6 +3,8 @@ import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import SubdomainHeader from "~/components/SubdomainHeader"; import FeatureCollage from "~/components/nook/FeatureCollage"; import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns"; +import IslandExtras from "~/components/nook/IslandExtras"; +import PhoneBeta from "~/components/nook/PhoneBeta"; import Button from "~/components/ui/Button"; import Input from "~/components/ui/Input"; import { createSignal, Show } from "solid-js"; @@ -149,6 +151,12 @@ export default function NookLanding() {
+ {/* ── Everything else: marquee + live module bento ───────────── */} + + + {/* ── iPhone + Watch companion, TestFlight beta ──────────────── */} + + {/* ── CTA ──────────────────────────────────────────────────────── */}
@@ -156,10 +164,10 @@ export default function NookLanding() { Try it free for 14 days

- Orchestrate your coding agents, watch temperatures, take control of - your fans, and keep background services quiet. When the trial ends, - unlock everything with a single one-time payment — no subscription, - ever. + Orchestrate your coding agents, take control of your fans and + battery, give every app its own volume, capture your screen, and + keep your day in the notch. When the trial ends, unlock everything + with a single one-time payment — no subscription, ever.