nook: show the full feature set on the landing page

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 23:07:10 -04:00
parent 6898186df5
commit 9508aee8e1
14 changed files with 2769 additions and 419 deletions

View File

@@ -0,0 +1,4 @@
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,4 @@
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@@ -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<typeof setTimeout>[] = [];
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 (
<div class="flex flex-col items-center gap-5 sm:flex-row sm:items-end sm:justify-center">
{/* island card */}
<div
class="w-full max-w-[300px] rounded-[10px] p-2.5 text-left transition-opacity duration-500"
style={{
background: "rgba(234,179,8,0.08)",
opacity: allowed() ? 0.45 : 1
}}
>
<div class="mb-1.5 flex items-center gap-1.5">
<span
class="inline-block h-[13px] w-[13px] rounded-[3px]"
style={{ background: "#D97742" }}
<div class="relative flex h-[390px] flex-col items-center gap-3">
<IslandPill
campfire={asking() ? "question" : "running"}
right={
<AgentDotGrid
states={asking() ? ["attention", "running", "ready"] : ["running", "running", "ready"]}
/>
<span class="text-[12px] font-semibold text-white">
the-nook · bridge
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{
background: allowed()
? "rgba(150,220,150,0.25)"
: "rgba(234,179,8,0.25)"
}}
>
{allowed() ? "allowed" : "approval"}
</span>
</div>
<p class="text-[13px] font-medium text-white">Run Edit</p>
<p class="mt-0.5 font-mono text-[11px] text-white/50">
server/routes.ts +12 −4
</p>
<div class="mt-2.5 flex gap-2">
<span
class="rounded-md px-3 py-1 text-[11px] font-semibold text-white transition-all duration-300"
style={{ background: ACCENT, opacity: allowed() ? 0.4 : 1 }}
>
Allow
</span>
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
Deny
</span>
</div>
</div>
{/* phone */}
}
/>
{/* the island opens: approval pinned above the session rows */}
<div
class="w-[150px] rounded-2xl border border-white/15 p-2 text-left"
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
style={{
background: "rgba(255,255,255,0.04)",
transform: allowed() ? "translateY(-4px)" : "translateY(0)",
transition: "transform 0.5s ease"
"max-height": open() ? "320px" : "0px",
opacity: open() ? 1 : 0,
transform: open() ? "translateY(0)" : "translateY(-8px)"
}}
>
<div class="mx-auto mb-1.5 h-1 w-10 rounded-full bg-white/20" />
<div class="p-2.5">
<div
class="grid transition-all duration-500"
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
>
<div class="overflow-hidden">
<div class="pb-1.5">
<ApprovalCardMock
agent={CLAUDE}
title="the-nook · harden the bridge"
tool="Bash"
input="swift test --filter Bridge"
pressed={phase() === "press" ? "allow" : undefined}
/>
</div>
</div>
</div>
<div class="space-y-1.5">
<p class="px-1 text-[9px] font-semibold tracking-wide text-white/40 uppercase">
The Nook
</p>
<div
class="rounded-lg p-2"
style={{
background: allowed()
? "rgba(111,185,130,0.14)"
: "rgba(231,167,98,0.14)"
}}
>
<p class="text-[10px] font-semibold text-white">
{allowed() ? "Allowed ✓" : "Claude Code wants to edit"}
</p>
<p class="text-[9px] text-white/50">
{allowed() ? "resumed" : "tap to Allow or Deny"}
</p>
<SessionRowMock
title="the-nook · main"
prompt="harden the bridge"
state={
asking()
? "Approve Bash — swift test --filter Bridge?"
: "Running swift test --filter Bridge…"
}
status={asking() ? "attention" : "running"}
agent={CLAUDE}
origin="iTerm2"
age="6m"
/>
<SessionRowMock
title="site · nook-334"
prompt="match the mocks to the app"
state="Reading ModuleMocks.tsx…"
status="running"
agent={CODEX}
origin="kitty-tmux"
age="2m"
/>
</div>
</div>
</div>
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[380px] -translate-x-1/2">
<FakeCursor
phase={phase() === "press" ? "press" : asking() ? "idle" : "live"}
at={{ x: 9.5, y: 41 }}
exit={{ x: 30, y: 70 }}
/>
</div>
</div>
);
}
@@ -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 (
<span
class="pointer-events-none absolute z-10 rounded-full bg-white"
style={{
width: "9px",
height: "9px",
"box-shadow":
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
left: `${p().x}%`,
top: `${p().y}%`,
opacity: p().o,
transform: `translate(-50%, -50%) scale(${p().scale})`,
transition:
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
}}
/>
);
}
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
function BatteryDemo() {
@@ -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<typeof setTimeout>[] = [];
const loop = () => {
setStep(0);
timers.push(setTimeout(() => setStep(1), 1600));
timers.push(setTimeout(() => setStep(2), 3000));
timers.push(setTimeout(() => setStep(3), 4200));
timers.push(setTimeout(() => setStep(4), 5400));
};
loop();
const interval = setInterval(loop, 11000);
onCleanup(() => {
clearInterval(interval);
timers.forEach(clearTimeout);
});
});
const cursorPhase = () =>
step() === 0
? ("idle" as const)
: step() === 1
? ("press" as const)
: ("live" as const);
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 (
<ModuleCard class="relative mx-auto w-full max-w-[330px]">
<div class="space-y-2.5 text-left">
<div class="flex items-center gap-2">
<div class="relative mx-auto w-full max-w-[360px] space-y-3">
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
<ModuleCard>
<div class="space-y-2 text-left">
<div class="flex items-center gap-2.5">
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
<p class="truncate font-mono text-[10px] text-white/40">
mike@192.168.64.3 · linux
</p>
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
</div>
<Show when={working()}>
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
</Show>
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-300"
style={{
background: step() === 0 ? ACCENT : "rgba(255,255,255,0.08)",
opacity: step() >= 4 ? 0 : 1,
transform: step() === 1 ? "scale(0.94)" : "scale(1)"
}}
>
{step() === 0 ? "Install hook" : "installing…"}
</span>
</div>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<div class="relative" style={{ "min-height": "86px" }}>
<div
class="space-y-1.5 transition-opacity duration-500"
style={{ opacity: step() >= 4 ? 0 : 1 }}
>
{step() === 0 && (
<p class="pt-2 text-center text-[10px] text-white/35">
one click: copy → setup → probe
</p>
)}
<For each={SSH_LINES}>
{(l) => (
<div
class="flex items-center gap-1.5 transition-all duration-500"
style={{
opacity: step() >= l.at ? 1 : 0,
transform:
step() >= l.at ? "translateY(0)" : "translateY(4px)"
}}
>
<span
class="font-mono text-[10px]"
style={{
color:
step() > l.at
? "rgba(150,220,150,0.9)"
: "rgba(255,255,255,0.45)"
}}
>
{step() > l.at ? "✓" : "…"}
</span>
<span class="truncate font-mono text-[10px] text-white/70">
{l.text}
</span>
</div>
)}
</For>
</div>
<div
class="absolute inset-0 flex flex-col justify-center transition-all duration-500"
style={{
opacity: step() >= 4 ? 1 : 0,
transform: step() >= 4 ? "translateY(0)" : "translateY(6px)"
}}
>
<div
class="rounded-[10px] p-2.5"
style={{
background: "rgba(255,255,255,0.055)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
}}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.running }}
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
style={{ background: chip().color }}
/>
<span class="text-[13px] font-semibold text-white">
gpu-rig
{chip().label}
</span>
<Show
when={live()}
fallback={
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
style={{
background: "rgba(110,167,255,0.22)",
color: "#9cc3ff"
background: ACCENT,
opacity: working() ? 0 : 1,
transform: pressed() ? "scale(0.92)" : "scale(1)"
}}
>
remote
Set up
</span>
}
>
<Glyph name="refresh" size={11} class="text-white/50" />
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
Remove
</span>
</Show>
</div>
<p class="mt-0.5 text-[11px] text-white/50">
You: benchmark fp16
<p class="h-4 text-[11px] text-white/50">
{working() ? SETUP_STEPS[step()] : " "}
</p>
</div>
<p class="pt-1.5 text-center text-[10px] text-white/40">
agents join the same dot grid
</p>
</div>
</div>
</div>
<FakeCursor phase={cursorPhase()} at={{ x: 90, y: 14 }} />
</ModuleCard>
{/* its agents arrive as ordinary rows, the machine named in the chip */}
<div
class="transition-all duration-500"
style={{
opacity: live() ? 1 : 0,
transform: live() ? "translateY(0)" : "translateY(6px)"
}}
>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Running python bench.py…"
status="running"
agent={CODEX}
host="gpu-rig"
origin="iTerm2"
age="now"
/>
</div>
<FakeCursor
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
at={{ x: 88, y: 16 }}
/>
</div>
);
}
@@ -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() {
<BreakdownSection
kicker="Approvals"
title="Answer from the island — or your phone"
body="Gated tool calls hold right in the panel. Allow, deny, or demand a reason. Walking away? The same request lands on your phone and your tap flies back to the agent."
body="A gated tool call surfaces the island: the fire turns to a question, the card pins above your sessions, and the row says what it's waiting on. Allow, Allow for session, Deny, or deny with a reason, and the run picks up where it stopped."
bullets={[
"Permission and question cards pin above the session list",
"Push-to-phone over your own network — no cloud middleman (coming soon)",
"Approval cards pin above the session list; questions answer inside their row",
"Allow for session waves through the rest of that session's requests",
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
"Works across every hooked agent, local or remote"
]}
reversed
@@ -1042,6 +983,46 @@ export default function FeatureBreakdowns() {
<ApprovalDemo />
</BreakdownSection>
<BreakdownSection
kicker="Terminals"
title="Reply without hunting for the tab"
body="Every session knows the terminal it runs in. Type a reply in the island and it lands in that exact pane; click Jump and the right window comes forward."
bullets={[
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
"Jump focuses the exact window, even for sessions over SSH",
"Per-turn replies render as Markdown, with tables and code"
]}
>
<TerminalReplyDemo />
</BreakdownSection>
<BreakdownSection
kicker="Remote"
title="Any SSH box, one click away"
body="Pick a host from your ssh config or your tailnet and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
bullets={[
"One click: connect, push the hook, configure agents, check it can reach this Mac",
"Hosts mirror this Mac's installed integrations automatically",
"Remote sessions are ordinary rows; the agent chip names the machine"
]}
reversed
>
<RemoteHostDemo />
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="A hearth that tells the truth"
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
bullets={[
"Idle, running, ready, question, and error — at a glance",
"Hand-drawn 14×14 sprites, no tweening",
"Six chiptunes you can assign to any agent event"
]}
>
<CampfireDemo />
</BreakdownSection>
<BreakdownSection
kicker="Cooling"
title="Fans that answer to you, not the scheduler"
@@ -1052,6 +1033,7 @@ export default function FeatureBreakdowns() {
"CPU/GPU die temps tier-color before things get loud",
"All local: your hardware data never leaves the device"
]}
reversed
>
<FansDemo />
</BreakdownSection>
@@ -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
>
<BatteryDemo />
</BreakdownSection>
<BreakdownSection
kicker="Sound"
title="Every app gets its own fader"
body="A real volume mixer: set each app's level, mute the noisy one, and send any app to its own output — music to headphones while the meeting stays on the speakers."
bullets={[
"Per-app volume, mute, and output routing",
"Master output, system sounds, and microphone pickers in one panel",
"Catches players that aren't apps, like command-line podcast tools"
]}
reversed
>
<MixerDemo />
</BreakdownSection>
<BreakdownSection
kicker="Capture"
title="Screenshots and recordings from the notch"
body="Drag out an area, pick a screen, or choose one app. While a recording runs, Wings unfold beside the island: pause and stop on the right, system audio and microphone on the left."
bullets={[
"Area, screen, or single-app capture",
"Screenshots save to your screenshot folder and copy to the clipboard",
"System audio and microphone tracks for recordings",
"The Nook's own windows never show up in a capture"
]}
>
<CaptureDemo />
</BreakdownSection>
<BreakdownSection
kicker="Your day"
title="Meetings come to the notch"
@@ -1082,6 +1091,7 @@ export default function FeatureBreakdowns() {
"Meeting links get an Open button in the row",
"Real calendars and reminders — no sync service"
]}
reversed
>
<CalendarDemo />
</BreakdownSection>
@@ -1096,35 +1106,23 @@ export default function FeatureBreakdowns() {
"A device picker for every connected camera",
"Opt-in module — in no default panel"
]}
reversed
>
<CameraDemo />
</BreakdownSection>
<BreakdownSection
kicker="Remote"
title="Any SSH box, one click away"
body="Point at any SSH-reachable box and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
kicker="Floating actions"
title="Your buttons, orbiting the island"
body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
bullets={[
"One click: copy the hook, run setup, probe answers pong",
"Remote agents wear a blue badge in the same dot grid",
"Works for Claude, Codex, Pi and many more"
"Run an Apple Shortcut, open an app or URL, or run a shell command",
"Toggle Low Power Mode, top-up, or overheat protection",
"Set the charge limit, cycle audio outputs, or start a timer",
"Cycle the approval mode without opening Settings"
]}
reversed
>
<SshInstallDemo />
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="A hearth that tells the truth"
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
bullets={[
"Idle, running, ready, question, and error — at a glance",
"Hand-drawn 14×14 sprites, no tweening",
"The fire is the fleet's pulse: glanceable from across the room"
]}
>
<CampfireDemo />
<FloatingActionsDemo />
</BreakdownSection>
</div>
);

View File

@@ -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 (
<div
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
@@ -55,50 +54,6 @@ function InkStage(props: { children: JSX.Element; class?: string }) {
);
}
/** The approvals mock rebuilt on the app's PermissionCard anatomy. */
function ApprovalCardMock() {
return (
<div
class="mx-auto w-full max-w-sm rounded-[10px] p-2.5 text-left"
style={{ background: "rgba(234,179,8,0.08)" }}
>
<div class="mb-1.5 flex items-center gap-1.5">
<span
class="inline-block h-[13px] w-[13px] rounded-[3px]"
style={{ background: "#D97742" }}
/>
<span class="text-[12px] font-semibold text-white">
the-nook · bridge
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: "rgba(234,179,8,0.25)" }}
>
approval
</span>
</div>
<p class="text-[13px] font-medium text-white">Run Edit</p>
<p class="mt-0.5 font-mono text-[11px] text-white/50">
server/routes.ts +12 −4
</p>
<div class="mt-2.5 flex gap-2">
<span
class="rounded-md px-3 py-1 text-[11px] font-semibold"
style={{ background: ACCENT }}
>
Allow
</span>
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
Deny
</span>
<span class="px-1 py-1 text-[11px] text-white/50">
Deny with reason…
</span>
</div>
</div>
);
}
/** 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 (
<div class="space-y-1.5">
<div
class="rounded-[10px] p-2.5"
style={{
background: "rgba(255,255,255,0.055)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
}}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.running }}
<SessionRowMock
title="api · main"
prompt="fix the flaky test"
state="Running pytest -x…"
status="running"
agent={CLAUDE}
host="build-box"
age="4m"
/>
<span class="text-[13px] font-semibold text-white">build-box</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
style={{ background: "rgba(255,255,255,0.08)" }}
>
ssh
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">You: fix flaky test</p>
</div>
<div
class="rounded-[10px] p-2.5"
style={{
background: "rgba(255,255,255,0.055)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
}}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.ready }}
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Done · replied 1m ago"
status="ready"
agent={CODEX}
host="gpu-rig"
age="12m"
/>
<span class="text-[13px] font-semibold text-white">gpu-rig</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
style={{ background: "rgba(255,255,255,0.08)" }}
>
ssh
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
</div>
</div>
);
}
/** 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 (
<div class="grid grid-cols-2 gap-2">
<ModuleCard>
<div class="flex items-baseline justify-between">
<span class="text-[9px] font-semibold text-white/50">CPU</span>
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
72°
</span>
</div>
<div
class="mt-1.5 h-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{ width: "64%", background: CALM }}
/>
<div class="space-y-2">
<DieLine label="CPU" usage={trace(0)} celsius={58 + 4 * Math.sin(n() * 0.3)} />
<DieLine label="GPU" usage={trace(2)} celsius={gpuCelsius()} />
</div>
</ModuleCard>
<ModuleCard>
<div class="flex items-baseline justify-between">
<span class="text-[9px] font-semibold text-white/50">GPU</span>
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
58°
</span>
</div>
<div
class="mt-1.5 h-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{ width: "31%", background: CALM }}
/>
</div>
</ModuleCard>
</div>
);
}
export default function FeatureCollage() {
return (
<section
@@ -311,11 +229,16 @@ export default function FeatureCollage() {
<CollageCell
kicker="Approvals"
title="Unblock agents, anywhere"
body="Permission prompts surface as cards in the panel — Allow once, always, or deny. Away from the desk, push them to your phone and answer from there (coming soon)."
body="Permission prompts pin as cards above your sessions — Allow, Allow for session, or Deny. Away from the desk, answer from your iPhone or Apple Watch (beta)."
>
<InkStage>
<div class="w-full max-w-sm px-3">
<ApprovalCardMock />
<ApprovalCardMock
agent={CLAUDE}
title="the-nook · bridge"
tool="Edit"
input="server/routes.ts"
/>
</div>
</InkStage>
</CollageCell>
@@ -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."
>
<InkStage class="w-full">
<div class="w-full max-w-[280px] text-left">
<div class="w-full px-3 text-left">
<RemoteMock />
</div>
</InkStage>
@@ -350,7 +273,7 @@ export default function FeatureCollage() {
<CollageCell
kicker="Your Mac"
title="The gauges that matter"
body="CPU and GPU die temps, memory pressure, live network throughput — the same tier palette the island uses, warning before a compile cooks your lap."
body="Each die's temperature beside its 60-second usage trace, tinted amber from 70° and red at 85°, so you see a compile heating up before the fans do."
>
<InkStage class="w-full">
<div class="w-full max-w-[300px] text-left">
@@ -361,8 +284,13 @@ export default function FeatureCollage() {
</div>
<p class="text-subtext1 mt-6 text-center text-xs">
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 —{" "}
<a
href="#island-extras"
class="underline decoration-dotted hover:opacity-80"
>
see them all ↓
</a>
</p>
</div>
</section>

View File

@@ -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 (
<div
class={`mx-auto flex h-[26px] items-center justify-center gap-2 rounded-b-[11px] bg-black px-3 ${props.class ?? ""}`}
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
{props.children}
</div>
);
}
/* ── Now Playing + the waveform slot ─────────────────────────────── */
function NowPlayingMock() {
return (
<div class="flex w-full max-w-[420px] flex-col items-center gap-4 px-3 sm:flex-row sm:items-center">
<ModuleCard class="w-full flex-1">
<div class="flex items-center gap-3 text-left">
<div
class="nook-art h-12 w-12 shrink-0 rounded-lg"
style={{
background:
"conic-gradient(from 210deg, #fa5c7c, #6EA7FF, #6FB982, #E7A762, #fa5c7c)"
}}
/>
<div class="min-w-0 flex-1">
<p class="truncate text-[12px] font-semibold text-white">
Night Drive
</p>
<p class="truncate text-[10px] text-white/50">
Synthwave Radio · Browser
</p>
<div
class="mt-2 h-[3px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.1)" }}
>
<div
class="nook-progress h-full rounded-full"
style={{ background: "rgba(255,255,255,0.75)" }}
/>
</div>
</div>
</div>
<div class="mt-2.5 flex justify-center gap-5 text-[12px] text-white/70">
<span>⏮</span>
<span class="text-white">⏸</span>
<span>⏭</span>
</div>
</ModuleCard>
<div class="flex flex-col items-center gap-1.5">
<MiniPill>
<span class="flex h-[14px] items-center gap-[2px]">
<For each={[0, 1, 2, 3, 4, 5]}>
{(i) => (
<span
class="nook-wave w-[2.5px] rounded-full"
style={{
background: "#fa5c7c",
"animation-delay": `${i * -0.13}s`,
"animation-duration": `${0.7 + (i % 3) * 0.18}s`
}}
/>
)}
</For>
</span>
</MiniPill>
<span class="text-[9px] text-white/40">waveform slot</span>
</div>
</div>
);
}
/* ── 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 (
<span class="flex h-[26px] flex-1 items-center justify-center gap-1 rounded-full bg-white/[0.08] font-mono text-[11px] font-medium whitespace-nowrap text-white">
<Show when={props.glyph}>
<Glyph name={props.glyph!} size={10} />
</Show>
{props.label}
</span>
);
}
/*
* 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 (
<ModuleCard class="w-full max-w-[260px]">
<div class="flex flex-col items-center gap-1">
<div class="flex h-[34px] items-center">
<Show
when={ringing()}
fallback={
<span class="font-mono text-[30px] font-semibold text-white tabular-nums">
{clock(leadLeft() * LEAD_SECONDS)}
</span>
}
>
<span
class="flex items-center gap-1.5 font-mono text-[22px] font-semibold"
style={{ color: TIMER_ORANGE }}
>
<Glyph
name="bell"
size={18}
style={{ animation: "nook-pulse 1s ease-in-out infinite" }}
/>
Time's up
</span>
</Show>
</div>
<div class={ringing() ? "nook-tape-ring" : ""}>
<TimerTapeMock remaining={ringing() ? 1 : leadLeft()} width={232} height={32} />
</div>
<p class="font-mono text-[9px] font-medium text-white/50">
{ringing() ? "3:00 timer" : "Remaining of 3:00"}
</p>
<div class="flex w-full gap-2 pt-1">
<Show
when={ringing()}
fallback={
<>
<TimerPill glyph="pause" label="Pause" />
<TimerPill glyph="xmark" label="Cancel" />
</>
}
>
<TimerPill glyph="plus" label="1 min" />
<TimerPill glyph="restart" label="Restart" />
<TimerPill glyph="xmark" label="Dismiss" />
</Show>
</div>
{/* a further timer: readout, slim tape, icon controls */}
<div class="mt-1.5 flex h-6 w-full items-center gap-2 rounded-full bg-white/[0.05] px-2.5 font-mono text-[11px] font-medium text-white">
<span class="w-11 tabular-nums">{clock(focusLeft() * 1500)}</span>
<div class="min-w-0 flex-1">
<TimerTapeMock remaining={focusLeft()} width={110} height={12} />
</div>
<Glyph name="pause" size={9} />
<Glyph name="xmark" size={9} />
</div>
<div class="mt-1.5 flex w-full gap-1.5">
<For each={["1m", "5m", "10m", "25m"]}>{(m) => <TimerPill label={m} />}</For>
</div>
</div>
</ModuleCard>
);
}
/* ── 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 (
<svg width={s()} height={s()} viewBox="0 0 16 16">
<Show when={props.sky === "sun"}>
<circle cx="8" cy="8" r="3.4" fill="#FFD23F" />
<g stroke="#FFD23F" stroke-width="1.3" stroke-linecap="round">
<path d="M8 1.2v1.6M8 13.2v1.6M1.2 8h1.6M13.2 8h1.6M3.2 3.2l1.1 1.1M11.7 11.7l1.1 1.1M3.2 12.8l1.1-1.1M11.7 4.3l1.1-1.1" />
</g>
</Show>
<Show when={props.sky !== "sun"}>
<path
d="M4.5 11.5a3 3 0 0 1 .3-6 4 4 0 0 1 7.6 1.2 2.4 2.4 0 0 1-.4 4.8Z"
fill="rgba(255,255,255,0.8)"
/>
</Show>
<Show when={props.sky === "rain"}>
<path
d="M6 13l-.6 1.6M9 13l-.6 1.6M12 13l-.6 1.6"
stroke={CALM}
stroke-width="1.2"
stroke-linecap="round"
/>
</Show>
</svg>
);
}
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 (
<div class="flex w-full max-w-[260px] flex-col items-center gap-3">
<MiniPill class="h-[34px] flex-col gap-0 px-4">
<SkyGlyph sky={p().sky} size={12} />
<span class="font-mono text-[11px] leading-none font-bold text-white">
{p().temp}°
</span>
</MiniPill>
<ModuleCard class="w-full">
<div class="text-left">
<div class="mb-2 flex items-baseline">
<span class="text-[11px] font-semibold text-white">{p().place}</span>
<span class="ml-auto text-[9px] text-white/40">
{i() + 1} of {PLACES.length}
</span>
</div>
<div class="flex justify-between">
<Index each={p().hourly}>
{(h, hour) => (
<div class="flex flex-col items-center gap-1">
<span class="text-[8px] text-white/40">
{hour === 0 ? "Now" : `${(13 + hour) % 24}`}
</span>
<SkyGlyph sky={h()[0]} size={12} />
<span class="font-mono text-[10px] text-white/85">
{h()[1]}°
</span>
</div>
)}
</Index>
</div>
</div>
</ModuleCard>
</div>
);
}
/* ── 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<number>(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 (
<div class="flex w-full max-w-[360px] flex-col items-center">
<div
class="relative overflow-hidden rounded-b-[16px] bg-black transition-all duration-300"
style={{
width: shown() ? "300px" : "200px",
height: shown() ? "68px" : "26px",
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
<div
class="absolute inset-x-0 bottom-0 flex items-center gap-2.5 px-4 pb-3 transition-opacity duration-200"
style={{ opacity: shown() ? 1 : 0 }}
>
<svg
width="15"
height="15"
viewBox="0 0 16 16"
style={{ color: xdr() ? "#FFD23F" : "white" }}
>
<Show
when={pk().kind === "volume"}
fallback={
<g fill="currentColor" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
<circle cx="8" cy="8" r="3" stroke="none" />
<path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1" fill="none" />
</g>
}
>
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</Show>
</svg>
<div class="flex-1">
<p class="mb-1 text-left text-[9px] text-white/50">{pk().label}</p>
<div
class="relative h-[6px] rounded-full"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${Math.min(level(), 1) * (pk().kind === "xdr" ? 76.9 : 100)}%`,
background: "white",
transition: "width 0.2s ease-out"
}}
/>
<Show when={pk().kind === "xdr"}>
<span
class="absolute top-[-3px] h-[12px] w-px bg-white/50"
style={{ left: "76.9%" }}
/>
<div
class="absolute top-0 h-full rounded-r-full"
style={{
left: "76.9%",
width: `${Math.max(0, level() - 1) * 76.9}%`,
background: "#FFD23F",
"box-shadow": "0 0 10px 2px rgba(255,210,63,0.6)",
transition: "width 0.2s ease-out"
}}
/>
</Show>
</div>
</div>
<span class="w-9 text-right font-mono text-[10px] text-white/80">
{Math.round(level() * 100)}%
</span>
</div>
</div>
</div>
);
}
/* ── 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 (
<ModuleCard class="w-full max-w-[260px]">
<div class="space-y-2 text-left">
<div class="flex items-end gap-3">
<div>
<p class="text-[8px] font-semibold text-white/40">DOWN</p>
<p class="font-mono text-[16px] font-semibold text-white">
{down()}
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
</p>
</div>
<div>
<p class="text-[8px] font-semibold text-white/40">UP</p>
<p class="font-mono text-[16px] font-semibold text-white">
{up()}
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
</p>
</div>
<span
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white"
style={{ background: running() ? "rgba(255,255,255,0.1)" : ACCENT }}
>
{running() ? "testing…" : "Run"}
</span>
</div>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<For
each={[
["Public IP", "203.0.113.42"],
["ISP", "Example Fiber · AS64500"],
["DNS", "1.1.1.1"]
]}
>
{([k, v]) => (
<div class="flex items-center gap-2 text-[10px]">
<span class="w-14 text-white/40">{k}</span>
<span class="truncate font-mono text-white/80">{v}</span>
<span class="ml-auto text-white/30">⧉</span>
</div>
)}
</For>
<span
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ background: "rgba(110,167,255,0.18)", color: "#9cc3ff" }}
>
VPN · default route
</span>
</div>
</ModuleCard>
);
}
/* ── 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 (
<ModuleCard class="w-full max-w-[260px]">
<div class="space-y-2 text-left">
<div class="flex items-baseline gap-3 font-mono text-[11px]">
<span style={{ color: CALM }}>R {read()} MB/s</span>
<span style={{ color: WARM }}>W {write()} MB/s</span>
</div>
<svg viewBox="0 0 200 42" class="h-[42px] w-full" preserveAspectRatio="none">
<path d={path(0)} fill="none" stroke={CALM} stroke-width="1.5" />
<path d={path(1)} fill="none" stroke={WARM} stroke-width="1.5" />
</svg>
<div class="flex items-center text-[10px]">
<span class="text-white/70">Macintosh HD</span>
<span class="ml-auto text-white/45">612 GB free of 1 TB</span>
</div>
<div
class="h-[5px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div class="h-full rounded-full" style={{ width: "39%", background: CALM }} />
</div>
<span
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ background: "rgba(111,185,130,0.18)", color: STATUS.ready }}
>
SMART · Verified
</span>
</div>
</ModuleCard>
);
}
/* ── 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 (
<ModuleCard class="w-full max-w-[260px]">
<div class="flex flex-col items-center gap-2.5">
<svg width="40" height="40" viewBox="0 0 40 40" class="overflow-visible">
<g class="nook-steam" stroke="rgba(255,255,255,0.5)" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M15 12c-2-3 2-4 0-7" />
<path d="M21 12c-2-3 2-4 0-7" style={{ "animation-delay": "-0.6s" }} />
</g>
<path d="M8 15h22v9a9 9 0 0 1-9 9h-4a9 9 0 0 1-9-9Z" fill={WARM} />
<path d="M30 18h2a4 4 0 0 1 0 8h-2" stroke={WARM} stroke-width="2.4" fill="none" />
</svg>
<div class="grid w-full grid-cols-2 gap-1.5">
<For each={AWAKE_MODES}>
{(m, n) => (
<span
class="rounded-md py-1 text-center text-[10px] font-semibold transition-colors duration-300"
style={{
background: mode() === n() ? ACCENT : CARD_FILL,
color: mode() === n() ? "white" : "rgba(255,255,255,0.55)",
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
{m.label}
</span>
)}
</For>
</div>
<p class="text-[10px] text-white/45">{AWAKE_MODES[mode()].note}</p>
</div>
</ModuleCard>
);
}
/* ── 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 (
<div class="w-full max-w-[260px] space-y-2.5">
<div
class="relative flex rounded-lg p-0.5"
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
<span
class="absolute top-0.5 bottom-0.5 rounded-md transition-all duration-300"
style={{
left: `calc(${mode() * 33.333}% + 2px)`,
width: "calc(33.333% - 4px)",
background: ACCENT
}}
/>
<For each={MODES}>
{(x, n) => (
<span
class="relative z-10 flex-1 py-1 text-center text-[11px] font-semibold transition-colors"
style={{ color: mode() === n() ? "white" : "rgba(255,255,255,0.55)" }}
>
{x.name}
</span>
)}
</For>
</div>
<div
class="rounded-[10px] p-2.5 text-left transition-colors duration-500"
style={{ background: m().tint }}
>
<div class="flex items-center gap-1.5">
<span class="text-[12px] font-medium text-white">Bash</span>
<span class="truncate font-mono text-[10px] text-white/50">
rm -rf build/
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[9px] font-semibold text-white"
style={{ background: "rgba(255,255,255,0.12)" }}
>
{m().card}
</span>
</div>
</div>
<p class="text-center text-[10px] text-white/45">{m().note}</p>
</div>
);
}
/* ── 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 (
<div
class="relative h-[150px] w-full max-w-[260px] overflow-hidden rounded-xl"
style={{
background: "radial-gradient(120% 90% at 30% 10%, #2b4a6b 0%, #191b2e 55%, #0d0d0f 100%)"
}}
>
<div
class="absolute top-0 left-1/2 flex h-[22px] -translate-x-1/2 items-center gap-2 rounded-b-[9px] bg-black px-2.5"
>
<span class="flex items-center gap-1">
<span class="h-[6px] w-[6px] rounded-[1.5px]" style={{ background: STATUS.running }} />
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().working}</span>
</span>
<span class="w-6" />
<span class="flex items-center gap-1">
<span
class="h-[6px] w-[6px] rounded-[1.5px] transition-colors"
style={{ background: counts().waiting ? STATUS.attention : STATUS.idle }}
/>
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().waiting}</span>
</span>
</div>
<div class="flex h-full flex-col items-center justify-center pt-4">
<p class="text-[34px] leading-tight font-semibold tracking-tight text-white/90">
9:41
</p>
<span class="mt-2 h-5 w-5 rounded-full border border-white/30" />
</div>
</div>
);
}
/* ── 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 (
<div
class="relative h-[130px] w-full max-w-[240px] rounded-[12px] bg-black p-2"
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
<div class="relative h-full w-full">
<For each={TILE_COLORS}>
{(color, i) => {
const b = () => LAYOUTS[n()][i()];
return (
<div
class="absolute rounded-md"
style={{
left: `${b()[0]}%`,
top: `${b()[1]}%`,
width: `${b()[2]}%`,
height: `${b()[3]}%`,
background: CARD_FILL,
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}, inset 3px 0 0 ${color}`,
transition: "all 0.7s cubic-bezier(0.34,1.3,0.64,1)"
}}
/>
);
}}
</For>
</div>
</div>
);
}
/* ── 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 (
<div class="nook-marquee">
<div class={`nook-marquee-track ${props.reverse ? "reverse" : ""}`}>
<For each={[...props.chips, ...props.chips]}>
{(chip, i) => (
<span
aria-hidden={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}
</span>
)}
</For>
</div>
</div>
);
}
export default function IslandExtras() {
return (
<section class="bg-base relative z-20 py-16" id="island-extras">
<div class="px-4 md:px-8">
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
And everything else
</p>
<h2 class="text-text mb-3 text-center text-3xl font-bold">
One island instead of a menu bar full of utilities
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed">
Every module below lives in the same notch, arranged however you
like, under the same one-time license.
</p>
</div>
<div class="mb-12 space-y-3">
<MarqueeRow chips={CHIPS_A} />
<MarqueeRow chips={CHIPS_B} reverse />
</div>
<div class="mx-auto grid max-w-5xl grid-cols-1 gap-5 px-4 md:grid-cols-3 md:px-8">
<CollageCell
class="md:col-span-2"
kicker="Now Playing"
title="Music in the notch"
body="Track, source, and transport controls in a module. Sources without artwork, like some browsers, get a live waveform in the slot instead."
>
<InkStage class="h-full w-full items-center">
<NowPlayingMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Timers"
title="Timers that stack"
body="A readout over a tick tape that drains with a soft crest. Run several at once; finished timers ring and hold the island open until you dismiss them."
>
<InkStage class="h-full w-full items-center px-3">
<TimersMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Weather"
title="The sky, in the slot"
body="Saved places with hourly and daily forecasts. The compact slot stacks the sky over the temperature."
>
<InkStage class="h-full w-full items-center px-3">
<WeatherMock />
</InkStage>
</CollageCell>
<CollageCell
class="md:col-span-2"
kicker="Interceptions"
title="Volume and brightness, under the notch"
body="An optional takeover of the system HUD: levels peek out below the island. Extra brightness spends an XDR panel's HDR headroom past the usual maximum, and external displays get real brightness control."
>
<InkStage class="h-full w-full items-center">
<div class="flex min-h-[110px] w-full justify-center pt-2">
<InterceptionMock />
</div>
</InkStage>
</CollageCell>
<CollageCell
kicker="Approval modes"
title="One stance for every agent"
body="Auto follows each agent's own settings, Yolo waves gated tools through, and Verify holds every one until you decide."
>
<InkStage class="h-full w-full items-center px-3">
<ApprovalModesMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Network"
title="Know your connection"
body="Public IP, ISP, gateway, and DNS one click from your clipboard. VPN routes get flagged, and a speed test is always a click away."
>
<InkStage class="h-full w-full items-center px-3">
<NetworkMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Disks"
title="Storage, measured"
body="Live read and write throughput, capacity that matches Finder, and SMART health for your drives."
>
<InkStage class="h-full w-full items-center px-3">
<DiskMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Keep Awake"
title="Sleep when you say"
body="Stay awake for a set time, while plugged in, with the lid closed, or only while your agents are working."
>
<InkStage class="h-full w-full items-center px-3">
<KeepAwakeMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Lock Screen"
title="Visible while locked"
body="The island stays on your Lock Screen, so you can see how many agents are working or waiting without unlocking."
>
<InkStage class="h-full w-full items-center px-3">
<LockScreenMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Layout"
title="Panels you shape"
body="Drag and resize modules in the mosaic editor, pick a density, and let slot rankings switch with your Focus mode."
>
<InkStage class="h-full w-full items-center px-3">
<LayoutMock />
</InkStage>
</CollageCell>
</div>
</section>
);
}

View File

@@ -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: {
</div>
);
}
/** 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 (
<span
class="pointer-events-none absolute z-10 rounded-full bg-white"
style={{
width: "9px",
height: "9px",
"box-shadow":
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
left: `${p().x}%`,
top: `${p().y}%`,
opacity: p().o,
transform: `translate(-50%, -50%) scale(${p().scale})`,
transition:
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
}}
/>
);
}

View File

@@ -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<string, () => JSX.Element> = {
speaker: () => (
<>
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
<path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.8 3.6a6 6 0 0 1 0 8.8" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
</>
),
speakerOff: () => (
<>
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
<path d="m11 6 4 4m0-4-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</>
),
mic: () => (
<>
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" />
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
</>
),
micOff: () => (
<>
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" opacity="0.9" />
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5M2.5 2.5l11 11" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
</>
),
pause: () => (
<>
<rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor" />
<rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor" />
</>
),
play: () => <path d="M5 3v10l8-5Z" fill="currentColor" />,
stop: () => <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />,
viewfinder: () => (
<path d="M2 5V3.5A1.5 1.5 0 0 1 3.5 2H5m6 0h1.5A1.5 1.5 0 0 1 14 3.5V5m0 6v1.5a1.5 1.5 0 0 1-1.5 1.5H11m-6 0H3.5A1.5 1.5 0 0 1 2 12.5V11m6-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
),
record: () => (
<>
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none" />
<circle cx="8" cy="8" r="3" fill="currentColor" />
</>
),
dashed: () => (
<rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="2 1.6" fill="none" />
),
display: () => (
<path d="M2.5 3h11a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3 11h5M8 11.5V14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
),
window: () => (
<>
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none" />
<path d="M1.5 6h13" stroke="currentColor" stroke-width="1.3" />
</>
),
chevron: () => <path d="m4.5 6.5 3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round" />,
film: () => (
<path d="M3 2.5h10a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5Zm2 0v11m6-11v11M2.5 6h2.5m6 0h2.5m-11 4h2.5m6 0h2.5" stroke="currentColor" stroke-width="1.2" fill="none" />
),
xmark: () => <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
bell: () => (
<path d="M8 1.8a4.2 4.2 0 0 0-4.2 4.2v3L2.5 11.3h11L12.2 9V6A4.2 4.2 0 0 0 8 1.8ZM6.3 12.8a1.8 1.8 0 0 0 3.4 0Z" fill="currentColor" />
),
restart: () => (
<path d="M3.5 8a4.5 4.5 0 1 0 1.4-3.3M3.2 2.2v2.8H6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
),
plus: () => <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
refresh: () => (
<path d="M12.5 8A4.5 4.5 0 1 1 11 4.6M11.6 2v2.8H8.8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
)
};
export type GlyphName = keyof typeof GLYPHS;
export function Glyph(props: { name: GlyphName; size?: number; class?: string; style?: JSX.CSSProperties }) {
return (
<svg
width={props.size ?? 12}
height={props.size ?? 12}
viewBox="0 0 16 16"
class={`shrink-0 ${props.class ?? ""}`}
style={props.style}
aria-hidden="true"
>
{GLYPHS[props.name]()}
</svg>
);
}
/* ── 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 (
<span
class="max-w-full truncate rounded-full px-[7px] py-[2px] text-[10px] font-semibold whitespace-nowrap"
style={{
color: props.tint ?? "rgba(255,255,255,0.75)",
background: props.tint ? `${props.tint}33` : "rgba(255,255,255,0.08)"
}}
>
{props.text}
</span>
);
}
/**
* 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 (
<div
class="@container rounded-xl px-2 py-2.5 text-left"
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
{/* The app's layout (badges in a right-hand third) wherever the row
is wide enough; narrow site cells stack the badges beneath. */}
<div class="flex flex-col gap-1.5 @[340px]:flex-row @[340px]:items-start @[340px]:gap-2.5">
<div class="min-w-0 flex-1 space-y-[3px]">
<p class="truncate text-[13px] font-semibold text-white">{props.title}</p>
<p class="truncate text-[11.5px] text-white/55">User: {props.prompt}</p>
<p
class="truncate text-[11.5px] font-medium transition-colors duration-500"
style={{ color: STATUS[props.status] }}
>
{props.state}
</p>
</div>
<div class="flex min-w-0 shrink-0 items-start gap-1.5 @[340px]:max-w-[50%]">
<div class="flex min-w-0 flex-col items-center">
<Badge
text={props.host ? `${props.agent.name} · ${props.host}` : props.agent.name}
tint={props.agent.color}
/>
</div>
<div class="flex items-center gap-1.5 @[340px]:flex-col @[340px]:items-end @[340px]:gap-0.5">
<Show when={props.origin}>
<Badge text={props.origin!} />
</Show>
<span class="flex items-center gap-1.5">
<span
class="h-[7px] w-[7px] rounded-full transition-colors duration-500"
style={{ background: STATUS[props.status] }}
/>
<span class="rounded-full bg-white/[0.08] px-[7px] py-[2px] text-[10px] text-white/80">
{props.age}
</span>
</span>
</div>
</div>
</div>
</div>
);
}
/** SwiftUI's dark-mode .yellow, which the approval card tints with. */
const YELLOW = "255,214,10";
/**
* ApprovalCard: brand mark, session title, "approval" capsule; "Run <tool>",
* 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) => (
<span
class="rounded-md px-2 py-[3px] text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
style={{
background: prominent ? ACCENT : "rgba(255,255,255,0.12)",
transform: props.pressed === key ? "scale(0.92)" : "scale(1)",
filter: props.pressed === key ? "brightness(1.25)" : "none"
}}
>
{label}
</span>
);
return (
<div class="rounded-[10px] p-2.5 text-left" style={{ background: `rgba(${YELLOW},0.08)` }}>
<div class="flex items-center gap-1.5">
<img src={props.agent.icon} alt="" class="h-[13px] w-[13px]" />
<span class="truncate text-[12px] font-semibold text-white">{props.title}</span>
<span
class="ml-auto shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: `rgba(${YELLOW},0.25)` }}
>
approval
</span>
</div>
<p class="mt-2 text-[13px] font-medium text-white">Run {props.tool}</p>
<p class="mt-0.5 truncate font-mono text-[11px] text-white/50">{props.input}</p>
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{button("allow", "Allow", true)}
{button("session", "Allow for session")}
{button("deny", "Deny")}
<span class="text-[11px] whitespace-nowrap text-white/50">Deny with reason…</span>
</div>
</div>
);
}
/* ── 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 (
<svg
viewBox={`0 0 ${props.width} ${props.height}`}
width={props.width}
height={props.height}
class="block max-w-full"
style={{ opacity: props.opacity ?? 1 }}
aria-hidden="true"
>
<For each={tapeTicks(Math.min(1, Math.max(0, props.remaining)), props.width, props.height)}>
{(t) => (
<>
<Show when={t.fill < 1}>
<rect x={t.x} y={t.y} width="1.5" height={t.height} rx="0.75" fill="rgba(255,255,255,0.18)" />
</Show>
<Show when={t.fill > 0}>
<rect
x={t.x}
y={t.y}
width="1.5"
height={t.height}
rx="0.75"
fill={props.color ?? TIMER_ORANGE}
opacity={t.fill}
/>
</Show>
</>
)}
</For>
</svg>
);
}
/* ── 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 (
<div class="flex items-center gap-2">
<span class="text-[9px] font-semibold text-white/50">{props.label}</span>
<svg viewBox="0 0 100 26" preserveAspectRatio="none" class="h-[26px] min-w-0 flex-1" aria-hidden="true">
<path d="M0,25.5 L100,25.5" stroke="rgba(255,255,255,0.08)" stroke-width="1" vector-effect="non-scaling-stroke" />
<path d={`${path()} L100,26 L0,26 Z`} fill={color()} fill-opacity="0.2" />
<path d={path()} fill="none" stroke={color()} stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
<span
class="font-mono text-[15px] font-medium transition-colors duration-500"
style={{ color: tierColor(props.celsius) }}
>
{Math.round(props.celsius)}°
</span>
</div>
);
}
/* ── 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 (
<div
class="flex rounded-full"
style={{
height: `${props.height}px`,
background: "rgba(0,0,0,0.94)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.18)"
}}
>
<For each={props.controls}>
{(c) => (
<span
class="flex items-center justify-center rounded-full transition-all duration-200"
style={{
width: `${props.height}px`,
height: `${props.height}px`,
color: `rgba(255,255,255,${c.on === false ? 0.45 : 1})`,
background: c.pressed ? "rgba(255,255,255,0.14)" : "transparent"
}}
>
<Glyph name={c.glyph} size={Math.round(props.height * 0.42)} />
</span>
)}
</For>
</div>
);
}

View File

@@ -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 (
<div class="mx-auto flex w-full max-w-[340px] flex-col gap-3">
<ModuleCard class="relative">
<div class="space-y-2 text-left">
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full transition-colors duration-500"
style={{
background: delivered() ? STATUS.running : STATUS.ready
}}
/>
<span class="text-[13px] font-semibold text-white">the-nook</span>
<span class="font-mono text-[10px] text-white/40">main</span>
<span
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white/80 transition-colors duration-300"
style={{
background:
phase() === "jump" ? ACCENT : "rgba(255,255,255,0.08)"
}}
>
Jump ↗
</span>
</div>
<p class="text-[11px] leading-snug text-white/60">
{delivered()
? "Running the test suite…"
: "Done. Want me to update the tests too?"}
</p>
<div
class="flex items-center gap-2 rounded-lg px-2 py-1.5"
style={{ background: "rgba(255,255,255,0.06)" }}
>
<span class="min-h-[14px] flex-1 truncate font-mono text-[11px] text-white/85">
{delivered() ? "" : REPLY.slice(0, typed())}
<Show when={phase() === "typing"}>
<span class="nook-caret">▍</span>
</Show>
<Show when={!typed() || delivered()}>
<span class="text-white/30">Reply…</span>
</Show>
</span>
<span
class="rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-transform duration-200"
style={{
background: ACCENT,
transform: phase() === "sent" ? "scale(0.9)" : "scale(1)"
}}
>
Send
</span>
</div>
</div>
<FakeCursor phase={cursorPhase()} at={cursorAt()} />
</ModuleCard>
{/* the terminal the session actually runs in */}
<div
class="overflow-hidden rounded-lg text-left transition-shadow duration-500"
style={{
background: "#16161a",
"box-shadow":
phase() === "jump"
? `0 0 0 2px ${ACCENT}, 0 8px 30px rgba(72,151,178,0.35)`
: `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
<div class="flex items-center gap-1.5 px-2.5 py-1.5">
<span class="h-2 w-2 rounded-full bg-[#ff5f57]" />
<span class="h-2 w-2 rounded-full bg-[#febc2e]" />
<span class="h-2 w-2 rounded-full bg-[#28c840]" />
<span class="ml-2 font-mono text-[10px] text-white/40">
tmux · the-nook:2
</span>
<span
class="ml-auto text-[9px] font-semibold tracking-wide uppercase transition-opacity duration-300"
style={{ color: ACCENT, opacity: phase() === "jump" ? 1 : 0 }}
>
focused
</span>
</div>
<div class="space-y-0.5 px-2.5 pb-2.5 font-mono text-[10.5px] leading-relaxed">
<p class="text-white/40">⏺ Updated 3 files</p>
<p class="text-white/70">
&gt;{" "}
<span
class="transition-opacity duration-300"
style={{ opacity: delivered() ? 1 : 0 }}
>
{REPLY}
</span>
</p>
<p
class="transition-opacity duration-500"
style={{ color: CALM, opacity: delivered() ? 1 : 0 }}
>
⏺ Bash(swift test)
</p>
</div>
</div>
</div>
);
}
/* ── 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 (
<ModuleCard class="mx-auto w-full max-w-[340px]">
<div class="space-y-2.5 text-left">
<div class="flex items-center">
<span class="text-[11px] font-semibold text-white/50">
Volume mixer
</span>
<span class="ml-auto font-mono text-[10px] text-white/40">
out · Speakers
</span>
</div>
<For each={MIXER_APPS}>
{(app, i) => (
<div class="flex items-center gap-2">
<span
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px] text-[8px] font-bold text-black/70"
style={{ background: app.color }}
>
{app.glyph}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-[11px] font-semibold text-white/85">
{app.name}
</span>
<span
class="ml-auto shrink-0 rounded px-1.5 text-[9px] text-white/55 transition-colors duration-500"
style={{
background:
i() === 0 && step() === 2
? `${ACCENT}55`
: "rgba(255,255,255,0.07)"
}}
>
{route(i())}
</span>
</div>
<div
class="relative mt-1 h-[5px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${level(i()) * 100}%`,
background: muted(i()) ? "rgba(255,255,255,0.18)" : CALM,
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), background 0.3s"
}}
/>
{/* live signal riding inside the level */}
<div
class="nook-meter absolute top-0 left-0 h-full rounded-full"
style={{
width: `${level(i()) * 100}%`,
background: "rgba(255,255,255,0.35)",
opacity: muted(i()) ? 0 : 1,
"animation-duration": `${0.5 + i() * 0.17}s`,
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), opacity 0.3s"
}}
/>
</div>
</div>
<span
class="w-4 shrink-0 text-center text-[11px] transition-colors duration-300"
style={{ color: muted(i()) ? CRITICAL : "rgba(255,255,255,0.4)" }}
>
{muted(i()) ? "✕" : "♪"}
</span>
</div>
)}
</For>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<p class="text-[10px] text-white/40">
{
[
"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()]
}
</p>
</div>
</ModuleCard>
);
}
/* ── 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 (
<span
class="flex flex-1 items-center justify-center gap-1 rounded-full py-1.5 text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
style={{
background: props.pressed ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.08)",
transform: props.pressed ? "scale(0.94)" : "scale(1)"
}}
>
<Glyph name={props.glyph} size={11} />
{props.label}
<Show when={props.menu}>
<Glyph name="chevron" size={8} class="opacity-50" />
</Show>
</span>
);
}
/** CaptureAudioToggles: paper capsule when on, faint when off. */
function AudioToggles(props: { system: boolean; mic: boolean }) {
const toggle = (on: boolean, glyph: GlyphName, label: string) => (
<span
class="flex items-center gap-1 rounded-full px-2 py-1 text-[10.5px] font-medium whitespace-nowrap transition-colors duration-300"
style={{
background: on ? PAPER : "rgba(255,255,255,0.08)",
color: on ? INK : "rgba(255,255,255,0.7)"
}}
>
<Glyph name={glyph} size={10} />
{label}
</span>
);
return (
<div class="flex gap-1.5">
{toggle(props.system, props.system ? "speaker" : "speakerOff", "System audio")}
{toggle(props.mic, props.mic ? "mic" : "micOff", "Microphone")}
</div>
);
}
function RowLabel(props: { glyph: GlyphName; label: string }) {
return (
<p class="flex items-center gap-1 text-[11px] font-semibold text-white/50">
<Glyph name={props.glyph} size={11} />
{props.label}
</p>
);
}
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 (
<div class="relative mx-auto w-full max-w-[360px]">
{/* the Compact island, flanked by the Wings while recording */}
<div class="relative h-[30px]">
<div
class="absolute top-0 left-1/2 flex items-start"
style={{ transform: "translateX(-50%) scale(0.5)", "transform-origin": "top center" }}
>
<div
class="flex justify-end overflow-hidden transition-all duration-500"
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
>
<WingCapsule
height={60}
controls={[
{ glyph: "speaker" },
{ glyph: mic() ? "mic" : "micOff", on: mic(), pressed: wingPress() === "mic" }
]}
/>
<span class="w-3 shrink-0" />
</div>
<IslandPill
campfire="running"
right={<AgentDotGrid states={["running", "ready", "running"]} />}
/>
<div
class="flex overflow-hidden transition-all duration-500"
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
>
<span class="w-3 shrink-0" />
<WingCapsule
height={60}
controls={[
{ glyph: paused() ? "play" : "pause", pressed: wingPress() === "pause" },
{ glyph: "stop", pressed: wingPress() === "stop" }
]}
/>
</div>
</div>
</div>
{/* the Capture module */}
<ModuleCard class="mt-5">
<div class="min-h-[150px] text-left">
<Show
when={recording()}
fallback={
<div class="space-y-2.5">
<div class="space-y-1.5">
<RowLabel glyph="viewfinder" label="Screenshot" />
<div class="flex gap-1.5">
<CaptureChip glyph="dashed" label="Area" />
<CaptureChip glyph="display" label="Screen" />
<CaptureChip glyph="window" label="App" menu />
</div>
</div>
<div class="space-y-1.5">
<RowLabel glyph="record" label="Record" />
<div class="flex gap-1.5">
<CaptureChip glyph="dashed" label="Area" />
<CaptureChip glyph="display" label="Screen" pressed={phase() === "press"} />
<CaptureChip glyph="window" label="App" menu />
</div>
<AudioToggles system mic={mic()} />
</div>
<Show when={phase() === "saved"}>
<div class="flex items-center gap-1.5 text-[10.5px]">
<Glyph name="film" size={11} class="text-white/50" />
<span class="min-w-0 flex-1 truncate text-white/85">
Screen Recording 2026-10-13 at 9.41.12.mov
</span>
<span class="shrink-0 text-white/50">Show in Finder</span>
</div>
</Show>
</div>
}
>
<div class="space-y-2.5">
<div class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full transition-colors"
style={{ background: paused() ? "rgba(255,255,255,0.4)" : RECORDING_RED }}
/>
<span
class="font-mono text-[22px] font-semibold transition-colors"
style={{ color: paused() ? "rgba(255,255,255,0.5)" : "white" }}
>
0:0{secs()}
</span>
<span class="ml-auto font-mono text-[10px] font-medium text-white/50">
{paused() ? "Paused" : "Recording"}
</span>
</div>
<div class="flex gap-2">
<CaptureChip glyph={paused() ? "play" : "pause"} label={paused() ? "Resume" : "Pause"} />
<CaptureChip glyph="stop" label="Stop" />
</div>
<AudioToggles system mic={mic()} />
</div>
</Show>
</div>
</ModuleCard>
{/* cursor plotted on a fixed 360 px frame centered over the demo */}
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[360px] -translate-x-1/2">
<FakeCursor phase={cursor().phase} at={cursor().at} />
</div>
</div>
);
}
/* ── Floating actions bloom around the open island ────────────────── */
/* Tiny monochrome glyphs for the action kinds the app offers. */
const GLYPH: Record<string, () => JSX.Element> = {
leaf: () => (
<path d="M3 13C3 7 7 3 13 3c0 6-4 10-10 10Zm0 0 6-6" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" />
),
bolt: () => <path d="M9 1 3 9h4l-1 6 6-8H8l1-6Z" fill="currentColor" />,
timer: () => (
<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="8" cy="9" r="5.5" />
<path d="M8 9V6M6.5 1.5h3" />
</g>
),
speaker: () => (
<g fill="currentColor">
<path d="M2 6h3l4-3v10l-4-3H2Z" />
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</g>
),
shell: () => (
<path d="m3 5 3 3-3 3m5 0h5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" />
),
stack: () => (
<g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="m8 2 6 3-6 3-6-3 6-3Z" />
<path d="m2 8.5 6 3 6-3M2 11.5l6 3 6-3" />
</g>
),
link: () => (
<path d="M7 9a3 3 0 0 0 4 0l2-2a3 3 0 0 0-4-4l-1 1m1 3a3 3 0 0 0-4 0l-2 2a3 3 0 0 0 4 4l1-1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
)
};
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<number[]>([]);
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 (
<div class="relative mx-auto flex h-[240px] w-full max-w-[340px] items-start justify-center pt-6">
{/* the expanded island */}
<div
class="relative z-10 rounded-b-[18px] bg-black transition-all duration-500"
style={{
width: open() ? "190px" : "120px",
height: open() ? "96px" : "26px",
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
<div
class="space-y-1.5 p-3 transition-opacity duration-300"
style={{ opacity: open() ? 1 : 0 }}
>
<div class="h-1.5 w-2/3 rounded bg-white/20" />
<div class="h-1.5 w-1/2 rounded bg-white/10" />
<div class="h-1.5 w-3/4 rounded" style={{ background: `${CALM}66` }} />
</div>
<For each={ACTIONS}>
{(a, i) => (
<span
class="absolute flex h-8 w-8 items-center justify-center rounded-full transition-all"
style={{
left: "50%",
top: "50%",
transform: open()
? `translate(calc(-50% + ${a.x}px), calc(-50% + ${a.y - 6}px)) scale(${pressed() === i() ? 0.82 : 1})`
: "translate(-50%, -50%) scale(0.2)",
opacity: open() ? 1 : 0,
background: lit().includes(i()) ? ACCENT : "#1c1c20",
color: lit().includes(i()) ? "white" : "rgba(255,255,255,0.7)",
"box-shadow": `inset 0 0 0 1px rgba(255,255,255,0.12), 0 4px 14px rgba(0,0,0,0.5)`,
"transition-duration": "520ms",
"transition-delay": open() ? `${i() * 45}ms` : `${(6 - i()) * 25}ms`,
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)"
}}
>
<svg width="15" height="15" viewBox="0 0 16 16">
{GLYPH[a.glyph]()}
</svg>
</span>
)}
</For>
</div>
{/* what the last press did */}
<div
class="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full px-3 py-1 text-[10px] font-semibold whitespace-nowrap text-white transition-all duration-300"
style={{
background: "rgba(72,151,178,0.22)",
opacity: lastLit() !== undefined && open() ? 1 : 0
}}
>
{lastLit() !== undefined ? ACTIONS[lastLit()].label : " "}
</div>
</div>
);
}

View File

@@ -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<Response> {
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 (
<div class="flex items-end justify-center gap-5">
{/* iPhone */}
<div
class="relative h-[300px] w-[150px] overflow-hidden rounded-[28px] p-2"
style={{
background:
"radial-gradient(120% 80% at 30% 0%, #2b4a6b 0%, #191b2e 60%, #0d0d0f 100%)",
"box-shadow":
"inset 0 0 0 1.5px rgba(255,255,255,0.18), 0 20px 50px rgba(0,0,0,0.5)"
}}
>
<div class="mx-auto mt-1 h-[16px] w-[52px] rounded-full bg-black" />
<p class="mt-3 text-center text-[30px] leading-none font-semibold text-white/90">
9:41
</p>
{/* Lock Screen widget: what the numbers count */}
<div class="mt-2 flex justify-center gap-1">
<span
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<span class="h-1.5 w-1.5 rounded-[1px]" style={{ background: STATUS.running }} />
{working()} working
</span>
<span
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<span
class="h-1.5 w-1.5 rounded-[1px]"
style={{ background: waiting() ? STATUS.attention : STATUS.idle }}
/>
{waiting()} waiting
</span>
</div>
{/* notification */}
<div
class="absolute inset-x-2 top-[118px] rounded-2xl p-2 text-left transition-all duration-500"
style={{
background: "rgba(40,40,46,0.88)",
"backdrop-filter": "blur(8px)",
opacity: askVisible() ? 1 : 0,
transform: askVisible() ? "translateY(0)" : "translateY(-14px)"
}}
>
<div class="flex items-center gap-1">
<img src="/nook/icon.png" alt="" class="h-3 w-3 rounded-[3px]" />
<span class="text-[7.5px] font-semibold tracking-wide text-white/50 uppercase">
The Nook · now
</span>
</div>
<p class="mt-1 text-[9.5px] font-semibold text-white">
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
</p>
<p class="text-[9px] leading-snug text-white/60">
{allowed() ? "Back to work." : "Run Bash: swift test"}
</p>
<Show when={!allowed()}>
<div class="mt-1.5 flex gap-1">
<span
class="flex-1 rounded-md py-0.5 text-center text-[8.5px] font-semibold text-white"
style={{ background: ACCENT }}
>
Allow
</span>
<span class="flex-1 rounded-md bg-white/10 py-0.5 text-center text-[8.5px] font-semibold text-white/80">
Deny
</span>
</div>
</Show>
</div>
</div>
{/* Apple Watch */}
<div class="flex flex-col items-center">
<div class="h-3 w-[64px] rounded-t-md bg-white/10" />
<div
class="relative h-[118px] w-[98px] overflow-hidden rounded-[26px] bg-black p-2.5"
style={{
"box-shadow":
"inset 0 0 0 2px rgba(255,255,255,0.16), 0 14px 34px rgba(0,0,0,0.5)",
transform: phase() === "question" ? "rotate(-2deg)" : "rotate(0deg)",
transition: "transform 0.15s"
}}
>
<Show
when={phase() === "question" || phase() === "answered"}
fallback={
<div class="flex h-full flex-col items-center justify-center">
<span class="font-mono text-[18px] font-semibold text-white/90">
{working()}
</span>
<span class="text-[8px] text-white/50">agents working</span>
</div>
}
>
<p class="text-[8px] font-semibold text-white/50">Codex asks</p>
<p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white">
Ship it to staging?
</p>
<div class="mt-2 space-y-1">
<For each={["Yes", "Not yet"]}>
{(option, i) => (
<span
class="block rounded-full py-1 text-center text-[8.5px] font-semibold transition-colors duration-300"
style={{
background:
phase() === "answered" && i() === 0
? STATUS.ready
: "rgba(255,255,255,0.12)",
color: "white"
}}
>
{phase() === "answered" && i() === 0 ? "Sent ✓" : option}
</span>
)}
</For>
</div>
</Show>
</div>
<div class="h-3 w-[64px] rounded-b-md bg-white/10" />
</div>
</div>
);
}
export default function PhoneBeta(props: { brandColor: string }) {
const [email, setEmail] = createSignal("");
const [state, setState] = createSignal<RequestState>("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 (
<section class="bg-base relative z-20 px-4 py-20 md:px-8" id="iphone">
<div class="mx-auto grid max-w-5xl grid-cols-1 items-center gap-12 md:grid-cols-2">
<div>
<span class="border-overlay0 bg-surface0 text-subtext0 mb-4 inline-block rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
TestFlight beta
</span>
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight">
Your fleet, in your pocket
</h2>
<p class="text-subtext0 mb-5 leading-relaxed">
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.
</p>
<ul class="mb-8 space-y-2.5">
<For
each={[
"Allow, deny, answer, and reply from iPhone or Apple Watch",
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN",
"Home Screen, Lock Screen, and watch-face widgets for fleet status",
"Mute notifications for a while, or while you're active at the Mac"
]}
>
{(bullet) => (
<li class="text-subtext1 flex gap-2.5 text-sm">
<span
class="mt-[7px] inline-block h-1.5 w-1.5 shrink-0 rounded-[2px]"
style={{ background: ACCENT }}
/>
{bullet}
</li>
)}
</For>
</ul>
<div class="border-overlay0 bg-surface0 rounded-xl border p-6">
<h3 class="text-text mb-1 text-lg font-bold">Request beta access</h3>
<p class="text-subtext0 mb-4 text-sm">
Leave your email to get a TestFlight invite. We only receive your
email address.
</p>
<form
class="flex flex-col gap-3 sm:flex-row sm:items-end"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<Input
type="email"
required
label="Email"
containerClass="flex-1"
disabled={state() === "sending"}
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
<Button
type="submit"
variant="primary"
color={props.brandColor}
loading={state() === "sending"}
>
Request access
</Button>
</form>
<Show when={state() === "sent"}>
<p class="text-subtext1 mt-4 text-sm">
Thanks. Watch your inbox for a TestFlight invite.
</p>
</Show>
<Show when={state() === "limited"}>
<p class="mt-4 text-sm text-red-500">
Too many requests from this network. Try again later.
</p>
</Show>
<Show when={state() === "failed"}>
<p class="mt-4 text-sm text-red-500">
Couldn't send the request. Check your connection and try again.
</p>
</Show>
</div>
</div>
<div
class="rounded-2xl px-6 py-10"
style={{
background:
"radial-gradient(70% 90% at 50% 0%, rgba(72,151,178,0.12) 0%, transparent 70%) #0d0d0f"
}}
>
<CompanionDemo />
</div>
</div>
</section>
);
}

View File

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

View File

@@ -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<typeof setTimeout>[] = [];
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);
});
});
}

View File

@@ -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<string, string> = {}) {
@@ -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("<b>Reply-to:</b> tester@example.com");
expect(email.html).not.toContain("<h3>Displays</h3>");
});
});

View File

@@ -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() {
</div>
</section>
{/* ── Everything else: marquee + live module bento ───────────── */}
<IslandExtras />
{/* ── iPhone + Watch companion, TestFlight beta ──────────────── */}
<PhoneBeta brandColor={brandColor()} />
{/* ── CTA ──────────────────────────────────────────────────────── */}
<section class="bg-surface0 relative z-20 px-4 py-20 md:px-8">
<div class="mx-auto max-w-4xl text-center">
@@ -156,10 +164,10 @@ export default function NookLanding() {
Try it free for 14 days
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
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.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button