Draw the docs chrome with @hanzo/gui

The rail, the tree, the bar, the outline and the page header were
Fumadocs' base-ui layout: Tailwind utilities over a CSS grid, with the
width decisions made twice, once in classes and once in a JS media
query.  They are gui stacks on theme tokens now, with every breakpoint a
media prop, so a phone hydrates the markup the export wrote.  Atomic
rules ship as hoisted <style> tags and <html> carries t_dark, so a page
is styled before any script runs.

The theme has one authority, the t_dark/t_light class that gui's
NextThemeProvider writes before paint; <Hanzo> follows it and the
Tailwind dark variant answers it.  The corner dock that covered content
on phones is gone; theme and help sit at the foot of the rail.

A body's leading "# heading" is dropped at compile time, since the
header already prints the frontmatter title: /docs/sdks and
/docs/openapi said their names twice.  The /docs hero stops printing a
second title.

The rail narrows by tree path, so an operation page opens the reference
it belongs to, and the drawer carries the top nav a phone had no way to
reach.

LLM.md carries the four-phase plan and the inventory it is measured by.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
This commit is contained in:
2026-09-27 14:15:45 -07:00
parent 4526190653
commit f11eb49dc2
50 changed files with 1738 additions and 1706 deletions
+71 -91
View File
@@ -72,9 +72,9 @@ at docs.hanzo.ai/docs/contributing/docs-architecture). Summary:
exist, which is why a single-tool MCP product is published as a page and not a
directory. **API reference and
SDKs are separated surfaces** (`/docs/openapi`, `/docs/sdks`) reached via the
top nav (`components/layouts/shared.tsx`), deliberately NOT in the root
top nav (`lib/nav.ts`), deliberately NOT in the root
sidebar descent. The landing (`app/(landing)/page.tsx` — its own route group,
because it carries the docs chrome from `components/layouts/docs.tsx` while
because it carries the docs chrome from `components/shell.tsx` while
`(home)` keeps the marketing chrome for /blog) + docs home
(`content/docs/index.mdx`) lead with the decentralized spine ("the AI cloud
you can run yourself"); `content/docs/network.mdx` = self-host/hanzo.network,
@@ -288,44 +288,36 @@ export const source = loader({
});
```
### Page Component
```tsx
import { DocsPage, DocsBody } from '@hanzo/docs-ui/layouts/docs/page';
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
```
### The chrome is @hanzo/gui (apps/docs)
### The docs grid is three columns
Every docs page sits in `components/shell.tsx`: the rail (`rail.tsx` — brand,
search, `tree.tsx`, account and theme at the foot), and a column holding the bar
(`bar.tsx`) and the page (`page.tsx` — breadcrumb, title, description, the
"Use agent" menu `agent.tsx`, body, previous/next; `toc.tsx` beside it). All of
it is gui stacks and text on theme tokens; no class names.
`layouts/docs/slots/container.tsx` (both UI packages — they are parallel forks,
so a change to one without the other diverges them): `var(--fd-sidebar-col)
minmax(0, 1fr) var(--fd-toc-width)`. Rails pin to the edges, the page takes what
is between them.
It was five columns, with `minmax(min-content, 1fr)` gutters centring a band
capped at `--fd-layout-width`, and the sidebar AREA spanned the leading gutter as
well as its own column. Measured on the live site at `/docs/api-keys`: at 1920
the columns were `176.5 232 1052 268 176.5` and the nav — 232px, right-aligned in
its area by `items-end` — began 176px from the left edge; at 2560 it began 496px
in. The empty strip was inside the sidebar's own bordered, filled card, so every
pixel a wider display gained went there. Now `232 1405 268` and `232 2045 268`,
nav at x=0.
`--fd-layout-width` no longer bounds this grid. It was doing two jobs at once —
bounding the reading measure and bounding the band — and the page slot already
owns the first (`max-w-[900px]`), so what was left was only the job that made the
gap. The article therefore stays 900px and does not move; what moved is the nav
(flush left) and the toc (flush right), and the page COLUMN absorbed the gutters.
Widening the article past 900px is a separate typography decision, untaken.
Narrow viewports are unchanged by construction: below `md`, `--fd-sidebar-width`
is 0 and the sidebar is a fixed drawer outside the grid, and `--fd-toc-width` is
0 until a toc exists.
### Layouts
- `@hanzo/docs-ui/layouts/docs` - Standard docs layout
- `@hanzo/docs-ui/layouts/home` - Homepage layout
- `@hanzo/docs-ui/layouts/notebook` - Notebook layout
- `@hanzo/docs-ui/layouts/flux` - Alternative flux layout (new)
- **Widths are media props, never JS.** `$max-md`, `$xl` compile to CSS media
rules, so the markup a phone hydrates is the markup the export wrote. Rail
264px, bar 56px, article 880px with the outline 240px beside it from 1280px;
below 768px the rail is a Sheet opened from the bar.
- **First paint is styled with no script.** gui emits each component's atomic
rules as `<style precedence>` during SSR, React hoists them, and `<html>` ships
`t_dark`. Check it: render any page with JavaScript off.
- **One theme authority: `t_dark`/`t_light` on `<html>`.** gui's
`NextThemeProvider` (`@hanzogui/next-theme`) writes it before paint and
`<Hanzo theme>` follows it (`app/provider.tsx`). The Tailwind `dark:` variant
and base-ui's shiki rules answer `.t_dark` until they are gone.
- **Muted text is `lib/ink.ts`.** gui's grey ramp is not symmetric (light step 10
is #333), so muted names step 10 dark / 9 light. Chrome text measures >= 7.5:1
in both themes.
- **gui v5 is shorthand-only.** `bg p px items justify rounded minW maxH t z`
exist; `w`/`h` do NOT (use `width`/`height`) — an unknown prop is dropped
silently and the box takes its content's size.
- **One title per page.** The header prints the frontmatter `title`;
`lib/remark-title.ts` drops a body's leading `# heading`.
- **The tree narrows by path, not address.** `lib/tree.tsx` resolves an
operation page (not in the tree) to its product page, so the rail opens the
reference it belongs to.
## Build
@@ -345,69 +337,57 @@ Build tool: `tsdown` (all packages except `hanzo-docs` wrapper which uses `tsup`
- Next.js 15-16+ with App Router
- React 19+
- Tailwind CSS 4+ (see "UI framework debt" — this is the thing being removed)
- Tailwind CSS 4+ (being removed — "Moving onto @hanzo/gui")
- pnpm 10+
## UI framework debt — where the tailwind actually is
## Moving onto @hanzo/gui — the plan
House rule is that `@hanzo/gui` is the only UI framework: it compiles to React
Native, so an app built on it runs on web, iOS and Android. Tailwind classes and
Radix primitives are DOM-only and cap an app at the browser. This repo is the
furthest thing from compliant, and the reason is structural, so measure before
planning anything.
House rule: `@hanzo/gui` is the only UI framework (it renders on web, iOS and
Android; Tailwind classes and Radix/Base UI are DOM-only). docs.hanzo.ai is being
moved onto it in four phases, each shipped to main when verified live.
**Tailwind here is LIVE, not inert.** 21 apps each carry a real
`postcss.config.mjs` with `@tailwindcss/postcss`, 60 CSS entrypoints begin
`@import "tailwindcss"`, and 66 package.json files declare a tailwind dep. Two of
those entrypoints — `packages/radix-ui/css/style.css` and
`packages/base-ui/css/style.css` — ship inside the published npm tarballs, so the
framework's *product* is tailwind. Counted with a utility-token regex over tracked
files: **39,433 class tokens across 537 source files.**
**Inventory of what docs.hanzo.ai renders** (Tailwind's own scanner, counting only
tokens Tailwind would emit CSS for, in class contexts; `content/docs/projects/**`
excluded — it is upstream's):
**The blocker is that the UI layer is two parallel forks of Fumadocs.**
`packages/radix-ui` publishes as `@hanzo/docs-ui` (Radix) and `packages/base-ui`
as `@hanzo/docs-base-ui` (Base UI) — ~110 files / 13.5k lines each, the same
components twice, kept in step by `.cursor/skills/radix-base-ui-sync`. Both names
are banned by the house rule, `apps/docs` depends on **both at once**, and every
app and example in the workspace consumes them. Complying means rebuilding that
layer once on `@hanzo/gui` and deleting both — a Fumadocs rewrite, not a patch.
Do not start it piecemeal: swapping one primitive inside a component whose markup
is still tailwind lowers a grep count and changes nothing real.
| Area | Tailwind tokens / files | Radix files | Base UI files |
|---|---|---|---|
| `apps/docs` app+components+lib, before phase 1 | 2,189 / 52 | 2 | 0 |
| `apps/docs` after phase 1 | 1,906 / 37 | 2 | 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 |
| `packages/story` / `twoslash` | 212 / 4, 3 / 1 | 1, 1 | 0 |
| authored MDX (`content/docs`, mostly code samples) | 63 / 56 | 0 | 0 |
**Radix → @hanzo/gui primitive map** (checked against `~/work/hanzo/gui/pkgs/ui`,
not guessed). Present: `presence`→`animate-presence`, `popover`, `select`,
`dialog`, `accordion`, `tabs`, `collapsible`, `tooltip`, `direction`→
`core/use-direction`. **Genuinely missing: `navigation-menu` and `scroll-area`**
(`scroll-view` is the React Native scroller, not a custom-scrollbar area). Those
two must be built in `@hanzo/gui` before `packages/radix-ui` can be retired.
`collapsible` exists but `apps/gui-docs` has no page for it — a docs gap here,
not a missing primitive.
Plus the CSS: `app/global.css` imports `tailwindcss`, the base-ui, openapi,
story and twoslash presets, and `tailwindcss-animate`.
**`@zenlm/ui` ships classes that style nothing.** `packages/zenlm-ui` publishes
`dist/` as JS + d.ts with **no CSS at all**, declares no tailwind dep and has no
postcss config, yet styles its four components entirely with tailwind utilities
(and shadcn's `bg-muted` / `text-muted-foreground` tokens). Inside `apps/zen-docs`
they happen to resolve because that app compiles tailwind; for any other npm
consumer they are dead strings. Note `apps/zen-docs` pins `@zenlm/ui: ^1.0.6` from
the registry, not `workspace:*`, so pnpm 11 (`link-workspace-packages` defaults to
false) serves the published tarball — editing `packages/zenlm-ui` does not change
what zen-docs renders until a release. Converting it to `@hanzo/gui` style props
is the highest-value next increment and fixes the inertness by construction,
because style props travel with the component.
**What replaces what.** Layout, rail, bar, outline and page header: our own gui
stacks (done). Drawer: `@hanzo/ui` Sheet. Menus: `@hanzo/ui` DropdownMenu.
Folds: plain state + gui stacks. Icons: `@hanzogui/lucide-icons-2`. Theme:
`@hanzogui/next-theme`. Tree/TOC logic stays in `@hanzo/docs-core` (headless).
| Phase | Scope | Replaces | Status |
|---|---|---|---|
| 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 |
| 2 | MDX components: Callout, Cards, Tabs, Steps, Accordion, CodeBlock (+ shiki vars), Files, TypeTable, Banner, Heading, ImageZoom, InlineTOC, link hover card; `getMDXComponents` ours; the `@hanzo/docs-base-ui/components/*` imports in content aliased to ours | base-ui/radix components, `twoslash` popover, openapi UI | next |
| 3 | Page bodies and app pages: prose typography, landing, catalogs, product sections, feedback, search dialog + AI search, 404, blog (HomeLayout), login/callback | Tailwind typography, base-ui search dialog, `RootProvider` | |
| 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`
without depending on it, so in this workspace the augmentation lands on the
hoisted 8.1.1 copy while `@hanzo/gui` 8.3.5 uses 8.3.5 — every gui shorthand
fails typecheck here (runtime is correct; `ignoreBuildErrors` is on). The fix is
@hanzo/ui declaring `@hanzogui/web`/`core` as dependencies of the same train.
**Out of scope when counting:** `content/docs/projects/**` is a snapshot of
upstream repos, so tailwind there is upstream's and a refresh overwrites edits.
Two more grep hits are not tailwind at all —
the Java/Spring IAM guide uses **Bootstrap** in a Thymeleaf template, and the IAM
login-customization guide uses custom class names with their own `<style>` blocks.
upstream repos. The Java/Spring IAM guide uses Bootstrap in a Thymeleaf template
and the IAM login-customization guide uses its own `<style>` blocks — not Tailwind.
**Shadcn is gone** (`packages/shadcn` + `examples/next-shadcn` deleted). It
existed to emit `npx shadcn@latest add ...` into rendered docs, which puts a
DOM-only library in the reader's app.
**Authored docs are converted.** The commerce recipes/storefront pages and
`zen5.mdx` teach `@hanzo/gui` now. Keep it that way: a snippet teaching tailwind
produces tailwind in someone's app, so examples count as shipping surface.
**`@zenlm/ui` ships classes that style nothing** (`packages/zenlm-ui`: Tailwind
utilities, no CSS, no Tailwind dep); `apps/zen-docs` pins the published tarball.
Converting it to gui style props fixes it by construction.
### Baseline when touching this repo
+1 -1
View File
@@ -39,7 +39,7 @@ export default function Layout({ children }: LayoutProps<'/'>) {
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="dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
<HanzoPreFooterCTA surface="hanzo.ai" />
</div>
<Footer />
+13 -36
View File
@@ -1,47 +1,24 @@
import { Docs } from '@/components/layouts/docs';
import { Shell } from '@/components/shell';
import { HanzoPreFooterCTA } from '@hanzogui/shell';
import { Footer } from '@/components/footer';
// The landing page carries the SAME chrome as /docs — page tree, sidebar
// filter, project switcher.
// 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.
//
// It sat under (home)'s HomeLayout, which has no tree and therefore no sidebar:
// a reader arriving at / saw eight domain cards and had to click one before the
// site would show them what it contains. The sidebar is the table of contents
// for ~1,600 pages, and the landing page is where it is worth the most.
//
// / gets its own route group because (home) also wraps /blog, an editorial
// page with no place in the docs tree, so it keeps the marketing chrome — and
// (home) keeps its name, which reads as "the pages using HomeLayout", the
// upstream component's own vocabulary.
// 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 (
<>
{/* `dark` on the route, because this page paints its own palette in
literal white-on-neutral rather than fd- tokens and only reads on a
dark ground. Hiding the theme switch does not make it dark: the class
is stored globally, so a reader who chose light on /docs and clicked
the wordmark arrived at a 72px white headline on a white page. `.dark`
is a plain class selector, so nesting it redefines the tokens for
everything below — chrome included, which is why it sits here and not
on the page's own <main>.
@hanzo/ui's own ramp has no `.dark` (dark is its :root, light its
`.light`), so in here a light reader's --text-primary stayed #0a0a0a and
its h1–h4 printed black on #050505. The div names the site's foreground
as that token, and the pre-footer's glass sits on this ground, not on
the white body. */}
{/* THE TREE IS THE POINT, so it starts open here as it does everywhere
else. It started collapsed on the argument that a reader who typed the
domain has not asked for a table of contents — but the landing page is
exactly where somebody does not yet know what is here, and a collapsed
rail answers that by showing nothing. It also cost the page its
wordmark: the sidebar renders it, so collapsing the sidebar rendered no
brand at all. One state, every route. */}
<div className="dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
<Docs themeSwitch={{ enabled: false }} sidebar={{ defaultCollapsed: false }}>
<Shell>
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
{children}
</Docs>
</div>
</Shell>
<div className="t_dark bg-fd-background text-fd-foreground [--text-primary:var(--color-fd-foreground)]">
<HanzoPreFooterCTA surface="hanzo.ai" />
</div>
<Footer />
+2 -2
View File
@@ -17,7 +17,7 @@ import {
} from 'lucide-react';
import { Card, CardContent, CardTitle, XStack } from '@hanzo/ui';
import { Grid } from '@hanzo/ui/grid';
import { AgentActions } from '@/components/agent-actions';
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';
@@ -225,7 +225,7 @@ export default function Page() {
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 }}>
<AgentActions />
<Agent />
<p className="text-xs text-neutral-400">
Or hand this to your agent — it installs the CLI, the MCP server and
the{' '}
+19 -30
View File
@@ -12,18 +12,14 @@ import { Mermaid } from '@/components/mdx/mermaid';
import { PageFeedback, PageFeedbackBlock } from '@/components/feedback';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@hanzo/docs-base-ui/components/ui/hover-card';
import Link from '@hanzo/docs-core/link';
import { findSiblings } from '@hanzo/docs-core/page-tree';
import { Card, Cards } from '@hanzo/docs-base-ui/components/card';
import { getMDXComponents } from '@/components/mdx';
import { Banner } from '@hanzo/docs-base-ui/components/banner';
import { AgentActions } from '@/components/agent-actions';
import { Installation } from '@/components/preview/installation';
import { Customization } from '@/components/preview/customization';
import {
DocsBody,
DocsPage,
PageLastUpdate,
} from '@hanzo/docs-base-ui/layouts/docs/page';
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';
@@ -52,31 +48,21 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
);
const { body: Mdx, toc, lastModified } = await page.data.load();
const tree = source.getPageTree();
const { previous, next } = findNeighbour(tree, page.url);
return (
<DocsPage
<Frame
title={page.data.title}
description={inlineCode(page.data.description)}
crumbs={getBreadcrumbItems(page.url, tree)}
toc={toc}
tableOfContent={{
style: 'clerk',
// At the HEAD of the right rail, not floating in the bar above the
// article. The header is its own grid area — "sidebar header toc" — so
// it stops exactly where the TOC column starts, and a control rendered
// there can never line up with the column beside it however it is
// padded. Put in the TOC's own header slot it shares that column: its
// left edge is "On this page"'s left edge, and the two read as one rail.
//
// It also follows the rail rather than the bar: below xl the TOC folds
// into a popover and this goes with it, which is where a reader on a
// narrow window looks for what acts on the page.
header: <AgentActions />,
}}
previous={previous && { name: previous.name, url: previous.url }}
next={next && { name: next.name, url: next.url }}
updated={lastModified ? DAY.format(new Date(lastModified)) : undefined}
>
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
<p className="text-lg text-fd-muted-foreground mb-2">{inlineCode(page.data.description)}</p>
{/* No page actions under the title: AgentActions is one control at the
head of the right rail, reachable from every page rather than only
from under a heading, and one row of chrome instead of two. */}
<div className="border-b pb-6" />
{/* The body is still Tailwind's typography until the MDX components move
onto @hanzo/gui (LLM.md, "Moving onto @hanzo/gui", phase 3). */}
<div className="prose flex-1 text-fd-foreground/90">
{page.data.preview && <PreviewRenderer preview={page.data.preview} />}
<MdxErrorBoundary>
@@ -126,11 +112,14 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
{page.data.index ? <DocsCategory url={page.url} /> : null}
</div>
<PageFeedback />
{lastModified && <PageLastUpdate date={lastModified} />}
</DocsPage>
</Frame>
);
}
// One formatter, fixed locale and zone, so the export and the browser print the
// same date and hydration has nothing to disagree about.
const DAY = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
function DocsCategory({ url }: { url: string }) {
return (
<Cards>
-6
View File
@@ -1,6 +0,0 @@
import { FluxLayout } from '@/components/layouts/flux';
import 'katex/dist/katex.min.css';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return <FluxLayout>{children}</FluxLayout>;
}
+3 -10
View File
@@ -1,19 +1,12 @@
import { Docs } from '@/components/layouts/docs';
import { Shell } from '@/components/shell';
import { Footer } from '@/components/footer';
import 'katex/dist/katex.min.css';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<>
<Docs>{children}</Docs>
{/* The docs grid ends exactly where the footer begins — measured at a 0px
seam — so the prev/next cards sat flush against the ecosystem columns
with nothing between them. The room belongs here rather than on the
footer, which is byte-identical across every Hanzo property, or on the
article, which would put it inside the scrolling column. */}
<div className="pt-16 md:pt-24">
<Footer />
</div>
<Shell>{children}</Shell>
<Footer />
</>
);
}
+14 -158
View File
@@ -7,6 +7,11 @@
@import '@hanzo/docs-story/css/preset.css';
@plugin 'tailwindcss-animate';
/* `dark:` answers gui's theme class, the one the theme script writes on <html>
and the landing sets on its own subtree. The preset's variant names `.dark`,
which nothing writes any more. */
@custom-variant dark (&:where(.t_dark, .t_dark *));
/* Zen. The faces are declared by the import above; these name the role. */
:root {
--font-sans: 'Zen', ui-sans-serif, system-ui, sans-serif;
@@ -25,7 +30,7 @@
--color-landing-foreground-200: #525252;
}
.dark {
.t_dark {
--color-landing-foreground: #f5f5f5;
--color-landing-foreground-200: #a3a3a3;
@@ -94,8 +99,11 @@ html > body[data-scroll-locked] {
--removed-body-scroll-bar-size: 0px !important;
}
/* The ground and ink are the palette's, and match gui's `$background`, so the
chrome and the page body sit on one ground in both themes. */
body {
background-color: var(--color-fd-background);
color: var(--color-fd-foreground);
}
/* The light theme. This block used to hold the DARK palette, with `.dark` below
@@ -105,7 +113,7 @@ body {
not a new hue. */
:root {
color-scheme: light;
--color-fd-background: hsl(0, 0%, 100%);
--color-fd-background: #f7f7f7;
--color-fd-foreground: hsl(0, 0%, 4%);
--color-fd-muted: hsl(0, 0%, 96%);
--color-fd-muted-foreground: hsla(0, 0%, 28%, 0.9);
@@ -113,7 +121,7 @@ body {
--color-fd-popover-foreground: hsl(0, 0%, 10%);
--color-fd-card: hsl(0, 0%, 99%);
--color-fd-card-foreground: hsl(0, 0%, 4%);
--color-fd-border: hsla(0, 0%, 0%, 0.12);
--color-fd-border: rgb(0 0 0 / 0.1);
--color-fd-primary: hsl(0, 0%, 8%);
--color-fd-primary-foreground: hsl(0, 0%, 98%);
--color-fd-secondary: hsl(0, 0%, 96%);
@@ -124,9 +132,9 @@ body {
--color-fd-overlay: hsla(0, 0%, 0%, 0.2);
}
.dark {
.t_dark {
color-scheme: dark;
--color-fd-background: hsl(0, 0%, 2%);
--color-fd-background: #0a0a0a;
--color-fd-foreground: hsl(0, 0%, 96%);
--color-fd-muted: hsl(0, 0%, 10%);
--color-fd-muted-foreground: hsla(0, 0%, 70%, 0.85);
@@ -134,7 +142,7 @@ body {
--color-fd-popover-foreground: hsl(0, 0%, 90%);
--color-fd-card: hsl(0, 0%, 4%);
--color-fd-card-foreground: hsl(0, 0%, 96%);
--color-fd-border: hsla(0, 0%, 40%, 0.2);
--color-fd-border: rgb(255 255 255 / 0.1);
--color-fd-primary: hsl(0, 0%, 96%);
--color-fd-primary-foreground: hsl(0, 0%, 8%);
--color-fd-secondary: hsl(0, 0%, 12%);
@@ -203,155 +211,3 @@ footer[data-hanzo-shell] {
transform: translateX(212px);
}
}
/* ── Help and appearance dock ────────────────────────────────────────────────
The bottom-right mark hanzo.ai carries, on the docs' own tokens. Fixed to the
viewport corner, clear of the safe area; the panel is a card with a ground
of its own so it composites over any page. */
.help-dock {
position: fixed;
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
z-index: 40;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.625rem;
}
/* Small on purpose: it is an affordance, not an action. 28px is under the 44px
coarse-pointer floor, so it carries its own touch target as a pseudo-element
rather than growing. */
.help-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--color-fd-border);
background: var(--color-fd-card);
color: var(--color-fd-muted-foreground);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.help-button::after {
content: '';
position: absolute;
inset: -8px;
border-radius: inherit;
}
.help-button:hover,
.help-button:focus-visible {
color: var(--color-fd-foreground);
border-color: var(--color-fd-ring);
outline: none;
}
.help-panel {
width: min(21rem, calc(100vw - 2rem));
max-height: min(34rem, calc(100dvh - 6rem));
overflow-y: auto;
padding: 0.875rem 1rem 1rem;
border-radius: 1rem;
border: 1px solid var(--color-fd-border);
background: var(--color-fd-card);
color: var(--color-fd-card-foreground);
box-shadow: 0 16px 48px rgb(0 0 0 / 0.45);
}
.help-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.help-title {
margin: 0;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-fd-muted-foreground);
}
.help-close {
display: inline-flex;
padding: 0.25rem;
margin: -0.25rem;
border-radius: 0.375rem;
color: var(--color-fd-muted-foreground);
}
.help-close:hover {
color: var(--color-fd-foreground);
}
.help-links {
display: flex;
flex-direction: column;
padding-block: 0.25rem;
}
.help-link {
display: flex;
align-items: center;
gap: 0.375rem;
min-height: 36px;
font-size: 0.875rem;
color: var(--color-fd-foreground);
}
.help-link svg { opacity: 0.5; }
.help-link:hover svg { opacity: 0.9; }
.help-section {
margin-top: 0.5rem;
padding-top: 0.625rem;
border-top: 1px solid var(--color-fd-border);
}
.help-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 40px;
padding-block: 0.375rem;
font-size: 0.875rem;
color: var(--color-fd-foreground);
}
.help-track {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
background: var(--color-fd-secondary);
}
.help-choice {
height: 24px;
padding: 0 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
color: var(--color-fd-muted-foreground);
opacity: 0.7;
}
.help-choice[data-on] {
background: var(--color-fd-card);
color: var(--color-fd-foreground);
opacity: 1;
}
/* @hanzo/ui injects `a { color: inherit }` and `a { color: var(--text-primary) }`
outside every cascade layer, after Tailwind. Unlayered rules beat layered
utilities no matter the order, so a filled control kept its light fill from
`bg-fd-primary` and inherited the page's light foreground — the label
disappeared (Get started, Get an API key). A class on the anchor outranks
the element reset even when that reset loads later. */
a.bg-fd-primary {
color: var(--color-fd-primary-foreground);
}
a.text-fd-primary {
color: var(--color-fd-primary);
}
a.text-fd-primary-foreground {
color: var(--color-fd-primary-foreground);
}
a.text-fd-foreground {
color: var(--color-fd-foreground);
}
a.text-fd-muted-foreground {
color: var(--color-fd-muted-foreground);
}
a.text-fd-secondary-foreground {
color: var(--color-fd-secondary-foreground);
}
-36
View File
@@ -1,36 +0,0 @@
'use client';
import { useParams } from 'next/navigation';
import { type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { getSection } from '@/lib/source/navigation';
import { getMenuBarSVG } from '@hanzo/logo';
export function Body({ children }: { children: ReactNode }): React.ReactElement {
const mode = useMode();
return <body className={cn(mode, 'relative flex min-h-screen flex-col')}>{children}</body>;
}
export function useMode(): string | undefined {
const params = useParams();
const slug = params?.slug ?? [];
if (Array.isArray(slug)) return getSection(slug[0]);
}
export function HanzoDocsIcon({ className, ...props }: React.SVGProps<SVGSVGElement>) {
// The mark arrives as markup, and `getMenuBarSVG()` carries a viewBox with no
// width, height or class of its own. An <svg> sized that way does not obey the
// box it is dropped into, so at `size-5` it painted past its 20px on both axes:
// sideways over the wordmark's gap, so the two read as one glued word, and
// downward past its own centre line, so `items-center` centred a box the mark
// had already left. Sizing it to the box is what makes the row's gap and
// centring apply to the thing you can see.
return (
<div
className={cn('[&>svg]:size-full', className)}
dangerouslySetInnerHTML={{ __html: getMenuBarSVG() }}
{...(props as React.HTMLAttributes<HTMLDivElement>)}
/>
);
}
+10 -15
View File
@@ -4,15 +4,13 @@ import '@hanzo/brand/styles/variables.css';
import './global.css';
import type { Viewport } from 'next';
import { baseUrl, createMetadata } from '@/lib/metadata';
import { Body } from '@/app/layout.client';
import { Provider } from './provider';
import type { ReactNode } from 'react';
import { Zen, ZenMono } from '@hanzo/font';
import { TreeContextProvider } from '@hanzo/docs-base-ui/contexts/tree';
import { TreeProvider } from '@/lib/tree';
import { clientTree } from '@/lib/source';
import { NextProvider } from '@hanzo/docs/core/framework/next';
import { Analytics } from '@hanzo/docs-analytics';
import { AppearanceDock } from '@/components/appearance-dock';
export const metadata = createMetadata({
title: {
@@ -31,24 +29,21 @@ export const viewport: Viewport = {
],
};
// <html> ships `t_dark`, the default, so a page read before any script runs is
// already themed. The theme script swaps it for the reader's stored choice
// before the first paint, which is why the element does not warn about a class
// it did not render with.
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={`${Zen.variable} ${ZenMono.variable}`} suppressHydrationWarning>
<Body>
<html lang="en" className={`${Zen.variable} ${ZenMono.variable} t_dark`} suppressHydrationWarning>
<body>
<NextProvider>
<TreeContextProvider tree={clientTree()}>
<TreeProvider tree={clientTree()}>
<Provider>{children}</Provider>
</TreeContextProvider>
</TreeProvider>
</NextProvider>
<Analytics product="docs" />
{/*
Help and appearance, bottom-right — the same corner control hanzo.ai
carries. The Hanzo Edit launcher used to hold this corner; it is an
authoring tool on a published page, and hanzo.ai dropped it for the
same reason. A reader of the docs edits them in the repo.
*/}
<AppearanceDock />
</Body>
</body>
</html>
);
}
+4 -6
View File
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { Docs } from '@/components/layouts/docs';
import { Shell } from '@/components/shell';
import { Footer } from '@/components/footer';
import { NotFound } from '@/components/layouts/not-found';
@@ -27,12 +27,10 @@ const suggestions = [
export default function Page() {
return (
<>
<Docs>
<Shell>
<NotFound getSuggestions={async () => suggestions} />
</Docs>
<div className="pt-16 md:pt-24">
<Footer />
</div>
</Shell>
<Footer />
</>
);
}
+23 -20
View File
@@ -1,6 +1,7 @@
'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';
@@ -9,29 +10,31 @@ const SearchDialog = dynamic(() => import('@/components/layouts/search'), {
ssr: false,
});
// `<Hanzo>` is what makes a @hanzo/ui component render as anything at all: it
// carries gui's process-global config, its stylesheet, and the root theme gui
// throws without. Before it was here, this app declared @hanzo/ui and @hanzo/gui
// as dependencies and rendered zero gui classes — the same shape @zenlm/ui is in,
// where the markup names classes no rule ever defines.
// ONE authority for the theme: the `t_dark` / `t_light` class on <html>.
//
// INSIDE RootProvider, not above it. Both manage a theme, and the one that wins
// has to be the one the reader can switch: fumadocs owns the toggle in the rail
// and writes `.dark` on <html>, so gui takes the fixed dark identity and the
// document class stays fumadocs' to set. Nesting the other way puts gui's theme
// context above a subtree that never reads it.
// gui's NextThemeProvider writes it — from a script that runs before the first
// paint, so a reader who chose light never sees the dark ground flash — and gui's
// 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' });
return (
<RootProvider
search={{
SearchDialog,
}}
theme={{
defaultTheme: 'dark',
enableSystem: false,
}}
<NextThemeProvider
skipNextHead
defaultTheme="dark"
enableSystem={false}
onChangeTheme={(name) => setTheme(name === 'light' ? 'light' : 'dark')}
>
<Hanzo theme="dark">{children}</Hanzo>
</RootProvider>
<Hanzo theme={theme}>
<RootProvider search={{ SearchDialog }} theme={{ enabled: false }}>
{children}
</RootProvider>
</Hanzo>
</NextThemeProvider>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { Text, XStack } from '@hanzo/gui';
import { ChevronsUpDown } from '@hanzogui/lucide-icons-2';
import { DropdownMenu } from '@hanzo/ui';
import { Action } from '@/components/action';
import { currentUser, iam, type DocsUser } from '@/lib/iam';
import { muted } from '@/lib/ink';
const CONSOLE = 'https://console.hanzo.ai';
/**
* Who is signed in, asked once per page load and shared by the rail, the bar and
* the org chip — three questions would be three userinfo calls that can answer
* differently. Signed-out is what renders until IAM answers, so a promise that
* never settles leaves a working "Sign in" rather than a hole.
*/
let asked: Promise<DocsUser | null> | undefined;
function useUser(): [DocsUser | null, (u: DocsUser | null) => void] {
const [user, setUser] = useState<DocsUser | null>(null);
useEffect(() => {
let live = true;
(asked ??= currentUser()).then((u) => live && setUser(u));
return () => {
live = false;
};
}, []);
return [user, setUser];
}
function signOut(done: () => void) {
// The SDK owns the token store and clears every key it wrote.
iam()
.logout()
.catch(() => iam().clearTokens())
.finally(() => {
done();
window.location.reload();
});
}
/**
* The account, at the foot of the rail: sign in and get a key side by side, or
* who you are with a menu. The rail is a column, so the long label fits here
* and nowhere in the 56px bar.
*/
export function Account() {
const [user, setUser] = useUser();
if (!user)
return (
<XStack gap={8}>
<Action tone="line" flex={1} render={<Link href="/login" prefetch={false} />}>
Sign in
</Action>
<Action tone="loud" flex={2} render="a" href={CONSOLE}>
Get an API key
</Action>
</XStack>
);
const label = user.name || user.email || 'Account';
return (
<DropdownMenu
placement="top-start"
trigger={
<XStack
render="button"
type="button"
height={40}
px={8}
gap={10}
rounded="$3"
items="center"
bg="transparent"
borderWidth={0}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<Initial name={label} />
<Text flex={1} minW={0} fontSize="$2" color="$color12" numberOfLines={1} text="left">
{label}
</Text>
<ChevronsUpDown size={14} color="$color10" />
</XStack>
}
items={[
{ key: 'console', label: 'Console', onSelect: () => window.location.assign(CONSOLE) },
{ key: 'settings', label: 'Account settings', onSelect: () => window.location.assign(`${CONSOLE}/settings`) },
{ type: 'separator' },
{ key: 'out', label: 'Sign out', onSelect: () => signOut(() => setUser(null)) },
]}
/>
);
}
/**
* The same control in the bar, for when the rail is not on screen: on a phone,
* and on a desktop that collapsed it. Short labels, because the bar is one row.
*/
export function Short() {
const [user] = useUser();
if (user)
return (
<XStack render="a" href={CONSOLE} title={user.name || user.email || 'Account'} rounded={999}>
<Initial name={user.name || user.email || 'A'} />
</XStack>
);
return (
<XStack gap={8} items="center">
<Action tone="quiet" render={<Link href="/login" prefetch={false} />} $max-sm={{ display: 'none' }}>
Sign in
</Action>
<Action tone="loud" render="a" href={CONSOLE}>
API key
</Action>
</XStack>
);
}
/**
* The organization the reader acts in. It is what is wrong when a curl from
* these pages answers 403, so it stays in view. Nothing for a signed-out reader.
*/
export function Org() {
const [user] = useUser();
if (!user?.owner) return null;
return (
<Text
title={`Signed in to ${user.owner}`}
fontSize="$1"
lineHeight={16}
{...muted}
px={10}
py={4}
rounded={999}
borderWidth={1}
borderColor="$borderColor"
numberOfLines={1}
$max-md={{ display: 'none' }}
>
{user.owner}
</Text>
);
}
function Initial({ name }: { name: string }) {
return (
<XStack width={26} height={26} rounded={999} bg="$color12" items="center" justify="center" shrink={0}>
<Text fontSize={11} fontWeight="600" color="$background">
{name.slice(0, 1).toUpperCase()}
</Text>
</XStack>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import type { ComponentProps, ReactNode } from 'react';
import { Text, XStack } from '@hanzo/gui';
import { muted } from '@/lib/ink';
/**
* The two controls the chrome is made of.
*
* `Action` is a labelled button or link. Its tone is one of three:
*
* loud the one filled control on a surface — the foreground as the fill and
* the ground as the label, so the pair inverts with the theme and the
* label can never sit on a fill of its own colour
* quiet a label that lights a surface under the pointer
* line the same, with a hairline edge
*
* `Tool` is an icon-only square. Both take `render` to become a link, and a
* label, which a `Tool` has no other way to carry.
*/
type Tone = 'loud' | 'quiet' | 'line';
const FRAME = {
loud: { bg: '$color12', borderColor: '$color12', hoverStyle: { opacity: 0.86 } },
quiet: { bg: 'transparent', borderColor: 'transparent', hoverStyle: { bg: '$hover' } },
line: { bg: 'transparent', borderColor: '$borderColor', hoverStyle: { bg: '$hover' } },
} as const;
const INK = { loud: { color: '$background' }, quiet: muted, line: muted } as const;
const RING = {
outlineWidth: 2,
outlineStyle: 'solid',
outlineColor: '$outlineColor',
outlineOffset: 2,
} as const;
export function Action({
tone = 'quiet',
children,
...props
}: Omit<ComponentProps<typeof XStack>, 'children'> & { tone?: Tone; children: ReactNode }) {
return (
<XStack
height={32}
px={12}
gap={6}
rounded="$3"
borderWidth={1}
items="center"
justify="center"
cursor="pointer"
focusVisibleStyle={RING}
{...FRAME[tone]}
{...props}
>
{typeof children === 'string' ? (
<Text fontSize="$2" lineHeight={18} fontWeight="500" {...INK[tone]} numberOfLines={1}>
{children}
</Text>
) : (
children
)}
</XStack>
);
}
export function Tool({
label,
children,
...props
}: Omit<ComponentProps<typeof XStack>, 'children'> & { label: string; children: ReactNode }) {
return (
<XStack
render="button"
aria-label={label}
title={label}
width={32}
height={32}
shrink={0}
rounded="$3"
items="center"
justify="center"
cursor="pointer"
bg="transparent"
borderWidth={0}
hoverStyle={{ bg: '$hover' }}
focusVisibleStyle={RING}
{...props}
>
{children}
</XStack>
);
}
-176
View File
@@ -1,176 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { usePathname } from 'next/navigation'
import { Check, ChevronDown, Sparkle } from 'lucide-react'
import { AGENT_SETUP_PROMPT } from '@/lib/agent-setup-prompt'
/**
* One control for "give this page to an agent", in the top bar, on every page.
*
* It replaced three: a Copy Markdown button, an Open menu, and a Copy prompt
* button that existed only on the front page. Three controls for one intention
* is three things to read before you can act, and two of them were unreachable
* from the page a reader was actually on.
*
* Everything it offers is derived from the PATHNAME, so it works on any page
* without being handed the page — which is what lets it live in the bar rather
* than being rebuilt into each route.
*/
const SKILLS = 'https://hanzoskills.com'
function markdownUrlFor(pathname: string) {
// /docs/a/b -> /llms.mdx/docs/a/b/content.md, the route app/llms.mdx serves.
const clean = pathname.replace(/\/+$/, '')
return `/llms.mdx${clean || '/docs'}/content.md`
}
// The setup prompt is written once, in lib/agent-setup-prompt — a second copy
// here would be a second thing to keep true. This only adds where the reader is
// standing and where the skills live.
function promptFor(url: string) {
return `${AGENT_SETUP_PROMPT}
---
Then read ${url} — that is the page I am on — and help me use it. Skills for every
Hanzo capability are published at ${SKILLS}.`
}
export function AgentActions() {
const pathname = usePathname()
const [open, setOpen] = useState(false)
const [done, setDone] = useState<string | null>(null)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const away = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) setOpen(false)
}
const esc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
document.addEventListener('mousedown', away)
document.addEventListener('keydown', esc)
return () => {
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', esc)
}
}, [open])
// Every page can be handed to an agent; only a doc page has a markdown twin.
// This returned null off /docs, which took the control off the FRONT PAGE —
// the one page a reader is most likely to hand over.
const hasMarkdown = pathname.startsWith('/docs')
const md = markdownUrlFor(pathname)
const here = typeof window === 'undefined' ? `https://docs.hanzo.ai${pathname}` : window.location.href
const flash = (what: string) => {
setDone(what)
setTimeout(() => setDone(null), 1600)
}
const copyMarkdown = async () => {
const text = await fetch(md).then((r) => r.text())
await navigator.clipboard.writeText(text)
flash('markdown')
setOpen(false)
}
// hanzo dev takes the prompt as its argument, so this is the same intention
// as "open in ChatGPT" for the agent that runs where the code is.
const copyDev = async () => {
// `hanzo code`, not `hanzo dev`: the bare form runs whichever backend the
// reader has selected — dev, Claude Code or Codex are all first-class — so
// the command does not pin one on their behalf. It runs in their terminal
// against their repo, so it is copied rather than opened.
await navigator.clipboard.writeText(`hanzo code ${JSON.stringify(promptFor(here))}`)
flash('dev')
setOpen(false)
}
const copyPrompt = async () => {
await navigator.clipboard.writeText(promptFor(here))
flash('prompt')
setOpen(false)
}
const item =
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-start text-sm text-fd-popover-foreground transition-colors hover:bg-fd-accent'
const group = 'px-2 pt-2 pb-1 text-xs font-medium text-fd-muted-foreground'
return (
<div ref={box} className="relative">
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
>
{done ? <Check className="size-3.5" /> : <Sparkle className="size-3.5" />}
<span className="max-sm:hidden">{done ? 'Copied' : 'Use Agent'}</span>
<ChevronDown className="size-3 opacity-60 max-sm:hidden" />
</button>
{open && (
<div
role="menu"
className="absolute end-0 top-full z-50 mt-1 w-60 overflow-hidden rounded-lg border bg-fd-popover p-1 shadow-lg"
>
{/* Ours, opened directly. Everything below this is the same page
handed to somebody else's agent, which is why the two are separated
rather than mixed into one list of eight. */}
<div className={group}>Use with Hanzo</div>
<a role="menuitem" target="_blank" rel="noreferrer" href="https://hanzo.app" className={item}>
Open in Hanzo App
</a>
<button role="menuitem" onClick={copyDev} className={item}>
Open in Hanzo CLI
</button>
<div className="my-1 border-t" />
<div className={group}>Use with any agent</div>
<button role="menuitem" onClick={copyPrompt} className={item}>
Copy prompt
</button>
{hasMarkdown && (
<>
<button role="menuitem" onClick={copyMarkdown} className={item}>
Copy page as Markdown
</button>
<a role="menuitem" href={md} className={item}>
View as Markdown
</a>
</>
)}
<div className="my-1 border-t" />
<a
role="menuitem"
target="_blank"
rel="noreferrer"
href={`https://chatgpt.com/?${new URLSearchParams({ hints: 'search', q: promptFor(here) })}`}
className={item}
>
Open in ChatGPT
</a>
<a
role="menuitem"
target="_blank"
rel="noreferrer"
href={`https://claude.ai/new?${new URLSearchParams({ q: promptFor(here) })}`}
className={item}
>
Open in Claude
</a>
<div className="my-1 border-t" />
{/* The skill for whatever this page is about, so an agent can pick up
the capability rather than only this one page's text. */}
<a role="menuitem" target="_blank" rel="noreferrer" href={SKILLS} className={item}>
Hanzo Skills →
</a>
</div>
)}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { Text } from '@hanzo/gui';
import { Check, ChevronDown, Sparkle } from '@hanzogui/lucide-icons-2';
import { DropdownMenu } from '@hanzo/ui';
import { Action } from '@/components/action';
import { AGENT_SETUP_PROMPT } from '@/lib/agent-setup-prompt';
import { muted } from '@/lib/ink';
const SKILLS = 'https://hanzoskills.com';
/** /docs/a/b -> /llms.mdx/docs/a/b/content.md, the route app/llms.mdx serves. */
function markdown(pathname: string) {
const clean = pathname.replace(/\/+$/, '');
return `/llms.mdx${clean || '/docs'}/content.md`;
}
function prompt(url: string) {
return `${AGENT_SETUP_PROMPT}
---
Then read ${url} — that is the page I am on — and help me use it. Skills for every
Hanzo capability are published at ${SKILLS}.`;
}
/**
* "Give this page to an agent", beside the page title: ours opened directly, or
* the page handed to any agent as a prompt or as Markdown. Everything is derived
* from the pathname, so it needs nothing from the page.
*/
export function Agent() {
const pathname = usePathname();
const [done, setDone] = useState(false);
const md = markdown(pathname);
// Every page can be handed to an agent; only a doc page has a Markdown twin.
const twin = pathname.startsWith('/docs');
const here = () => window.location.href;
const copy = async (text: string | Promise<string>) => {
await navigator.clipboard.writeText(await text);
setDone(true);
setTimeout(() => setDone(false), 1600);
};
const go = (url: string) => window.open(url, '_blank', 'noopener,noreferrer');
return (
<DropdownMenu
placement="bottom-end"
minWidth={232}
trigger={
<Action tone="line" render="button" type="button" aria-label="Use this page with an agent">
{done ? <Check size={14} color="$color11" /> : <Sparkle size={14} color="$color11" />}
<Text fontSize="$2" lineHeight={18} fontWeight="500" {...muted} $max-sm={{ display: 'none' }}>
{done ? 'Copied' : 'Use agent'}
</Text>
<ChevronDown size={12} color="$color10" />
</Action>
}
items={[
{ type: 'label', label: 'Use with Hanzo' },
{ key: 'app', label: 'Open in Hanzo App', onSelect: () => go('https://hanzo.app') },
// `hanzo code` runs whichever coding backend the reader chose, in their
// own terminal, so the command is copied rather than opened.
{ key: 'cli', label: 'Open in Hanzo CLI', description: 'Copies the command', onSelect: () => copy(`hanzo code ${JSON.stringify(prompt(here()))}`) },
{ type: 'separator' },
{ type: 'label', label: 'Use with any agent' },
{ key: 'prompt', label: 'Copy prompt', onSelect: () => copy(prompt(here())) },
...(twin
? [
{ key: 'copy', label: 'Copy page as Markdown', onSelect: () => copy(fetch(md).then((r) => r.text())) },
{ key: 'view', label: 'View as Markdown', onSelect: () => window.location.assign(md) },
]
: []),
{ type: 'separator' },
{ key: 'chatgpt', label: 'Open in ChatGPT', onSelect: () => go(`https://chatgpt.com/?${new URLSearchParams({ hints: 'search', q: prompt(here()) })}`) },
{ key: 'claude', label: 'Open in Claude', onSelect: () => go(`https://claude.ai/new?${new URLSearchParams({ q: prompt(here()) })}`) },
{ type: 'separator' },
{ key: 'skills', label: 'Hanzo Skills', onSelect: () => go(SKILLS) },
]}
/>
);
}
-133
View File
@@ -1,133 +0,0 @@
'use client';
/**
* Help and appearance, from a small mark in the bottom-right corner of every
* page — the same control hanzo.ai carries, so a reader who crosses from the
* site to the docs finds the theme where they left it.
*
* Theme is the one preference this file owns. RootProvider already runs
* next-themes for the document, so this reads and writes that provider rather
* than keeping a second answer to which theme is in effect; the rail's own
* toggle is switched off in the docs layout so there is one control, here.
*
* Closed it is a 28px mark. Nothing inside is mounted until it is asked for.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTheme } from 'next-themes';
import { HanzoMark } from '@hanzogui/shell';
/** Where a reader goes when the page is not enough. Real destinations only. */
const HELP = [
{ label: 'Get help', href: '/docs/support' },
{ label: 'Console', href: 'https://console.hanzo.ai' },
{ label: 'Contact us', href: 'https://hanzo.ai/contact' },
];
const THEMES = [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
];
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="help-section">
<h2 className="help-title">{title}</h2>
{children}
</section>
);
}
export function AppearanceDock() {
const [open, setOpen] = useState(false);
const box = useRef<HTMLDivElement>(null);
const { theme, setTheme } = useTheme();
// next-themes resolves on the client, so the selected option is unknown on the
// server. Rendering one as selected anyway hydrates wrong; waiting one paint
// costs nothing on a control nobody has opened.
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
// Close on outside press and on Escape — a panel that traps you is worse
// than no panel.
useEffect(() => {
if (!open) return;
const away = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
};
const key = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', away);
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('mousedown', away);
document.removeEventListener('keydown', key);
};
}, [open]);
return (
<div ref={box} className="help-dock">
{open && (
<div className="help-panel" role="dialog" aria-label="Help and appearance">
<div className="help-head">
<h2 className="help-title">Help</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="help-close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<nav className="help-links">
{HELP.map((l) => {
const away = l.href.startsWith('http');
return (
<a
key={l.label}
href={l.href}
{...(away ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className="help-link"
>
{l.label}
{away ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M7 17L17 7M8 7h9v9" />
</svg>
) : null}
</a>
);
})}
</nav>
<Section title="Appearance">
<div className="help-row">
<span>Theme</span>
<div className="help-track">
{THEMES.map((t) => (
<button
key={t.value}
type="button"
aria-pressed={ready ? theme === t.value : undefined}
data-on={ready && theme === t.value ? '' : undefined}
onClick={() => setTheme(t.value)}
className="help-choice"
>
{t.label}
</button>
))}
</div>
</div>
</Section>
</div>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label="Help and appearance"
className="help-button"
>
<HanzoMark size={14} />
</button>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Text, XStack } from '@hanzo/gui';
import { Menu, PanelLeft } from '@hanzogui/lucide-icons-2';
import { Org, Short } from '@/components/account';
import { Tool } from '@/components/action';
import { Brand } from '@/components/brand';
import { BAR, useShell } from '@/components/shell';
import { lit, nav } from '@/lib/nav';
import { loud, muted } from '@/lib/ink';
/**
* The bar across the top of the page column.
*
* Desktop: the section links, and on the right the org you act in. While the
* rail is folded away the bar carries the control that brings it back and the
* account, since the rail's foot is not on screen.
*
* Phone: the menu that opens the rail as a drawer, the name, and the key. The
* links live in the drawer there.
*/
export function Bar() {
const { collapsed, setCollapsed, setOpen } = useShell();
const pathname = usePathname();
return (
<XStack
render="header"
position="sticky"
t={0}
z={30}
height={BAR}
shrink={0}
px={24}
gap={12}
items="center"
borderBottomWidth={1}
borderColor="$borderColor"
bg="$background"
$max-md={{ px: 12, gap: 8 }}
>
<XStack items="center" gap={6} minW={0} $md={{ display: 'none' }}>
<Tool label="Open navigation" onPress={() => setOpen(true)}>
<Menu size={18} color="$color11" />
</Tool>
<Brand />
</XStack>
{collapsed && (
<XStack items="center" gap={10} mr={8} $max-md={{ display: 'none' }}>
<Tool label="Show sidebar" onPress={() => setCollapsed(false)}>
<PanelLeft size={16} color="$color10" />
</Tool>
<Brand />
</XStack>
)}
<XStack render="nav" aria-label="Sections" flex={1} minW={0} gap={2} items="center" $max-md={{ display: 'none' }}>
{nav.map((item) => {
const on = lit(item, pathname);
return (
<Text
key={item.url}
render={<Link href={item.url} prefetch={false} />}
aria-current={on ? 'page' : undefined}
fontSize="$2"
lineHeight={18}
fontWeight={on ? '500' : '400'}
px={10}
py={6}
rounded="$3"
{...(on ? loud : muted)}
hoverStyle={{ color: '$color12' }}
>
{item.text}
</Text>
);
})}
</XStack>
<XStack flex={1} $md={{ display: 'none' }} />
<XStack items="center" gap={10} shrink={0}>
<Org />
<XStack display={collapsed ? 'flex' : 'none'} $max-md={{ display: 'flex' }}>
<Short />
</XStack>
</XStack>
</XStack>
);
}
+36 -22
View File
@@ -1,30 +1,44 @@
'use client';
import { useSidebar } from '@hanzo/docs-base-ui/components/sidebar/base';
import { HanzoDocsIcon } from '@/app/layout.client';
import Link from 'next/link';
import { Text, XStack } from '@hanzo/gui';
import { HanzoMark } from '@hanzogui/shell';
import { muted } from '@/lib/ink';
/**
* The name at the top left.
* The name at the top left: the mark, "Hanzo AI", and which surface this is.
*
* ONE slot renders in two places — the sidebar's own header while it is open,
* and the top bar once it collapses (docs-navbar.tsx suppresses its copy while
* the rail is drawing one). So this reads the same collapse state both do and
* shows the form that fits the space it is actually in:
*
* open the wordmark alone. It is the full name and there is room for it,
* and setting the mark beside it says the same thing twice.
* collapsed the mark alone. The rail is a strip; a wordmark does not fit one,
* and the mark is what a reader recognises at that size.
*
* It is "Hanzo AI" and not "Hanzo" because that is the company's name. The
* shorter form reads as a product on a page that is documenting several.
* hanzo.ai, hanzo.app and this site share the wordmark, so without "Docs" the
* header is ambiguous the moment a reader arrives from search. It is the
* company's name rather than "Hanzo" because the page documents several
* products.
*/
export function Brand({ className }: { className?: string }) {
const { collapsed } = useSidebar();
return collapsed ? (
<HanzoDocsIcon className={className ?? 'size-5'} />
) : (
<span className="font-medium">Hanzo AI</span>
export function Brand() {
return (
<XStack
render={<Link href="/" prefetch={false} aria-label="Hanzo AI Docs, home" />}
items="center"
gap={8}
minW={0}
>
<XStack color="$color12" shrink={0}>
<HanzoMark size={18} title="Hanzo" />
</XStack>
<Text fontSize="$4" lineHeight={20} fontWeight="600" color="$color12" numberOfLines={1}>
Hanzo AI
</Text>
<Text
fontSize={11}
lineHeight={16}
fontWeight="500"
{...muted}
px={6}
rounded={6}
borderWidth={1}
borderColor="$borderColor"
>
Docs
</Text>
</XStack>
);
}
+3 -38
View File
@@ -2,7 +2,6 @@
import { useState } from 'react';
import Link from 'next/link';
import { PROVIDER_ICONS } from '@/components/provider-icons';
// The masthead. Three doors, one model, and nothing else above the fold.
//
@@ -67,25 +66,9 @@ const PATHS = [
},
] as const;
function BrandIcon({ name, className = 'size-4' }: { name: string; className?: string }) {
const svg = PROVIDER_ICONS[name];
if (!svg) return null;
return (
<span
aria-hidden
className={`inline-flex items-center justify-center ${className} [&>svg]:size-full`}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
export function DocsHero({
title = 'Build Anything with Hanzo',
description = 'Over 400 models, one platform. Pick how much you want to type.',
}: {
title?: string;
description?: string;
}) {
// No title of its own: the page header above it prints the page's title and
// 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];
@@ -113,24 +96,6 @@ export function DocsHero({
}
`}</style>
<div className="hanzo-rise mb-4 flex items-center gap-2.5">
<BrandIcon name="hanzo" />
</div>
<h1
className="hanzo-rise mb-3 text-4xl font-semibold tracking-tight text-fd-foreground md:text-5xl"
style={{ ['--d' as string]: '60ms' }}
>
{title}
</h1>
<p
className="hanzo-rise mb-8 max-w-xl text-base text-fd-muted-foreground md:text-lg"
style={{ ['--d' as string]: '120ms' }}
>
{description}
</p>
{/* 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. */}
-63
View File
@@ -1,63 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/cn';
import { linkItems } from '@/components/layouts/shared';
// The four developer surfaces, rendered INSIDE the docs top bar.
//
// The header slot lays out: navTitle, then `nav.children` in a flex-1 track, then
// the search trigger. `links` never reaches it — those render in the sidebar — so a
// nav row has to be handed in as nav.children or it simply does not exist on doc
// pages. This is that row.
//
// Sourced from the same `linkItems` the landing page uses, so the two bars cannot
// drift: add a surface once and it appears in both.
export function DocsNavLinks() {
const pathname = usePathname();
// A run of equal-height links, so the tracks come from the content and the
// spacing is stated once on the container rather than as a margin each link
// carries. `justify-start` keeps the row left-aligned inside the stretched
// middle track of the bar above.
//
// The tracks are CLASSES, not an inline style, because this element's display
// is also what hides it: an inline `display:grid` outranks every class, so
// `max-md:hidden` stopped hiding and the whole row spilled across a 390px
// header under the account pill. One property, one owner — and since the
// hiding has to be a media query, the tracks join it there.
return (
<nav className="grid grid-flow-col justify-start items-center gap-1 max-md:hidden">
{linkItems
.filter((item) => item.type !== 'icon' && 'url' in item)
.map((item) => {
const url = String((item as { url: string }).url);
// Each item says how it matches, and this honours it rather than
// assuming. `nested-url` keeps a section lit while the reader is
// anywhere inside it; `url` is exact, which is what /docs needs — it
// prefixes every page here and would otherwise never be off.
const mode = (item as { active?: string }).active ?? 'nested-url';
const active =
mode === 'url'
? pathname === url
: pathname === url || pathname.startsWith(`${url}/`);
return (
<Link
key={url}
href={url}
className={cn(
'rounded-md px-2.5 py-1.5 text-sm transition-colors',
active
? 'text-fd-foreground'
: 'text-fd-muted-foreground hover:text-fd-foreground',
)}
>
{String((item as { text: string }).text)}
</Link>
);
})}
</nav>
);
}
-139
View File
@@ -1,139 +0,0 @@
'use client';
import type { ComponentProps } from 'react';
import { useDocsLayout } from '@hanzo/docs-base-ui/layouts/docs';
import { useSidebar } from '@hanzo/docs-base-ui/components/sidebar/base';
import { Grid } from '@hanzo/ui/grid';
import { cn } from '@/lib/cn';
import { DocsNavLinks } from '@/components/docs-nav-links';
import { OrgBadge } from '@/components/org-badge';
import { NavAccount } from '@/components/sidebar-account';
// The top bar on doc pages.
//
// DocsLayout ships its header slot as `md:hidden`: on desktop it puts the wordmark
// and search in the SIDEBAR and renders no bar at all. That is why the
// API/CLI/MCP/SDKs nav shows on the landing page and disappears the moment you open
// a doc — there was no bar to put it in.
//
// This replaces the slot rather than wrapping it, for a concrete reason. The stock
// Header renders its middle track from `nav?.children`, but layouts/docs/client.tsx
// DESTRUCTURES `nav` out of the props before spreading the rest into context:
//
// nav: { enabled: navEnabled = true, transparentMode: ... } = {},
// ...
// props: { tabMode, tabs, ...baseProps }
//
// so `useDocsLayout().props.nav` is always undefined. Passing nav.children — or
// nav.component, which the stock Header checks first — reaches nothing. Measured,
// not assumed: the bar rendered with md:flex and an empty middle track.
//
// The shell below mirrors the stock slot's classes so it sits in the same grid area
// with the same height and sticky offset; only `md:hidden` is dropped and the middle
// track is ours. Search and the sidebar trigger still come from the layout's own
// slots, so they keep their behaviour.
export function DocsNavbar(props: ComponentProps<'header'>) {
const { isNavTransparent, slots } = useDocsLayout();
const { collapsed } = useSidebar();
return (
<header
id="nd-subnav"
data-transparent={isNavTransparent}
{...props}
// THREE TRACKS: the wordmark, the section links, the account controls.
// It was `flex items-center gap-3` with a `flex-1` wrapper in the middle
// whose only job was to push its neighbours apart — which is a row asking
// to be a grid. The stretch is a property of the TRACK, so saying it here
// deletes the wrapper and the class that made it stretch, and neither
// neighbour has to know the other exists.
style={{
display: 'grid',
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
alignItems: 'center',
columnGap: 12,
...props.style,
}}
className={cn(
'[grid-area:header] sticky top-(--fd-docs-row-1) z-30 pe-2.5',
// A COLLAPSED RAIL TAKES NO COLUMN, SO THIS BAR STARTS AT THE VIEWPORT EDGE.
// container.tsx sets `--fd-sidebar-col: 0px` when collapsed, and the rail's
// floating control is `fixed inset-s-4` at 66px wide — x∈[16,82] — at z-40,
// above this bar's z-30. With a flat ps-4 the first nav item begins at x=16,
// i.e. underneath it, which is what buried the leading link on the landing
// page (the one route that opens collapsed). Reserve the control's footprint:
// 88px clears 82 with a gap. The rail only exists in `full` mode, so below md
// there is no control to clear and the padding stays 4.
collapsed ? 'ps-4 md:ps-22' : 'ps-4 md:ps-8',
'border-b transition-colors backdrop-blur-sm h-(--fd-header-height)',
// The height variable is only declared under max-md upstream, because the bar
// is not expected on desktop. Without it the grid row collapses to zero and
// the bar renders clipped over the content.
'max-md:layout:[--fd-header-height:--spacing(14)] md:[--fd-header-height:--spacing(14)]',
'data-[transparent=false]:bg-fd-background/80',
props.className,
)}
>
{/* Mobile only. The sidebar renders this same `navTitle` slot at its top, and
the sidebar is visible from md up — so on desktop the wordmark drew twice,
once here and once six inches to the left. Below md the sidebar collapses
into a drawer and this is the only place a wordmark can live, so it stays
there rather than being dropped outright. */}
{/* The three tracks are always present, so the wordmark's cell is emitted
even when the slot is not — a missing child would shift the links into
the wordmark's track and the controls into theirs. */}
{slots.navTitle ? (
<slots.navTitle
className={cn(
'inline-grid grid-flow-col items-center gap-2.5 font-semibold',
// Desktop always has a copy of this somewhere to the left, so this one
// is mobile's. Open, the rail's header draws it as the wordmark;
// collapsed, the rail's own control draws it as the mark. It was
// conditional on `collapsed` back when the collapsed case had nobody
// drawing it — that left the corner with a mark and a toggle side by
// side, which is the pair they are no longer.
'md:hidden',
)}
/>
) : (
<span />
)}
{/* The links are `max-md:hidden`, and a hidden element is not a grid item
at all — so below md it would vacate the stretch track and the controls
would slide into it, left-aligned. The cell stays; only its contents
disappear. */}
<div>
<DocsNavLinks />
</div>
{/* The right-hand rail. These are passed to DocsLayout as `links` with
`on: 'nav'`, which the STOCK header renders — but this bar replaces that
header, so anything not named here simply does not appear. That is how
the site lost its Sign in and Console buttons: they were configured
correctly and rendered by nobody. Named explicitly so the omission
cannot happen silently again. */}
<Grid columns={['auto', 'auto']} gap={8} style={{ alignItems: 'center' }}>
{/* NO SEARCH HERE. The sidebar carries the search field itself, and the
collapsed rail carries a search button beside its own toggle — so a
third one on the far right was the same action offered twice on one
screen, and the two were not even the same shape. Search lives where
the tree lives. */}
{/* The full-width account controls live at the foot of the sidebar: this
row is a fixed 56px and "Get API Key" is the widest label on the
page, so it was the control that reflowed to three lines and spilled
out of the bar between 768 and 805px. A column can afford a long
label; this row carries the short one instead, and only when the rail
is not on screen to carry either. */}
<OrgBadge />
{/* Shown exactly where the rail is not showing it: below md the rail is
a drawer, and on desktop only while it is collapsed. Everywhere else
the column version at the foot of the rail is the one control. */}
<div className={cn(collapsed ? '' : 'md:hidden')}>
<NavAccount />
</div>
</Grid>
</header>
);
}
-103
View File
@@ -1,103 +0,0 @@
import type { ReactNode } from 'react';
import { DocsLayout, type DocsLayoutProps } from '@hanzo/docs-base-ui/layouts/docs';
import { DocsNavbar } from '@/components/docs-navbar';
import { Brand } from '@/components/brand';
import { HanzoDocsIcon } from '@/app/layout.client';
import { baseOptions } from '@/components/layouts/shared';
import { clientTree, source } from '@/lib/source';
import { getSection } from '@/lib/source/navigation';
import { SidebarSearch } from '@/components/sidebar-search';
import { SidebarAccount } from '@/components/sidebar-account';
// The documentation chrome: navbar, page tree, sidebar filter.
//
// TWO routes mount it — /docs/** and the landing page at / — so it lives here
// rather than in either layout.tsx. Copying it would give the site two page
// trees and two sidebar banners that agree only until someone edits one of
// them, and the pieces below (the `never` cast, the tab colours) are exactly
// the kind of detail that drifts silently.
//
// What a caller may vary is what is NOT spread over: the tree, the slots, the
// nav title and the sidebar's CONTENT are fixed here, everything else in
// DocsLayoutProps passes through. The one exception is the rail's starting
// state, which is genuinely per-route — / opens collapsed, /docs/** does not —
// so it is threaded as a narrow prop rather than by reopening `sidebar` whole.
// Typed to that single field on purpose: nobody can clobber the banner or tabs.
export function Docs({
children,
sidebar,
...props
}: Omit<DocsLayoutProps, 'tree' | 'slots' | 'nav' | 'sidebar' | 'children'> & {
children: ReactNode;
sidebar?: Pick<NonNullable<DocsLayoutProps['sidebar']>, 'defaultCollapsed'>;
}) {
const base = baseOptions();
return (
<DocsLayout
{...base}
{...props}
// Desktop top bar. The stock slot is md:hidden, so doc pages had no header
// and the API/CLI/MCP/SDKs nav had nowhere to render. DocsNavbar is a client
// MODULE — an inline function here crashes the server at the RSC boundary.
// The theme control lives in the bottom-right dock (components/appearance-dock),
// the same corner hanzo.ai keeps it in — so the rail carries none.
slots={{ header: DocsNavbar, themeSwitch: false }}
tree={clientTree()}
nav={{
...base.nav,
// The mark and the wordmark are alternatives, not a pair — see Brand.
title: <Brand />,
}}
sidebar={{
prefetch: false,
// The collapsed rail's control IS the mark — see SidebarProps.mark. The
// mark itself, not <Brand/>: Brand answers "what fits the space", and
// the space here is always the collapsed one.
mark: <HanzoDocsIcon className="size-4.5" />,
// The tree shows 12 rows so it fits one screen; the filter is how a
// reader reaches the ~1,600 pages it does not list. Titles and paths are
// computed here, on the server, from the same source the tree comes from.
//
// The filter is the only thing here. A product switcher sat above it and
// answered the same question worse: it listed sections a reader could
// already see in the tree, and typing reaches any of them plus every page
// inside them.
banner: <SidebarSearch />,
// Who you are, at the foot of the rail. The top bar is a fixed 56px row
// and these are the widest labels on the page; a column can afford them.
footer: <SidebarAccount />,
tabs: {
transform(option, node) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
const color = `var(--${getSection(meta.path)}-color, var(--color-fd-foreground))`;
return {
...option,
icon: (
// One cell holding one glyph, centred. `place-items` is the
// grid word for it and needs no wrapper to align against.
<div
className="[&_svg]:size-full rounded-lg size-full text-(--tab-color) max-md:bg-(--tab-color)/10 max-md:border max-md:p-1.5"
style={
{
'--tab-color': color,
display: 'grid',
placeItems: 'center',
} as object
}
>
{node.icon}
</div>
),
};
},
},
...sidebar,
}}
>
{children}
</DocsLayout>
);
}
@@ -1,154 +0,0 @@
'use client';
import {
DocsLayout,
DocsLayoutProps,
NavigationPanel,
NavigationPanelOverlay,
NavigationPanelProps,
} from '@hanzo/docs-base-ui/layouts/flux';
import {
AISearch,
AISearchInput,
AISearchInputActions,
AISearchPanelHeader,
AISearchPanelList,
useAISearchContext,
useHotKey,
} from '@/components/ai/search';
import { MessageCircleIcon } from 'lucide-react';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { cn } from '@/lib/cn';
import { AnimatePresence, motion } from 'motion/react';
import { useSidebar } from '@hanzo/docs-base-ui/components/sidebar/base';
import { useEffect, useState } from 'react';
export function LayoutClient(props: DocsLayoutProps) {
return (
<AISearch>
<DocsLayout
{...props}
renderNavigationPanel={(panel) => <CustomNavigationPanel {...panel} />}
>
{props.children}
</DocsLayout>
</AISearch>
);
}
function CustomNavigationPanel({ tool, ...props }: NavigationPanelProps) {
const [mounted, setMounted] = useState(false);
const ai = useAISearchContext();
const sidebar = useSidebar();
useHotKey();
const variants = {
show: {
opacity: 1,
y: 0,
},
hide: {
opacity: 0,
y: '100%',
},
};
useEffect(() => {
setMounted(true);
}, []);
return (
<>
<NavigationPanelOverlay
enabled={ai.open}
onClick={() => ai.setOpen(false)}
className="pt-6 pb-34"
>
<AnimatePresence>
{ai.open && (
<motion.div
className="flex flex-col size-full mx-auto sm:max-w-[380px]"
variants={{
show: {
y: 0,
opacity: 1,
},
hide: {
y: '80%',
opacity: 0,
},
}}
initial="hide"
animate="show"
exit="hide"
transition={{
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
opacity: {
duration: 0.2,
},
}}
>
<AISearchPanelHeader onClick={(e) => e.stopPropagation()} />
<AISearchPanelList className="px-3 flex-1" />
</motion.div>
)}
</AnimatePresence>
</NavigationPanelOverlay>
<NavigationPanel
layout="size"
tool={
<>
<button
aria-label="Ask AI"
className={cn(
buttonVariants({
variant: 'ghost',
size: 'icon-sm',
}),
)}
onClick={() => {
ai.setOpen(!ai.open);
sidebar.setOpen(false);
}}
>
<MessageCircleIcon />
</button>
{tool}
</>
}
{...props}
>
{(node) => (
<AnimatePresence mode="popLayout">
{ai.open ? (
<motion.div
key="ai"
variants={variants}
initial="hide"
animate="show"
exit="hide"
transition={{ duration: 0.2 }}
>
<AISearchInput />
<div className="flex items-center gap-1.5 p-1 empty:hidden">
<AISearchInputActions />
</div>
</motion.div>
) : (
<motion.div
key="content"
variants={variants}
initial={mounted && 'hide'}
animate="show"
exit="hide"
transition={{ duration: 0.2 }}
>
{node}
</motion.div>
)}
</AnimatePresence>
)}
</NavigationPanel>
</>
);
}
@@ -1,56 +0,0 @@
import { baseOptions, linkItems, logo } from '@/components/layouts/shared';
import { clientTree, source } from '@/lib/source';
import { getSection } from '@/lib/source/navigation';
import { getSidebarTabs } from '@hanzo/docs-base-ui/components/sidebar/tabs/index';
import type { ReactNode } from 'react';
import { LayoutClient } from './client';
import { getLayoutTabs } from '@hanzo/docs-base-ui/layouts/shared';
export function FluxLayout({ children }: { children: ReactNode }) {
const base = baseOptions();
return (
<LayoutClient
{...base}
tree={clientTree()}
// just icon items
links={linkItems.filter((item) => item.type === 'icon')}
nav={{
...base.nav,
title: (
<>
{logo}
<span className="font-medium max-md:hidden">Hanzo Docs</span>
</>
),
}}
sidebar={{
tabs: getLayoutTabs(source.getPageTree(), {
transform(option, node) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
const color = `var(--${getSection(meta.path)}-color, var(--color-fd-foreground))`;
return {
...option,
icon: (
<div
className="[&_svg]:size-full size-full text-(--tab-color)"
style={
{
'--tab-color': color,
} as object
}
>
{node.icon}
</div>
),
};
},
}),
}}
>
{children}
</LayoutClient>
);
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/com
import { ArrowRight, Check, ChevronDown, Sparkle } from 'lucide-react';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { cn } from '@/lib/cn';
import { useTreeContext } from '@hanzo/docs-base-ui/contexts/tree';
import { useTree } from '@/lib/tree';
import type { Item, Node } from '@hanzo/docs/core/page-tree';
import { useRouter } from 'next/navigation';
@@ -60,7 +60,7 @@ export default function CustomSearchDialog(props: SharedProps) {
from: '/api/search',
tag,
});
const { full } = useTreeContext();
const { tree: full } = useTree();
const router = useRouter();
const searchMap = useMemo(() => {
const map = new Map<string, Item>();
+10 -70
View File
@@ -1,82 +1,22 @@
import type { BaseLayoutProps, LinkItemType } from '@hanzo/docs-base-ui/layouts/shared';
import { HanzoDocsIcon } from '@/app/layout.client';
import { HanzoMark } from '@hanzogui/shell';
import { nav } from '@/lib/nav';
// Docs top-nav: the four developer surfaces, and nothing that leaves the site.
//
// It carried six items shaped like the MARKETING nav — Models, Agents, MCP,
// Developers, Pricing, Enterprise — two of which resolved out to hanzo.ai, bouncing
// a reader off mid-page. These four are how you actually reach Hanzo: over the API,
// from the CLI, through MCP, or via an SDK. Each was probed 200 before landing.
//
// CLI does NOT point at /docs/cli. That section is fumadocs' own tooling, kept
// from the fork — "the CLI tool that automates setups and installs components",
// plus a Markdown previewer. 200, and about the wrong product. The `hanzo`
// binary is documented at the URL below, and hanzo.ai's nav now names the same
// four surfaces pointing at these same pages.
// Ordered the way a reader arrives: the whole thing, then the guided path
// through it, then the four surfaces they will write against.
//
// Guides points at /docs/guides: six task journeys, each written as CLI, SDK,
// HTTP and MCP, with the migration and integration guides nested beside them.
// The nav item, the URL, the page title and the sidebar heading are one word,
// which they were not — the nav said Guides and every other surface said Start.
export const linkItems: LinkItemType[] = [
{
// Exact, not nested: /docs is a prefix of every page here, so nesting would
// light this up beside whichever surface the reader is actually in.
text: 'Docs',
url: '/docs',
active: 'url',
},
{
text: 'Guides',
url: '/docs/guides',
active: 'nested-url',
},
{
// The model families: Zen, Enso, Kai. On-site, unlike the marketing
// "Models" item this nav once carried, which left for hanzo.ai.
text: 'Models',
url: '/docs/models',
active: 'nested-url',
},
{
text: 'APIs',
url: '/docs/openapi',
active: 'nested-url',
},
{
text: 'SDKs',
url: '/docs/sdks',
active: 'nested-url',
},
{
text: 'CLI',
url: '/docs/cli',
active: 'nested-url',
},
{
text: 'MCP',
url: '/docs/mcp',
active: 'nested-url',
},
];
export const logo = (
<HanzoDocsIcon className="size-5" />
);
// 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: (
<>
{logo}
<HanzoMark size={18} />
<span className="font-medium max-md:hidden">Hanzo AI</span>
{/* Says WHICH surface you are on. hanzo.ai, hanzo.app and this site share
the wordmark, so without it the header is ambiguous the moment a reader
arrives from search. It sits at the wordmark and not in the nav row
because it is an identity, not a destination. */}
<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>
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { use, useEffect, useId, useState } from 'react';
import { useTheme } from 'next-themes';
import { useThemeSetting } from '@hanzogui/next-theme';
export function Mermaid({ chart }: { chart: string }) {
const [mounted, setMounted] = useState(false);
@@ -27,7 +27,7 @@ function cachePromise<T>(key: string, setPromise: () => Promise<T>): Promise<T>
function MermaidContent({ chart }: { chart: string }) {
const id = useId();
const { resolvedTheme } = useTheme();
const { resolvedTheme } = useThemeSetting();
const { default: mermaid } = use(cachePromise('mermaid', () => import('mermaid')));
mermaid.initialize({
-40
View File
@@ -1,40 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { currentUser } from '@/lib/iam'
/**
* The organization the reader is acting in, in the top bar.
*
* The bar used to carry the whole auth surface — sign in, get an API key, email,
* sign out, console — five controls answering two different questions. The
* account moved to the foot of the sidebar; what stays here is the one fact a
* reader needs while reading: WHICH tenant these examples and keys belong to,
* which is the thing that is wrong when a curl from the docs 403s.
*
* It renders nothing for a signed-out reader. There is no org to name, and an
* empty chip is a control that looks broken.
*/
export function OrgBadge() {
const [org, setOrg] = useState<string | null>(null)
useEffect(() => {
let live = true
currentUser().then((u) => live && setOrg(u?.owner || null))
return () => {
live = false
}
}, [])
if (!org) return null
return (
<span
title={`Signed in to ${org}`}
className="hidden shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs text-fd-muted-foreground md:inline-block"
>
{org}
</span>
)
}
+167
View File
@@ -0,0 +1,167 @@
'use client';
import Link from 'next/link';
import { Fragment, type ReactNode } from 'react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { AnchorProvider, type TOCItemType } from '@hanzo/docs-core/toc';
import { Agent } from '@/components/agent';
import { Fold, OUTLINE, Outline } from '@/components/toc';
import { muted } from '@/lib/ink';
/** The widest the article gets, padding included; the outline sits beside it. */
const ARTICLE = 880;
export interface Crumb {
name: ReactNode;
url?: string;
}
export interface Turn {
name: ReactNode;
url: string;
}
/**
* A docs page: where it sits, its title and description, the body, and the way
* on. The title is the frontmatter's and it is printed once, here — a body that
* opens with its own `# heading` has it dropped at compile time
* (`lib/remark-title.ts`), so no page says its name twice.
*/
export function Page({
title,
description,
crumbs,
toc,
previous,
next,
updated,
children,
}: {
title: ReactNode;
description?: ReactNode;
crumbs: Crumb[];
toc: TOCItemType[];
previous?: Turn;
next?: Turn;
/** Already formatted on the server, so both renders print the same string. */
updated?: string;
children: ReactNode;
}) {
const outlined = toc.length > 0;
return (
<AnchorProvider toc={toc} single>
<XStack width="100%" maxW={ARTICLE + (outlined ? OUTLINE : 0)} self="center" items="flex-start">
<YStack flex={1} minW={0}>
{outlined && <Fold toc={toc} />}
<YStack
render="article"
px={48}
pt={40}
pb={80}
$max-lg={{ px: 32 }}
$max-md={{ px: 20, pt: 24, pb: 56 }}
>
<YStack gap={10} pb={28} mb={32} borderBottomWidth={1} borderColor="$borderColor">
{crumbs.length > 0 && <Crumbs crumbs={crumbs} />}
<XStack items="flex-start" gap={16}>
<Text
render="h1"
flex={1}
minW={0}
fontSize={32}
lineHeight={40}
fontWeight="600"
letterSpacing={-0.6}
color="$color12"
$max-md={{ fontSize: 28, lineHeight: 34 }}
>
{title}
</Text>
<XStack pt={4} shrink={0}>
<Agent />
</XStack>
</XStack>
{description ? (
<Text render="p" fontSize={17} lineHeight={27} {...muted}>
{description}
</Text>
) : null}
</YStack>
{children}
{(previous || next) && (
<XStack gap={12} mt={56} $max-sm={{ flexDirection: 'column' }}>
{previous ? <Step turn={previous} way="Previous" /> : <YStack flex={1} />}
{next ? <Step turn={next} way="Next" /> : <YStack flex={1} />}
</XStack>
)}
{updated && (
<Text mt={24} fontSize="$2" {...muted}>
Last updated {updated}
</Text>
)}
</YStack>
</YStack>
{outlined && <Outline toc={toc} />}
</XStack>
</AnchorProvider>
);
}
function Crumbs({ crumbs }: { crumbs: Crumb[] }) {
return (
<XStack render="nav" aria-label="Breadcrumb" flexWrap="wrap" items="center" gap={6}>
{crumbs.map((crumb, i) => (
<Fragment key={i}>
{i > 0 && (
<Text fontSize="$2" {...muted} aria-hidden>
/
</Text>
)}
{crumb.url ? (
<Text
render={<Link href={crumb.url} prefetch={false} />}
fontSize="$2"
{...muted}
hoverStyle={{ color: '$color12' }}
>
{crumb.name}
</Text>
) : (
<Text fontSize="$2" {...muted}>
{crumb.name}
</Text>
)}
</Fragment>
))}
</XStack>
);
}
function Step({ turn, way }: { turn: Turn; way: 'Previous' | 'Next' }) {
const end = way === 'Next';
return (
<YStack
render={<Link href={turn.url} prefetch={false} />}
flex={1}
minW={0}
gap={4}
px={16}
py={14}
rounded="$5"
borderWidth={1}
borderColor="$borderColor"
items={end ? 'flex-end' : 'flex-start'}
hoverStyle={{ bg: '$hover' }}
>
<Text fontSize="$1" lineHeight={16} {...muted}>
{way}
</Text>
<Text fontSize="$3" lineHeight={20} fontWeight="500" color="$color12" text={end ? 'right' : 'left'}>
{turn.name}
</Text>
</YStack>
);
}
+219
View File
@@ -0,0 +1,219 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
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 { Account } from '@/components/account';
import { Tool } from '@/components/action';
import { Brand } from '@/components/brand';
import { BAR, RAIL, useShell } from '@/components/shell';
import { Tree } from '@/components/tree';
import { lit, nav } from '@/lib/nav';
import { loud, muted } from '@/lib/ink';
/**
* The rail: the name, search, the tree, and the account, top to bottom. The
* tree scrolls between a pinned head and a pinned foot, so the account is
* always in reach however long the section.
*
* On a desktop it is a sticky column the reader can fold away. Below 768px it is
* the same panel in a drawer, opened from the bar, and it also carries the top
* nav, which the bar has no room for there.
*/
export function Rail() {
const { collapsed, setCollapsed, open, setOpen } = useShell();
return (
<>
<YStack
render="aside"
aria-label="Documentation"
position="sticky"
t={0}
height="100dvh"
width={RAIL}
shrink={0}
borderRightWidth={1}
borderColor="$borderColor"
bg="$background"
display={collapsed ? 'none' : 'flex'}
$max-md={{ display: 'none' }}
>
<Panel close={<Tool label="Collapse sidebar" onPress={() => setCollapsed(true)}><PanelLeft size={16} color="$color10" /></Tool>} />
</YStack>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="left"
showCloseButton={false}
p={0}
gap={0}
width="88%"
maxW={340}
rounded={0}
borderWidth={0}
borderRightWidth={1}
borderColor="$borderColor"
>
<SheetTitle position="absolute" width={1} height={1} overflow="hidden" opacity={0}>
Navigation
</SheetTitle>
<Panel drawer close={<Tool label="Close navigation" onPress={() => setOpen(false)}><X size={16} color="$color10" /></Tool>} />
</SheetContent>
</Sheet>
</>
);
}
function Panel({ drawer = false, close }: { drawer?: boolean; close: React.ReactNode }) {
const scroller = useRef<HTMLElement>(null);
const pathname = usePathname();
// Bring the page being read into the tree's view, inside the rail only — the
// document itself never moves for it.
useEffect(() => {
const box = scroller.current;
const row = box?.querySelector<HTMLElement>('[data-active="true"]');
if (!box || !row) return;
const b = box.getBoundingClientRect();
const r = row.getBoundingClientRect();
if (r.top < b.top || r.bottom > b.bottom) box.scrollTop += r.top - b.top - b.height / 3;
}, [pathname]);
return (
<YStack flex={1} minH={0} height="100%">
<XStack height={BAR} px={16} pr={10} gap={8} items="center" justify="space-between" shrink={0}>
<Brand />
{close}
</XStack>
<YStack px={12} pb={8} gap={10} shrink={0}>
<Find drawer={drawer} />
{drawer && <Links />}
</YStack>
<YStack
ref={scroller as never}
flex={1}
minH={0}
overflowY="auto"
px={12}
py={8}
>
<Tree />
</YStack>
<YStack shrink={0} px={12} pt={12} pb={12} gap={10} borderTopWidth={1} borderColor="$borderColor">
<Account />
<XStack items="center" justify="space-between">
<Text
render={<Link href="/docs/support" prefetch={false} />}
fontSize="$2"
{...muted}
px={4}
hoverStyle={{ color: '$color12' }}
>
Help
</Text>
<Theme />
</XStack>
</YStack>
</YStack>
);
}
/**
* The one way to search: the same dialog ⌘K opens, wearing the shape of a field.
* A button, not an input — an input would swallow the first keystrokes before
* the dialog mounted.
*/
function Find({ drawer }: { drawer: boolean }) {
const { setOpenSearch } = useSearchContext();
const { setOpen } = useShell();
return (
<XStack
render="button"
type="button"
onPress={() => {
if (drawer) setOpen(false);
setOpenSearch(true);
}}
height={36}
px={10}
gap={8}
rounded="$3"
items="center"
borderWidth={1}
borderColor="$borderColor"
bg="$panel"
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<Search size={15} color="$color10" />
<Text flex={1} fontSize="$2" {...muted} text="left">
Search or ask AI
</Text>
<Text
fontSize={11}
lineHeight={16}
{...muted}
px={5}
rounded={4}
borderWidth={1}
borderColor="$borderColor"
$max-md={{ display: 'none' }}
>
⌘K
</Text>
</XStack>
);
}
/** The top nav, for the drawer, where the bar has no room for it. */
function Links() {
const pathname = usePathname();
return (
<XStack render="nav" aria-label="Sections" flexWrap="wrap" gap={6}>
{nav.map((item) => {
const on = lit(item, pathname);
return (
<Text
key={item.url}
render={<Link href={item.url} prefetch={false} />}
aria-current={on ? 'page' : undefined}
fontSize="$2"
lineHeight={18}
px={10}
py={5}
rounded={999}
borderWidth={1}
borderColor={on ? '$color12' : '$borderColor'}
{...(on ? loud : muted)}
>
{item.text}
</Text>
);
})}
</XStack>
);
}
/** Light or dark. The class on <html> is the one authority; this flips it. */
function Theme() {
const { resolvedTheme, set } = useThemeSetting();
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
const dark = !ready || resolvedTheme !== 'light';
return (
<Tool label={dark ? 'Switch to light theme' : 'Switch to dark theme'} onPress={() => set(dark ? 'light' : 'dark')}>
{dark ? <Sun size={16} color="$color10" /> : <Moon size={16} color="$color10" />}
</Tool>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { createContext, use, useEffect, useMemo, useState, type ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { XStack, YStack } from '@hanzo/gui';
import { Bar } from '@/components/bar';
import { Rail } from '@/components/rail';
/**
* The frame every docs page sits in: the rail on the left, and a column holding
* the bar and the page.
*
* Two pieces of state live here because the rail and the bar both read them.
* `collapsed` hides the desktop rail; the bar then carries the control that
* brings it back. `open` is the phone drawer. Both start closed on the server
* and the client alike, and every width decision is a media query, so the
* markup a phone hydrates is the markup the export wrote.
*/
interface Shell {
collapsed: boolean;
setCollapsed: (v: boolean) => void;
open: boolean;
setOpen: (v: boolean) => void;
}
const Context = createContext<Shell | null>(null);
export function useShell(): Shell {
const shell = use(Context);
if (!shell) throw new Error('useShell needs <Shell> above it');
return shell;
}
/** Width of the desktop rail, and height of the bar. The page and the TOC read both. */
export const RAIL = 264;
export const BAR = 56;
export function Shell({ children }: { children: ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState(false);
const pathname = usePathname();
// A link followed from the drawer lands on its page with the drawer shut.
useEffect(() => setOpen(false), [pathname]);
const value = useMemo(() => ({ collapsed, setCollapsed, open, setOpen }), [collapsed, open]);
return (
<Context value={value}>
<XStack minH="100dvh" items="flex-start" bg="$background">
<Rail />
<YStack flex={1} minW={0} self="stretch">
<Bar />
{children}
</YStack>
</XStack>
</Context>
);
}
-214
View File
@@ -1,214 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { ChevronsUpDown } from 'lucide-react'
import { Grid } from '@hanzo/ui/grid'
import { currentUser, iam, type DocsUser } from '@/lib/iam'
/**
* Who you are, at the foot of the sidebar.
*
* These controls used to sit in the top bar, competing for a fixed 56px row
* with the logo, six section links and the search trigger — and "Get API Key"
* is the widest label on the page, so it was the thing that broke first: at
* 768-805px it reflowed to three lines and spilled above and below the bar.
*
* The foot of the sidebar has the opposite properties. It is a column, so a
* label that grows takes a row rather than overflowing a bar; it is stable
* across pages; and it is where a reader already looks for "who am I" because
* that is where every console puts it.
*/
// Who is signed in, asked once and answered the same way wherever it is asked.
// Two components render this — the rail and the top bar — and a second copy of
// the question is how they come to show different people on one screen.
function useDocsUser() {
const [user, setUser] = useState<DocsUser | null>(null)
useEffect(() => {
let live = true
currentUser().then((u) => live && setUser(u))
return () => {
live = false
}
}, [])
return [user, setUser] as const
}
export function SidebarAccount() {
const [user, setUser] = useDocsUser()
// Signed-out is the state this renders until IAM says otherwise, rather than
// nothing-until-resolved. A rail that renders nothing while a promise is in
// flight is a rail with a hole in it if that promise never settles, and this
// one is asked on every page of the site.
return user ? <Account user={user} onSignOut={() => setUser(null)} /> : <SignedOut />
}
function SignedOut() {
return (
<Grid columns={1} gap={8}>
{/* Sign in first: it is the step that costs nothing and the one most
readers already have an account for. Getting a key is the commitment,
so it sits at the foot as the thing you end on. */}
<Link
href="/login"
className="rounded-lg px-3 py-1.5 text-center text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
>
Sign in
</Link>
<a
href="https://console.hanzo.ai"
className="rounded-lg bg-fd-primary px-3 py-1.5 text-center text-sm font-medium text-fd-primary-foreground transition-colors hover:opacity-90"
>
Get an API key
</a>
</Grid>
)
}
function Account({ user, onSignOut }: { user: DocsUser; onSignOut: () => void }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
const label = user.name || user.email || 'Account'
// Dismiss on an outside click and on Escape. A menu that can only be closed
// by the control that opened it traps a keyboard reader.
useEffect(() => {
if (!open) return
const away = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) setOpen(false)
}
const esc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
document.addEventListener('mousedown', away)
document.addEventListener('keydown', esc)
return () => {
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', esc)
}
}, [open])
const signOut = () => {
// The SDK owns the token store and clears every key it wrote, including the
// ones this component never knew about. Picking keys off by hand is how
// `hanzo_iam_id_token` used to survive a sign-out.
iam()
.logout()
.catch(() => iam().clearTokens())
.finally(() => {
onSignOut()
window.location.reload()
})
}
return (
<div ref={box} className="relative">
{open && (
<div
role="menu"
className="absolute bottom-full mb-1 w-full overflow-hidden rounded-lg border bg-fd-popover p-1 shadow-lg"
>
<a
role="menuitem"
href="https://console.hanzo.ai"
className="block rounded-md px-2 py-1.5 text-sm text-fd-popover-foreground transition-colors hover:bg-fd-accent"
>
Console
</a>
<a
role="menuitem"
href="https://console.hanzo.ai/settings"
className="block rounded-md px-2 py-1.5 text-sm text-fd-popover-foreground transition-colors hover:bg-fd-accent"
>
Account settings
</a>
<button
role="menuitem"
onClick={signOut}
className="block w-full rounded-md px-2 py-1.5 text-start text-sm text-fd-popover-foreground transition-colors hover:bg-fd-accent"
>
Sign out
</button>
</div>
)}
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
// Initial, name, chevron. The name track is `minmax(0, 1fr)`, which is
// what lets it truncate — a flex child needed `min-w-0` spelled out to
// do the same thing, and a track that forgets it is how a long email
// pushes the chevron out of the rail.
className="w-full rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-fd-accent"
style={{
display: 'grid',
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
alignItems: 'center',
columnGap: 8,
}}
>
<span
aria-hidden
className="grid size-6 place-items-center rounded-full bg-fd-primary text-[0.6875rem] font-medium text-fd-primary-foreground"
>
{label.slice(0, 1).toUpperCase()}
</span>
<span className="truncate text-sm text-fd-foreground">{label}</span>
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground" />
</button>
</div>
)
}
/**
* The same control, in the top bar, for the two states where the rail is not
* showing it: a collapsed rail on desktop, and mobile, where the rail is a
* drawer behind a button. Without it "Get an API key" simply vanishes on the
* front page and on every phone.
*
* It is deliberately the SHORT label. The bar is a fixed 56px row and the full
* phrase is the widest thing on the page — it is what reflowed to three lines
* and spilled out of the bar between 768 and 805px, which is why the long form
* lives in a column and this one does not.
*/
export function NavAccount() {
const [user] = useDocsUser()
if (user)
return (
<a
href="https://console.hanzo.ai"
title={user.name || user.email || 'Account'}
className="grid size-7 place-items-center rounded-full bg-fd-primary text-xs font-medium text-fd-primary-foreground"
>
{(user.name || user.email || 'A').slice(0, 1).toUpperCase()}
</a>
)
// BOTH, and sign in first — the same order the column at the foot of the rail
// uses. Carrying only the key made this the one place the two controls
// disagreed: on the front page and on every phone, "API key" stood alone with
// no way to sign in beside it, so the commitment was the only thing offered.
//
// Short labels, because the bar is a fixed 56px row: the full phrase is what
// reflowed to three lines and spilled between 768 and 805px, which is why the
// long form lives in the column and this one does not.
return (
<div className="flex items-center gap-2">
<Link
href="/login"
className="rounded-lg px-2 py-1.5 text-sm text-fd-muted-foreground whitespace-nowrap transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground max-sm:hidden"
>
Sign in
</Link>
<a
href="https://console.hanzo.ai"
className="rounded-lg bg-fd-primary px-2.5 py-1.5 text-sm font-medium text-fd-primary-foreground whitespace-nowrap transition-colors hover:opacity-90"
>
API key
</a>
</div>
)
}
-47
View File
@@ -1,47 +0,0 @@
'use client';
import { Search } from 'lucide-react';
import { useSearchContext } from '@hanzo/docs-base-ui/contexts/search';
/**
* The one way to search, at the top of the sidebar.
*
* This slot used to hold a filter that matched page titles and URLs offline and
* stood in place of the tree. It was a second search that could not find what
* the first one could: a reader who remembered a sentence rather than a title
* got nothing, with no hint that full text lived somewhere else.
*
* So it is not a second search any more — it is the same dialog ⌘K opens,
* wearing the shape of a field. Full text, every page, and the AI answer on the
* same surface, because "find the page" and "answer my question" are one
* intention and the reader should not have to pick a mode before they know
* which one they wanted.
*
* It renders as a BUTTON, not an input. An input would take focus and swallow
* the first keystrokes before the dialog mounted, and a field you cannot type
* into is worse than a button that looks like one.
*/
export function SidebarSearch() {
const { setOpenSearch } = useSearchContext();
return (
<button
type="button"
onClick={() => setOpenSearch(true)}
// Icon, label, shortcut. The label used to claim the middle with `flex-1`;
// the middle track claims it now, so the label is just text and the two
// fixed ends cannot be squeezed by it.
className="w-full rounded-lg border bg-fd-secondary/50 px-2.5 py-1.5 text-start transition-colors hover:bg-fd-accent"
style={{
display: 'grid',
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
alignItems: 'center',
columnGap: 8,
}}
>
<Search className="size-4 text-fd-muted-foreground" />
<span className="text-sm text-fd-muted-foreground">Search or ask AI</span>
<kbd className="rounded border px-1.5 py-0.5 text-[10px] text-fd-muted-foreground">⌘K</kbd>
</button>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useState, type ReactNode } from 'react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { ChevronDown } from '@hanzogui/lucide-icons-2';
import { useActiveAnchor, type TOCItemType } from '@hanzo/docs-core/toc';
import { BAR } from '@/components/shell';
import { loud, muted } from '@/lib/ink';
/**
* "On this page", twice over: a column beside the article from 1280px up, and a
* row under the bar below that, which opens to the same list. Both highlight the
* one heading the reader is at, which docs-core's anchor observer decides.
*
* The column starts level with the page title and its label sits directly on
* the list — a label with a gap under it reads as belonging to nothing.
*/
export const OUTLINE = 240;
export function Outline({ toc }: { toc: TOCItemType[] }) {
const at = useActiveAnchor();
return (
<YStack
render="nav"
aria-label="On this page"
position="sticky"
t={BAR}
width={OUTLINE}
shrink={0}
maxH={`calc(100dvh - ${BAR}px)`}
pt={40}
pb={24}
pr={24}
gap={10}
$max-xl={{ display: 'none' }}
>
<Text fontSize="$2" lineHeight={18} fontWeight="500" color="$color12">
On this page
</Text>
<YStack minH={0} overflowY="auto" borderLeftWidth={1} borderColor="$borderColor">
<Items toc={toc} at={at} />
</YStack>
</YStack>
);
}
export function Fold({ toc }: { toc: TOCItemType[] }) {
const at = useActiveAnchor();
const [open, setOpen] = useState(false);
const current = toc.find((item) => item.url === `#${at}`);
return (
<YStack
position="sticky"
t={BAR}
z={20}
bg="$background"
borderBottomWidth={1}
borderColor="$borderColor"
$xl={{ display: 'none' }}
>
<XStack
render="button"
type="button"
aria-expanded={open}
onPress={() => setOpen(!open)}
height={44}
px={48}
gap={8}
items="center"
bg="transparent"
borderWidth={0}
cursor="pointer"
$max-lg={{ px: 32 }}
$max-md={{ px: 20 }}
>
<Text fontSize="$2" lineHeight={18} fontWeight="500" color="$color12" shrink={0}>
On this page
</Text>
<Text flex={1} minW={0} fontSize="$2" lineHeight={18} {...muted} numberOfLines={1} text="left">
{!open && current ? current.title : null}
</Text>
<YStack rotate={open ? '180deg' : '0deg'}>
<ChevronDown size={14} color="$color10" />
</YStack>
</XStack>
{open && (
<YStack
maxH="50vh"
overflowY="auto"
mx={48}
mb={12}
borderLeftWidth={1}
borderColor="$borderColor"
$max-lg={{ mx: 32 }}
$max-md={{ mx: 20 }}
onPress={() => setOpen(false)}
>
<Items toc={toc} at={at} />
</YStack>
)}
</YStack>
);
}
function Items({ toc, at }: { toc: TOCItemType[]; at: string | undefined }) {
return (
<>
{toc.map((item) => (
<Item key={item.url} item={item} on={item.url === `#${at}`} />
))}
</>
);
}
function Item({ item, on }: { item: TOCItemType; on: boolean }) {
return (
<Text
render="a"
href={item.url}
ml={-1}
py={5}
pl={12 + Math.max(0, item.depth - 2) * 12}
pr={4}
fontSize="$2"
lineHeight={18}
borderLeftWidth={1}
borderColor={on ? '$color12' : 'transparent'}
{...(on ? loud : muted)}
hoverStyle={{ color: '$color12' }}
>
{item.title as ReactNode}
</Text>
);
}
+308
View File
@@ -0,0 +1,308 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useMemo, useState, type ReactNode } from 'react';
import { Text, XStack, YStack } from '@hanzo/gui';
import { ArrowLeft, ChevronRight } from '@hanzogui/lucide-icons-2';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { same, useTree } from '@/lib/tree';
import { loud, muted } from '@/lib/ink';
/**
* The page tree, as the rail draws it.
*
* A separator in meta.json (`---Build with---`) heads the pages after it, so a
* run of pages under one separator is drawn as one section that folds. A page
* inside a top-level folder narrows the rail to that folder, with a way back to
* the top — the tree lists ~1,600 pages, and the folder you are in is the part
* of it you need.
*
* Every row is 32px and every level indents 12px under a hairline, so depth
* reads from the left edge rather than from type size.
*/
export function Tree() {
const { tree, path } = useTree();
const pathname = usePathname();
// `path` rather than the address: an operation page is not in the tree, and its
// path is its product page's, so the rail narrows to the reference it is in.
const scope = useMemo(() => {
const folder = tree.children.find(
(node): node is PageTree.Folder => node.type === 'folder' && path.includes(node),
);
if (!folder) return undefined;
const home = tree.children.find((node): node is PageTree.Item => node.type === 'page');
return { folder, home };
}, [tree, path]);
if (!scope) return <List nodes={tree.children} />;
const { folder, home } = scope;
const here = folder.index !== undefined && same(folder.index.url, pathname);
return (
<YStack gap={2}>
{home && (
<Row href={home.url} active={false}>
<ArrowLeft size={14} color="$color10" />
<Label active={false}>{tree.name}</Label>
</Row>
)}
{folder.index ? (
<Row href={folder.index.url} active={here} mt={8}>
<Text flex={1} minW={0} fontSize="$3" lineHeight={20} fontWeight="600" color="$color12" text="left">
{folder.name}
</Text>
</Row>
) : (
<Text px={10} pt={12} pb={4} fontSize="$3" lineHeight={20} fontWeight="600" color="$color12">
{folder.name}
</Text>
)}
<List nodes={folder.children} />
</YStack>
);
}
/** Nodes in order, with each separator folding the pages that follow it. */
function List({ nodes }: { nodes: PageTree.Node[] }) {
const out: ReactNode[] = [];
for (let i = 0; i < nodes.length; ) {
const node = nodes[i];
if (node.type !== 'separator') {
out.push(<Node key={node.$id ?? i} node={node} />);
i++;
continue;
}
let end = i + 1;
while (end < nodes.length && nodes[end].type !== 'separator') end++;
const run = nodes.slice(i + 1, end);
out.push(
run.length > 0 ? (
<Section key={node.$id ?? i} name={node.name} nodes={run} />
) : null,
);
i = end;
}
return <YStack gap={2}>{out}</YStack>;
}
function Node({ node }: { node: PageTree.Item | PageTree.Folder }) {
const pathname = usePathname();
if (node.type === 'page')
return (
<Row href={node.url} active={same(node.url, pathname)} external={node.external}>
<Label active={same(node.url, pathname)}>{node.name}</Label>
</Row>
);
return <Folder node={node} />;
}
function Section({ name, nodes }: { name: ReactNode; nodes: PageTree.Node[] }) {
const { path } = useTree();
const [open, setOpen] = useFold(nodes.some((node) => path.includes(node)));
return (
<YStack>
<Toggle open={open} onPress={() => setOpen(!open)}>
<Text flex={1} fontSize="$3" lineHeight={20} fontWeight="500" color="$color12" numberOfLines={1} text="left">
{name}
</Text>
</Toggle>
{open && (
<Indent>
<List nodes={nodes} />
</Indent>
)}
</YStack>
);
}
function Folder({ node }: { node: PageTree.Folder }) {
const pathname = usePathname();
const { path } = useTree();
const active = node.index !== undefined && same(node.index.url, pathname);
const fixed = node.collapsible === false;
const [open, setOpen] = useFold(fixed || path.includes(node) || node.defaultOpen === true);
const body = (
<Indent>
<List nodes={node.children} />
</Indent>
);
if (!node.index)
return (
<YStack>
<Toggle open={open} onPress={() => setOpen(!open)} disabled={fixed}>
<Label active={false}>{node.name}</Label>
</Toggle>
{(open || fixed) && body}
</YStack>
);
return (
<YStack>
<XStack items="center">
<Row
href={node.index.url}
active={active}
flex={1}
onPress={() => setOpen(active ? !open : true)}
>
<Label active={active}>{node.name}</Label>
</Row>
{!fixed && (
<Chevron open={open} onPress={() => setOpen(!open)} label={open ? 'Collapse' : 'Expand'} />
)}
</XStack>
{(open || fixed) && body}
</YStack>
);
}
/** Open state that follows the reader: a fold that comes to hold the page opens. */
function useFold(want: boolean): [boolean, (v: boolean) => void] {
const [open, setOpen] = useState(want);
const [was, setWas] = useState(want);
if (want !== was) {
setWas(want);
if (want) setOpen(true);
}
return [open, setOpen];
}
function Row({
href,
active,
external,
children,
...props
}: {
href: string;
active: boolean;
external?: boolean;
children: ReactNode;
flex?: number;
mt?: number;
onPress?: () => void;
}) {
return (
<XStack
render={
external ? (
<a href={href} target="_blank" rel="noreferrer noopener" />
) : (
<Link href={href} prefetch={false} />
)
}
data-active={active ? 'true' : undefined}
aria-current={active ? 'page' : undefined}
minH={32}
py={6}
px={10}
gap={8}
rounded="$3"
items="center"
minW={0}
bg={active ? '$hover' : 'transparent'}
hoverStyle={{ bg: '$hover' }}
{...props}
>
{children}
</XStack>
);
}
function Label({ active, children }: { active: boolean; children: ReactNode }) {
return (
<Text
flex={1}
minW={0}
fontSize="$3"
lineHeight={20}
fontWeight={active ? '500' : '400'}
{...(active ? loud : muted)}
text="left"
>
{children}
</Text>
);
}
function Toggle({
open,
onPress,
disabled,
children,
}: {
open: boolean;
onPress: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<XStack
render="button"
type="button"
aria-expanded={open}
disabled={disabled}
onPress={disabled ? undefined : onPress}
minH={32}
py={6}
px={10}
gap={8}
rounded="$3"
items="center"
bg="transparent"
borderWidth={0}
cursor={disabled ? 'default' : 'pointer'}
hoverStyle={disabled ? undefined : { bg: '$hover' }}
>
{children}
{!disabled && <Turn open={open} />}
</XStack>
);
}
function Chevron({ open, onPress, label }: { open: boolean; onPress: () => void; label: string }) {
return (
<XStack
render="button"
type="button"
aria-label={label}
aria-expanded={open}
onPress={onPress}
width={28}
height={28}
ml={2}
shrink={0}
rounded="$3"
items="center"
justify="center"
bg="transparent"
borderWidth={0}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<Turn open={open} />
</XStack>
);
}
function Turn({ open }: { open: boolean }) {
return (
<YStack shrink={0} rotate={open ? '90deg' : '0deg'}>
<ChevronRight size={14} color="$color10" />
</YStack>
);
}
function Indent({ children }: { children: ReactNode }) {
return (
<YStack ml={16} pl={6} mt={2} borderLeftWidth={1} borderColor="$borderColor">
{children}
</YStack>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
icon: Book
title: Documentation
description: Build Anything with Hanzo. Every model, every tool, one key — in our cloud or on your own machine.
description: Build anything with Hanzo. Every model, every tool, one key — in our cloud or on your own machine.
---
import { Callout } from '@hanzo/docs-base-ui/components/callout'
+12
View File
@@ -0,0 +1,12 @@
/**
* The two text inks the chrome uses beside `$color12`.
*
* gui's grey ramp is not symmetric: dark's step 10 is #ababab, a muted grey,
* while light's step 10 is #333, a near-black that sits a hair from the #0a0a0a
* of a title and flattens every hierarchy it is used in. So muted text names a
* step per theme — 10 in dark, 9 (#4d4d4d) in light — here, once. Both clear
* 7:1 on their ground.
*/
export const muted = { color: '$color10', '$theme-light': { color: '$color9' } } as const;
export const loud = { color: '$color12' } as const;
+1 -1
View File
@@ -12,7 +12,7 @@ export function inlineCode(s: string | undefined): ReactNode {
if (parts.length < 3 || parts.length % 2 === 0) return s;
return parts.map((part, i) =>
i % 2 ? (
<code key={i} className="font-mono text-[0.9em]">
<code key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: '0.9em' }}>
{part}
</code>
) : (
+28
View File
@@ -0,0 +1,28 @@
/**
* The docs top nav: the whole thing, the guided path through it, then the
* surfaces a reader writes against. On-site only — nothing here leaves for
* hanzo.ai mid-page.
*
* CLI points at /docs/cli, the `hanzo` binary. `exact` is for /docs, which
* prefixes every page and would otherwise stay lit everywhere.
*/
export interface Nav {
text: string;
url: string;
exact?: boolean;
}
export const nav: Nav[] = [
{ text: 'Docs', url: '/docs', exact: true },
{ text: 'Guides', url: '/docs/guides' },
{ text: 'Models', url: '/docs/models' },
{ text: 'APIs', url: '/docs/openapi' },
{ text: 'SDKs', url: '/docs/sdks' },
{ text: 'CLI', url: '/docs/cli' },
{ text: 'MCP', url: '/docs/mcp' },
];
export function lit(item: Nav, pathname: string): boolean {
const path = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname;
return item.exact ? path === item.url : path === item.url || path.startsWith(`${item.url}/`);
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { remark } from 'remark';
import remarkMdx from 'remark-mdx';
import type { Root } from 'mdast';
import { remarkTitle } from './remark-title';
// The page header prints the frontmatter title, so a body that opens with its
// own `# heading` loses that heading and keeps every other one.
const headings = (mdx: string) => {
const tree = remark().use(remarkMdx).parse(mdx) as Root;
remarkTitle()(tree);
return tree.children
.filter((node) => node.type === 'heading')
.map((node) => `${node.depth}:${(node.children[0] as { value: string }).value}`);
};
describe('remarkTitle', () => {
it('drops the heading that opens the body', () => {
expect(headings('# SDKs\n\nText.\n\n## Install\n')).toEqual(['2:Install']);
});
it('looks past imports to find the opening', () => {
expect(headings("import { Cards } from 'x'\n\n# Every capability\n\n## Domains\n")).toEqual(['2:Domains']);
});
it('keeps an h1 that does not open the body', () => {
expect(headings('Intro.\n\n# Later\n')).toEqual(['1:Later']);
});
it('keeps a body that opens with a lower heading', () => {
expect(headings('## Install\n\n# Appendix\n')).toEqual(['2:Install', '1:Appendix']);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { Root } from 'mdast';
/**
* One title per page.
*
* The page header prints the frontmatter's `title`. A body that opens with its
* own `# heading` printed a second one under it — /docs/sdks said "SDKs" twice,
* /docs/openapi said "Capabilities" and then "Every capability". So a depth-1
* heading that OPENS the body (nothing but imports, exports and frontmatter
* before it) is dropped. A `#` further down is the author's and stays.
*/
const PREAMBLE = new Set(['mdxjsEsm', 'yaml', 'toml']);
export function remarkTitle() {
return (tree: Root) => {
const at = tree.children.findIndex((node) => !PREAMBLE.has(node.type));
const first = tree.children[at];
if (first?.type === 'heading' && first.depth === 1) tree.children.splice(at, 1);
};
}
+1 -1
View File
@@ -51,7 +51,7 @@ function pageTreeCodeTitles(): LoaderPlugin {
return {
...node,
name: (
<code key="0" className="text-[0.8125rem]">
<code key="0" style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>
{node.name}
</code>
),
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { createContext, use, useMemo, type ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { searchNearestPath, searchPath } from '@hanzo/docs-core/breadcrumb';
/**
* The page tree the chrome draws, and where the reader is in it.
*
* `path` runs from the top of the tree to the page being read. A generated
* operation page is not in the tree, so it borrows the path of the nearest page
* above it (its product page) — the rail then opens where that page opens.
*/
interface Tree {
tree: PageTree.Root;
path: PageTree.Node[];
}
const Context = createContext<Tree | null>(null);
export function TreeProvider({ tree, children }: { tree: PageTree.Root; children: ReactNode }) {
const pathname = usePathname();
const path = useMemo(
() => searchPath(tree.children, pathname) ?? searchNearestPath(tree.children, pathname) ?? [],
[tree, pathname],
);
return <Context value={useMemo(() => ({ tree, path }), [tree, path])}>{children}</Context>;
}
export function useTree(): Tree {
const tree = use(Context);
if (!tree) throw new Error('useTree needs <TreeProvider> above it');
return tree;
}
/** `/docs/a/` and `/docs/a` are one page. */
export function same(href: string, pathname: string): boolean {
const trim = (s: string) => (s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s);
return trim(href) === trim(pathname);
}
+8 -14
View File
@@ -33,14 +33,14 @@ const assetPrefix = sectionAssetPrefix[process.env.DOCS_SECTION ?? ''];
// Used as the resolution target for unresolvable upstream doc platform packages.
const emptyProjectModule = path.resolve(__dirname, 'lib/empty-project-module.js');
// On the web `react-native` IS `react-native-web`. @hanzo/gui is cross-platform
// and its packages name `react-native` (@hanzogui/scroll-view 8.3.5, under the
// landing page's cards, imports it outright); resolved as written, the bundler
// parses react-native's Flow source and `/` fails to compile with "Expected
// 'from', got 'typeOf'". @hanzo/ui/next states the mapping once, for every app on
// the stack; both bundlers below take it from there. The module ships no types.
// @hanzo/gui is cross-platform, so its graph names `react-native` and ships
// `.web.*` siblings beside native entries (react-native-svg under the icons is
// the one that bites: resolved natively it is Flow source and the build dies on
// "Expected ',', got '{'"). @hanzo/ui/next is the one statement of what a web
// build needs — the alias, `.web.*` first, and the gui graph transpiled — for
// both bundlers at once; the export below wraps this config in it.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { GUI_ALIAS } = require('@hanzo/ui/next') as { GUI_ALIAS: Record<string, string> };
const withGui = require('@hanzo/ui/next') as <T>(config: T, dir: string) => T;
const isExport = process.env.NEXT_EXPORT === '1';
@@ -69,7 +69,6 @@ const config: NextConfig = {
turbopack: {
resolveAlias: {
...collectionsAlias,
...GUI_ALIAS,
// Ported docs written against the upstream framework name still say
// `fumadocs-ui`; those components are ours, under our name. They resolve to
// the base-ui adapter because that is the one this site renders with — the
@@ -147,7 +146,6 @@ const config: NextConfig = {
// ------------------------------------------------------------------ //
config.resolve.alias = {
...config.resolve.alias,
...GUI_ALIAS,
// Virtual collection modules (internal). Both the namespaced specifier
// and the bare `collections/*` specifier resolve to the generated source
@@ -272,10 +270,6 @@ const config: NextConfig = {
'@hanzo/docs-openapi',
'@hanzogui/shell',
'@hanzo/brand',
// @hanzo/ui ships 'use client' ESM against React 19; the app build has to
// compile it for the client/server split and the JSX runtime to resolve,
// the same reason the shell above is listed.
'@hanzo/ui',
],
serverExternalPackages: [
'ts-morph',
@@ -319,4 +313,4 @@ const withMDX = createMDX();
// identical types are nominally distinct here. (The comment this replaces blamed
// a next@16-vs-next@15 skew that no longer exists — both are on 16.2.12.)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default withAnalyzer(withMDX(config as any) as any);
export default withAnalyzer(withMDX(withGui(config, __dirname) as any) as any);
+2 -1
View File
@@ -39,6 +39,8 @@
"@hanzo/iam": "catalog:",
"@hanzo/logo": "catalog:",
"@hanzo/ui": "catalog:",
"@hanzogui/lucide-icons-2": "catalog:",
"@hanzogui/next-theme": "catalog:",
"@hanzogui/shell": "catalog:",
"@llmgateway/ai-sdk-provider": "catalog:",
"@openrouter/ai-sdk-provider": "catalog:",
@@ -62,7 +64,6 @@
"mermaid": "catalog:",
"motion": "catalog:",
"next": "catalog:",
"next-themes": "catalog:",
"octokit": "catalog:",
"oxc-transform": "catalog:",
"react": "catalog:",
+2
View File
@@ -11,6 +11,7 @@ import { visit } from 'unist-util-visit';
import type { Transformer } from 'unified';
import type { Root } from 'mdast';
import { remarkFixInternalLinks } from './lib/remark-fix-links';
import { remarkTitle } from './lib/remark-title';
import { rehypeExample } from './lib/rehype-example';
const isLint = process.env.LINT === '1';
@@ -124,6 +125,7 @@ export const docs = defineDocs({
remarkPlugins: isLint
? [remarkElementIds]
: [
remarkTitle,
remarkFixInternalLinks,
remarkPassthroughUnknownJsx,
remarkSteps,
+4 -2
View File
@@ -89,7 +89,8 @@
}
}
.dark .shiki:not(.not-hanzo-codeblock *) {
.dark .shiki:not(.not-hanzo-codeblock *),
.t_dark .shiki:not(.not-hanzo-codeblock *) {
code span {
color: var(--shiki-dark);
font-style: var(--shiki-dark-font-style);
@@ -102,7 +103,8 @@ code.shiki:not(.not-hanzo-code *) span {
font-style: var(--shiki-light-font-style);
}
.dark code.shiki:not(.not-hanzo-code *) span {
.dark code.shiki:not(.not-hanzo-code *) span,
.t_dark code.shiki:not(.not-hanzo-code *) span {
color: var(--shiki-dark);
font-style: var(--shiki-dark-font-style);
}
+48 -16
View File
@@ -78,6 +78,12 @@ catalogs:
'@hanzo/ui':
specifier: 8.27.18
version: 8.27.18
'@hanzogui/lucide-icons-2':
specifier: 8.3.5
version: 8.3.5
'@hanzogui/next-theme':
specifier: 8.3.5
version: 8.3.5
'@hanzogui/shell':
specifier: 8.3.5
version: 8.3.5
@@ -928,7 +934,7 @@ importers:
version: link:../../packages/mdx-runtime
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/shell':
specifier: ^7.6.3
version: 7.6.5(@hanzo/[email protected]([email protected]))([email protected]([email protected]))([email protected])
@@ -1361,7 +1367,13 @@ importers:
version: 1.0.26([email protected])
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/lucide-icons-2':
specifier: 'catalog:'
version: 8.3.5([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])
'@hanzogui/next-theme':
specifier: 'catalog:'
version: 8.3.5([email protected](@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])
'@hanzogui/shell':
specifier: 'catalog:'
version: 8.3.5([email protected]([email protected]))([email protected])
@@ -1431,9 +1443,6 @@ importers:
next:
specifier: 'catalog:'
version: 16.3.3(@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: 'catalog:'
version: 0.4.6([email protected]([email protected]))([email protected])
octokit:
specifier: 'catalog:'
version: 5.0.5
@@ -1599,7 +1608,7 @@ importers:
version: link:../../packages/mdx-runtime
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/shell':
specifier: ^7.6.3
version: 7.6.5(@hanzo/[email protected]([email protected]))([email protected]([email protected]))([email protected])
@@ -1775,7 +1784,7 @@ importers:
version: link:../../packages/mdx
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/shell':
specifier: ^7.6.3
version: 7.6.5(@hanzo/[email protected]([email protected]))([email protected]([email protected]))([email protected])
@@ -1854,7 +1863,7 @@ importers:
version: link:../../packages/mdx-runtime
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/shell':
specifier: ^7.6.3
version: 7.6.5(@hanzo/[email protected]([email protected]))([email protected]([email protected]))([email protected])
@@ -2081,7 +2090,7 @@ importers:
version: link:../../packages/mdx
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/shell':
specifier: ^7.6.3
version: 7.6.5(@hanzo/[email protected]([email protected]))([email protected]([email protected]))([email protected])
@@ -2172,7 +2181,7 @@ importers:
version: link:../../packages/mdx-runtime
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
'@hanzogui/shell':
specifier: ^7.6.3
version: 7.6.5(@hanzo/[email protected]([email protected]))([email protected]([email protected]))([email protected])
@@ -3435,7 +3444,7 @@ importers:
version: 1.9.2([email protected](@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(377d4171ce1b3e7dff0b1e051e337fbb)
version: 8.27.18(69aa96667453d6f1bea96249b73f6d39)
gray-matter:
specifier: 'catalog:'
version: 4.0.3
@@ -6371,7 +6380,7 @@ importers:
dependencies:
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.18(373b44d141835356d8012e3b9dfd6982)
version: 8.27.18(0f5a7bb8315b57e6133ce7dca7fd4cd4)
'@zenlm/models':
specifier: 'catalog:'
version: 1.0.4
@@ -9251,6 +9260,12 @@ packages:
peerDependencies:
react: '>=19.2.8'
'@hanzogui/[email protected]':
resolution: {integrity: sha512-l3xMQ7ZGgIImgIJJpgTtrtwDlwyU1xy0/QhIJZ192UFsICQ7wO/k/sI8cy8Lc7xzasM3ASQs9uCtTNKsWCSnOg==}
peerDependencies:
next: '>=16.3.3'
react: '>=19.2.8'
'@hanzogui/[email protected]':
resolution: {integrity: sha512-O3NBnMTVZbPgEnH0AA3Zxr61obWy/81xHDTBWBOBkiRQuTDxmkYLjXPO+RxtYLBiT8OKv9mcfOLu8mjhgJegQg==}
@@ -24186,7 +24201,7 @@ snapshots:
'@hanzo/[email protected](@hanzo/[email protected]([email protected]([email protected]))([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])':
dependencies:
'@hanzo/gui': 8.3.5([email protected]([email protected]))([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
'@hanzogui/lucide-icons-2': 8.3.1([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])
'@hanzogui/lucide-icons-2': 8.3.5([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])
react: 19.2.8
transitivePeerDependencies:
- react-dom
@@ -24438,7 +24453,7 @@ snapshots:
'@hanzo/[email protected]([email protected])':
dependencies:
'@hanzo/event': 0.3.37([email protected])
'@hanzo/event': 0.3.41([email protected])
optionalDependencies:
react: 19.2.8
@@ -24452,7 +24467,7 @@ snapshots:
'@hanzogui/lucide-icons-2': 8.3.5([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])
react: 19.2.8
'@hanzo/[email protected](373b44d141835356d8012e3b9dfd6982)':
'@hanzo/[email protected](0f5a7bb8315b57e6133ce7dca7fd4cd4)':
dependencies:
'@hanzo/appearance': 0.2.2([email protected])
'@hanzo/brand': 1.4.8([email protected])
@@ -24474,13 +24489,14 @@ snapshots:
react-native-svg: 15.15.5([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
react-native-web: 0.21.2([email protected]([email protected]))([email protected])
optionalDependencies:
'@hanzogui/next-theme': 8.3.5([email protected](@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])
next: 16.3.4(@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-themes: 0.4.6([email protected]([email protected]))([email protected])
transitivePeerDependencies:
- encoding
- react-native
'@hanzo/[email protected](377d4171ce1b3e7dff0b1e051e337fbb)':
'@hanzo/[email protected](69aa96667453d6f1bea96249b73f6d39)':
dependencies:
'@hanzo/appearance': 0.2.2([email protected])
'@hanzo/brand': 1.4.8([email protected])
@@ -24502,6 +24518,7 @@ snapshots:
react-native-svg: 15.15.5([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
react-native-web: 0.21.2([email protected]([email protected]))([email protected])
optionalDependencies:
'@hanzogui/next-theme': 8.3.5([email protected](@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])
next: 16.3.3(@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-themes: 0.4.6([email protected]([email protected]))([email protected])
transitivePeerDependencies:
@@ -25854,6 +25871,21 @@ snapshots:
dependencies:
react: 19.2.8
'@hanzogui/[email protected]([email protected](@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@hanzogui/constants': 8.3.5([email protected])
'@hanzogui/use-event': 8.3.5([email protected])
next: 16.3.3(@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
react: 19.2.8
'@hanzogui/[email protected]([email protected](@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@hanzogui/constants': 8.3.5([email protected])
'@hanzogui/use-event': 8.3.5([email protected])
next: 16.3.4(@babel/[email protected]([email protected]))(@opentelemetry/[email protected])(@playwright/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
react: 19.2.8
optional: true
'@hanzogui/[email protected]':
dependencies:
'@react-native/normalize-color': 2.1.0
+2
View File
@@ -63,6 +63,8 @@ catalog:
'@hanzo/mdx': '14.2.6'
'@hanzo/products': '0.2.6'
'@hanzo/ui': '8.27.18'
'@hanzogui/lucide-icons-2': '8.3.5'
'@hanzogui/next-theme': '8.3.5'
'@hanzogui/shell': '8.3.5'
'@hono/node-server': '^2.1.1'
'@hono/node-ws': '^1.3.1'