mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+48
-16
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user