chore: import upstream snapshot with attribution
CI / Deep Native Runtime Cases (1/6) (push) Has been skipped
CI / Native Preflight (push) Failing after 1s
CI / Native Runtime Cases (1/2) (push) Failing after 0s
CI / Native Runtime Cases (2/2) (push) Failing after 1s
CI / Native Metadata Reports (push) Failing after 0s
CI / Native Direct Backend Artifacts (push) Failing after 0s
CI / Native Sanitizer Smoke (push) Failing after 1s
CI / Command Contract Snapshots (push) Failing after 1s
CI / Deep Conformance Suite (push) Has been skipped
CI / Graph Build Perf (push) Failing after 1s
CI / Deep Native Preflight (push) Has been skipped
CI / Deep Native Runtime Cases (2/6) (push) Has been skipped
CI / Deep Native Runtime Cases (3/6) (push) Has been skipped
CI / Conformance Suite (push) Failing after 1s
CI / Workspace Checks (push) Failing after 0s
CI / Deep Native Runtime Cases (5/6) (push) Has been skipped
CI / Deep Native Runtime Cases (6/6) (push) Has been skipped
CI / Deep Native Runtime Cases (4/6) (push) Has been skipped
CI / Deep Graph Build Perf (push) Has been skipped
CI / Deep Native Runtime Cases (1/6) (push) Has been skipped
CI / Native Preflight (push) Failing after 1s
CI / Native Runtime Cases (1/2) (push) Failing after 0s
CI / Native Runtime Cases (2/2) (push) Failing after 1s
CI / Native Metadata Reports (push) Failing after 0s
CI / Native Direct Backend Artifacts (push) Failing after 0s
CI / Native Sanitizer Smoke (push) Failing after 1s
CI / Command Contract Snapshots (push) Failing after 1s
CI / Deep Conformance Suite (push) Has been skipped
CI / Graph Build Perf (push) Failing after 1s
CI / Deep Native Preflight (push) Has been skipped
CI / Deep Native Runtime Cases (2/6) (push) Has been skipped
CI / Deep Native Runtime Cases (3/6) (push) Has been skipped
CI / Conformance Suite (push) Failing after 1s
CI / Workspace Checks (push) Failing after 0s
CI / Deep Native Runtime Cases (5/6) (push) Has been skipped
CI / Deep Native Runtime Cases (6/6) (push) Has been skipped
CI / Deep Native Runtime Cases (4/6) (push) Has been skipped
CI / Deep Graph Build Perf (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type ToolCall = { command: string; output?: string };
|
||||
|
||||
type Message =
|
||||
| { role: "user"; text: string }
|
||||
| { role: "assistant"; text: string }
|
||||
| { role: "skill"; name: string }
|
||||
| { role: "tools"; calls: ToolCall[] }
|
||||
| { role: "output"; text: string };
|
||||
|
||||
export type ChatSpec = { title?: string; messages: Message[] };
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy message"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
} catch {}
|
||||
}}
|
||||
className="shrink-0 cursor-pointer rounded-md p-1.5 text-muted transition hover:bg-surface-muted hover:text-fg"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13.5 4.5 6.5 11.5 3 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 1.75A1.75 1.75 0 0 1 6.75 0h5.5A1.75 1.75 0 0 1 14 1.75v5.5A1.75 1.75 0 0 1 12.25 9H11V7.5h1.25a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-5.5a.25.25 0 0 0-.25.25V3H5V1.75Z" />
|
||||
<path d="M2 4.75A1.75 1.75 0 0 1 3.75 3h5.5A1.75 1.75 0 0 1 11 4.75v5.5A1.75 1.75 0 0 1 9.25 12h-5.5A1.75 1.75 0 0 1 2 10.25v-5.5Zm1.75-.25a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h5.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-5.5Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
className={`shrink-0 text-muted transition-transform duration-200 ${open ? "rotate-90" : ""}`}
|
||||
>
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCalls({ calls }: { calls: ToolCall[] }) {
|
||||
const [open, setOpen] = useState<number[]>([]);
|
||||
const toggle = (i: number) =>
|
||||
setOpen((prev) => (prev.includes(i) ? prev.filter((n) => n !== i) : [...prev, i]));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{calls.map((call, i) => {
|
||||
const isOpen = open.includes(i);
|
||||
const hasOutput = typeof call.output === "string" && call.output.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={call.command}
|
||||
className="overflow-hidden rounded-lg border border-border bg-surface"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasOutput && toggle(i)}
|
||||
aria-expanded={isOpen}
|
||||
disabled={!hasOutput}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 font-mono text-[0.78125rem] leading-relaxed text-muted transition-colors enabled:cursor-pointer enabled:hover:bg-surface-muted"
|
||||
>
|
||||
{hasOutput ? <ChevronIcon open={isOpen} /> : <span className="w-3 shrink-0" />}
|
||||
<span
|
||||
className={`min-w-0 flex-1 text-left text-fg/80 ${
|
||||
isOpen ? "whitespace-pre-wrap break-all" : "truncate"
|
||||
}`}
|
||||
>
|
||||
{call.command}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && hasOutput && (
|
||||
<pre className="m-0 overflow-x-auto border-t border-border/50 bg-bg px-3 py-2.5 text-[0.78125rem] leading-relaxed text-fg/70">
|
||||
{call.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }: { message: Message }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<CopyButton text={message.text} />
|
||||
<div className="max-w-[82%] rounded-2xl rounded-br-md bg-fg px-4 py-2.5 text-[0.875rem] leading-relaxed text-bg">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
return (
|
||||
<div className="max-w-[92%] text-[0.875rem] leading-[1.65] text-fg">{message.text}</div>
|
||||
);
|
||||
}
|
||||
if (message.role === "skill") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[0.78125rem] text-muted">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" className="shrink-0">
|
||||
<path
|
||||
d="M6 2 3 8h3l-1 6 5-8H6l2-4H6Z"
|
||||
fill="currentColor"
|
||||
className="text-fg/50"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Loaded <span className="font-medium text-fg">{message.name}</span> skill
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (message.role === "tools") {
|
||||
return <ToolCalls calls={message.calls} />;
|
||||
}
|
||||
return (
|
||||
<pre className="m-0 overflow-x-auto rounded-lg border border-border bg-code-bg px-4 py-3 font-mono text-[0.8125rem] leading-relaxed text-code-fg">
|
||||
{message.text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChat({ spec }: { spec: ChatSpec }) {
|
||||
return (
|
||||
<div className="not-prose my-6 overflow-hidden rounded-2xl border border-border bg-bg">
|
||||
<div className="flex flex-col gap-5 p-5 sm:p-6">
|
||||
{spec.messages.map((message, i) => (
|
||||
<ChatMessage key={i} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import type { ButtonHTMLAttributes, ComponentProps } from "react";
|
||||
|
||||
const SIZES = {
|
||||
sm: "h-8 px-4 text-[0.8125rem]",
|
||||
md: "h-10 px-6 text-sm",
|
||||
lg: "h-11 gap-2 px-8 text-[0.9375rem]",
|
||||
};
|
||||
|
||||
const VARIANTS = {
|
||||
default: "border-border bg-bg text-fg hover:border-fg",
|
||||
primary:
|
||||
"border-accent bg-accent text-accent-fg hover:bg-transparent hover:text-accent",
|
||||
};
|
||||
|
||||
type ButtonVariant = keyof typeof VARIANTS;
|
||||
type ButtonSize = keyof typeof SIZES;
|
||||
|
||||
type ButtonStyleProps = {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function classes({ variant = "default", size = "md", className = "" }: ButtonStyleProps): string {
|
||||
return [
|
||||
"inline-flex items-center justify-center rounded-md border font-medium no-underline transition",
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & ButtonStyleProps;
|
||||
|
||||
export function Button({ variant, size, className, ...rest }: ButtonProps) {
|
||||
return <button className={classes({ variant, size, className })} {...rest} />;
|
||||
}
|
||||
|
||||
type ButtonLinkProps = ComponentProps<typeof Link> & ButtonStyleProps;
|
||||
|
||||
export function ButtonLink({ variant, size, className, ...rest }: ButtonLinkProps) {
|
||||
return <Link className={classes({ variant, size, className })} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { UIEvent } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { highlight } from "@/lib/highlight";
|
||||
|
||||
type CodeEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language?: string;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = "zero",
|
||||
ariaLabel,
|
||||
className = "",
|
||||
}: CodeEditorProps) {
|
||||
const preRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const html = useMemo(() => {
|
||||
const trailingNewline = value.endsWith("\n") ? " " : "";
|
||||
return highlight(value + trailingNewline, language);
|
||||
}, [value, language]);
|
||||
|
||||
function handleScroll(event: UIEvent<HTMLTextAreaElement>) {
|
||||
const pre = preRef.current;
|
||||
if (!pre) return;
|
||||
pre.scrollTop = event.currentTarget.scrollTop;
|
||||
pre.scrollLeft = event.currentTarget.scrollLeft;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${className}`}>
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre p-6 font-mono text-[0.9375rem] leading-[1.7] text-code-fg"
|
||||
>
|
||||
<code dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</pre>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onScroll={handleScroll}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
aria-label={ariaLabel}
|
||||
className="relative block h-full w-full resize-none whitespace-pre border-0 bg-transparent p-6 font-mono text-[0.9375rem] leading-[1.7] text-transparent caret-fg outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function CopyCodeButton() {
|
||||
const [state, setState] = useState("idle");
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function handleClick(event: MouseEvent<HTMLButtonElement>) {
|
||||
const button = event.currentTarget;
|
||||
const block = button.closest("figure[data-rehype-pretty-code-figure], div[data-code-block]");
|
||||
const code = block?.querySelector("code");
|
||||
const text = code?.textContent ?? "";
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setState("copied");
|
||||
} catch {
|
||||
setState("failed");
|
||||
}
|
||||
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setState("idle"), 1400);
|
||||
}
|
||||
|
||||
const color =
|
||||
state === "copied" ? "text-success" : state === "failed" ? "text-danger" : "text-muted";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={state === "copied" ? "Code copied to clipboard" : "Copy code to clipboard"}
|
||||
onClick={handleClick}
|
||||
className={`absolute right-2 top-2 z-10 inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded border-0 bg-transparent transition hover:text-fg ${color}`}
|
||||
>
|
||||
{state === "copied" ? (
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5 fill-current">
|
||||
<path d="M13.78 3.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 8.28a.75.75 0 0 1 1.06-1.06L6 9.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5 fill-current">
|
||||
<path d="M5 1.75A1.75 1.75 0 0 1 6.75 0h5.5A1.75 1.75 0 0 1 14 1.75v5.5A1.75 1.75 0 0 1 12.25 9H11V7.5h1.25a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-5.5a.25.25 0 0 0-.25.25V3H5V1.75Z" />
|
||||
<path d="M2 4.75A1.75 1.75 0 0 1 3.75 3h5.5A1.75 1.75 0 0 1 11 4.75v5.5A1.75 1.75 0 0 1 9.25 12h-5.5A1.75 1.75 0 0 1 2 10.25v-5.5Zm1.75-.25a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h5.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-5.5Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { isValidElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Streamdown } from "streamdown";
|
||||
import type {
|
||||
Components as StreamdownComponents,
|
||||
ExtraProps as StreamdownExtraProps,
|
||||
} from "streamdown";
|
||||
import type {
|
||||
DynamicToolUIPart,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UIMessagePart,
|
||||
UITools,
|
||||
ToolUIPart,
|
||||
} from "ai";
|
||||
import Link from "next/link";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { highlight } from "@/lib/highlight";
|
||||
|
||||
const STORAGE_KEY = "docs-chat-messages";
|
||||
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||
|
||||
const TOOL_LABELS = {
|
||||
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
|
||||
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
|
||||
};
|
||||
|
||||
type ToolLabel = {
|
||||
label: string;
|
||||
pastLabel: string;
|
||||
argKey?: string;
|
||||
};
|
||||
|
||||
type ToolDisplayPart = ToolUIPart<UITools> | DynamicToolUIPart;
|
||||
|
||||
function extractCodeText(children: ReactNode): string {
|
||||
if (children == null || children === false) return "";
|
||||
if (typeof children === "string") return children;
|
||||
if (Array.isArray(children)) return children.map(extractCodeText).join("");
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) {
|
||||
return extractCodeText(children.props.children);
|
||||
}
|
||||
return String(children);
|
||||
}
|
||||
|
||||
function ChatCodeBlock({
|
||||
children,
|
||||
}: ComponentPropsWithoutRef<"pre"> & StreamdownExtraProps) {
|
||||
const codeEl = Array.isArray(children) ? children[0] : children;
|
||||
const className = isValidElement<{ className?: string }>(codeEl)
|
||||
? codeEl.props.className ?? ""
|
||||
: "";
|
||||
const language = /language-([A-Za-z0-9_-]+)/.exec(className)?.[1] ?? "";
|
||||
const code = extractCodeText(
|
||||
isValidElement<{ children?: ReactNode }>(codeEl) ? codeEl.props.children : codeEl,
|
||||
).replace(/\n$/, "");
|
||||
const html = highlight(code, language);
|
||||
return (
|
||||
<pre className="not-prose my-3 overflow-x-auto rounded-md border border-border bg-code-bg p-3 text-[0.8125rem] leading-relaxed text-code-fg">
|
||||
<code
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const STREAMDOWN_COMPONENTS: StreamdownComponents = {
|
||||
pre: ChatCodeBlock,
|
||||
};
|
||||
|
||||
function isToolPart(
|
||||
part: UIMessagePart<UIDataTypes, UITools>,
|
||||
): part is ToolDisplayPart {
|
||||
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
}
|
||||
|
||||
function getToolName(part: ToolDisplayPart): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
|
||||
return part.type.replace(/^tool-/, "");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function ToolCallDisplay({ part }: { part: ToolDisplayPart }) {
|
||||
const toolName = getToolName(part);
|
||||
const config: ToolLabel = TOOL_LABELS[toolName as keyof typeof TOOL_LABELS] ?? {
|
||||
label: toolName,
|
||||
pastLabel: toolName,
|
||||
};
|
||||
const isDone = part.state === "output-available";
|
||||
const isError = part.state === "output-error";
|
||||
const isRunning = !isDone && !isError;
|
||||
const displayLabel = isRunning ? config.label : config.pastLabel;
|
||||
|
||||
const args = isRecord(part.input) ? part.input : {};
|
||||
const argValue = config.argKey ? args[config.argKey] : undefined;
|
||||
const argPreview =
|
||||
argValue != null
|
||||
? String(argValue)
|
||||
.replace(/^\/workspace\//, "/")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/\/index$/, "") || "/"
|
||||
: "";
|
||||
|
||||
const docsLink = toolName === "readFile" && argPreview.startsWith("/") ? argPreview : null;
|
||||
|
||||
const argEl = argPreview ? (
|
||||
docsLink ? (
|
||||
<Link href={docsLink} className="truncate underline underline-offset-2">
|
||||
{argPreview}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{argPreview}</span>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 py-0.5 text-xs">
|
||||
<span
|
||||
className={`inline-flex min-w-0 max-w-full items-center gap-1 font-mono ${
|
||||
isRunning
|
||||
? "animate-pulse text-neutral-500 dark:text-neutral-400"
|
||||
: "text-neutral-400 dark:text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
{isError && <span className="text-red-500">failed</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What is Zero?",
|
||||
"How do I install it?",
|
||||
"What commands are available?",
|
||||
"How does cross-compilation work?",
|
||||
"Show me a hello world.",
|
||||
];
|
||||
|
||||
export function DocsChat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const messagesScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, error } = useChat({ transport });
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const showMessages = messages.length > 0 || !!error || isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) setMessages(parsed as UIMessage[]);
|
||||
}
|
||||
} catch {}
|
||||
}, [setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current || isLoading) return;
|
||||
if (messages.length === 0) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
} catch {}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: globalThis.KeyboardEvent) {
|
||||
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 200);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) setOpen(true);
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, error]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: { preventDefault: () => void }) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, [setMessages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white shadow-lg transition-opacity hover:opacity-90 dark:bg-neutral-100 dark:text-neutral-900 sm:left-auto sm:right-4 sm:translate-x-0"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
Ask AI
|
||||
<kbd className="hidden items-center gap-0.5 font-mono text-xs opacity-60 sm:inline-flex">
|
||||
<span>⌘</span>I
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="flex w-full max-w-md flex-col gap-0 p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Zero Docs
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{showMessages && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMessages ? (
|
||||
<div ref={messagesScrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.map((message) => {
|
||||
const hasVisible = message.parts?.some(
|
||||
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
||||
);
|
||||
if (!hasVisible) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "user" ? (
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
|
||||
{message.parts.filter((p) => p.type === "text").map((p) => p.text).join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="docs-chat-content max-w-none text-sm leading-relaxed text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<Streamdown components={STREAMDOWN_COMPONENTS}>
|
||||
{part.text}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isToolPart(part)) {
|
||||
return <ToolCallDisplay key={part.toolCallId ?? i} part={part} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600/80 dark:bg-red-950/30 dark:text-red-400/80">
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
return parsed.message || parsed.error || error.message;
|
||||
} catch {
|
||||
return error.message || "Something went wrong. Please try again.";
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-wrap gap-2 p-4">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => sendMessage({ text: s })}
|
||||
className="rounded-full border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-end gap-2 border-t border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
rows={1}
|
||||
enterKeyHint="send"
|
||||
placeholder="Ask a question..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="max-h-32 flex-1 resize-none bg-transparent text-base leading-relaxed text-neutral-900 outline-none placeholder:text-neutral-400 disabled:opacity-50 dark:text-neutral-100 dark:placeholder:text-neutral-500 sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="shrink-0 rounded-full bg-neutral-900 p-1.5 text-white transition-colors hover:bg-neutral-700 disabled:opacity-30 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDownIcon, HamburgerIcon } from "@/components/icons";
|
||||
import { Sheet, SheetTrigger, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { DocsGroup } from "@/lib/types";
|
||||
|
||||
const COLLAPSE_STORAGE_KEY = "docs-sidebar-collapsed";
|
||||
|
||||
type SidebarNavProps = {
|
||||
groups: DocsGroup[];
|
||||
activeSlug: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
function SidebarNav({ groups, activeSlug, onNavigate }: SidebarNavProps) {
|
||||
const initialCollapsed = new Set(
|
||||
groups
|
||||
.filter((g) => g.section !== "Start Here" && !g.items.some((i) => i.slug === activeSlug))
|
||||
.map((g) => g.section),
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(initialCollapsed);
|
||||
const restored = useRef(false);
|
||||
|
||||
// Restore the user's expand/collapse choices so navigating between pages
|
||||
// does not reset sections the user opened. (The active section is always
|
||||
// shown expanded via the `hasActive` check below, regardless of this set.)
|
||||
useEffect(() => {
|
||||
if (restored.current) return;
|
||||
restored.current = true;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(COLLAPSE_STORAGE_KEY);
|
||||
if (stored) setCollapsed(new Set(JSON.parse(stored) as string[]));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function toggle(section: string) {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
try {
|
||||
sessionStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify([...next]));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="px-3 py-4">
|
||||
{groups.map((group) => {
|
||||
const hasActive = group.items.some((item) => item.slug === activeSlug);
|
||||
const isCollapsed = collapsed.has(group.section) && !hasActive;
|
||||
return (
|
||||
<div key={group.section} className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!isCollapsed}
|
||||
onClick={() => toggle(group.section)}
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded bg-transparent px-3 py-2 text-[0.6875rem] font-semibold uppercase tracking-[0.06em] text-muted transition hover:text-fg"
|
||||
>
|
||||
<span>{group.section}</span>
|
||||
<ChevronDownIcon className={`shrink-0 transition-transform ${isCollapsed ? "-rotate-90" : ""}`} />
|
||||
</button>
|
||||
<div
|
||||
className={`grid transition-[grid-template-rows] duration-200 ${
|
||||
isCollapsed ? "grid-rows-[0fr]" : "grid-rows-[1fr]"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{group.items.map((item) => {
|
||||
const active = item.slug === activeSlug;
|
||||
return (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={item.path}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onNavigate}
|
||||
className={`block rounded px-3 py-[0.1875rem] text-[0.8125rem] leading-[1.8] no-underline transition hover:text-fg ${
|
||||
active ? "font-medium text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type DocsSidebarShellProps = {
|
||||
groups: DocsGroup[];
|
||||
};
|
||||
|
||||
function activeDocForPath(groups: DocsGroup[], pathname: string) {
|
||||
const normalized = pathname.replace(/\/$/, "") || "/";
|
||||
return groups.flatMap((group) => group.items).find((item) => item.path === normalized);
|
||||
}
|
||||
|
||||
export function DocsSidebarShell({ groups }: DocsSidebarShellProps) {
|
||||
const pathname = usePathname();
|
||||
const activeDoc = activeDocForPath(groups, pathname);
|
||||
const activeSlug = activeDoc?.slug ?? "";
|
||||
const currentTitle = activeDoc?.title;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger
|
||||
aria-label="Open table of contents"
|
||||
className="sticky top-14 z-40 flex w-full items-center justify-between border-b border-border bg-bg/80 px-6 py-3 backdrop-blur-sm focus:outline-none md:hidden"
|
||||
>
|
||||
<span className="text-sm font-medium text-fg">{currentTitle ?? "Documentation"}</span>
|
||||
<span className="flex h-8 w-8 items-center justify-center text-muted">
|
||||
<HamburgerIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="overflow-y-auto p-0" showCloseButton={false}>
|
||||
<SheetTitle className="px-6 pt-6">Table of Contents</SheetTitle>
|
||||
<SidebarNav groups={groups} activeSlug={activeSlug} onNavigate={() => setOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<aside
|
||||
aria-label="Documentation"
|
||||
className="hidden md:sticky md:top-14 md:block md:h-[calc(100vh-3.5rem)] md:w-60 md:overflow-y-auto"
|
||||
>
|
||||
<SidebarNav groups={groups} activeSlug={activeSlug} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Heading } from "@/lib/types";
|
||||
|
||||
export function DocsToc({ headings }: { headings: Heading[] }) {
|
||||
const [activeId, setActiveId] = useState<string | null>(headings[0]?.id ?? null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || headings.length === 0) return;
|
||||
|
||||
const ids = headings.map((h) => h.id);
|
||||
const elements = ids
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((el): el is HTMLElement => el !== null);
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const visible = new Set();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) visible.add(entry.target.id);
|
||||
else visible.delete(entry.target.id);
|
||||
}
|
||||
|
||||
let next = null;
|
||||
for (const h of headings) {
|
||||
if (visible.has(h.id)) {
|
||||
next = h.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!next && visible.size === 0) {
|
||||
const scrollY = window.scrollY;
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
if (elements[i].offsetTop <= scrollY + 80) {
|
||||
next = elements[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next) setActiveId(next);
|
||||
},
|
||||
{ rootMargin: "-64px 0px -60% 0px", threshold: 0 },
|
||||
);
|
||||
|
||||
elements.forEach((el) => observer.observe(el));
|
||||
observerRef.current = observer;
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="On this page"
|
||||
className="sticky top-14 hidden h-[calc(100vh-3.5rem)] overflow-y-auto py-8 pl-0 pr-4 lg:block"
|
||||
>
|
||||
<p className="mb-3 pl-4 text-xs font-semibold uppercase tracking-[0.05em] text-fg">
|
||||
On this page
|
||||
</p>
|
||||
<nav>
|
||||
{headings.map((h) => {
|
||||
const indent = h.level === 3 ? "pl-7" : h.level === 4 ? "pl-10" : "pl-4";
|
||||
return (
|
||||
<a
|
||||
key={h.id}
|
||||
href={`#${h.id}`}
|
||||
className={`block py-1 pr-4 ${indent} text-[0.8125rem] leading-snug no-underline transition hover:text-fg ${
|
||||
activeId === h.id ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{h.text}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
type FlowTone = "graph" | "text" | "compiler" | "human";
|
||||
|
||||
type FlowNodeSpec = {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
tone?: FlowTone;
|
||||
};
|
||||
|
||||
type FlowEdgeSpec = {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FlowChartSpec = {
|
||||
type: "flow";
|
||||
title?: string;
|
||||
height?: number;
|
||||
nodes: FlowNodeSpec[];
|
||||
edges: FlowEdgeSpec[];
|
||||
};
|
||||
|
||||
// x/y in the spec are the top-left of each node, in px.
|
||||
const NODE_W = 224;
|
||||
const NODE_H = 72;
|
||||
const PAD = 80;
|
||||
|
||||
const TONE_CLASS: Record<FlowTone, string> = {
|
||||
// faint, "old" loop
|
||||
text: "border border-border bg-bg text-muted",
|
||||
// the enforcer: strong inverted
|
||||
compiler: "bg-fg text-bg",
|
||||
// the good path: clean and present
|
||||
graph: "border border-fg/40 bg-surface text-fg",
|
||||
// human review
|
||||
human: "border border-border bg-surface-muted text-fg",
|
||||
};
|
||||
|
||||
export function FlowChart({ spec }: { spec: FlowChartSpec }) {
|
||||
const byId = new Map(spec.nodes.map((n) => [n.id, n]));
|
||||
const maxX = Math.max(0, ...spec.nodes.map((n) => n.x));
|
||||
const maxY = Math.max(0, ...spec.nodes.map((n) => n.y));
|
||||
const width = PAD * 2 + maxX + NODE_W;
|
||||
const height = Math.max(spec.height ?? 0, PAD * 2 + maxY + NODE_H);
|
||||
|
||||
const cx = (n: FlowNodeSpec) => PAD + n.x + NODE_W / 2;
|
||||
const topY = (n: FlowNodeSpec) => PAD + n.y;
|
||||
const bottomY = (n: FlowNodeSpec) => PAD + n.y + NODE_H;
|
||||
|
||||
const edges = spec.edges
|
||||
.map((edge, i) => {
|
||||
const s = byId.get(edge.source);
|
||||
const t = byId.get(edge.target);
|
||||
if (!s || !t) return null;
|
||||
const loopBack = t.y <= s.y;
|
||||
let path: string;
|
||||
let labelX: number;
|
||||
let labelY: number;
|
||||
if (loopBack) {
|
||||
const leftEdge = Math.min(PAD + s.x, PAD + t.x);
|
||||
const sy = PAD + s.y + NODE_H / 2;
|
||||
const ty = PAD + t.y + NODE_H / 2;
|
||||
const bulge = leftEdge - 40;
|
||||
path = `M ${leftEdge} ${sy} C ${bulge} ${sy}, ${bulge} ${ty}, ${leftEdge} ${ty}`;
|
||||
labelX = bulge;
|
||||
labelY = (sy + ty) / 2;
|
||||
} else {
|
||||
const sx = cx(s);
|
||||
const sy = bottomY(s);
|
||||
const tx = cx(t);
|
||||
const ty = topY(t);
|
||||
const mid = (sy + ty) / 2;
|
||||
path = `M ${sx} ${sy} C ${sx} ${mid}, ${tx} ${mid}, ${tx} ${ty}`;
|
||||
labelX = (sx + tx) / 2;
|
||||
labelY = mid;
|
||||
}
|
||||
return {
|
||||
key: edge.id ?? `${edge.source}-${edge.target}-${i}`,
|
||||
path,
|
||||
label: edge.label,
|
||||
labelX,
|
||||
labelY,
|
||||
};
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null);
|
||||
|
||||
// Only show a connection dot where an edge actually attaches. Normal edges
|
||||
// attach to the source's bottom and the target's top; loop-backs attach to
|
||||
// the side, so they do not light up a top/bottom dot.
|
||||
const topConn = new Set<string>();
|
||||
const bottomConn = new Set<string>();
|
||||
for (const edge of spec.edges) {
|
||||
const s = byId.get(edge.source);
|
||||
const t = byId.get(edge.target);
|
||||
if (!s || !t || t.y <= s.y) continue;
|
||||
bottomConn.add(s.id);
|
||||
topConn.add(t.id);
|
||||
}
|
||||
|
||||
const stroke = "color-mix(in srgb, var(--color-fg) 28%, transparent)";
|
||||
const dot = "color-mix(in srgb, var(--color-fg) 40%, transparent)";
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6 overflow-hidden rounded-2xl border border-border bg-bg">
|
||||
{spec.title ? (
|
||||
<div className="border-b border-border px-5 py-3.5 font-mono text-xs font-medium text-muted">
|
||||
{spec.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="overflow-hidden p-2"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(var(--color-border) 1px, transparent 1px)",
|
||||
backgroundSize: "22px 22px",
|
||||
backgroundPosition: "-1px -1px",
|
||||
}}
|
||||
>
|
||||
{/* Scales down to fit the column; never upscales past its natural size. */}
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="mx-auto block h-auto w-full"
|
||||
style={{ maxWidth: width }}
|
||||
role="img"
|
||||
aria-label={spec.title ?? "diagram"}
|
||||
>
|
||||
{edges.map((e) => (
|
||||
<path key={e.key} d={e.path} fill="none" stroke={stroke} strokeWidth={1.5} />
|
||||
))}
|
||||
{spec.nodes.map((n) => (
|
||||
<g key={`dots-${n.id}`} fill={dot}>
|
||||
{topConn.has(n.id) && <circle cx={cx(n)} cy={topY(n)} r={3} />}
|
||||
{bottomConn.has(n.id) && <circle cx={cx(n)} cy={bottomY(n)} r={3} />}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{edges
|
||||
.filter((e) => e.label)
|
||||
.map((e) => (
|
||||
<foreignObject
|
||||
key={`label-${e.key}`}
|
||||
x={e.labelX - 40}
|
||||
y={e.labelY - 12}
|
||||
width={80}
|
||||
height={24}
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="rounded border border-border bg-bg px-1.5 py-0.5 font-mono text-[0.6875rem] text-muted">
|
||||
{e.label}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
))}
|
||||
|
||||
{spec.nodes.map((n) => (
|
||||
<foreignObject key={n.id} x={PAD + n.x} y={PAD + n.y} width={NODE_W} height={NODE_H}>
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl px-4 text-center text-[0.875rem] font-medium leading-snug ${
|
||||
TONE_CLASS[n.tone ?? "text"]
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</div>
|
||||
</foreignObject>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function HeadingAnchor({ id }: { id?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
async function handleClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||
event.preventDefault();
|
||||
const url = `${window.location.origin}${window.location.pathname}#${id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
} catch {}
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1400);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`#${id}`}
|
||||
onClick={handleClick}
|
||||
aria-label={copied ? "Anchor link copied" : "Copy anchor link"}
|
||||
className="heading-anchor ml-2 inline-flex h-5 w-5 items-center justify-center align-middle text-muted no-underline opacity-0 transition-opacity hover:text-fg group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { highlight } from "@/lib/highlight";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
command: "zero init",
|
||||
output: 'Created zero.toml\nCreated zero.graph graph:e3b0c442\nInitialized package "hello"',
|
||||
},
|
||||
{
|
||||
command: 'zero patch --op addMain --op \'addCheckWrite fn="main" text="hello from zero\\n"\'',
|
||||
output: "Patched zero.graph\n graph hash graph:a7f7e689\n symbols main",
|
||||
},
|
||||
{
|
||||
command: "zero run",
|
||||
output: "hello from zero",
|
||||
},
|
||||
];
|
||||
|
||||
function TerminalIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="shrink-0 text-muted"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatToolRuns({ startDelayMs = 0 }: { startDelayMs?: number }) {
|
||||
const [open, setOpen] = useState<number[]>([]);
|
||||
|
||||
const toggle = (i: number) =>
|
||||
setOpen((prev) => (prev.includes(i) ? prev.filter((n) => n !== i) : [...prev, i]));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{STEPS.map((step, i) => {
|
||||
const isOpen = open.includes(i);
|
||||
return (
|
||||
<div
|
||||
key={step.command}
|
||||
className="home-tool-row overflow-hidden rounded-lg border border-border bg-surface"
|
||||
style={{ "--chat-delay": `${startDelayMs + i * 180}ms` } as CSSProperties}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 font-mono text-[0.78125rem] leading-relaxed text-muted transition-colors hover:bg-surface-muted"
|
||||
>
|
||||
<TerminalIcon />
|
||||
<span
|
||||
className={`min-w-0 flex-1 text-left text-fg/80 ${
|
||||
isOpen ? "whitespace-pre-wrap break-all" : "truncate"
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlight(step.command, "sh") }}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<pre className="m-0 overflow-x-auto border-t border-border/50 bg-bg px-3 py-2.5 text-[0.78125rem] leading-relaxed text-fg/70">
|
||||
{step.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function HomeChatViewport({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isReady = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const fitsInViewport = rect.height <= viewportHeight;
|
||||
|
||||
if (fitsInViewport) {
|
||||
return rect.top >= 0 && rect.bottom <= viewportHeight;
|
||||
}
|
||||
|
||||
return rect.top <= viewportHeight * 0.12 && rect.bottom >= viewportHeight * 0.88;
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
if (!isReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="home-chat-viewport" data-chat-visible={visible ? "true" : "false"}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export function LogoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="18" height="16" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRightIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GithubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownIcon({ className = "", ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HamburgerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
id: "humans",
|
||||
label: "For humans",
|
||||
command: "curl -fsSL https://zerolang.ai/install.sh | bash",
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
label: "For agents",
|
||||
command: "npx skills add vercel-labs/zerolang",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export function InstallCopy() {
|
||||
const [activeId, setActiveId] = useState<TabId>(TABS[0].id);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const active = TABS.find((t) => t.id === activeId) ?? TABS[0];
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(active.command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-full max-w-[32rem] flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-4 text-[0.9375rem]">
|
||||
{TABS.map((tab, i) => (
|
||||
<div key={tab.id} className="flex items-center gap-4">
|
||||
{i > 0 && <span className="h-4 w-px bg-border" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(tab.id);
|
||||
setCopied(false);
|
||||
}}
|
||||
className={`cursor-pointer font-medium transition ${
|
||||
activeId === tab.id ? "text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full items-center overflow-hidden rounded-md border border-border bg-surface">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap px-4 py-2 text-left font-mono text-[0.8125rem] tracking-tight text-muted">
|
||||
<span className="text-muted/60">$ </span>
|
||||
{active.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy install command"
|
||||
onClick={handleCopy}
|
||||
className="flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center self-stretch border-l border-border text-muted transition hover:bg-surface-muted hover:text-fg"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="text-success">
|
||||
<path d="M13.78 3.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 8.28a.75.75 0 0 1 1.06-1.06L6 9.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 1.75A1.75 1.75 0 0 1 6.75 0h5.5A1.75 1.75 0 0 1 14 1.75v5.5A1.75 1.75 0 0 1 12.25 9H11V7.5h1.25a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-5.5a.25.25 0 0 0-.25.25V3H5V1.75Z" />
|
||||
<path d="M2 4.75A1.75 1.75 0 0 1 3.75 3h5.5A1.75 1.75 0 0 1 11 4.75v5.5A1.75 1.75 0 0 1 9.25 12h-5.5A1.75 1.75 0 0 1 2 10.25v-5.5Zm1.75-.25a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h5.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-5.5Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Theme-aware, responsive version of the "agent coding loop" comparison.
|
||||
Scales to fit its container (never upscales past natural size). */
|
||||
|
||||
type Tone = "step" | "compiler" | "optional";
|
||||
type Node = { label: string; tone: Tone };
|
||||
type Loop = { from: number; to: number; side: "left" | "right" };
|
||||
type Col = { x: number; kicker: string; title: string; loop: Loop; nodes: Node[] };
|
||||
|
||||
const W = 372;
|
||||
const H = 68;
|
||||
const NSTART = 88;
|
||||
const STEP = 104;
|
||||
|
||||
const COLS: Col[] = [
|
||||
{
|
||||
x: 44,
|
||||
kicker: "Traditional",
|
||||
title: "Text is the source of truth",
|
||||
loop: { from: 5, to: 0, side: "left" },
|
||||
nodes: [
|
||||
{ label: "agent writes source text", tone: "step" },
|
||||
{ label: "format", tone: "step" },
|
||||
{ label: "check", tone: "step" },
|
||||
{ label: "build", tone: "step" },
|
||||
{ label: "test", tone: "step" },
|
||||
{ label: "inspect failures", tone: "step" },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 520,
|
||||
kicker: "Zerolang",
|
||||
title: "Graph is the program",
|
||||
loop: { from: 1, to: 0, side: "right" },
|
||||
nodes: [
|
||||
{ label: "agent writes graph patch", tone: "step" },
|
||||
{ label: "compiler checks patch", tone: "compiler" },
|
||||
{ label: "projection available for review", tone: "optional" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TONE: Record<Tone, string> = {
|
||||
step: "h-full w-full border border-border bg-surface text-fg",
|
||||
compiler: "h-full w-full bg-fg font-semibold text-bg",
|
||||
optional: "h-full w-full border border-dashed border-border bg-transparent text-muted",
|
||||
};
|
||||
|
||||
const VB_W = 936;
|
||||
const MAX = Math.max(...COLS.map((c) => c.nodes.length));
|
||||
const VB_H = NSTART + (MAX - 1) * STEP + H;
|
||||
|
||||
const STROKE = "color-mix(in srgb, var(--color-fg) 30%, transparent)";
|
||||
const ARROW = "color-mix(in srgb, var(--color-fg) 55%, transparent)";
|
||||
const nodeTop = (i: number) => NSTART + i * STEP;
|
||||
|
||||
export function LoopDiagram() {
|
||||
const verticals: { key: string; d: string }[] = [];
|
||||
const loops: { key: string; d: string; lx: number; ly: number }[] = [];
|
||||
|
||||
for (const col of COLS) {
|
||||
const cx = col.x + W / 2;
|
||||
col.nodes.forEach((_, i) => {
|
||||
if (i < col.nodes.length - 1) {
|
||||
verticals.push({
|
||||
key: `${col.x}-${i}`,
|
||||
d: `M ${cx} ${nodeTop(i) + H + 2} L ${cx} ${nodeTop(i + 1) - 4}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
const sy = nodeTop(col.loop.from) + H / 2;
|
||||
const ty = nodeTop(col.loop.to) + H / 2;
|
||||
const r = 12;
|
||||
let d: string;
|
||||
let lx: number;
|
||||
if (col.loop.side === "right") {
|
||||
const edge = col.x + W;
|
||||
const bulge = edge + 28;
|
||||
d = `M ${edge} ${sy} H ${bulge - r} Q ${bulge} ${sy} ${bulge} ${sy - r} V ${ty + r} Q ${bulge} ${ty} ${bulge - r} ${ty} H ${edge + 3}`;
|
||||
lx = bulge;
|
||||
} else {
|
||||
const edge = col.x;
|
||||
const bulge = edge - 36;
|
||||
d = `M ${edge} ${sy} H ${bulge + r} Q ${bulge} ${sy} ${bulge} ${sy - r} V ${ty + r} Q ${bulge} ${ty} ${bulge + r} ${ty} H ${edge - 3}`;
|
||||
lx = bulge;
|
||||
}
|
||||
loops.push({ key: `loop-${col.x}`, d, lx, ly: (sy + ty) / 2 });
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`-40 0 ${VB_W + 80} ${VB_H}`}
|
||||
className="mx-auto block h-auto w-full"
|
||||
style={{ maxWidth: VB_W + 80 }}
|
||||
role="img"
|
||||
aria-label="Traditional source loop versus the Zerolang graph loop"
|
||||
>
|
||||
<defs>
|
||||
<marker id="loop-ah" markerUnits="userSpaceOnUse" markerWidth="10" markerHeight="10" refX="7" refY="5" orient="auto">
|
||||
<path d="M1.5 1.5 L8 5 L1.5 8.5 Z" fill={ARROW} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* column headers */}
|
||||
{COLS.map((col) => (
|
||||
<foreignObject key={`h-${col.x}`} x={col.x} y={0} width={W} height={NSTART - 24}>
|
||||
<div className="font-mono text-[12px] font-medium uppercase tracking-[0.2em] text-muted">
|
||||
{col.kicker}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[20px] font-medium tracking-[-0.02em] text-fg">{col.title}</div>
|
||||
</foreignObject>
|
||||
))}
|
||||
|
||||
{verticals.map((v) => (
|
||||
<path key={v.key} d={v.d} fill="none" stroke={STROKE} strokeWidth={1.75} markerEnd="url(#loop-ah)" />
|
||||
))}
|
||||
{loops.map((l) => (
|
||||
<path key={l.key} d={l.d} fill="none" stroke={STROKE} strokeWidth={1.75} markerEnd="url(#loop-ah)" />
|
||||
))}
|
||||
|
||||
{loops.map((l) => (
|
||||
<foreignObject key={`lbl-${l.key}`} x={l.lx - 40} y={l.ly - 16} width={80} height={32}>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="rounded border border-border bg-bg px-1.5 py-0.5 font-mono text-[11px] text-muted">
|
||||
repeat
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
))}
|
||||
|
||||
{COLS.map((col) =>
|
||||
col.nodes.map((n, i) => (
|
||||
<foreignObject key={`${col.x}-${i}`} x={col.x} y={nodeTop(i)} width={W} height={H}>
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-2xl px-4 text-center text-[16px] font-medium leading-[1.2] tracking-[-0.01em] ${TONE[n.tone]}`}
|
||||
>
|
||||
{n.label}
|
||||
</div>
|
||||
</foreignObject>
|
||||
)),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchResult } from "@/lib/types";
|
||||
|
||||
export function Search() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
(href: string) => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
router.push(href);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: globalThis.KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
} else {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { results?: SearchResult[] };
|
||||
setResults(Array.isArray(data.results) ? data.results : []);
|
||||
}
|
||||
} catch {
|
||||
// aborted or network error
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [results]);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && results[activeIndex]) {
|
||||
e.preventDefault();
|
||||
navigate(results[activeIndex].href);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const active = listRef.current?.querySelector("[data-active='true']");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const hasQuery = query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-muted px-3 py-1.5 text-sm text-muted transition-colors hover:border-fg/25 hover:text-fg sm:flex"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
Search docs
|
||||
<kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border bg-bg px-1.5 py-0.5 font-mono text-[10px] text-muted">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex cursor-pointer items-center text-muted transition-colors hover:text-fg sm:hidden"
|
||||
aria-label="Search docs"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent showCloseButton={false} className="gap-0 p-0 sm:max-w-lg">
|
||||
<DialogTitle className="sr-only">Search documentation</DialogTitle>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-muted">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search docs..."
|
||||
className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => setQuery("")} className="text-muted hover:text-fg">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="max-h-[min(60vh,400px)] overflow-y-auto p-2">
|
||||
{loading && hasQuery ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted border-t-transparent" />
|
||||
</div>
|
||||
) : hasQuery && results.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted">No results found.</p>
|
||||
) : !hasQuery ? (
|
||||
<p className="py-6 text-center text-sm text-muted">Type to search documentation...</p>
|
||||
) : (
|
||||
results.map((item, i) => (
|
||||
<button
|
||||
key={item.href}
|
||||
data-active={i === activeIndex}
|
||||
onClick={() => navigate(item.href)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
|
||||
i === activeIndex ? "bg-surface-muted text-fg" : "text-fg",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
{item.snippet && (
|
||||
<span className="line-clamp-2 text-xs leading-relaxed text-muted">{item.snippet}</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { GeistPixelSquare } from "geist/font/pixel";
|
||||
import { Search } from "@/components/search";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { LogoIcon } from "@/components/icons";
|
||||
|
||||
export function SiteHeader({ stars }: { stars: string }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white/90 backdrop-blur-sm dark:bg-neutral-950/90">
|
||||
<div className="flex h-14 items-center justify-between gap-6 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<LogoIcon width="16" height="14" />
|
||||
<span className={`${GeistPixelSquare.className} text-lg`}>zerolang</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/getting-started"
|
||||
className="text-sm text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Search />
|
||||
<a
|
||||
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_REPO || "vercel-labs/zerolang"}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
{stars ? <span>{stars}</span> : null}
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="h-8 w-8" />;
|
||||
}
|
||||
|
||||
const isDark = (theme === "system" ? resolvedTheme : theme) === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
aria-label="Toggle theme"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog(props: ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal(props: ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogContentProps = ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
children?: ReactNode;
|
||||
showCloseButton?: boolean;
|
||||
};
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-bg p-6 shadow-card outline-none duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("font-semibold text-fg", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogContent, DialogTitle };
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet(props: ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal(props: ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
children?: ReactNode;
|
||||
side?: "left" | "right";
|
||||
showCloseButton?: boolean;
|
||||
overlayClassName?: string;
|
||||
};
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
overlayClassName,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-white shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-neutral-950",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l border-neutral-200 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right dark:border-neutral-800 sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r border-neutral-200 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left dark:border-neutral-800 sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-neutral-900 dark:text-neutral-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTrigger({ className, ...props }: ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Sheet, SheetTrigger, SheetContent, SheetTitle };
|
||||
Reference in New Issue
Block a user