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.