midway
This commit is contained in:
148
src/app.css
148
src/app.css
@@ -574,3 +574,151 @@ a.hover-underline-animation:hover::after {
|
|||||||
.shaker:hover {
|
.shaker:hover {
|
||||||
animation: shaker 0.5s ease;
|
animation: shaker 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: campfire sprite frame cycling ──────────────────── */
|
||||||
|
/* Each frame layer is stacked; its animation holds opacity 1 during its
|
||||||
|
slot and 0 otherwise, so the stack reads as stop-motion at the app's
|
||||||
|
sprite cadence (frame time = total duration / frame count). The phase
|
||||||
|
lives inside the keyframes and every layer animates delay-free from the
|
||||||
|
same clock WebKit quantizes animation starts per cycle wrap, so
|
||||||
|
delay-offset layers can drop a wrap frame and show BOTH transparent
|
||||||
|
for a frame (the "blank frame" flicker). Complementary holds mean any
|
||||||
|
moment sums to exactly one opaque layer. Linear timing: steps()
|
||||||
|
holds mis-swap at boundary frames under WebKit. */
|
||||||
|
/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */
|
||||||
|
@keyframes campfire-slot-2-a {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes campfire-slot-2-b {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6-frame sprites (question): six slot phases. */
|
||||||
|
@keyframes campfire-slot-6-a {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-b {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-c {
|
||||||
|
0%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-d {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-e {
|
||||||
|
0%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-f {
|
||||||
|
0%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s)
|
||||||
|
linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: breakdown section reveal ───────────────────────── */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame + .campfire-frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
367
src/components/nook/Campfire.tsx
Normal file
367
src/components/nook/Campfire.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { For, type JSX } from "solid-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel-art campfire sprite, faithful to the app's CampfireFrames: a 14×14
|
||||||
|
* grid where the log base never moves and only the flame and sparks animate.
|
||||||
|
* `state` picks the frame set and cadence — idle simmers, running dances,
|
||||||
|
* ready exhales, error pulses dead embers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIRE_PALETTE: Record<string, string> = {
|
||||||
|
o: "#e7873a",
|
||||||
|
y: "#ffce70",
|
||||||
|
w: "#ffffff",
|
||||||
|
b: "#8b5a2b",
|
||||||
|
d: "#5a3a1b",
|
||||||
|
s: "#ffd166",
|
||||||
|
r: "#c43a30",
|
||||||
|
R: "#f25240",
|
||||||
|
x: "#ff7864",
|
||||||
|
m: "rgba(255,255,255,0.5)",
|
||||||
|
k: "rgba(255,255,255,0.26)",
|
||||||
|
e: "#a8582a",
|
||||||
|
E: "#d87636",
|
||||||
|
B: "#4890fc",
|
||||||
|
L: "#92c7ff",
|
||||||
|
C: "#e9f8ff",
|
||||||
|
S: "#bfe0ff"
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CampfireFrame {
|
||||||
|
rows: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....oyo......",
|
||||||
|
"....oyyyo.....",
|
||||||
|
"..dddeeeeeEd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".........k....",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"......oyo.....",
|
||||||
|
"....oyyyo.....",
|
||||||
|
"..dddEEEEEEd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const RUN_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....s.yy.....",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oywwyo....",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"....oywwyo....",
|
||||||
|
"..dddddddddd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....s........",
|
||||||
|
".....yy.......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oywwyo....",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"....oywwyo....",
|
||||||
|
"..dddddddddd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const READY_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"......yy......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"..dddyyyyyyd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"........m.....",
|
||||||
|
".....yy.......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"..dddyyyyyyd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* App CampfireFrames.ques0-5: cool blue flame, tip dips, sparks spit. */
|
||||||
|
const QUES_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....S.LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....S........",
|
||||||
|
".....LL.......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....S........",
|
||||||
|
"......LL......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
".....S........",
|
||||||
|
"..............",
|
||||||
|
"......LL......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
".....S........",
|
||||||
|
"..............",
|
||||||
|
".......LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"........S.....",
|
||||||
|
"..............",
|
||||||
|
".......LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const ERROR_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..dddrrrrrrd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"........x.....",
|
||||||
|
"..............",
|
||||||
|
".....x........",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..dddRRRRRRd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CampfireState = "idle" | "running" | "ready" | "question" | "error";
|
||||||
|
|
||||||
|
const STATE_FRAMES: Record<CampfireState, CampfireFrame[]> = {
|
||||||
|
idle: IDLE_FRAMES,
|
||||||
|
running: RUN_FRAMES,
|
||||||
|
ready: READY_FRAMES,
|
||||||
|
question: QUES_FRAMES,
|
||||||
|
error: ERROR_FRAMES
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_FPS: Record<CampfireState, number> = {
|
||||||
|
idle: 2,
|
||||||
|
running: 9,
|
||||||
|
ready: 4,
|
||||||
|
question: 4.5,
|
||||||
|
error: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Campfire(props: { state: CampfireState; pixel?: number }) {
|
||||||
|
const pixel = () => props.pixel ?? 3;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="animate-campfire grid"
|
||||||
|
style={{
|
||||||
|
"grid-template-columns": `repeat(14, ${pixel()}px)`,
|
||||||
|
/* One full cycle: every frame exactly one frame-interval long. */
|
||||||
|
"--campfire-duration": `${(1000 * STATE_FRAMES[props.state].length) / STATE_FPS[props.state] / 1000}s`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Slot phase lives in the keyframes per layer (campfire-slot-N-x);
|
||||||
|
delay-offset layers blank a wrap frame under WebKit. */}
|
||||||
|
<For each={STATE_FRAMES[props.state]}>
|
||||||
|
{(frame, i) => {
|
||||||
|
const n = STATE_FRAMES[props.state].length;
|
||||||
|
const phase = String.fromCharCode(97 + (i() % 26));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="campfire-frame col-span-full row-start-1"
|
||||||
|
style={{ "--slot-kf": `campfire-slot-${n}-${phase}` }}
|
||||||
|
>
|
||||||
|
<For each={frame.rows}>
|
||||||
|
{(row) => (
|
||||||
|
<div class="flex" style={{ height: `${pixel()}px` }}>
|
||||||
|
<For each={row.split("")}>
|
||||||
|
{(ch) => (
|
||||||
|
<span
|
||||||
|
class="inline-block"
|
||||||
|
style={{
|
||||||
|
width: `${pixel()}px`,
|
||||||
|
height: `${pixel()}px`,
|
||||||
|
background:
|
||||||
|
ch === "." ? "transparent" : FIRE_PALETTE[ch]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
522
src/components/nook/FeatureBreakdowns.tsx
Normal file
522
src/components/nook/FeatureBreakdowns.tsx
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||||
|
import {
|
||||||
|
IslandPill,
|
||||||
|
IslandExpanded,
|
||||||
|
AgentDotGrid,
|
||||||
|
STATUS,
|
||||||
|
CALM,
|
||||||
|
ACCENT,
|
||||||
|
CRITICAL,
|
||||||
|
ModuleCard
|
||||||
|
} from "./IslandMock";
|
||||||
|
import { Campfire, type CampfireState } from "./Campfire";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
||||||
|
* Each demo animates when scrolled into view (IntersectionObserver +
|
||||||
|
* motion's animate), staging the real interaction loop: attention
|
||||||
|
* flares, approval resolves, fans throttle, sessions stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
||||||
|
function useReveal() {
|
||||||
|
const [el, setEl] = createSignal<HTMLDivElement>();
|
||||||
|
onMount(() => {
|
||||||
|
const node = el();
|
||||||
|
if (!node) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
node.classList.add("in");
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.25 }
|
||||||
|
);
|
||||||
|
observer.observe(node);
|
||||||
|
onCleanup(() => observer.disconnect());
|
||||||
|
});
|
||||||
|
return [setEl, el] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownSection(props: {
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
bullets: string[];
|
||||||
|
reversed?: boolean;
|
||||||
|
children: JSX.Element;
|
||||||
|
}) {
|
||||||
|
const [ref] = useReveal();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
class="reveal grid grid-cols-1 items-center gap-10 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div class={props.reversed ? "md:order-2" : ""}>
|
||||||
|
<p class="text-subtext1 mb-2 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
{props.kicker}
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-3 text-2xl font-bold tracking-tight">
|
||||||
|
{props.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 mb-5 leading-relaxed">{props.body}</p>
|
||||||
|
<ul class="space-y-2.5">
|
||||||
|
<For each={props.bullets}>
|
||||||
|
{(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>
|
||||||
|
<div
|
||||||
|
class={`rounded-2xl p-6 md:p-8 ${props.reversed ? "md:order-1" : ""}`}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(70% 90% at 50% 0%, rgba(72,151,178,0.12) 0%, transparent 70%) #0d0d0f"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 1: the fleet loop — attention flares, then resolves ─────── */
|
||||||
|
|
||||||
|
function FleetDemo() {
|
||||||
|
const [phase, setPhase] = createSignal<"watch" | "flare" | "resolve">(
|
||||||
|
"watch"
|
||||||
|
);
|
||||||
|
onMount(() => {
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const loop = () => {
|
||||||
|
setPhase("watch");
|
||||||
|
timers.push(setTimeout(() => setPhase("flare"), 3200));
|
||||||
|
timers.push(setTimeout(() => setPhase("resolve"), 6400));
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
const interval = setInterval(loop, 9600);
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const states = () => {
|
||||||
|
switch (phase()) {
|
||||||
|
case "watch":
|
||||||
|
return ["running", "running", "ready", "running", "idle"] as const;
|
||||||
|
case "flare":
|
||||||
|
return ["running", "attention", "ready", "running", "idle"] as const;
|
||||||
|
case "resolve":
|
||||||
|
return ["running", "ready", "ready", "running", "idle"] as const;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<IslandPill
|
||||||
|
campfire={phase() === "flare" ? "question" : "running"}
|
||||||
|
right={<AgentDotGrid states={states().slice(0, 4) as never} />}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="rounded-xl px-4 py-3 text-center shadow-lg transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
background: phase() === "flare" ? "rgba(231,167,98,0.12)" : "rgba(255,255,255,0.055)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)",
|
||||||
|
transform: phase() === "flare" ? "translateY(0) scale(1)" : "translateY(6px) scale(0.98)",
|
||||||
|
opacity: phase() === "watch" ? 0.55 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p class="text-[13px] font-semibold text-white">
|
||||||
|
{phase() === "flare"
|
||||||
|
? "Codex needs your input"
|
||||||
|
: phase() === "resolve"
|
||||||
|
? "Answer delivered — agents back to work"
|
||||||
|
: "Fleet running calmly"}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-white/50">
|
||||||
|
{phase() === "flare" ? "question · in the island now" : "\u00a0"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 2: permission card resolves, phone push follows ─────────── */
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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" }}
|
||||||
|
/>
|
||||||
|
<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 */}
|
||||||
|
<div
|
||||||
|
class="w-[150px] rounded-2xl border border-white/15 p-2 text-left"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
transform: allowed() ? "translateY(-4px)" : "translateY(0)",
|
||||||
|
transition: "transform 0.5s ease"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="rounded-lg p-2"
|
||||||
|
style={{
|
||||||
|
background: allowed()
|
||||||
|
? "rgba(111,185,130,0.14)"
|
||||||
|
: "rgba(231,167,98,0.14)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-semibold text-white">
|
||||||
|
{allowed() ? "Allowed ✓" : "Claude Code wants to edit"}
|
||||||
|
</p>
|
||||||
|
<p class="text-[9px] text-white/50">
|
||||||
|
{allowed() ? "resumed" : "tap to Allow or Deny"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 3: fans throttle up under load, then back down ──────────── */
|
||||||
|
|
||||||
|
function FansDemo() {
|
||||||
|
const [rpm, setRpm] = createSignal(1270);
|
||||||
|
const [maxed, setMaxed] = createSignal(false);
|
||||||
|
onMount(() => {
|
||||||
|
let frame = 0;
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
/* Linear ramp toward `target` — close enough to the app's spring
|
||||||
|
at marketing-frame rates, with zero easing-library surface. */
|
||||||
|
const ramp = (target: number, durationMs: number) => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
const start = rpm();
|
||||||
|
const startTime = performance.now();
|
||||||
|
const step = (now: number) => {
|
||||||
|
const t = Math.min((now - startTime) / durationMs, 1);
|
||||||
|
setRpm(Math.round(start + (target - start) * t));
|
||||||
|
if (t < 1) frame = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
const loop = () => {
|
||||||
|
setMaxed(true);
|
||||||
|
ramp(5960, 1200);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
setMaxed(false);
|
||||||
|
ramp(1270, 1600);
|
||||||
|
}, 5200)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
const interval = setInterval(loop, 10400);
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fans = () => [
|
||||||
|
{ rpm: rpm(), frac: rpm() / 6000 },
|
||||||
|
{ rpm: Math.round(rpm() * 0.96), frac: (rpm() * 0.96) / 6000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModuleCard class="mx-auto w-full max-w-[320px]">
|
||||||
|
<div class="space-y-3 text-left">
|
||||||
|
<For each={fans()}>
|
||||||
|
{(f, i) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-[10px] font-medium text-white/50">
|
||||||
|
Fan {i() + 1}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="h-1 flex-1 overflow-hidden rounded-full"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(f.frac, 1) * 100}%`,
|
||||||
|
background: maxed() ? CRITICAL : CALM,
|
||||||
|
transition: "width 0.2s linear, background 0.4s"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-mono text-white"
|
||||||
|
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||||
|
>
|
||||||
|
{Math.round(f.rpm)}
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-md px-2 py-0.5 text-[11px] font-bold transition-colors"
|
||||||
|
style={{ color: maxed() ? CRITICAL : "rgba(255,255,255,0.7)" }}
|
||||||
|
>
|
||||||
|
{maxed() ? "MAX" : "Auto"}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-white/40">
|
||||||
|
{maxed() ? "cooling session active" : "curve in control"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 4: remote sessions streaming into the island ────────────── */
|
||||||
|
const REMOTE_TICKS: {
|
||||||
|
name: string;
|
||||||
|
via: string;
|
||||||
|
state: keyof typeof STATUS;
|
||||||
|
line: string;
|
||||||
|
}[] = [
|
||||||
|
{ name: "build-box", via: "ssh", state: "running", line: "You: fix flaky test" },
|
||||||
|
{ name: "gpu-rig", via: "tailscale", state: "running", line: "You: benchmark fp16" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function RemoteDemo() {
|
||||||
|
const [tick, setTick] = createSignal(0);
|
||||||
|
onMount(() => {
|
||||||
|
const interval = setInterval(
|
||||||
|
() => setTick((t) => (t + 1) % REMOTE_TICKS.length),
|
||||||
|
2600
|
||||||
|
);
|
||||||
|
onCleanup(() => clearInterval(interval));
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<IslandExpanded
|
||||||
|
activeCount={2}
|
||||||
|
tabs={[
|
||||||
|
{ icon: "🔥", label: "Agents", active: true },
|
||||||
|
{ icon: "♪", label: "Music" },
|
||||||
|
{ icon: "◌", label: "System" }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div class="space-y-2 text-left">
|
||||||
|
<For each={REMOTE_TICKS}>
|
||||||
|
{(host, i) => (
|
||||||
|
<div
|
||||||
|
class="rounded-[10px] p-2.5 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.055)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)",
|
||||||
|
opacity: i() === tick() ? 1 : 0.55,
|
||||||
|
transform: i() === tick() ? "scale(1.01)" : "scale(1)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block h-[7px] w-[7px] rounded-full"
|
||||||
|
style={{ background: STATUS[host.state] }}
|
||||||
|
/>
|
||||||
|
<span class="text-[13px] font-semibold text-white">
|
||||||
|
{host.name}
|
||||||
|
</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)" }}
|
||||||
|
>
|
||||||
|
{host.via}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-[11px] text-white/50">{host.line}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<p class="pt-1 text-center text-[10px] text-white/40">
|
||||||
|
same island, same glance — agents two networks away
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</IslandExpanded>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Demo 5: campfire states ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
function CampfireDemo() {
|
||||||
|
const states: CampfireState[] = [
|
||||||
|
"running",
|
||||||
|
"question",
|
||||||
|
"ready",
|
||||||
|
"idle",
|
||||||
|
"error"
|
||||||
|
];
|
||||||
|
const labels = [
|
||||||
|
"fleet running",
|
||||||
|
"pending question",
|
||||||
|
"ready to review",
|
||||||
|
"all quiet",
|
||||||
|
"something failed"
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||||
|
<For each={states}>
|
||||||
|
{(state, i) => (
|
||||||
|
<div class="flex flex-col items-center gap-2.5">
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-xl"
|
||||||
|
style={{ background: "rgba(255,255,255,0.055)" }}
|
||||||
|
>
|
||||||
|
<Campfire state={state} pixel={3} />
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-[11px] font-medium text-white/60">{labels[i()]}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeatureBreakdowns() {
|
||||||
|
return (
|
||||||
|
<div class="space-y-24">
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="The island"
|
||||||
|
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",
|
||||||
|
"Slot rankings — you choose which data earns the notch"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<FleetDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<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."
|
||||||
|
bullets={[
|
||||||
|
"Permission and question cards pin above the session list",
|
||||||
|
"Push-to-phone over your own network — no cloud middleman",
|
||||||
|
"Works across every hooked agent, local or remote"
|
||||||
|
]}
|
||||||
|
reversed
|
||||||
|
>
|
||||||
|
<ApprovalDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Cooling"
|
||||||
|
title="Fans that answer to you, not the scheduler"
|
||||||
|
body="Live RPM for every fan, a privileged helper for the curve. MAX before a compile, aggressive when the render queue spikes, auto when you walk away."
|
||||||
|
bullets={[
|
||||||
|
"MAX and COOL sessions surface right in the pill",
|
||||||
|
"CPU/GPU die temps tier-color before things get loud",
|
||||||
|
"All local: your hardware data never leaves the device"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<FansDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Remote"
|
||||||
|
title="SSH boxes join the same island"
|
||||||
|
body="Push the nook-hook to any Linux host — Tailscale or plain SSH with a reverse tunnel — and its agents appear beside your local ones, indistinguishable."
|
||||||
|
bullets={[
|
||||||
|
"One-line install bakes the bridge endpoint for you",
|
||||||
|
"Works for Claude Code, Codex, Gemini CLI and friends",
|
||||||
|
"Sessions from both machines share one dot grid"
|
||||||
|
]}
|
||||||
|
reversed
|
||||||
|
>
|
||||||
|
<RemoteDemo />
|
||||||
|
</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 />
|
||||||
|
</BreakdownSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
src/components/nook/FeatureCollage.tsx
Normal file
363
src/components/nook/FeatureCollage.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { For, type JSX } from "solid-js";
|
||||||
|
import {
|
||||||
|
IslandPill,
|
||||||
|
AgentDotGrid,
|
||||||
|
STATUS,
|
||||||
|
CALM,
|
||||||
|
CRITICAL,
|
||||||
|
ACCENT,
|
||||||
|
ModuleCard
|
||||||
|
} from "./IslandMock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||||
|
* shows the real collapsed pill (campfire slot, camera housing, live data)
|
||||||
|
* dressed in the island's black surface; supporting cells reuse the same
|
||||||
|
* module chrome, status palette, and metric language.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function CollageCell(props: {
|
||||||
|
class?: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`border-overlay1 bg-surface1 relative flex flex-col overflow-hidden rounded-2xl border p-6 ${props.class ?? ""}`}
|
||||||
|
>
|
||||||
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
{props.kicker}
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-2 text-lg font-bold tracking-tight">
|
||||||
|
{props.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 text-sm leading-relaxed">{props.body}</p>
|
||||||
|
<div class="mt-5 flex-1">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage for app-UI mocks: the island's black ink ground. The real island
|
||||||
|
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
||||||
|
* in both site themes.
|
||||||
|
*/
|
||||||
|
function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||||
|
return (
|
||||||
|
<div class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`} style={{ background: "#0d0d0f" }}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 = [
|
||||||
|
{ rpm: 1270, frac: 0.26 },
|
||||||
|
{ rpm: 1219, frac: 0.25 }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ModuleCard>
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<For each={fans}>
|
||||||
|
{(f) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="h-1 flex-1 overflow-hidden rounded-full"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${f.frac * 100}%`,
|
||||||
|
background: CALM
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-mono text-white"
|
||||||
|
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||||
|
>
|
||||||
|
{f.rpm}
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="rounded-md border border-white/15 px-2 py-0.5 text-[11px] text-white/70">
|
||||||
|
Auto
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-md px-2 py-0.5 text-[11px] font-bold"
|
||||||
|
style={{ color: CRITICAL }}
|
||||||
|
>
|
||||||
|
MAX
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote agents: two session rows like the expanded agents panel. */
|
||||||
|
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)" }}
|
||||||
|
>
|
||||||
|
tailscale
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System gauges: temp die blocks + memory, in module cards. */
|
||||||
|
function GaugesMock() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default function FeatureCollage() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
class="bg-base relative z-20 -mt-28 px-4 pb-16 pt-40 md:px-8"
|
||||||
|
id="feature-collage"
|
||||||
|
>
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||||
|
Why the Nook
|
||||||
|
</p>
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||||
|
One glance. Everything your agents are doing.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||||
|
{/* Hero: real collapsed pill on the island surface */}
|
||||||
|
<div
|
||||||
|
class="border-overlay1 bg-surface1 relative col-span-1 flex flex-col items-center overflow-hidden rounded-2xl border p-6 md:col-span-2"
|
||||||
|
>
|
||||||
|
<div class="mb-5 self-start">
|
||||||
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
The island
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||||
|
Every agent, one glance
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||||
|
A pill tucked into your notch — a campfire for your whole
|
||||||
|
fleet on the left, live data on the right. Blue running,
|
||||||
|
green ready, amber needs you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="my-10 flex w-full justify-center">
|
||||||
|
<IslandPill
|
||||||
|
campfire="running"
|
||||||
|
right={
|
||||||
|
<div class="flex flex-col items-center gap-1 px-1">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.running }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.ready }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.attention }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.idle }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||||
|
expand for the full panel ↓
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approvals */}
|
||||||
|
<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."
|
||||||
|
>
|
||||||
|
<InkStage>
|
||||||
|
<div class="w-full max-w-sm px-3">
|
||||||
|
<ApprovalCardMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Fans */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Cooling"
|
||||||
|
title="Silence it or max it"
|
||||||
|
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[260px] text-left">
|
||||||
|
<FansPanelMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Remote agents */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Remote"
|
||||||
|
title="Agents on other machines"
|
||||||
|
body="Run agents on Linux boxes and SSH servers — a one-line install pipes their sessions back to the same island, over Tailscale or a reverse tunnel."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[280px] text-left">
|
||||||
|
<RemoteMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Gauges */}
|
||||||
|
<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."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[300px] text-left">
|
||||||
|
<GaugesMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
src/components/nook/IslandMock.tsx
Normal file
258
src/components/nook/IslandMock.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { For, createMemo, onMount, onCleanup, type JSX } from "solid-js";
|
||||||
|
import { Campfire, type CampfireState } from "./Campfire";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faithful mock of the island's collapsed pill and expanded panel.
|
||||||
|
* Geometry follows the app's IslandSurfaceShape: the top edge spans the
|
||||||
|
* full width, the notch housing sits centered, and each corner is a
|
||||||
|
* concave scoop (quadratic curve with the control ON the top edge).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* The app's island indicator palette (AgentsModuleView.statusColor). */
|
||||||
|
export const STATUS = {
|
||||||
|
running: "#6EA7FF",
|
||||||
|
ready: "#6FB982",
|
||||||
|
attention: "#E7A762",
|
||||||
|
error: "#E5484D",
|
||||||
|
idle: "rgba(255,255,255,0.35)"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** MetricPalette.calm — the desaturated sky of every metric bar. */
|
||||||
|
export const CALM = "#6EA7FF";
|
||||||
|
export const WARM = "#E7A762";
|
||||||
|
export const CRITICAL = "rgba(229,72,77,0.9)";
|
||||||
|
|
||||||
|
/* NookPalette.accent — brand + live interactive state, never status. */
|
||||||
|
export const ACCENT = "#4897b2";
|
||||||
|
|
||||||
|
/** The expanded panel's card chrome: white 5.5% fill, white 7% stroke. */
|
||||||
|
export const CARD_FILL = "rgba(255,255,255,0.055)";
|
||||||
|
export const CARD_STROKE = "rgba(255,255,255,0.07)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module-card surface shared by every mock panel — the app's
|
||||||
|
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
||||||
|
*/
|
||||||
|
export function ModuleCard(props: { children: JSX.Element; class?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`rounded-xl ${props.class ?? ""}`}
|
||||||
|
style={{
|
||||||
|
background: CARD_FILL,
|
||||||
|
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="p-3">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Island surface, faithful to the app's IslandSurfaceShape: the top edge
|
||||||
|
* spans the full outer width while the body is inset, each corner joining
|
||||||
|
* them with a concave quad flare (control point ON the top edge), the
|
||||||
|
* bottom a convex 14px round. Rendered as an inline SVG with
|
||||||
|
* foreignObject-clip via CSS `clip-path: path(...)`.
|
||||||
|
*/
|
||||||
|
const FLARE = 22; // the app's topRadius — how far the wings taper inward
|
||||||
|
const BOTTOM_R = 14;
|
||||||
|
|
||||||
|
function islandPath(w: number, h: number): string {
|
||||||
|
const tr = Math.min(FLARE, w / 2);
|
||||||
|
const br = Math.min(BOTTOM_R, tr);
|
||||||
|
// Mirrors IslandSurfaceShape.path exactly (same node order, same controls).
|
||||||
|
return (
|
||||||
|
`M0,0 L${w},0 ` +
|
||||||
|
`Q${w - tr},0 ${w - tr},${tr} ` +
|
||||||
|
`L${w - tr},${h - br} ` +
|
||||||
|
`Q${w - tr},${h} ${w - tr - br},${h} ` +
|
||||||
|
`L${tr + br},${h} ` +
|
||||||
|
`Q${tr},${h} ${tr},${h - br} ` +
|
||||||
|
`L${tr},${tr} ` +
|
||||||
|
`Q${tr},0 0,0 Z`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IslandSurface(props: { children: JSX.Element; class?: string }) {
|
||||||
|
let el: HTMLDivElement | undefined;
|
||||||
|
// The path must track the element's real size; a fixed viewBox would
|
||||||
|
// stretch the flare. Measure on mount + resize.
|
||||||
|
onMount(() => {
|
||||||
|
const node = el;
|
||||||
|
if (!node) return;
|
||||||
|
const apply = () => {
|
||||||
|
const r = node.getBoundingClientRect();
|
||||||
|
node.style.clipPath = `path("${islandPath(r.width, r.height)}")`;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const observer = new ResizeObserver(apply);
|
||||||
|
observer.observe(node);
|
||||||
|
onCleanup(() => observer.disconnect());
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={el}
|
||||||
|
class={`bg-black ${props.class ?? ""}`}
|
||||||
|
style={{ "border-radius": "0 0 14px 14px" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISLAND … */
|
||||||
|
const NOTCH_NAME = "The Nook";
|
||||||
|
|
||||||
|
/* ── Collapsed pill ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collapsed pill: campfire slot left, camera housing center, live
|
||||||
|
* data slot right. Slot squares are native (no menu-bar stub — the wings
|
||||||
|
* hang from nothing, like a floating notch).
|
||||||
|
*/
|
||||||
|
export function IslandPill(props: {
|
||||||
|
campfire?: CampfireState;
|
||||||
|
right?: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<IslandSurface class="mx-auto w-fit">
|
||||||
|
<div class="flex items-end" style={{ height: "60px" }}>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "86px", height: "56px" }}
|
||||||
|
>
|
||||||
|
<Campfire state={props.campfire ?? "running"} pixel={4.2} />
|
||||||
|
</div>
|
||||||
|
{/* camera housing: lens dot centered like the real notch */}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "150px", height: "56px" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded-full"
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
background: "radial-gradient(circle at 40% 35%, #2a3a4a 0%, #0a0c10 70%)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "86px", height: "56px" }}
|
||||||
|
>
|
||||||
|
{props.right}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IslandSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents dot-grid slot indicator: balanced rows of status squares. */
|
||||||
|
export function AgentDotGrid(props: { states: (keyof typeof STATUS)[] }) {
|
||||||
|
// Balanced rows: 3+3+2 for 8, 2x2 for 4, 3 for 3 … app: 1,2,3,4=2x2,
|
||||||
|
// 5=[3,2], 6=[3,3], 7=[4,3], 8=[4,4], 9=[3,3,3]
|
||||||
|
const rows = (n: number): number[] => {
|
||||||
|
switch (n) {
|
||||||
|
case 1:
|
||||||
|
return [1];
|
||||||
|
case 2:
|
||||||
|
return [2];
|
||||||
|
case 3:
|
||||||
|
return [3];
|
||||||
|
case 4:
|
||||||
|
return [2, 2];
|
||||||
|
case 5:
|
||||||
|
return [3, 2];
|
||||||
|
case 6:
|
||||||
|
return [3, 3];
|
||||||
|
case 7:
|
||||||
|
return [4, 3];
|
||||||
|
case 8:
|
||||||
|
return [4, 4];
|
||||||
|
default:
|
||||||
|
return [4, 4];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sizes = rows(props.states.length);
|
||||||
|
const groups = createMemo(() => {
|
||||||
|
let cursor = 0;
|
||||||
|
return sizes.map((count) => props.states.slice(cursor, (cursor += count)));
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-[1.5px]">
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="flex gap-[1.5px]">
|
||||||
|
<For each={group}>
|
||||||
|
{(s) => (
|
||||||
|
<span
|
||||||
|
class="rounded-[1.5px]"
|
||||||
|
style={{
|
||||||
|
width: "7px",
|
||||||
|
height: "7px",
|
||||||
|
background: STATUS[s]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Expanded panel ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded island: header ("The Nook" + "N active" + controls),
|
||||||
|
* tab strip, divider, then one panel page. Width ~ the app's expanded
|
||||||
|
* footprint on a 14" display.
|
||||||
|
*/
|
||||||
|
export function IslandExpanded(props: {
|
||||||
|
activeCount: number;
|
||||||
|
tabs: { icon: string; label: string; active?: boolean }[];
|
||||||
|
children: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="w-[560px] max-w-full">
|
||||||
|
<IslandSurface>
|
||||||
|
{/* header */}
|
||||||
|
<div class="flex items-center px-[18px] py-2.5">
|
||||||
|
<span class="text-[14px] font-semibold text-white">{NOTCH_NAME}</span>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<span class="text-[11px] text-white/50">
|
||||||
|
{props.activeCount} active
|
||||||
|
</span>
|
||||||
|
<span class="ml-3 text-[11px] font-semibold text-white/60">⚙</span>
|
||||||
|
<span class="ml-3 text-[11px] font-semibold text-white/60">✕</span>
|
||||||
|
</div>
|
||||||
|
{/* tab strip: one icon button per panel; active wears accent 22% */}
|
||||||
|
<div class="flex gap-2 px-[18px] pb-1.5">
|
||||||
|
<For each={props.tabs}>
|
||||||
|
{(tab) => (
|
||||||
|
<span
|
||||||
|
title={tab.label}
|
||||||
|
class="flex items-center justify-center rounded-[5px]"
|
||||||
|
style={{
|
||||||
|
width: "22px",
|
||||||
|
height: "22px",
|
||||||
|
"font-size": "13px",
|
||||||
|
background: tab.active ? `${ACCENT}38` : "transparent",
|
||||||
|
color: tab.active ? "rgba(255,255,255,0.95)" : "rgba(255,255,255,0.5)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-white/10" />
|
||||||
|
<div class="px-[18px] py-4">{props.children}</div>
|
||||||
|
</IslandSurface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import FeatureCollage from "~/components/nook/FeatureCollage";
|
||||||
|
import FeatureBreakdowns from "~/components/nook/FeatureBreakdowns";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
import Input from "~/components/ui/Input";
|
import Input from "~/components/ui/Input";
|
||||||
import { createSignal, Show } from "solid-js";
|
import { createSignal, Show } from "solid-js";
|
||||||
@@ -8,29 +10,6 @@ import { useSite } from "~/context/SiteContext";
|
|||||||
|
|
||||||
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
|
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
|
||||||
|
|
||||||
const FEATURES = [
|
|
||||||
{
|
|
||||||
title: "Agent orchestration",
|
|
||||||
body: "Orchestrate your coding agents in one native panel, with sessions that keep working while you do."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "A beautiful native UI",
|
|
||||||
body: "Animated panels and buttery SwiftUI transitions, tuned to feel right at home on your Mac."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Fan & thermal insight",
|
|
||||||
body: "Read and control system fans, watch temperatures, and keep performance predictable under load."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Private by design",
|
|
||||||
body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "One-time license",
|
|
||||||
body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals."
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function NookLanding() {
|
export default function NookLanding() {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
const { isDark } = useDarkMode();
|
const { isDark } = useDarkMode();
|
||||||
@@ -103,14 +82,14 @@ export default function NookLanding() {
|
|||||||
The Nook
|
The Nook
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
|
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
|
||||||
Your Mac, under your control
|
Your Mac and Agents, under control
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
||||||
Agent orchestration, fan and thermal control — native macOS,
|
Agent orchestration, fan and thermal control in a beautiful native
|
||||||
one-time license.
|
UI.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-subtext1 mb-8 text-sm">
|
<p class="text-subtext1 mb-8 text-sm">
|
||||||
macOS 14+ · 14-day free trial · 3 devices
|
macOS 14+ · 14-day free trial · up to 3 devices
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
||||||
@@ -136,29 +115,25 @@ export default function NookLanding() {
|
|||||||
</span>
|
</span>
|
||||||
<p class="text-subtext1 text-xs">
|
<p class="text-subtext1 text-xs">
|
||||||
<span class="text-subtext0 line-through">$15</span>{" "}
|
<span class="text-subtext0 line-through">$15</span>{" "}
|
||||||
<span class="text-text font-bold">$10</span> one-time · 3 devices ·
|
<span class="text-text font-bold">$10</span> one-time
|
||||||
14-day free trial
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Feature highlights ───────────────────────────────────────── */}
|
{/* ── Feature collage ─────────────────────────────────────────── */}
|
||||||
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
|
<FeatureCollage />
|
||||||
<div class="mx-auto max-w-4xl">
|
|
||||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
{/* ── Feature breakdowns: the features in action ─────────────── */}
|
||||||
One panel for your agents and your Mac
|
<section class="bg-base relative z-20 px-4 py-16 md:px-8">
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||||
|
In action
|
||||||
|
</p>
|
||||||
|
<h2 class="text-text mb-16 text-center text-3xl font-bold">
|
||||||
|
Built for the way you actually run agents
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-2">
|
<FeatureBreakdowns />
|
||||||
{FEATURES.map((feature) => (
|
|
||||||
<div class="border-overlay0 bg-surface0 rounded-lg border p-6">
|
|
||||||
<h3 class="text-text mb-2 text-xl font-semibold">
|
|
||||||
{feature.title}
|
|
||||||
</h3>
|
|
||||||
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -187,7 +162,8 @@ export default function NookLanding() {
|
|||||||
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
|
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
|
||||||
href="/checkout"
|
href="/checkout"
|
||||||
>
|
>
|
||||||
Buy a license → $10 <span class="text-subtext0 line-through">$15</span>
|
Buy a license → $10{" "}
|
||||||
|
<span class="text-subtext0 line-through">$15</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-subtext1 mt-6 text-xs">
|
<p class="text-subtext1 mt-6 text-xs">
|
||||||
|
|||||||
Reference in New Issue
Block a user