Merge branch 'NOOK-334' — NOOK-334 landing page feature set
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