Draw the app's own pages with @hanzo/gui

What was left of Tailwind and base-ui in apps/docs was the pages around
the docs: the front page, the hero and product sections on /docs, the
catalogs, search, feedback, the 404, the blog and the sign-in screens.
Each is a gui part on the theme now, and the app source carries no
utility class, no Radix and no base-ui import.

The front page follows the reader's theme instead of forcing a dark
subtree, and its API Reference card pointed at /reference, which is not
a route; it goes to /docs/openapi.  Search is one gui dialog opened
from the rail or with Cmd-K, over the same static index, with the jump
and the ask-an-agent rows it had.  The feedback control sends a
docs.feedback event on the one telemetry stream; it used to hand the
reader's answer to a stub that logged it to their own console.

The blog sits in the docs shell on the same page frame, and a post no
longer prints its title twice.  The registry build, the fork's preview
components, the unused AI-search dialogs, the API page and the tree's
icon plugin go: nothing rendered them, and the icons were an SVG tree
serialised into every page.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
This commit is contained in:
2026-09-27 15:23:16 -07:00
parent 5af1e60dfb
commit 0d7c74917d
81 changed files with 1874 additions and 6094 deletions
+2 -1
View File
@@ -362,6 +362,7 @@ excluded — it is upstream's):
|---|---|---|---|
| `apps/docs` app+components+lib, before phase 1 | 2,189 / 52 | 2 | 0 |
| `apps/docs` after phase 1 | 1,906 / 37 | 2 | 0 |
| `apps/docs` after phase 3 | 0 / 0 | 0 | 0 |
| `packages/base-ui` (@hanzo/docs-base-ui) | 2,774 / 76 | 0 | 10 |
| `packages/radix-ui` (@hanzo/docs-ui, via openapi/twoslash/typescript) | 2,568 / 71 | 9 | 0 |
| `packages/openapi` | 794 / 23 | 3 | 0 |
@@ -380,7 +381,7 @@ Folds: plain state + gui stacks. Icons: `@hanzogui/lucide-icons-2`. Theme:
|---|---|---|---|
| 1 | Chrome on every page: rail, tree, bar, outline, page header, search trigger, account, theme | base-ui `DocsLayout`/`DocsPage`/sidebar/toc, next-themes, the corner dock | shipped (f11eb49dc2) |
| 2 | Page bodies: markdown's elements (`components/mdx/prose.tsx`), the parts content names — Callout, Cards, Tabs, Steps, Accordion, Files, code blocks and code tabs (`blocks.tsx`, `tabs.tsx`, `code.tsx`) — and `Example`; content imports none of them; ported and studio pages reach them through the `parts` aliases in next.config | Tailwind typography, base-ui MDX components, shiki.css, the HoverCard link preview (a `title` now) | shipped |
| 3 | App pages and app parts: landing, catalogs, product sections, provider strip, hero, feedback, search dialog + AI search, 404, blog (HomeLayout), login/callback | base-ui search dialog, `RootProvider`, the remaining Tailwind in `app/` and `components/` | next |
| 3 | App pages and app parts: landing (on the theme now, no forced dark), hero, product sections, provider strip, catalogs, search (`search.tsx` + `finder.tsx`, ⌘K), feedback (a real `docs.feedback` event, not a console stub), 404, blog (in the shell, on the page frame), sign-in screens | base-ui search dialog, `RootProvider`, HomeLayout, the registry build and the dead preview / AI-search / API-page modules | shipped |
| 4 | The build: drop `@import 'tailwindcss'`, the presets, `@tailwindcss/postcss` + `postcss.config.mjs`, `tailwind-merge`, `cva`, every `@radix-ui/*` and `@base-ui/*`, `@hanzo/docs-base-ui`/`@hanzo/docs-ui` from `apps/docs`; delete what nothing else consumes (`packages/gui` stub, the registry build) | — | |
**Known gap in @hanzo/ui (8.27.18):** its `.d.ts` augments `@hanzogui/web`
@@ -1,22 +0,0 @@
'use client';
import { Check, Share } from 'lucide-react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { useCopyButton } from '@hanzo/docs-base-ui/utils/use-copy-button';
export function ShareButton({ url }: { url: string }) {
const [isChecked, onCopy] = useCopyButton(() => {
void navigator.clipboard.writeText(`${window.location.origin}${url}`);
});
return (
<button
type="button"
className={cn(buttonVariants({ variant: 'primary', size: 'md', className: 'gap-2' }))}
onClick={onCopy}
>
{isChecked ? <Check className="size-4" /> : <Share className="size-4" />}
{isChecked ? 'Copied URL' : 'Share Post'}
</button>
);
}
+27 -57
View File
@@ -1,80 +1,50 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import type { TOCItemType } from '@hanzo/docs-core/toc';
import { InlineTOC } from '@hanzo/docs-base-ui/components/inline-toc';
import path from 'node:path';
import { blog } from '@/lib/source';
import { createMetadata } from '@/lib/metadata';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { ShareButton } from '@/app/(home)/blog/[slug]/page.client';
import { getMDXComponents } from '@/components/mdx';
import path from 'node:path';
import { cn } from '@/lib/cn';
import { Body } from '@/components/mdx/prose';
import { Page as Frame } from '@/components/page';
interface BlogPageProps {
params: Promise<{ slug: string }>;
}
// Force static generation for GitHub Pages export
// Force static generation for the export.
export const dynamic = 'force-static';
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
export default async function Page(props: BlogPageProps) {
const params = await props.params;
const page = blog.getPage([params.slug]);
const components = getMDXComponents();
if (!page) notFound();
// Create a clean copy of data to avoid proxy trap issues during static generation
// The proxy wrapping by Next.js can cause ownKeys invariant violations
const rawData = page.data as unknown as { author: string; date?: string; title: string; description?: string; load: () => Promise<{ body: React.ComponentType<{ components: object }>; toc: TOCItemType[] }> };
const author = rawData.author;
const date = rawData.date;
const pageTitle = rawData.title;
const pageDescription = rawData.description;
const loadFn = rawData.load;
const { body: Mdx, toc } = await loadFn();
// A plain copy of the data: Next's proxy around it trips an ownKeys invariant
// during static generation.
const data = page.data as unknown as {
author: string;
date?: string;
title: string;
description?: string;
load: () => Promise<{ body: React.ComponentType<{ components: object }>; toc: TOCItemType[] }>;
};
const { body: Mdx, toc } = await data.load();
const when = DAY.format(new Date(data.date ?? path.basename(page.path, path.extname(page.path))));
return (
<article className="flex flex-col mx-auto w-full max-w-[800px] px-4 py-8">
<div className="flex flex-row gap-4 text-sm mb-8">
<div>
<p className="mb-1 text-fd-muted-foreground">Written by</p>
<p className="font-medium">{author}</p>
</div>
<div>
<p className="mb-1 text-sm text-fd-muted-foreground">At</p>
<p className="font-medium">
{new Date(
date ?? path.basename(page.path, path.extname(page.path)),
).toDateString()}
</p>
</div>
</div>
<h1 className="text-3xl font-semibold mb-4">{pageTitle}</h1>
<p className="text-fd-muted-foreground mb-8">{pageDescription}</p>
<div className="prose min-w-0 flex-1">
<div className="flex flex-row items-center gap-2 mb-8 not-prose">
<ShareButton url={page.url} />
<Link
href="/blog"
className={cn(
buttonVariants({
size: 'sm',
variant: 'secondary',
}),
)}
>
Back
</Link>
</div>
<InlineTOC items={toc} />
<Mdx components={components} />
</div>
</article>
<Frame
title={data.title}
description={data.description}
crumbs={[{ name: 'Blog', url: '/blog' }, { name: data.author ? `${data.author} · ${when}` : when }]}
toc={toc}
>
<Body>
<Mdx components={getMDXComponents()} />
</Body>
</Frame>
);
}
+19 -52
View File
@@ -1,64 +1,31 @@
import Link from 'next/link';
import { blog } from '@/lib/source';
import { PathUtils } from '@hanzo/docs/core/source';
import { Posts } from '@/components/posts';
// Blog frontmatter type (matches source.config.ts schema)
// Blog frontmatter (source.config.ts). A post with no date is dated by its file.
interface BlogData {
title: string;
description?: string;
author: string;
date: string | Date;
date?: string | Date;
}
function getName(path: string) {
return PathUtils.basename(path, PathUtils.extname(path));
}
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
export default function Page() {
const posts = [...blog.getPages()].sort(
(a, b) =>
new Date((b.data as unknown as BlogData).date ?? getName(b.path)).getTime() -
new Date((a.data as unknown as BlogData).date ?? getName(a.path)).getTime(),
);
const posts = blog
.getPages()
.map((page) => {
const data = page.data as unknown as BlogData;
const when = new Date(data.date ?? PathUtils.basename(page.path, PathUtils.extname(page.path)));
return { page, data, when };
})
.sort((a, b) => b.when.getTime() - a.when.getTime())
.map(({ page, data, when }) => ({
url: page.url,
title: data.title,
description: data.description,
date: DAY.format(when),
}));
return (
<main className="mx-auto w-full max-w-page px-4 pb-12 md:py-12">
{/* The backdrop is the brand's own architectural grid — @hanzo/brand's
hero treatment, drawn in CSS. It replaced 2.2 MB of upstream orange
gradient art that came through the fork and matched no Hanzo palette. */}
<div className="relative dark mb-2 overflow-hidden rounded-2xl border border-fd-border bg-fd-card p-8 z-2 md:p-12">
<div
aria-hidden
className="absolute inset-0 -z-1"
style={{
backgroundImage:
'linear-gradient(to right, rgba(255,255,255,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.07) 1px, transparent 1px)',
backgroundSize: '50px 50px',
}}
/>
<h1 className="mb-4 text-3xl text-landing-foreground font-mono font-medium">
Hanzo Docs Blog
</h1>
<p className="text-sm font-mono text-landing-foreground-200">
Latest announcements and updates.
</p>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-3 xl:grid-cols-4">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="flex flex-col bg-fd-card rounded-2xl border shadow-sm p-4 transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
>
<p className="font-medium">{post.data.title}</p>
<p className="text-sm text-fd-muted-foreground">{post.data.description}</p>
<p className="mt-auto pt-4 text-xs text-fd-muted-foreground">
{new Date((post.data as unknown as BlogData).date ?? getName(post.path)).toDateString()}
</p>
</Link>
))}
</div>
</main>
);
return <Posts posts={posts} />;
}
+5 -40
View File
@@ -1,48 +1,13 @@
import { HomeLayout } from '@hanzo/docs-base-ui/layouts/home';
import { baseOptions, linkItems } from '@/components/layouts/shared';
import { MeetHanzo } from '@/components/meet-hanzo';
import { HanzoPreFooterCTA } from '@hanzogui/shell';
import { Shell } from '@/components/shell';
import { Footer } from '@/components/footer';
// The blog sits in the same chrome as the docs: one rail, one bar, one way to
// find anything from any page.
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<>
<HomeLayout
{...baseOptions()}
themeSwitch={{ enabled: false }}
links={[
...linkItems,
{
// NO `on`. useLinkItems routes 'nav' to the desktop rail ONLY — which is
// max-lg:hidden and never reaches the mobile collapsible — so the home page
// had no ecosystem menu at all below 1024px (measured at 390 and 834: zero
// affordance). Omitting `on` puts the item in BOTH lists, which is what
// "one affordance, every width" actually requires. The removed app launcher
// carried the same shape and was equally invisible there, so this was never
// a regression — just never true.
type: 'custom',
secondary: true,
// NOT compact. This entry renders in the desktop rail AND in the mobile
// collapsible — a VERTICAL list where every other row is a word, so a bare
// glyph there is precisely the unlabeled affordance that was removed. The
// home header has no sidebar competing for width, so the label costs it
// nothing. `compact` exists for the docs header, which is a fixed 56px row.
children: <MeetHanzo />,
},
]}
className="dark:bg-neutral-950 dark:[--color-fd-background:var(--color-neutral-950)]"
>
{children}
</HomeLayout>
{/* The pre-footer is glass drawn for a dark ground, and the blog is light
for a light reader: on the white body its heading printed at 2.38:1 and
its links at 4.19:1. It sits on the same dark ground the landing gives
it, with the site's foreground as @hanzo/ui's --text-primary (its ramp
has no `.dark`; see the landing layout). */}
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
<HanzoPreFooterCTA surface="hanzo.ai" />
</div>
<Footer />
<Shell>{children}</Shell>
<Footer cta />
</>
);
}
+2 -15
View File
@@ -1,27 +1,14 @@
import { Shell } from '@/components/shell';
import { HanzoPreFooterCTA } from '@hanzogui/shell';
import { Footer } from '@/components/footer';
// The landing page carries the SAME chrome as /docs — rail, tree, bar — because
// it is where a reader does not yet know what is here, and the tree is the
// table of contents.
//
// The page itself paints a dark palette of its own (literal white-on-neutral),
// so the page, and only the page, is held dark with gui's `t_dark` on a wrapper:
// the chrome around it follows the reader's theme like every other route. That
// wrapper goes when the landing moves onto gui tokens.
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<>
<Shell>
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
{children}
</div>
</Shell>
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
<HanzoPreFooterCTA surface="hanzo.ai" />
</div>
<Footer />
<Shell>{children}</Shell>
<Footer cta />
</>
);
}
+32 -766
View File
@@ -1,772 +1,38 @@
import Link from 'next/link';
import {
Activity,
ArrowRight,
Code2,
Database,
FileJson2,
Globe,
LayoutGrid,
Package,
Server,
Shield,
ShoppingCart,
Sparkle,
Terminal,
Workflow,
} from 'lucide-react';
import { Card, CardContent, CardTitle, XStack } from '@hanzo/ui';
import { Grid } from '@hanzo/ui/grid';
import { Agent } from '@/components/agent';
import { Tabs, Tab } from '@hanzo/docs-base-ui/components/tabs';
import { HeroField } from '@/components/hero-field';
import { CodeBlock } from '@/components/code-block';
import { Landing } from '@/components/landing';
import { Example } from '@/components/example';
import { Tab, Tabs } from '@/components/mdx/tabs';
/* -- The nine domains -- category-first, mirrors the console shell ---------- */
// The install and first-call tabs are the same two steps /docs/quickstart shows,
// highlighted here on the server so the page ships tokens, not a highlighter.
// Every SDK tab is the catalogue read — the same call the quickstart makes.
const install = [
['npm', 'bash', 'npm i -g hanzo'],
['Script', 'bash', 'curl -fsSL hanzo.sh | sh'],
['Homebrew', 'bash', 'brew install hanzoai/tap/hanzo'],
['From source', 'bash', 'git clone https://github.com/hanzoai/cli && cd cli && cargo install --path .'],
] as const;
const domains = [
{
name: 'Identity & trust',
desc: 'Who someone is, what they may touch, and where secrets stay safe. IAM, AuthZ, KMS, MPC, Zero-Trust.',
icon: Shield,
href: '/docs/openapi/iam',
tag: 'Identity',
},
{
name: 'Intelligence',
desc: 'The mind of the cloud — every model, agent, and tool you can call. Models, Agents, MCP, Embeddings, Prompts, GPUs, Functions.',
icon: Sparkle,
href: '/docs/openapi/ai',
tag: 'Intelligence',
},
{
name: 'Data',
desc: 'Somewhere to put your data and read it back fast. SQL, Vector, KV, Search, Object, Base, DocDB.',
icon: Database,
href: '/docs/openapi/provisioning',
tag: 'Data',
},
{
name: 'Streams',
desc: 'Move messages and run work in the background, reliably. PubSub, Tasks, Pipelines, Crawl.',
icon: Workflow,
href: '/docs/openapi/pubsub',
tag: 'Streams',
},
{
name: 'Observability',
desc: 'See exactly what your system is doing, live. Metrics, Logs, Traces, Sessions, Evals, Analytics.',
icon: Activity,
href: '/docs/openapi/o11y',
tag: 'Observe',
},
{
name: 'Commerce',
desc: 'Turn usage into money — meter it, price it, bill it, reward it. Commerce, Billing, Marketplace, Referrals.',
icon: ShoppingCart,
href: '/docs/openapi/commerce',
tag: 'Commerce',
},
{
name: 'Platform',
desc: 'Ship your code and run it anywhere. Gateway, Machines, Edge, Registry.',
icon: Server,
href: '/docs/openapi/gateway',
tag: 'Platform',
},
{
name: 'Applications',
desc: 'The finished products people use every day. Chat, Studio, Dev, Integrations, Apps.',
icon: LayoutGrid,
href: '/docs/openapi/git',
tag: 'Apps',
},
{
name: 'Chain',
desc: 'The networks the cloud speaks to — enumerate them, call one, read a holder\'s balances. Web3, Explorer.',
icon: Globe,
href: '/docs/openapi/web3',
tag: 'Chain',
},
];
const use = [
['CLI', 'bash', `hanzo auth login\n\nhanzo "explain quantum computing"\nhanzo models list\nhanzo projects deploy my-app`],
['TypeScript', 'typescript', `// npm i hanzoai\nimport { AiApi, Configuration } from 'hanzoai'\n\nconst ai = new AiApi(new Configuration())\n\nconst { data } = await ai.getModels()\nconsole.log(data.data?.length, 'models')`],
['Python', 'python', `# pip install hanzoai\nfrom hanzoai.cloud import AiApi, ApiClient, Configuration\n\nai = AiApi(ApiClient(Configuration()))\n\nprint(len(ai.get_models().data or []), "models")`],
['Go', 'go', `// go get github.com/hanzoai/go-sdk/v8\nimport hanzoai "github.com/hanzoai/go-sdk/v8"\n\nclient := hanzoai.NewAPIClient(hanzoai.NewConfiguration())\n\nmodels, _, err := client.AiAPI.GetModels(ctx).Execute()`],
['Rust', 'rust', `// cargo add hanzo-client\nuse hanzo_client::apis::{ai_api, configuration::Configuration};\n\nlet cfg = Configuration::new();\n\nlet models = ai_api::get_models(&cfg).await?;`],
['HTTP', 'bash', `curl https://api.hanzo.ai/v1/chat/completions \\\n -H "Authorization: Bearer $HANZO_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"zen5","messages":[{"role":"user","content":"Hello!"}]}'`],
] as const;
/* -- Developer quick links -------------------------------------------------- */
const devLinks = [
{
name: 'The Network',
desc: 'Run the whole cloud yourself',
href: '/docs/network',
icon: Globe,
},
{
name: 'SDKs',
desc: 'Python, TypeScript, Go, Rust, C++, Swift, Kotlin',
href: '/docs/sdks',
icon: Code2,
},
{
name: 'API Reference',
desc: 'Every /v1 endpoint, live',
href: '/reference',
icon: FileJson2,
},
{
name: 'Architecture',
desc: 'One binary, one contract',
href: '/docs/architecture',
icon: Package,
},
];
/* -- Page ------------------------------------------------------------------- */
export default function Page() {
function Group({ rows }: { rows: readonly (readonly [string, string, string])[] }) {
return (
// `[grid-area:main]` because this page hangs off DocsLayout, whose container
// is a named 5-column grid — sidebar, header, main, toc. An unplaced child
// is auto-placed into an implicit row under column one and renders 268px
// wide beneath the sidebar. The docs pages get this from DocsPage; this one
// is hand-built, so it says it itself.
//
// Widths below are sized to the column the grid gives it — roughly 1170px at
// a 1440 viewport, not the 1400px the full-bleed home layout had — so the
// page is laid out for the space it has rather than clamped into it. Every
// `md:`/`lg:` column count that assumed the full viewport moved up one step,
// because the sidebar takes 268px from `md` up and 768→1024→1280 step by 256.
<main className="[grid-area:main] min-w-0 pb-6 md:pb-12">
{/* -- Hero ---------------------------------------------------------- */}
{/* One column, centred. It was `flex flex-col items-center`, which is the
same picture drawn with the wrong primitive: a column of blocks IS a
one-track grid, and saying so means `justifyItems` centres the children
without each of them having to be told. `minmax(0, 1fr)` pins the
track to the section; the implicit `auto` track grows to the cards
grid's intrinsic 896px and overflows a phone. */}
<section
className="relative mx-auto w-full max-w-5xl px-6 pt-24 pb-16 md:pt-36 md:pb-24"
style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', justifyItems: 'center', textAlign: 'center' }}
>
{/* The atmosphere behind the headline, as dots rather than a gradient —
the halftone IS the mark, and a gradient carries none of it. `fade`
dissolves the canvas rectangle on both axes so the field ends in the
page instead of on an edge you can see. It is its own client island
because its shape is a function; see components/hero-field.tsx. */}
<HeroField />
{/* The model families first. They are the reason to choose the
platform; a capability count is the reason to choose nothing. */}
<Link
href="/docs/models"
className="relative mb-8 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-neutral-400 backdrop-blur transition-colors hover:border-white/20 hover:text-white"
style={{ display: 'inline-grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 8 }}
>
<span className="rounded-full bg-white" style={{ width: 6, height: 6 }} />
Zen generates · Enso routes · Kai decides
<ArrowRight className="size-3.5" />
</Link>
{/* Display optics, measured off hanzo.ai: leading 1.0 and tracking
-0.025em are most of why that type reads as SET rather than merely
large — at display size the default 1.05 opens a gap the eye reads as
two lines instead of one block. The ink is #e5e5e5, not #fff: pure
white halates against a near-black ground, thickening the stems. */}
<h1 className="relative text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-none">
<span className="text-[#e5e5e5]">Build anything<br className="hidden sm:block" /> with Hanzo.</span>
</h1>
<p className="relative mt-5 max-w-xl text-neutral-300 text-lg md:text-xl leading-relaxed">
Every model. Every tool. One key. Start in the browser, ship from your
terminal, and when you want it on your own hardware, take the whole thing
with you — it is the same software we run in production.
</p>
{/* -- Install command -- the main CTA ------------------------------ */}
<div className="relative mt-10 w-full max-w-lg">
<Card
bg="rgba(255,255,255,0.03)"
borderColor="rgba(255,255,255,0.08)"
rounded={16}
p={4}
py={4}
gap={0}
>
{/* `$` then the command: a fixed leader and a track that takes the
rest. The `flex-1` that used to be on the span was the child
claiming the width; the track owns it now. */}
<CardContent
style={{
display: 'grid',
gridTemplateColumns: 'auto minmax(0, 1fr)',
alignItems: 'center',
columnGap: 12,
borderRadius: 12,
background: '#0a0a0a',
padding: '16px 20px',
}}
>
<span className="font-mono text-sm text-neutral-400 select-none">$</span>
<span className="font-mono text-sm text-white" style={{ textAlign: 'left' }}>curl hanzo.sh | sh</span>
</CardContent>
</Card>
<p className="text-xs text-neutral-400 mt-3">
Installs the <code className="text-neutral-400 bg-white/5 px-1 py-0.5 rounded">hanzo</code> CLI.
Then <code className="text-neutral-400 bg-white/5 px-1 py-0.5 rounded">hanzo auth login</code> to get a key.
</p>
</div>
{/* -- The other way in: hand it to an agent ------------------------ */}
{/* Beside the install line, because they are the two ways to start and a
reader arriving with an agent open should not have to find that out
further down. It is the SAME control the doc pages carry, so what it
copies here is what it copies there — one prompt, one place it is
written, and it sets the agent up to use Hanzo's models and skills
rather than merely to call the API.
It also has to be here rather than in the bar: this page has no table
of contents, which is where the doc pages host it, so without this the
the landing page offered no way to hand anything to an agent at all. */}
<div className="relative mt-6" style={{ display: 'grid', justifyItems: 'center', rowGap: 8 }}>
<Agent />
<p className="text-xs text-neutral-400">
Or hand this to your agent — it installs the CLI, the MCP server and
the{' '}
<Link
href="https://hanzoskills.com"
target="_blank"
rel="noreferrer"
className="text-neutral-300 underline underline-offset-4 hover:text-white"
>
skills
</Link>
, then points its own model calls at Hanzo.
</p>
</div>
{/* The three doors, in descending order of how much you type. The same
three the docs masthead offers, so the choice a reader makes here is
the one they keep. Replaces the stats bar: 67 / 436 / 600+ / 7 asked a
reader to be impressed before they knew what the thing was, and the
counts went stale the moment a service shipped. */}
{/* The track floor is the whole responsive story: a column never goes
below 260px, and there are never more than three. Breakpoints cannot
express this, which is why the three they replaced read 3 → 1 → 3 —
at 768 the sidebar has appeared but the viewport has not grown to pay
for it, so a viewport query says "wide" while the column is 500px and
a third of it put "Build with App" on three lines. `auto-fill`
measures the COLUMN, so the sidebar is simply part of the arithmetic
and the row is right at every width without being told about any. */}
<Grid
columns={{ min: 260, max: 3 }}
gap={16}
style={{ position: 'relative', marginTop: 48, width: '100%', maxWidth: 896 }}
>
{[
{
eyebrow: 'No code',
title: 'Build with App',
body: 'Describe it in English and watch it build. Chat, agents and MCP tools in the browser.',
href: 'https://hanzo.app',
external: true,
},
{
eyebrow: 'In your terminal',
title: 'Build with Dev',
body: 'Our coding agent, in your repo. Or bring Claude Code and Codex — they work here too.',
href: '/docs/cli',
},
{
eyebrow: 'Lower level',
title: 'Build with API',
body: 'Over 400 models behind one REST endpoint, with SDKs for every language we ship.',
href: '/docs/openapi',
},
].map((d) => (
// Three rows — eyebrow, title, body — so the gaps are declared once
// on the card instead of as a margin on each child that has to know
// what follows it.
<Link
key={d.title}
href={d.href}
{...(d.external ? { target: '_blank', rel: 'noreferrer' } : {})}
className="group rounded-2xl border border-white/[0.08] bg-white/[0.02] p-6 transition-all duration-200 hover:-translate-y-0.5 hover:border-white/20 hover:bg-white/[0.05]"
style={{
display: 'grid',
gridTemplateRows: 'auto auto auto',
alignContent: 'start',
rowGap: 6,
textAlign: 'left',
}}
>
<span className="text-xs font-medium text-neutral-300">
{d.eyebrow}
</span>
<span
className="text-lg font-semibold text-white"
style={{ display: 'grid', gridAutoFlow: 'column', justifyContent: 'start', alignItems: 'center', columnGap: 6 }}
>
{d.title}
<ArrowRight className="size-4 opacity-40 transition-all group-hover:translate-x-0.5 group-hover:opacity-100" />
</span>
<span className="text-sm leading-relaxed text-neutral-300">{d.body}</span>
</Link>
))}
</Grid>
</section>
<div className="mx-auto w-full max-w-5xl px-6 md:px-8 space-y-24">
{/* -- Quick Start Guide ------------------------------------------- */}
<section>
<h2 className="text-3xl font-bold tracking-tight mb-3">
Quick Start
</h2>
<p className="text-neutral-300 text-sm mb-8">
Install the CLI, log in, and reach every capability from your terminal.
</p>
{/* Install, then use — the same two steps /docs/quickstart shows, and
the same order of tabs, so the front page and the quickstart do not
teach different install lines. npm is first because it is the one
most readers already have. There is no pip tab: PyPI `hanzo` is the
orchestration library and installs a `hanzo-py` command, so it
cannot be a fourth way to get this binary. */}
<Tabs items={['npm', 'Script', 'Homebrew', 'From source']}>
<Tab value="npm">
<CodeBlock code={`npm i -g hanzo`} lang="bash" />
</Tab>
<Tab value="Script">
<CodeBlock code={`curl -fsSL hanzo.sh | sh`} lang="bash" />
</Tab>
<Tab value="Homebrew">
<CodeBlock code={`brew install hanzoai/tap/hanzo`} lang="bash" />
</Tab>
<Tab value="From source">
<CodeBlock
code={`git clone https://github.com/hanzoai/cli && cd cli && cargo install --path .`}
lang="bash"
/>
</Tab>
</Tabs>
<p className="text-neutral-300 text-sm mt-6 mb-3">
Sign in once, then reach every capability from the terminal — or from
your own code.
</p>
<Tabs items={['CLI', 'TypeScript', 'Python', 'Go', 'Rust', 'HTTP']}>
<Tab value="CLI">
<CodeBlock
code={`hanzo auth login\n\nhanzo "explain quantum computing"\nhanzo models list\nhanzo projects deploy my-app`}
lang="bash"
/>
</Tab>
{/* Every SDK tab is the catalogue read, the same call /docs/quickstart
opens with. It is the one operation all four generated clients
express and the one that answers without a credential, so a reader
can run the tab they are looking at. A typed chat call is not a
fourth thing they could paste here: cloud declares
/v1/chat/completions as an address with no requestBody, so every
generated method takes no prompt — the HTTP tab is where a
completion goes until the document describes its shape. */}
<Tab value="TypeScript">
<CodeBlock
code={`// npm i hanzoai\nimport { AiApi, Configuration } from 'hanzoai'\n\nconst ai = new AiApi(new Configuration())\n\nconst { data } = await ai.getModels()\nconsole.log(data.data?.length, 'models')`}
lang="typescript"
/>
</Tab>
<Tab value="Python">
<CodeBlock
code={`# pip install hanzoai\nfrom hanzoai.cloud import AiApi, ApiClient, Configuration\n\nai = AiApi(ApiClient(Configuration()))\n\nprint(len(ai.get_models().data or []), "models")`}
lang="python"
/>
</Tab>
<Tab value="Go">
<CodeBlock
code={`// go get github.com/hanzoai/go-sdk/v8\nimport hanzoai "github.com/hanzoai/go-sdk/v8"\n\nclient := hanzoai.NewAPIClient(hanzoai.NewConfiguration())\n\nmodels, _, err := client.AiAPI.GetModels(ctx).Execute()`}
lang="go"
/>
</Tab>
<Tab value="Rust">
<CodeBlock
code={`// cargo add hanzo-client\nuse hanzo_client::apis::{ai_api, configuration::Configuration};\n\nlet cfg = Configuration::new();\n\nlet models = ai_api::get_models(&cfg).await?;`}
lang="rust"
/>
</Tab>
<Tab value="HTTP">
<CodeBlock
code={`curl https://api.hanzo.ai/v1/chat/completions \\\n -H "Authorization: Bearer $HANZO_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"zen5","messages":[{"role":"user","content":"Hello!"}]}'`}
lang="bash"
/>
</Tab>
</Tabs>
</section>
{/* -- Nine domains -- category-first ------------------------------- */}
<section>
<h2 className="text-3xl font-bold tracking-tight mb-3">
One binary. The whole platform.
</h2>
<p className="text-neutral-300 text-sm mb-8">
Every capability has one name and one route. Browse by domain — click any card to go deep.
</p>
{/* 220, not 240: the column at 768 is 457px, and 240 needs 492 for a
second track, so a 40px difference in one number is the whole gap
between 2-up and 1-up there. Measured at the three real container
widths — 342 / 457 / 960 — this gives 1 / 2 / 3. */}
<Grid columns={{ min: 220, max: 3 }} gap={12}>
{domains.map((item) => {
const isExternal = !!(item as { external?: boolean }).external;
const linkProps = isExternal
? { target: '_blank' as const, rel: 'noreferrer noopener' }
: {};
return (
// Four rows: the icon/tag line, the name, the description, and
// the footer pushed to the bottom by `1fr` on the description
// row. Before, the footer was a separate flex child relying on
// the frame's own gap; now the card states its own shape and the
// minimum height has something to distribute.
<Card
key={item.name}
className="group"
minH={140}
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.08)"
hoverStyle={{ borderColor: 'rgba(255,255,255,0.2)', bg: 'rgba(255,255,255,0.05)' }}
rounded={16}
py={0}
gap={0}
style={{
position: 'relative',
display: 'grid',
gridTemplateRows: 'auto auto 1fr auto',
transition: 'all 300ms',
}}
>
{/* The whole card is the click target, so the link has no text
of its own — it takes its accessible name from the card
title it covers. Without this a screen reader announces a
bare "link". */}
{isExternal ? (
<a
href={item.href}
{...linkProps}
aria-label={item.name}
className="absolute inset-0 z-10"
/>
) : (
<Link href={item.href} aria-label={item.name} className="absolute inset-0 z-10" />
)}
<div
className="p-5 sm:p-6 pb-0"
style={{ display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', marginBottom: 12 }}
>
<div className="rounded-xl bg-white/[0.06] p-2" style={{ justifySelf: 'start' }}>
<item.icon className="size-5 text-neutral-400 group-hover:text-white transition-colors" />
</div>
<span className="text-[11px] font-medium text-neutral-400">{item.tag}</span>
</div>
<CardTitle px={24} fontSize={14} fontWeight="600" color="#ffffff" mb={6}>
{item.name}
</CardTitle>
<p className="px-5 sm:px-6 text-xs text-neutral-400 leading-relaxed group-hover:text-neutral-200 transition-colors">
{item.desc}
</p>
<span
className="p-5 sm:p-6 pt-3 text-[10px] font-medium text-neutral-400 group-hover:text-white transition-colors"
style={{ display: 'grid', gridAutoFlow: 'column', justifyContent: 'start', alignItems: 'center', columnGap: 4 }}
>
{isExternal ? 'Visit' : 'View docs'} <ArrowRight className="size-3" />
</span>
</Card>
);
})}
</Grid>
</section>
{/* -- Developer Tools --------------------------------------------- */}
<section>
<h2 className="text-3xl font-bold tracking-tight mb-3">
Developer Tools
</h2>
<p className="text-neutral-300 text-sm mb-8">
SDKs, APIs, and protocols for every stack.
</p>
{/* 230 is the 1 / 2 / 4 story the three breakpoints told, measured at
the column rather than the window: 1-up on a phone, 2-up once the
column clears ~476, and never more than four however wide it gets. */}
<Grid columns={{ min: 230, max: 4 }} gap={16}>
{devLinks.map((t) => (
<Card
key={t.name}
className="group"
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.08)"
hoverStyle={{ borderColor: 'rgba(255,255,255,0.2)', bg: 'rgba(255,255,255,0.04)' }}
rounded={16}
py={0}
gap={0}
style={{ position: 'relative', transition: 'all 300ms' }}
>
<Link href={t.href} aria-label={t.name} className="absolute inset-0 z-10" />
<CardContent
p={24}
style={{ display: 'grid', gridTemplateRows: 'auto auto auto', justifyItems: 'start', rowGap: 4 }}
>
<t.icon className="size-5 text-neutral-400 mb-3 group-hover:text-white transition-colors" />
<CardTitle fontSize={14} fontWeight="600" color="#ffffff">{t.name}</CardTitle>
<p className="text-xs text-neutral-400 group-hover:text-neutral-200 transition-colors">{t.desc}</p>
</CardContent>
</Card>
))}
</Grid>
</section>
{/* -- Models & providers ------------------------------------------ */}
<section>
<h2 className="text-3xl font-bold tracking-tight mb-3">
Every model, one API
</h2>
<p className="text-neutral-300 text-sm mb-8">
Over 400 models across every major provider — call any of them with one credential, one request shape.
</p>
<Grid columns={{ min: 120, max: 8 }} gap={12}>
{[
{ name: 'Zen', spec: 'Open weights' },
{ name: 'OpenAI', spec: 'GPT' },
{ name: 'Anthropic', spec: 'Claude' },
{ name: 'Qwen', spec: 'Open' },
{ name: 'Llama', spec: 'Open' },
{ name: 'DeepSeek', spec: 'Open' },
{ name: 'Mistral', spec: 'Open' },
{ name: 'Gemma', spec: 'Open' },
].map((p) => (
<Card
key={p.name}
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.06)"
rounded={8}
py={0}
gap={0}
>
<CardContent style={{ display: 'grid', rowGap: 4, padding: '12px 16px', textAlign: 'center' }}>
<span className="text-xs font-semibold text-white">{p.name}</span>
<span className="text-[10px] text-neutral-400">{p.spec}</span>
</CardContent>
</Card>
))}
</Grid>
</section>
{/* -- SDKs + connectors ------------------------------------------- */}
<Grid columns={{ min: 470, max: 2 }} gap={16}>
{[
{
icon: Code2,
title: 'SDKs in every language',
body: (
<>
Generated from one contract — the same <code className="font-mono">/v1</code> surface, typed for your stack.
</>
),
chips: ['Python', 'TypeScript', 'Go', 'Rust', 'C++', 'Dart'],
href: '/docs/sdks',
cta: 'SDK reference',
},
{
icon: Workflow,
title: 'Every tool, one MCP surface',
body: 'Native connectors + the open MCP registry — Slack, GitHub, Notion, Stripe, and more — exposed as MCP tools any agent can call.',
chips: ['Slack', 'GitHub', 'Notion', 'Stripe', 'Google', 'Linear', '+700 more'],
href: '/docs/mcp',
cta: 'MCP tools',
},
].map((s) => (
<Card
key={s.title}
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.08)"
rounded={16}
py={0}
gap={0}
overflow="hidden"
>
<CardContent
p={32}
style={{ display: 'grid', gridTemplateRows: 'auto auto auto auto', justifyItems: 'start', rowGap: 8 }}
>
<span style={{ display: 'grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 12 }}>
<s.icon className="size-5 text-neutral-400" />
<CardTitle fontSize={20} fontWeight="700" letterSpacing={-0.4}>{s.title}</CardTitle>
</span>
<p className="text-xs text-neutral-400 mb-4">{s.body}</p>
{/* A wrapping run of chips is the one place flex is still the
right answer — grid cannot wrap items of unequal intrinsic
width without giving them a track — so it is said with gui's
XStack, whose style props travel to native, rather than with
tailwind's. */}
<XStack flexWrap="wrap" gap={8}>
{s.chips.map((c) => (
<span key={c} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-neutral-400">
{c}
</span>
))}
</XStack>
<Link
href={s.href}
className="mt-4 text-xs font-medium text-neutral-400 hover:text-white transition-colors"
style={{ display: 'inline-grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 4 }}
>
{s.cta} <ArrowRight className="size-3" />
</Link>
</CardContent>
</Card>
))}
</Grid>
{/* -- What the CLI can do ----------------------------------------- */}
<Card
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.08)"
rounded={16}
py={0}
gap={0}
overflow="hidden"
>
<div className="p-8 md:p-10" style={{ display: 'grid', rowGap: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', alignItems: 'center', columnGap: 12 }}>
<div className="rounded-xl bg-white/[0.06] p-2.5">
<Terminal className="size-5 text-neutral-400" />
</div>
<div style={{ display: 'grid', rowGap: 2 }}>
<CardTitle fontSize={24} fontWeight="700" letterSpacing={-0.5}>
The <code className="font-mono">hanzo</code> CLI
</CardTitle>
<span className="text-xs text-neutral-400">A ~15 MB Rust client for any live cloud — prod, laptop, or self-host</span>
</div>
</div>
<Grid columns={{ min: 200, max: 3 }} gap={12}>
{[
{ cmd: 'hanzo chat', desc: 'Chat with any model interactively' },
{ cmd: 'hanzo models list', desc: 'Browse every available model' },
{ cmd: 'hanzo keys create', desc: 'Create and manage API keys' },
{ cmd: 'hanzo deploy', desc: 'Deploy apps with git push' },
{ cmd: 'hanzo logs', desc: 'Stream logs from any service' },
{ cmd: 'hanzo storage', desc: 'Manage S3-compatible storage' },
{ cmd: 'hanzo secrets', desc: 'Manage secrets and env vars' },
{ cmd: 'hanzo bot', desc: 'Deploy and manage AI bots' },
{ cmd: 'hanzo flow', desc: 'Run workflow automations' },
].map((item) => (
<Card
key={item.cmd}
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.06)"
rounded={8}
py={0}
gap={0}
>
<CardContent style={{ display: 'grid', rowGap: 4, padding: '12px 16px' }}>
<span className="text-xs font-mono font-medium text-white">{item.cmd}</span>
<span className="text-[11px] text-neutral-400">{item.desc}</span>
</CardContent>
</Card>
))}
</Grid>
</div>
</Card>
{/* -- Zen Models Banner ------------------------------------------- */}
<Card
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.08)"
rounded={16}
py={0}
gap={0}
overflow="hidden"
>
<div className="p-8 md:p-10" style={{ display: 'grid', rowGap: 24 }}>
<div style={{ display: 'grid', gridAutoFlow: 'column', justifyContent: 'start', alignItems: 'center', columnGap: 12 }}>
<Sparkle className="size-5 text-neutral-400" />
<CardTitle fontSize={24} fontWeight="700" letterSpacing={-0.5}>
Zen
</CardTitle>
<span className="rounded-full bg-white/5 px-2 py-0.5 font-mono text-xs text-neutral-400">open weights</span>
</div>
<p className="text-neutral-300 text-sm leading-relaxed max-w-2xl">
The generative family: reasoning, code, vision, speech, embeddings and safety. Weights on
Hugging Face; the same ids hosted on api.hanzo.ai, routed by Enso.
</p>
<Grid columns={{ min: 130, max: 6 }} gap={12}>
{[
{ name: 'zen6', spec: '27B dense, 1M context' },
{ name: 'zen6-coder', spec: 'Agentic code' },
{ name: 'zen6-flash', spec: 'Ternary vision' },
{ name: 'zen5', spec: 'Hosted' },
{ name: 'zen-embedding', spec: 'Retrieval' },
{ name: 'zen-guard', spec: 'Safety' },
].map((m) => (
<Card
key={m.name}
bg="rgba(255,255,255,0.02)"
borderColor="rgba(255,255,255,0.06)"
rounded={8}
py={0}
gap={0}
>
<CardContent style={{ display: 'grid', rowGap: 4, padding: 12 }}>
<span className="text-xs font-mono font-medium text-white">{m.name}</span>
<span className="text-[10px] text-neutral-400">{m.spec}</span>
</CardContent>
</Card>
))}
</Grid>
<XStack flexWrap="wrap" gap={12} alignItems="center">
<Link
href="/docs/models/zen"
className="rounded-full border border-white/20 px-4 py-2 text-xs text-white transition-colors hover:bg-white/5"
>
Zen models &rarr;
</Link>
<a
href="https://huggingface.co/zenlm"
target="_blank"
rel="noreferrer noopener"
className="text-xs text-neutral-400 hover:text-white transition-colors"
>
HuggingFace &rarr;
</a>
</XStack>
</div>
</Card>
{/* -- CTA --------------------------------------------------------- */}
<section className="relative text-center py-16">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_400px_200px_at_50%_50%,rgba(255,255,255,0.03),transparent_70%)]" />
<h2 className="relative text-3xl md:text-4xl font-bold tracking-tight mb-3">
Start building
</h2>
<p className="relative text-neutral-400 text-sm mb-3">
Free tier with generous limits. No credit card required.
</p>
<div className="relative font-mono text-sm text-neutral-400 mb-8">
curl hanzo.sh | sh
</div>
<XStack flexWrap="wrap" gap={12} alignItems="center" justifyContent="center" position="relative">
<a
href="https://hanzo.id/signup?redirect_uri=https://console.hanzo.ai"
className="rounded-full bg-white px-8 py-3 text-sm font-medium text-black transition-colors hover:bg-neutral-200"
style={{ display: 'inline-grid', gridAutoFlow: 'column', alignItems: 'center', columnGap: 4 }}
>
Sign Up Free
<ArrowRight className="size-4" />
</a>
<Link
href="/docs"
className="rounded-full border border-white/15 px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-white/5"
>
Browse Documentation
</Link>
</XStack>
</section>
</div>
</main>
<Tabs items={rows.map(([name]) => name)}>
{rows.map(([name, lang, code]) => (
<Tab key={name} value={name}>
<Example lang={lang} code={code} />
</Tab>
))}
</Tabs>
);
}
export default function Page() {
return <Landing install={<Group rows={install} />} use={<Group rows={use} />} />;
}
+13 -22
View File
@@ -2,6 +2,8 @@
import { useEffect, useState } from 'react'
import { Action } from '@/components/action'
import { Status } from '@/components/status'
import { iam } from '@/lib/iam'
export default function CallbackPage() {
@@ -16,29 +18,18 @@ export default function CallbackPage() {
.catch(() => setError('Authentication failed. Please try again.'))
}, [])
if (error) {
if (error)
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-white">Sign In Failed</h2>
<p className="mb-4 text-sm text-neutral-400">{error}</p>
<a
href="/login"
className="inline-block rounded-full bg-white px-4 py-1.5 text-sm font-medium text-black hover:bg-neutral-200 transition-colors"
>
Try Again
</a>
</div>
</div>
<Status
title="Sign in failed"
note={error}
action={
<Action tone="loud" render="a" href="/login" mt={6} rounded={999}>
Try again
</Action>
}
/>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
<p className="text-sm text-neutral-400">Signing in...</p>
</div>
</div>
)
return <Status note="Signing in…" />
}
+6 -10
View File
@@ -1,19 +1,18 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import type { ComponentProps } from 'react';
import { createMetadata } from '@/lib/metadata';
import { source } from '@/lib/source';
import { inlineCode } from '@/lib/inline-code';
import { Mermaid } from '@/components/mdx/mermaid';
import { PageFeedback } from '@/components/feedback';
import { Feedback } from '@/components/feedback';
import { getMDXComponents } from '@/components/mdx';
import { A, Body } from '@/components/mdx/prose';
import { Card, Cards } from '@/components/mdx/blocks';
import { Page as Frame } from '@/components/page';
import { getBreadcrumbItems } from '@hanzo/docs-core/breadcrumb';
import { findNeighbour, findSiblings } from '@hanzo/docs-core/page-tree';
import { NotFound } from '@/components/layouts/not-found';
import { MdxErrorBoundary } from '@/components/mdx-error-boundary';
import { getSuggestions } from './suggestions';
import { PathUtils } from '@hanzo/docs-core/source';
export const revalidate = false;
@@ -22,12 +21,9 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page)
return (
<NotFound
getSuggestions={async () => (params.slug ? getSuggestions(params.slug.join(' ')) : [])}
/>
);
// The export holds only the pages the source lists; any other address is the
// site's 404 (app/not-found.tsx).
if (!page) notFound();
const { body: Mdx, toc, lastModified } = await page.data.load();
const tree = source.getPageTree();
@@ -65,7 +61,7 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
</MdxErrorBoundary>
{page.data.index ? <DocsCategory url={page.url} /> : null}
</Body>
<PageFeedback />
<Feedback />
</Frame>
);
}
@@ -1,44 +0,0 @@
import type { Suggestion } from '@/components/layouts/not-found';
import { publishableKey, searchEndpoint } from '@/lib/hanzo/client';
interface HanzoSuggestionHit {
id: string;
url: string;
title: string;
}
interface HanzoSuggestionResponse {
hits: HanzoSuggestionHit[];
}
export async function getSuggestions(pathname: string): Promise<Suggestion[]> {
if (!publishableKey) return [];
const res = await fetch(searchEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${publishableKey}`,
},
body: JSON.stringify({
query: pathname,
mode: 'vector',
limit: 5,
}),
});
if (!res.ok) return [];
const data = (await res.json()) as HanzoSuggestionResponse;
const seen = new Set<string>();
return data.hits.flatMap((hit) => {
if (seen.has(hit.url)) return [];
seen.add(hit.url);
return {
id: hit.id,
href: hit.url,
title: hit.title,
};
});
}
+7 -6
View File
@@ -37,12 +37,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={`${Zen.variable} ${ZenMono.variable} t_dark`} suppressHydrationWarning>
<body>
<NextProvider>
<TreeProvider tree={clientTree()}>
<Provider>{children}</Provider>
</TreeProvider>
</NextProvider>
<Analytics product="docs" />
<Analytics product="docs">
<NextProvider>
<TreeProvider tree={clientTree()}>
<Provider>{children}</Provider>
</TreeProvider>
</NextProvider>
</Analytics>
</body>
</html>
);
+13 -22
View File
@@ -2,6 +2,8 @@
import { useEffect, useState } from 'react'
import { Action } from '@/components/action'
import { Status } from '@/components/status'
import { iam } from '@/lib/iam'
export default function LoginPage() {
@@ -19,29 +21,18 @@ export default function LoginPage() {
.catch(() => setError('Failed to load authentication. Please try again.'))
}, [])
if (error) {
if (error)
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-white">Sign In Error</h2>
<p className="mb-4 text-sm text-neutral-400">{error}</p>
<button
onClick={() => window.location.reload()}
className="rounded-full bg-white px-4 py-1.5 text-sm font-medium text-black hover:bg-neutral-200 transition-colors"
>
Try Again
</button>
</div>
</div>
<Status
title="Sign in error"
note={error}
action={
<Action tone="loud" render="button" type="button" onPress={() => window.location.reload()} mt={6} rounded={999}>
Try again
</Action>
}
/>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
<p className="text-sm text-neutral-400">Redirecting to sign in...</p>
</div>
</div>
)
return <Status note="Redirecting to sign in…" />
}
+7 -7
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import { Shell } from '@/components/shell';
import { Footer } from '@/components/footer';
import { NotFound } from '@/components/layouts/not-found';
import { Missing } from '@/components/missing';
// The export writes this page as 404.html, and the edge serves it for any path
// the site does not hold (ingress staticFiles errorPage404). A miss is answered
@@ -17,18 +17,18 @@ export const metadata: Metadata = {
// Static, because a static export has no request to reason about: the path that
// missed is not known at build time.
const suggestions = [
{ id: 'docs', href: '/docs', title: 'Documentation' },
{ id: 'quickstart', href: '/docs/quickstart', title: 'Quickstart' },
{ id: 'openapi', href: '/docs/openapi', title: 'API reference' },
{ id: 'sdks', href: '/docs/sdks', title: 'SDKs' },
{ id: 'mcp', href: '/docs/mcp', title: 'MCP' },
{ href: '/docs', title: 'Documentation' },
{ href: '/docs/quickstart', title: 'Quickstart' },
{ href: '/docs/openapi', title: 'API reference' },
{ href: '/docs/sdks', title: 'SDKs' },
{ href: '/docs/mcp', title: 'MCP' },
];
export default function Page() {
return (
<>
<Shell>
<NotFound getSuggestions={async () => suggestions} />
<Missing suggestions={suggestions} />
</Shell>
<Footer />
</>
+2 -12
View File
@@ -1,14 +1,9 @@
'use client';
import { RootProvider } from '@hanzo/docs-base-ui/provider/base';
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme';
import { Hanzo } from '@hanzo/ui';
import dynamic from 'next/dynamic';
import type { ReactNode } from 'react';
const SearchDialog = dynamic(() => import('@/components/layouts/search'), {
ssr: false,
});
import { SearchProvider } from '@/components/search';
// ONE authority for the theme: the `t_dark` / `t_light` class on <html>.
//
@@ -17,9 +12,6 @@ const SearchDialog = dynamic(() => import('@/components/layouts/search'), {
// tokens, @hanzo/ui's design tokens and this app's CSS all key on it. `<Hanzo>`
// takes the same name, so the components and the document cannot disagree:
// useRootTheme reads the class the script already wrote, and a toggle moves both.
//
// RootProvider stays for the search dialog alone (its context and ⌘K) until
// search moves onto gui; its next-themes copy is off.
export function Provider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useRootTheme({ fallback: 'dark' });
@@ -31,9 +23,7 @@ export function Provider({ children }: { children: ReactNode }) {
onChangeTheme={(name) => setTheme(name === 'light' ? 'light' : 'dark')}
>
<Hanzo theme={theme}>
<RootProvider search={{ SearchDialog }} theme={{ enabled: false }}>
{children}
</RootProvider>
<SearchProvider>{children}</SearchProvider>
</Hanzo>
</NextThemeProvider>
);
-484
View File
@@ -1,484 +0,0 @@
'use client';
import {
type ComponentProps,
createContext,
type ReactNode,
type SyntheticEvent,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { Loader2, MessageCircleIcon, RefreshCw, SearchIcon, Send, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type Tool, type UIMessage, type UIToolInvocation } from 'ai';
import { Markdown } from '../markdown';
import { Presence } from '@radix-ui/react-presence';
export type ChatUIMessage = UIMessage<
never,
{
client: {
location: string;
};
}
>;
export type SearchTool = Tool<{ query: string; limit: number }>;
const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: UseChatHelpers<ChatUIMessage>;
} | null>(null);
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
const { setOpen } = useAISearchContext();
return (
<div
className={cn(
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
className,
)}
{...props}
>
<div className="px-3 py-2 flex-1">
<p className="text-sm font-medium mb-2">AI Chat</p>
<p className="text-xs text-fd-muted-foreground">
AI can be inaccurate, please verify the answers.
</p>
</div>
<button
aria-label="Close"
tabIndex={-1}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'text-fd-muted-foreground rounded-full',
}),
)}
onClick={() => setOpen(false)}
>
<X />
</button>
</div>
);
}
export function AISearchInputActions() {
const { messages, status, setMessages, regenerate } = useChatContext();
const isLoading = status === 'streaming';
if (messages.length === 0) return null;
return (
<>
{!isLoading && messages.at(-1)?.role === 'assistant' && (
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'rounded-full gap-1.5',
}),
)}
onClick={() => regenerate()}
>
<RefreshCw className="size-4" />
Retry
</button>
)}
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
>
Clear Chat
</button>
</>
);
}
const StorageKeyInput = '__ai_search_input';
export function AISearchInput(props: ComponentProps<'form'>) {
const { status, sendMessage, stop } = useChatContext();
const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? '');
const isLoading = status === 'streaming' || status === 'submitted';
const onStart = (e?: SyntheticEvent) => {
e?.preventDefault();
const message = input.trim();
if (message.length === 0) return;
void sendMessage({
role: 'user',
parts: [
{
type: 'data-client',
data: {
location: location.href,
},
},
{
type: 'text',
text: message,
},
],
});
setInput('');
localStorage.removeItem(StorageKeyInput);
};
useEffect(() => {
if (isLoading) document.getElementById('nd-ai-input')?.focus();
}, [isLoading]);
return (
<form {...props} className={cn('flex items-start pe-2', props.className)} onSubmit={onStart}>
<Input
value={input}
placeholder={isLoading ? 'AI is answering...' : 'Ask a question'}
autoFocus
className="p-3"
disabled={status === 'streaming' || status === 'submitted'}
onChange={(e) => {
setInput(e.target.value);
localStorage.setItem(StorageKeyInput, e.target.value);
}}
onKeyDown={(event) => {
if (!event.shiftKey && event.key === 'Enter') {
onStart(event);
}
}}
/>
{isLoading ? (
<button
key="bn"
type="button"
className={cn(
buttonVariants({
color: 'secondary',
className: 'transition-all rounded-full mt-2 gap-2',
}),
)}
onClick={stop}
>
<Loader2 className="size-4 animate-spin text-fd-muted-foreground" />
Abort Answer
</button>
) : (
<button
key="bn"
type="submit"
className={cn(
buttonVariants({
color: 'primary',
className: 'transition-all rounded-full mt-2',
}),
)}
disabled={input.length === 0}
>
<Send className="size-4" />
</button>
)}
</form>
);
}
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
function callback() {
const container = containerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: 'instant',
});
}
const observer = new ResizeObserver(callback);
callback();
const element = containerRef.current?.firstElementChild;
if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
};
}, []);
return (
<div
ref={containerRef}
{...props}
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
>
{props.children}
</div>
);
}
function Input(props: ComponentProps<'textarea'>) {
const ref = useRef<HTMLDivElement>(null);
const shared = cn('col-start-1 row-start-1', props.className);
return (
<div className="grid flex-1">
<textarea
id="nd-ai-input"
{...props}
className={cn(
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
shared,
)}
/>
<div ref={ref} className={cn(shared, 'break-all invisible')}>
{`${props.value?.toString() ?? ''}\n`}
</div>
</div>
);
}
const roleName: Record<string, string> = {
user: 'you',
assistant: 'Hanzo Docs',
};
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
let markdown = '';
const searchCalls: UIToolInvocation<SearchTool>[] = [];
for (const part of message.parts ?? []) {
if (part.type === 'text') {
markdown += part.text;
continue;
}
if (part.type.startsWith('tool-')) {
const toolName = part.type.slice('tool-'.length);
const p = part as UIToolInvocation<Tool>;
if (toolName !== 'search' || !p.toolCallId) continue;
searchCalls.push(p);
}
}
return (
<div onClick={(e) => e.stopPropagation()} {...props}>
<p
className={cn(
'mb-1 text-sm font-medium text-fd-muted-foreground',
message.role === 'assistant' && 'text-fd-primary',
)}
>
{roleName[message.role] ?? 'unknown'}
</p>
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
{searchCalls.map((call) => {
return (
<div
key={call.toolCallId}
className="flex flex-row gap-2 items-center mt-3 rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs p-2"
>
<SearchIcon className="size-4" />
{call.state === 'output-error' || call.state === 'output-denied' ? (
<p className="text-fd-error">{call.errorText ?? 'Failed to search'}</p>
) : (
<p>{!call.output ? 'Searching…' : `${call.output.length} search results`}</p>
)}
</div>
);
})}
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat<ChatUIMessage>({
id: 'search',
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
return (
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
);
}
export function AISearchTrigger({
position = 'default',
className,
...props
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
const { open, setOpen } = useAISearchContext();
return (
<button
data-state={open ? 'open' : 'closed'}
className={cn(
position === 'float' && [
'fixed bottom-4 gap-3 w-24 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
open && 'translate-y-10 opacity-0',
],
className,
)}
onClick={() => setOpen(!open)}
{...props}
>
{props.children}
</button>
);
}
export function AISearchPanel() {
const { open, setOpen } = useAISearchContext();
useHotKey();
return (
<>
<style>
{`
@keyframes ask-ai-open {
from {
translate: 100% 0;
}
to {
translate: 0 0;
}
}
@keyframes ask-ai-close {
from {
width: var(--ai-chat-width);
}
to {
width: 0px;
}
}`}
</style>
<Presence present={open}>
<div
className={cn(
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
)}
onClick={() => setOpen(false)}
/>
</Presence>
<Presence present={open}>
<div
className={cn(
'overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
>
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
<AISearchInput />
<div className="flex items-center gap-1.5 p-1 empty:hidden">
<AISearchInputActions />
</div>
</div>
</div>
</div>
</Presence>
</>
);
}
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
const chat = useChatContext();
const messages = chat.messages.filter((msg) => msg.role !== 'system');
return (
<List
className={cn('py-4 overscroll-contain', className)}
style={{
maskImage:
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
...style,
}}
{...props}
>
{messages.length === 0 ? (
<div className="text-sm text-fd-muted-foreground/80 size-full flex flex-col items-center justify-center text-center gap-2">
<MessageCircleIcon fill="currentColor" stroke="none" />
<p onClick={(e) => e.stopPropagation()}>Start a new chat below.</p>
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{chat.error && (
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
<p className="text-xs text-fd-muted-foreground mb-1">
Request Failed: {chat.error.name}
</p>
<p className="text-sm">{chat.error.message}</p>
</div>
)}
{messages.map((item) => (
<Message key={item.id} message={item} />
))}
</div>
)}
</List>
);
}
export function useHotKey() {
const { open, setOpen } = useAISearchContext();
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
setOpen(false);
e.preventDefault();
}
if (e.key === '/' && (e.metaKey || e.ctrlKey) && !open) {
setOpen(true);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener('keydown', onKeyPress);
return () => window.removeEventListener('keydown', onKeyPress);
}, []);
}
export function useAISearchContext() {
return use(Context)!;
}
function useChatContext() {
return use(Context)!.chat;
}
-116
View File
@@ -1,116 +0,0 @@
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import {
Children,
type ComponentProps,
type ReactElement,
type ReactNode,
Suspense,
use,
useDeferredValue,
} from 'react';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { DynamicCodeBlock } from '@hanzo/docs-base-ui/components/dynamic-codeblock';
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
import { visit } from 'unist-util-visit';
import type { ElementContent, Root, RootContent } from 'hast';
export interface Processor {
process: (content: string) => Promise<ReactNode>;
}
export function rehypeWrapWords() {
return (tree: Root) => {
visit(tree, ['text', 'element'], (node, index, parent) => {
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
if (node.type !== 'text' || !parent || index === undefined) return;
const words = node.value.split(/(?=\s)/);
// Create new span nodes for each word and whitespace
const newNodes: ElementContent[] = words.flatMap((word) => {
if (word.length === 0) return [];
return {
type: 'element',
tagName: 'span',
properties: {
class: 'animate-fd-fade-in',
},
children: [{ type: 'text', value: word }],
};
});
Object.assign(node, {
type: 'element',
tagName: 'span',
properties: {},
children: newNodes,
} satisfies RootContent);
return 'skip';
});
};
}
function createProcessor(): Processor {
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
return {
async process(content) {
const nodes = processor.parse({ value: content });
const hast = await processor.run(nodes);
return toJsxRuntime(hast, {
development: false,
jsx,
jsxs,
Fragment,
components: {
...defaultMdxComponents,
pre: Pre,
img: undefined, // use JSX
},
});
},
};
}
function Pre(props: ComponentProps<'pre'>) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<'code'>;
const content = codeProps.children;
if (typeof content !== 'string') return null;
let lang =
codeProps.className
?.split(' ')
.find((v) => v.startsWith('language-'))
?.slice('language-'.length) ?? 'text';
if (lang === 'mdx') lang = 'md';
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
}
const processor = createProcessor();
export function Markdown({ text }: { text: string }) {
const deferredText = useDeferredValue(text);
return (
<Suspense fallback={<p className="invisible">{text}</p>}>
<Renderer text={deferredText} />
</Suspense>
);
}
const cache = new Map<string, Promise<ReactNode>>();
function Renderer({ text }: { text: string }) {
const result = cache.get(text) ?? processor.process(text);
cache.set(text, result);
return use(result);
}
-477
View File
@@ -1,477 +0,0 @@
'use client';
import {
type ComponentProps,
createContext,
type ReactNode,
type SyntheticEvent,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import Link from '@hanzo/docs-core/link';
import type { ProvideLinksToolSchema } from '@/lib/ai/qa-schema';
import type { z } from 'zod';
import { Markdown } from '../markdown';
import { Presence } from '@radix-ui/react-presence';
import { useHanzoChat, type ChatMessage, type HanzoChat } from '@/lib/hanzo/use-chat';
const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: HanzoChat;
} | null>(null);
// Citation URLs come from untrusted model output: `provideLinks` is parsed by a
// loose schema whose `url` is any string, so a model can emit `javascript:` or
// `data:`. Render a link only when its href is site-relative ('/…') or resolves
// to http(s) — the render-time scheme allowlist.
function isSafeHref(url: string | null | undefined): boolean {
if (!url) return false;
const u = url.trim();
if (u.startsWith('/') && !u.startsWith('//')) return true;
try {
const { protocol } = new URL(u, 'https://docs.hanzo.ai');
return protocol === 'https:' || protocol === 'http:';
} catch {
return false;
}
}
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
const { setOpen } = useAISearchContext();
return (
<div
className={cn(
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
className,
)}
{...props}
>
<div className="px-3 py-2 flex-1">
<p className="text-sm font-medium mb-2">AI Chat</p>
<p className="text-xs text-fd-muted-foreground">
Powered by{' '}
<a href="https://hanzo.ai" target="_blank" rel="noreferrer noopener">
Hanzo AI
</a>
</p>
</div>
<button
aria-label="Close"
tabIndex={-1}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'text-fd-muted-foreground rounded-full',
}),
)}
onClick={() => setOpen(false)}
>
<X />
</button>
</div>
);
}
export function AISearchInputActions() {
const { messages, status, setMessages, regenerate } = useChatContext();
const isLoading = status === 'streaming';
if (messages.length === 0) return null;
return (
<>
{!isLoading && messages.at(-1)?.role === 'assistant' && (
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'rounded-full gap-1.5',
}),
)}
onClick={() => regenerate()}
>
<RefreshCw className="size-4" />
Retry
</button>
)}
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
>
Clear Chat
</button>
</>
);
}
const StorageKeyInput = '__ai_search_input';
export function AISearchInput(props: ComponentProps<'form'>) {
const { status, sendMessage, stop } = useChatContext();
const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? '');
const isLoading = status === 'streaming' || status === 'submitted';
const onStart = (e?: SyntheticEvent) => {
e?.preventDefault();
const message = input.trim();
if (message.length === 0) return;
void sendMessage({
role: 'user',
parts: [
{
type: 'data-client',
data: {
location: location.href,
},
},
{
type: 'text',
text: message,
},
],
});
setInput('');
localStorage.removeItem(StorageKeyInput);
};
useEffect(() => {
if (isLoading) document.getElementById('nd-ai-input')?.focus();
}, [isLoading]);
return (
<form {...props} className={cn('flex items-start pe-2', props.className)} onSubmit={onStart}>
<Input
value={input}
placeholder={isLoading ? 'AI is answering...' : 'Ask a question'}
autoFocus
className="p-3"
disabled={status === 'streaming' || status === 'submitted'}
onChange={(e) => {
setInput(e.target.value);
localStorage.setItem(StorageKeyInput, e.target.value);
}}
onKeyDown={(event) => {
if (!event.shiftKey && event.key === 'Enter') {
onStart(event);
}
}}
/>
{isLoading ? (
<button
key="bn"
type="button"
className={cn(
buttonVariants({
color: 'secondary',
className: 'transition-all rounded-full mt-2 gap-2',
}),
)}
onClick={stop}
>
<Loader2 className="size-4 animate-spin text-fd-muted-foreground" />
Abort Answer
</button>
) : (
<button
key="bn"
type="submit"
className={cn(
buttonVariants({
color: 'primary',
className: 'transition-all rounded-full mt-2',
}),
)}
disabled={input.length === 0}
>
<Send className="size-4" />
</button>
)}
</form>
);
}
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
function callback() {
const container = containerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: 'instant',
});
}
const observer = new ResizeObserver(callback);
callback();
const element = containerRef.current?.firstElementChild;
if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
};
}, []);
return (
<div
ref={containerRef}
{...props}
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
>
{props.children}
</div>
);
}
function Input(props: ComponentProps<'textarea'>) {
const ref = useRef<HTMLDivElement>(null);
const shared = cn('col-start-1 row-start-1', props.className);
return (
<div className="grid flex-1">
<textarea
id="nd-ai-input"
{...props}
className={cn(
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
shared,
)}
/>
<div ref={ref} className={cn(shared, 'break-all invisible')}>
{`${props.value?.toString() ?? ''}\n`}
</div>
</div>
);
}
const roleName: Record<string, string> = {
user: 'you',
assistant: 'Hanzo Docs',
};
function Message({ message, ...props }: { message: ChatMessage } & ComponentProps<'div'>) {
let markdown = '';
let links: z.infer<typeof ProvideLinksToolSchema>['links'] = [];
for (const part of message.parts ?? []) {
if (part.type === 'text') {
markdown += part.text;
continue;
}
if (part.type === 'tool-provideLinks' && part.input) {
links = (part.input as z.infer<typeof ProvideLinksToolSchema>).links;
}
}
const safeLinks = (links ?? []).filter((item) => isSafeHref(item?.url));
return (
<div onClick={(e) => e.stopPropagation()} {...props}>
<p
className={cn(
'mb-1 text-sm font-medium text-fd-muted-foreground',
message.role === 'assistant' && 'text-fd-primary',
)}
>
{roleName[message.role] ?? 'unknown'}
</p>
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
{safeLinks.length > 0 && (
<div className="mt-2 flex flex-row flex-wrap items-center gap-1">
{safeLinks.map((item, i) => (
<Link
key={i}
href={item.url}
className="block text-xs rounded-lg border p-3 hover:bg-fd-accent hover:text-fd-accent-foreground"
>
<p className="font-medium">{item.title}</p>
<p className="text-fd-muted-foreground">Reference {item.label}</p>
</Link>
))}
</div>
)}
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useHanzoChat();
return (
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
);
}
export function AISearchTrigger({
position = 'default',
className,
...props
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
const { open, setOpen } = useAISearchContext();
return (
<button
data-state={open ? 'open' : 'closed'}
className={cn(
position === 'float' && [
'fixed bottom-4 gap-3 w-24 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
open && 'translate-y-10 opacity-0',
],
className,
)}
onClick={() => setOpen(!open)}
{...props}
>
{props.children}
</button>
);
}
export function AISearchPanel() {
const { open, setOpen } = useAISearchContext();
useHotKey();
return (
<>
<style>
{`
@keyframes ask-ai-open {
from {
translate: 100% 0;
}
to {
translate: 0 0;
}
}
@keyframes ask-ai-close {
from {
width: var(--ai-chat-width);
}
to {
width: 0px;
}
}`}
</style>
<Presence present={open}>
<div
className={cn(
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
)}
onClick={() => setOpen(false)}
/>
</Presence>
<Presence present={open}>
<div
className={cn(
'overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
>
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
<AISearchInput />
<div className="flex items-center gap-1.5 p-1 empty:hidden">
<AISearchInputActions />
</div>
</div>
</div>
</div>
</Presence>
</>
);
}
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
const chat = useChatContext();
const messages = chat.messages.filter((msg) => msg.role !== 'system');
return (
<List
className={cn('py-4 overscroll-contain', className)}
style={{
maskImage:
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
...style,
}}
{...props}
>
{messages.length === 0 ? (
<div className="text-sm text-fd-muted-foreground/80 size-full flex flex-col items-center justify-center text-center gap-2">
<MessageCircleIcon fill="currentColor" stroke="none" />
<p onClick={(e) => e.stopPropagation()}>Start a new chat below.</p>
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{messages.map((item) => (
<Message key={item.id} message={item} />
))}
</div>
)}
</List>
);
}
export function useHotKey() {
const { open, setOpen } = useAISearchContext();
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
setOpen(false);
e.preventDefault();
}
if (e.key === '/' && (e.metaKey || e.ctrlKey) && !open) {
setOpen(true);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener('keydown', onKeyPress);
return () => window.removeEventListener('keydown', onKeyPress);
}, []);
}
export function useAISearchContext() {
return use(Context)!;
}
function useChatContext() {
return use(Context)!.chat;
}
-9
View File
@@ -1,9 +0,0 @@
import { openapi, hasSpecs } from '@/lib/openapi';
import { defaultShikiOptions } from '@/lib/shiki';
import { createAPIPage } from '@hanzo/docs-openapi/ui';
export const APIPage = hasSpecs
? createAPIPage(openapi, {
shikiOptions: defaultShikiOptions,
})
: null;
+11
View File
@@ -0,0 +1,11 @@
'use client';
import dynamic from 'next/dynamic';
/**
* The live catalogs — models, connectors, installs — as client islands. Each
* fetches its data in the browser, so none holds up the page it sits in.
*/
export const ModelsCatalog = dynamic(() => import('@/components/models-catalog').then((m) => m.ModelsCatalog));
export const ConnectorsCatalog = dynamic(() => import('@/components/connectors-catalog').then((m) => m.ConnectorsCatalog));
export const InstallCatalog = dynamic(() => import('@/components/install-catalog').then((m) => m.InstallCatalog));
+44
View File
@@ -0,0 +1,44 @@
'use client';
import { Text, XStack } from '@hanzo/gui';
import { loud, muted } from '@/lib/ink';
/** One-of-several filter, as a row of pills; the chosen one is outlined. */
export function Chips<T extends string | undefined>({
items,
value,
onChange,
}: {
items: readonly { label: string; value: T }[];
value: T;
onChange: (v: T) => void;
}) {
return (
<XStack gap={6} flexWrap="wrap">
{items.map((item) => {
const on = item.value === value;
return (
<Text
key={item.label}
render="button"
type="button"
onPress={() => onChange(item.value)}
aria-pressed={on}
fontSize={12}
lineHeight={18}
px={10}
py={3}
rounded={999}
borderWidth={1}
borderColor={on ? '$color12' : '$borderColor'}
bg="transparent"
cursor="pointer"
{...(on ? loud : muted)}
>
{item.label}
</Text>
);
})}
</XStack>
);
}
-26
View File
@@ -1,26 +0,0 @@
import * as Base from '@hanzo/docs-base-ui/components/codeblock';
import { cn } from '@/lib/cn';
import { highlight } from '@hanzo/docs-core/highlight';
import { shikiConfig } from '@/lib/shiki';
export interface CodeBlockProps {
code: string;
wrapper?: Base.CodeBlockProps;
lang: string;
}
export async function CodeBlock({ code, lang, wrapper }: CodeBlockProps) {
const rendered = await highlight(code, {
config: shikiConfig,
lang,
components: {
pre: Base.Pre,
},
});
return (
<Base.CodeBlock {...wrapper} className={cn('my-0', wrapper?.className)}>
{rendered}
</Base.CodeBlock>
);
}
+99 -71
View File
@@ -11,7 +11,12 @@
// Rendered inline in the docs (never a link-out). Same lazy-island + fd-token
// pattern as <ModelsCatalog/>.
import { useEffect, useMemo, useState } from 'react';
import { Search, Plug, Boxes, ArrowUpRight } from 'lucide-react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { Plug, Boxes, ArrowUpRight } from '@hanzogui/lucide-icons-2';
import { Grid } from '@hanzo/ui/grid';
import { Action } from '@/components/action';
import { Field } from '@/components/field';
import { muted } from '@/lib/ink';
const MCP_REGISTRY = 'https://registry.modelcontextprotocol.io/v0/servers?limit=100';
@@ -48,10 +53,12 @@ export function ConnectorsCatalog() {
fetch(cur ? `${MCP_REGISTRY}&cursor=${encodeURIComponent(cur)}` : MCP_REGISTRY)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((d: { servers: RegItem[]; metadata?: { nextCursor?: string } }) => {
// The registry lists a server once per published version, so a name is
// kept once, across pages and within one.
setServers((prev) => {
const seen = new Set(prev.map((s) => s.name));
const fresh = d.servers.map((i) => i.server).filter((s) => s.name && !seen.has(s.name));
return [...prev, ...fresh];
const byName = new Map(prev.map((s) => [s.name, s]));
for (const { server } of d.servers) if (server.name && !byName.has(server.name)) byName.set(server.name, server);
return [...byName.values()];
});
setCursor(d.metadata?.nextCursor ?? null);
})
@@ -72,88 +79,109 @@ export function ConnectorsCatalog() {
}, [q]);
return (
<div className="not-prose my-6">
<label className="relative mb-5 flex items-center">
<Search className="pointer-events-none absolute left-2.5 size-4 text-fd-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search connectors and MCP servers…"
className="w-full rounded-md border border-fd-border bg-fd-background py-2 pl-8 pr-3 text-sm outline-none focus:border-fd-primary"
/>
</label>
<YStack gap={28}>
<Field value={q} onChange={setQ} placeholder="Search connectors and MCP servers…" />
{/* Native connectors */}
{nativeFiltered.length > 0 && (
<section className="mb-8">
<div className="mb-2 flex items-center gap-2">
<Plug className="size-4 text-fd-primary" />
<h3 className="m-0 text-base font-semibold text-fd-foreground">Native connectors</h3>
<span className="text-xs text-fd-muted-foreground">link once in the console</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{nativeFiltered.map((c) => {
return (
<div key={c.id} className="rounded-lg border border-fd-border bg-fd-card p-3">
<div className="flex items-center gap-2">
<Plug className="size-4 text-fd-foreground" />
<span className="font-medium text-fd-foreground">{c.label}</span>
<span className="ml-auto rounded border border-fd-border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">{c.method}</span>
</div>
<p className="mt-1.5 mb-0 text-sm text-fd-muted-foreground">{c.blurb}</p>
</div>
);
})}
</div>
</section>
<YStack render="section" gap={10}>
<Heading icon={<Plug size={16} color="$color12" />} title="Native connectors" note="link once in the console" />
<Grid columns={{ min: 220 }} gap={8}>
{nativeFiltered.map((c) => (
<YStack key={c.id} gap={6} p={12} rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel">
<XStack gap={8} items="center">
<Plug size={14} color="$color12" />
<Text flex={1} fontSize={14} fontWeight="500" color="$color12">
{c.label}
</Text>
<Badge>{c.method}</Badge>
</XStack>
<Text fontSize={14} whiteSpace="normal" {...muted}>
{c.blurb}
</Text>
</YStack>
))}
</Grid>
</YStack>
)}
{/* MCP servers — live from the official registry */}
<section>
<div className="mb-2 flex items-center gap-2">
<Boxes className="size-4 text-fd-primary" />
<h3 className="m-0 text-base font-semibold text-fd-foreground">MCP servers</h3>
<span className="text-xs text-fd-muted-foreground">
{servers.length ? `${servers.length}+ indexed · Model Context Protocol registry` : 'loading the registry…'}
</span>
</div>
<YStack render="section" gap={10}>
<Heading
icon={<Boxes size={16} color="$color12" />}
title="MCP servers"
note={servers.length ? `${servers.length}+ indexed · Model Context Protocol registry` : 'loading the registry…'}
/>
{err ? (
<p className="text-sm text-fd-muted-foreground">
<Text fontSize={14} whiteSpace="normal" {...muted}>
Couldn’t reach the MCP registry ({err}). Browse it at{' '}
<a className="text-fd-primary underline" href="https://registry.modelcontextprotocol.io">registry.modelcontextprotocol.io</a>.
</p>
<Text render="a" href="https://registry.modelcontextprotocol.io" color="$color12" textDecorationLine="underline">
registry.modelcontextprotocol.io
</Text>
.
</Text>
) : (
<>
<div className="overflow-hidden rounded-lg border border-fd-border">
<YStack rounded="$4" borderWidth={1} borderColor="$borderColor" overflow="hidden">
{filtered.map((s, i) => (
<div key={s.name} className={`flex items-start gap-3 p-3 ${i > 0 ? 'border-t border-fd-border/60' : ''}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-fd-foreground">{s.title || s.name}</span>
<span className="rounded border border-fd-border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">{connectVia(s)}</span>
</div>
<div className="truncate font-mono text-xs text-fd-muted-foreground">{s.name}</div>
{s.description ? <p className="mt-1 mb-0 line-clamp-2 text-sm text-fd-muted-foreground">{s.description}</p> : null}
</div>
</div>
<YStack key={s.name} gap={2} p={12} borderTopWidth={i ? 1 : 0} borderColor="$borderColor">
<XStack gap={8} items="center">
<Text fontSize={14} fontWeight="500" color="$color12" numberOfLines={1}>
{s.title || s.name}
</Text>
<Badge>{connectVia(s)}</Badge>
</XStack>
<Text fontFamily="$mono" fontSize={12} numberOfLines={1} {...muted}>
{s.name}
</Text>
{s.description ? (
<Text fontSize={14} numberOfLines={2} whiteSpace="normal" {...muted}>
{s.description}
</Text>
) : null}
</YStack>
))}
{!filtered.length && !loading ? <div className="p-4 text-sm text-fd-muted-foreground">No servers match “{q}”.</div> : null}
</div>
{!filtered.length && !loading ? (
<Text p={16} fontSize={14} {...muted}>
No servers match “{q}”.
</Text>
) : null}
</YStack>
{!q && cursor ? (
<button
type="button"
onClick={() => loadPage(cursor)}
disabled={loading}
className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-fd-border px-3 py-1.5 text-sm text-fd-foreground hover:bg-fd-muted disabled:opacity-50"
>
{loading ? 'Loading…' : 'Load more'} <ArrowUpRight className="size-3.5" />
</button>
<XStack>
<Action tone="line" render="button" type="button" onPress={() => loadPage(cursor)} disabled={loading} opacity={loading ? 0.5 : 1}>
<Text fontSize={13} color="$color12">
{loading ? 'Loading…' : 'Load more'}
</Text>
<ArrowUpRight size={13} color="$color12" />
</Action>
</XStack>
) : null}
</>
)}
</section>
</div>
</YStack>
</YStack>
);
}
function Heading({ icon, title, note }: { icon: React.ReactNode; title: string; note: string }) {
return (
<XStack gap={8} items="center" flexWrap="wrap">
{icon}
<Text render="h3" fontSize={16} fontWeight="600" color="$color12">
{title}
</Text>
<Text fontSize={12} {...muted}>
{note}
</Text>
</XStack>
);
}
function Badge({ children }: { children: React.ReactNode }) {
return (
<Text fontSize={10} px={6} py={1} rounded={4} borderWidth={1} borderColor="$borderColor" {...muted}>
{children}
</Text>
);
}
@@ -1,57 +0,0 @@
import type { HTMLAttributes } from 'react';
import Image from 'next/image';
import { cn } from '@/lib/cn';
import { fetchContributors } from '@/lib/get-contributors';
export interface ContributorCounterProps extends HTMLAttributes<HTMLDivElement> {
repoOwner: string;
repoName: string;
displayCount?: number;
}
export default async function ContributorCounter({
repoOwner,
repoName,
displayCount = 20,
...props
}: ContributorCounterProps): Promise<React.ReactElement> {
const contributors = await fetchContributors(repoOwner, repoName);
const topContributors = contributors
.filter((contributor) => contributor.login !== repoOwner)
.slice(0, displayCount);
return (
<div {...props} className={cn('flex flex-col items-center gap-4', props.className)}>
<div className="flex flex-row flex-wrap items-center justify-center md:pe-4">
{topContributors.map((contributor, i) => (
<a
key={contributor.login}
href={`https://github.com/${contributor.login}`}
rel="noreferrer noopener"
target="_blank"
className="size-10 overflow-hidden rounded-full border-4 border-fd-background bg-fd-background md:-mr-4 md:size-12"
style={{
zIndex: topContributors.length - i,
}}
>
<Image
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}
unoptimized
width={48}
height={48}
/>
</a>
))}
{displayCount < contributors.length ? (
<div className="size-12 content-center rounded-full bg-fd-secondary text-center">
+{contributors.length - displayCount}
</div>
) : null}
</div>
<div className="text-center text-sm text-fd-muted-foreground">
Some of our best contributors.
</div>
</div>
);
}
+71 -104
View File
@@ -2,6 +2,9 @@
import { useState } from 'react';
import Link from 'next/link';
import { Text, XStack, YStack } from '@hanzo/gui';
import { Action } from '@/components/action';
import { muted } from '@/lib/ink';
// The masthead. Three doors, one model, and nothing else above the fold.
//
@@ -70,136 +73,100 @@ const PATHS = [
// description, and a hero that printed them again made /docs say its name twice.
export function DocsHero() {
const [active, setActive] = useState<string>(PATHS[0].id);
const path = PATHS.find((d) => d.id === active) ?? PATHS[0];
return (
<div className="not-prose mb-12">
{/* Entrance and panel motion. Colocated rather than pushed into globals.css:
these keyframes have exactly one consumer, and globals.css is where the
font-token overrides live — not a file to touch for a decoration.
Everything is gated on prefers-reduced-motion, which resolves the whole
block to no animation rather than a faster one. */}
<style>{`
@keyframes hanzo-rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
.hanzo-rise {
opacity: 0;
animation: hanzo-rise .55s cubic-bezier(.16,1,.3,1) forwards;
animation-delay: var(--d, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.hanzo-rise { animation: none; opacity: 1; }
}
`}</style>
<YStack gap={16}>
{/* The three doors. Each is a tab: hovering or focusing selects it, so the
panel below follows the pointer and a reader compares paths by moving
across them rather than by clicking three times. */}
<div className="mb-4 grid gap-3 sm:grid-cols-3">
{PATHS.map((d, i) => {
<XStack gap={12} $max-sm={{ flexDirection: 'column' }}>
{PATHS.map((d) => {
const on = d.id === active;
return (
<button
<YStack
key={d.id}
render="button"
type="button"
onMouseEnter={() => setActive(d.id)}
onHoverIn={() => setActive(d.id)}
onFocus={() => setActive(d.id)}
onClick={() => setActive(d.id)}
onPress={() => setActive(d.id)}
aria-pressed={on}
className={[
'hanzo-rise group flex flex-col rounded-xl border p-5 text-left',
'transition-[transform,border-color,background-color] duration-200',
'hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
on
? 'border-fd-foreground/30 bg-fd-accent/50'
: 'border-fd-border bg-fd-card/40 hover:border-fd-foreground/20',
].join(' ')}
style={{ ['--d' as string]: `${180 + i * 70}ms` }}
flex={1}
gap={6}
p={18}
rounded="$5"
borderWidth={1}
borderColor={on ? '$color8' : '$borderColor'}
bg={on ? '$hover' : '$panel'}
items="flex-start"
cursor="pointer"
hoverStyle={{ borderColor: '$color8' }}
>
<span className="mb-2 text-xs font-medium text-fd-muted-foreground">
<Text fontSize="$1" fontWeight="500" {...muted}>
{d.eyebrow}
</span>
<span className="mb-1.5 flex items-center gap-1.5 text-base font-semibold text-fd-foreground">
{d.title}
<span
aria-hidden
className={[
'transition-[transform,opacity] duration-200',
on ? 'translate-x-0.5 opacity-100' : 'opacity-40',
].join(' ')}
>
→
</span>
</span>
<span className="text-sm leading-relaxed text-fd-muted-foreground">{d.body}</span>
</button>
</Text>
<Text fontSize={15} fontWeight="600" color="$color12">
{d.title} {on ? '→' : ''}
</Text>
<Text fontSize="$2" lineHeight={20} text="left" whiteSpace="normal" {...muted}>
{d.body}
</Text>
</YStack>
);
})}
</div>
</XStack>
{/* The selected path's real first step. Keyed on the path id so React
remounts it and the entrance animation replays on every switch — the
movement is what tells you the panel answers the thing you just hovered. */}
<div
className="hanzo-rise mb-8 overflow-hidden rounded-xl border border-fd-border bg-fd-card/40"
style={{ ['--d' as string]: '400ms' }}
>
<div className="flex items-center justify-between gap-3 border-b border-fd-border px-4 py-2.5">
<span className="text-xs font-medium text-fd-muted-foreground">
{/* The selected path's real first step. */}
<YStack rounded="$5" borderWidth={1} borderColor="$borderColor" bg="$panel" overflow="hidden">
<XStack justify="space-between" items="center" gap={12} px={16} py={10} borderBottomWidth={1} borderColor="$borderColor">
<Text fontSize="$1" fontWeight="500" {...muted}>
{path.lang}
</span>
<Link
href={path.href}
{...(path.external ? { target: '_blank', rel: 'noreferrer' } : {})}
className="text-xs text-fd-muted-foreground underline-offset-4 transition-colors hover:text-fd-foreground hover:underline"
</Text>
<Text
render={path.external ? <a href={path.href} target="_blank" rel="noreferrer" /> : <Link href={path.href} prefetch={false} />}
fontSize="$1"
{...muted}
hoverStyle={{ color: '$color12' }}
>
{path.cta} →
</Link>
</div>
<pre key={path.id} className="hanzo-rise overflow-x-auto px-4 py-3.5 text-[13px] leading-relaxed text-fd-foreground">
<code>{path.code}</code>
</pre>
</div>
</Text>
</XStack>
<Text render="pre" fontFamily="$mono" fontSize={13} lineHeight={21} px={16} py={14} whiteSpace="pre" overflowX="auto" color="$color12">
{path.code}
</Text>
</YStack>
{/* The models, because they are the reason to choose the platform at all. */}
<Link
href="/docs/models"
className="hanzo-rise group mb-8 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-fd-border bg-fd-card/40 px-5 py-4 transition-colors hover:border-fd-foreground/25 hover:bg-fd-accent/40"
style={{ ['--d' as string]: '460ms' }}
<XStack
render={<Link href="/docs/models" prefetch={false} />}
flexWrap="wrap"
items="center"
gap={12}
px={18}
py={14}
rounded="$5"
borderWidth={1}
borderColor="$borderColor"
bg="$panel"
hoverStyle={{ bg: '$hover' }}
>
<span className="rounded border border-fd-border px-1.5 py-0.5 text-[11px] font-medium text-fd-muted-foreground">
<Text fontSize={11} fontWeight="500" px={6} py={1} rounded={6} borderWidth={1} borderColor="$borderColor" {...muted}>
Models
</span>
<span className="text-sm font-medium text-fd-foreground">
</Text>
<Text fontSize="$3" fontWeight="500" color="$color12">
Zen generates, Enso routes, Kai decides
</span>
<span className="text-sm text-fd-muted-foreground">
Open weights, one router, typed decisions.{' '}
<span className="text-fd-foreground/70 underline-offset-4 group-hover:underline">
See the models →
</span>
</span>
</Link>
</Text>
<Text fontSize="$3" {...muted}>
Open weights, one router, typed decisions. See the models →
</Text>
</XStack>
{/* The action row, in the order a reader needs it: start, hand the docs to an
assistant, or — for anyone who would rather be shown than told — go build
something that plays. */}
<div
className="hanzo-rise flex flex-wrap items-center gap-3"
style={{ ['--d' as string]: '520ms' }}
>
<Link
href="/docs/quickstart"
className="inline-flex h-10 items-center rounded-full bg-fd-primary px-6 text-sm font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
>
<XStack>
<Action tone="loud" render={<Link href="/docs/quickstart" prefetch={false} />} height={40} px={22} rounded={999}>
Get started
</Link>
</div>
</div>
</Action>
</XStack>
</YStack>
);
}
+112
View File
@@ -0,0 +1,112 @@
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Text, TextArea, XStack, YStack } from '@hanzo/gui';
import { ThumbsDown, ThumbsUp } from '@hanzogui/lucide-icons-2';
import { useAnalytics } from '@hanzo/docs-analytics';
import { Action } from '@/components/action';
import { muted } from '@/lib/ink';
/**
* "Was this page useful?" at the foot of every page.
*
* The answer goes where every other signal from this site goes: an event on
* the one telemetry stream (`docs.feedback`, POST /v1/event), with the page, the
* verdict and whatever the reader wrote. It used to be handed to a stub that
* logged it to the reader's own console and thanked them, so nothing anyone
* said reached anyone. A page remembers that it was answered, in this browser.
*/
type Verdict = 'good' | 'bad';
export function Feedback() {
const page = usePathname();
const analytics = useAnalytics();
const [verdict, setVerdict] = useState<Verdict | null>(null);
const [note, setNote] = useState('');
const [sent, setSent] = useState(false);
const key = `docs.feedback:${page}`;
useEffect(() => {
try {
setSent(localStorage.getItem(key) !== null);
} catch {
/* storage refused: ask again */
}
setVerdict(null);
setNote('');
}, [key]);
const send = () => {
if (!verdict) return;
analytics.capture('docs.feedback', { page, verdict, note: note.trim() || undefined });
try {
localStorage.setItem(key, verdict);
} catch {
/* the event is what matters */
}
setSent(true);
};
return (
<YStack mt={48} py={16} gap={12} borderTopWidth={1} borderBottomWidth={1} borderColor="$borderColor">
{sent ? (
<Text fontSize="$3" {...muted}>
Thanks — that reached the team.
</Text>
) : (
<>
<XStack gap={8} items="center" flexWrap="wrap">
<Text fontSize="$3" fontWeight="500" color="$color12" mr={4}>
Was this page useful?
</Text>
{(['good', 'bad'] as const).map((v) => (
<Action
key={v}
tone={verdict === v ? 'loud' : 'line'}
render="button"
type="button"
aria-pressed={verdict === v}
onPress={() => setVerdict(v)}
rounded={999}
>
{v === 'good' ? (
<ThumbsUp size={14} color={verdict === v ? '$background' : '$color11'} />
) : (
<ThumbsDown size={14} color={verdict === v ? '$background' : '$color11'} />
)}
<Text fontSize="$2" fontWeight="500" color={verdict === v ? '$background' : '$color11'}>
{v === 'good' ? 'Yes' : 'No'}
</Text>
</Action>
))}
</XStack>
{verdict && (
<YStack gap={8}>
<TextArea
value={note}
onChangeText={setNote}
placeholder={verdict === 'good' ? 'What helped? (optional)' : 'What was missing or wrong? (optional)'}
aria-label="Feedback"
minH={84}
p={10}
fontSize="$3"
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$panel"
color="$color12"
placeholderTextColor="$color10"
/>
<XStack>
<Action tone="loud" render="button" type="button" onPress={send}>
Send
</Action>
</XStack>
</YStack>
)}
</>
)}
</YStack>
);
}
-516
View File
@@ -1,516 +0,0 @@
'use client';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { CornerDownRightIcon, MessageSquare, ThumbsDown, ThumbsUp } from 'lucide-react';
import {
type HTMLAttributes,
type ReactNode,
type SyntheticEvent,
useEffect,
useEffectEvent,
useRef,
useState,
useTransition,
} from 'react';
import { Collapsible, CollapsibleContent } from '@hanzo/docs-base-ui/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/components/ui/popover';
import type { FeedbackBlockProps } from '@hanzo/docs-core/mdx-plugins/remark-feedback-block';
import {
actionResponse,
blockFeedback,
pageFeedback,
type ActionResponse,
type BlockFeedback,
type PageFeedback,
} from './schema';
import { z } from 'zod/mini';
import { usePathname } from '@hanzo/docs-core/framework';
const rateButtonVariants = cva(
'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
{
variants: {
active: {
true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
false: 'text-fd-muted-foreground',
},
},
},
);
const pageFeedbackResult = z.extend(pageFeedback, {
response: actionResponse,
});
const blockFeedbackResult = z.extend(blockFeedback, {
response: actionResponse,
});
/**
* A feedback component to be attached at the end of page
*/
export function Feedback({
onSendAction,
}: {
onSendAction: (feedback: PageFeedback) => Promise<ActionResponse>;
}) {
const url = usePathname() ?? '/';
const { previous, setPrevious } = useSubmissionStorage(url, (v) => {
const result = pageFeedbackResult.safeParse(v);
return result.success ? result.data : null;
});
const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
const [message, setMessage] = useState('');
const [isPending, startTransition] = useTransition();
function submit(e?: SyntheticEvent) {
if (opinion == null) return;
startTransition(async () => {
const feedback: PageFeedback = {
url: location.href,
opinion,
message,
};
const response = await onSendAction(feedback);
setPrevious({
response,
...feedback,
});
setMessage('');
setOpinion(null);
});
e?.preventDefault();
}
const activeOpinion = previous?.opinion ?? opinion;
return (
<Collapsible
open={opinion !== null || previous !== null}
onOpenChange={(v) => {
if (!v) setOpinion(null);
}}
className="border-y py-3"
>
<div className="flex flex-row items-center gap-2">
<p className="text-sm font-medium pe-2">How is this guide?</p>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: activeOpinion === 'good',
}),
)}
onClick={() => {
setOpinion('good');
}}
>
<ThumbsUp />
Good
</button>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: activeOpinion === 'bad',
}),
)}
onClick={() => {
setOpinion('bad');
}}
>
<ThumbsDown />
Bad
</button>
</div>
<CollapsibleContent className="mt-3">
{previous ? (
<div className="px-3 py-6 flex flex-col items-center gap-3 bg-fd-card text-fd-muted-foreground text-sm text-center rounded-xl">
<p>Thank you for your feedback!</p>
<div className="flex flex-row items-center gap-2">
<a
href={previous.response?.githubUrl}
rel="noreferrer noopener"
target="_blank"
className={cn(
buttonVariants({
color: 'primary',
}),
'text-xs',
)}
>
View on GitHub
</a>
<button
className={cn(
buttonVariants({
color: 'secondary',
}),
'text-xs',
)}
onClick={() => {
setOpinion(previous.opinion);
setPrevious(null);
}}
>
Submit Again
</button>
</div>
</div>
) : (
<form className="flex flex-col gap-3" onSubmit={submit}>
<textarea
autoFocus
required
value={message}
onChange={(e) => setMessage(e.target.value)}
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
placeholder="Leave your feedback..."
onKeyDown={(e) => {
if (!e.shiftKey && e.key === 'Enter') {
submit(e);
}
}}
/>
<button
type="submit"
className={cn(buttonVariants({ color: 'outline' }), 'w-fit px-3')}
disabled={isPending}
>
Submit
</button>
</form>
)}
</CollapsibleContent>
</Collapsible>
);
}
export interface FeedbackTextProps {
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
children?: ReactNode;
}
/**
* A feedback component for each content block in page, should be used with `remark-feedback-block`.
*
* See https://hanzoai.github.io/docs/docs/guides/integrations/feedback.
*/
export function FeedbackText({ onSendAction, children }: FeedbackTextProps) {
const [popup, _setPopup] = useState<{
mode: 'tooltip' | 'expanded';
blockId: string;
selection: string;
range: Range;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles } = useFloating({
open: popup !== null,
placement: 'bottom',
middleware: [offset(6), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function expandPopup() {
if (popup?.mode !== 'tooltip') return;
const highlight = new Highlight(popup.range);
CSS.highlights.set('fd-feedback-text', highlight);
_setPopup({ ...popup, mode: 'expanded' });
}
function closePopup() {
if (popup?.mode === 'expanded') {
CSS.highlights.delete('fd-feedback-text');
}
_setPopup(null);
}
const updateSelectionPopover = useEffectEvent(() => {
if (popup && popup.mode === 'expanded') return;
const container = containerRef.current;
const selection = window.getSelection();
if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
closePopup();
return;
}
const range = selection.getRangeAt(0).cloneRange();
if (!container.contains(range.commonAncestorContainer)) {
closePopup();
return;
}
const selectionText = selection.toString().trim();
// also prevent cross-paragraph selection
if (selectionText.length === 0 || selectionText.includes('\n')) {
closePopup();
return;
}
const element =
range.startContainer instanceof Element
? range.startContainer
: range.startContainer.parentElement;
const blockId = element?.closest('[data-block="feedback"]')?.id;
if (!blockId) {
closePopup();
return;
}
refs.setReference({
getBoundingClientRect() {
return range.getBoundingClientRect();
},
contextElement: container,
});
_setPopup({ mode: 'tooltip', range, selection: selectionText, blockId });
});
const closeOnEscape = useEffectEvent((event: KeyboardEvent) => {
if (popup === null) return;
if (event.key === 'Escape') closePopup();
});
const closeOnPointerDown = useEffectEvent((event: PointerEvent) => {
const target = event.target;
if (popup === null || !(target instanceof Node)) return;
if (
refs.floating.current?.contains(target) ||
(popup.mode === 'tooltip' && containerRef.current?.contains(target))
) {
return;
}
closePopup();
});
useEffect(() => {
let frame: number | null = null;
function scheduleSelectionPopover() {
if (frame !== null) window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
frame = null;
updateSelectionPopover();
});
}
document.addEventListener('selectionchange', scheduleSelectionPopover);
document.addEventListener('keydown', closeOnEscape);
document.addEventListener('pointerdown', closeOnPointerDown);
return () => {
document.removeEventListener('keydown', closeOnEscape);
document.removeEventListener('pointerdown', closeOnPointerDown);
document.removeEventListener('selectionchange', scheduleSelectionPopover);
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, []);
return (
<>
<div
ref={containerRef}
className="prose-no-margin [&_::highlight(fd-feedback-text)]:bg-fd-primary [&_::highlight(fd-feedback-text)]:text-fd-primary-foreground"
>
{children}
</div>
{popup && (
<div
ref={refs.setFloating}
className={cn(
'not-prose z-40 text-sm bg-fd-popover text-fd-popover-foreground border overflow-hidden shadow-lg rounded-xl w-30 h-9.5 box-content transition-[width,height]',
popup.mode === 'expanded' ? 'w-[300px] h-32 max-w-[98vw]' : 'select-none',
)}
style={floatingStyles}
>
{popup.mode === 'tooltip' ? (
<div className="w-30 h-9.5 p-1">
<button
className={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'size-full gap-1.5',
)}
onClick={expandPopup}
>
<CornerDownRightIcon className="size-4 text-fd-muted-foreground" />
Feedback
</button>
</div>
) : (
<FeedbackTextForm
blockId={popup.blockId}
selection={popup.selection}
onSendAction={onSendAction}
onClose={closePopup}
container={{ className: 'p-2 w-[300px] h-32 max-w-[98vw] animate-fd-fade-in' }}
/>
)}
</div>
)}
</>
);
}
function FeedbackTextForm({
blockId,
selection,
onSendAction,
onClose,
container,
}: {
container: HTMLAttributes<HTMLElement>;
blockId: string;
selection: string;
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
onClose: () => void;
}) {
const { previous, setPrevious } = useSubmissionStorage(blockId, (v) => {
const result = blockFeedbackResult.safeParse(v);
if (result.success) return result.data;
return null;
});
const [message, setMessage] = useState('');
const [isPending, startTransition] = useTransition();
function submit(e?: SyntheticEvent) {
startTransition(async () => {
const feedback: BlockFeedback = {
blockId,
blockBody: selection,
url: location.href,
message,
};
const response = await onSendAction(feedback);
setPrevious({
response,
...feedback,
});
setMessage('');
});
e?.preventDefault();
}
if (previous)
return (
<div
{...container}
className={cn(
'flex flex-col items-center justify-center gap-2 text-fd-muted-foreground text-center',
container.className,
)}
>
<p>Thank you for your feedback!</p>
<div className="flex flex-row items-center gap-2">
<a
href={previous.response?.githubUrl}
rel="noreferrer noopener"
target="_blank"
className={cn(
buttonVariants({
color: 'primary',
}),
'text-xs',
)}
>
View on GitHub
</a>
<button
className={cn(
buttonVariants({
color: 'secondary',
}),
'text-xs',
)}
onClick={() => {
setPrevious(null);
}}
>
Submit Again
</button>
</div>
</div>
);
return (
<form
{...container}
className={cn('flex flex-col gap-2', container.className)}
onSubmit={submit}
>
<textarea
autoFocus
required
value={message}
onChange={(e) => setMessage(e.target.value)}
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
placeholder="Leave your feedback..."
onKeyDown={(e) => {
if (!e.shiftKey && e.key === 'Enter') {
submit(e);
}
}}
/>
<div className="grid grid-cols-2 gap-2 mt-auto">
<button
type="submit"
className={cn(buttonVariants({ variant: 'primary', size: 'sm' }), 'gap-1.5')}
disabled={isPending}
>
<CornerDownRightIcon className="size-4" />
Submit
</button>
<button
type="button"
className={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'gap-1.5')}
disabled={isPending}
onClick={onClose}
>
Close
</button>
</div>
</form>
);
}
function useSubmissionStorage<Result>(key: string, validate: (v: unknown) => Result | null) {
const storageKey = `docs-feedback-${key}`;
const [value, setValue] = useState<Result | null>(null);
const validateCallback = useEffectEvent(validate);
useEffect(() => {
const item = localStorage.getItem(storageKey);
if (item === null) return;
const validated = validateCallback(JSON.parse(item));
if (validated !== null) setValue(validated);
}, [storageKey]);
return {
previous: value,
setPrevious(result: Result | null) {
if (result) localStorage.setItem(storageKey, JSON.stringify(result));
else localStorage.removeItem(storageKey);
setValue(result);
},
};
}
-31
View File
@@ -1,31 +0,0 @@
'use client';
import type { ReactNode } from 'react';
import type { FeedbackBlockProps } from '@hanzo/docs-core/mdx-plugins/remark-feedback-block';
import { Feedback as FeedbackInner, FeedbackText as FeedbackBlockInner } from './client';
import type { ActionResponse, BlockFeedback, PageFeedback } from './schema';
async function mockPageAction(feedback: PageFeedback): Promise<ActionResponse> {
console.log('Feedback submitted (static export):', feedback);
return { githubUrl: '#' };
}
async function mockBlockAction(feedback: BlockFeedback): Promise<ActionResponse> {
console.log('Block feedback submitted (static export):', feedback);
return { githubUrl: '#' };
}
export function PageFeedback() {
return <FeedbackInner onSendAction={mockPageAction} />;
}
export function PageFeedbackBlock({
children,
...props
}: FeedbackBlockProps & { children: ReactNode }) {
return (
<FeedbackBlockInner {...props} onSendAction={mockBlockAction}>
{children}
</FeedbackBlockInner>
);
}
-26
View File
@@ -1,26 +0,0 @@
import { z } from 'zod/mini';
export const blockFeedback = z.object({
/** full URL of page where fired */
url: z.string(),
blockId: z.string(),
message: z.string(),
/** the referenced text of block */
blockBody: z.string(),
});
export const pageFeedback = z.object({
opinion: z.enum(['good', 'bad']),
/** full URL of page where fired */
url: z.string(),
message: z.string(),
});
export const actionResponse = z.object({
githubUrl: z.optional(z.string()),
});
export type BlockFeedback = z.infer<typeof blockFeedback>;
export type PageFeedback = z.infer<typeof pageFeedback>;
export type ActionResponse = z.infer<typeof actionResponse>;
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { Input, XStack } from '@hanzo/gui';
import { Search } from '@hanzogui/lucide-icons-2';
/** A filter field: a glass and a one-line input, for the catalogs. */
export function Field({
value,
onChange,
placeholder,
width,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
width?: number;
}) {
return (
<XStack
items="center"
gap={8}
px={10}
height={36}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$background"
width={width ?? '100%'}
$max-sm={{ width: '100%' }}
>
<Search size={14} color="$color10" />
<Input
unstyled
flex={1}
minW={0}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="$color10"
fontSize={14}
color="$color12"
bg="transparent"
borderWidth={0}
aria-label={placeholder}
/>
</XStack>
);
}
+291
View File
@@ -0,0 +1,291 @@
'use client';
import { useRouter } from 'next/navigation';
import { Fragment, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react';
import { Input, Text, XStack, YStack } from '@hanzo/gui';
import { ArrowRight, Check, ChevronRight, Hash, Search as Glass, Sparkle } from '@hanzogui/lucide-icons-2';
import { Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import type { SortedResult } from '@hanzo/docs-core/search';
import { createMarkdownRenderer } from '@hanzo/docs-core/content/md';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import rehypeRaw from 'rehype-raw';
import { loud, muted } from '@/lib/ink';
import { Chips } from '@/components/chips';
import { useTree } from '@/lib/tree';
import { AGENT_SETUP_PROMPT } from '@/lib/agent-setup-prompt';
/**
* The search dialog. Full text over every page (the static index at
* /api/search), a jump straight to a page whose title starts with what was
* typed, and — when the words land nowhere — the question handed to the
* reader's own agent, set up for Hanzo. Arrows move, Enter opens, Esc closes.
*/
const TAGS = [
{ label: 'All', value: undefined },
{ label: 'Services', value: 'services' },
{ label: 'SDKs', value: 'sdks' },
{ label: 'API', value: 'openapi' },
{ label: 'Products', value: 'products' },
] as const;
type Item =
| (SortedResult & { kind: 'result' })
| { kind: 'action'; id: string; node: ReactNode; run: () => void };
// Result text arrives as Markdown with the matched words in <mark>.
const md = createMarkdownRenderer({ remarkRehypeOptions: { allowDangerousHtml: true }, rehypePlugins: [rehypeRaw] });
const MD = {
p: ({ children }: ComponentProps<'p'>) => <Text whiteSpace="normal">{children}</Text>,
a: ({ children }: ComponentProps<'a'>) => <Text>{children}</Text>,
mark: ({ children }: ComponentProps<'mark'>) => (
<Text color="$color12" fontWeight="600" textDecorationLine="underline">
{children}
</Text>
),
strong: ({ children }: ComponentProps<'strong'>) => <Text fontWeight="600">{children}</Text>,
code: ({ children }: ComponentProps<'code'>) => (
<Text fontFamily="$mono" fontSize="0.9em" px={3} rounded={4} bg="$hover">
{children}
</Text>
),
pre: ({ children }: ComponentProps<'pre'>) => (
<Text render="pre" fontFamily="$mono" fontSize={12} whiteSpace="pre" overflow="hidden" maxH={60}>
{children}
</Text>
),
};
function pages(nodes: PageTree.Node[], out = new Map<string, PageTree.Item>()) {
for (const node of nodes) {
if (node.type === 'page' && typeof node.name === 'string') out.set(node.name.toLowerCase(), node);
if (node.type === 'folder') {
if (node.index) pages([node.index], out);
pages(node.children, out);
}
}
return out;
}
export default function Finder({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const router = useRouter();
const { tree } = useTree();
const [tag, setTag] = useState<string | undefined>();
const [asked, setAsked] = useState(false);
const { search, setSearch, query } = useDocsSearch({ type: 'flexsearch-static', from: '/api/search', tag });
const titles = useMemo(() => pages(tree.children), [tree]);
const items = useMemo<Item[] | null>(() => {
const q = search.trim();
const out: Item[] = [];
if (q) {
const lower = q.toLowerCase();
for (const [name, page] of titles) {
if (!name.startsWith(lower)) continue;
out.push({
kind: 'action',
id: 'jump',
node: (
<XStack gap={8} items="center">
<ArrowRight size={14} color="$color10" />
<Text fontSize="$3" {...muted}>
Jump to <Text {...loud} fontWeight="500">{page.name}</Text>
</Text>
</XStack>
),
run: () => router.push(page.url),
});
break;
}
}
if (Array.isArray(query.data)) for (const r of query.data) out.push({ ...r, kind: 'result' });
// A question is the fallback when the terms did not land, so it is last.
if (q.length >= 3)
out.push({
kind: 'action',
id: 'ask',
node: (
<XStack gap={8} items="center">
{asked ? <Check size={14} color="$color10" /> : <Sparkle size={14} color="$color10" />}
<Text fontSize="$3" {...muted}>
{asked ? 'Copied — paste it into your agent' : <>Ask an agent <Text {...loud} fontWeight="500">“{q}”</Text></>}
</Text>
</XStack>
),
run: () =>
void navigator.clipboard
.writeText(
`${AGENT_SETUP_PROMPT}\n\n---\n\nThen answer this using the Hanzo documentation at https://docs.hanzo.ai, and use the Hanzo skills at https://hanzoskills.com for any capability it touches:\n\n${q}`,
)
.then(() => setAsked(true)),
});
return q ? out : null;
}, [search, query.data, titles, router, asked]);
const [active, setActive] = useState(0);
useEffect(() => setActive(0), [items]);
const choose = (item: Item) => {
if (item.kind === 'action') {
item.run();
if (item.id === 'ask') return;
} else router.push(item.url);
onOpenChange(false);
};
// Keys are read from the document while the dialog is open, so they work
// whether focus is in the field or on a row.
const list = useRef(items);
list.current = items;
const at = useRef(active);
at.current = active;
useEffect(() => {
if (!open) return;
const key = (e: KeyboardEvent) => {
const rows = list.current;
if (!rows?.length || e.isComposing) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => (i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length);
} else if (e.key === 'Enter') {
e.preventDefault();
const row = rows[at.current];
if (row) choose(row);
}
};
document.addEventListener('keydown', key);
return () => document.removeEventListener('keydown', key);
});
const box = useRef<HTMLElement>(null);
useEffect(() => {
box.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
}, [active]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} p={0} gap={0} width="92%" maxW={640} overflow="hidden" bg="$background">
<DialogTitle position="absolute" width={1} height={1} overflow="hidden" opacity={0}>
Search the docs
</DialogTitle>
<XStack px={14} height={52} gap={10} items="center" borderBottomWidth={1} borderColor="$borderColor">
<Glass size={18} color="$color10" opacity={query.isLoading ? 0.5 : 1} />
<Input
unstyled
autoFocus
flex={1}
minW={0}
value={search}
onChangeText={setSearch}
placeholder="Search or ask"
placeholderTextColor="$color10"
fontSize={17}
color="$color12"
bg="transparent"
borderWidth={0}
outlineWidth={0}
aria-label="Search the docs"
/>
<Text
render="button"
onPress={() => onOpenChange(false)}
fontFamily="$mono"
fontSize={11}
px={6}
py={2}
rounded={6}
borderWidth={1}
borderColor="$borderColor"
bg="transparent"
cursor="pointer"
{...muted}
>
ESC
</Text>
</XStack>
{items && (
<YStack ref={box as never} maxH={440} overflowY="auto" p={6} gap={2} borderBottomWidth={1} borderColor="$borderColor">
{items.length === 0 ? (
<Text py={40} text="center" fontSize="$3" {...muted}>
Nothing matches.
</Text>
) : (
items.map((item, i) => (
<Row key={item.id} item={item} active={i === active} onHover={() => setActive(i)} onPress={() => choose(item)} />
))
)}
</YStack>
)}
<XStack px={12} py={10} gap={10} items="center" bg="$panel">
<Text fontSize={12} {...muted}>
Filter
</Text>
<Chips items={TAGS} value={tag} onChange={setTag} />
</XStack>
</DialogContent>
</Dialog>
);
}
function Row({ item, active, onHover, onPress }: { item: Item; active: boolean; onHover: () => void; onPress: () => void }) {
const inset = item.kind === 'result' && item.type !== 'page';
return (
<YStack
render="button"
type="button"
data-active={active ? 'true' : undefined}
aria-selected={active}
onPress={onPress}
onHoverIn={onHover}
position="relative"
gap={4}
px={10}
py={8}
pl={inset ? (item.kind === 'result' && item.type === 'heading' ? 34 : 22) : 10}
rounded="$3"
bg={active ? '$hover' : 'transparent'}
borderWidth={0}
cursor="pointer"
items="flex-start"
>
{item.kind === 'action' ? (
item.node
) : (
<>
{item.breadcrumbs && item.breadcrumbs.length > 0 && (
<XStack items="center" gap={2} flexWrap="wrap">
{item.breadcrumbs.map((crumb, i) => (
<Fragment key={i}>
{i > 0 && <ChevronRight size={12} color="$color9" />}
<Text fontSize={12} {...muted}>
{crumb}
</Text>
</Fragment>
))}
</XStack>
)}
{inset && <YStack position="absolute" l={12} t={0} b={0} width={1} bg="$borderColor" />}
{item.type === 'heading' && (
<YStack position="absolute" l={18} t={10}>
<Hash size={13} color="$color10" />
</YStack>
)}
<Text
fontSize="$3"
lineHeight={20}
text="left"
whiteSpace="normal"
fontWeight={item.type === 'text' ? '400' : '500'}
{...(item.type === 'text' ? muted : loud)}
>
<md.Markdown components={MD}>{item.content}</md.Markdown>
</Text>
</>
)}
</YStack>
);
}
+16 -6
View File
@@ -1,12 +1,22 @@
'use client';
import { HanzoFooter } from '@hanzogui/shell';
import { YStack } from '@hanzo/gui';
import { HanzoFooter, HanzoPreFooterCTA } from '@hanzogui/shell';
/**
* The shared 6-column ecosystem footer (HANZO_FOOTER_COLUMNS + bottom legal
* bar), byte-identical across every Hanzo property. Docs is hanzo.ai-adjacent
* and is not itself a flagship product, so `currentProductId` stays unset.
* The shared ecosystem footer, byte-identical across every Hanzo property, and
* on the front page and the blog the pre-footer call to action above it.
*
* Both are drawn by @hanzogui/shell for a dark ground with a fixed white ink,
* so they sit on the dark ground in both themes — on a light page the
* pre-footer's heading measured 2.38:1 against the body. Docs is not itself a
* flagship product, so no `currentProductId`.
*/
export function Footer() {
return <HanzoFooter />;
export function Footer({ cta = false }: { cta?: boolean }) {
return (
<YStack bg="#0a0a0a">
{cta && <HanzoPreFooterCTA surface="hanzo.ai" />}
<HanzoFooter />
</YStack>
);
}
-184
View File
@@ -1,184 +0,0 @@
'use client';
import { lazy, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import type {
ForceGraphMethods,
ForceGraphProps,
LinkObject,
NodeObject,
} from 'react-force-graph-2d';
import { forceCollide, forceLink, forceManyBody } from 'd3-force';
import { useRouter } from '@hanzo/docs/core/framework';
export interface Graph {
links: Link[];
nodes: Node[];
}
export type Node = NodeObject<NodeType>;
export type Link = LinkObject<NodeType, LinkType>;
export interface NodeType {
text: string;
description?: string;
neighbors?: string[];
url: string;
}
export type LinkType = Record<string, unknown>;
export interface GraphViewProps {
graph: Graph;
}
const ForceGraph2D = lazy(
() => import('react-force-graph-2d'),
) as typeof import('react-force-graph-2d').default;
export function GraphView(props: GraphViewProps) {
const ref = useRef<HTMLDivElement>(null);
const [mount, setMount] = useState(false);
useEffect(() => {
setMount(true);
}, []);
return (
<div
ref={ref}
className="relative border h-[600px] [&_canvas]:size-full rounded-xl overflow-hidden bg-fd-background"
>
{mount && <ClientOnly {...props} containerRef={ref} />}
</div>
);
}
function ClientOnly({
containerRef,
graph,
}: GraphViewProps & { containerRef: RefObject<HTMLDivElement | null> }) {
const graphRef = useRef<ForceGraphMethods<Node, Link> | undefined>(undefined);
const hoveredRef = useRef<Node | null>(null);
const router = useRouter();
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
content: string;
} | null>(null);
const handleNodeHover = (node: Node | null) => {
const graph = graphRef.current;
if (!graph) return;
hoveredRef.current = node;
if (node) {
const coords = graph.graph2ScreenCoords(node.x!, node.y!);
setTooltip({
x: coords.x + 4,
y: coords.y + 4,
content: node.description ?? 'No description',
});
} else {
setTooltip(null);
}
};
// Custom node rendering: circle with text label below
const nodeCanvasObject: ForceGraphProps['nodeCanvasObject'] = (node, ctx) => {
const container = containerRef.current;
if (!container) return;
const style = getComputedStyle(container);
const fontSize = 14;
const radius = 5;
// Draw circle
ctx.beginPath();
ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI, false);
const hoverNode = hoveredRef.current;
const isActive = hoverNode?.id === node.id || hoverNode?.neighbors?.includes(node.id as string);
ctx.fillStyle = isActive
? style.getPropertyValue('--color-fd-primary')
: style.getPropertyValue('--color-purple-300');
ctx.fill();
// Draw text below the node
ctx.font = `${fontSize}px Sans-Serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = getComputedStyle(container).getPropertyValue('color');
ctx.fillText(node.text, node.x!, node.y! + radius + fontSize);
};
const linkColor = (link: Link) => {
const container = containerRef.current;
if (!container) return '#999';
const style = getComputedStyle(container);
const hoverNode = hoveredRef.current;
if (
hoverNode &&
typeof link.source === 'object' &&
typeof link.target === 'object' &&
(hoverNode.id === link.source.id || hoverNode.id === link.target.id)
) {
return style.getPropertyValue('--color-fd-primary');
}
return `color-mix(in oklab, ${style.getPropertyValue('--color-fd-muted-foreground')} 50%, transparent)`;
};
// Enrich nodes with neighbors for hover effects
const enrichedNodes = useMemo(() => {
const { nodes, links } = structuredClone(graph);
for (const node of nodes) {
node.neighbors = links.flatMap((link) => {
if (link.source === node.id) return link.target as string;
if (link.target === node.id) return link.source as string;
return [];
});
}
return {
nodes,
links,
};
}, [graph]);
return (
<>
<ForceGraph2D<NodeType, LinkType>
ref={{
get current() {
return graphRef.current;
},
set current(fg) {
graphRef.current = fg;
if (fg) {
fg.d3Force('link', forceLink().distance(200));
fg.d3Force('charge', forceManyBody().strength(10));
fg.d3Force('collision', forceCollide(60));
}
},
}}
graphData={enrichedNodes}
nodeCanvasObject={nodeCanvasObject}
linkColor={linkColor}
onNodeHover={handleNodeHover}
onNodeClick={(node) => {
router.push(node.url);
}}
linkWidth={2}
enableNodeDrag
enableZoomInteraction
/>
{tooltip && (
<div
className="absolute bg-fd-popover text-fd-popover-foreground size-fit p-2 border rounded-xl shadow-lg text-sm max-w-xs"
style={{ top: tooltip.y, left: tooltip.x }}
>
{tooltip.content}
</div>
)}
</>
);
}
+16 -77
View File
@@ -1,74 +1,28 @@
'use client';
import { useEffect, useState } from 'react';
import { YStack } from '@hanzo/gui';
import { useThemeSetting } from '@hanzogui/next-theme';
import { Dots } from '@hanzo/ui/dots';
/**
* The hero's halftone, as a client island.
* The hero's halftone, as a client island — `Dots` takes its shape as a
* function, and a function cannot cross the server/client boundary.
*
* `Dots` takes its shape as `field`, a function of space and time — and a
* function cannot cross the server/client boundary. The landing page is a
* Server Component, so passing one there fails the build outright:
*
* Functions cannot be passed directly to Client Components unless you
* explicitly expose it by marking it with "use server".
*
* So the function has to be DEFINED on the client side of the line, which is
* what this file is. It is the whole reason the component exists.
*
* Module scope, not an inline arrow: `field` is a dependency of the effect that
* paints, so a new identity every render would re-raster the canvas every
* render.
*/
/**
* The ENVELOPE: brightest at top centre, gone by the edges — the shape the CSS
* `radial-gradient(ellipse 600px 300px at 50% 0%, …)` had. `Dots`'s own default
* is SWELL, a DIAGONAL wave, which is ambient texture for a left-aligned surface
* but reads as a smudge off to one side under a centred headline.
*
* Separated from the motion below because it is the half that must not move: it
* is what keeps the field dim where the type sits.
* The envelope is brightest at top centre and gone by the edges, so the field
* stays dim where the type sits; one slow ring travels outward from behind the
* headline, modulating the envelope between 0.6 and 1.0 and never switching a
* dot off. Module scope, so the painting effect does not re-raster every render.
*/
const envelope = (x: number, y: number) => {
const r = Math.hypot((x - 0.5) / 0.45, y / 0.5);
return Math.max(0, 1 - r) ** 1.5;
};
/**
* One slow ring travelling outward from the same origin the envelope falls off
* from — so the field reads as emanating from behind the headline rather than
* drifting past it. A diagonal or a scroll would have a DIRECTION, and a
* direction under centred type points somewhere the page does not go.
*
* The three constants are the whole character and each is bounded on purpose:
*
* 0.20 the amplitude, so the wave rides between 0.60 and 1.00 of the envelope
* — a 40% swing peak to trough. It MODULATES and never extinguishes: the
* shape is always the shape, and at its brightest it is exactly the
* static field this replaced. Measured across the field over a full
* period, the ratio to the envelope stays within [0.600, 1.000] and the
* value never leaves [0, 1]. Deeper and the dots visibly switch on and
* off, which reads as a loading state rather than as atmosphere.
* 6.5 the ring count, about two visible across the field. More becomes a
* moiré against the 7px cell pitch, which shimmers rather than breathes.
* 0.5 the speed: one ring every 12.6s. Slow enough that nothing in peripheral
* vision demands attention while a reader is reading.
*
* Where the headline sits the field peaks at 0.35 of full ink across the whole
* cycle, so the motion never competes with the type it sits behind.
*/
const HERO = (x: number, y: number, t: number) =>
envelope(x, y) * (0.8 + 0.2 * Math.cos(Math.hypot((x - 0.5) / 0.45, y / 0.5) * 6.5 - t * 0.5));
/**
* Whether this reader has asked for less motion. Read once on mount and watched
* after: the setting is changed while a page is open often enough — a system
* theme flip carries it — that a value sampled once goes stale in place.
*
* It starts TRUE so the first paint is the still field. The alternative starts
* animating and stops, which is the one outcome the setting exists to prevent.
*/
/** Reduced motion gets the still field, on the first paint too. */
function useStill() {
const [still, setStill] = useState(true);
useEffect(() => {
@@ -81,30 +35,15 @@ function useStill() {
return still;
}
/**
* Decorative: hidden from assistive tech, and it cannot take a pointer.
*
* It ANIMATES now. It did not, on the argument that a full-hero canvas
* repainting every frame is a real battery cost on a page whose job is to be
* read — which is true, and is why the motion is a 12-second breath at a fifth
* of the envelope's depth rather than anything a reader has to look away from.
* Frozen, the field was a gradient drawn out of dots; moving, the dots are the
* reason it is dots.
*
* Reduced motion still gets the still field, and gets it on the FIRST paint —
* which is also what a reader sees if JavaScript never runs.
*/
/** Decorative: hidden from assistive tech, and it takes no pointer. The dots are
* the page's ink at low alpha, so the field reads on either ground. */
export function HeroField() {
const still = useStill();
const { resolvedTheme } = useThemeSetting();
const ink = resolvedTheme === 'light' ? 'rgb(0 0 0 / 0.14)' : 'rgb(255 255 255 / 0.18)';
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
<Dots
field={HERO}
animate={!still}
cell={7}
color="rgb(255 255 255 / 0.18)"
fade={{ top: 0.06, bottom: 0.45, left: 0.1, right: 0.1 }}
/>
</div>
<YStack aria-hidden pointerEvents="none" position="absolute" t={0} r={0} b={0} l={0} overflow="hidden">
<Dots field={HERO} animate={!still} cell={7} color={ink} fade={{ top: 0.06, bottom: 0.45, left: 0.1, right: 0.1 }} />
</YStack>
);
}
+57 -76
View File
@@ -11,7 +11,12 @@
// catalog is usable without leaving the page: a framework that installs from npm
// shows the npm line, one that ships a tag shows the tag.
import { useMemo, useState } from 'react';
import { Search, Check, Copy } from 'lucide-react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { Check, Copy } from '@hanzogui/lucide-icons-2';
import { Grid } from '@hanzo/ui/grid';
import { Chips } from '@/components/chips';
import { Field } from '@/components/field';
import { muted } from '@/lib/ink';
type Kind = 'web' | 'mobile' | 'server' | 'llm';
@@ -77,44 +82,54 @@ const TARGETS: Target[] = [
{ id: 'mcp', label: 'MCP', kind: 'llm', install: 'npx @hanzo/mcp', docs: '/docs/mcp' },
];
const KINDS: { id: Kind | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'web', label: 'Web' },
{ id: 'mobile', label: 'Mobile' },
{ id: 'server', label: 'Server' },
{ id: 'llm', label: 'LLM' },
const KINDS: { value: Kind | 'all'; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'web', label: 'Web' },
{ value: 'mobile', label: 'Mobile' },
{ value: 'server', label: 'Server' },
{ value: 'llm', label: 'LLM' },
];
function Row({ t }: { t: Target }) {
const [copied, setCopied] = useState(false);
return (
<div className="flex flex-col gap-2 rounded-lg border border-fd-border bg-fd-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{t.label}</span>
<YStack gap={8} p={14} rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel">
<XStack justify="space-between" items="center" gap={8}>
<Text fontSize={14} fontWeight="500" color="$color12">
{t.label}
</Text>
{t.docs ? (
<a href={t.docs} className="text-xs text-fd-muted-foreground underline-offset-2 hover:underline">
<Text render="a" href={t.docs} fontSize={12} {...muted} hoverStyle={{ textDecorationLine: 'underline' }}>
docs
</a>
</Text>
) : null}
</div>
<button
</XStack>
<XStack
render="button"
type="button"
onClick={() => {
aria-label={`Copy install for ${t.label}`}
onPress={() => {
void navigator.clipboard.writeText(t.install);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}}
className="group flex items-center justify-between gap-2 rounded border border-fd-border bg-fd-secondary/40 px-2 py-1.5 text-left font-mono text-xs"
aria-label={`Copy install for ${t.label}`}
justify="space-between"
items="center"
gap={8}
px={8}
py={6}
rounded="$2"
borderWidth={1}
borderColor="$borderColor"
bg="$background"
cursor="pointer"
>
<code className="truncate">{t.install}</code>
{copied ? (
<Check className="size-3.5 shrink-0" aria-hidden />
) : (
<Copy className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" aria-hidden />
)}
</button>
</div>
<Text fontFamily="$mono" fontSize={12} numberOfLines={1} color="$color12">
{t.install}
</Text>
{copied ? <Check size={13} color="$color11" /> : <Copy size={13} color="$color9" />}
</XStack>
</YStack>
);
}
@@ -132,61 +147,27 @@ export function InstallCatalog() {
}, [q, kind]);
return (
<div className="not-prose flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[12rem]">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fd-muted-foreground" aria-hidden />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search frameworks and platforms"
aria-label="Search frameworks and platforms"
className="w-full rounded-md border border-fd-border bg-fd-background py-1.5 pl-8 pr-2 text-sm"
/>
</div>
<div className="flex flex-wrap gap-1">
{KINDS.map((k) => (
<button
key={k.id}
type="button"
onClick={() => setKind(k.id)}
aria-pressed={kind === k.id}
className={
'rounded-md px-2.5 py-1 text-sm ' +
(kind === k.id
? 'bg-fd-primary text-fd-primary-foreground'
: 'text-fd-muted-foreground hover:bg-fd-secondary')
}
>
{k.label}
</button>
))}
</div>
</div>
<YStack gap={16}>
<XStack gap={10} flexWrap="wrap" items="center">
<YStack flex={1} minW={200}>
<Field value={q} onChange={setQ} placeholder="Search frameworks and platforms" />
</YStack>
<Chips items={KINDS} value={kind} onChange={setKind} />
</XStack>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Grid columns={{ min: 240 }} gap={12}>
{shown.map((t) => (
<Row key={t.id} t={t} />
))}
</div>
</Grid>
{shown.length === 0 ? (
<p className="text-sm text-fd-muted-foreground">
Nothing matches “{q}”. Every stack can use the{' '}
<a href="/docs/openapi" className="underline underline-offset-2">
HTTP API
</a>
.
</p>
) : (
<p className="text-sm text-fd-muted-foreground">
{shown.length} of {TARGETS.length}. Anything not listed reaches the same endpoints over the{' '}
<a href="/docs/openapi" className="underline underline-offset-2">
HTTP API
</a>
.
</p>
)}
</div>
<Text fontSize={14} whiteSpace="normal" {...muted}>
{shown.length === 0 ? `Nothing matches “${q}”. Every stack can use the ` : `${shown.length} of ${TARGETS.length}. Anything not listed reaches the same endpoints over the `}
<Text render="a" href="/docs/openapi" color="$color12" textDecorationLine="underline">
HTTP API
</Text>
.
</Text>
</YStack>
);
}
+424
View File
@@ -0,0 +1,424 @@
'use client';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { Text, XStack, YStack } from '@hanzo/gui';
import {
Activity,
ArrowRight,
Code2,
Database,
FileJson2,
Globe,
LayoutGrid,
Package,
Server,
Shield,
ShoppingCart,
Sparkle,
Terminal,
Workflow,
} from '@hanzogui/lucide-icons-2';
import { Grid } from '@hanzo/ui/grid';
import { Action } from '@/components/action';
import { Agent } from '@/components/agent';
import { HeroField } from '@/components/hero-field';
import { muted } from '@/lib/ink';
/**
* The front page, drawn on the theme like every other route: the headline and
* the two ways in (install the CLI, or hand it to an agent), the three doors,
* the quick start, the nine domains, the tools, the models, the CLI and Zen.
*
* The quick start's code is highlighted on the server and handed in, so the
* page ships tokens rather than a highlighter.
*/
const domains = [
{ name: 'Identity & trust', desc: 'Who someone is, what they may touch, and where secrets stay safe. IAM, AuthZ, KMS, MPC, Zero-Trust.', Icon: Shield, href: '/docs/openapi/iam', tag: 'Identity' },
{ name: 'Intelligence', desc: 'The mind of the cloud — every model, agent, and tool you can call. Models, Agents, MCP, Embeddings, Prompts, GPUs, Functions.', Icon: Sparkle, href: '/docs/openapi/ai', tag: 'Intelligence' },
{ name: 'Data', desc: 'Somewhere to put your data and read it back fast. SQL, Vector, KV, Search, Object, Base, DocDB.', Icon: Database, href: '/docs/openapi/provisioning', tag: 'Data' },
{ name: 'Streams', desc: 'Move messages and run work in the background, reliably. PubSub, Tasks, Pipelines, Crawl.', Icon: Workflow, href: '/docs/openapi/pubsub', tag: 'Streams' },
{ name: 'Observability', desc: 'See exactly what your system is doing, live. Metrics, Logs, Traces, Sessions, Evals, Analytics.', Icon: Activity, href: '/docs/openapi/o11y', tag: 'Observe' },
{ name: 'Commerce', desc: 'Turn usage into money — meter it, price it, bill it, reward it. Commerce, Billing, Marketplace, Referrals.', Icon: ShoppingCart, href: '/docs/openapi/commerce', tag: 'Commerce' },
{ name: 'Platform', desc: 'Ship your code and run it anywhere. Gateway, Machines, Edge, Registry.', Icon: Server, href: '/docs/openapi/gateway', tag: 'Platform' },
{ name: 'Applications', desc: 'The finished products people use every day. Chat, Studio, Dev, Integrations, Apps.', Icon: LayoutGrid, href: '/docs/openapi/git', tag: 'Apps' },
{ name: 'Chain', desc: "The networks the cloud speaks to — enumerate them, call one, read a holder's balances. Web3, Explorer.", Icon: Globe, href: '/docs/openapi/web3', tag: 'Chain' },
];
const tools = [
{ name: 'The Network', desc: 'Run the whole cloud yourself', href: '/docs/network', Icon: Globe },
{ name: 'SDKs', desc: 'Python, TypeScript, Go, Rust, C++, Swift, Kotlin', href: '/docs/sdks', Icon: Code2 },
{ name: 'API Reference', desc: 'Every /v1 endpoint, live', href: '/docs/openapi', Icon: FileJson2 },
{ name: 'Architecture', desc: 'One binary, one contract', href: '/docs/architecture', Icon: Package },
];
const doors = [
{ eyebrow: 'No code', title: 'Build with App', body: 'Describe it in English and watch it build. Chat, agents and MCP tools in the browser.', href: 'https://hanzo.app' },
{ eyebrow: 'In your terminal', title: 'Build with Dev', body: 'Our coding agent, in your repo. Or bring Claude Code and Codex — they work here too.', href: '/docs/cli' },
{ eyebrow: 'Lower level', title: 'Build with API', body: 'Over 400 models behind one REST endpoint, with SDKs for every language we ship.', href: '/docs/openapi' },
];
const providers = [
{ name: 'Zen', spec: 'Open weights' },
{ name: 'OpenAI', spec: 'GPT' },
{ name: 'Anthropic', spec: 'Claude' },
{ name: 'Qwen', spec: 'Open' },
{ name: 'Llama', spec: 'Open' },
{ name: 'DeepSeek', spec: 'Open' },
{ name: 'Mistral', spec: 'Open' },
{ name: 'Gemma', spec: 'Open' },
];
const surfaces = [
{
Icon: Code2,
title: 'SDKs in every language',
body: 'Generated from one contract — the same /v1 surface, typed for your stack.',
chips: ['Python', 'TypeScript', 'Go', 'Rust', 'C++', 'Dart'],
href: '/docs/sdks',
cta: 'SDK reference',
},
{
Icon: Workflow,
title: 'Every tool, one MCP surface',
body: 'Native connectors + the open MCP registry — Slack, GitHub, Notion, Stripe, and more — exposed as MCP tools any agent can call.',
chips: ['Slack', 'GitHub', 'Notion', 'Stripe', 'Google', 'Linear', '+700 more'],
href: '/docs/mcp',
cta: 'MCP tools',
},
];
const commands = [
{ cmd: 'hanzo chat', desc: 'Chat with any model interactively' },
{ cmd: 'hanzo models list', desc: 'Browse every available model' },
{ cmd: 'hanzo keys create', desc: 'Create and manage API keys' },
{ cmd: 'hanzo deploy', desc: 'Deploy apps with git push' },
{ cmd: 'hanzo logs', desc: 'Stream logs from any service' },
{ cmd: 'hanzo storage', desc: 'Manage S3-compatible storage' },
{ cmd: 'hanzo secrets', desc: 'Manage secrets and env vars' },
{ cmd: 'hanzo bot', desc: 'Deploy and manage AI bots' },
{ cmd: 'hanzo flow', desc: 'Run workflow automations' },
];
const zen = [
{ name: 'zen6', spec: '27B dense, 1M context' },
{ name: 'zen6-coder', spec: 'Agentic code' },
{ name: 'zen6-flash', spec: 'Ternary vision' },
{ name: 'zen5', spec: 'Hosted' },
{ name: 'zen-embedding', spec: 'Retrieval' },
{ name: 'zen-guard', spec: 'Safety' },
];
const CARD = { rounded: 16, borderWidth: 1, borderColor: '$borderColor', bg: '$panel' } as const;
function go(href: string) {
return /^https?:/.test(href) ? <a href={href} target="_blank" rel="noreferrer" /> : <Link href={href} prefetch={false} />;
}
function Section({ title, lead, children }: { title: string; lead: string; children: ReactNode }) {
return (
<YStack render="section" gap={8}>
<Text render="h2" fontSize={30} lineHeight={36} fontWeight="700" letterSpacing={-0.6} color="$color12">
{title}
</Text>
<Text fontSize={14} mb={20} whiteSpace="normal" {...muted}>
{lead}
</Text>
{children}
</YStack>
);
}
function Tile({ name, spec, mono = false }: { name: string; spec: string; mono?: boolean }) {
return (
<YStack {...CARD} rounded={10} px={14} py={12} gap={3}>
<Text fontSize={12} fontWeight="600" fontFamily={mono ? '$mono' : '$body'} color="$color12">
{name}
</Text>
<Text fontSize={11} {...muted}>
{spec}
</Text>
</YStack>
);
}
export function Landing({ install, use }: { install: ReactNode; use: ReactNode }) {
return (
<YStack render="main" minW={0} pb={48}>
{/* The hero, centred over its halftone. */}
<YStack position="relative" items="center" px={24} pt={120} pb={80} $max-md={{ pt: 72, pb: 56 }}>
<HeroField />
<XStack
render={<Link href="/docs/models" prefetch={false} />}
position="relative"
items="center"
gap={8}
mb={32}
px={16}
py={8}
rounded={999}
borderWidth={1}
borderColor="$borderColor"
bg="$panel"
hoverStyle={{ borderColor: '$color8' }}
>
<YStack width={6} height={6} rounded={999} bg="$color12" />
<Text fontSize={14} {...muted}>
Zen generates · Enso routes · Kai decides
</Text>
<ArrowRight size={14} color="$color10" />
</XStack>
<Text
render="h1"
position="relative"
text="center"
fontSize={72}
lineHeight={72}
fontWeight="700"
letterSpacing={-1.8}
color="$color12"
maxW={760}
$max-lg={{ fontSize: 60, lineHeight: 60 }}
$max-md={{ fontSize: 44, lineHeight: 46, letterSpacing: -1 }}
>
Build anything with Hanzo.
</Text>
<Text
render="p"
position="relative"
mt={20}
maxW={576}
text="center"
fontSize={19}
lineHeight={30}
whiteSpace="normal"
{...muted}
$max-md={{ fontSize: 17, lineHeight: 27 }}
>
Every model. Every tool. One key. Start in the browser, ship from your terminal, and when you want it on your own hardware, take the whole thing with you — it is the same software we run in production.
</Text>
{/* The main call: install the CLI. */}
<YStack position="relative" mt={40} width="100%" maxW={512} gap={12} items="center">
<XStack {...CARD} width="100%" p={4}>
<XStack flex={1} gap={12} items="center" px={20} py={16} rounded={12} bg="$background">
<Text fontFamily="$mono" fontSize={14} select="none" {...muted}>
$
</Text>
<Text fontFamily="$mono" fontSize={14} color="$color12">
curl hanzo.sh | sh
</Text>
</XStack>
</XStack>
<Text fontSize={12} text="center" whiteSpace="normal" {...muted}>
Installs the <Text fontFamily="$mono">hanzo</Text> CLI. Then <Text fontFamily="$mono">hanzo auth login</Text> to get a
key.
</Text>
</YStack>
{/* The other way in: hand it to an agent. */}
<YStack position="relative" mt={24} gap={8} items="center">
<Agent />
<Text fontSize={12} text="center" whiteSpace="normal" {...muted}>
Or hand this to your agent — it installs the CLI, the MCP server and the{' '}
<Text render="a" href="https://hanzoskills.com" target="_blank" rel="noreferrer" color="$color12" textDecorationLine="underline">
skills
</Text>
, then points its own model calls at Hanzo.
</Text>
</YStack>
{/* The three doors, in descending order of how much you type. */}
<YStack position="relative" mt={48} width="100%" maxW={896}>
<Grid columns={{ min: 260, max: 3 }} gap={16}>
{doors.map((d) => (
<YStack key={d.title} render={go(d.href)} {...CARD} p={24} gap={6} hoverStyle={{ bg: '$hover', borderColor: '$color8' }}>
<Text fontSize={12} fontWeight="500" {...muted}>
{d.eyebrow}
</Text>
<XStack gap={6} items="center">
<Text fontSize={18} fontWeight="600" color="$color12">
{d.title}
</Text>
<ArrowRight size={16} color="$color10" />
</XStack>
<Text fontSize={14} lineHeight={22} whiteSpace="normal" {...muted}>
{d.body}
</Text>
</YStack>
))}
</Grid>
</YStack>
</YStack>
<YStack width="100%" maxW={1024} self="center" px={32} gap={96} $max-md={{ px: 20, gap: 72 }}>
<Section title="Quick Start" lead="Install the CLI, log in, and reach every capability from your terminal.">
<YStack gap={20}>
{install}
<Text fontSize={14} whiteSpace="normal" {...muted}>
Sign in once, then reach every capability from the terminal — or from any SDK, over HTTP, or through MCP.
</Text>
{use}
</YStack>
</Section>
<Section title="One binary. The whole platform." lead="Every capability has one name and one route. Browse by domain — click any card to go deep.">
<Grid columns={{ min: 220, max: 3 }} gap={12}>
{domains.map(({ name, desc, Icon, href, tag }) => (
<YStack key={name} render={<Link href={href} prefetch={false} />} {...CARD} p={24} gap={8} minH={140} hoverStyle={{ bg: '$hover', borderColor: '$color8' }}>
<XStack justify="space-between" items="center" mb={4}>
<XStack p={8} rounded={12} bg="$hover">
<Icon size={18} color="$color11" />
</XStack>
<Text fontSize={11} fontWeight="500" {...muted}>
{tag}
</Text>
</XStack>
<Text fontSize={14} fontWeight="600" color="$color12">
{name}
</Text>
<Text flex={1} fontSize={12} lineHeight={19} whiteSpace="normal" {...muted}>
{desc}
</Text>
<XStack gap={4} items="center">
<Text fontSize={11} fontWeight="500" {...muted}>
View docs
</Text>
<ArrowRight size={12} color="$color10" />
</XStack>
</YStack>
))}
</Grid>
</Section>
<Section title="Developer Tools" lead="SDKs, APIs, and protocols for every stack.">
<Grid columns={{ min: 230, max: 4 }} gap={16}>
{tools.map(({ name, desc, href, Icon }) => (
<YStack key={name} render={<Link href={href} prefetch={false} />} {...CARD} p={24} gap={4} hoverStyle={{ bg: '$hover', borderColor: '$color8' }}>
<XStack mb={10}>
<Icon size={18} color="$color11" />
</XStack>
<Text fontSize={14} fontWeight="600" color="$color12">
{name}
</Text>
<Text fontSize={12} whiteSpace="normal" {...muted}>
{desc}
</Text>
</YStack>
))}
</Grid>
</Section>
<Section title="Every model, one API" lead="Over 400 models across every major provider — call any of them with one credential, one request shape.">
<Grid columns={{ min: 120, max: 8 }} gap={12}>
{providers.map((p) => (
<Tile key={p.name} name={p.name} spec={p.spec} />
))}
</Grid>
</Section>
<Grid columns={{ min: 470, max: 2 }} gap={16}>
{surfaces.map(({ Icon, title, body, chips, href, cta }) => (
<YStack key={title} {...CARD} p={32} gap={10} items="flex-start" $max-md={{ p: 24 }}>
<XStack gap={12} items="center">
<Icon size={18} color="$color11" />
<Text fontSize={20} fontWeight="700" letterSpacing={-0.4} color="$color12">
{title}
</Text>
</XStack>
<Text fontSize={13} mb={8} whiteSpace="normal" {...muted}>
{body}
</Text>
<XStack flexWrap="wrap" gap={8}>
{chips.map((c) => (
<Text key={c} fontSize={12} px={12} py={6} rounded={999} borderWidth={1} borderColor="$borderColor" bg="$background" {...muted}>
{c}
</Text>
))}
</XStack>
<XStack render={<Link href={href} prefetch={false} />} mt={12} gap={4} items="center">
<Text fontSize={12} fontWeight="500" color="$color12">
{cta}
</Text>
<ArrowRight size={12} color="$color12" />
</XStack>
</YStack>
))}
</Grid>
{/* What the CLI can do. */}
<YStack {...CARD} p={40} gap={24} $max-md={{ p: 24 }}>
<XStack gap={12} items="center">
<XStack p={10} rounded={12} bg="$hover">
<Terminal size={18} color="$color11" />
</XStack>
<YStack gap={2} flex={1} minW={0}>
<Text fontSize={24} fontWeight="700" letterSpacing={-0.5} color="$color12">
The <Text fontFamily="$mono">hanzo</Text> CLI
</Text>
<Text fontSize={12} whiteSpace="normal" {...muted}>
A ~15 MB Rust client for any live cloud — prod, laptop, or self-host
</Text>
</YStack>
</XStack>
<Grid columns={{ min: 200, max: 3 }} gap={12}>
{commands.map((c) => (
<Tile key={c.cmd} name={c.cmd} spec={c.desc} mono />
))}
</Grid>
</YStack>
{/* Zen. */}
<YStack {...CARD} p={40} gap={24} $max-md={{ p: 24 }}>
<XStack gap={12} items="center">
<Sparkle size={18} color="$color11" />
<Text fontSize={24} fontWeight="700" letterSpacing={-0.5} color="$color12">
Zen
</Text>
<Text fontFamily="$mono" fontSize={12} px={8} py={2} rounded={999} bg="$hover" {...muted}>
open weights
</Text>
</XStack>
<Text fontSize={14} lineHeight={22} maxW={672} whiteSpace="normal" {...muted}>
The generative family: reasoning, code, vision, speech, embeddings and safety. Weights on Hugging Face; the same ids hosted on api.hanzo.ai, routed by Enso.
</Text>
<Grid columns={{ min: 130, max: 6 }} gap={12}>
{zen.map((m) => (
<Tile key={m.name} name={m.name} spec={m.spec} mono />
))}
</Grid>
<XStack flexWrap="wrap" gap={12} items="center">
<Action tone="line" render={<Link href="/docs/models/zen" prefetch={false} />} rounded={999}>
Zen models →
</Action>
<Text render="a" href="https://huggingface.co/zenlm" target="_blank" rel="noreferrer noopener" fontSize={12} {...muted} hoverStyle={{ color: '$color12' }}>
HuggingFace →
</Text>
</XStack>
</YStack>
{/* The close. */}
<YStack render="section" items="center" gap={12} py={48}>
<Text render="h2" fontSize={36} lineHeight={42} fontWeight="700" letterSpacing={-0.8} color="$color12" text="center">
Start building
</Text>
<Text fontSize={14} text="center" {...muted}>
Free tier with generous limits. No credit card required.
</Text>
<Text fontFamily="$mono" fontSize={14} mb={20} {...muted}>
curl hanzo.sh | sh
</Text>
<XStack flexWrap="wrap" gap={12} justify="center">
<Action tone="loud" render="a" href="https://hanzo.id/signup?redirect_uri=https://console.hanzo.ai" height={44} px={28} rounded={999}>
Sign up free
</Action>
<Action tone="line" render={<Link href="/docs" prefetch={false} />} height={44} px={28} rounded={999}>
Browse documentation
</Action>
</XStack>
</YStack>
</YStack>
</YStack>
);
}
@@ -1,66 +0,0 @@
import Link from '@hanzo/docs/core/link';
import { type ReactNode, Suspense } from 'react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
export interface Suggestion {
id: string;
href: string;
title: ReactNode;
}
export interface NotFoundProps {
getSuggestions: () => Promise<Suggestion[]>;
}
export function NotFound(props: NotFoundProps) {
return (
<div className="flex flex-col items-center justify-center text-center gap-4 p-8 [grid-area:main]">
<h1 className="text-4xl font-bold font-mono">Page not found</h1>
<p className="text-sm text-fd-muted-foreground">
Nothing is published at this address. Search the docs from the rail, or start here.
</p>
<div className="w-full border border-fd-foreground/50 border-dashed p-4 max-w-[600px]">
<Suspense
fallback={<p className="text-sm text-fd-muted-foreground">Finding alternatives…</p>}
>
<Alternative {...props} />
</Suspense>
</div>
</div>
);
}
async function Alternative({ getSuggestions }: NotFoundProps) {
const suggestions = await getSuggestions();
if (suggestions.length === 0) {
return (
<div>
<p className="text-sm text-fd-muted-foreground mb-2">No alternative found</p>
<Link href="/" className={cn(buttonVariants({ variant: 'secondary' }))}>
Return to Home
</Link>
</div>
);
}
return (
<div>
<h2 className="text-sm text-fd-muted-foreground mb-2">Maybe you meant</h2>
<div className="flex flex-col rounded-lg border bg-fd-card text-fd-card-foreground shadow-md overflow-hidden divide-y divide-fd-border">
{suggestions.map((doc) => (
<Link
key={doc.id}
href={doc.href}
className="inline-flex items-center justify-between gap-4 text-sm px-3 py-2 hover:bg-fd-accent hover:text-fd-accent-foreground"
>
<p className="font-medium text-nowrap">{doc.title}</p>
<code className="text-fd-muted-foreground truncate">{doc.href}</code>
</Link>
))}
</div>
</div>
);
}
-218
View File
@@ -1,218 +0,0 @@
'use client';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SearchItemType,
type SharedProps,
} from '@hanzo/docs-base-ui/components/dialog/search';
import { useDocsSearch } from '@hanzo/docs/core/search/client';
import { useMemo, useState } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/components/ui/popover';
import { ArrowRight, Check, ChevronDown, Sparkle } from 'lucide-react';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { cn } from '@/lib/cn';
import { useTree } from '@/lib/tree';
import type { Item, Node } from '@hanzo/docs/core/page-tree';
import { useRouter } from 'next/navigation';
import { AGENT_SETUP_PROMPT } from '@/lib/agent-setup-prompt';
const items = [
{
name: 'All',
value: undefined,
},
{
name: 'Services',
description: 'Hanzo Cloud services documentation',
value: 'services',
},
{
name: 'SDKs',
description: 'Python, TypeScript, Go, Rust, and C SDKs',
value: 'sdks',
},
{
name: 'API',
description: 'OpenAPI reference for all services',
value: 'openapi',
},
{
name: 'Products',
description: 'Zen LM, Chat, MCP, Dev, ZAP',
value: 'products',
},
];
export default function CustomSearchDialog(props: SharedProps) {
const [open, setOpen] = useState(false);
const [tag, setTag] = useState<string | undefined>();
const { search, setSearch, query } = useDocsSearch({
type: 'flexsearch-static',
from: '/api/search',
tag,
});
const { tree: full } = useTree();
const router = useRouter();
const searchMap = useMemo(() => {
const map = new Map<string, Item>();
function onNode(node: Node) {
if (node.type === 'page' && typeof node.name === 'string') {
map.set(node.name.toLowerCase(), node);
} else if (node.type === 'folder') {
if (node.index) onNode(node.index);
for (const item of node.children) onNode(item);
}
}
for (const item of full.children) onNode(item);
return map;
}, [full]);
// ASKING IS A DIFFERENT INTENTION FROM FINDING, and until now only one of them
// had one. The field says "search or ask", full-text search matches TERMS,
// and a question is mostly terms that are not in any page — measured, "api
// key", "quickstart" and "kms" all return results while "how do I get an api
// key" returns none. So the reader most in need of an answer got the emptiest
// screen, under a label that had promised otherwise.
//
// The answer is not a chat surface bolted into this dialog. It is the one this
// product already gives everywhere else: hand the question to the reader's own
// agent, with the setup that points it at Hanzo's models and skills. Same
// prompt as the Use Agent control, so there is one thing to keep true.
const [asked, setAsked] = useState(false);
const askAction = useMemo<SearchItemType | undefined>(() => {
const q = search.trim();
if (q.length < 3) return;
return {
id: 'ask-agent',
type: 'action',
node: (
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
{asked ? <Check className="size-4" /> : <Sparkle className="size-4" />}
<p>
{asked ? (
'Copied — paste it into your agent'
) : (
<>
Ask an agent<span className="font-medium text-fd-foreground"> “{q}”</span>
</>
)}
</p>
</div>
),
onSelect: () => {
void navigator.clipboard
.writeText(
`${AGENT_SETUP_PROMPT}\n\n---\n\nThen answer this using the Hanzo documentation at ` +
`https://docs.hanzo.ai, and use the Hanzo skills at https://hanzoskills.com for any ` +
`capability it touches:\n\n${q}`,
)
.then(() => setAsked(true));
},
};
}, [asked, search]);
const pageTreeAction = useMemo<SearchItemType | undefined>(() => {
if (search.length === 0) return;
const normalized = search.toLowerCase();
for (const [k, page] of searchMap) {
if (!k.startsWith(normalized)) continue;
return {
id: 'quick-action',
type: 'action',
node: (
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
<ArrowRight className="size-4" />
<p>
Jump to <span className="font-medium text-fd-foreground">{page.name}</span>
</p>
</div>
),
onSelect: () => router.push(page.url),
};
}
}, [router, search, searchMap]);
return (
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList
items={
query.data !== 'empty' || pageTreeAction || askAction
? [
...(pageTreeAction ? [pageTreeAction] : []),
...(Array.isArray(query.data) ? query.data : []),
// LAST, always. A question is the fallback when the terms did
// not land, not a competitor to a page that did.
...(askAction ? [askAction] : []),
]
: null
}
/>
<SearchDialogFooter className="flex flex-row flex-wrap gap-2 items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={buttonVariants({
size: 'sm',
color: 'ghost',
className: '-m-1.5 me-auto',
})}
>
<span className="text-fd-muted-foreground/80 me-2">Filter</span>
{items.find((item) => item.value === tag)?.name}
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
</PopoverTrigger>
<PopoverContent className="flex flex-col p-1 gap-1" align="start">
{items.map((item, i) => {
const isSelected = item.value === tag;
return (
<button
key={i}
onClick={() => {
setTag(item.value);
setOpen(false);
}}
className={cn(
'rounded-lg text-start px-2 py-1.5',
isSelected
? 'text-fd-primary bg-fd-primary/10'
: 'hover:text-fd-accent-foreground hover:bg-fd-accent',
)}
>
<p className="font-medium mb-0.5">{item.name}</p>
<p className="text-xs opacity-70">{item.description}</p>
</button>
);
})}
</PopoverContent>
</Popover>
<a
href="https://hanzo.ai"
rel="noreferrer noopener"
className="text-xs text-nowrap text-fd-muted-foreground"
>
Powered by Hanzo Search
</a>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
-27
View File
@@ -1,27 +0,0 @@
import type { BaseLayoutProps, LinkItemType } from '@hanzo/docs-base-ui/layouts/shared';
import { HanzoMark } from '@hanzogui/shell';
import { nav } from '@/lib/nav';
// The blog's HomeLayout (app/(home)) is the last caller of this, and it goes
// when the blog moves onto the gui shell. The nav itself is lib/nav.
export const linkItems: LinkItemType[] = nav.map((item) => ({
text: item.text,
url: item.url,
active: item.exact ? 'url' : 'nested-url',
}));
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: (
<>
<HanzoMark size={18} />
<span className="font-medium max-md:hidden">Hanzo AI</span>
<span className="max-md:hidden rounded border border-fd-border px-1.5 py-0.5 text-[11px] font-medium text-fd-muted-foreground">
Docs
</span>
</>
),
},
};
}
-116
View File
@@ -1,116 +0,0 @@
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import {
Children,
type ComponentProps,
type ReactElement,
type ReactNode,
Suspense,
use,
useDeferredValue,
} from 'react';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { DynamicCodeBlock } from '@hanzo/docs-base-ui/components/dynamic-codeblock';
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
import { visit } from 'unist-util-visit';
import type { ElementContent, Root, RootContent } from 'hast';
export interface Processor {
process: (content: string) => Promise<ReactNode>;
}
export function rehypeWrapWords() {
return (tree: Root) => {
visit(tree, ['text', 'element'], (node, index, parent) => {
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
if (node.type !== 'text' || !parent || index === undefined) return;
const words = node.value.split(/(?=\s)/);
// Create new span nodes for each word and whitespace
const newNodes: ElementContent[] = words.flatMap((word) => {
if (word.length === 0) return [];
return {
type: 'element',
tagName: 'span',
properties: {
class: 'animate-fd-fade-in',
},
children: [{ type: 'text', value: word }],
};
});
Object.assign(node, {
type: 'element',
tagName: 'span',
properties: {},
children: newNodes,
} satisfies RootContent);
return 'skip';
});
};
}
function createProcessor(): Processor {
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
return {
async process(content) {
const nodes = processor.parse({ value: content });
const hast = await processor.run(nodes);
return toJsxRuntime(hast, {
development: false,
jsx,
jsxs,
Fragment,
components: {
...defaultMdxComponents,
pre: Pre,
img: undefined, // use JSX
},
});
},
};
}
function Pre(props: ComponentProps<'pre'>) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<'code'>;
const content = codeProps.children;
if (typeof content !== 'string') return null;
let lang =
codeProps.className
?.split(' ')
.find((v) => v.startsWith('language-'))
?.slice('language-'.length) ?? 'text';
if (lang === 'mdx') lang = 'md';
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
}
const processor = createProcessor();
export function Markdown({ text }: { text: string }) {
const deferredText = useDeferredValue(text);
return (
<Suspense fallback={<p className="invisible">{text}</p>}>
<Renderer text={deferredText} />
</Suspense>
);
}
const cache = new Map<string, Promise<ReactNode>>();
function Renderer({ text }: { text: string }) {
const result = cache.get(text) ?? processor.process(text);
cache.set(text, result);
return use(result);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import * as Prose from '@/components/mdx/prose';
import { Code, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger } from '@/components/mdx/code';
import { Tab, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/mdx/tabs';
import { Accordion, Accordions, Callout, Card, Cards, File, Files, Folder, Step, Steps } from '@/components/mdx/blocks';
import { ModelsCatalog, ConnectorsCatalog, InstallCatalog } from '@/components/preview/lazy';
import { ModelsCatalog, ConnectorsCatalog, InstallCatalog } from '@/components/catalogs';
import { DocsHero } from '@/components/docs-hero';
import { ProductSection, ProductSections } from '@/components/product-section';
import { ProviderStrip } from '@/components/provider-strip';
+23 -4
View File
@@ -4,6 +4,15 @@ import Link from 'next/link';
import { Children, useState, type ReactNode } from 'react';
import { Text, XStack, YStack } from '@hanzo/gui';
import {
Boxes,
Code,
Key,
LayoutGrid,
Package,
Rocket,
Sparkle,
Sparkles,
Terminal,
ChevronRight,
CircleCheck,
CircleX,
@@ -15,6 +24,7 @@ import {
TriangleAlert,
} from '@hanzogui/lucide-icons-2';
import { muted } from '@/lib/ink';
import { Grid } from '@hanzo/ui/grid';
import { Lead, Small, flow } from '@/components/mdx/prose';
/**
@@ -57,12 +67,18 @@ export function Callout({ type = 'info', title, icon, children }: { type?: Kind;
export function Cards({ children }: { children: ReactNode }) {
return (
<YStack gap={12} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(260px, 100%), 1fr))' }}>
<Grid columns={{ min: 260 }} gap={12}>
{children}
</YStack>
</Grid>
);
}
/**
* A card's icon, named in the content (`<Card icon="Rocket">`). Content
* cannot hand a drawn icon across to a client part, so it names one of these.
*/
const GLYPH = { Boxes, Code, Key, LayoutGrid, Package, Rocket, Sparkle, Sparkles, Terminal } as const;
export function Card({
title,
description,
@@ -73,7 +89,7 @@ export function Card({
title: ReactNode;
description?: ReactNode;
href?: string;
icon?: ReactNode;
icon?: ReactNode | keyof typeof GLYPH;
external?: boolean;
children?: ReactNode;
}) {
@@ -92,7 +108,10 @@ export function Card({
>
{icon ? (
<XStack width={30} height={30} mb={4} rounded="$3" borderWidth={1} borderColor="$borderColor" items="center" justify="center">
{icon}
{typeof icon === 'string' ? (() => {
const Glyph = GLYPH[icon as keyof typeof GLYPH];
return Glyph ? <Glyph size={15} color="$color11" /> : null;
})() : icon}
</XStack>
) : null}
<Text fontSize={15} lineHeight={22} fontWeight="600" color="$color12" whiteSpace="normal">
-75
View File
@@ -1,75 +0,0 @@
'use client';
import { useState } from 'react';
import { ChevronDown, LayoutGrid } from 'lucide-react';
import { MeetHanzoMenu } from '@hanzogui/shell';
/**
* The one shared "Meet Hanzo" affordance — a nav trigger that opens the
* universal <MeetHanzoMenu> (the ecosystem mega-menu driven by HANZO_FLAGSHIP +
* MEET_HANZO_GROUPS). Replaces the bespoke try-hanzo-dropdown so docs discovers
* the whole ecosystem the same way every other Hanzo property does.
*
* `anchor` (56px) matches the Fumadocs nav row height so the panel drops
* flush beneath the header.
*/
// Keep the ecosystem menu's product items INSIDE the docs when we have a docs
// page for them — instead of bouncing out to the hanzo.ai marketing home. Maps
// the registry link id → its docs path; anything not listed keeps the canonical
// ecosystem link. Passed to MeetHanzoMenu's `resolveHref` (opt-in; a no-op on
// shell versions before that prop exists, so it's safe to ship ahead).
const DOCS_HREFS: Record<string, string> = {
models: '/docs/openapi/ai',
enso: '/docs/openapi/ai',
agents: '/docs/openapi/agent',
mcp: '/docs/mcp',
gateway: '/docs/openapi/gateway',
api: '/docs/openapi',
keys: '/docs/api-keys',
};
/**
* `compact` collapses the trigger to its icon below `lg`.
*
* The docs header is a fixed 56px row that also carries search, Sign In and Get API
* Key. Below `lg` a text label does not fit beside them: Get API Key wraps and spills
* out of the bar at 834, and clips off the right edge at 768 (measured). So the label
* earns its space by breakpoint. The accessible name stays "Hanzo" at every width —
* only the glyph changes, so nothing is lost to a screen reader or a keyboard user.
*/
export function MeetHanzo({ compact = false }: { compact?: boolean }) {
const [open, setOpen] = useState(false);
const panelId = 'meet-hanzo-menu';
return (
<>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
aria-label="Hanzo"
className={`inline-flex shrink-0 items-center gap-1 rounded-full text-sm font-medium text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground ${
compact ? 'size-9 justify-center lg:size-auto lg:px-3 lg:py-1.5' : 'px-3 py-1.5'
}`}
>
{compact && <LayoutGrid className="size-4 lg:hidden" aria-hidden />}
<span className={compact ? 'hidden lg:inline' : undefined}>Hanzo</span>
<ChevronDown
aria-hidden
className={`size-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''} ${
compact ? 'hidden lg:inline-block' : ''
}`}
/>
</button>
<MeetHanzoMenu
id={panelId}
open={open}
onClose={() => setOpen(false)}
anchor={56}
resolveHref={(href, id) => DOCS_HREFS[id] ?? href}
/>
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import Link from 'next/link';
import { Text, YStack } from '@hanzo/gui';
import { muted } from '@/lib/ink';
/**
* The 404 body. The export writes it once, as 404.html, and the edge serves it
* for any address the site does not hold, so the likely destinations are a
* fixed list rather than a guess about the address that missed.
*/
export function Missing({ suggestions }: { suggestions: { href: string; title: string }[] }) {
return (
<YStack items="center" gap={14} px={20} py={72}>
<Text render="h1" fontSize={32} lineHeight={40} fontWeight="600" color="$color12" letterSpacing={-0.6}>
Page not found
</Text>
<Text fontSize="$3" text="center" whiteSpace="normal" {...muted}>
Nothing is published at this address. Search the docs from the rail, or start here.
</Text>
<YStack
width="100%"
maxW={520}
mt={10}
rounded="$5"
borderWidth={1}
borderColor="$borderColor"
bg="$panel"
overflow="hidden"
>
{suggestions.map((s, i) => (
<YStack
key={s.href}
render={<Link href={s.href} prefetch={false} />}
flexDirection="row"
justify="space-between"
items="center"
gap={16}
px={14}
py={11}
borderTopWidth={i ? 1 : 0}
borderColor="$borderColor"
hoverStyle={{ bg: '$hover' }}
>
<Text fontSize="$3" fontWeight="500" color="$color12">
{s.title}
</Text>
<Text fontFamily="$mono" fontSize={12} numberOfLines={1} {...muted}>
{s.href}
</Text>
</YStack>
))}
</YStack>
</YStack>
);
}
+133 -111
View File
@@ -5,7 +5,12 @@
// the current models/prices without a rebuild. Grouped by family, searchable,
// theme-aware via Fumadocs fd-* tokens.
import { useEffect, useMemo, useState } from 'react';
import { Search, Copy, Check, Cpu, Sparkle, Zap, Box } from 'lucide-react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { Field } from '@/components/field';
import { Copy, Check, Cpu, Sparkle, Zap, Box } from '@hanzogui/lucide-icons-2';
import { Figure, Pre } from '@/components/mdx/code';
import { Table, Tbody, Td, Th, Thead, Tr } from '@/components/mdx/prose';
import { muted } from '@/lib/ink';
import { PROVIDER_ICONS, providerKey } from '@/components/provider-icons';
const ENDPOINT = 'https://api.hanzo.ai/v1/models';
@@ -65,55 +70,56 @@ function initials(name: string): string {
return name.replace(/[^\p{L}\p{N}]/gu, '').slice(0, 2).toUpperCase() || '?';
}
function ProviderChip({ name }: { name: string }) {
// Real brand logo (mono, currentColor) when we have one; monogram otherwise.
const svg = PROVIDER_ICONS[providerKey(name)];
if (svg) {
if (svg)
return (
<span
<XStack
aria-hidden
className="inline-flex size-5 shrink-0 items-center justify-center text-fd-foreground [&>svg]:size-[18px]"
dangerouslySetInnerHTML={{ __html: svg }}
width={18}
height={18}
shrink={0}
color="$color12"
dangerouslySetInnerHTML={{ __html: svg.replace('<svg', '<svg width="100%" height="100%"') }}
/>
);
}
const hue = hashHue(name.toLowerCase());
return (
<span
aria-hidden
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-[9px] font-semibold leading-none"
style={{
background: `hsl(${hue} 55% 92%)`,
color: `hsl(${hue} 60% 32%)`,
boxShadow: `inset 0 0 0 1px hsl(${hue} 45% 82%)`,
}}
>
{initials(name)}
</span>
<XStack aria-hidden width={20} height={20} shrink={0} rounded={4} items="center" justify="center" style={{ background: `hsl(${hue} 55% 92%)` }}>
<Text fontSize={9} fontWeight="600" style={{ color: `hsl(${hue} 60% 32%)` }}>
{initials(name)}
</Text>
</XStack>
);
}
function CopyId({ id }: { id: string }) {
const [done, setDone] = useState(false);
return (
<button
<XStack
render="button"
type="button"
onClick={() => {
title="Copy model id"
onPress={() => {
navigator.clipboard?.writeText(id).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1200);
});
}}
className="group inline-flex items-center gap-1.5 font-mono text-xs text-fd-muted-foreground hover:text-fd-foreground transition-colors"
title="Copy model id"
gap={6}
items="center"
bg="transparent"
borderWidth={0}
p={0}
cursor="pointer"
>
<span>{id}</span>
{done ? <Check className="size-3 text-green-500" /> : <Copy className="size-3 opacity-0 group-hover:opacity-60" />}
</button>
<Text fontFamily="$mono" fontSize={12} {...muted}>
{id}
</Text>
{done ? <Check size={12} color="$green10" /> : <Copy size={12} color="$color9" />}
</XStack>
);
}
// What the catalogue actually says a model can do. It publishes booleans, not a
// `features` list, so the chips are derived from those rather than left empty.
function capabilities(m: Model): string[] {
if (m.features?.length) return m.features;
const out: string[] = [];
@@ -125,29 +131,35 @@ function capabilities(m: Model): string[] {
function ModelRow({ m }: { m: Model }) {
const p = m.pricing;
return (
<tr className="border-t border-fd-border/60 hover:bg-fd-muted/40 transition-colors">
<td className="py-2.5 pr-4 align-top">
<div className="font-medium text-fd-foreground">{m.fullName || m.name || m.id}</div>
<CopyId id={m.id} />
{m.tier ? (
<span className="ml-2 rounded bg-fd-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-fd-primary align-middle">
{m.tier}
</span>
) : null}
</td>
<td className="py-2.5 px-3 text-right align-top tabular-nums text-fd-muted-foreground">{ctx(m.context_window)}</td>
<td className="py-2.5 px-3 text-right align-top tabular-nums">{price(p?.input)}</td>
<td className="py-2.5 px-3 text-right align-top tabular-nums">{price(p?.output)}</td>
<td className="py-2.5 pl-3 align-top">
<div className="flex flex-wrap gap-1 justify-end">
<Tr>
<Td>
<YStack gap={2} items="flex-start">
<Text fontSize={14} fontWeight="500" color="$color12">
{m.fullName || m.name || m.id}
</Text>
<XStack gap={8} items="center" flexWrap="wrap">
<CopyId id={m.id} />
{m.tier ? (
<Text fontSize={11} fontWeight="500" px={6} rounded={4} bg="$hover" color="$color12">
{m.tier}
</Text>
) : null}
</XStack>
</YStack>
</Td>
<Td align="right">{ctx(m.context_window)}</Td>
<Td align="right">{price(p?.input)}</Td>
<Td align="right">{price(p?.output)}</Td>
<Td align="right">
<XStack gap={4} flexWrap="wrap" justify="flex-end">
{capabilities(m).slice(0, 4).map((f) => (
<span key={f} className="rounded border border-fd-border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">
<Text key={f} fontSize={10} px={6} py={1} rounded={4} borderWidth={1} borderColor="$borderColor" {...muted}>
{f}
</span>
</Text>
))}
</div>
</td>
</tr>
</XStack>
</Td>
</Tr>
);
}
@@ -218,87 +230,97 @@ export function ModelsCatalog() {
if (err)
return (
<div className="my-4 rounded-lg border border-fd-border bg-fd-card p-4 text-sm text-fd-muted-foreground">
<p className="mt-0">
Couldn’t load the live catalog ({err}). <code>GET /v1/models</code> requires a bearer token, and this page
has none to send — so read it with your own key instead:
</p>
<pre className="overflow-x-auto rounded bg-fd-muted/60 p-3 text-xs">
{`curl -s ${ENDPOINT} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`}
</pre>
<p className="mb-0">
<a className="text-fd-primary underline" href="/docs/api-keys">
Mint a key →
</a>
</p>
</div>
<YStack gap={10} p={16} rounded="$5" borderWidth={1} borderColor="$borderColor" bg="$panel">
<Text fontSize={14} lineHeight={22} whiteSpace="normal" {...muted}>
Couldn’t load the live catalog ({err}). GET /v1/models requires a bearer token, and this page has none to
send — so read it with your own key instead:
</Text>
<Figure>
<Pre>
<code>{`curl -s ${ENDPOINT} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`}</code>
</Pre>
</Figure>
<Text render="a" href="/docs/api-keys" fontSize={14} color="$color12" textDecorationLine="underline">
Mint a key →
</Text>
</YStack>
);
if (!cat)
return (
<div className="my-4 space-y-2" aria-busy>
<YStack gap={8} aria-busy>
{[0, 1, 2].map((i) => (
<div key={i} className="h-12 animate-pulse rounded-md bg-fd-muted/60" />
<YStack key={i} height={48} rounded="$3" bg="$hover" />
))}
</div>
</YStack>
);
const s = cat.summary ?? {};
return (
<div className="not-prose my-6">
{/* Summary + search */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-fd-muted-foreground">
<span className="font-medium text-fd-foreground">{s.totalModels ?? cat.data.length} models</span>
{s.zenModels ? <span>{s.zenModels} Zen</span> : null}
{s.ensoModels ? <span>{s.ensoModels} Enso</span> : null}
{cat.updated ? <span>updated {new Date(cat.updated).toLocaleDateString()}</span> : null}
</div>
<label className="relative flex items-center">
<Search className="pointer-events-none absolute left-2.5 size-4 text-fd-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filter models…"
className="w-full rounded-md border border-fd-border bg-fd-background py-1.5 pl-8 pr-3 text-sm outline-none focus:border-fd-primary sm:w-64"
/>
</label>
</div>
<YStack gap={24}>
<XStack gap={12} justify="space-between" items="center" flexWrap="wrap">
<XStack gap={16} flexWrap="wrap" items="center">
<Text fontSize={14} fontWeight="500" color="$color12">
{s.totalModels ?? cat.data.length} models
</Text>
{s.zenModels ? <Text fontSize={14} {...muted}>{s.zenModels} Zen</Text> : null}
{s.ensoModels ? <Text fontSize={14} {...muted}>{s.ensoModels} Enso</Text> : null}
{cat.updated ? (
<Text fontSize={14} {...muted}>
updated {DAY.format(new Date(cat.updated))}
</Text>
) : null}
</XStack>
<Field value={q} onChange={setQ} placeholder="Filter models…" width={260} />
</XStack>
{groups.map((f) => {
const isProvider = f.id.startsWith('provider-');
const Icon = FAMILY_ICON[f.icon ?? ''] ?? Box;
return (
<section key={f.id} className="mb-8">
<div className="mb-1 flex items-center gap-2">
{isProvider ? <ProviderChip name={f.name} /> : <Icon className="size-4 text-fd-primary" />}
<h3 className="m-0 text-base font-semibold text-fd-foreground">{f.name}</h3>
<span className="text-xs text-fd-muted-foreground">({f.resolved.length})</span>
</div>
{f.description ? <p className="mb-2 mt-0 text-sm text-fd-muted-foreground">{f.description}</p> : null}
<div className="overflow-x-auto rounded-lg border border-fd-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="py-2 pr-4 text-left font-medium">Model</th>
<th className="py-2 px-3 text-right font-medium">Context</th>
<th className="py-2 px-3 text-right font-medium">Input /1M</th>
<th className="py-2 px-3 text-right font-medium">Output /1M</th>
<th className="py-2 pl-3 text-right font-medium">Capabilities</th>
</tr>
</thead>
<tbody>
{f.resolved.map((m) => (
<ModelRow key={m.id} m={m} />
))}
</tbody>
</table>
</div>
</section>
<YStack key={f.id} render="section" gap={8}>
<XStack gap={8} items="center">
{isProvider ? <ProviderChip name={f.name} /> : <Icon size={16} color="$color12" />}
<Text render="h3" fontSize={16} fontWeight="600" color="$color12">
{f.name}
</Text>
<Text fontSize={12} {...muted}>
({f.resolved.length})
</Text>
</XStack>
{f.description ? (
<Text fontSize={14} whiteSpace="normal" {...muted}>
{f.description}
</Text>
) : null}
<Table>
<Thead>
<Tr>
<Th>Model</Th>
<Th align="right">Context</Th>
<Th align="right">Input /1M</Th>
<Th align="right">Output /1M</Th>
<Th align="right">Capabilities</Th>
</Tr>
</Thead>
<Tbody>
{f.resolved.map((m) => (
<ModelRow key={m.id} m={m} />
))}
</Tbody>
</Table>
</YStack>
);
})}
{!groups.length ? <p className="text-sm text-fd-muted-foreground">No models match “{q}”.</p> : null}
</div>
{!groups.length ? (
<Text fontSize={14} {...muted}>
No models match “{q}”.
</Text>
) : null}
</YStack>
);
}
// Fixed locale and zone, so every reader sees the same date for the same data.
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
export default ModelsCatalog;
+57
View File
@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { Text, YStack } from '@hanzo/gui';
import { Grid } from '@hanzo/ui/grid';
import { muted } from '@/lib/ink';
export interface Post {
url: string;
title: string;
description?: string;
/** Formatted on the server, so both renders print the same string. */
date: string;
}
/** The blog's index: a heading and the posts, newest first. */
export function Posts({ posts }: { posts: Post[] }) {
return (
<YStack render="main" width="100%" maxW={1120} self="center" px={40} py={48} gap={28} $max-md={{ px: 20, py: 28 }}>
<YStack gap={8} pb={24} borderBottomWidth={1} borderColor="$borderColor">
<Text render="h1" fontSize={32} lineHeight={40} fontWeight="600" letterSpacing={-0.6} color="$color12">
Blog
</Text>
<Text fontSize={17} {...muted}>
Announcements and updates from Hanzo.
</Text>
</YStack>
<Grid columns={{ min: 260, max: 3 }} gap={12}>
{posts.map((post) => (
<YStack
key={post.url}
render={<Link href={post.url} prefetch={false} />}
gap={6}
p={18}
rounded="$5"
borderWidth={1}
borderColor="$borderColor"
bg="$panel"
hoverStyle={{ bg: '$hover' }}
>
<Text fontSize={15} lineHeight={22} fontWeight="600" color="$color12" whiteSpace="normal">
{post.title}
</Text>
{post.description ? (
<Text fontSize={14} lineHeight={21} whiteSpace="normal" {...muted}>
{post.description}
</Text>
) : null}
<Text mt="auto" pt={12} fontSize={12} {...muted}>
{post.date}
</Text>
</YStack>
))}
</Grid>
</YStack>
);
}
@@ -1,13 +0,0 @@
import { ServerCodeBlock } from '@hanzo/docs-base-ui/components/codeblock.rsc';
export function Customization() {
return (
<div className="flex flex-col gap-2 p-3 rounded-xl border bg-fd-card text-fd-card-foreground not-prose">
<p className="font-medium text-sm">Install via Hanzo Docs CLI</p>
<p className="text-fd-muted-foreground text-sm">
For advanced customization that supported options cannot suffice.
</p>
<ServerCodeBlock code="npx @hanzo/docs-cli@latest customise" lang="bash" />
</div>
);
}
@@ -1,51 +0,0 @@
'use client';
import { DynamicCodeBlock } from '@hanzo/docs-base-ui/components/dynamic-codeblock';
import { shikiConfig } from '@/lib/shiki';
import { useState } from 'react';
import { bundledLanguages } from 'shiki';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@hanzo/docs-base-ui/components/ui/select';
export default function Example() {
const [lang, setLang] = useState('js');
const [code, setCode] = useState('console.log("This is pre-rendered")');
return (
<div className="prose flex flex-col gap-4 rounded-lg bg-fd-background p-4">
<div className="not-prose flex flex-col gap-2 rounded-lg bg-fd-secondary text-fd-secondary-foreground p-2">
<Select
value={lang}
onValueChange={(next) => {
if (next) setLang(next);
}}
>
<SelectTrigger className="w-fit min-w-[140px] h-8 text-xs bg-transparent border-fd-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-60">
{Object.keys(bundledLanguages).map((l) => (
<SelectItem value={l} key={l} className="text-xs">
{l}
</SelectItem>
))}
</SelectContent>
</Select>
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
className="bg-transparent px-4 py-2 text-sm focus-visible:outline-none"
/>
</div>
<DynamicCodeBlock
lang={lang}
code={code}
options={shikiConfig}
/>
</div>
);
}
-254
View File
@@ -1,254 +0,0 @@
import { Home } from 'lucide-react';
import { Heading } from '@hanzo/docs-base-ui/components/heading';
import { Card } from '@hanzo/docs-base-ui/components/card';
import { Callout } from '@hanzo/docs-base-ui/components/callout';
import { Accordion, Accordions } from '@hanzo/docs-base-ui/components/accordion';
import { Tab, Tabs } from '@hanzo/docs-base-ui/components/tabs';
import { Step, Steps } from '@hanzo/docs-base-ui/components/steps';
import { TypeTable } from '@hanzo/docs-base-ui/components/type-table';
import { type ReactNode } from 'react';
import { Wrapper } from './wrapper';
import { GithubInfo } from '@hanzo/docs-base-ui/components/github-info';
import { owner, repo } from '@/lib/github';
import {
Banner,
DynamicCodeBlock,
File,
Files,
Folder,
ImageZoom,
InlineTOC,
} from '@/components/preview/lazy';
import BannerImage from '@/public/banner.png';
import { GraphView } from '@/components/preview/lazy';
import { buildGraph } from '@/lib/build-graph';
export function heading(): ReactNode {
return (
<Wrapper>
<div className="rounded-lg bg-fd-background p-4 prose-no-margin">
<Heading id="preview" as="h3">
Hello World
</Heading>
<Heading id="preview" as="h3">
Hello <code>World</code> Everyone!
</Heading>
</div>
</Wrapper>
);
}
export function card(): ReactNode {
return (
<Wrapper>
<div className="rounded-lg bg-fd-background">
<Card
href="#"
icon={<Home />}
title="Hello World"
description="Learn More about Caching and Revalidation"
/>
</div>
</Wrapper>
);
}
export function tabs(): ReactNode {
return (
<Wrapper>
<div className="prose-no-margin">
<Tabs groupId="language" persist items={['Javascript', 'Rust', 'Typescript']}>
<Tab value="Javascript">Hello World in Javascript</Tab>
<Tab value="Rust">Hello World in Rust</Tab>
<Tab value="Typescript">Also works if items are not the same</Tab>
</Tabs>
<Tabs groupId="language" persist items={['Javascript', 'Rust']}>
<Tab value="Javascript">
Value is shared! Try refresh and see if the value is persisted
</Tab>
<Tab value="Rust">Value is shared! Try refresh and see if the value is persisted</Tab>
</Tabs>
</div>
</Wrapper>
);
}
export function typeTable(): ReactNode {
return (
<Wrapper>
<div className="rounded-xl bg-fd-background">
<TypeTable
type={{
percentage: {
description: 'The percentage of scroll position to display the roll button',
type: 'number',
default: '0.2',
},
}}
/>
</div>
</Wrapper>
);
}
export function zoomImage(): ReactNode {
return (
<Wrapper>
<ImageZoom
alt="banner"
src={BannerImage}
className="!my-0 rounded-xl bg-fd-background"
priority
/>
</Wrapper>
);
}
export function accordion(): ReactNode {
return (
<Wrapper>
<Accordions>
<Accordion id="what-is-hanzo-docs" title="What is Hanzo Docs?">
A framework for building documentations
</Accordion>
<Accordion id="ux" title="What do we love?">
We love websites with a good user experience
</Accordion>
</Accordions>
</Wrapper>
);
}
export function callout(): ReactNode {
return (
<Wrapper>
<Callout title="Title">Hello World</Callout>
</Wrapper>
);
}
export function files(): ReactNode {
return (
<Wrapper>
<Files>
<Folder name="app" defaultOpen>
<Folder name="[id]" defaultOpen>
<File name="page.tsx" />
</Folder>
<File name="layout.tsx" />
<File name="page.tsx" />
<File name="global.css" />
</Folder>
<Folder name="components">
<File name="button.tsx" />
<File name="tabs.tsx" />
<File name="dialog.tsx" />
<Folder name="empty" />
</Folder>
<File name="package.json" />
</Files>
</Wrapper>
);
}
export function inlineTOC(): ReactNode {
return (
<Wrapper>
<InlineTOC
items={[
{ title: 'Welcome', url: '#welcome', depth: 2 },
{ title: 'Getting Started', url: '#getting-started', depth: 3 },
{ title: 'Usage', url: '#usage', depth: 3 },
{ title: 'Styling', url: '#styling', depth: 3 },
{ title: 'Reference', url: '#reference', depth: 2 },
{ title: 'Components', url: '#components', depth: 3 },
{ title: 'APIs', url: '#api', depth: 3 },
{ title: 'Credits', url: '#credits', depth: 2 },
]}
/>
</Wrapper>
);
}
export function steps(): ReactNode {
return (
<Wrapper>
<div className="rounded-xl bg-fd-background p-3">
<Steps>
<Step>
<h4>Buy Coffee</h4>
<p>Some text here</p>
</Step>
<Step>
<h4>Go to Office Some text here</h4>
<p>Some text here</p>
</Step>
<Step>
<h4>Have a meeting Some text here</h4>
<p>Some text here</p>
</Step>
</Steps>
</div>
</Wrapper>
);
}
export function dynamicCodeBlock() {
return (
<Wrapper>
<DynamicCodeBlock />
</Wrapper>
);
}
export function banner(): ReactNode {
return (
<Wrapper>
<div className="flex flex-col gap-4">
<Banner className="z-0" changeLayout={false}>
Be careful, Hanzo Docs v99 has released
</Banner>
<Banner id="test-rainbow" className="z-0" variant="rainbow" changeLayout={false}>
Using the <code>rainbow</code> variant
</Banner>
<Banner
id="test"
className="z-0"
variant="rainbow"
rainbowColors={[
'rgba(255,100,0, 0.5)',
'rgba(255,100,0, 0.5)',
'transparent',
'rgba(255,100,0, 0.5)',
'transparent',
'rgba(255,100,0, 0.5)',
'transparent',
]}
changeLayout={false}
>
customize the <code>rainbow</code> variant
</Banner>
</div>
</Wrapper>
);
}
export function githubInfo() {
return (
<Wrapper>
<GithubInfo
owner={owner}
repo={repo}
token={process.env.GITHUB_TOKEN}
className="not-prose bg-fd-card"
/>
</Wrapper>
);
}
export async function graphView() {
return <GraphView graph={await buildGraph()} />;
}
@@ -1,12 +0,0 @@
import { ServerCodeBlock } from '@hanzo/docs-base-ui/components/codeblock.rsc';
export function Installation({ name }: { name: string }) {
return (
<div className="p-3 border rounded-xl bg-fd-card text-fd-card-foreground my-6 text-sm not-prose">
<p className="font-medium">Install to your codebase</p>
<p className="mt-1 mb-4 text-fd-muted-foreground">Easier customization &amp; control.</p>
<ServerCodeBlock code={`npx @hanzo/docs-cli@latest add ${name}`} lang="bash" />
</div>
);
}
-41
View File
@@ -1,41 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const DynamicCodeBlock = dynamic(() => import('./dynamic-codeblock'));
export const Banner = dynamic(() =>
import('@hanzo/docs-base-ui/components/banner').then((res) => res.Banner),
);
export const InlineTOC = dynamic(() =>
import('@hanzo/docs-base-ui/components/inline-toc').then((res) => res.InlineTOC),
);
export const File = dynamic(() => import('@hanzo/docs-base-ui/components/files').then((res) => res.File));
export const Files = dynamic(() => import('@hanzo/docs-base-ui/components/files').then((res) => res.Files));
export const Folder = dynamic(() =>
import('@hanzo/docs-base-ui/components/files').then((res) => res.Folder),
);
export const ImageZoom = dynamic(() =>
import('@hanzo/docs-base-ui/components/image-zoom').then((res) => res.ImageZoom),
);
export const GraphView = dynamic(() =>
import('@/components/graph-view').then((res) => res.GraphView),
);
// Local 'use client' component: a static import into the server-rendered
// getMDXComponents (mdx.tsx) resolves to $undefined in this static-export MDX
// pipeline (no client boundary). Wrapping in dynamic() forces a proper lazy
// client island that hydrates — same reason GraphView above does it.
export const ModelsCatalog = dynamic(() =>
import('@/components/models-catalog').then((res) => res.ModelsCatalog),
);
// Unified connectors + MCP registry catalog (client-fetches the MCP registry).
export const ConnectorsCatalog = dynamic(() =>
import('@/components/connectors-catalog').then((res) => res.ConnectorsCatalog),
);
export const InstallCatalog = dynamic(() =>
import('@/components/install-catalog').then((res) => res.InstallCatalog),
);
-16
View File
@@ -1,16 +0,0 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
export function Wrapper(props: HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'rounded-lg bg-radial-[at_bottom] from-blue-500/20 p-4 border bg-origin-border border-fd-primary/10 prose-no-margin dark:bg-black/20',
props.className,
)}
>
{props.children}
</div>
);
}
+83 -113
View File
@@ -1,27 +1,11 @@
import Link from 'next/link';
'use client';
// The landing page's unit of navigation: one DOMAIN, not one product.
//
// A card grid treats ten domains as ten equal tiles, which is exactly the shape
// that tells a reader nothing — every tile is the same size, so the page has no
// opinion about where to start, and the reader has to read all ten to find the
// one they came for. This is the other arrangement: a handful of sections, each
// one wide enough to say what the domain IS, show the single line of code that
// proves it, and then list its products inline for anyone who already knows the
// name they want.
//
// So there are three depths on one page, and a reader stops at whichever matches
// what they already know:
//
// the sentence — "what is this domain for", for someone who does not know
// the snippet — the shortest true call, for someone who wants to try it
// the link row — every product in the domain, for someone who knows the name
//
// Monochrome by construction. Hanzo's tokens carry no hue (--color-brand is
// hsl(0,0%,96%) on hsl(0,0%,4%)), so the only emphasis available is WEIGHT,
// BORDER and SPACE. That is a constraint worth keeping honest rather than
// working around: nothing here introduces a colour to mean "important", because
// a page that needs colour to establish hierarchy has not established it.
import Link from 'next/link';
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { Figure, Pre } from '@/components/mdx/code';
import { Small, flow } from '@/components/mdx/prose';
import { muted } from '@/lib/ink';
/** One product inside a domain: the name a reader would search for, and its page. */
export interface ProductLink {
@@ -29,25 +13,12 @@ export interface ProductLink {
href: string;
}
export interface ProductSectionProps {
/** The domain's name — the thing a reader is scanning for. */
title: string;
/** Where the domain's own overview lives. The title links here. */
href: string;
/** One sentence. What this domain is for, in the reader's terms, not ours. */
children: React.ReactNode;
/**
* The shortest true call into this domain — one line, actually runnable.
* Optional: a domain with no honest one-liner should show nothing rather
* than a decorative fragment that does not run.
*/
snippet?: string;
/** The next step, phrased as the thing the reader would do. */
action?: { label: string; href: string };
/** Every product in the domain, inline. */
links?: ProductLink[];
}
/**
* One domain on the docs home: what it is on the left (its name, one sentence,
* the next step), how to touch it on the right (the shortest true call, every
* product in it). Stacked on a phone. Hairlines between sections are the
* layout, so no section guesses its own margin.
*/
export function ProductSection({
title,
href,
@@ -55,81 +26,80 @@ export function ProductSection({
snippet,
action,
links,
}: ProductSectionProps) {
first = false,
}: {
/** Set by ProductSections on the first one, which starts flush with no rule. */
first?: boolean;
title: string;
href: string;
children: ReactNode;
/** The shortest true call into this domain — one runnable line, or nothing. */
snippet?: string;
action?: { label: string; href: string };
links?: ProductLink[];
}) {
return (
<section className="not-prose border-t border-fd-border py-10 first:border-t-0 first:pt-0">
<div className="grid gap-x-12 gap-y-6 md:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
{/* Left rail: what it is. Narrow on purpose — a domain that needs a
paragraph to introduce itself is really two domains. */}
<div>
<h3 className="text-lg font-semibold tracking-tight text-fd-foreground">
<Link href={href} className="hover:underline underline-offset-4">
{title}
</Link>
</h3>
{/* A div, not a p: MDX hands multi-line children over already
wrapped in a <p>, and a <p> inside a <p> is invalid HTML the
browser splits apart, so React failed to hydrate /docs. */}
<div className="mt-2 text-sm leading-relaxed text-fd-muted-foreground">
{children}
</div>
{action ? (
<Link
href={action.href}
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-fd-foreground hover:underline underline-offset-4"
>
{action.label}
{/* Marks the link as forward motion without spending a colour on it. */}
<span aria-hidden="true">→</span>
</Link>
) : null}
</div>
<XStack
render="section"
gap={40}
pt={first ? 0 : 32}
pb={32}
borderTopWidth={first ? 0 : 1}
borderColor="$borderColor"
$max-md={{ flexDirection: 'column', gap: 18 }}
>
<YStack width={300} shrink={0} gap={8} $max-md={{ width: '100%' }}>
<Text render={<Link href={href} prefetch={false} />} fontSize={18} lineHeight={26} fontWeight="600" color="$color12" hoverStyle={{ textDecorationLine: 'underline' }}>
{title}
</Text>
{/* A div: MDX hands multi-line children over wrapped in a <p>. */}
<YStack gap={8}>
<Small value>{flow(children, 'small')}</Small>
</YStack>
{action ? (
<Text render={<Link href={action.href} prefetch={false} />} fontSize="$3" fontWeight="500" color="$color12" hoverStyle={{ textDecorationLine: 'underline' }}>
{action.label} →
</Text>
) : null}
</YStack>
{/* Right rail: how to touch it. */}
<div className="min-w-0">
{snippet ? (
<pre className="overflow-x-auto rounded-lg border border-fd-border bg-fd-card px-4 py-3 text-[13px] leading-relaxed text-fd-foreground">
<YStack flex={1} minW={0} gap={16}>
{snippet ? (
<Figure>
<Pre>
<code>{snippet}</code>
</pre>
) : null}
{links?.length ? (
<ul className="mt-4 flex flex-wrap items-center gap-x-1 gap-y-2">
{links.map((l, i) => (
<li key={l.href} className="flex items-center">
{/* A separator BETWEEN items, never trailing — and aria-hidden,
because a screen reader announcing "dot" between twenty
product names is noise, and the <li> boundary already
carries the separation. */}
{i > 0 ? (
<span
aria-hidden="true"
className="mx-2 text-fd-muted-foreground/50"
>
·
</span>
) : null}
<Link
href={l.href}
className="text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
{l.name}
</Link>
</li>
))}
</ul>
) : null}
</div>
</div>
</section>
</Pre>
</Figure>
) : null}
{links?.length ? (
<XStack render="ul" flexWrap="wrap" items="center" rowGap={8} style={{ listStyleType: 'none' }}>
{links.map((l, i) => (
<XStack key={l.href} render="li" items="center">
{i > 0 ? (
<Text aria-hidden mx={8} color="$color8">
·
</Text>
) : null}
<Text render={<Link href={l.href} prefetch={false} />} fontSize="$3" {...muted} hoverStyle={{ color: '$color12' }}>
{l.name}
</Text>
</XStack>
))}
</XStack>
) : null}
</YStack>
</XStack>
);
}
/**
* Wraps the sections so the hairlines between them are the layout, rather than
* each section guessing its own margin. `first:border-t-0` on the child means
* the group can start flush under a heading.
*/
export function ProductSections({ children }: { children: React.ReactNode }) {
return <div className="not-prose my-10">{children}</div>;
/** The sections, one after another; the first starts flush under its heading. */
export function ProductSections({ children }: { children: ReactNode }) {
let at = 0;
return (
<YStack>
{Children.map(children, (child) =>
isValidElement(child) ? cloneElement(child as ReactElement<{ first?: boolean }>, { first: at++ === 0 }) : child,
)}
</YStack>
);
}
@@ -1,96 +0,0 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { LayoutGrid } from 'lucide-react';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@hanzo/docs-base-ui/components/ui/select';
import { SECTIONS, type DocSection, type SectionGroup } from '@/generated/sections';
/** Flatten all items for route matching */
const ALL_ITEMS = SECTIONS.flatMap((g) => g.items);
/** The value held when the path matches no section. A select needs a value, so
* "everything" is a sentinel rather than null. */
const ALL = '__all__';
/** value -> label, the whole vocabulary of this switcher. Base UI resolves the
* trigger's text from `items`; given no map it prints the value itself, which
* is how every docs page came to show `__all__` where the section name goes. */
const LABELS: Record<string, string> = {
[ALL]: 'All capabilities',
...Object.fromEntries(ALL_ITEMS.map((item) => [item.route, item.name])),
};
/** Deduplicate by route — keep first occurrence */
const DEDUPED_SECTIONS: SectionGroup[] = (() => {
const seen = new Set<string>();
return SECTIONS.map((group) => ({
...group,
items: group.items.filter((item) => {
if (seen.has(item.route)) return false;
seen.add(item.route);
return true;
}),
})).filter((g) => g.items.length > 0);
})();
function findCurrentSection(pathname: string): DocSection | null {
let best: DocSection | null = null;
for (const item of ALL_ITEMS) {
if (pathname.startsWith(item.route)) {
if (!best || item.route.length > best.route.length) {
best = item;
}
}
}
return best;
}
export function ProjectSwitcher() {
const router = useRouter();
const pathname = usePathname();
const current = findCurrentSection(pathname ?? '');
const value = current?.route ?? ALL;
// No eyebrow above the trigger: it read "Docs" inside the docs sidebar, over a
// control that now names the section itself.
return (
<Select
items={LABELS}
value={value}
onValueChange={(next) => {
if (next && next !== ALL) router.push(next);
else if (next === ALL) router.push('/docs/openapi');
}}
>
<SelectTrigger aria-label="Section switcher">
<LayoutGrid className="h-3.5 w-3.5 shrink-0 text-fd-muted-foreground mr-1.5" />
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value={ALL}>{LABELS[ALL]}</SelectItem>
{DEDUPED_SECTIONS.map((group, idx) => (
<SelectGroup key={group.label}>
{idx > 0 && <SelectSeparator />}
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((item) => (
<SelectItem key={item.route} value={item.route}>
{item.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
}
+25 -32
View File
@@ -1,17 +1,10 @@
'use client';
import { Text, XStack, YStack } from '@hanzo/gui';
import { Grid } from '@hanzo/ui/grid';
import { PROVIDER_ICONS } from '@/components/provider-icons';
import { muted } from '@/lib/ink';
// The providers, as marks rather than a list of words.
//
// A reader scanning for "can I use Claude here" finds a logo in a glance and a
// noun in a second. Names alone make them read seven lines to answer one question.
// These are the real brand marks from hanzoai/icons (our MIT fork of lobe-icons),
// already vendored in this app for the models catalogue — no CDN, no runtime dep.
//
// Ordered deliberately: Zen first because the open weights are ours, then the
// frontier labs, then the rest of the open families. Not alphabetical — the order
// is the argument.
const PROVIDERS = [
{ key: 'hanzo', name: 'Zen', note: 'Open weights' },
{ key: 'openai', name: 'OpenAI', note: 'GPT' },
@@ -23,35 +16,35 @@ const PROVIDERS = [
{ key: 'mistral', name: 'Mistral', note: 'Open' },
] as const;
function Mark({ svg }: { svg: string }) {
return (
<span
aria-hidden
className="inline-flex size-6 items-center justify-center text-fd-foreground [&>svg]:size-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
/** The model families one key reaches, as their marks: four across, two on a phone. */
export function ProviderStrip() {
return (
<div className="not-prose my-8 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-fd-border bg-fd-border sm:grid-cols-4">
<YStack rounded="$5" borderWidth={1} borderColor="$borderColor" bg="$borderColor" overflow="hidden">
<Grid columns={{ min: 150, max: 4 }} gap={1}>
{PROVIDERS.map(({ key, name, note }) => {
const svg = PROVIDER_ICONS[key];
if (!svg) return null;
return (
<div
key={key}
className="flex flex-col gap-2 bg-fd-background p-4 transition-colors hover:bg-fd-accent/40"
>
<Mark svg={svg} />
<div>
<div className="text-sm font-medium text-fd-foreground">{name}</div>
<div className="text-xs text-fd-muted-foreground">{note}</div>
</div>
</div>
<YStack key={key} gap={8} p={16} bg="$background" hoverStyle={{ bg: '$hover' }}>
<XStack
aria-hidden
width={24}
height={24}
color="$color12"
dangerouslySetInnerHTML={{ __html: svg.replace('<svg', '<svg width="100%" height="100%"') }}
/>
<YStack>
<Text fontSize="$3" fontWeight="500" color="$color12">
{name}
</Text>
<Text fontSize="$1" {...muted}>
{note}
</Text>
</YStack>
</YStack>
);
})}
</div>
</Grid>
</YStack>
);
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { Text, XStack, YStack } from '@hanzo/gui';
import { Moon, PanelLeft, Search, Sun, X } from '@hanzogui/lucide-icons-2';
import { useThemeSetting } from '@hanzogui/next-theme';
import { Sheet, SheetContent, SheetTitle } from '@hanzo/ui';
import { useSearchContext } from '@hanzo/docs-base-ui/contexts/search';
import { useSearch } from '@/components/search';
import { Account } from '@/components/account';
import { Tool } from '@/components/action';
import { Brand } from '@/components/brand';
@@ -133,7 +133,7 @@ function Panel({ drawer = false, close }: { drawer?: boolean; close: React.React
* the dialog mounted.
*/
function Find({ drawer }: { drawer: boolean }) {
const { setOpenSearch } = useSearchContext();
const search = useSearch();
const { setOpen } = useShell();
return (
@@ -142,7 +142,7 @@ function Find({ drawer }: { drawer: boolean }) {
type="button"
onPress={() => {
if (drawer) setOpen(false);
setOpenSearch(true);
search.setOpen(true);
}}
height={36}
px={10}
@@ -1,29 +0,0 @@
import { source } from '@/lib/source';
import type { Graph } from '@/components/graph-view';
export function buildGraph(): Graph {
const pages = source.getPages();
const graph: Graph = { links: [], nodes: [] };
for (const page of pages) {
graph.nodes.push({
id: page.url,
url: page.url,
text: page.data.title,
description: page.data.description,
});
const { extractedReferences = [] } = page.data;
for (const ref of extractedReferences) {
const refPage = source.getPageByHref(ref.href);
if (!refPage) continue;
graph.links.push({
source: page.url,
target: refPage.page.url,
});
}
}
return graph;
}
-246
View File
@@ -1,246 +0,0 @@
import { type CompileOptions, type Registry } from '@hanzo/docs-cli/build';
import * as radixUi from '../../../../packages/radix-ui/registry';
import * as baseUi from '../../../../packages/base-ui/registry';
import * as sanity from '../../../../packages/sanity/registry';
import * as path from 'node:path';
const baseDir = path.join(import.meta.dirname, '../../');
export const compileOptions: Partial<CompileOptions> = {
onUnknownFile(absolutePath) {
const filePath = path.relative(baseDir, absolutePath);
// source object is external
if (filePath.startsWith('lib/source/')) return false;
// Hanzo platform infra — the gateway client config (lib/hanzo/client) and
// the client-side chat hook (lib/hanzo/use-chat) are provided by the host
// app, not vendored into installable components (same as lib/source above).
if (filePath.startsWith('lib/hanzo/')) return false;
},
onParseReference(ref) {
if (ref.type === 'unknown' && ref.specifier === 'hast') {
return {
type: 'dependency',
dep: '@types/hast',
specifier: 'hast',
};
}
if (ref.type === 'file') {
let file = path.relative(baseDir, ref.file);
if (file === 'lib/cn.ts') {
return {
type: 'file',
file: path.join(radixUi.registry.dir, 'utils/cn.ts'),
};
}
file = path.relative(radixUi.registry.dir, ref.file);
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
return {
dep: '@hanzo/docs-ui',
type: 'dependency',
specifier: `@hanzo/docs-ui/${removeExtname(file)}`,
};
}
file = path.relative(baseUi.registry.dir, ref.file);
if (file.startsWith('contexts/') || file.startsWith('utils/use-')) {
return {
dep: '@hanzo/docs-base-ui',
type: 'dependency',
specifier: `@hanzo/docs-base-ui/${removeExtname(file)}`,
};
}
}
// map dep imports to actual components
if (ref.type === 'dependency' && ref.dep === '@hanzo/docs-ui') {
const match = /@hanzo\/docs-ui\/components\/ui\/(.*)/.exec(ref.specifier);
if (match) {
return {
type: 'file',
file: path.join(radixUi.registry.dir, `components/ui/${match[1]}.tsx`),
};
}
}
return ref;
},
};
export const registry: Registry = {
dir: baseDir,
name: 'Hanzo Docs',
subRegistries: [radixUi.registry, baseUi.registry, sanity.registry],
components: [
{
name: 'layouts/docs-min',
description: 'Replace Docs Layout (Minimal)',
files: [
{
type: 'layout',
path: 'components/registry/layout/docs-min.tsx',
target: '<dir>/docs/index.tsx',
},
{
type: 'layout',
path: 'components/registry/layout/page-min.tsx',
target: '<dir>/docs/page.tsx',
},
],
unlisted: true,
},
{
name: 'graph-view',
description: 'A graph to display relationships of all pages',
files: [
{
type: 'components',
path: 'components/graph-view.tsx',
},
{
type: 'lib',
path: 'components/registry/build-graph.ts',
target: 'lib/build-graph.ts',
},
],
},
{
name: 'feedback',
title: 'Feedback',
description: 'Component to send user feedbacks about the docs',
files: [
{
type: 'components',
path: 'components/feedback/client.tsx',
target: '<dir>/feedback/client.tsx',
},
{
type: 'components',
path: 'components/feedback/schema.ts',
target: '<dir>/feedback/schema.ts',
},
],
},
{
name: 'ai/shared',
unlisted: true,
files: [
{
type: 'components',
path: 'components/ai-sdk/search.tsx',
target: '<dir>/ai/search.tsx',
},
],
},
{
name: 'ai/openrouter',
title: 'AI Chat (AI SDK)',
description: 'Ask AI dialog for your docs, default using OpenRouter',
files: [
{
type: 'route-handler',
route: 'api/chat',
path: 'lib/openrouter/route.ts',
},
],
dependencies: {
flexsearch: '^0.8.212',
},
},
{
name: 'ai/llmgateway',
title: 'AI Chat (LLMGateway)',
description: 'Ask AI dialog for your docs, using LLMGateway',
files: [
{
type: 'route-handler',
route: 'api/chat',
path: 'lib/llmgateway/route.ts',
},
],
dependencies: {
flexsearch: '^0.8.212',
},
},
{
name: 'markdown',
unlisted: true,
files: [
{
type: 'components',
path: 'components/markdown.tsx',
},
],
},
{
name: 'ai/hanzo',
title: 'AI Chat (Next.js + Hanzo AI)',
description: 'Ask AI dialog for your docs, powered by Hanzo Cloud',
files: [
{
type: 'components',
path: 'components/ai/search.tsx',
target: '<dir>/ai/search.tsx',
},
{
type: 'components',
path: 'components/ai/markdown.tsx',
target: '<dir>/ai/markdown.tsx',
},
{
type: 'route',
path: 'app/api/chat/route.ts',
target: 'app/api/chat/route.ts',
},
{
type: 'route-handler',
route: 'api/ai',
path: 'lib/ai/route.ts',
},
{
type: 'lib',
path: 'lib/ai/qa-schema.ts',
target: '<dir>/ai/qa-schema.ts',
},
],
},
{
name: 'og/mono',
description: 'Open graph image generation - mono style',
files: [
{
type: 'lib',
path: 'lib/og/mono.tsx',
target: '<dir>/og/mono.tsx',
},
{
type: 'lib',
path: 'lib/og/JetBrainsMono-Bold.ttf',
target: '<dir>/og/JetBrainsMono-Bold.ttf',
},
{
type: 'lib',
path: 'lib/og/JetBrainsMono-Regular.ttf',
target: '<dir>/og/JetBrainsMono-Regular.ttf',
},
],
},
],
dependencies: {
'@hanzo/docs-core': null,
'@hanzo/docs-ui': null,
'lucide-react': null,
next: null,
react: null,
},
};
function removeExtname(file: string) {
return file.slice(0, -path.extname(file).length);
}
@@ -1,177 +0,0 @@
'use client';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { type ComponentProps, createContext, type ReactNode, use, useMemo, useState } from 'react';
import { cn } from '@/lib/cn';
import { TreeContextProvider, useTreeContext } from '@hanzo/docs-base-ui/contexts/tree';
import Link from '@hanzo/docs-core/link';
import { useSearchContext } from '@hanzo/docs-base-ui/contexts/search';
import { cva } from 'class-variance-authority';
import { usePathname } from '@hanzo/docs-core/framework';
interface SidebarContext {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const SidebarContext = createContext<SidebarContext | null>(null);
export interface DocsLayoutProps {
tree: PageTree.Root;
children: ReactNode;
}
export function DocsLayout({ tree, children }: DocsLayoutProps) {
return (
<TreeContextProvider tree={tree}>
<SidebarProvider>
<header className="sticky top-0 bg-fd-background h-14 z-20">
<nav className="flex flex-row items-center gap-2 size-full px-4">
<Link href="/" className="font-medium mr-auto">
My Docs
</Link>
<SearchToggle />
<NavbarSidebarTrigger className="md:hidden" />
</nav>
</header>
<main id="nd-docs-layout" className="flex flex-1 flex-row [--fd-nav-height:56px]">
<Sidebar />
{children}
</main>
</SidebarProvider>
</TreeContextProvider>
);
}
function SidebarProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<SidebarContext
value={useMemo(
() => ({
open,
setOpen,
}),
[open],
)}
>
{children}
</SidebarContext>
);
}
function SearchToggle(props: ComponentProps<'button'>) {
const { enabled, setOpenSearch } = useSearchContext();
if (!enabled) return;
return (
<button
{...props}
className={cn('text-sm', props.className)}
onClick={() => setOpenSearch(true)}
>
Search
</button>
);
}
function NavbarSidebarTrigger(props: ComponentProps<'button'>) {
const { open, setOpen } = use(SidebarContext)!;
return (
<button {...props} className={cn('text-sm', props.className)} onClick={() => setOpen(!open)}>
Sidebar
</button>
);
}
function Sidebar() {
const { root } = useTreeContext();
const { open } = use(SidebarContext)!;
const children = useMemo(() => {
function renderItems(items: PageTree.Node[]) {
return items.map((item) => (
<SidebarItem key={item.$id} item={item}>
{item.type === 'folder' ? renderItems(item.children) : null}
</SidebarItem>
));
}
return renderItems(root.children);
}, [root]);
return (
<aside
className={cn(
'fixed flex flex-col shrink-0 p-4 top-14 z-20 text-sm overflow-auto md:sticky md:h-[calc(100dvh-56px)] md:w-[300px]',
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background',
!open && 'max-md:invisible',
)}
>
{children}
</aside>
);
}
const linkVariants = cva(
'flex items-center gap-2 w-full py-1.5 rounded-lg text-fd-foreground/80 [&_svg]:size-4',
{
variants: {
active: {
true: 'text-fd-primary font-medium',
false: 'hover:text-fd-accent-foreground',
},
},
},
);
function SidebarItem({ item, children }: { item: PageTree.Node; children: ReactNode }) {
const pathname = usePathname();
if (item.type === 'page') {
return (
<Link
href={item.url}
className={linkVariants({
active: pathname === item.url,
})}
>
{item.icon}
{item.name}
</Link>
);
}
if (item.type === 'separator') {
return (
<p className="text-fd-muted-foreground mt-6 mb-2 first:mt-0">
{item.icon}
{item.name}
</p>
);
}
return (
<div>
{item.index ? (
<Link
className={linkVariants({
active: pathname === item.index.url,
})}
href={item.index.url}
>
{item.index.icon}
{item.index.name}
</Link>
) : (
<p className={cn(linkVariants(), 'text-start')}>
{item.icon}
{item.name}
</p>
)}
<div className="pl-4 border-l flex flex-col">{children}</div>
</div>
);
}
@@ -1,117 +0,0 @@
'use client';
import { type ComponentProps, type ReactNode, useMemo } from 'react';
import { AnchorProvider, type TOCItemType, useActiveAnchors } from '@hanzo/docs-core/toc';
import { cn } from '@/lib/cn';
import { useTreeContext } from '@hanzo/docs-base-ui/contexts/tree';
import { Link, usePathname } from '@hanzo/docs-core/framework';
import type * as PageTree from '@hanzo/docs-core/page-tree';
export interface DocsPageProps {
toc?: TOCItemType[];
children: ReactNode;
}
export function DocsPage({ toc = [], ...props }: DocsPageProps) {
return (
<AnchorProvider toc={toc}>
<main className="flex w-full min-w-0 flex-col">
<article className="flex flex-1 flex-col w-full max-w-[860px] gap-6 px-4 py-8 md:px-6 md:mx-auto">
{props.children}
<Footer />
</article>
</main>
{toc.length > 0 && (
<div className="sticky top-(--fd-nav-height) w-[286px] shrink-0 h-[calc(100dvh-var(--fd-nav-height))] p-4 overflow-auto max-xl:hidden">
<p className="text-sm text-fd-muted-foreground mb-2">On this page</p>
<div className="flex flex-col">
{toc.map((item) => (
<TocItem key={item.url} item={item} />
))}
</div>
</div>
)}
</AnchorProvider>
);
}
export function DocsBody(props: ComponentProps<'div'>) {
return (
<div {...props} className={cn('prose', props.className)}>
{props.children}
</div>
);
}
export function DocsDescription(props: ComponentProps<'p'>) {
// don't render if no description provided
if (props.children === undefined) return null;
return (
<p {...props} className={cn('mb-8 text-lg text-fd-muted-foreground', props.className)}>
{props.children}
</p>
);
}
export function DocsTitle(props: ComponentProps<'h1'>) {
return (
<h1 {...props} className={cn('text-3xl font-semibold', props.className)}>
{props.children}
</h1>
);
}
function TocItem({ item }: { item: TOCItemType }) {
const isActive = useActiveAnchors().includes(item.url.slice(1));
return (
<a
href={item.url}
className={cn('text-sm text-fd-foreground/80 py-1', isActive && 'text-fd-primary')}
style={{
paddingLeft: Math.max(0, item.depth - 2) * 16,
}}
>
{item.title}
</a>
);
}
function Footer() {
const { root } = useTreeContext();
const pathname = usePathname();
const flatten = useMemo(() => {
const result: PageTree.Item[] = [];
function scan(items: PageTree.Node[]) {
for (const item of items) {
if (item.type === 'page') result.push(item);
else if (item.type === 'folder') {
if (item.index) result.push(item.index);
scan(item.children);
}
}
}
scan(root.children);
return result;
}, [root]);
const { previous, next } = useMemo(() => {
const idx = flatten.findIndex((item) => item.url === pathname);
if (idx === -1) return {};
return {
previous: flatten[idx - 1],
next: flatten[idx + 1],
};
}, [flatten, pathname]);
return (
<div className="flex flex-row justify-between gap-2 items-center font-medium">
{previous ? <Link href={previous.url}>{previous.name}</Link> : null}
{next ? <Link href={next.url}>{next.name}</Link> : null}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import dynamic from 'next/dynamic';
import { createContext, use, useEffect, useMemo, useState, type ReactNode } from 'react';
/**
* Search: one dialog, opened from the rail's field or with ⌘K / Ctrl+K from
* anywhere. The dialog and its index client load the first time it opens, so a
* reader who never searches never downloads either.
*/
interface Search {
open: boolean;
setOpen: (v: boolean) => void;
}
const Context = createContext<Search>({ open: false, setOpen: () => undefined });
export function useSearch(): Search {
return use(Context);
}
const Finder = dynamic(() => import('@/components/finder'), { ssr: false });
export function SearchProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [wanted, setWanted] = useState(false);
useEffect(() => {
const key = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setWanted(true);
setOpen((v) => !v);
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, []);
const value = useMemo(
() => ({
open,
setOpen: (v: boolean) => {
if (v) setWanted(true);
setOpen(v);
},
}),
[open],
);
return (
<Context value={value}>
{children}
{wanted && <Finder open={open} onOpenChange={setOpen} />}
</Context>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import type { ReactNode } from 'react';
import { Spinner, Text, YStack } from '@hanzo/gui';
import { muted } from '@/lib/ink';
/**
* A whole-screen word from the sign-in routes: a spinner while IAM answers, or
* what went wrong and the one thing to do about it.
*/
export function Status({ title, note, action }: { title?: string; note: string; action?: ReactNode }) {
return (
<YStack minH="100dvh" items="center" justify="center" gap={10} px={20} bg="$background">
{title ? (
<Text fontSize={18} fontWeight="600" color="$color12">
{title}
</Text>
) : (
<Spinner size="small" color="$color12" />
)}
<Text fontSize="$3" text="center" {...muted}>
{note}
</Text>
{action}
</YStack>
);
}
+3 -4
View File
@@ -4,7 +4,6 @@ description: The chat app on Hanzo Cloud — enso and Zen models, MCP tools, age
index: true
---
import { LayoutGrid } from 'lucide-react'
# Hanzo Chat
@@ -154,7 +153,7 @@ chat route into the rest of `/v1`.
## In this section
<Cards>
<Card icon={<LayoutGrid />} title="Hanzo Dev" href="/docs/dev" description="AI-powered coding assistant for your terminal. Install, configure, and operate Hanzo Dev." />
<Card icon={<LayoutGrid />} title="Integrations" href="/docs/guides/integrations" description="Connect blob storage, Slack, Mixpanel, and Insights to your project." />
<Card icon={<LayoutGrid />} title="Integrations & Extensions" href="/docs/apps" description="Install and connect Hanzo across your browser, editor, desktop, docs, design tools, and business apps — one…" />
<Card icon="LayoutGrid" title="Hanzo Dev" href="/docs/dev" description="AI-powered coding assistant for your terminal. Install, configure, and operate Hanzo Dev." />
<Card icon="LayoutGrid" title="Integrations" href="/docs/guides/integrations" description="Connect blob storage, Slack, Mixpanel, and Insights to your project." />
<Card icon="LayoutGrid" title="Integrations & Extensions" href="/docs/apps" description="Install and connect Hanzo across your browser, editor, desktop, docs, design tools, and business apps — one…" />
</Cards>
@@ -3,7 +3,6 @@ title: User Guide
description: The CLI tool that automates setups and installs components.
---
import { Terminal } from 'lucide-react'
## Installation
@@ -93,9 +92,9 @@ export default (
## In this section
<Cards>
<Card icon={<Terminal />} title="SDKs" href="/docs/sdks" description="Official Hanzo SDKs for every language — Python, TypeScript, Go, Rust, C++, Swift, Kotlin. Two lines per…" />
<Card icon={<Terminal />} title="ZAP Protocol" href="/docs/zap" description="Zero-Copy App Proto — High-performance Cap'n Proto RPC for AI agent communication. 10-100x faster than MCP…" />
<Card icon={<Terminal />} title="Hanzo Skills Reference" href="/docs/openapi/skills" description="Comprehensive reference documentation for all 49 Hanzo skills — deep-dive guides for AI agents and developers." />
<Card icon={<Terminal />} title="Usage" href="/docs/usage" description="One usage plane for every AI account you have — rate limits, credits, and spend across Codex, Claude, Hanzo…" />
<Card icon={<Terminal />} title="Organizations" href="/docs/openapi/iam" description="Multi-org setup, resource scoping, team management, and white-label login for Hanzo organizations." />
<Card icon="Terminal" title="SDKs" href="/docs/sdks" description="Official Hanzo SDKs for every language — Python, TypeScript, Go, Rust, C++, Swift, Kotlin. Two lines per…" />
<Card icon="Terminal" title="ZAP Protocol" href="/docs/zap" description="Zero-Copy App Proto — High-performance Cap'n Proto RPC for AI agent communication. 10-100x faster than MCP…" />
<Card icon="Terminal" title="Hanzo Skills Reference" href="/docs/openapi/skills" description="Comprehensive reference documentation for all 49 Hanzo skills — deep-dive guides for AI agents and developers." />
<Card icon="Terminal" title="Usage" href="/docs/usage" description="One usage plane for every AI account you have — rate limits, credits, and spend across Codex, Claude, Hanzo…" />
<Card icon="Terminal" title="Organizations" href="/docs/openapi/iam" description="Multi-org setup, resource scoping, team management, and white-label login for Hanzo organizations." />
</Cards>
+8 -9
View File
@@ -4,7 +4,6 @@ title: Build a game
description: The shortest path from nothing to something you can play — in the browser, from your terminal, or against the raw API.
---
import { Rocket, Code, Package, Sparkles } from 'lucide-react'
# Build a game
@@ -128,17 +127,17 @@ somewhere shared to put the state, and something to tell each tab when it change
which is the point at which a toy starts using the platform properly:
<Cards>
<Card icon={<Sparkles />} title="Keep the scores" href="/docs/openapi/kv" description="A key/value store for the leaderboard — read and written from the browser." />
<Card icon={<Rocket />} title="Push the moves" href="/docs/openapi/pubsub" description="Publish each move, subscribe in the other tab. No polling loop to write." />
<Card icon={<Code />} title="Know who is playing" href="/docs/openapi/iam" description="Sign-in, so a high score belongs to a person instead of a browser." />
<Card icon={<Package />} title="Put it somewhere" href="/docs/openapi/deploy" description="Ship the finished thing to a URL you can send to someone." />
<Card icon="Sparkles" title="Keep the scores" href="/docs/openapi/kv" description="A key/value store for the leaderboard — read and written from the browser." />
<Card icon="Rocket" title="Push the moves" href="/docs/openapi/pubsub" description="Publish each move, subscribe in the other tab. No polling loop to write." />
<Card icon="Code" title="Know who is playing" href="/docs/openapi/iam" description="Sign-in, so a high score belongs to a person instead of a browser." />
<Card icon="Package" title="Put it somewhere" href="/docs/openapi/deploy" description="Ship the finished thing to a URL you can send to someone." />
</Cards>
## Then take it further
<Cards>
<Card icon={<Rocket />} title="Get Started" href="/docs/quickstart" description="Account, API key, first call — the five-minute version." />
<Card icon={<Code />} title="API Reference" href="/docs/openapi" description="Every endpoint, generated from OpenAPI." />
<Card icon={<Package />} title="SDKs" href="/docs/sdks" description="Python, TypeScript, Go, Rust — one contract, many bindings." />
<Card icon={<Sparkles />} title="MCP" href="/docs/mcp" description="Give the model real tools — files, HTTP, your own functions." />
<Card icon="Rocket" title="Get Started" href="/docs/quickstart" description="Account, API key, first call — the five-minute version." />
<Card icon="Code" title="API Reference" href="/docs/openapi" description="Every endpoint, generated from OpenAPI." />
<Card icon="Package" title="SDKs" href="/docs/sdks" description="Python, TypeScript, Go, Rust — one contract, many bindings." />
<Card icon="Sparkles" title="MCP" href="/docs/mcp" description="Give the model real tools — files, HTTP, your own functions." />
</Cards>
-111
View File
@@ -1,111 +0,0 @@
import { ProvideLinksToolSchema } from '@/lib/ai/qa-schema';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { $routeHandler } from 'fuma-cli/macros/route-handler';
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
export type HanzoUIMessage = UIMessage<
never,
{
client: {
location: string;
};
}
>;
// NATIVE Hanzo AI — replaces the retired third-party hosted RAG. Everything
// runs on our own stack: the `rag-api` native index for retrieval + the Hanzo
// gateway (api.hanzo.ai, OpenAI-compatible) for generation. No third-party AI.
const hanzo = createOpenAICompatible({
name: 'hanzo',
apiKey: process.env.HANZO_API_KEY,
baseURL: process.env.HANZO_AI_BASE_URL ?? 'https://api.hanzo.ai/v1',
});
// The native RAG index (rag-api). `/query` returns the top-k indexed doc chunks
// (page_content + metadata) — we ground the answer on these and cite their URLs.
const RAG_URL = process.env.HANZO_RAG_URL ?? 'https://api.hanzo.ai/v1';
const RAG_ENTITY = process.env.HANZO_RAG_ENTITY ?? 'docs.hanzo.ai';
interface RagChunk {
page_content: string;
metadata?: Record<string, unknown>;
}
async function retrieve(
query: string,
): Promise<{ context: string; links: { url: string; title?: string }[] }> {
try {
const res = await fetch(`${RAG_URL}/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.HANZO_API_KEY ?? ''}`,
},
body: JSON.stringify({ query, k: 8, entity_id: RAG_ENTITY }),
});
if (!res.ok) return { context: '', links: [] };
const chunks = (await res.json()) as RagChunk[];
if (!Array.isArray(chunks)) return { context: '', links: [] };
const links = chunks
.map((c) => ({
url: String(c.metadata?.url ?? c.metadata?.source ?? ''),
title: (c.metadata?.title as string | undefined) ?? undefined,
}))
.filter((l) => l.url);
const context = chunks
.map((c, i) => `[${i + 1}] ${(c.metadata?.title as string) ?? ''}\n${c.page_content}`)
.join('\n\n');
return { context, links };
} catch {
return { context: '', links: [] };
}
}
export const handler = $routeHandler(
{
methods: ['POST'],
params: [],
},
async (req) => {
const reqJson = await req.json();
// Ground the answer on the native index: retrieve for the latest user turn.
const msgs = (reqJson.messages ?? []) as HanzoUIMessage[];
const lastUser = [...msgs].reverse().find((m) => m.role === 'user');
const question = (lastUser?.parts ?? [])
.map((p) => ((p as { type?: string; text?: string }).type === 'text' ? (p as { text?: string }).text ?? '' : ''))
.join(' ')
.trim();
const { context } = question ? await retrieve(question) : { context: '' };
const result = streamText({
model: hanzo(process.env.HANZO_AI_MODEL ?? 'enso'),
system:
'You are the Hanzo documentation assistant. Answer using ONLY the Hanzo ' +
'documentation context below. Cite every source you use by calling the ' +
'provideLinks tool with its URL. If the answer is not in the context, say ' +
'so plainly and point to the closest relevant section.\n\n' +
(context
? `--- Hanzo docs context ---\n${context}\n--- end context ---`
: '(no matching docs were found in the native index for this query)'),
tools: {
provideLinks: {
inputSchema: ProvideLinksToolSchema,
},
},
messages: await convertToModelMessages<HanzoUIMessage>(reqJson.messages, {
ignoreIncompleteToolCalls: true,
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
}),
toolChoice: 'auto',
});
return result.toUIMessageStreamResponse();
},
);
-32
View File
@@ -1,32 +0,0 @@
import { source } from '@/lib/source';
import type { Graph } from '@/components/graph-view';
export async function buildGraph(): Promise<Graph> {
const graph: Graph = { links: [], nodes: [] };
await Promise.all(
source.getPages().map(async (page) => {
if (page.type === 'openapi') return;
graph.nodes.push({
id: page.url,
url: page.url,
text: page.data.title,
description: page.data.description,
});
const { extractedReferences = [] } = await page.data.load();
for (const ref of extractedReferences) {
const refPage = source.getPageByHref(ref.href);
if (!refPage) continue;
graph.links.push({
source: page.url,
target: refPage.page.url,
});
}
}),
);
return graph;
}
-1
View File
@@ -1 +0,0 @@
export { twMerge as cn } from 'tailwind-merge';
-168
View File
@@ -1,168 +0,0 @@
import { App, Octokit } from 'octokit';
import {
blockFeedback,
BlockFeedback,
pageFeedback,
type ActionResponse,
type PageFeedback,
} from '@/components/feedback/schema';
export const repo = 'docs';
export const owner = 'hanzoai';
export const DocsCategory = 'Docs Feedback';
let instance: Octokit | undefined;
async function getOctokit(): Promise<Octokit> {
if (instance) return instance;
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
if (!appId || !privateKey) {
throw new Error('No GitHub keys provided for Github app, docs feedback feature will not work.');
}
const app = new App({
appId,
privateKey,
});
const { data } = await app.octokit.request('GET /repos/{owner}/{repo}/installation', {
owner,
repo,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
});
instance = await app.getInstallationOctokit(data.id);
return instance;
}
interface RepositoryInfo {
id: string;
discussionCategories: {
nodes: {
id: string;
name: string;
}[];
};
}
let cachedDestination: RepositoryInfo | undefined;
async function getFeedbackDestination() {
if (cachedDestination) return cachedDestination;
const octokit = await getOctokit();
const {
repository,
}: {
repository: RepositoryInfo;
} = await octokit.graphql(`
query {
repository(owner: "${owner}", name: "${repo}") {
id
discussionCategories(first: 25) {
nodes { id name }
}
}
}
`);
return (cachedDestination = repository);
}
// Note: Server Actions are disabled for static export
// These functions are stubbed out and return mock responses
// To enable feedback, deploy with a server (non-static export) and uncomment 'use server' directives
export async function onPageFeedbackAction(feedback: PageFeedback): Promise<ActionResponse> {
// 'use server'; // Disabled for static export
if (typeof window !== 'undefined') {
// Client-side: return mock response
console.log('Feedback submitted (static export - no server):', feedback);
return { githubUrl: '#' };
}
feedback = pageFeedback.parse(feedback);
const url = new URL(feedback.url);
return createDiscussionThread(
url.pathname,
`[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`,
);
}
export async function onBlockFeedbackAction(feedback: BlockFeedback): Promise<ActionResponse> {
// 'use server'; // Disabled for static export
if (typeof window !== 'undefined') {
// Client-side: return mock response
console.log('Block feedback submitted (static export - no server):', feedback);
return { githubUrl: '#' };
}
feedback = blockFeedback.parse(feedback);
const url = new URL(feedback.url);
url.hash = feedback.blockId;
return createDiscussionThread(
url.pathname,
`> ${feedback.blockBody}\n\n${feedback.message}\n\n> [Forwarded from user feedback](${url.href}).`,
);
}
async function createDiscussionThread(pageId: string, body: string) {
const octokit = await getOctokit();
const destination = await getFeedbackDestination();
const category = destination.discussionCategories.nodes.find(
(category) => category.name === DocsCategory,
);
if (!category) throw new Error(`Please create a "${DocsCategory}" category in GitHub Discussion`);
const title = `Feedback for ${pageId}`;
const queryResult: {
search: {
nodes: { id: string; title: string; url: string }[];
};
} = await octokit.graphql(`
query {
search(type: DISCUSSION, query: ${JSON.stringify(`"${title}" in:title repo:${owner}/${repo} author:@me`)}, first: 10) {
nodes {
... on Discussion { id, title, url }
}
}
}`);
const discussion = queryResult.search.nodes.find((item) => item.title === title);
if (discussion) {
const result: {
addDiscussionComment: {
comment: { id: string; url: string };
};
} = await octokit.graphql(`
mutation {
addDiscussionComment(input: { body: ${JSON.stringify(body)}, discussionId: "${discussion.id}" }) {
comment { id, url }
}
}`);
return {
githubUrl: result.addDiscussionComment.comment.url,
};
} else {
const result: {
createDiscussion: {
discussion: { id: string; url: string };
};
} = await octokit.graphql(`
mutation {
createDiscussion(input: { repositoryId: "${destination.id}", categoryId: "${category.id}", body: ${JSON.stringify(body)}, title: ${JSON.stringify(title)} }) {
discussion { id, url }
}
}`);
return {
githubUrl: result.createDiscussion.discussion.url,
};
}
}
-41
View File
@@ -1,41 +0,0 @@
export const searchBackend =
(process.env.NEXT_PUBLIC_HANZO_SEARCH_BACKEND as 'cloud' | 'meilisearch' | undefined) ??
'meilisearch';
export const searchEndpoint =
process.env.NEXT_PUBLIC_HANZO_SEARCH_ENDPOINT ??
(searchBackend === 'meilisearch'
? 'https://search.hanzo.ai'
: 'https://api.hanzo.ai/v1/search-docs');
export const searchIndex =
process.env.NEXT_PUBLIC_HANZO_SEARCH_INDEX ?? 'app-docs-hanzo-ai-docs';
export const indexEndpoint =
process.env.HANZO_SEARCH_INDEX_ENDPOINT ??
'https://api.hanzo.ai/v1/index-docs';
// Gateway BASE the docs chat widget streams from, CLIENT-SIDE. The @hanzo/ai
// client appends `/v1/chat/completions` itself (never an `/api/` prefix), so
// this is the gateway ROOT — not a full path. Replaces the retired server-route
// RAG proxy (the dead `/v1/chat-docs` name): docs deploys as a static export
// with no server, so the browser calls the real gateway directly.
export const chatEndpoint =
process.env.NEXT_PUBLIC_HANZO_CHAT_ENDPOINT ?? 'https://api.hanzo.ai';
// Publishable key (pk-*) for the hanzo-docs org, scoped to chat. Ships in the
// client bundle BY DESIGN (NEXT_PUBLIC) — never a server secret. Answering is
// balance-gated: the hanzo-docs org wallet must be funded for the gateway to bill.
export const chatKey = process.env.NEXT_PUBLIC_HANZO_CHAT_KEY ?? '';
// Generation model on the gateway. `enso` is the Hanzo docs assistant model.
export const chatModel = process.env.NEXT_PUBLIC_HANZO_CHAT_MODEL ?? 'enso';
export const publishableKey =
process.env.NEXT_PUBLIC_HANZO_SEARCH_KEY ??
(searchBackend === 'meilisearch'
? '2d99c3ab7551b807c9b8c132f663eba7e27e765a511907d9a566799497c7fd42'
: 'pk-hanzo-docs-search-2026');
export const adminKey =
process.env.HANZO_SEARCH_ADMIN_KEY ?? '';
-248
View File
@@ -1,248 +0,0 @@
'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import { createAiClient, type ChatCompletionMessage, type Tool } from '@hanzo/ai';
import type { z } from 'zod';
import type { ProvideLinksToolSchema } from '@/lib/ai/qa-schema';
import { chatEndpoint, chatKey, chatModel } from '@/lib/hanzo/client';
// Client-side chat for the docs AISearch widget. docs.hanzo.ai deploys as a
// STATIC export (hanzoai/static) with no server, so the retired `/api/chat`
// route can never run in prod. This hook streams straight from the Hanzo
// gateway (`POST {chatEndpoint}/v1/chat/completions`, model `enso`) using our
// own headless SDK (@hanzo/ai `createAiClient`) with a PUBLISHABLE key — never a
// server secret. It presents the exact surface the widget already consumes
// (messages/status/sendMessage/regenerate/stop/setMessages), so the bespoke
// provideLinks/sources UI is untouched.
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error';
// Reuse the ONE links contract the UI already renders (from the tool schema).
type Links = z.infer<typeof ProvideLinksToolSchema>['links'];
export type ChatPart =
| { type: 'text'; text: string }
| { type: 'tool-provideLinks'; input: { links: Links } }
| { type: 'data-client'; data: { location: string } };
export interface ChatMessage {
id: string;
role: 'system' | 'user' | 'assistant';
parts: ChatPart[];
}
export interface HanzoChat {
messages: ChatMessage[];
status: ChatStatus;
sendMessage: (message: { role: 'user'; parts: ChatPart[] }) => void;
regenerate: () => void;
stop: () => void;
setMessages: (messages: ChatMessage[]) => void;
}
const SYSTEM_PROMPT =
'You are Hanzo Docs AI, the assistant for the Hanzo AI Cloud documentation. ' +
'Answer questions about Hanzo products, APIs, SDKs, and self-hosting concisely ' +
'and accurately. Cite the documentation pages you draw on by calling the ' +
'provideLinks tool with their URLs. If you are unsure, say so plainly and ' +
'point to the closest relevant section — never invent APIs, flags, or endpoints.';
// The model-facing contract for citations. Kept as a clean JSON Schema (the zod
// `ProvideLinksToolSchema` is deliberately loose because it PARSES untrusted
// model output; this is what we ASK the model to produce). Shape matches the
// source cards the widget renders (label / url / title).
const provideLinksTool: Tool = {
type: 'function',
function: {
name: 'provideLinks',
description:
'Cite the Hanzo documentation pages used to answer. Call once with every ' +
'source URL referenced in the answer.',
parameters: {
type: 'object',
properties: {
links: {
type: 'array',
description: 'The documentation pages cited in the answer.',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Footnote label, e.g. "1".' },
url: { type: 'string', description: 'Absolute URL of the doc page.' },
title: { type: 'string', description: 'Human-readable page title.' },
},
required: ['url'],
},
},
},
required: ['links'],
},
},
};
function partsToText(parts: ChatPart[]): string {
return parts
.map((p) =>
p.type === 'text'
? p.text
: p.type === 'data-client'
? `[Current page: ${p.data.location}]\n\n`
: '',
)
.join('')
.trim();
}
function toCompletionMessages(history: ChatMessage[]): ChatCompletionMessage[] {
const turns: ChatCompletionMessage[] = history
.filter((m) => m.role !== 'system')
.map((m) => ({ role: m.role as 'user' | 'assistant', content: partsToText(m.parts) }))
.filter((m) => typeof m.content === 'string' && m.content.length > 0);
return [{ role: 'system', content: SYSTEM_PROMPT }, ...turns];
}
let seq = 0;
const nextId = () => `msg-${Date.now()}-${seq++}`;
export function useHanzoChat(): HanzoChat {
const [messages, setMessagesState] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>('ready');
const messagesRef = useRef<ChatMessage[]>([]);
const abortRef = useRef<AbortController | null>(null);
const commit = useCallback((next: ChatMessage[]) => {
messagesRef.current = next;
setMessagesState(next);
}, []);
// One client for the widget's lifetime. Missing key → null (fail secure: we
// surface a config message rather than firing an unauthenticated request).
const client = useMemo(
() => (chatKey ? createAiClient({ baseUrl: chatEndpoint, token: chatKey }) : null),
[],
);
const run = useCallback(
async (history: ChatMessage[]) => {
if (!client) {
commit([
...history,
{
id: nextId(),
role: 'assistant',
parts: [
{
type: 'text',
text: 'AI chat is not configured yet. Set `NEXT_PUBLIC_HANZO_CHAT_KEY` to a Hanzo publishable key.',
},
],
},
]);
setStatus('ready');
return;
}
const controller = new AbortController();
abortRef.current = controller;
setStatus('submitted');
const assistantId = nextId();
let body = '';
const toolArgs = new Map<number, { name: string; args: string }>();
const render = () => {
const parts: ChatPart[] = [];
if (body) parts.push({ type: 'text', text: body });
for (const { name, args } of toolArgs.values()) {
if (name !== 'provideLinks') continue;
try {
const parsed = JSON.parse(args) as { links: Links };
if (parsed?.links) parts.push({ type: 'tool-provideLinks', input: { links: parsed.links } });
} catch {
// Arguments still streaming (incomplete JSON) — render once valid.
}
}
commit([...history, { id: assistantId, role: 'assistant', parts }]);
};
// Seed an empty assistant turn so the panel shows activity immediately.
commit([...history, { id: assistantId, role: 'assistant', parts: [] }]);
try {
const stream = await client.chat.completions.create(
{
model: chatModel,
messages: toCompletionMessages(history),
tools: [provideLinksTool],
tool_choice: 'auto',
stream: true,
},
{ signal: controller.signal },
);
setStatus('streaming');
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
if (delta.content) body += delta.content;
for (const call of delta.tool_calls ?? []) {
const cur = toolArgs.get(call.index) ?? { name: '', args: '' };
if (call.function?.name) cur.name = call.function.name;
if (call.function?.arguments) cur.args += call.function.arguments;
toolArgs.set(call.index, cur);
}
render();
}
render();
setStatus('ready');
} catch {
if (controller.signal.aborted) {
setStatus('ready');
return;
}
// Fail closed: never leak gateway internals to the client.
commit([
...history,
{
id: assistantId,
role: 'assistant',
parts: [{ type: 'text', text: 'Chat is temporarily unavailable. Please try again in a moment.' }],
},
]);
setStatus('error');
} finally {
abortRef.current = null;
}
},
[client, commit],
);
const sendMessage = useCallback(
(message: { role: 'user'; parts: ChatPart[] }) => {
const next = [...messagesRef.current, { id: nextId(), role: 'user' as const, parts: message.parts }];
commit(next);
void run(next);
},
[commit, run],
);
const regenerate = useCallback(() => {
const msgs = messagesRef.current;
let lastUser = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
lastUser = i;
break;
}
}
if (lastUser === -1) return;
const history = msgs.slice(0, lastUser + 1);
commit(history);
void run(history);
}, [commit, run]);
const stop = useCallback(() => {
abortRef.current?.abort();
}, []);
return { messages, status, sendMessage, regenerate, stop, setMessages: commit };
}
-112
View File
@@ -1,112 +0,0 @@
import { createLLMGateway } from '@llmgateway/ai-sdk-provider';
import { convertToModelMessages, stepCountIs, streamText, tool } from 'ai';
import { z } from 'zod';
import { source } from '@/lib/source';
import { Document, type DocumentData } from 'flexsearch';
import { $routeHandler } from 'fuma-cli/macros/route-handler';
import type { ChatUIMessage, SearchTool } from '@/components/ai-sdk/search';
interface CustomDocument extends DocumentData {
url: string;
title: string;
description: string;
content: string;
}
const searchServer = createSearchServer();
async function createSearchServer() {
const search = new Document<CustomDocument>({
document: {
id: 'url',
index: ['title', 'description', 'content'],
store: true,
},
});
const docs = await chunkedAll(
source.getPages().map(async (page) => {
if (!('getText' in page.data)) return null;
return {
title: page.data.title,
description: page.data.description,
url: page.url,
content: await page.data.getText('processed'),
} as CustomDocument;
}),
);
for (const doc of docs) {
if (doc) search.add(doc);
}
return search;
}
async function chunkedAll<O>(promises: Promise<O>[]): Promise<O[]> {
const SIZE = 50;
const out: O[] = [];
for (let i = 0; i < promises.length; i += SIZE) {
out.push(...(await Promise.all(promises.slice(i, i + SIZE))));
}
return out;
}
const llmgateway = createLLMGateway({
apiKey: process.env.LLM_GATEWAY_API_KEY,
});
/** System prompt, you can update it to provide more specific information */
const systemPrompt = [
'You are an AI assistant for a documentation site.',
'Use the `search` tool to retrieve relevant docs context before answering when needed.',
'The `search` tool returns raw JSON results from documentation. Use those results to ground your answer and cite sources as markdown links using the document `url` field when available.',
'If you cannot find the answer in search results, say you do not know and suggest a better search query.',
].join('\n');
export const handler = $routeHandler(
{
methods: ['POST'],
params: [],
},
async (req) => {
const reqJson = await req.json();
const modelId = process.env.LLM_GATEWAY_MODEL ?? 'anthropic/claude-3.5-sonnet';
const result = streamText({
model: llmgateway.chat(modelId as never),
stopWhen: stepCountIs(5),
tools: {
search: searchTool,
},
messages: [
{ role: 'system', content: systemPrompt },
...(await convertToModelMessages<ChatUIMessage>(reqJson.messages ?? [], {
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
})),
],
toolChoice: 'auto',
});
return result.toUIMessageStreamResponse();
},
);
const searchTool = tool({
description: 'Search the docs content and return raw JSON results.',
inputSchema: z.object({
query: z.string(),
limit: z.number().int().min(1).max(100).default(10),
}),
async execute({ query, limit }) {
const search = await searchServer;
return await search.searchAsync(query, { limit, merge: true, enrich: true });
},
}) satisfies SearchTool;
-22
View File
@@ -1,22 +0,0 @@
import { createOpenAPI } from '@hanzo/docs/openapi/server';
import fs from 'node:fs';
import { DOCUMENT } from '@/scripts/openapi-doc';
const isExport = process.env.NEXT_EXPORT === '1';
// ONE document. The interactive loader reads exactly the file the static
// reference is generated from, so dev and the export never disagree — which
// means it reads the path through `openapi-doc`, the one module that resolves
// it, rather than spelling a filename the lock is free to change.
const document = DOCUMENT;
// Load the document when it is present (fetched by sync-openapi.sh). During
// static export the generated API pages contain slugs with dots and deeply
// nested paths that break Next.js prerendering / file-copy, so we skip them
// entirely in that mode and serve the generated MDX instead.
const specFiles = !isExport && fs.existsSync(document) ? [document] : [];
export const hasSpecs = specFiles.length > 0;
export const openapi = hasSpecs
? createOpenAPI({ input: specFiles, proxyUrl: '/api/proxy' })
: (null as any);
-110
View File
@@ -1,110 +0,0 @@
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai';
import { z } from 'zod';
import { source } from '@/lib/source';
import { Document, type DocumentData } from 'flexsearch';
import { $routeHandler } from 'fuma-cli/macros/route-handler';
import { ChatUIMessage, SearchTool } from '@/components/ai-sdk/search';
interface CustomDocument extends DocumentData {
url: string;
title: string;
description: string;
content: string;
}
const searchServer = createSearchServer();
async function createSearchServer() {
const search = new Document<CustomDocument>({
document: {
id: 'url',
index: ['title', 'description', 'content'],
store: true,
},
});
const docs = await chunkedAll(
source.getPages().map(async (page) => {
if (!('getText' in page.data)) return null;
return {
title: page.data.title,
description: page.data.description,
url: page.url,
content: await page.data.getText('processed'),
} as CustomDocument;
}),
);
for (const doc of docs) {
if (doc) search.add(doc);
}
return search;
}
async function chunkedAll<O>(promises: Promise<O>[]): Promise<O[]> {
const SIZE = 50;
const out: O[] = [];
for (let i = 0; i < promises.length; i += SIZE) {
out.push(...(await Promise.all(promises.slice(i, i + SIZE))));
}
return out;
}
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
/** System prompt, you can update it to provide more specific information */
const systemPrompt = [
'You are an AI assistant for a documentation site.',
'Use the `search` tool to retrieve relevant docs context before answering when needed.',
'The `search` tool returns raw JSON results from documentation. Use those results to ground your answer and cite sources as markdown links using the document `url` field when available.',
'If you cannot find the answer in search results, say you do not know and suggest a better search query.',
].join('\n');
export const handler = $routeHandler(
{
methods: ['POST'],
params: [],
},
async (req) => {
const reqJson = await req.json();
const result = streamText({
model: openrouter.chat(process.env.OPENROUTER_MODEL ?? 'anthropic/claude-3.5-sonnet'),
stopWhen: stepCountIs(5),
tools: {
search: searchTool,
},
messages: [
{ role: 'system', content: systemPrompt },
...(await convertToModelMessages<ChatUIMessage>(reqJson.messages ?? [], {
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
})),
],
toolChoice: 'auto',
});
return result.toUIMessageStreamResponse();
},
);
const searchTool = tool({
description: 'Search the docs content and return raw JSON results.',
inputSchema: z.object({
query: z.string(),
limit: z.number().int().min(1).max(100).default(10),
}),
async execute({ query, limit }) {
const search = await searchServer;
return await search.searchAsync(query, { limit, merge: true, enrich: true });
},
}) satisfies SearchTool;
+3 -5
View File
@@ -1,7 +1,6 @@
import { type LoaderPlugin, loader } from '@hanzo/docs-core/source';
import { blog as blogPosts, docs } from 'collections/server';
import { createSource } from '@hanzo/docs-mdx/runtime/server';
import { lucideIconsPlugin } from '@hanzo/docs-core/source/lucide-icons';
import { inlineCode } from '@/lib/inline-code';
// ONE REFERENCE.
@@ -29,10 +28,9 @@ export const source = loader(
},
{
baseUrl: '/docs',
plugins: [
pageTreeCodeTitles(),
lucideIconsPlugin(),
],
// No icon plugin: the rail draws no icons, and each one it resolved was an
// SVG element tree serialised into every page's copy of the tree.
plugins: [pageTreeCodeTitles()],
},
);
+1
View File
@@ -71,6 +71,7 @@
"react-force-graph-2d": "catalog:",
"react-native-web": "catalog:",
"rehype-katex": "catalog:",
"rehype-raw": "catalog:",
"remark": "catalog:",
"remark-gfm": "catalog:",
"remark-math": "catalog:",
-12
View File
@@ -1,12 +0,0 @@
import { compile, writeRegistry } from '@hanzo/docs-cli/build';
import { compileOptions, registry } from '@/components/registry/index.js';
export async function buildRegistry() {
const all = await compile({
root: registry,
...compileOptions,
});
await writeRegistry(all, {
dir: 'public/registry',
});
}
+4 -5
View File
@@ -269,7 +269,6 @@ function render(p: Pricing): string {
)}`,
'---',
'',
"import { Sparkle, Boxes, Key, Code } from 'lucide-react'",
'',
'# Pricing',
'',
@@ -432,10 +431,10 @@ function render(p: Pricing): string {
'## Next',
'',
'<Cards>',
' <Card icon={<Boxes />} title="Models" href="/docs/models" description="The live catalogue — every model id and its current rate." />',
' <Card icon={<Sparkle />} title="Enso" href="/docs/models/enso" description="How the router picks a model, and the enso-* tiers." />',
' <Card icon={<Key />} title="API keys" href="/docs/api-keys" description="Mint the key these requests are billed against." />',
' <Card icon={<Code />} title="Pricing API" href="/docs/openapi/pricing" description="Every pricing endpoint, generated from the spec." />',
' <Card icon="Boxes" title="Models" href="/docs/models" description="The live catalogue — every model id and its current rate." />',
' <Card icon="Sparkle" title="Enso" href="/docs/models/enso" description="How the router picks a model, and the enso-* tiers." />',
' <Card icon="Key" title="API keys" href="/docs/api-keys" description="Mint the key these requests are billed against." />',
' <Card icon="Code" title="Pricing API" href="/docs/openapi/pricing" description="Every pricing endpoint, generated from the spec." />',
'</Cards>',
'',
`*Per-token rates and context windows read from \`/v1/models\`. Everything` +
-2
View File
@@ -1,4 +1,3 @@
import { buildRegistry } from '@/scripts/build-registry';
import { genOpenapiPages } from './gen-openapi-pages';
import { genFlowPages } from './gen-flow-pages';
import { genMcpPages } from './gen-mcp-pages';
@@ -58,7 +57,6 @@ async function main() {
// reads /v1/pricing, so it neither needs nor blocks the three above.
await genPricingPage();
await buildRegistry();
// Ported project docs are NOT fetched here. They are a committed snapshot
// (content/docs/projects/, tracked), refreshed on purpose by
// `pnpm --filter docs sync:projects` and read in a diff before it ships. A
+1 -1
View File
@@ -181,7 +181,7 @@ export const blog = defineCollections({
id: 'package-manager',
},
},
remarkPlugins: isLint ? [remarkElementIds] : [remarkSteps, remarkParts],
remarkPlugins: isLint ? [remarkElementIds] : [remarkTitle, remarkSteps, remarkParts],
})(environment);
},
});
+9 -3
View File
@@ -1,7 +1,11 @@
'use client';
import { AnalyticsProvider, usePageview } from '@hanzo/event/react';
/** The client, for a page that sends an event of its own. */
export { useAnalytics } from '@hanzo/event/react';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
/** The ONE Hanzo Cloud telemetry front door — POST api.hanzo.ai/v1/event. Cloud
* fans the one batched stream out to the web (analytics), product (insights) and
@@ -36,9 +40,10 @@ function Pageview() {
}
/**
* Telemetry for a Hanzo docs site — mount once in the root layout:
* Telemetry for a Hanzo docs site — mount once in the root layout, around the
* app when a page sends events of its own (`useAnalytics` from @hanzo/event/react):
*
* <Analytics product="zen-docs" />
* <Analytics product="zen-docs">{app}</Analytics>
*
* The provider owns the ONE @hanzo/event client: it fires the first pageview,
* registers auto error capture (window.onerror + unhandledrejection), and flushes
@@ -47,12 +52,13 @@ function Pageview() {
* here is docs-specific except the default host and consent gate — `product` is
* the only knob, because Cloud derives everything else server-side.
*/
export function Analytics({ product }: { product: string }) {
export function Analytics({ product, children }: { product: string; children?: ReactNode }) {
return (
<AnalyticsProvider
config={{ product, host: HOST, ingestKey: INGEST_KEY, enabled: consented() }}
>
<Pageview />
{children}
</AnalyticsProvider>
);
}
+51 -48
View File
@@ -1464,6 +1464,9 @@ importers:
rehype-katex:
specifier: 'catalog:'
version: 7.0.1
rehype-raw:
specifier: 'catalog:'
version: 7.0.0
remark:
specifier: 'catalog:'
version: 15.0.1([email protected])
@@ -21973,7 +21976,7 @@ snapshots:
'@astrojs/[email protected]':
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
js-yaml: 4.1.1
picomatch: 4.0.4
@@ -31355,7 +31358,7 @@ snapshots:
'@shikijs/engine-oniguruma': 1.17.7
'@shikijs/types': 1.17.7
'@shikijs/vscode-textmate': 9.3.1
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/[email protected]':
@@ -31363,7 +31366,7 @@ snapshots:
'@shikijs/primitive': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/[email protected]':
@@ -31419,7 +31422,7 @@ snapshots:
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@shikijs/[email protected]':
dependencies:
@@ -31465,12 +31468,12 @@ snapshots:
'@shikijs/[email protected]':
dependencies:
'@shikijs/vscode-textmate': 9.3.1
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@shikijs/[email protected]':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@shikijs/[email protected]':
dependencies:
@@ -35436,12 +35439,12 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-is-element: 3.0.0
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-embedded: 3.0.0
hast-util-minify-whitespace: 1.0.1
hast-util-phrasing: 3.0.1
@@ -35451,20 +35454,20 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hastscript: 9.0.1
web-namespaces: 2.0.1
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-from-dom: 5.0.1
hast-util-from-html: 2.0.3
unist-util-remove-position: 5.0.0
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
@@ -35473,7 +35476,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
@@ -35484,19 +35487,19 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-embedded: 3.0.0
hast-util-is-element: 3.0.0
hast-util-whitespace: 3.0.0
@@ -35504,11 +35507,11 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-embedded: 3.0.0
hast-util-has-property: 3.0.0
hast-util-is-body-ok-link: 3.0.1
@@ -35516,9 +35519,9 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.1
'@ungap/structured-clone': 1.4.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
@@ -35532,8 +35535,8 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.1
'@types/hast': 3.0.5
'@ungap/structured-clone': 1.4.0
unist-util-position: 5.0.0
[email protected]:
@@ -35580,7 +35583,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
@@ -35634,7 +35637,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
@@ -35648,18 +35651,18 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
@@ -36404,7 +36407,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
devlop: 1.1.0
highlight.js: 11.11.1
@@ -36663,7 +36666,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
longest-streak: 3.1.0
@@ -36675,7 +36678,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
longest-streak: 3.1.0
@@ -36688,7 +36691,7 @@ snapshots:
[email protected]:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
@@ -36699,7 +36702,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3([email protected])
@@ -36710,7 +36713,7 @@ snapshots:
[email protected]:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
@@ -36727,7 +36730,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
@@ -36764,7 +36767,7 @@ snapshots:
[email protected]:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
@@ -36775,7 +36778,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3([email protected])
@@ -39012,7 +39015,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
'@types/prismjs': 1.26.6
hastscript: 9.0.1
parse-entities: 4.0.2
@@ -39072,8 +39075,8 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.1
'@types/hast': 3.0.5
'@ungap/structured-clone': 1.4.0
hast-util-is-element: 3.0.0
is-absolute-url: 4.0.1
space-separated-tokens: 2.0.2
@@ -39081,7 +39084,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-format: 1.1.0
[email protected]:
@@ -39096,20 +39099,20 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-from-html: 2.0.3
unified: 11.0.5
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-raw: 9.1.0
vfile: 6.0.3
[email protected]:
dependencies:
'@types/estree': 1.0.9
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-to-estree: 3.1.3
transitivePeerDependencies:
- supports-color
@@ -39117,14 +39120,14 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/estree': 1.0.9
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-to-estree: 3.1.3([email protected])
transitivePeerDependencies:
- supports-color
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
hast-util-sanitize: 5.0.2
[email protected]:
@@ -39135,7 +39138,7 @@ snapshots:
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/hast': 3.0.5
rehype-parse: 9.0.1
rehype-stringify: 10.0.1
unified: 11.0.5
@@ -39868,7 +39871,7 @@ snapshots:
'@shikijs/engine-oniguruma': 1.17.7
'@shikijs/types': 1.17.7
'@shikijs/vscode-textmate': 9.3.1
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies:
@@ -39879,7 +39882,7 @@ snapshots:
'@shikijs/themes': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@types/hast': 3.0.5
[email protected]:
dependencies: