forked from Mike/freno-dev
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:
111
src/app.css
111
src/app.css
@@ -755,3 +755,114 @@ a.hover-underline-animation:hover::after {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Nook landing: newer demos ─────────────────────────────────── */
|
||||
.nook-caret {
|
||||
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
/* Live signal inside a mixer fader */
|
||||
.nook-meter {
|
||||
transform-origin: left center;
|
||||
animation: nook-meter 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-meter {
|
||||
0% { transform: scaleX(0.35); }
|
||||
40% { transform: scaleX(0.8); }
|
||||
100% { transform: scaleX(0.55); }
|
||||
}
|
||||
|
||||
/* Waveform slot bars */
|
||||
.nook-wave {
|
||||
height: 100%;
|
||||
transform-origin: center;
|
||||
animation: nook-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-wave {
|
||||
0% { transform: scaleY(0.25); }
|
||||
100% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* Now Playing progress and artwork sheen */
|
||||
.nook-progress {
|
||||
animation: nook-progress 14s linear infinite;
|
||||
}
|
||||
@keyframes nook-progress {
|
||||
from { width: 8%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
.nook-art {
|
||||
animation: nook-art 9s linear infinite;
|
||||
}
|
||||
@keyframes nook-art {
|
||||
to { filter: hue-rotate(360deg); }
|
||||
}
|
||||
|
||||
/* A ringing timer's tape breathes between 35 % and full */
|
||||
.nook-tape-ring {
|
||||
animation: nook-tape-ring 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-tape-ring {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Keep Awake coffee steam */
|
||||
.nook-steam path {
|
||||
animation: nook-steam 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nook-steam {
|
||||
0% { transform: translateY(2px); opacity: 0; }
|
||||
40% { opacity: 1; }
|
||||
100% { transform: translateY(-4px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Two-way feature marquee; edges fade, hover pauses */
|
||||
.nook-marquee {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||
}
|
||||
.nook-marquee-track {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
width: max-content;
|
||||
padding-right: 0.625rem;
|
||||
animation: nook-marquee 60s linear infinite;
|
||||
}
|
||||
.nook-marquee-track.reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
.nook-marquee:hover .nook-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes nook-marquee {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nook-meter,
|
||||
.nook-wave,
|
||||
.nook-progress,
|
||||
.nook-art,
|
||||
.nook-tape-ring,
|
||||
.nook-steam path,
|
||||
.nook-caret {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.nook-marquee {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.nook-marquee-track {
|
||||
animation: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nook-marquee-track > [aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="space-y-1.5">
|
||||
<p class="px-1 text-[9px] font-semibold tracking-wide text-white/40 uppercase">
|
||||
The Nook
|
||||
</p>
|
||||
<div class="p-2.5">
|
||||
<div
|
||||
class="rounded-lg p-2"
|
||||
style={{
|
||||
background: allowed()
|
||||
? "rgba(111,185,130,0.14)"
|
||||
: "rgba(231,167,98,0.14)"
|
||||
}}
|
||||
class="grid transition-all duration-500"
|
||||
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<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="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
||||
<p class="truncate font-mono text-[10px] text-white/40">
|
||||
mike@192.168.64.3 · linux
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-300"
|
||||
style={{
|
||||
background: step() === 0 ? ACCENT : "rgba(255,255,255,0.08)",
|
||||
opacity: step() >= 4 ? 0 : 1,
|
||||
transform: step() === 1 ? "scale(0.94)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
{step() === 0 ? "Install hook" : "installing…"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<div class="relative" style={{ "min-height": "86px" }}>
|
||||
<div
|
||||
class="space-y-1.5 transition-opacity duration-500"
|
||||
style={{ opacity: step() >= 4 ? 0 : 1 }}
|
||||
>
|
||||
{step() === 0 && (
|
||||
<p class="pt-2 text-center text-[10px] text-white/35">
|
||||
one click: copy → setup → probe
|
||||
</p>
|
||||
)}
|
||||
<For each={SSH_LINES}>
|
||||
{(l) => (
|
||||
<div
|
||||
class="flex items-center gap-1.5 transition-all duration-500"
|
||||
style={{
|
||||
opacity: step() >= l.at ? 1 : 0,
|
||||
transform:
|
||||
step() >= l.at ? "translateY(0)" : "translateY(4px)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="font-mono text-[10px]"
|
||||
style={{
|
||||
color:
|
||||
step() > l.at
|
||||
? "rgba(150,220,150,0.9)"
|
||||
: "rgba(255,255,255,0.45)"
|
||||
}}
|
||||
>
|
||||
{step() > l.at ? "✓" : "…"}
|
||||
</span>
|
||||
<span class="truncate font-mono text-[10px] text-white/70">
|
||||
{l.text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col justify-center transition-all duration-500"
|
||||
style={{
|
||||
opacity: step() >= 4 ? 1 : 0,
|
||||
transform: step() >= 4 ? "translateY(0)" : "translateY(6px)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="rounded-[10px] p-2.5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.055)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||
style={{ background: STATUS.running }}
|
||||
/>
|
||||
<span class="text-[13px] font-semibold text-white">
|
||||
gpu-rig
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
background: "rgba(110,167,255,0.22)",
|
||||
color: "#9cc3ff"
|
||||
}}
|
||||
>
|
||||
remote
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">
|
||||
You: benchmark fp16
|
||||
</p>
|
||||
<div 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 text-[11px] text-white/45">tailnet peer</p>
|
||||
</div>
|
||||
<p class="pt-1.5 text-center text-[10px] text-white/40">
|
||||
agents join the same dot grid
|
||||
</p>
|
||||
<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="h-[7px] w-[7px] rounded-full transition-colors duration-300"
|
||||
style={{ background: chip().color }}
|
||||
/>
|
||||
{chip().label}
|
||||
</span>
|
||||
<Show
|
||||
when={live()}
|
||||
fallback={
|
||||
<span
|
||||
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
|
||||
style={{
|
||||
background: ACCENT,
|
||||
opacity: working() ? 0 : 1,
|
||||
transform: pressed() ? "scale(0.92)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
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="h-4 text-[11px] text-white/50">
|
||||
{working() ? SETUP_STEPS[step()] : " "}
|
||||
</p>
|
||||
</div>
|
||||
</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={cursorPhase()} at={{ x: 90, y: 14 }} />
|
||||
</ModuleCard>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
<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 }}
|
||||
/>
|
||||
<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>
|
||||
<SessionRowMock
|
||||
title="api · main"
|
||||
prompt="fix the flaky test"
|
||||
state="Running pytest -x…"
|
||||
status="running"
|
||||
agent={CLAUDE}
|
||||
host="build-box"
|
||||
age="4m"
|
||||
/>
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Done · replied 1m ago"
|
||||
status="ready"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="12m"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
<ModuleCard>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
900
src/components/nook/IslandExtras.tsx
Normal file
900
src/components/nook/IslandExtras.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
405
src/components/nook/ModuleMocks.tsx
Normal file
405
src/components/nook/ModuleMocks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
617
src/components/nook/MoreDemos.tsx
Normal file
617
src/components/nook/MoreDemos.tsx
Normal 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">
|
||||
>{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
286
src/components/nook/PhoneBeta.tsx
Normal file
286
src/components/nook/PhoneBeta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/nook/betaRequest.ts
Normal file
12
src/components/nook/betaRequest.ts
Normal 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
|
||||
};
|
||||
}
|
||||
28
src/components/nook/useDemoLoop.ts
Normal file
28
src/components/nook/useDemoLoop.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user