Files
freno-dev/src/components/nook/IslandMock.tsx
2026-09-05 15:08:36 -04:00

264 lines
8.1 KiB
TypeScript

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;
style?: Record<string, string>;
}) {
return (
<div
class={`rounded-xl ${props.class ?? ""}`}
style={{
background: CARD_FILL,
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
...props.style
}}
>
<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>
);
}