docs: rebuild the documentation site around two readers

The site had drifted from the product. Every screenshot predated the
v0.5.0 UI redesign, the reading experience shipped no search and no
syntax highlighting, and a third of the pages were internal process
artefacts — migration task lists addressed to agentic workers, a
release runbook, a proposal marked "historical".

Reorganise around the only two people who read this: someone getting
the desktop app running for the first time, and someone reading the
source. Five sections replace nine — start / desktop / im / cli /
internals — and the pages that served neither reader are gone.

Site rewrite:

- Palette lifted from the desktop app's 「纸·墨·印」 themes, so the
  site and the product read as one thing. Light mirrors 纯白, dark
  mirrors 墨夜, and dark mode exists at all now.
- Fonts are self-hosted. The old @import from Google Fonts is
  unreachable from mainland China, which left every heading in a
  fallback serif; it also only requested weight 600 while the CSS
  asked for 900, so Latin and CJK in the same heading disagreed.
- Docs were shipped as one 968KB manifest downloaded on every page
  view. Split into a 32KB index plus one lazily imported chunk per
  page; the entry bundle is now 101KB gzipped.
- Add search, syntax highlighting, per-route meta with canonical and
  hreflang, a sitemap, and an error boundary. Replace the 44vh
  mobile sidebar with a drawer.
- Image dimensions are read at build time and written into the tag,
  so lazy images reserve their space instead of collapsing.

Screenshots are recaptured from a real v0.5.0 build against a clean
demo project, with tokens, QR codes and paired accounts redacted.
The previous set is deleted rather than kept alongside.

Routes follow file paths, so the restructure would have broken every
inbound link; 37 old paths redirect, in both languages. The PR policy
gate and CODEOWNERS also hardcoded docs/guide/contributing.md.

Verified: check:docs 78 pages / 323 links / 0 problems, check:policy
127 pass. Walked every route at 1440 and 390 in both themes for
overflow, contrast, keyboard reachability and focus management.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-27 17:32:41 +08:00
parent c64f69972d
commit c2cd615824
299 changed files with 9059 additions and 11274 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
dist/
node_modules/
src/generated/docs-manifest.js
src/generated/
+33 -1
View File
@@ -2,9 +2,41 @@
These rules apply to the public landing page and documentation experience under `site/`.
## Contracts that must not break
- Keep the site independently installable with `npm ci` and buildable with `npm run build`.
- Keep `npm run check` deterministic, offline, and responsible for site-specific validation beyond compilation.
- Preserve the GitHub Pages custom-domain contract; production assets and routes must work from the root of `claudecode-haha.relakkesyang.org`.
- Preserve the GitHub Pages custom-domain contract; production assets and routes must work from the root of `claudecode-haha.relakkesyang.org`. `scripts/prepare-static-output.mjs` hard-fails when the CNAME drifts.
- Treat files under `docs/` as the source of truth for long-form Chinese and English documentation. Keep paired public routes aligned when both languages exist.
- Do not copy private user state, credentials, local filesystem paths, or unredacted product screenshots into the site.
- Run `bun run check:docs` after site or docs changes and include desktop plus narrow-mobile browser evidence for user-visible layout changes.
## How content reaches the site
`scripts/generate-docs-manifest.mjs` scans `docs/`, then emits three things into `src/generated/` (gitignored):
- `docs-index.js` — the eager index: route, section, title, `nav_title`, description, `order`. Keep it small; it ships on every page.
- `content/<id>.js` — one module per document holding the markdown **body** (frontmatter already stripped). `docsContent` maps a route to a dynamic import, so opening one page never downloads the rest.
- `search-index.js` — lazily imported by the search dialog only.
Routes come from file paths (`docs/start/install.md` → `/start/install`). Renaming a file renames its URL, so add the old path to both `LEGACY_ROUTES` in `src/content/docs.js` and `legacyRoutes` in `scripts/prepare-static-output.mjs`.
Sidebar grouping comes from the `sections` array in the generator — register any new top-level `docs/` directory there or it sorts last with a bare directory name. Order inside a group comes from each document's `order` frontmatter.
## Design system
`src/styles/base.css` holds every token. The palette is lifted from the desktop app's 「纸·墨·印」 themes — light mirrors 纯白, dark mirrors 墨夜 — so the site and the app read as one product. Rules:
- Use tokens (`--surface-*`, `--text-*`, `--border*`, `--brand*`, `--sp-*`, `--fs-*`, `--r-*`) rather than literal values. A raw hex in a component is a bug.
- The primary button is ink (`--ink`), turning terracotta (`--brand`) on hover. It is not brand-coloured at rest.
- Depth comes from 1px borders and surface layering, not heavy shadows.
- Both themes must work. `data-theme` on `<html>` is set by the bootstrap script in `index.html` before first paint.
- Breakpoints are 1180 / 900 / 620 across both stylesheets. Do not introduce a fourth.
## Mermaid
````mermaid fences render as diagrams. Nothing under `docs/` currently uses one — the two pages that did were deleted in the July 2026 restructure — so the dependency and its `.doc-mermaid` styles sit unused, lazily loaded and costing readers nothing. Keep them: `internals/` is exactly where a diagram would earn its place, and the mermaid theme is already wired to the site tokens.
## Fonts
Self-hosted in `public/fonts/`, copied from `desktop/public/fonts/`. **Never add a Google Fonts `@import` or `<link>`** — it is unreachable from mainland China and would leave every heading in a fallback serif. Only the latin subsets are hosted; Chinese glyphs fall through to the platform font on purpose, exactly as the desktop app does.
+23 -13
View File
@@ -3,22 +3,33 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#f3efe4">
<meta
name="description"
content="Claude Code Haha — 把 Main Session、多 Agent、桌面宠物、Computer Use 和远程协作带进一个真正能工作的桌面应用。"
>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#201d17" media="(prefers-color-scheme: dark)">
<meta name="description" content="Claude Code Haha — Claude Code 的本地优先桌面客户端。会话、改动、Agent、定时任务都摆在明处,接哪个模型你说了算。">
<link rel="icon" href="/images/app-icon.png" type="image/png">
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/noto-serif-sc-latin.woff2" as="font" type="font/woff2" crossorigin>
<meta property="og:type" content="website">
<meta property="og:title" content="Claude Code Haha — Claude Code 的桌面工作室">
<meta
property="og:description"
content="主会话、代码审阅、Agent、桌面宠物、Computer Use、远程入口与自动任务,都在一个本地优先的桌面应用里。"
>
<meta property="og:site_name" content="Claude Code Haha">
<meta property="og:title" content="Claude Code Haha — Claude Code 的本地优先桌面客户端">
<meta property="og:description" content="会话、改动、Agent、定时任务都摆在明处。接哪个模型你说了算,改哪一行你点头才算。">
<meta property="og:image" content="https://claudecode-haha.relakkesyang.org/images/banner.png">
<meta name="twitter:card" content="summary_large_image">
<title>Claude Code Haha — Claude Code 的本地优先桌面客户端</title>
<script>
// 首帧就把主题定下来,避免深色偏好的用户先闪一下白屏。
(function () {
try {
var stored = localStorage.getItem('cch-theme')
var theme = stored === 'dark' || stored === 'light'
? stored
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.dataset.theme = theme
} catch (error) {
document.documentElement.dataset.theme = 'light'
}
})()
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-D42DM82263"></script>
<script>
window.dataLayer = window.dataLayer || []
@@ -26,7 +37,6 @@
gtag('js', new Date())
gtag('config', 'G-D42DM82263')
</script>
<title>Claude Code Haha — 会工作的桌面 AI 搭档</title>
</head>
<body>
<div id="root"></div>
+10
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"dompurify": "^3.3.0",
"highlight.js": "^11.11.1",
"marked": "^17.0.5",
"mermaid": "^11.14.0",
"react": "^19.2.4",
@@ -1283,6 +1284,15 @@
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
"license": "MIT"
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"dompurify": "^3.3.0",
"highlight.js": "^11.11.1",
"marked": "^17.0.5",
"mermaid": "^11.14.0",
"react": "^19.2.4",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+162 -151
View File
@@ -1,60 +1,38 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { fileURLToPath } from 'node:url'
import { readImageSize } from './image-size.mjs'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const siteDir = path.resolve(scriptDir, '..')
const repoDir = path.resolve(siteDir, '..')
const docsDir = path.join(repoDir, 'docs')
const outputPath = path.join(siteDir, 'src', 'generated', 'docs-manifest.js')
const generatedDir = path.join(siteDir, 'src', 'generated')
const contentDir = path.join(generatedDir, 'content')
export const paths = {
contentDir,
docsDir,
outputPath,
generatedDir,
repoDir,
siteDir
}
const excludedDirectoryNames = new Set(['superpowers', 'ui-clone'])
const excludedFiles = new Set(['index.md', 'en/index.md', 'AGENTS.md'])
const excludedDirectoryNames = new Set(['superpowers', 'ui-clone', 'public', 'images'])
const excludedFiles = new Set(['AGENTS.md'])
const sectionLabels = {
zh: {
guide: '开始使用',
desktop: '桌面应用',
features: '核心能力',
im: '消息接入',
agent: '多 Agent',
skills: '技能系统',
memory: '记忆系统',
channel: 'Channel 研究',
reference: '参考资料'
},
en: {
guide: 'Get started',
desktop: 'Desktop app',
features: 'Core capabilities',
im: 'Messaging',
agent: 'Multi-agent',
skills: 'Skills',
memory: 'Memory',
channel: 'Channel research',
reference: 'Reference'
}
}
const sectionOrder = [
'guide',
'desktop',
'features',
'im',
'agent',
'skills',
'memory',
'channel',
'reference'
/** 分区顺序与标题。新增一个 docs/ 顶层目录时在这里登记,否则会排到最后并显示裸目录名。 */
export const sections = [
{ id: 'start', zh: '开始使用', en: 'Get started' },
{ id: 'desktop', zh: '桌面端功能', en: 'Desktop app' },
{ id: 'im', zh: 'IM 接入', en: 'Messaging' },
{ id: 'cli', zh: '命令行', en: 'Command line' },
{ id: 'internals', zh: '深入原理', en: 'Internals' }
]
const sectionIndex = new Map(sections.map((section, index) => [section.id, index]))
function toPosix(value) {
return value.split(path.sep).join('/')
}
@@ -64,7 +42,7 @@ function shouldInclude(relativePath) {
const parts = normalized.split('/')
return normalized.endsWith('.md')
&& !excludedFiles.has(normalized)
&& !excludedFiles.has(path.posix.basename(normalized))
&& !parts.some((part) => excludedDirectoryNames.has(part))
}
@@ -129,22 +107,16 @@ function plainText(value) {
}
function extractTitle(body, frontmatter, fallback) {
if (frontmatter.title) {
return plainText(frontmatter.title)
}
if (frontmatter.title) return plainText(frontmatter.title)
const heading = body.match(/^#\s+(.+)$/m)
return heading ? plainText(heading[1]) : fallback
}
function extractDescription(body, frontmatter) {
if (frontmatter.description) {
return plainText(frontmatter.description).slice(0, 180)
}
if (frontmatter.description) return plainText(frontmatter.description).slice(0, 180)
const withoutFences = body.replace(/```[\s\S]*?```/g, '')
const paragraphs = withoutFences.split(/\n\s*\n/)
const paragraph = paragraphs.find((candidate) => {
const paragraph = withoutFences.split(/\n\s*\n/).find((candidate) => {
const value = candidate.trim()
return value
&& !value.startsWith('#')
@@ -157,19 +129,19 @@ function extractDescription(body, frontmatter) {
return plainText(paragraph || '').slice(0, 180)
}
/** 搜索用的正文摘要:去掉代码块和标记,保留可读文字。 */
function searchBody(body) {
return plainText(body.replace(/```[\s\S]*?```/g, ' ')).slice(0, 1400)
}
function routeFromRelativePath(relativePath) {
const withoutExtension = relativePath.replace(/\.md$/i, '')
const withoutIndex = withoutExtension.replace(/(^|\/)index$/i, '$1')
return `/${withoutIndex}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
}
function sortKey(relativePath) {
const basename = path.posix.basename(relativePath, '.md')
if (basename === 'index') {
return '0000-index'
}
return basename.replace(/^(\d+)-/, (_, value) => value.padStart(4, '0'))
function moduleIdFor(relativePath) {
return relativePath.replace(/\.md$/i, '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function buildRecord(relativePath, markdown) {
@@ -184,121 +156,160 @@ function buildRecord(relativePath, markdown) {
? section
: basename.replace(/^\d+-/, '').replaceAll('-', ' ')
const title = extractTitle(body, attributes, fallbackTitle)
const explicitOrder = Number.parseInt(attributes.order ?? '', 10)
return {
slug: route.replace(/^\//, ''),
path: route,
locale,
section,
title: extractTitle(body, attributes, fallbackTitle),
body,
content: markdown,
description: extractDescription(body, attributes),
locale,
moduleId: moduleIdFor(relativePath),
navTitle: attributes.nav_title ? plainText(attributes.nav_title) : title,
order: Number.isFinite(explicitOrder) ? explicitOrder : (basename === 'index' ? 0 : 900),
path: route,
search: searchBody(body),
section,
sectionRank: sectionIndex.has(section) ? sectionIndex.get(section) : 999,
slug: route.replace(/^\//, ''),
sourcePath: `docs/${relativePath}`,
sortKey: sortKey(relativePath),
content: markdown
title
}
}
function buildNavigation(records, locale) {
const localeRecords = records.filter((record) => record.locale === locale)
const sections = [...new Set(localeRecords.map((record) => record.section))]
.sort((left, right) => {
const leftIndex = sectionOrder.indexOf(left)
const rightIndex = sectionOrder.indexOf(right)
return (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex)
- (rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex)
|| left.localeCompare(right)
})
return sections.map((section) => ({
key: section,
title: sectionLabels[locale][section] || section,
items: localeRecords
.filter((record) => record.section === section)
.sort((left, right) => left.sortKey.localeCompare(right.sortKey))
.map(({ content, sortKey: _sortKey, section: _section, ...item }) => item)
}))
function sortRecords(records) {
return [...records].sort((left, right) => {
if (left.locale !== right.locale) return left.locale.localeCompare(right.locale)
if (left.sectionRank !== right.sectionRank) return left.sectionRank - right.sectionRank
if (left.section !== right.section) return left.section.localeCompare(right.section)
if (left.order !== right.order) return left.order - right.order
return left.title.localeCompare(right.title)
})
}
function serialize(value) {
return JSON.stringify(value, null, 2)
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029')
async function emptyDirectory(directory) {
await fs.rm(directory, { force: true, recursive: true })
await fs.mkdir(directory, { recursive: true })
}
const IMAGE_EXTENSIONS = new Set(['.png', '.webp', '.jpg', '.jpeg', '.gif'])
/**
* 站点资源路径 → 像素尺寸。渲染时写进 <img width height>,
* 浏览器就能在图片下载完之前留出正确的位置,懒加载不再让正文跳动。
*/
async function collectImageSizes(directory, prefix = '') {
const sizes = {}
let entries
try {
entries = await fs.readdir(directory, { withFileTypes: true })
} catch {
return sizes
}
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isDirectory()) {
Object.assign(sizes, await collectImageSizes(absolutePath, relativePath))
continue
}
if (!IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue
const size = await readImageSize(absolutePath)
if (size?.width && size?.height) sizes[`/${relativePath}`] = [size.width, size.height]
}
return sizes
}
export async function generateDocsManifest() {
const relativePaths = await listMarkdownFiles(docsDir)
const records = await Promise.all(relativePaths.map(async (relativePath) => {
const records = []
for (const relativePath of relativePaths) {
const markdown = await fs.readFile(path.join(docsDir, relativePath), 'utf8')
return buildRecord(relativePath, markdown)
records.push(buildRecord(relativePath, markdown))
}
const sorted = sortRecords(records)
await fs.mkdir(generatedDir, { recursive: true })
await emptyDirectory(contentDir)
// 每篇正文单独成模块,路由表里用动态 import 拿 —— 打开一篇文档不再下载全部 80 篇。
// 写的是去掉 frontmatter 之后的 body,渲染层拿到就能直接解析。
for (const record of sorted) {
await fs.writeFile(
path.join(contentDir, `${record.moduleId}.js`),
`export default ${JSON.stringify(record.body)}\n`,
'utf8'
)
}
const index = sorted.map((record) => ({
description: record.description,
locale: record.locale,
navTitle: record.navTitle,
order: record.order,
path: record.path,
section: record.section,
slug: record.slug,
sourcePath: record.sourcePath,
title: record.title
}))
records.sort((left, right) => left.path.localeCompare(right.path))
const navigation = {
zh: buildNavigation(records, 'zh'),
en: buildNavigation(records, 'en')
}
const loaders = sorted
.map((record) => ` ${JSON.stringify(record.path)}: () => import('./content/${record.moduleId}.js'),`)
.join('\n')
const moduleSource = `// Generated by site/scripts/generate-docs-manifest.mjs. Do not edit.
export const docsManifest = ${serialize(records)}
const imageSizes = await collectImageSizes(path.join(docsDir, 'images'), 'images')
export const docsNavigation = ${serialize(navigation)}
await fs.writeFile(
path.join(generatedDir, 'docs-index.js'),
[
'// 由 scripts/generate-docs-manifest.mjs 生成,请勿手工编辑。',
`export const sections = ${JSON.stringify(sections, null, 2)}`,
'',
`export const docsIndex = ${JSON.stringify(index, null, 2)}`,
'',
`export const imageSizes = ${JSON.stringify(imageSizes)}`,
'',
'export const docsContent = {',
loaders,
'}',
''
].join('\n'),
'utf8'
)
export const docsBySlug = Object.fromEntries(
docsManifest.flatMap((document) => [
[document.slug, document],
[document.path, document],
[\`\${document.path}/\`, document]
])
)
await fs.writeFile(
path.join(generatedDir, 'search-index.js'),
[
'// 由 scripts/generate-docs-manifest.mjs 生成,请勿手工编辑。',
`export default ${JSON.stringify(sorted.map((record) => ({
d: record.description,
l: record.locale,
p: record.path,
s: record.section,
t: record.title,
x: record.search
})))}`,
''
].join('\n'),
'utf8'
)
export function normalizeDocsPath(value) {
const pathname = String(value || '/').split(/[?#]/, 1)[0]
const decoded = decodeURIComponent(pathname)
const withoutHtml = decoded.replace(/(?:\\/index)?\\.html$/i, '')
const withoutMarkdown = withoutHtml.replace(/\\.md$/i, '')
const normalized = \`/\${withoutMarkdown}\`.replace(/\\/+/g, '/').replace(/\\/$/, '')
return normalized || '/'
return { records: sorted, sections }
}
export function resolveDocsPath(value) {
const normalized = normalizeDocsPath(value)
return docsBySlug[normalized]
|| docsBySlug[normalized.replace(/^\\//, '')]
|| null
}
const invokedDirectly = process.argv[1]
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
export function resolveDocsHref(href, sourcePath) {
if (!href || /^(?:[a-z]+:|\\/\\/|#)/i.test(href)) {
return href
}
const suffixIndex = href.search(/[?#]/)
const pathname = suffixIndex === -1 ? href : href.slice(0, suffixIndex)
const suffix = suffixIndex === -1 ? '' : href.slice(suffixIndex)
if (pathname.startsWith('/')) {
return \`\${normalizeDocsPath(pathname)}\${suffix}\`
}
const sourceRelative = sourcePath.replace(/^docs\\//, '')
const resolved = new URL(pathname, \`https://docs.local/\${sourceRelative}\`).pathname
const isMarkdown = /\\.md$/i.test(resolved)
const target = isMarkdown ? normalizeDocsPath(resolved) : resolved
return \`\${target}\${suffix}\`
}
`
await fs.mkdir(path.dirname(outputPath), { recursive: true })
const current = await fs.readFile(outputPath, 'utf8').catch(() => '')
if (current !== moduleSource) {
await fs.writeFile(outputPath, moduleSource)
}
return { records, navigation, outputPath }
}
const isDirectRun = process.argv[1]
&& pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
if (isDirectRun) {
if (invokedDirectly) {
const { records } = await generateDocsManifest()
console.log(`Generated ${records.length} documentation routes.`)
console.log(`Generated docs index for ${records.length} pages.`)
}
+82
View File
@@ -0,0 +1,82 @@
import { promises as fs } from 'node:fs'
/**
* 从文件头读出图片的像素尺寸,只认站点用到的四种格式。
* 不引第三方依赖 —— 站点的构建链路是刻意保持零外部图像库的。
* 读不出来就返回 null,调用方跳过即可(顶多是那张图没有 width/height)。
*/
export async function readImageSize(filePath) {
let handle
try {
handle = await fs.open(filePath, 'r')
const { buffer, bytesRead } = await handle.read(Buffer.alloc(64), 0, 64, 0)
if (bytesRead < 16) return null
// PNG: 8 字节签名 + IHDR,宽高是 16..24 的两个大端 uint32
if (buffer.readUInt32BE(0) === 0x89504e47) {
return { height: buffer.readUInt32BE(20), width: buffer.readUInt32BE(16) }
}
// GIF: "GIF8" + 逻辑屏幕宽高(小端 uint16)
if (buffer.toString('ascii', 0, 4) === 'GIF8') {
return { height: buffer.readUInt16LE(8), width: buffer.readUInt16LE(6) }
}
// WebP: RIFF....WEBP,三种子格式的宽高位置各不相同
if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
const format = buffer.toString('ascii', 12, 16)
if (format === 'VP8 ') {
return { height: buffer.readUInt16LE(28) & 0x3fff, width: buffer.readUInt16LE(26) & 0x3fff }
}
if (format === 'VP8L') {
const bits = buffer.readUInt32LE(21)
return { height: ((bits >> 14) & 0x3fff) + 1, width: (bits & 0x3fff) + 1 }
}
if (format === 'VP8X') {
const read24 = (offset) =>
buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16)
return { height: read24(27) + 1, width: read24(24) + 1 }
}
return null
}
// JPEG: 逐个 marker 往下走,直到碰上 SOFn
if (buffer.readUInt16BE(0) === 0xffd8) {
return await readJpegSize(handle)
}
return null
} catch {
return null
} finally {
await handle?.close()
}
}
async function readJpegSize(handle) {
const head = Buffer.alloc(2)
const size = Buffer.alloc(2)
const frame = Buffer.alloc(5)
let offset = 2
// 帧头最多往后找 2000 个 marker,避免坏文件把构建卡死
for (let guard = 0; guard < 2000; guard += 1) {
const marker = await handle.read(head, 0, 2, offset)
if (marker.bytesRead < 2 || head[0] !== 0xff) return null
const code = head[1]
// SOF0..SOF15,跳过 DHT(c4) / JPG(c8) / DAC(cc) 这三个不是帧头的
if (code >= 0xc0 && code <= 0xcf && code !== 0xc4 && code !== 0xc8 && code !== 0xcc) {
const read = await handle.read(frame, 0, 5, offset + 4)
if (read.bytesRead < 5) return null
return { height: frame.readUInt16BE(1), width: frame.readUInt16BE(3) }
}
const length = await handle.read(size, 0, 2, offset + 2)
if (length.bytesRead < 2) return null
offset += 2 + size.readUInt16BE(0)
}
return null
}
+119 -13
View File
@@ -77,9 +77,15 @@ async function siteSourceFiles() {
const srcDir = path.join(paths.siteDir, 'src')
const entries = await fs.readdir(srcDir, { recursive: true, withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && /\.(?:jsx?|css)$/.test(entry.name)) {
files.push(path.join(entry.parentPath || entry.path, entry.name))
}
if (!entry.isFile() || !/\.(?:jsx?|css)$/.test(entry.name)) continue
const directory = entry.parentPath || entry.path
// src/generated/ 里有一张「路径 → 图片尺寸」表,覆盖 docs/images 下的每张图。
// 那是给渲染器查尺寸用的,不代表页面引用了它们 —— 扫进来会把没人用的图
// (赞助、打赏之类)全拷进产物。
if (path.relative(srcDir, directory).split(path.sep)[0] === 'generated') continue
files.push(path.join(directory, entry.name))
}
return files
}
@@ -110,7 +116,54 @@ async function copySiteReferencedImages() {
console.log(`Copied ${sources.size} site-referenced images.`)
}
async function createRouteEntry(route, shell) {
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* 每条路由的 HTML 骨架都写进自己的 title / description / canonical / hreflang。
* 不执行 JS 的爬虫看到的就不再是 78 个一模一样的页面。
*/
function shellForRoute(shell, meta) {
if (!meta) return shell
const origin = `https://${expectedCustomDomain}`
const isEnglish = meta.path === '/en' || meta.path.startsWith('/en/')
const canonical = `${origin}${meta.path}`
const alternate = meta.alternate ? `${origin}${meta.alternate}` : null
const head = [
`<link rel="canonical" href="${escapeHtml(canonical)}">`,
`<link rel="alternate" hreflang="${isEnglish ? 'en' : 'zh-Hans'}" href="${escapeHtml(canonical)}">`,
alternate
? `<link rel="alternate" hreflang="${isEnglish ? 'zh-Hans' : 'en'}" href="${escapeHtml(alternate)}">`
: '',
`<meta property="og:url" content="${escapeHtml(canonical)}">`
].filter(Boolean).join('\n ')
return shell
.replace(/<html lang="[^"]*"/, `<html lang="${isEnglish ? 'en' : 'zh-CN'}"`)
.replace(/<title>[\s\S]*?<\/title>/, `<title>${escapeHtml(meta.title)}</title>`)
.replace(
/<meta name="description" content="[^"]*">/,
`<meta name="description" content="${escapeHtml(meta.description)}">`
)
.replace(
/<meta property="og:title" content="[^"]*">/,
`<meta property="og:title" content="${escapeHtml(meta.title)}">`
)
.replace(
/<meta property="og:description" content="[^"]*">/,
`<meta property="og:description" content="${escapeHtml(meta.description)}">`
)
.replace('</head>', ` ${head}\n </head>`)
}
async function createRouteEntry(route, shell, meta) {
const relativeRoute = route.replace(/^\/+|\/+$/g, '')
if (!relativeRoute) {
return
@@ -118,9 +171,49 @@ async function createRouteEntry(route, shell) {
const routeDirectory = path.join(distDir, ...relativeRoute.split('/'))
await fs.mkdir(routeDirectory, { recursive: true })
await fs.writeFile(path.join(routeDirectory, 'index.html'), shell)
await fs.writeFile(path.join(routeDirectory, 'index.html'), shellForRoute(shell, meta))
}
function alternateFor(record, records) {
const source = record.sourcePath.replace(/^docs\//, '')
const target = record.locale === 'en' ? source.replace(/^en\//, '') : `en/${source}`
return records.find((candidate) => candidate.sourcePath === `docs/${target}`)?.path || null
}
async function writeSitemap(records) {
const origin = `https://${expectedCustomDomain}`
const urls = ['/', '/en', ...records.map((record) => record.path)]
const body = urls
.map((url) => ` <url><loc>${origin}${url}</loc><changefreq>weekly</changefreq></url>`)
.join('\n')
await fs.writeFile(
path.join(distDir, 'sitemap.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`
)
await fs.writeFile(
path.join(distDir, 'robots.txt'),
`User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`
)
}
/** 2026-07 信息架构重组前的老路径,发一份骨架出去让前端接管重定向,别在 CDN 层就 404。 */
const legacyRoutes = [
'/agent', '/agent/01-usage-guide', '/agent/02-implementation', '/agent/03-agent-framework',
'/channel', '/channel/01-channel-system', '/channel/02-im-gateway-proposal',
'/desktop/01-quick-start', '/desktop/02-architecture', '/desktop/03-features',
'/desktop/04-installation', '/desktop/05-FAQ', '/desktop/06-h5-access',
'/desktop/07-electron-migration-research', '/desktop/08-electron-migration-tasks',
'/desktop/09-electron-migration-validation-checklist', '/desktop/10-release-auto-update',
'/docs', '/features/computer-use', '/features/computer-use-architecture',
'/guide/cli-reference', '/guide/contributing', '/guide/env-vars', '/guide/faq',
'/guide/global-usage', '/guide/quick-start', '/guide/third-party-models',
'/memory', '/memory/01-usage-guide', '/memory/02-implementation', '/memory/03-autodream',
'/reference/fixes', '/reference/local-server', '/reference/project-structure',
'/skills', '/skills/01-usage-guide', '/skills/02-implementation'
]
async function main() {
const { records } = await generateDocsManifest()
const shellPath = path.join(distDir, 'index.html')
@@ -135,17 +228,30 @@ async function main() {
throw new Error(`Expected CNAME to contain ${expectedCustomDomain}, received ${customDomain || 'an empty value'}.`)
}
const routes = new Set(records.map((record) => record.path))
routes.add('/en')
routes.add('/docs')
routes.add('/en/docs')
for (const route of routes) {
await createRouteEntry(route, shell)
for (const record of records) {
await createRouteEntry(record.path, shell, {
alternate: alternateFor(record, records),
description: record.description,
path: record.path,
title: `${record.title} · Claude Code Haha`
})
}
await createRouteEntry('/en', shell, {
alternate: '/',
description: 'A local-first desktop client for Claude Code. Sessions, diffs, agents and scheduled runs all sit in the open.',
path: '/en',
title: 'Claude Code Haha — a local-first desktop client for Claude Code'
})
for (const legacy of legacyRoutes) {
await createRouteEntry(legacy, shell, null)
await createRouteEntry(`/en${legacy}`, shell, null)
}
await writeSitemap(records)
await fs.writeFile(path.join(distDir, '404.html'), shell)
console.log(`Prepared static output for ${routes.size} client-side routes.`)
console.log(`Prepared static output for ${records.length} documentation routes and ${legacyRoutes.length * 2} redirects.`)
}
await main()
+36 -18
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useState } from 'react'
import HomePage from './pages/home/HomePage'
import { resolveLegacyRoute, toSiteHref } from './content/docs'
const DocPage = lazy(() => import('./components/DocPage'))
@@ -7,11 +8,35 @@ function currentPath() {
return window.location.pathname.replace(/\/+$/, '') || '/'
}
function NotFound({ pathname }) {
const isEnglish = pathname.startsWith('/en')
return (
<main className="not-found">
<p>404</p>
<h1>{isEnglish ? 'This page moved or never existed.' : '这个页面搬走了,或者从来没存在过。'}</h1>
<p className="not-found__hint">
{isEnglish
? 'The documentation was reorganised in July 2026. Try the two entry points below.'
: '文档在 2026 年 7 月重新分区过。从下面两个入口找找看。'}
</p>
<div className="not-found__actions">
<a className="btn btn--primary" href={toSiteHref(isEnglish ? '/en/start' : '/start')}>
{isEnglish ? 'Get started' : '开始使用'}
</a>
<a className="btn btn--ghost" href={toSiteHref(isEnglish ? '/en/internals' : '/internals')}>
{isEnglish ? 'Internals' : '深入原理'}
</a>
</div>
</main>
)
}
export default function App() {
const [path, setPath] = useState(currentPath)
useEffect(() => {
const onPopState = () => setPath(currentPath())
const onClick = (event) => {
const anchor = event.target.closest('a')
if (
@@ -30,12 +55,7 @@ export default function App() {
const target = new URL(anchor.href, window.location.href)
if (target.origin !== window.location.origin) return
if (/\.html$/i.test(target.pathname)) return
if (
target.pathname === window.location.pathname
&& target.search === window.location.search
) {
return
}
if (target.pathname === window.location.pathname && target.search === window.location.search) return
event.preventDefault()
window.history.pushState({}, '', `${target.pathname}${target.search}${target.hash}`)
@@ -50,23 +70,21 @@ export default function App() {
}
}, [])
// 老路由一律换到新地址,别让重组把外部链接打死在 404 上。
useEffect(() => {
const legacy = resolveLegacyRoute(path)
if (!legacy) return
window.history.replaceState({}, '', toSiteHref(legacy) + window.location.hash)
setPath(currentPath())
}, [path])
if (path === '/' || path === '/en') {
return <HomePage locale={path === '/en' ? 'en' : 'zh'} />
}
return (
<Suspense fallback={<div className="page-loading">Opening the field manual…</div>}>
<DocPage
pathname={path}
onNotFound={() => (
<main className="not-found">
<span>404 · OFF THE MAP</span>
<h1>这里还没有留下脚印。</h1>
<p>The crew could not find this page.</p>
<a href="/">回到工作室 →</a>
</main>
)}
/>
<Suspense fallback={<div className="page-loading" />}>
<DocPage pathname={path} onNotFound={(pathname) => <NotFound pathname={pathname} />} />
</Suspense>
)
}
+161 -112
View File
@@ -1,29 +1,37 @@
import React, { useEffect, useMemo } from 'react'
import {
findDoc,
getAdjacentDocs,
renderMarkdown,
toSiteHref,
} from '../content/docs'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
alternateLocaleRoute,
ensureHighlighter,
findDoc,
getAdjacentDocs,
getDocNavigation,
} from '../content/navigation'
getHighlighter,
getSections,
loadDocContent,
renderMarkdown,
toSiteHref
} from '../content/docs'
import { DocPager } from './DocPager'
import { DocSidebar } from './DocSidebar'
import { DocToc } from './DocToc'
import Icon from './icons'
import SiteHeader from './SiteHeader'
import { setPageMeta } from '../lib/meta'
import '../docs/doc.css'
const copy = {
zh: { home: '首页', menu: '目录', reading: '正在打开…', sidebar: '文档目录' },
en: { home: 'Home', menu: 'Contents', reading: 'Opening…', sidebar: 'Documentation' }
}
function defaultNavigate(href) {
window.history.pushState({}, '', toSiteHref(href))
window.dispatchEvent(new PopStateEvent('popstate'))
}
async function scrollToDocHeading(id) {
const images = [...document.querySelectorAll('.doc-content img')]
images.forEach((image) => {
image.loading = 'eager'
})
const images = [...document.querySelectorAll('.prose img')]
images.forEach((image) => { image.loading = 'eager' })
await Promise.all(images.map((image) => {
if (image.complete && image.naturalWidth > 0) return Promise.resolve()
return image.decode?.().catch(() => undefined) || Promise.resolve()
@@ -33,64 +41,108 @@ async function scrollToDocHeading(id) {
document.getElementById(id)?.scrollIntoView()
}
export function DocPage({
onNavigate = defaultNavigate,
onNotFound,
path,
pathname = path || window.location.pathname,
}) {
export function DocPage({ onNavigate = defaultNavigate, onNotFound, path, pathname = path || window.location.pathname }) {
const doc = useMemo(() => findDoc(pathname), [pathname])
const navigation = useMemo(
() => getDocNavigation(doc?.locale || 'zh'),
[doc?.locale],
)
const [markdown, setMarkdown] = useState(null)
const [highlightReady, setHighlightReady] = useState(() => Boolean(getHighlighter()))
const [navOpen, setNavOpen] = useState(false)
const mainRef = useRef(null)
const firstRender = useRef(true)
const locale = doc?.locale || 'zh'
const c = copy[locale] || copy.zh
const navigation = useMemo(() => getDocNavigation(locale), [locale])
useEffect(() => {
let cancelled = false
setMarkdown(null)
if (!doc) return undefined
Promise.all([loadDocContent(doc.path), ensureHighlighter().catch(() => null)])
.then(([content]) => {
if (cancelled) return
setHighlightReady(true)
setMarkdown(content ?? '')
})
return () => { cancelled = true }
}, [doc])
const rendered = useMemo(
() => doc ? renderMarkdown(doc) : null,
[doc],
)
const adjacent = useMemo(
() => doc ? getAdjacentDocs(doc, navigation) : {},
[doc, navigation],
() => (doc && markdown !== null ? renderMarkdown(doc, markdown) : null),
// highlightReady 参与依赖:高亮器就绪后要用带高亮的结果重渲染一次
[doc, markdown, highlightReady]
)
useEffect(() => {
if (!doc) return
document.documentElement.lang = doc.locale === 'en' ? 'en' : 'zh-CN'
document.title = `${doc.title} · Claude Code Haha`
const hash = window.location.hash
if (hash) scrollToDocHeading(decodeURIComponent(hash.slice(1)))
else requestAnimationFrame(() => window.scrollTo({ top: 0 }))
const alternate = alternateLocaleRoute(doc)
setPageMeta({
canonical: doc.path,
description: doc.description,
lang: doc.locale === 'en' ? 'en' : 'zh-CN',
alternate: alternate === doc.path ? null : alternate,
title: `${doc.title} · Claude Code Haha`
})
}, [doc])
useEffect(() => {
if (!rendered) return
const hash = window.location.hash
if (hash) scrollToDocHeading(decodeURIComponent(hash.slice(1)))
else requestAnimationFrame(() => window.scrollTo({ top: 0 }))
}, [rendered])
// 换页是客户端跳转,浏览器不会重置焦点。不把焦点搬进正文,读屏用户
// 停在旧页面的某个链接上,也听不到新页面的标题。
useEffect(() => {
if (firstRender.current) {
firstRender.current = false
return
}
if (!doc) return
mainRef.current?.focus({ preventScroll: true })
}, [doc])
// 抽屉是覆盖在正文上的,Esc 得能关掉它。
useEffect(() => {
if (!navOpen) return undefined
const onKeyDown = (event) => { if (event.key === 'Escape') setNavOpen(false) }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [navOpen])
useEffect(() => {
if (!rendered) return undefined
const diagrams = [...document.querySelectorAll('.doc-mermaid[data-mermaid-pending="true"]')]
if (diagrams.length === 0) return undefined
let cancelled = false
async function renderDiagrams() {
const { default: mermaid } = await import('mermaid')
const styles = getComputedStyle(document.documentElement)
const token = (name, fallback) => styles.getPropertyValue(name).trim() || fallback
import('mermaid').then(async ({ default: mermaid }) => {
mermaid.initialize({
securityLevel: 'strict',
startOnLoad: false,
theme: 'base',
themeVariables: {
fontFamily: 'Inter, PingFang SC, sans-serif',
lineColor: '#1a1610',
primaryColor: '#f3efe4',
primaryBorderColor: '#1a1610',
primaryTextColor: '#1a1610',
secondaryColor: '#f6e6de',
tertiaryColor: '#faf7ee',
},
fontFamily: token('--font-sans', 'Inter, sans-serif'),
lineColor: token('--text-2', '#5d5850'),
primaryBorderColor: token('--border-strong', '#d8d5cc'),
primaryColor: token('--surface-muted', '#f6f6f3'),
primaryTextColor: token('--text', '#1f1c17'),
secondaryColor: token('--brand-soft', '#f6ece5'),
tertiaryColor: token('--surface', '#ffffff')
}
})
for (const [index, diagram] of diagrams.entries()) {
if (cancelled) return
const source = diagram.querySelector('pre')?.textContent || ''
try {
const id = `doc-mermaid-${Date.now()}-${index}`
const { svg } = await mermaid.render(id, source)
const { svg } = await mermaid.render(`doc-mermaid-${index}-${Math.random().toString(36).slice(2)}`, source)
if (!cancelled) {
diagram.innerHTML = svg
diagram.dataset.mermaidPending = 'false'
@@ -99,16 +151,16 @@ export function DocPage({
diagram.dataset.mermaidPending = 'error'
}
}
}
})
renderDiagrams()
return () => {
cancelled = true
}
}, [doc, rendered])
return () => { cancelled = true }
}, [rendered])
if (!doc) return onNotFound ? onNotFound(pathname) : null
const adjacent = getAdjacentDocs(doc, navigation)
const sectionLabel = getSections().find((section) => section.id === doc.section)?.[locale] || doc.section
function handleArticleClick(event) {
const hashAnchor = event.target.closest('a[href^="#"]')
if (hashAnchor && !event.defaultPrevented) {
@@ -127,85 +179,82 @@ export function DocPage({
onNavigate(anchor.dataset.docRoute)
}
const alternateRoute = alternateLocaleRoute(doc)
const localeLabel = doc.locale === 'en' ? '阅读中文' : 'Read in English'
const homeRoute = doc.locale === 'en' ? '/en' : '/'
const handleTocNavigate = (event, id) => {
event.preventDefault()
window.history.pushState({}, '', `#${encodeURIComponent(id)}`)
scrollToDocHeading(id)
}
return (
<>
<header className="doc-site-header">
<a
className="doc-site-header__brand"
href={toSiteHref(homeRoute)}
onClick={(event) => {
event.preventDefault()
onNavigate(homeRoute)
}}
>
<img src={toSiteHref('/images/app-icon.png')} alt="" />
<span>Claude Code Haha</span>
<small>Docs</small>
</a>
<div className="doc-site-header__marker" aria-hidden="true">
Field manual · 2026
</div>
<nav className="doc-site-header__actions" aria-label="Documentation links">
<a href="https://github.com/NanmiCoder/cc-haha" target="_blank" rel="noreferrer">
GitHub
</a>
<a
href={toSiteHref(alternateRoute)}
onClick={(event) => {
event.preventDefault()
onNavigate(alternateRoute)
}}
>
{localeLabel}
</a>
</nav>
</header>
<a className="u-skip" href="#doc-main">{locale === 'en' ? 'Skip to content' : '跳到正文'}</a>
<SiteHeader
activeSection={doc.section}
locale={locale}
localeHref={alternateLocaleRoute(doc)}
/>
<div className="doc-shell">
<div className="doc-mobilebar">
<button
aria-controls="doc-nav"
aria-expanded={navOpen}
onClick={() => setNavOpen((value) => !value)}
type="button"
>
<Icon name="sidebar" size={16} />
{c.menu}
</button>
<span className="doc-mobilebar__here">{sectionLabel} · {doc.navTitle || doc.title}</span>
</div>
<div className="doc-scrim" data-open={navOpen} onClick={() => setNavOpen(false)} />
<div className="doc-layout">
<DocSidebar
activeRoute={doc.route}
activeRoute={doc.path}
label={c.sidebar}
navigation={navigation}
onNavigate={onNavigate}
onRequestClose={() => setNavOpen(false)}
open={navOpen}
/>
<main className="doc-main" id="main-content">
<div className="doc-main__meta">
<span>{doc.locale === 'en' ? 'DOCUMENTATION' : '使用手册'}</span>
<a href={toSiteHref(alternateRoute)} onClick={(event) => {
event.preventDefault()
onNavigate(alternateRoute)
}}>
{localeLabel}
{/* tabIndex -1 让「跳到正文」和换页真的把焦点搬进来,而不是停在 body 上 */}
<main className="doc-main" id="doc-main" ref={mainRef} tabIndex={-1}>
<div className="doc-breadcrumb">
<a
href={toSiteHref(locale === 'en' ? '/en' : '/')}
onClick={(event) => { event.preventDefault(); onNavigate(locale === 'en' ? '/en' : '/') }}
>
{c.home}
</a>
<span aria-hidden="true">/</span>
<span>{sectionLabel}</span>
</div>
<article
className="doc-content"
dangerouslySetInnerHTML={{ __html: rendered.html }}
onClick={handleArticleClick}
/>
{rendered
? (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: rendered.html }}
onClick={handleArticleClick}
/>
)
: <p className="doc-loading">{c.reading}</p>}
<DocPager
locale={locale}
next={adjacent.next}
onNavigate={onNavigate}
previous={adjacent.previous}
/>
</main>
<DocToc
headings={rendered.tableOfContents}
locale={doc.locale}
onAnchorNavigate={handleTocNavigate}
/>
{rendered && (
<DocToc
headings={rendered.tableOfContents}
locale={locale}
onAnchorNavigate={(event, id) => {
event.preventDefault()
window.history.pushState({}, '', `#${encodeURIComponent(id)}`)
scrollToDocHeading(id)
}}
/>
)}
</div>
</>
)
+27 -20
View File
@@ -1,29 +1,36 @@
import React from 'react'
import { toSiteHref } from '../content/docs'
export function DocPager({ next, onNavigate, previous }) {
function handleClick(event, route) {
if (!onNavigate) return
event.preventDefault()
onNavigate(route)
}
const copy = {
zh: { label: '上一篇 / 下一篇', next: '下一篇', previous: '上一篇' },
en: { label: 'Previous and next page', next: 'Next', previous: 'Previous' }
}
export function DocPager({ locale = 'zh', next, onNavigate, previous }) {
if (!previous && !next) return null
const c = copy[locale] || copy.zh
const link = (doc, direction) => (
<a
className={`doc-pager__link doc-pager__link--${direction}`}
href={toSiteHref(doc.route)}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return
event.preventDefault()
onNavigate(doc.route)
}}
>
<span className="doc-pager__dir">{direction === 'next' ? c.next : c.previous}</span>
<span className="doc-pager__title">{doc.label}</span>
{doc.section && <span className="doc-pager__section">{doc.section}</span>}
</a>
)
return (
<nav className="doc-pager" aria-label="Adjacent documentation">
{previous ? (
<a href={toSiteHref(previous.route)} onClick={(event) => handleClick(event, previous.route)}>
<small>← Previous</small>
<span>{previous.title}</span>
</a>
) : <span />}
{next ? (
<a href={toSiteHref(next.route)} onClick={(event) => handleClick(event, next.route)}>
<small>Next →</small>
<span>{next.title}</span>
</a>
) : <span />}
<nav aria-label={c.label} className="doc-pager">
{previous ? link(previous, 'previous') : <span />}
{next && link(next, 'next')}
</nav>
)
}
export default DocPager
+73 -36
View File
@@ -1,43 +1,80 @@
import React from 'react'
import { useEffect, useRef } from 'react'
import { toSiteHref } from '../content/docs'
export function DocSidebar({ activeRoute, navigation, onNavigate }) {
function handleClick(event, route) {
if (!onNavigate) return
event.preventDefault()
onNavigate(route)
export function DocSidebar({ activeRoute, label = 'Documentation', navigation, onNavigate, onRequestClose, open }) {
const scope = useRef(null)
// 打开靠后分区的页面时,把当前项滚进视口,别让用户以为侧栏没高亮。
useEffect(() => {
scope.current?.querySelector('[aria-current="page"]')?.scrollIntoView({ block: 'nearest' })
}, [activeRoute])
// 窄屏抽屉是盖在正文上的模态:焦点得进去,也得留在里面,
// 关掉之后再还给「目录」按钮。宽屏侧栏 open 恒为 false,不受影响。
useEffect(() => {
if (!open) return undefined
const opener = document.activeElement
const target = scope.current?.querySelector('[aria-current="page"]')
|| scope.current?.querySelector('a')
target?.focus()
return () => {
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus()
}
}, [open])
function onKeyDown(event) {
if (!open || event.key !== 'Tab') return
const stops = [...(scope.current?.querySelectorAll('a[href]') || [])]
if (stops.length === 0) return
const first = stops[0]
const last = stops[stops.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
return (
<aside className="doc-sidebar" aria-label="Documentation navigation">
<div className="doc-sidebar__eyebrow">Field manual</div>
<nav>
{navigation.map((group, index) => (
<details
className="doc-sidebar__group"
key={group.label}
open={index < 2 || group.items.some((item) => item.route === activeRoute)}
>
<summary>
<span className="doc-sidebar__index">{String(index + 1).padStart(2, '0')}</span>
{group.label}
</summary>
<ul>
{group.items.map((item) => (
<li key={item.route}>
<a
aria-current={item.route === activeRoute ? 'page' : undefined}
href={toSiteHref(item.route)}
onClick={(event) => handleClick(event, item.route)}
>
{item.label}
</a>
</li>
))}
</ul>
</details>
))}
</nav>
</aside>
<nav
aria-label={label}
className="doc-nav"
data-open={open}
id="doc-nav"
onKeyDown={onKeyDown}
ref={scope}
>
{navigation.map((group) => (
<div className="doc-nav__group" key={group.id}>
<span className="doc-nav__title">{group.label}</span>
<ul className="doc-nav__list">
{group.items.map((item) => (
<li key={item.route}>
<a
aria-current={item.route === activeRoute ? 'page' : undefined}
className="doc-nav__link"
href={toSiteHref(item.route)}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return
event.preventDefault()
onNavigate(item.route)
onRequestClose?.()
}}
title={item.title}
>
{item.label}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
)
}
export default DocSidebar
+61 -24
View File
@@ -1,44 +1,81 @@
import React, { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
export function DocToc({ headings, locale, onAnchorNavigate }) {
const [activeId, setActiveId] = useState(null)
/** 高亮当前正在读的小节。用 IntersectionObserver 跟踪标题的进出。 */
function useActiveHeading(headings) {
const [active, setActive] = useState(headings[0]?.id)
useEffect(() => {
if (!headings.length) return undefined
if (headings.length === 0) return undefined
setActive(headings[0].id)
// 没有 IntersectionObserver 就退化成「高亮第一节」,不能让整棵树炸掉。
if (typeof IntersectionObserver === 'undefined') return undefined
const sections = headings
.map((heading) => document.getElementById(heading.id))
.filter(Boolean)
if (!sections.length) return undefined
const seen = new Map()
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) seen.set(entry.target.id, entry.isIntersecting)
const visible = headings.find((heading) => seen.get(heading.id))
if (visible) setActive(visible.id)
},
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
)
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) setActiveId(entry.target.id)
})
}, { rootMargin: '-72px 0px -68% 0px', threshold: 0 })
for (const heading of headings) {
const node = document.getElementById(heading.id)
if (node) observer.observe(node)
}
sections.forEach((section) => observer.observe(section))
return () => observer.disconnect()
}, [headings])
if (!headings.length) return null
return active
}
export function DocToc({ headings, locale, onAnchorNavigate }) {
const active = useActiveHeading(headings)
const scope = useRef(null)
// 长文的目录本身会溢出成滚动区。高亮项一旦滚出可视范围,目录就不再
// 指示"我读到哪了" —— internals 那批四十来条的页面读到一半就失去定位。
useEffect(() => {
if (!active) return
const node = scope.current?.querySelector('[aria-current="true"]')
const list = scope.current
if (!node || !list || list.scrollHeight <= list.clientHeight) return
// 直接改容器的 scrollTop,不用 scrollIntoView —— 后者会向上遍历所有可
// 滚动祖先,有把整个页面也滚一下的风险,那会和用户自己的滚动打架。
const item = node.getBoundingClientRect()
const box = list.getBoundingClientRect()
if (item.top < box.top) list.scrollTop -= box.top - item.top
else if (item.bottom > box.bottom) list.scrollTop += item.bottom - box.bottom
}, [active])
if (headings.length < 2) return null
const title = locale === 'en' ? 'On this page' : '本页内容'
return (
<aside className="doc-toc" aria-label="On this page">
<p>{locale === 'en' ? 'On this page' : '本页内容'}</p>
<ol>
<nav aria-label={title} className="doc-toc" ref={scope}>
<div className="doc-toc__title">{title}</div>
<ul className="doc-toc__list">
{headings.map((heading) => (
<li className={`doc-toc__depth-${heading.depth}`} key={heading.id}>
<li key={heading.id}>
<a
className={heading.id === activeId ? 'is-active' : undefined}
href={`#${heading.id}`}
onClick={(event) => onAnchorNavigate?.(event, heading.id)}
aria-current={heading.id === active ? 'true' : undefined}
className="doc-toc__link"
data-depth={heading.depth}
href={`#${encodeURIComponent(heading.id)}`}
onClick={(event) => onAnchorNavigate(event, heading.id)}
>
{heading.text}
</a>
</li>
))}
</ol>
</aside>
</ul>
</nav>
)
}
export default DocToc
+58
View File
@@ -0,0 +1,58 @@
import { Component } from 'react'
const copy = {
zh: {
body: '这一页没能渲染出来。刷新一般能解决;如果一直这样,麻烦去 GitHub 提个 issue。',
reload: '刷新页面',
title: '出了点问题'
},
en: {
body: 'This page failed to render. A reload usually fixes it — if it keeps happening, please open an issue on GitHub.',
reload: 'Reload',
title: 'Something broke'
}
}
/**
* 没有它的话,任意一个组件在渲染或 effect 里抛错都会把整棵树卸载掉,
* 用户看到的是纯白页面而不是错误。文档站不该因为一处小故障就整个消失。
*/
export default class ErrorBoundary extends Component {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error, info) {
console.error('[site] 渲染失败', error, info?.componentStack)
}
render() {
if (!this.state.failed) return this.props.children
const locale = document.documentElement.lang === 'en' ? 'en' : 'zh'
const c = copy[locale]
return (
<main className="not-found">
<p>ERROR</p>
<h1>{c.title}</h1>
<p className="not-found__hint">{c.body}</p>
<div className="not-found__actions">
<button className="btn btn--primary" onClick={() => window.location.reload()} type="button">
{c.reload}
</button>
<a
className="btn btn--ghost"
href="https://github.com/NanmiCoder/cc-haha/issues"
rel="noreferrer"
target="_blank"
>
GitHub
</a>
</div>
</main>
)
}
}
-28
View File
@@ -1,28 +0,0 @@
const paths = {
arrow: <><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>,
check: <path d="m5 12 4 4L19 6" />,
chevron: <path d="m9 18 6-6-6-6" />,
copy: <><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
download: <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>,
github: <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3.28-.36 6.72-1.61 6.72-7.25A5.7 5.7 0 0 0 19.22 3.3 5.3 5.3 0 0 0 19.07 0S17.88-.38 15 1.48a13.4 13.4 0 0 0-7 0C5.12-.38 3.93 0 3.93 0a5.3 5.3 0 0 0-.15 3.3 5.7 5.7 0 0 0-1.5 3.95c0 5.63 3.44 6.88 6.72 7.25A4.8 4.8 0 0 0 8 18v4" />,
menu: <><path d="M4 7h16" /><path d="M4 12h16" /><path d="M4 17h16" /></>,
play: <path d="m8 5 11 7-11 7Z" />,
spark: <><path d="m12 3-1.6 4.4L6 9l4.4 1.6L12 15l1.6-4.4L18 9l-4.4-1.6Z" /><path d="m5 15-.8 2.2L2 18l2.2.8L5 21l.8-2.2L8 18l-2.2-.8Z" /></>,
}
export default function Icon({ name, size = 20, className = '' }) {
return (
<svg
aria-hidden="true"
className={className}
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
>
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8">
{paths[name]}
</g>
</svg>
)
}
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Icon from './icons'
import { getSections, toSiteHref } from '../content/docs'
const copy = {
zh: {
close: '关闭搜索',
empty: '没有匹配的文档',
hint: '搜标题、摘要和正文',
label: '搜索文档',
placeholder: '搜索文档…',
results: '搜索结果',
tips: ['↑↓ 选择', '↵ 打开', 'Esc 关闭']
},
en: {
close: 'Close search',
empty: 'Nothing matched',
hint: 'Searches titles, summaries and body text',
label: 'Search docs',
placeholder: 'Search docs…',
results: 'Search results',
tips: ['↑↓ to move', '↵ to open', 'Esc to close']
}
}
const FOCUSABLE = 'a[href], button:not([disabled]), input, [tabindex]'
function score(entry, terms) {
const title = entry.t.toLowerCase()
const description = (entry.d || '').toLowerCase()
const body = (entry.x || '').toLowerCase()
let total = 0
for (const term of terms) {
if (title.includes(term)) total += title.startsWith(term) ? 12 : 8
else if (description.includes(term)) total += 4
else if (body.includes(term)) total += 2
else return 0
}
return total
}
/** 取正文里第一处命中的上下文,让结果能自我解释。 */
function excerpt(entry, terms) {
const body = entry.x || ''
if (!body) return entry.d || ''
const lower = body.toLowerCase()
const at = terms.map((term) => lower.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0]
if (at === undefined) return entry.d || body.slice(0, 110)
const start = Math.max(0, at - 40)
return `${start > 0 ? '…' : ''}${body.slice(start, start + 120).trim()}…`
}
export default function SearchDialog({ locale = 'zh', onClose }) {
const [query, setQuery] = useState('')
const [index, setIndex] = useState(null)
const [active, setActive] = useState(0)
const inputRef = useRef(null)
const listRef = useRef(null)
const dialogRef = useRef(null)
const c = copy[locale] || copy.zh
const sectionLabels = useMemo(() => {
const map = new Map()
for (const section of getSections()) map.set(section.id, section[locale] || section.id)
return map
}, [locale])
useEffect(() => {
let cancelled = false
import('../generated/search-index').then((module) => {
if (!cancelled) setIndex(module.default)
})
inputRef.current?.focus()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previous
}
}, [])
// 关掉对话框要把焦点还给打开它的那个控件,否则键盘用户会被扔回文档开头。
// 触发者要在首次渲染时就记下来 —— 等 effect 跑完,焦点已经被搬进输入框了。
const openerRef = useRef(undefined)
if (openerRef.current === undefined) openerRef.current = document.activeElement
useEffect(() => () => {
const opener = openerRef.current
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus()
}, [])
const results = useMemo(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (!index || terms.length === 0) return []
return index
.filter((entry) => entry.l === locale)
.map((entry) => ({ entry, value: score(entry, terms) }))
.filter((item) => item.value > 0)
.sort((left, right) => right.value - left.value)
.slice(0, 12)
.map(({ entry }) => ({
excerpt: excerpt(entry, terms),
path: entry.p,
section: sectionLabels.get(entry.s) || entry.s,
title: entry.t
}))
}, [index, locale, query, sectionLabels])
useEffect(() => setActive(0), [query])
function open(path) {
window.history.pushState({}, '', toSiteHref(path))
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}
/** 挂在对话框根节点上:Esc 和方向键在关闭按钮、结果项上一样要管用。 */
function onKeyDown(event) {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
// 模态对话框必须困住 Tab,否则焦点会溜到背后被遮住的页面上。
if (event.key === 'Tab') {
const stops = [...(dialogRef.current?.querySelectorAll(FOCUSABLE) || [])]
.filter((node) => node.tabIndex >= 0 && node.offsetParent !== null)
if (stops.length === 0) return
const first = stops[0]
const last = stops[stops.length - 1]
const current = document.activeElement
if (event.shiftKey && (current === first || !dialogRef.current?.contains(current))) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && (current === last || !dialogRef.current?.contains(current))) {
event.preventDefault()
first.focus()
}
return
}
if (results.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setActive((value) => (value + 1) % results.length)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActive((value) => (value - 1 + results.length) % results.length)
} else if (event.key === 'Enter') {
event.preventDefault()
open(results[active].path)
}
}
useEffect(() => {
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active, results])
const hasResults = results.length > 0
return (
<div className="search-scrim" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<div
aria-label={c.label}
aria-modal="true"
className="search-dialog"
onKeyDown={onKeyDown}
ref={dialogRef}
role="dialog"
>
<div className="search-dialog__field">
<Icon name="search" />
<input
aria-activedescendant={hasResults ? `search-result-${active}` : undefined}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={hasResults}
aria-label={c.label}
onChange={(event) => setQuery(event.target.value)}
placeholder={c.placeholder}
ref={inputRef}
role="combobox"
type="search"
value={query}
/>
<button aria-label={c.close} className="icon-btn" onClick={onClose} type="button">
<Icon name="close" size={16} />
</button>
</div>
<div className="search-dialog__body" ref={listRef}>
{query.trim() === '' && <p className="search-dialog__hint">{c.hint}</p>}
{query.trim() !== '' && !hasResults && (
<p className="search-dialog__hint">{c.empty}</p>
)}
{/* 选中项靠 aria-activedescendant 汇报,所以结果本身不进 Tab 序列。 */}
<div aria-label={c.results} id="search-results" role="listbox">
{results.map((result, position) => (
<button
aria-selected={position === active}
className="search-result"
data-active={position === active}
id={`search-result-${position}`}
key={result.path}
onClick={() => open(result.path)}
onMouseEnter={() => setActive(position)}
role="option"
tabIndex={-1}
type="button"
>
<span className="search-result__section">{result.section}</span>
<span className="search-result__title">{result.title}</span>
<span className="search-result__excerpt">{result.excerpt}</span>
</button>
))}
</div>
</div>
<div className="search-dialog__foot">
{c.tips.map((tip) => <span key={tip}>{tip}</span>)}
</div>
</div>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react'
import Icon from './icons'
import { toSiteHref } from '../content/docs'
import { useTheme } from '../lib/theme'
import SearchDialog from './SearchDialog'
export const GITHUB_URL = 'https://github.com/NanmiCoder/cc-haha'
export const DOWNLOAD_URL = 'https://github.com/NanmiCoder/cc-haha/releases/latest'
const copy = {
zh: {
docs: '文档',
download: '下载',
entries: [
['/start', '开始使用'],
['/desktop', '桌面端功能'],
['/internals', '深入原理']
],
menu: '打开导航',
// 页脚也有一栏叫「文档」,主导航得换个名字,否则地标列表里两个 nav 同名。
nav: '主导航',
search: '搜索文档',
theme: '切换深浅色',
toEnglish: 'English'
},
en: {
docs: 'Docs',
download: 'Download',
entries: [
['/en/start', 'Get started'],
['/en/desktop', 'Desktop app'],
['/en/internals', 'Internals']
],
menu: 'Open navigation',
nav: 'Main',
search: 'Search docs',
theme: 'Toggle theme',
toEnglish: '中文'
}
}
export default function SiteHeader({ activeSection, locale = 'zh', localeHref }) {
const [open, setOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const { theme, toggle } = useTheme()
const c = copy[locale] || copy.zh
useEffect(() => {
function onKeyDown(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
setSearchOpen(true)
return
}
// 展开的移动端菜单要能用 Esc 收起来,光靠点别处对键盘用户没用。
if (event.key === 'Escape') setOpen(false)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const home = locale === 'en' ? '/en' : '/'
const switchHref = localeHref || (locale === 'en' ? '/' : '/en')
return (
<>
<header className="site-header">
<div className="site-header__inner">
<a className="brand" href={toSiteHref(home)}>
<img alt="" src={toSiteHref('/images/app-icon.png')} width="26" height="26" />
<span>Claude Code <b>Haha</b></span>
</a>
<nav aria-label={c.nav} className="site-nav" data-open={open} id="site-nav">
{c.entries.map(([href, label]) => (
<a
aria-current={activeSection && href.endsWith(`/${activeSection}`) ? 'page' : undefined}
href={toSiteHref(href)}
key={href}
onClick={() => setOpen(false)}
>
{label}
</a>
))}
<a href={toSiteHref(switchHref)} onClick={() => setOpen(false)}>{c.toEnglish}</a>
</nav>
<div className="header-tools">
<button aria-label={c.search} className="icon-btn" onClick={() => setSearchOpen(true)} type="button">
<Icon name="search" />
</button>
<button aria-label={c.theme} className="icon-btn" onClick={toggle} type="button">
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
</button>
<a aria-label="GitHub" className="icon-btn" href={GITHUB_URL} rel="noreferrer" target="_blank">
<Icon name="github" />
</a>
<a className="btn btn--primary header-download" href={DOWNLOAD_URL}>
<Icon name="download" size={16} />
{c.download}
</a>
<button
aria-controls="site-nav"
aria-expanded={open}
aria-label={c.menu}
className="icon-btn header-menu-btn"
onClick={() => setOpen((value) => !value)}
type="button"
>
<Icon name={open ? 'close' : 'menu'} />
</button>
</div>
</div>
</header>
{searchOpen && <SearchDialog locale={locale} onClose={() => setSearchOpen(false)} />}
</>
)
}
+38
View File
@@ -0,0 +1,38 @@
const paths = {
arrow: 'M4 10h11M11 5l5 5-5 5',
book: 'M4 4.5A1.5 1.5 0 0 1 5.5 3H16v14H5.5A1.5 1.5 0 0 0 4 18.5V4.5ZM16 3v14',
check: 'M4 10.5 8 14.5 16 5.5',
close: 'M5 5l10 10M15 5 5 15',
copy: 'M7 7V4.5A1.5 1.5 0 0 1 8.5 3h7A1.5 1.5 0 0 1 17 4.5v7A1.5 1.5 0 0 1 15.5 13H13M3 8.5A1.5 1.5 0 0 1 4.5 7h7A1.5 1.5 0 0 1 13 8.5v7A1.5 1.5 0 0 1 11.5 17h-7A1.5 1.5 0 0 1 3 15.5v-7Z',
download: 'M10 3v10m0 0 4-4m-4 4-4-4M3.5 16.5h13',
github: 'M10 1.7a8.3 8.3 0 0 0-2.6 16.2c.4.1.6-.2.6-.5v-1.6c-2.3.5-2.8-1.1-2.8-1.1-.4-1-.9-1.2-.9-1.2-.8-.5 0-.5 0-.5.8.1 1.2.8 1.2.8.7 1.3 1.9.9 2.4.7.1-.5.3-.9.5-1.1-1.8-.2-3.8-.9-3.8-4.1 0-.9.3-1.7.8-2.3-.1-.2-.4-1 .1-2.1 0 0 .7-.2 2.3.9a7.7 7.7 0 0 1 4.2 0c1.6-1.1 2.3-.9 2.3-.9.5 1.1.2 1.9.1 2.1.5.6.8 1.4.8 2.3 0 3.2-2 3.9-3.8 4.1.3.3.6.8.6 1.6v2.4c0 .3.2.6.6.5A8.3 8.3 0 0 0 10 1.7Z',
menu: 'M3 6h14M3 10h14M3 14h14',
moon: 'M16 11.5A6.5 6.5 0 0 1 8.5 4a6.5 6.5 0 1 0 7.5 7.5Z',
search: 'M12.5 12.5 17 17M14 9a5 5 0 1 1-10 0 5 5 0 0 1 10 0Z',
sidebar: 'M3.5 4.5h13v11h-13v-11ZM8 4.5v11',
sun: 'M10 3.5v1.5M10 15v1.5M3.5 10H5m10 0h1.5M5.4 5.4l1 1m7.2 7.2 1 1m0-9.2-1 1m-7.2 7.2-1 1M13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
sun_: '',
translate: 'M3 5h7M6.5 5v1.5c0 3-1.5 5-3.5 6M5 9.5c.7 1.7 2.2 3 4 3.5M10.5 17l3.2-8 3.3 8M11.9 14.2h4.3'
}
export default function Icon({ name, size = 18, ...rest }) {
const d = paths[name]
if (!d) return null
return (
<svg
aria-hidden="true"
fill="none"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 20 20"
width={size}
{...rest}
>
<path d={d} />
</svg>
)
}
+273 -184
View File
@@ -1,12 +1,14 @@
import { Marked, Renderer } from 'marked'
import DOMPurify from 'dompurify'
import { docsManifest } from '../generated/docs-manifest'
import { docsContent, docsIndex, imageSizes, sections } from '../generated/docs-index'
const EXTERNAL_PROTOCOL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
const SITE_BASE = `/${String(import.meta.env.BASE_URL || '/').replace(/^\/+|\/+$/g, '')}`
.replace(/\/{2,}/g, '/')
.replace(/\/$/, '') || '/'
/* ── 路由归一化 ─────────────────────────────────────────────── */
function cleanRoute(route) {
const withoutQuery = route.split(/[?#]/, 1)[0]
let normalized = decodeURIComponent(withoutQuery || '/')
@@ -19,17 +21,21 @@ function cleanRoute(route) {
return normalized
}
function splitHref(href) {
const match = href.match(/^([^?#]*)([?#].*)?$/)
return { path: match?.[1] || '', suffix: match?.[2] || '' }
}
export function toSiteHref(href) {
if (!href || href.startsWith('#') || EXTERNAL_PROTOCOL.test(href)) return href
const { path, suffix } = splitHref(href)
const route = /\.html$/i.test(path)
? `/${decodeURIComponent(path).replace(/^\/+/, '').replace(/\/{2,}/g, '/')}`
: cleanRoute(path)
const { path: rawPath, suffix } = splitHref(href)
const route = /\.html$/i.test(rawPath)
? `/${decodeURIComponent(rawPath).replace(/^\/+/, '').replace(/\/{2,}/g, '/')}`
: cleanRoute(rawPath)
if (SITE_BASE === '/') return `${route}${suffix}`
if (route === SITE_BASE || route.startsWith(`${SITE_BASE}/`)) {
return `${route}${suffix}`
}
if (route === SITE_BASE || route.startsWith(`${SITE_BASE}/`)) return `${route}${suffix}`
return `${SITE_BASE}${route}${suffix}`
}
@@ -41,122 +47,141 @@ function withoutSiteBase(pathname) {
return route
}
function routeFromSourcePath(sourcePath) {
const withoutExtension = sourcePath.replace(/\.md$/i, '')
/* ── 索引 ───────────────────────────────────────────────────── */
if (withoutExtension === 'index') return '/docs'
if (withoutExtension === 'en/index') return '/en/docs'
if (withoutExtension.endsWith('/index')) {
return cleanRoute(`/${withoutExtension.slice(0, -'/index'.length)}`)
}
const docsByRoute = new Map(docsIndex.map((doc) => [doc.path.toLowerCase(), doc]))
const docsBySource = new Map(
docsIndex.map((doc) => [doc.sourcePath.replace(/^docs\//, '').toLowerCase(), doc])
)
return cleanRoute(`/${withoutExtension}`)
/**
* 2026-07 的信息架构重组把大部分文档挪了位置。老链接(README、外站、App 内)
* 不能死在 404 上,这里做一次性重定向。
*/
const LEGACY_ROUTES = new Map(Object.entries({
'/agent': '/internals/agent',
'/agent/01-usage-guide': '/internals/agent',
'/agent/02-implementation': '/internals/agent-internals',
'/agent/03-agent-framework': '/internals/agent-framework',
'/channel': '/internals/channel',
'/channel/01-channel-system': '/internals/channel',
'/channel/02-im-gateway-proposal': '/internals',
'/desktop/01-quick-start': '/start/first-session',
'/desktop/02-architecture': '/internals/desktop',
'/desktop/03-features': '/desktop',
'/desktop/04-installation': '/start/install',
'/desktop/05-faq': '/start/troubleshooting',
'/desktop/06-h5-access': '/desktop/remote',
'/desktop/07-electron-migration-research': '/internals/desktop',
'/desktop/08-electron-migration-tasks': '/internals/desktop',
'/desktop/09-electron-migration-validation-checklist': '/internals/desktop',
'/desktop/10-release-auto-update': '/internals/contributing',
'/docs': '/start',
'/features/computer-use': '/desktop/computer-use',
'/features/computer-use-architecture': '/internals/computer-use',
'/guide/cli-reference': '/cli/reference',
'/guide/contributing': '/internals/contributing',
'/guide/env-vars': '/cli/env',
'/guide/faq': '/start/troubleshooting',
'/guide/global-usage': '/cli',
'/guide/quick-start': '/cli',
'/guide/third-party-models': '/start/models',
'/memory': '/internals/memory',
'/memory/01-usage-guide': '/internals/memory',
'/memory/02-implementation': '/internals/memory-internals',
'/memory/03-autodream': '/internals/autodream',
'/reference/fixes': '/internals',
'/reference/local-server': '/internals/server',
'/reference/project-structure': '/internals/structure',
'/skills': '/internals/skills',
'/skills/01-usage-guide': '/internals/skills',
'/skills/02-implementation': '/internals/skills-internals'
}))
export function resolveLegacyRoute(pathname) {
const route = withoutSiteBase(pathname)
const isEnglish = route === '/en' || route.startsWith('/en/')
const bare = isEnglish ? route.slice(3) || '/' : route
const target = LEGACY_ROUTES.get(bare.toLowerCase())
if (!target) return null
const next = isEnglish ? `/en${target}` : target
return docsByRoute.has(next.toLowerCase()) ? next : null
}
function parseFrontmatter(source) {
if (!source.startsWith('---\n')) return { attributes: {}, body: source }
export function findDoc(pathname) {
if (!pathname) return null
return docsByRoute.get(withoutSiteBase(pathname).toLowerCase()) || null
}
const closingIndex = source.indexOf('\n---\n', 4)
if (closingIndex === -1) return { attributes: {}, body: source }
export function getAllDocs(locale) {
return docsIndex.filter((doc) => !locale || doc.locale === locale)
}
const attributes = {}
const block = source.slice(4, closingIndex)
for (const line of block.split('\n')) {
const separator = line.indexOf(':')
if (separator === -1) continue
const key = line.slice(0, separator).trim()
let value = line.slice(separator + 1).trim()
if (!key || !value) continue
value = value.replace(/^(['"])(.*)\1$/, '$2')
attributes[key] = value
export function getSections() {
return sections
}
/** 侧边栏:按登记好的分区顺序分组,组内按 frontmatter 的 order。 */
export function getDocNavigation(locale = 'zh') {
const grouped = new Map()
for (const doc of getAllDocs(locale)) {
if (!grouped.has(doc.section)) grouped.set(doc.section, [])
grouped.get(doc.section).push(doc)
}
const known = sections
.filter((section) => grouped.has(section.id))
.map((section) => ({ id: section.id, label: section[locale] || section.id }))
const extras = [...grouped.keys()]
.filter((id) => !sections.some((section) => section.id === id))
.sort()
.map((id) => ({ id, label: id }))
return [...known, ...extras].map(({ id, label }) => ({
id,
items: grouped.get(id).map((doc) => ({
label: doc.navTitle || doc.title,
route: doc.path,
title: doc.title
})),
label
}))
}
export function alternateLocaleRoute(doc) {
if (!doc) return '/'
const source = doc.sourcePath.replace(/^docs\//, '')
const target = doc.locale === 'en' ? source.replace(/^en\//, '') : `en/${source}`
const alternate = docsBySource.get(target.toLowerCase())
if (alternate) return alternate.path
return doc.locale === 'en' ? '/start' : '/en/start'
}
export function getAdjacentDocs(doc, navigation) {
const flat = navigation.flatMap((group) =>
group.items.map((item) => ({ ...item, section: group.label }))
)
const index = flat.findIndex((item) => item.route === doc.path)
return {
attributes,
body: source.slice(closingIndex + 5),
next: index >= 0 && index + 1 < flat.length ? flat[index + 1] : null,
previous: index > 0 ? flat[index - 1] : null
}
}
function plainText(value) {
return value
.replace(/<[^>]*>/g, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`~]/g, '')
.trim()
/** 按需拉取一篇文档的正文,Vite 会把每篇切成独立 chunk。 */
export async function loadDocContent(route) {
const load = docsContent[route]
if (!load) return null
const module = await load()
return module.default
}
function titleFromBody(body, sourcePath) {
const match = body.match(/^#\s+(.+)$/m)
if (match) return plainText(match[1])
const filename = sourcePath.split('/').pop().replace(/\.md$/i, '')
return filename
.replace(/^\d+[-_.]?/, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function descriptionFromBody(body) {
const content = body
.replace(/^#.*$/gm, '')
.replace(/^>.*$/gm, '')
.replace(/```[\s\S]*?```/g, '')
.split(/\n\s*\n/)
.map(plainText)
.find((paragraph) => paragraph.length > 30)
return content ? content.slice(0, 180) : ''
}
function orderFromSourcePath(sourcePath) {
const filename = sourcePath.split('/').pop()
const match = filename.match(/^(\d+)/)
return match ? Number(match[1]) : filename === 'index.md' ? 0 : 999
}
function sectionFromSourcePath(sourcePath) {
const parts = sourcePath.split('/')
const offset = parts[0] === 'en' ? 1 : 0
return parts[offset] === 'index.md' ? 'guide' : parts[offset]
}
function preprocessDirectives(markdown) {
return markdown.replace(
/^(:{3,})\s*(info|tip|warning|danger)?\s*([^\n]*)\n([\s\S]*?)^\1\s*$/gm,
(_, _fence, kind = 'info', label, content) => {
const safeKind = kind || 'info'
const heading = label.trim() || {
danger: 'Danger',
info: 'Info',
tip: 'Tip',
warning: 'Warning',
}[safeKind]
return `<aside class="doc-callout doc-callout--${safeKind}">\n<strong>${escapeAttribute(heading)}</strong>\n\n${content.trim()}\n</aside>\n\n`
},
)
}
const docs = docsManifest
.map((record) => {
const sourcePath = record.sourcePath.replace(/^docs\//, '')
const { attributes, body } = parseFrontmatter(String(record.content))
return {
attributes,
body,
description: record.description || attributes.description || descriptionFromBody(body),
locale: record.locale,
order: orderFromSourcePath(sourcePath),
route: record.path,
section: record.section || sectionFromSourcePath(sourcePath),
sourcePath,
title: record.title || attributes.title || titleFromBody(body, sourcePath),
}
})
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath))
const docsByRoute = new Map(docs.map((doc) => [doc.route.toLowerCase(), doc]))
const docsBySource = new Map(docs.map((doc) => [doc.sourcePath.toLowerCase(), doc]))
/* ── Markdown 渲染 ──────────────────────────────────────────── */
function normalizeRelativePath(basePath, targetPath) {
const segments = basePath.split('/')
@@ -171,21 +196,14 @@ function normalizeRelativePath(basePath, targetPath) {
return segments.join('/')
}
function splitHref(href) {
const match = href.match(/^([^?#]*)([?#].*)?$/)
return {
path: match?.[1] || '',
suffix: match?.[2] || '',
}
}
function resolveSourceReference(doc, href) {
const { path, suffix } = splitHref(href)
if (!path || path.startsWith('#') || EXTERNAL_PROTOCOL.test(path)) return href
const { path: rawPath, suffix } = splitHref(href)
if (!rawPath || rawPath.startsWith('#') || EXTERNAL_PROTOCOL.test(rawPath)) return href
const sourcePath = path.startsWith('/')
? path.replace(/^\/+/, '')
: normalizeRelativePath(doc.sourcePath, path)
const source = doc.sourcePath.replace(/^docs\//, '')
const sourcePath = rawPath.startsWith('/')
? rawPath.replace(/^\/+/, '')
: normalizeRelativePath(source, rawPath)
return { sourcePath, suffix }
}
@@ -195,18 +213,14 @@ export function resolveDocHref(doc, href) {
if (typeof reference === 'string') return reference
const { sourcePath, suffix } = reference
if (/\.html$/i.test(sourcePath)) {
return `/${sourcePath.replace(/^\/+/, '')}${suffix}`
}
if (/\.html$/i.test(sourcePath)) return `/${sourcePath.replace(/^\/+/, '')}${suffix}`
const markdownPath = sourcePath.endsWith('.md')
? sourcePath
: `${sourcePath.replace(/\/+$/, '')}.md`
const markdownPath = sourcePath.endsWith('.md') ? sourcePath : `${sourcePath.replace(/\/+$/, '')}.md`
const indexPath = `${sourcePath.replace(/\/+$/, '')}/index.md`
const target = docsBySource.get(markdownPath.toLowerCase())
|| docsBySource.get(indexPath.toLowerCase())
if (target) return `${target.route}${suffix}`
if (target) return `${target.path}${suffix}`
return `${cleanRoute(`/${sourcePath}`)}${suffix}`
}
@@ -218,6 +232,15 @@ export function resolveDocAsset(doc, href) {
return `${toSiteHref(`/${reference.sourcePath}`)}${reference.suffix}`
}
function plainText(value) {
return value
.replace(/<[^>]*>/g, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`~]/g, '')
.trim()
}
function textFromTokens(tokens = []) {
return tokens.map((token) => {
if (typeof token.text === 'string') return plainText(token.text)
@@ -242,10 +265,79 @@ function escapeAttribute(value) {
.replace(/>/g, '&gt;')
}
export function renderMarkdown(doc) {
function preprocessDirectives(markdown) {
return markdown.replace(
/^(:{3,})\s*(info|tip|warning|danger)?\s*([^\n]*)\n([\s\S]*?)^\1\s*$/gm,
(_, _fence, kind = 'info', label, content) => {
const safeKind = kind || 'info'
const heading = label.trim() || {
danger: '注意',
info: '说明',
tip: '提示',
warning: '当心'
}[safeKind]
return `<aside class="doc-callout doc-callout--${safeKind}">\n<strong>${escapeAttribute(heading)}</strong>\n\n${content.trim()}\n</aside>\n\n`
}
)
}
/** 语法高亮按需加载:首屏不为它买单,加载好之后重渲染一次。 */
let highlighter = null
let highlighterPromise = null
export function getHighlighter() {
return highlighter
}
export function ensureHighlighter() {
if (highlighter) return Promise.resolve(highlighter)
if (highlighterPromise) return highlighterPromise
highlighterPromise = (async () => {
const [{ default: hljs }, ...languages] = await Promise.all([
import('highlight.js/lib/core'),
import('highlight.js/lib/languages/bash'),
import('highlight.js/lib/languages/typescript'),
import('highlight.js/lib/languages/javascript'),
import('highlight.js/lib/languages/json'),
import('highlight.js/lib/languages/yaml'),
import('highlight.js/lib/languages/xml'),
import('highlight.js/lib/languages/markdown'),
import('highlight.js/lib/languages/powershell'),
import('highlight.js/lib/languages/nginx'),
import('highlight.js/lib/languages/css'),
import('highlight.js/lib/languages/ini'),
import('highlight.js/lib/languages/python'),
import('highlight.js/lib/languages/diff')
])
const names = [
'bash', 'typescript', 'javascript', 'json', 'yaml', 'xml', 'markdown',
'powershell', 'nginx', 'css', 'ini', 'python', 'diff'
]
names.forEach((name, index) => hljs.registerLanguage(name, languages[index].default))
hljs.registerAliases(['sh', 'shell', 'zsh', 'console'], { languageName: 'bash' })
hljs.registerAliases(['ts', 'tsx'], { languageName: 'typescript' })
hljs.registerAliases(['js', 'jsx', 'mjs'], { languageName: 'javascript' })
hljs.registerAliases(['yml'], { languageName: 'yaml' })
hljs.registerAliases(['html', 'svg'], { languageName: 'xml' })
hljs.registerAliases(['md'], { languageName: 'markdown' })
hljs.registerAliases(['toml', 'conf', 'env', 'dotenv'], { languageName: 'ini' })
hljs.registerAliases(['py'], { languageName: 'python' })
hljs.configure({ classPrefix: 'hljs-' })
highlighter = hljs
return hljs
})()
return highlighterPromise
}
export function renderMarkdown(doc, markdown) {
const renderer = new Renderer()
const headingCounts = new Map()
const tableOfContents = []
const hljs = highlighter
renderer.heading = function heading({ tokens, depth }) {
const content = this.parser.parseInline(tokens)
@@ -255,11 +347,13 @@ export function renderMarkdown(doc) {
const id = count === 0 ? baseSlug : `${baseSlug}-${count}`
headingCounts.set(baseSlug, count + 1)
if (depth >= 2 && depth <= 3) {
tableOfContents.push({ depth, id, text })
}
if (depth >= 2 && depth <= 3) tableOfContents.push({ depth, id, text })
return `<h${depth} id="${escapeAttribute(id)}">${content}<a class="doc-heading-anchor" href="#${escapeAttribute(id)}" aria-label="${escapeAttribute(`Link to ${text}`)}">#</a></h${depth}>`
const anchor = depth >= 2 && depth <= 3
? `<a class="doc-anchor" href="#${escapeAttribute(id)}" aria-label="${escapeAttribute(text)}">#</a>`
: ''
return `<h${depth} id="${escapeAttribute(id)}">${content}${anchor}</h${depth}>`
}
renderer.link = function link({ href, title, tokens }) {
@@ -267,89 +361,84 @@ export function renderMarkdown(doc) {
const isExternal = EXTERNAL_PROTOCOL.test(resolvedHref)
const isStaticHtml = /\.html(?:[?#]|$)/i.test(resolvedHref)
const titleAttribute = title ? ` title="${escapeAttribute(title)}"` : ''
const externalAttributes = isExternal
const extra = isExternal
? ' target="_blank" rel="noreferrer noopener"'
: isStaticHtml
? ''
: ` data-doc-link data-doc-route="${escapeAttribute(resolvedHref)}"`
const publicHref = isExternal ? resolvedHref : toSiteHref(resolvedHref)
return `<a href="${escapeAttribute(publicHref)}"${titleAttribute}${externalAttributes}>${this.parser.parseInline(tokens)}</a>`
return `<a href="${escapeAttribute(publicHref)}"${titleAttribute}${extra}>${this.parser.parseInline(tokens)}</a>`
}
renderer.image = function image({ href, title, text }) {
const resolvedHref = resolveDocAsset(doc, href)
const titleAttribute = title ? ` title="${escapeAttribute(title)}"` : ''
return `<img src="${escapeAttribute(resolvedHref)}" alt="${escapeAttribute(text || '')}"${titleAttribute} loading="lazy" decoding="async">`
// 带上真实像素尺寸,浏览器才能在图片下载完之前按比例留位,正文不会跳。
const size = imageSizes[resolvedHref.split(/[?#]/, 1)[0]]
const sizeAttributes = size ? ` width="${size[0]}" height="${size[1]}"` : ''
// 竖图(手机截图、聊天软件的扫码页)在正文列宽下能铺满两屏,得单独限高。
// 构建期就知道比例,所以这里标出来,CSS 不用去猜文件名。
const tall = size ? ` data-tall="${size[1] / size[0] > 1.1}"` : ''
return `<img src="${escapeAttribute(resolvedHref)}" alt="${escapeAttribute(text || '')}"${titleAttribute}${sizeAttributes}${tall} loading="lazy" decoding="async">`
}
renderer.code = function code({ text, lang, escaped }) {
// 宽表格在窄屏上必须自己横向滚动,不能把页面撑出横向滚动条。
renderer.table = function table(token) {
const header = `<tr>${token.header.map((cell, index) =>
`<th${token.align[index] ? ` style="text-align:${token.align[index]}"` : ''}>${this.parser.parseInline(cell.tokens)}</th>`
).join('')}</tr>`
const body = token.rows.map((row) =>
`<tr>${row.map((cell, index) =>
`<td${token.align[index] ? ` style="text-align:${token.align[index]}"` : ''}>${this.parser.parseInline(cell.tokens)}</td>`
).join('')}</tr>`
).join('')
return `<div class="doc-table-wrap"><table><thead>${header}</thead><tbody>${body}</tbody></table></div>`
}
renderer.code = function code({ text, lang }) {
const language = (lang || '').trim().split(/\s+/)[0]
if (language === 'mermaid') {
return `<div class="doc-mermaid" data-mermaid-pending="true"><pre>${escapeAttribute(text)}</pre></div>`
}
let markup = escapeAttribute(text)
if (hljs && language && hljs.getLanguage(language)) {
try {
markup = hljs.highlight(text, { ignoreIllegals: true, language }).value
} catch {
markup = escapeAttribute(text)
}
}
const className = language ? ` class="language-${escapeAttribute(language)}"` : ''
const codeText = escaped ? text : escapeAttribute(text)
return `<pre data-language="${escapeAttribute(language || 'text')}"><code${className}>${codeText}</code></pre>`
return `<pre data-language="${escapeAttribute(language || 'text')}"><code${className}>${markup}</code></pre>`
}
const parser = new Marked({
gfm: true,
renderer,
})
const html = DOMPurify.sanitize(parser.parse(preprocessDirectives(doc.body)), {
const parser = new Marked({ gfm: true, renderer })
const html = DOMPurify.sanitize(parser.parse(preprocessDirectives(markdown)), {
ADD_ATTR: [
'aria-label',
'data-doc-link',
'data-doc-route',
'data-language',
'data-mermaid-pending',
'data-tall',
'decoding',
'height',
'loading',
'target',
'width'
],
FORBID_TAGS: ['embed', 'form', 'iframe', 'input', 'object', 'script', 'style', 'textarea'],
FORBID_TAGS: ['embed', 'form', 'iframe', 'input', 'object', 'script', 'style', 'textarea']
})
return {
html,
tableOfContents,
}
}
function normalizeRequestedRoute(pathname) {
let route = withoutSiteBase(pathname)
if (route === '/docs') return '/desktop'
if (route === '/en/docs') return '/en/desktop'
if (route.startsWith('/docs/')) route = route.slice('/docs'.length)
if (route.startsWith('/en/docs/')) route = `/en${route.slice('/en/docs'.length)}`
return route
}
export function findDoc(pathname) {
if (!pathname) return null
const route = normalizeRequestedRoute(pathname)
return docsByRoute.get(route.toLowerCase()) || null
}
export function getAllDocs(locale) {
return docs.filter((doc) => !locale || doc.locale === locale)
}
export function getAdjacentDocs(doc, navigation) {
const routes = navigation.flatMap((group) => group.items.map((item) => item.route))
const index = routes.indexOf(doc.route)
return {
next: index >= 0 && index + 1 < routes.length ? findDoc(routes[index + 1]) : null,
previous: index > 0 ? findDoc(routes[index - 1]) : null,
}
return { html, tableOfContents }
}
export function isDocRoute(pathname) {
return Boolean(findDoc(pathname))
|| cleanRoute(pathname) === '/docs'
|| cleanRoute(pathname) === '/en/docs'
return Boolean(findDoc(pathname)) || Boolean(resolveLegacyRoute(pathname))
}
export { docs }
export { docsIndex }
-138
View File
@@ -1,138 +0,0 @@
import { findDoc, getAllDocs } from './docs'
const sectionOrder = [
'guide',
'desktop',
'features',
'agent',
'skills',
'memory',
'im',
'channel',
'reference',
]
const sectionLabels = {
zh: {
agent: '多 Agent',
channel: 'Channel 研究',
desktop: '桌面工作台',
features: '核心能力',
guide: '开始使用',
im: '远程连接',
memory: '记忆系统',
reference: '开发者参考',
skills: 'Skills 系统',
},
en: {
agent: 'Multi-Agent',
channel: 'Channel research',
desktop: 'Desktop workspace',
features: 'Core capabilities',
guide: 'Get started',
im: 'Remote access',
memory: 'Memory',
reference: 'Developer reference',
skills: 'Skills',
},
}
const preferredLabels = new Map([
['/desktop', '桌面端概览'],
['/desktop/01-quick-start', '三分钟快速上手'],
['/desktop/02-architecture', '桌面端架构'],
['/desktop/03-features', '完整功能地图'],
['/desktop/04-installation', '安装与更新'],
['/desktop/05-FAQ', '桌面端排障'],
['/desktop/06-h5-access', '手机与浏览器访问'],
['/desktop/pets', '桌面宠物'],
['/features/computer-use', 'Computer Use'],
['/features/computer-use-architecture', 'Computer Use 架构'],
['/guide/quick-start', 'CLI 安装与启动'],
['/guide/cli-reference', 'CLI 参考'],
['/guide/global-usage', '全局使用(任意目录启动)'],
['/guide/faq', '常见问题与求助'],
['/guide/env-vars', '环境变量'],
['/guide/contributing', '贡献指南与本地质量门禁'],
['/guide/third-party-models', '连接模型服务'],
['/agent', '多 Agent 系统概览'],
['/agent/01-usage-guide', '使用指南'],
['/agent/02-implementation', '实现原理'],
['/agent/03-agent-framework', 'Agent 框架深度解析'],
['/skills/01-usage-guide', '使用指南'],
['/skills/02-implementation', '实现原理'],
['/memory', '记忆系统概览'],
['/memory/01-usage-guide', '使用指南'],
['/memory/02-implementation', '实现原理'],
['/memory/03-autodream', 'AutoDream 记忆整合'],
['/im', 'IM 接入总览'],
['/channel', 'Channel 源码研究'],
['/channel/01-channel-system', 'Channel 系统架构解析'],
['/channel/02-im-gateway-proposal', 'IM Gateway 方案设计(历史设计稿)'],
['/reference/local-server', '本地服务与 API'],
['/reference/fixes', '泄露源码修复说明'],
['/reference/project-structure', '项目结构'],
['/en/desktop', 'Desktop overview'],
['/en/desktop/01-quick-start', 'Quick start'],
['/en/desktop/03-features', 'Feature map'],
['/en/desktop/04-installation', 'Install & update'],
['/en/desktop/05-FAQ', 'Desktop troubleshooting'],
['/en/desktop/06-h5-access', 'Browser & mobile access'],
['/en/desktop/pets', 'Desktop pets'],
['/en/features/computer-use', 'Computer Use'],
['/en/guide/quick-start', 'CLI quick start'],
['/en/guide/third-party-models', 'Connect a model'],
['/en/agent', 'Multi-agent overview'],
['/en/agent/01-usage-guide', 'Usage guide'],
['/en/agent/02-implementation', 'Implementation'],
['/en/agent/03-agent-framework', 'Agent framework deep dive'],
['/en/skills/01-usage-guide', 'Usage guide'],
['/en/skills/02-implementation', 'Implementation'],
['/en/memory', 'Memory system overview'],
['/en/memory/01-usage-guide', 'Usage guide'],
['/en/memory/02-implementation', 'Implementation'],
['/en/memory/03-autodream', 'AutoDream memory consolidation'],
['/en/im', 'IM overview'],
['/en/reference/local-server', 'Local server & API'],
['/en/reference/fixes', 'Fixes vs. leaked source'],
])
export function getDocNavigation(locale = 'zh') {
const grouped = new Map()
for (const doc of getAllDocs(locale)) {
const sectionDocs = grouped.get(doc.section) || []
sectionDocs.push(doc)
grouped.set(doc.section, sectionDocs)
}
return [...grouped.entries()]
.sort(([left], [right]) => {
const leftIndex = sectionOrder.indexOf(left)
const rightIndex = sectionOrder.indexOf(right)
return (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex)
- (rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex)
|| left.localeCompare(right)
})
.map(([section, sectionDocs]) => ({
label: sectionLabels[locale]?.[section] || section,
items: sectionDocs
.sort((left, right) => left.order - right.order || left.title.localeCompare(right.title))
.map((doc) => ({
label: preferredLabels.get(doc.route) || doc.title,
route: doc.route,
title: doc.title,
})),
}))
}
export function alternateLocaleRoute(doc) {
if (!doc) return '/'
const sourcePath = doc.locale === 'en'
? doc.sourcePath.replace(/^en\//, '')
: `en/${doc.sourcePath}`
const alternate = findDoc(`/${sourcePath.replace(/(?:\/index)?\.md$/i, '')}`)
if (alternate) return alternate.route
return doc.locale === 'en' ? '/docs' : '/en/docs'
}
+541 -614
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
const SITE_ORIGIN = 'https://claudecode-haha.relakkesyang.org'
function upsert(selector, create) {
let node = document.head.querySelector(selector)
if (!node) {
node = create()
document.head.append(node)
}
return node
}
function setMetaContent(attribute, key, value) {
const selector = `meta[${attribute}="${key}"]`
if (!value) {
document.head.querySelector(selector)?.remove()
return
}
const node = upsert(selector, () => {
const meta = document.createElement('meta')
meta.setAttribute(attribute, key)
return meta
})
node.setAttribute('content', value)
}
function setLink(rel, href, hreflang) {
const selector = hreflang ? `link[rel="${rel}"][hreflang="${hreflang}"]` : `link[rel="${rel}"]:not([hreflang])`
if (!href) {
document.head.querySelector(selector)?.remove()
return
}
const node = upsert(selector, () => {
const link = document.createElement('link')
link.setAttribute('rel', rel)
if (hreflang) link.setAttribute('hreflang', hreflang)
return link
})
node.setAttribute('href', href)
}
/**
* SPA 换页时同步 title / description / canonical / hreflang。
* 静态构建会把同样的值写进每条路由的 HTML 骨架,所以爬虫拿到的也是对的。
*/
export function setPageMeta({ alternate, canonical, description, lang, title }) {
document.title = title
document.documentElement.lang = lang
setMetaContent('name', 'description', description)
setMetaContent('property', 'og:title', title)
setMetaContent('property', 'og:description', description)
setMetaContent('property', 'og:url', canonical ? `${SITE_ORIGIN}${canonical}` : null)
setLink('canonical', canonical ? `${SITE_ORIGIN}${canonical}` : null)
if (canonical) {
const isEnglish = canonical === '/en' || canonical.startsWith('/en/')
setLink('alternate', `${SITE_ORIGIN}${canonical}`, isEnglish ? 'en' : 'zh-Hans')
setLink('alternate', alternate ? `${SITE_ORIGIN}${alternate}` : null, isEnglish ? 'zh-Hans' : 'en')
}
}
+60
View File
@@ -0,0 +1,60 @@
import { useCallback, useEffect, useState } from 'react'
const STORAGE_KEY = 'cch-theme'
export function readStoredTheme() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored === 'dark' || stored === 'light' ? stored : null
} catch {
return null
}
}
export function systemTheme() {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function applyTheme(theme) {
document.documentElement.dataset.theme = theme
}
/**
* 深浅色跟随系统,用户手动切过之后以用户的选择为准。
* 首帧的主题由 index.html 里的引导脚本设置,避免闪白。
*/
export function useTheme() {
const [theme, setTheme] = useState(() =>
(typeof document === 'undefined' ? 'light' : document.documentElement.dataset.theme || 'light')
)
useEffect(() => {
const media = window.matchMedia?.('(prefers-color-scheme: dark)')
if (!media) return undefined
const onChange = () => {
if (readStoredTheme()) return
const next = media.matches ? 'dark' : 'light'
applyTheme(next)
setTheme(next)
}
media.addEventListener('change', onChange)
return () => media.removeEventListener('change', onChange)
}, [])
const toggle = useCallback(() => {
setTheme((current) => {
const next = current === 'dark' ? 'light' : 'dark'
applyTheme(next)
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
/* 隐私模式下写不进去,忽略即可 */
}
return next
})
}, [])
return { theme, toggle }
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles/site.css'
import ErrorBoundary from './components/ErrorBoundary'
import './styles/base.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
+256 -388
View File
@@ -1,451 +1,319 @@
import { useEffect, useRef, useState } from 'react'
import Icon from '../../components/Icon'
import appIcon from '../../../../docs/images/app-icon.png'
import mainSession from '../../../../docs/images/desktop_ui/25_main_session.png'
import petSettings from '../../../../docs/images/desktop_ui/14_pet_settings_overview.png'
import computerUse from '../../../../docs/images/desktop_ui/06_settings_computer_use.png'
import scheduledTask from '../../../../docs/images/desktop_ui/20_scheduled_task.png'
import skillMarketplace from '../../../../docs/images/desktop_ui/21_skill_marketplace.png'
import diffReview from '../../../../docs/images/desktop_ui/23_workspace_diff_review.png'
import browserPreview from '../../../../docs/images/desktop_ui/24_browser_preview.png'
import dada from '../../../../desktop/src/assets/agent-mascots/agent-mascot-code.png'
import huhu from '../../../../desktop/src/assets/agent-mascots/agent-mascot-plan.png'
import bubu from '../../../../desktop/src/assets/agent-mascots/agent-mascot-fix.png'
import huihui from '../../../../desktop/src/assets/agent-mascots/agent-mascot-build.png'
import { useEffect, useState } from 'react'
import Icon from '../../components/icons'
import SiteHeader, { DOWNLOAD_URL, GITHUB_URL } from '../../components/SiteHeader'
import { toSiteHref } from '../../content/docs'
import { setPageMeta } from '../../lib/meta'
import { content, mascotAccents, mascots } from './content'
import './home.css'
const DOWNLOAD_URL = 'https://github.com/NanmiCoder/cc-haha/releases/latest'
const GITHUB_URL = 'https://github.com/NanmiCoder/cc-haha'
const SOURCE_COMMANDS = [
'git clone https://github.com/NanmiCoder/cc-haha.git',
'cd cc-haha && bun install',
'./bin/claude-haha'
]
const content = {
zh: {
nav: ['产品旅程', '真实界面', '认识搭档', '文档'],
download: '下载桌面端',
eyebrow: 'CLAUDE CODE · 现在有了一间工作室',
titleA: 'Agent 干活的全过程,',
titleB: '你看得见、审得完、管得住。',
intro: 'Claude Code 的本地优先桌面工作室:跑会话、派 Agent、审改动,离开电脑用手机继续同一条会话。',
primary: '下载 macOS / Windows / Linux',
secondary: '先跑通第一条会话',
local: '本地优先',
providers: '模型自选',
remote: '离开也能续',
fig: 'FIG. 01 — MAIN SESSION · 实拍直出',
figStatus: '127.0.0.1 · 已连接',
heroNote: '真实截图,没修过——连光标都是原装的。',
rail: '桌面工作室 · A STUDIO FOR CLAUDE CODE · 本地优先 ·',
journeyEyebrow: 'ONE SESSION, MANY HANDS',
journeyTitle: '一句话开工,五步见结果。',
journeyIntro: '所有功能挂在同一条会话上:你说目标,它们各司其职,进度随时可查。',
steps: [
['01', '说出任务', '在 Main Session 里输入目标,选好项目、模型和权限模式,回车开工。'],
['02', '拆开派活', '大活交给 Task、SubAgent 或 Agent Team 并行干,后台 Agent 的工具活动也汇总进活动面板。'],
['03', '审完再落地', '改动按文件逐个看 Diff,试验放进 Worktree 不碰主分支,你点头才落地。'],
['04', '把手伸出电脑', 'Computer Use 让 Agent 操作真实桌面;出门用手机 H5 或 IM 继续会话,断连不中断。'],
['05', '到点自动跑', '把重复流程设成定时任务,到点在独立会话执行,每跑一次都留记录可复盘。'],
],
tourEyebrow: 'REAL PRODUCT, NOT A MOCKUP',
tourTitle: '所有截图,都拍自真实产品。',
tourIntro: '不用概念图充数:全部截取自运行中的桌面端,你看到的功能,装好就是那个样子。',
tours: [
{ id: 'session', label: 'Main Session', kicker: '从对话到代码', title: '描述目标,看它一步步做完。', body: '选好项目、模型和权限后开聊;它产生的每处代码改动都留在上下文里,等你审完再落地。', image: mainSession },
{ id: 'pets', label: '桌面宠物', kicker: '状态一眼可见', title: '宠物在动,Agent 就在干活。', body: '搭搭、弧弧、补补、回回随任务状态变换动作;上传一张角色图,就能养一只自己的。', image: petSettings },
{ id: 'computer', label: 'Computer Use', kicker: '看得见,也动得了', title: '能看屏幕,能点能输能验证。', body: '原生无障碍引擎驱动:Agent 能看屏幕、点鼠标、敲键盘,做完还会自己核对;授权分级,敏感操作等你点头。', image: computerUse },
{ id: 'schedule', label: '定时任务', kicker: '到点自动开工', title: '设好时间,它按时回来交活。', body: '定好频率、模型、目录和通知方式;任务在独立会话执行,每跑一次都有记录可查。', image: scheduledTask },
{ id: 'skills', label: 'Skills & Agents', kicker: '给 Agent 添本事', title: '缺什么手艺,装什么手艺。', body: '看中就装,来源和安全提示摆在明处;再给自己的 Agent 单独配模型、工具和系统提示词。', image: skillMarketplace },
{ id: 'diff', label: '代码审阅', kicker: '先看清楚再决定', title: '改了什么,逐个文件看清楚。', body: '改动按文件列出,Diff 逐行对照;Worktree 把试验隔在主分支外,合不合你说了算。', image: diffReview },
{ id: 'preview', label: '浏览器预览', kicker: '做完当场验证', title: '页面效果,会话里直接验证。', body: '在应用里打开本地或公开网页,看到问题直接带回会话继续改;出门就换 H5 或 IM 接力。', image: browserPreview },
],
petEyebrow: 'MEET THE CREW',
petTitle: '认识搭搭、弧弧、补补、回回。',
petIntro: '一个图标代表所有状态,太敷衍了。搭搭、弧弧、补补、回回随任务变换动作——忙不忙,看一眼就知道。',
pets: [
['搭搭', 'Dada', '搭建', '把想法一块块变成可运行的东西。', dada, '#2eaa91'],
['弧弧', 'Huhu', '规划', '复杂任务也能画出一条清楚路线。', huhu, '#3577d4'],
['补补', 'Bubu', '修复', '找到裂缝,验证之后再补好它。', bubu, '#e56645'],
['回回', 'Huihui', '构建', '新回复一到,就抱着齿轮继续跑。', huihui, '#7657c8'],
],
docsEyebrow: 'THE FIELD GUIDE',
docsTitle: '按任务查,不按目录翻。',
docsIntro: '文档按真实任务组织:先跑通第一次会话,再按需深入配置、原理和排障。',
docGroups: [
['第一次上手', '装好应用、接上模型账号,跑通你的第一条 Main Session。', '/desktop/01-quick-start', '打开快速上手'],
['用熟桌面端', '会话、多 Agent 协作、代码审阅、宠物和定时任务,一个个用起来。', '/desktop/03-features', '逛逛桌面功能'],
['连到电脑外', '配置 Computer Use、手机 H5 和 IM 接入,弄清授权和安全边界。', '/features/computer-use', '配置远程入口'],
['看懂底层', '本地服务、Agent 机制、记忆、Skills 和项目结构,写给想改代码的人。', '/reference/project-structure', '读开发者参考'],
],
installEyebrow: 'READY WHEN YOU ARE',
installTitle: '现在下载,马上开工。',
installBody: 'GitHub Releases 有三平台安装包,也可 bun install 从源码跑。打开、选项目、说任务,今天就跑通第一条会话。',
copy: '复制',
copied: '已复制',
footer: '本地优先的 Claude Code 桌面工作室',
},
en: {
nav: ['Journey', 'Real UI', 'Meet the crew', 'Docs'],
download: 'Download desktop',
eyebrow: 'CLAUDE CODE · NOW HAS A STUDIO',
titleA: 'Every step your agents take,',
titleB: 'you can see, review, and stop.',
intro: 'A local-first studio for Claude Code: run sessions, split work across agents, review diffs, and keep going from your phone.',
primary: 'Download for macOS / Windows / Linux',
secondary: 'Run your first session',
local: 'Local-first',
providers: 'Bring your models',
remote: 'Phone-friendly',
fig: 'FIG. 01 — MAIN SESSION, AS SHIPPED',
figStatus: '127.0.0.1 · CONNECTED',
heroNote: 'A real screenshot, unretouched—cursor included as found.',
rail: 'A STUDIO FOR CLAUDE CODE · LOCAL-FIRST · DESKTOP-NATIVE ·',
journeyEyebrow: 'ONE SESSION, MANY HANDS',
journeyTitle: 'One sentence starts it. Five steps finish it.',
journeyIntro: 'Every capability hangs off the same session, so you never switch tools to get from ask to done.',
steps: [
['01', 'Say the task', 'Type the goal into Main Session, pick the project, model, and permission mode, and hit enter.'],
['02', 'Split the work', 'Break big jobs across tasks, subagents, or an agent team. Background progress still rolls up into the activity panel.'],
['03', 'Review, then land', 'Open a diff for every changed file, keep experiments in a worktree, and land nothing until you approve it.'],
['04', 'Reach past the screen', 'Computer Use operates the real desktop. Away from it, continue the same session from H5 or IM—disconnects don\'t interrupt.'],
['05', 'Put it on a clock', 'Turn repeatable routines into scheduled jobs that run in their own sessions, each run leaving a record to revisit.'],
],
tourEyebrow: 'REAL PRODUCT, NOT A MOCKUP',
tourTitle: 'Every screenshot is the real app.',
tourIntro: 'No concept art. The feature you see in a frame is the one that opens after install.',
tours: [
{ id: 'session', label: 'Main Session', kicker: 'From prompt to code', title: 'Describe the goal. Watch it get done.', body: 'Pick the project, model, and permissions, then start talking. Every change it makes stays open for your review.', image: mainSession },
{ id: 'pets', label: 'Desktop pets', kicker: 'Status at a glance', title: 'If a pet is moving, an agent is working.', body: 'Dada, Huhu, Bubu, and Huihui move with the task at hand. Upload one character image and raise a pet of your own.', image: petSettings },
{ id: 'computer', label: 'Computer Use', kicker: 'Eyes and hands on the desktop', title: 'Sees the screen. Clicks, types, verifies.', body: 'Computer Use runs on a native accessibility engine with tiered authorization. Sensitive moves still wait for your nod.', image: computerUse },
{ id: 'schedule', label: 'Scheduled work', kicker: 'Back on time', title: 'Set the time. It comes back with results.', body: 'Pick a cadence, model, directory, and notification. Jobs run in their own sessions, and every run leaves a record you can review.', image: scheduledTask },
{ id: 'skills', label: 'Skills & Agents', kicker: 'A studio that can grow', title: 'Missing a trick? Install it.', body: 'Install what looks useful—source and safety notes are shown up front. Then give each agent its own model, tools, and system prompt.', image: skillMarketplace },
{ id: 'diff', label: 'Code review', kicker: 'Look before you land', title: 'Know exactly what changed, file by file.', body: 'Every edit is listed per file with its diff; worktrees keep experiments off your main branch. What lands is your call.', image: diffReview },
{ id: 'preview', label: 'Browser preview', kicker: 'Verify on the spot', title: 'Check the page without leaving the session.', body: 'Open local or public pages inside the app and bring what you see back into the task. Step away and hand off to H5 or IM instead.', image: browserPreview },
],
petEyebrow: 'MEET THE CREW',
petTitle: 'Meet Dada, Huhu, Bubu, and Huihui.',
petIntro: 'One icon for every state felt lazy, so we adopted Dada, Huhu, Bubu, and Huihui—running, thinking, waiting, done. One glance tells you.',
pets: [
['搭搭', 'Dada', 'Build', 'Turns an idea into something you can run.', dada, '#2eaa91'],
['弧弧', 'Huhu', 'Plan', 'Finds a clear route through complicated work.', huhu, '#3577d4'],
['补补', 'Bubu', 'Fix', 'Finds the crack, proves it, then patches it.', bubu, '#e56645'],
['回回', 'Huihui', 'Ship', 'Grabs the gear and moves when a reply arrives.', huihui, '#7657c8'],
],
docsEyebrow: 'THE FIELD GUIDE',
docsTitle: 'Find answers by task, not by chapter.',
docsIntro: 'The docs follow real tasks: get your first session running, then go deeper into configuration, internals, and fixes as needed.',
docGroups: [
['First launch', 'Install the app, connect a model account, and finish your first Main Session.', '/en/desktop/01-quick-start', 'Open the quick start'],
['The desktop studio', 'Main Session, the activity panel, code review, pets, and scheduled runs.', '/en/desktop/03-features', 'Tour the desktop app'],
['Beyond the machine', 'Set up Computer Use, H5, and IM access, and know exactly where the permission boundaries are.', '/en/features/computer-use', 'Configure remote access'],
['Under the hood', 'Local services, agent mechanics, memory, skills, and project structure—written for people who hack on it.', '/en/reference/project-structure', 'Read the developer reference'],
],
installEyebrow: 'READY WHEN YOU ARE',
installTitle: 'Download it and get to work.',
installBody: 'Grab the installer on GitHub Releases, or bun install from source. Open the app, pick a project, and say your first task.',
copy: 'Copy',
copied: 'Copied',
footer: 'A local-first desktop studio for Claude Code',
},
}
function useReveal() {
const scope = useRef(null)
useEffect(() => {
const root = scope.current
if (!root) return
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.dataset.visible = 'true'
observer.unobserve(entry.target)
}
})
}, { threshold: 0.14 })
root.querySelectorAll('[data-reveal]').forEach((node) => observer.observe(node))
return () => observer.disconnect()
}, [])
return scope
}
function Header({ locale, c }) {
const [open, setOpen] = useState(false)
const prefix = locale === 'en' ? '/en' : ''
const ids = ['journey', 'tour', 'crew', 'guide']
const closeMenu = () => setOpen(false)
return (
<header className="site-header">
<a className="brand" href={prefix || '/'} aria-label="Claude Code Haha">
<span className="brand-mark"><img src={appIcon} alt="" /></span>
<span className="brand-word">Claude Code <b>Haha</b></span>
</a>
<button className="menu-button" type="button" aria-label="Toggle navigation" aria-expanded={open} onClick={() => setOpen(!open)}>
<Icon name="menu" />
</button>
<nav className={open ? 'header-nav is-open' : 'header-nav'} aria-label="Main navigation">
{c.nav.map((item, index) => <a href={`${prefix || ''}/#${ids[index]}`} key={item} onClick={closeMenu}>{item}</a>)}
<a href={locale === 'en' ? '/' : '/en/'} onClick={closeMenu}>{locale === 'en' ? '中文' : 'EN'}</a>
<a className="nav-download" href={DOWNLOAD_URL}>
<Icon name="download" size={15} />{c.download}
</a>
</nav>
</header>
)
}
function Hero({ locale, c }) {
function Hero({ c, locale }) {
return (
<section className="hero">
<div className="hero-copy" data-reveal>
<div className="eyebrow">{c.eyebrow}</div>
<h1><span>{c.titleA}</span><em>{c.titleB}</em></h1>
<p className="hero-intro">{c.intro}</p>
<div className="hero-actions">
<a className="button button-primary" href={DOWNLOAD_URL}>{c.primary}<Icon name="arrow" /></a>
<a className="button button-ghost" href={locale === 'en' ? '/en/desktop/01-quick-start' : '/desktop/01-quick-start'}>{c.secondary}<Icon name="arrow" /></a>
</div>
<div className="hero-trust" aria-label="Product qualities">
{[c.local, c.providers, c.remote].map((item) => <span key={item}>{item}</span>)}
</div>
</div>
<div className="hero-stage" data-reveal>
<div className="stage-frame">
<div className="frame-meta" aria-hidden="true">
<span>{c.fig}</span>
<span className="frame-status"><i />{c.figStatus}</span>
<div className="u-shell hero__inner">
<div className="hero__copy">
<h1>{c.hero.title}</h1>
<p className="u-lede">{c.hero.lede}</p>
<div className="hero__actions">
<a className="btn btn--primary btn--lg" href={DOWNLOAD_URL}>
<Icon name="download" size={17} />
{c.hero.primary}
</a>
<a
className="btn btn--ghost btn--lg"
href={toSiteHref(locale === 'en' ? '/en/start/first-session' : '/start/first-session')}
>
{c.hero.secondary}
<Icon name="arrow" size={17} />
</a>
</div>
<div className="window-card">
<div className="window-bar">
<span><i /><i /><i /></span>
<b>CLAUDE CODE HAHA</b>
<small>SESSION — 001</small>
</div>
<img src={mainSession} alt="Claude Code Haha Main Session" />
</div>
<img className="stage-mascot" src={huhu} alt="" />
<p className="stage-caption">{c.heroNote}</p>
<ul className="hero__badges">
{c.hero.badges.map((badge) => <li key={badge}>{badge}</li>)}
</ul>
</div>
<figure className="hero__figure">
<img
alt={c.tour.tabs[0].title}
fetchPriority="high"
height="1436"
src={c.tour.tabs[0].image}
width="2000"
/>
<figcaption>{c.hero.caption}</figcaption>
</figure>
</div>
<div className="hero-rail" aria-hidden="true"><span>{c.rail}</span></div>
</section>
)
}
function Journey({ c }) {
function Capabilities({ c }) {
return (
<section className="section journey" id="journey">
<div className="section-heading" data-reveal>
<div className="eyebrow">{c.journeyEyebrow}</div>
<h2>{c.journeyTitle}</h2>
<p>{c.journeyIntro}</p>
<section className="section" id="capabilities">
<div className="u-shell">
<header className="section__head">
<h2>{c.capabilities.title}</h2>
<p className="u-lede">{c.capabilities.lede}</p>
</header>
<ul className="cap-grid">
{c.capabilities.items.map(([title, body]) => (
<li key={title}>
<h3>{title}</h3>
<p>{body}</p>
</li>
))}
</ul>
</div>
<ol className="journey-list">
{c.steps.map(([number, title, body]) => (
<li data-reveal key={number}>
<div className="step-number">{number}</div>
<div className="step-copy"><h3>{title}</h3><p>{body}</p></div>
<div className="step-arrow" aria-hidden="true"><Icon name="arrow" size={22} /></div>
</li>
))}
</ol>
</section>
)
}
function ProductTour({ c }) {
const [activeId, setActiveId] = useState(c.tours[0].id)
const active = c.tours.find((tour) => tour.id === activeId) || c.tours[0]
function Tour({ c, locale }) {
const [activeId, setActiveId] = useState(c.tour.tabs[0].id)
useEffect(() => setActiveId(c.tour.tabs[0].id), [c])
useEffect(() => setActiveId(c.tours[0].id), [c])
const active = c.tour.tabs.find((tab) => tab.id === activeId) || c.tour.tabs[0]
const selectAdjacentTab = (event, currentIndex) => {
function onKeyDown(event, index) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
let nextIndex = currentIndex
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = c.tours.length - 1
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + c.tours.length) % c.tours.length
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % c.tours.length
const nextTour = c.tours[nextIndex]
setActiveId(nextTour.id)
event.currentTarget.parentElement
?.querySelector(`#tour-tab-${nextTour.id}`)
?.focus()
const count = c.tour.tabs.length
let next = index
if (event.key === 'Home') next = 0
if (event.key === 'End') next = count - 1
if (event.key === 'ArrowLeft') next = (index - 1 + count) % count
if (event.key === 'ArrowRight') next = (index + 1) % count
setActiveId(c.tour.tabs[next].id)
event.currentTarget.parentElement?.querySelector(`#tour-${c.tour.tabs[next].id}`)?.focus()
}
return (
<section className="section product-tour" id="tour">
<div className="tour-heading" data-reveal>
<div>
<div className="eyebrow">{c.tourEyebrow}</div>
<h2>{c.tourTitle}</h2>
<section className="section section--muted" id="tour">
<div className="u-shell">
<header className="section__head">
<h2>{c.tour.title}</h2>
<p className="u-lede">{c.tour.lede}</p>
</header>
<div
aria-label={locale === 'en' ? 'Product tour' : '产品巡览'}
className="tour__tabs"
role="tablist"
>
{c.tour.tabs.map((tab, index) => (
<button
aria-controls={`tourpanel-${tab.id}`}
aria-selected={tab.id === active.id}
className="tour__tab"
id={`tour-${tab.id}`}
key={tab.id}
onClick={() => setActiveId(tab.id)}
onKeyDown={(event) => onKeyDown(event, index)}
role="tab"
tabIndex={tab.id === active.id ? 0 : -1}
type="button"
>
{tab.label}
</button>
))}
</div>
<p>{c.tourIntro}</p>
</div>
<div className="tour-tabs" role="tablist" aria-label="Product tour">
{c.tours.map((tour, index) => (
<button
aria-controls={`tour-panel-${tour.id}`}
aria-selected={active.id === tour.id}
className={active.id === tour.id ? 'is-active' : ''}
id={`tour-tab-${tour.id}`}
key={tour.id}
onClick={() => setActiveId(tour.id)}
onKeyDown={(event) => selectAdjacentTab(event, index)}
role="tab"
tabIndex={active.id === tour.id ? 0 : -1}
type="button"
>
<span>{String(index + 1).padStart(2, '0')}</span>{tour.label}
</button>
))}
</div>
<div
aria-labelledby={`tour-tab-${active.id}`}
className="tour-board"
data-reveal
id={`tour-panel-${active.id}`}
role="tabpanel"
tabIndex={0}
>
<div className="tour-copy">
<div className="tour-kicker">{active.kicker}</div>
<h3>{active.title}</h3>
<p>{active.body}</p>
</div>
<div className="tour-screen">
<img key={active.id} src={active.image} alt={`${active.label} interface`} />
<div
aria-labelledby={`tour-${active.id}`}
className="tour__panel"
id={`tourpanel-${active.id}`}
role="tabpanel"
tabIndex={0}
>
<div className="tour__copy">
<h3>{active.title}</h3>
<p>{active.body}</p>
</div>
<div className={active.id === 'remote' ? 'tour__shot tour__shot--tall' : 'tour__shot'}>
<img alt={active.title} key={active.id} loading="lazy" src={active.image} />
</div>
</div>
</div>
</section>
)
}
function Crew({ c }) {
function Crew({ c, locale }) {
return (
<section className="section crew" id="crew">
<div className="crew-intro" data-reveal>
<div className="eyebrow">{c.petEyebrow}</div>
<h2>{c.petTitle}</h2>
<p>{c.petIntro}</p>
</div>
<div className="crew-track">
{c.pets.map(([name, latin, role, body, image, color], index) => (
<article className="crew-member" data-reveal key={latin} style={{ '--pet-accent': color, '--pet-delay': `${index * 90}ms` }}>
<div className="crew-callsign"><span>UNIT 0{index + 1}</span><i style={{ background: color }} /></div>
<div className="pet-portrait"><img src={image} alt="" /></div>
<div className="crew-role">{role}</div>
<h3>{name}<em>{latin}</em></h3>
<p>{body}</p>
</article>
))}
</div>
</section>
)
}
function Guide({ c }) {
return (
<section className="section guide" id="guide">
<div className="guide-title" data-reveal>
<div className="eyebrow">{c.docsEyebrow}</div>
<h2>{c.docsTitle}</h2>
<p>{c.docsIntro}</p>
</div>
<div className="guide-stack">
{c.docGroups.map(([title, body, href, action], index) => (
<a className="guide-row" data-reveal href={href} key={title}>
<span className="guide-number">{String(index + 1).padStart(2, '0')}</span>
<span className="guide-row-copy"><strong>{title}</strong><small>{body}</small></span>
<span className="guide-action">{action}<Icon name="arrow" /></span>
<section className="section" id="crew">
<div className="u-shell crew">
<div className="crew__intro">
<h2>{c.crew.title}</h2>
<p className="u-lede">{c.crew.lede}</p>
<a className="link-arrow" href={toSiteHref(locale === 'en' ? '/en/desktop/pets' : '/desktop/pets')}>
{c.crew.link}
<Icon name="arrow" size={16} />
</a>
))}
</div>
<ul className="crew__grid">
{c.crew.members.map(([name, latin, role, body], index) => (
<li key={latin} style={{ '--accent': mascotAccents[index] }}>
<img alt="" loading="lazy" src={mascots[index]} />
<span className="crew__role">{role}</span>
<h3>{name}{name !== latin && <em>{latin}</em>}</h3>
<p>{body}</p>
</li>
))}
</ul>
</div>
</section>
)
}
function Install({ locale, c }) {
const [copied, setCopied] = useState(false)
const command = [
'git clone https://github.com/NanmiCoder/cc-haha.git',
'cd cc-haha && bun install',
'./bin/claude-haha',
].join('\n')
function Paths({ c }) {
return (
<section className="section section--muted" id="docs">
<div className="u-shell">
<header className="section__head">
<h2>{c.paths.title}</h2>
<p className="u-lede">{c.paths.lede}</p>
</header>
<div className="paths">
{c.paths.items.map((item) => (
<article className="path-card" key={item.title}>
<span className="path-card__eyebrow">{item.eyebrow}</span>
<h3>{item.title}</h3>
<p>{item.body}</p>
<ul>
{item.links.map(([href, label]) => (
<li key={href}>
<a href={toSiteHref(href)}>
{label}
<Icon name="arrow" size={16} />
</a>
</li>
))}
</ul>
</article>
))}
</div>
</div>
</section>
)
}
const copyCommand = async () => {
await navigator.clipboard.writeText(command)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
function Install({ c, locale }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(SOURCE_COMMANDS.join('\n'))
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
/* 剪贴板被禁用时静默失败,命令仍然可以手选复制 */
}
}
return (
<section className="section install" id="install">
<div className="install-copy">
<div className="eyebrow">{c.installEyebrow}</div>
<h2>{c.installTitle}</h2>
<p>{c.installBody}</p>
<div className="install-actions">
<a className="button button-paper" href={DOWNLOAD_URL}><Icon name="download" />{c.primary}</a>
<a className="text-link" href={locale === 'en' ? '/en/desktop/04-installation' : '/desktop/04-installation'}>Installation guide <Icon name="arrow" size={16} /></a>
<section className="section" id="install">
<div className="u-shell install">
<div className="install__copy">
<h2>{c.install.title}</h2>
<p className="u-lede">{c.install.lede}</p>
<div className="install__actions">
<a className="btn btn--primary btn--lg" href={DOWNLOAD_URL}>
<Icon name="download" size={17} />
{c.install.primary}
</a>
<a
className="link-arrow"
href={toSiteHref(locale === 'en' ? '/en/start/troubleshooting' : '/start/troubleshooting')}
>
{c.install.docs}
<Icon name="arrow" size={16} />
</a>
</div>
</div>
<div className="install__terminal">
<div className="install__terminal-head">
<span>{c.install.commandLabel}</span>
<button onClick={copy} type="button">
<Icon name={copied ? 'check' : 'copy'} size={15} />
{copied ? c.install.copied : c.install.copy}
</button>
</div>
<pre><code>{SOURCE_COMMANDS.map((line) => `$ ${line}`).join('\n')}</code></pre>
</div>
</div>
<div className="command-sheet">
<div className="command-label">CLI · SOURCE CHECKOUT</div>
<code>
<span>$</span> git clone https://github.com/NanmiCoder/cc-haha.git<br />
<span>$</span> cd cc-haha &amp;&amp; bun install<br />
<span>$</span> ./bin/claude-haha
</code>
<button type="button" onClick={copyCommand}><Icon name={copied ? 'check' : 'copy'} size={16} />{copied ? c.copied : c.copy}</button>
<p>Desktop + CLI · same local runtime</p>
<div className="install-mascot" aria-hidden="true"><img src={dada} alt="" /></div>
</div>
</section>
)
}
function Footer({ locale, c }) {
function Footer({ c, locale }) {
return (
<footer className="footer">
<div className="brand footer-brand"><span className="brand-mark"><img src={appIcon} alt="" /></span><span className="brand-word">Claude Code <b>Haha</b></span></div>
<p>{c.footer}</p>
<div className="footer-links">
<a href={locale === 'en' ? '/en/desktop/01-quick-start' : '/desktop/01-quick-start'}>Docs</a>
<a href={GITHUB_URL}><Icon name="github" size={17} />GitHub</a>
<a href={locale === 'en' ? '/' : '/en/'}>{locale === 'en' ? '中文' : 'English'}</a>
<footer className="site-footer">
<div className="u-shell site-footer__inner">
<div className="site-footer__brand">
<img alt="" src={toSiteHref('/images/app-icon.png')} width="30" height="30" />
<strong>Claude Code Haha</strong>
<p>{c.footer.tagline}</p>
<a className="link-arrow" href={GITHUB_URL} rel="noreferrer" target="_blank">
<Icon name="github" size={16} />
GitHub
</a>
</div>
{c.footer.columns.map(([title, links]) => (
// 页脚栏目名和头部导航会撞车,加个前缀让地标列表能区分。
<nav aria-label={`${locale === 'en' ? 'Footer' : '页脚'} · ${title}`} key={title}>
<h2 className="site-footer__title">{title}</h2>
<ul>
{links.map(([href, label]) => (
<li key={href}><a href={toSiteHref(href)}>{label}</a></li>
))}
</ul>
</nav>
))}
<div className="site-footer__meta">
<a href={toSiteHref(locale === 'en' ? '/' : '/en')}>{locale === 'en' ? '中文' : 'English'}</a>
</div>
</div>
</footer>
)
}
export default function HomePage({ locale = 'zh' }) {
const c = content[locale]
const pageRef = useReveal()
const c = content[locale] || content.zh
useEffect(() => {
document.documentElement.lang = locale === 'en' ? 'en' : 'zh-CN'
document.title = locale === 'en'
? 'Claude Code Haha — A studio for Claude Code'
: 'Claude Code Haha — Claude Code 的桌面工作室'
}, [locale])
setPageMeta({
alternate: locale === 'en' ? '/' : '/en',
canonical: locale === 'en' ? '/en' : '/',
description: c.hero.lede,
lang: locale === 'en' ? 'en' : 'zh-CN',
title: locale === 'en'
? 'Claude Code Haha — a local-first desktop client for Claude Code'
: 'Claude Code Haha — Claude Code 的本地优先桌面客户端'
})
}, [c, locale])
return (
<div className="home" ref={pageRef}>
<a className="skip-link" href="#journey">Skip to content</a>
<Header locale={locale} c={c} />
<main>
<Hero locale={locale} c={c} />
<Journey c={c} />
<ProductTour c={c} />
<Crew c={c} />
<Guide c={c} />
<Install locale={locale} c={c} />
<>
<a className="u-skip" href="#main">{locale === 'en' ? 'Skip to content' : '跳到正文'}</a>
<SiteHeader locale={locale} />
{/* 跳过链接要落在正文开头(含 h1 和主按钮),tabIndex 保证焦点真的搬过来 */}
<main id="main" tabIndex={-1}>
<Hero c={c} locale={locale} />
<Capabilities c={c} />
<Tour c={c} locale={locale} />
<Crew c={c} locale={locale} />
<Paths c={c} />
<Install c={c} locale={locale} />
</main>
<Footer locale={locale} c={c} />
</div>
<Footer c={c} locale={locale} />
</>
)
}
+259
View File
@@ -0,0 +1,259 @@
import sessionMain from '../../../../docs/images/app/session-main.webp'
import workspaceDiff from '../../../../docs/images/app/workspace-diff.webp'
import workspacePreview from '../../../../docs/images/app/workspace-preview.webp'
import providerAdd from '../../../../docs/images/app/settings-provider-add.webp'
import skillMarket from '../../../../docs/images/app/skill-market.webp'
import scheduleCreate from '../../../../docs/images/app/schedule-create.webp'
import h5Session from '../../../../docs/images/app/h5-session.webp'
import petDesktop from '../../../../docs/images/app/pet-desktop.webp'
// 吉祥物与截图一样从 docs/ 取 —— docs/ 是站点唯一的媒体源。
// 原图是桌面端的 agent-mascots,改动那边时记得同步这四张。
import dada from '../../../../docs/images/mascots/dada.png'
import huhu from '../../../../docs/images/mascots/huhu.png'
import bubu from '../../../../docs/images/mascots/bubu.png'
import huihui from '../../../../docs/images/mascots/huihui.png'
export const images = {
h5Session,
petDesktop,
providerAdd,
scheduleCreate,
sessionMain,
skillMarket,
workspaceDiff,
workspacePreview
}
export const content = {
zh: {
hero: {
title: '让 Claude Code 有个能看见的地方干活',
lede: '本地优先的桌面客户端:会话、改动、Agent、定时任务都摆在明处。接哪个模型你说了算,改哪一行你点头才算。',
primary: '下载桌面端',
secondary: '三步跑通第一条会话',
badges: ['macOS · Windows · Linux', '开源免费', '数据留在本机'],
caption: '真实截图:一条会话读完项目、改了三个文件,改动就在对话里逐行摊开。'
},
capabilities: {
title: '它替你做的事',
lede: '不是一个聊天框,是一整套把「想法」变成「已合并」的工序。',
items: [
['写代码', '说清目标,它读项目、拆任务、动手改,每一步的工具调用都能展开看。'],
['审改动', '改了哪些文件、每行怎么改,Diff 逐行摆出来;不点头就不落地。'],
['隔离试验', '把试验放进独立工作树,主分支一个字都不动。'],
['派 Agent', '大活拆给子 Agent 并行跑,进度和后台任务都汇总在活动面板。'],
['装技能', '技能市场里看中就装,来源和安全状态摆在明处。'],
['到点自动跑', '重复流程设成定时任务,在独立会话里执行,每次都留记录。'],
['操作电脑', 'Computer Use 让它看屏幕、点鼠标、敲键盘,敏感操作等你点头。'],
['出门接着聊', '手机浏览器扫码进 H5,或者从飞书 / Telegram / 微信继续同一条会话。']
]
},
tour: {
title: '所有截图都拍自 v0.5.0,没有概念图',
lede: '装好之后你看到的就是这个样子。',
tabs: [
{
id: 'session',
label: '会话',
title: '说一句话,看它一步步做完',
body: '选好项目、权限模式和模型就能开工。它读了哪些文件、执行了什么命令、改了哪一行,全都留在对话里。',
image: sessionMain
},
{
id: 'review',
label: '审阅',
title: '改了什么,逐个文件看清楚',
body: '右侧工作区列出本轮改动,点开就是带语法高亮的 Diff。看不顺眼可以撤销整轮。',
image: workspaceDiff
},
{
id: 'preview',
label: '预览',
title: '页面效果,会话里直接验证',
body: '内置浏览器打开本地服务,改完当场看效果;截图和元素选择都能带回对话继续说。',
image: workspacePreview
},
{
id: 'models',
label: '模型',
title: '接哪个模型,你自己定',
body: 'Claude / ChatGPT / Grok 官方账号直接登录,也可以接 DeepSeek、Kimi、智谱、MiniMax,或者本地跑的 LM Studio 与 Ollama。',
image: providerAdd
},
{
id: 'skills',
label: '技能',
title: '缺什么手艺,装什么手艺',
body: '技能市场聚合 ClawHub 与 SkillHub,每个技能都标了来源和安全状态,装之前先看清楚。',
image: skillMarket
},
{
id: 'schedule',
label: '定时',
title: '设好时间,它按时回来交活',
body: '定好频率、模型、目录和通知方式;任务在独立会话里执行,每跑一次都有记录可查。',
image: scheduleCreate
},
{
id: 'remote',
label: '手机',
title: '离开电脑,会话不断',
body: '扫码用手机浏览器接着聊。锁屏切后台也不会打断正在跑的任务,回来就能看结果。',
image: h5Session
}
]
},
crew: {
title: '干活的时候,桌面上有个伴',
lede: '一个转圈图标代表所有状态太敷衍了。搭搭、弧弧、补补、回回随任务状态换动作——忙不忙,瞟一眼就知道。',
link: '怎么养一只自己的',
members: [
['搭搭', 'Dada', '构建', '把想法一块块搭成能跑的东西。'],
['弧弧', 'Huhu', '规划', '复杂任务也能画出一条清楚的路线。'],
['补补', 'Bubu', '修复', '找到裂缝,验证之后再补好它。'],
['回回', 'Huihui', '交付', '新回复一到,抱着齿轮就接着跑。']
]
},
paths: {
title: '你是哪一种',
lede: '文档只分两条路,别的都是这两条的支线。',
items: [
{
eyebrow: '我想用起来',
title: '从 0 到 1 把它跑起来',
body: '装好应用、接上模型、跑通第一条会话,再一个个把功能用熟。不需要懂代码。',
links: [
['/start/install', '下载与安装'],
['/start/models', '连接模型服务'],
['/start/first-session', '跑通第一条会话'],
['/desktop', '桌面端功能地图']
]
},
{
eyebrow: '我想拆开看',
title: '架构、实现与贡献',
body: 'CLI 内核怎么分层、Agent 与 Skills 怎么调度、记忆怎么落盘、本地服务有哪些 API。',
links: [
['/internals', '架构总览'],
['/internals/agent', '多 Agent 系统'],
['/internals/server', '本地 Server 与 API'],
['/internals/contributing', '参与贡献']
]
}
]
},
install: {
title: '装上试试',
lede: 'GitHub Releases 有三平台安装包;想从源码跑也就三行命令。',
primary: '下载安装包',
docs: '安装遇到问题',
commandLabel: '从源码运行',
copy: '复制',
copied: '已复制'
},
footer: {
tagline: '本地优先的 Claude Code 桌面客户端',
columns: [
['文档', [['/start', '开始使用'], ['/desktop', '桌面端功能'], ['/im', 'IM 接入'], ['/cli', '命令行']]],
['开发者', [['/internals', '架构总览'], ['/internals/structure', '项目结构'], ['/internals/contributing', '参与贡献']]]
]
}
},
en: {
hero: {
title: 'Give Claude Code somewhere you can watch it work',
lede: 'A local-first desktop client. Sessions, diffs, agents and scheduled runs all sit in the open. You pick the model; nothing lands until you say so.',
primary: 'Download the app',
secondary: 'Run your first session',
badges: ['macOS · Windows · Linux', 'Open source', 'Your data stays local'],
caption: 'A real screenshot: one session read the project, changed three files, and laid every edit out inline.'
},
capabilities: {
title: 'What it does for you',
lede: 'Not a chat box — the whole path from an idea to a merged change.',
items: [
['Write code', 'State the goal. It reads the project, splits the work, and edits — every tool call open for inspection.'],
['Review edits', 'Which files changed and exactly how, line by line. Nothing lands without your nod.'],
['Isolate experiments', 'Keep risky work in its own worktree and leave your main branch untouched.'],
['Delegate', 'Split big jobs across subagents; progress and background tasks roll up into one panel.'],
['Install skills', 'Browse the marketplace with source and safety status shown up front.'],
['Run on a clock', 'Turn routines into scheduled jobs that run in their own sessions and leave a record.'],
['Drive the desktop', 'Computer Use can see the screen, click and type. Sensitive moves still wait for you.'],
['Keep going anywhere', 'Scan into the mobile web app, or continue the same session from Feishu, Telegram or WeChat.']
]
},
tour: {
title: 'Every screenshot is v0.5.0. No concept art.',
lede: 'What you see is what opens after install.',
tabs: [
{ id: 'session', label: 'Session', title: 'Say it once. Watch it get done.', body: 'Pick a project, a permission mode and a model. Every file it read, every command it ran and every line it changed stays in the thread.', image: sessionMain },
{ id: 'review', label: 'Review', title: 'Know exactly what changed', body: 'The workspace lists this turn’s edits; open any file for a syntax-highlighted diff. Don’t like it? Undo the whole turn.', image: workspaceDiff },
{ id: 'preview', label: 'Preview', title: 'Check the page without leaving', body: 'Open your dev server in the built-in browser, see the result immediately, and bring screenshots or picked elements back into the thread.', image: workspacePreview },
{ id: 'models', label: 'Models', title: 'Bring your own model', body: 'Sign in to Claude, ChatGPT or Grok, or point it at DeepSeek, Kimi, Zhipu, MiniMax — or LM Studio and Ollama running on your own machine.', image: providerAdd },
{ id: 'skills', label: 'Skills', title: 'Missing a trick? Install it.', body: 'The marketplace aggregates ClawHub and SkillHub, and labels every skill with its source and safety status before you install.', image: skillMarket },
{ id: 'schedule', label: 'Schedule', title: 'Set the time. It comes back with results.', body: 'Choose a cadence, model, directory and notification. Jobs run in their own sessions and every run leaves a record.', image: scheduleCreate },
{ id: 'remote', label: 'Mobile', title: 'Step away, keep the session', body: 'Scan the QR code and continue in your phone browser. Locking the screen won’t kill a running task.', image: h5Session }
]
},
crew: {
title: 'Someone to keep you company while it works',
lede: 'One spinner for every state felt lazy. Dada, Huhu, Bubu and Huihui change what they are doing with the task — one glance tells you.',
link: 'Raise one of your own',
members: [
['Dada', 'Dada', 'Build', 'Turns an idea into something you can run.'],
['Huhu', 'Huhu', 'Plan', 'Finds a clear route through complicated work.'],
['Bubu', 'Bubu', 'Fix', 'Finds the crack, proves it, then patches it.'],
['Huihui', 'Huihui', 'Ship', 'Grabs the gear and moves the moment a reply lands.']
]
},
paths: {
title: 'Which one are you',
lede: 'The docs run along two tracks. Everything else branches off them.',
items: [
{
eyebrow: 'I want to use it',
title: 'From zero to a working session',
body: 'Install the app, connect a model, finish your first session, then learn the features one at a time. No code required.',
links: [
['/en/start/install', 'Install'],
['/en/start/models', 'Connect a model'],
['/en/start/first-session', 'Your first session'],
['/en/desktop', 'Feature map']
]
},
{
eyebrow: 'I want to read the source',
title: 'Architecture, internals and contributing',
body: 'How the CLI core is layered, how agents and skills are scheduled, how memory is persisted, what the local server exposes.',
links: [
['/en/internals', 'Architecture overview'],
['/en/internals/agent', 'Multi-agent system'],
['/en/internals/server', 'Local server & API'],
['/en/internals/contributing', 'Contributing']
]
}
]
},
install: {
title: 'Try it',
lede: 'Installers for all three platforms on GitHub Releases — or three commands from source.',
primary: 'Download',
docs: 'Install troubleshooting',
commandLabel: 'Run from source',
copy: 'Copy',
copied: 'Copied'
},
footer: {
tagline: 'A local-first desktop client for Claude Code',
columns: [
['Docs', [['/en/start', 'Get started'], ['/en/desktop', 'Desktop app'], ['/en/im', 'Messaging'], ['/en/cli', 'Command line']]],
['Developers', [['/en/internals', 'Architecture'], ['/en/internals/structure', 'Project structure'], ['/en/internals/contributing', 'Contributing']]]
]
}
}
}
export const mascots = [dada, huhu, bubu, huihui]
export const mascotAccents = ['#2eaa91', '#3577d4', '#e56645', '#7657c8']
+604
View File
@@ -0,0 +1,604 @@
/* ============================================================================
落地页
========================================================================== */
.section {
padding: var(--sp-24) 0;
}
.section--muted {
border-block: 1px solid var(--border);
background: var(--surface-sunken);
}
.section__head {
max-width: 640px;
margin-bottom: var(--sp-12);
}
.section__head h2 {
margin-bottom: var(--sp-3);
font-size: var(--fs-4xl);
letter-spacing: -0.015em;
}
.link-arrow {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
color: var(--brand);
font-size: var(--fs-base);
font-weight: 500;
}
.link-arrow svg {
transition: transform var(--t-base) var(--ease);
}
.link-arrow:hover svg {
transform: translateX(3px);
}
.btn--lg {
padding: 12px var(--sp-6);
font-size: var(--fs-md);
}
/* ── Hero ───────────────────────────────────────────────────── */
.hero {
padding: var(--sp-24) 0 var(--sp-20);
}
.hero__inner {
display: grid;
gap: var(--sp-12);
}
.hero__copy {
max-width: 780px;
}
.hero h1 {
margin-bottom: var(--sp-5);
font-size: clamp(34px, 5.2vw, 58px);
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.15;
}
.hero .u-lede {
max-width: 640px;
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
margin-top: var(--sp-8);
}
.hero__badges {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
margin: var(--sp-6) 0 0;
padding: 0;
list-style: none;
color: var(--text-3);
font-size: var(--fs-sm);
}
.hero__badges li {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.hero__badges li::before {
content: '';
width: 5px;
height: 5px;
border-radius: var(--r-full);
background: var(--brand);
}
.hero__figure {
margin: 0;
}
.hero__figure img {
width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: var(--r-2xl);
box-shadow: var(--shadow-lg);
}
.hero__figure figcaption {
margin-top: var(--sp-4);
color: var(--text-3);
font-size: var(--fs-sm);
}
/* ── 能力网格 ───────────────────────────────────────────────── */
.cap-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
gap: 1px;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: var(--border);
list-style: none;
overflow: hidden;
}
.cap-grid li {
padding: var(--sp-6);
background: var(--bg);
}
.cap-grid h3 {
margin-bottom: var(--sp-2);
font-family: var(--font-sans);
font-size: var(--fs-lg);
font-weight: 600;
}
.cap-grid p {
color: var(--text-2);
font-size: var(--fs-base);
line-height: 1.7;
}
/* ── 产品巡览 ───────────────────────────────────────────────── */
.tour__tabs {
display: flex;
gap: var(--sp-1);
margin-bottom: var(--sp-6);
padding-bottom: var(--sp-2);
overflow-x: auto;
scrollbar-width: none;
}
.tour__tabs::-webkit-scrollbar {
display: none;
}
.tour__tab {
padding: 8px var(--sp-4);
border: 1px solid transparent;
border-radius: var(--r-full);
color: var(--text-2);
font-size: var(--fs-base);
white-space: nowrap;
transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
border-color var(--t-fast) var(--ease);
}
.tour__tab:hover {
background: var(--surface-hover);
color: var(--text);
}
.tour__tab[aria-selected='true'] {
border-color: var(--brand-border);
background: var(--brand-soft);
color: var(--on-brand-soft);
font-weight: 500;
}
.tour__panel {
display: grid;
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
gap: var(--sp-10);
align-items: center;
padding: var(--sp-8);
border: 1px solid var(--border);
border-radius: var(--r-2xl);
background: var(--bg);
}
.tour__panel:focus-visible {
outline-offset: 4px;
}
.tour__copy h3 {
margin-bottom: var(--sp-3);
font-size: var(--fs-2xl);
}
.tour__copy p {
color: var(--text-2);
line-height: 1.75;
}
/* 舞台按截图比例固定住高度。两件事一起解决:换 tab 时新图加载完之前
高度不会塌成 0;竖图那一格也不会比其余六格高出一截,面板不再跳动。 */
.tour__shot {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 2000 / 1436;
}
.tour__shot img {
width: 100%;
height: auto;
aspect-ratio: 2000 / 1436;
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
}
/* 竖图按舞台高度缩放,宽度自适应 */
.tour__shot--tall img {
width: auto;
height: 100%;
aspect-ratio: 1206 / 2622;
}
/* ── 搭档 ───────────────────────────────────────────────────── */
.crew {
display: grid;
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
gap: var(--sp-12);
align-items: start;
}
.crew__intro h2 {
margin-bottom: var(--sp-3);
font-size: var(--fs-4xl);
letter-spacing: -0.015em;
}
.crew__intro .link-arrow {
margin-top: var(--sp-5);
}
.crew__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
margin: 0;
padding: 0;
list-style: none;
}
.crew__grid li {
padding: var(--sp-5);
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: var(--bg);
transition: border-color var(--t-base) var(--ease), transform var(--t-base) var(--ease);
}
.crew__grid li:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.crew__grid img {
width: 76px;
height: 76px;
margin-bottom: var(--sp-3);
object-fit: contain;
}
.crew__role {
display: inline-block;
margin-bottom: var(--sp-2);
padding: 2px 8px;
border-radius: var(--r-full);
background: color-mix(in srgb, var(--accent) 12%, transparent);
/* --accent 是吉祥物自带的品牌色,直接当 12px 文字用时最低只有 2.57:1。
往当前主题的正文色掺一道,保住色相又够 AA。 */
color: color-mix(in srgb, var(--accent) 60%, var(--text));
font-size: var(--fs-xs);
font-weight: 500;
}
.crew__grid h3 {
display: flex;
align-items: baseline;
gap: var(--sp-2);
margin-bottom: var(--sp-1);
font-size: var(--fs-xl);
}
.crew__grid h3 em {
color: var(--text-3);
font-family: var(--font-sans);
font-size: var(--fs-sm);
font-style: normal;
font-weight: 400;
}
.crew__grid p {
color: var(--text-2);
font-size: var(--fs-base);
line-height: 1.65;
}
/* ── 两条路线 ───────────────────────────────────────────────── */
.paths {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--sp-5);
}
.path-card {
padding: var(--sp-8);
border: 1px solid var(--border);
border-radius: var(--r-2xl);
background: var(--bg);
}
.path-card__eyebrow {
display: inline-block;
margin-bottom: var(--sp-3);
padding: 3px 10px;
border-radius: var(--r-full);
background: var(--brand-soft);
color: var(--on-brand-soft);
font-size: var(--fs-sm);
font-weight: 500;
}
.path-card h3 {
margin-bottom: var(--sp-3);
font-size: var(--fs-2xl);
}
.path-card > p {
color: var(--text-2);
line-height: 1.75;
}
.path-card ul {
display: grid;
gap: 1px;
margin: var(--sp-6) 0 0;
padding: 0;
list-style: none;
}
.path-card li a {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: 11px var(--sp-4);
border: 1px solid var(--border);
border-radius: var(--r-md);
color: var(--text);
font-size: var(--fs-base);
transition: border-color var(--t-base) var(--ease), background var(--t-base) var(--ease),
color var(--t-base) var(--ease);
}
.path-card li + li {
margin-top: var(--sp-2);
}
.path-card li a svg {
color: var(--text-3);
transition: transform var(--t-base) var(--ease), color var(--t-base) var(--ease);
}
.path-card li a:hover {
border-color: var(--brand-border);
background: var(--brand-soft);
color: var(--on-brand-soft);
}
.path-card li a:hover svg {
color: var(--brand);
transform: translateX(3px);
}
/* ── 安装 ───────────────────────────────────────────────────── */
.install {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--sp-12);
align-items: center;
}
.install__copy h2 {
margin-bottom: var(--sp-3);
font-size: var(--fs-4xl);
letter-spacing: -0.015em;
}
.install__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-5);
margin-top: var(--sp-8);
}
.install__terminal {
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: var(--code-bg);
overflow: hidden;
}
.install__terminal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
color: var(--text-3);
font-size: var(--fs-sm);
}
.install__terminal-head button {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-sm);
color: var(--text-2);
font-size: var(--fs-sm);
}
.install__terminal-head button:hover {
background: var(--surface-hover);
color: var(--text);
}
.install__terminal pre {
margin: 0;
padding: var(--sp-5);
overflow-x: auto;
color: var(--text-2);
font-family: var(--font-mono);
font-size: var(--fs-sm);
line-height: 2;
}
/* ── 页脚 ───────────────────────────────────────────────────── */
.site-footer {
padding: var(--sp-16) 0 var(--sp-12);
border-top: 1px solid var(--border);
background: var(--surface-sunken);
}
.site-footer__inner {
display: grid;
grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr)) auto;
gap: var(--sp-10);
}
.site-footer__brand img {
margin-bottom: var(--sp-3);
border-radius: var(--r-sm);
}
.site-footer__brand strong {
display: block;
margin-bottom: var(--sp-2);
font-size: var(--fs-md);
}
.site-footer__brand p {
max-width: 300px;
margin-bottom: var(--sp-4);
color: var(--text-3);
font-size: var(--fs-base);
}
.site-footer__title {
margin-bottom: var(--sp-3);
font-family: var(--font-sans);
font-size: var(--fs-sm);
font-weight: 600;
}
.site-footer ul {
display: grid;
gap: var(--sp-2);
margin: 0;
padding: 0;
list-style: none;
}
.site-footer li a {
color: var(--text-2);
font-size: var(--fs-base);
}
.site-footer li a:hover {
color: var(--brand);
}
.site-footer__meta a {
color: var(--text-2);
font-size: var(--fs-base);
}
.site-footer__meta a:hover {
color: var(--brand);
}
/* ── 断点(与文档页统一:1180 / 900 / 620)──────────────────── */
@media (min-width: 1024px) {
.hero__inner {
gap: var(--sp-16);
}
}
@media (max-width: 900px) {
.section {
padding: var(--sp-16) 0;
}
.hero {
padding: var(--sp-12) 0 var(--sp-16);
}
.section__head {
margin-bottom: var(--sp-8);
}
.tour__panel {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-6);
padding: var(--sp-5);
}
.crew {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-8);
}
.install {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-8);
}
.site-footer__inner {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-8);
}
.site-footer__brand {
grid-column: 1 / -1;
}
}
@media (max-width: 620px) {
.u-shell {
padding-inline: var(--sp-5);
}
.hero__actions .btn {
width: 100%;
justify-content: center;
}
.cap-grid {
grid-template-columns: minmax(0, 1fr);
}
.path-card {
padding: var(--sp-6);
}
.site-footer__inner {
grid-template-columns: minmax(0, 1fr);
}
}
+750
View File
@@ -0,0 +1,750 @@
/* ============================================================================
Claude Code Haha — 文档站设计系统
色板取自桌面端「纸·墨·印」的「纯白」与「墨夜」两套主题,
保证网站和应用是同一个产品。字体自托管,零 CDN 依赖。
========================================================================== */
/* ── 自托管字体(与桌面端同一批文件)──────────────────────────────
中文刻意不加载 webfont:Noto Serif SC 的 CJK 切片每字重约 6MB,
落到系统宋体/苹方就是桌面端本身的观感。 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url('/fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Noto Serif SC';
font-style: normal;
font-weight: 600 900;
font-display: swap;
src: url('/fonts/noto-serif-sc-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Noto Serif SC';
font-style: normal;
font-weight: 600 900;
font-display: swap;
src: url('/fonts/noto-serif-sc-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* ── 刻度(与主题无关)───────────────────────────────────────── */
:root {
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-serif: 'Noto Serif SC', 'Songti SC', 'Source Han Serif SC', 'SimSun', Georgia, serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
/* 字号 */
--fs-2xs: 11px;
--fs-xs: 12px;
--fs-sm: 13px;
--fs-base: 14px;
--fs-md: 15px;
--fs-lg: 16px;
--fs-xl: 18px;
--fs-2xl: 20px;
--fs-3xl: 24px;
--fs-4xl: 30px;
--fs-5xl: 38px;
/* 间距 */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sp-12: 48px;
--sp-16: 64px;
--sp-20: 80px;
--sp-24: 96px;
--sp-32: 128px;
/* 圆角(与桌面端一致) */
--r-sm: 6px;
--r-md: 10px;
--r-lg: 13px;
--r-xl: 17px;
--r-2xl: 20px;
--r-3xl: 24px;
--r-full: 9999px;
/* 版心 */
--w-page: 1180px;
--w-prose: 748px;
--w-sidebar: 258px;
--w-toc: 208px;
--h-header: 60px;
/* 动效 */
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--t-fast: 120ms;
--t-base: 200ms;
/* 层级 */
--z-sticky: 20;
--z-nav: 30;
--z-scrim: 40;
--z-drawer: 50;
--z-dialog: 60;
--z-popover: 75;
}
/* ── 浅色:桌面端「纯白」───────────────────────────────────────── */
:root,
:root[data-theme='light'] {
color-scheme: light;
--bg: #ffffff;
--surface: #ffffff;
--surface-sunken: #fafaf8;
--surface-muted: #f6f6f3;
--surface-hover: #f0efe9;
--surface-active: #ebe9e2;
--text: #1f1c17;
--text-2: #5d5850;
--text-3: #777269;
--border: #ebe9e2;
--border-strong: #d8d5cc;
--border-control: #918e87;
--brand: #96442b;
--brand-hover: #7c3620;
--brand-soft: #f6ece5;
--brand-soft-hover: #efded4;
--brand-border: #e6cdbf;
--on-brand: #fbf6ec;
/* 压在 --brand-soft 上的文字。浅色下与 --brand 同色够用,深色下必须提亮,
否则只有 4.32:1 —— 侧栏当前项和巡览当前 tab 都吃这个色。 */
--on-brand-soft: #96442b;
--ink: #1f1c17;
--on-ink: #ffffff;
--ok: #3e8153;
--ok-soft: #e8f2e9;
--ok-ink: #3a784d;
--warn: #a8751f;
--warn-soft: #f8f0dc;
--warn-ink: #90641b;
--danger: #c74436;
--danger-soft: #faece8;
--danger-ink: #bd4133;
--info: #3b7068;
--info-soft: #f1f5f4;
--info-ink: #35655e;
--code-bg: #f7f7f4;
--code-border: #ebe9e2;
--shadow-sm: 0 1px 3px rgba(30, 28, 22, 0.04), 0 6px 24px rgba(30, 28, 22, 0.06);
--shadow-md: 0 1px 2px rgba(30, 28, 22, 0.05), 0 14px 44px rgba(30, 28, 22, 0.08);
--shadow-lg: 0 8px 24px rgba(30, 28, 22, 0.1), 0 24px 64px rgba(30, 28, 22, 0.15);
--scrim: rgba(19, 17, 14, 0.42);
--glass: rgba(255, 255, 255, 0.86);
/* 语法高亮 */
--c-comment: #756f63;
--c-string: #437220;
--c-keyword: #b8533b;
--c-function: #1d5a8c;
--c-number: #1b7a6a;
--c-property: #7a3e20;
--c-type: #7e5520;
--c-punct: #5c504a;
}
/* ── 深色:桌面端「墨夜」───────────────────────────────────────── */
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #201d17;
--surface: #252119;
--surface-sunken: #1a1813;
--surface-muted: #2b271f;
--surface-hover: #2f2b22;
--surface-active: #383226;
--text: #ede6d6;
--text-2: #a79b83;
--text-3: #918777;
--border: #383226;
--border-strong: #4c4433;
--border-control: #756d5c;
--brand: #d07b52;
--brand-hover: #de8f66;
--brand-soft: #3b2a1e;
--brand-soft-hover: #493424;
--brand-border: #59402c;
--on-brand: #201d17;
--on-brand-soft: #e5a582;
--ink: #ede6d6;
--on-ink: #201d17;
--ok: #6fae7f;
--ok-soft: #24301f;
--ok-ink: #86bd94;
--warn: #d0a04a;
--warn-soft: #332a17;
--warn-ink: #ddb469;
--danger: #e0705f;
--danger-soft: #392119;
--danger-ink: #ea8877;
--info: #6aa79c;
--info-soft: #1f2b28;
--info-ink: #83bcb1;
--code-bg: #1a1813;
--code-border: #383226;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.28), 0 6px 24px rgba(0, 0, 0, 0.3);
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.3), 0 14px 44px rgba(0, 0, 0, 0.36);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(0, 0, 0, 0.5);
--scrim: rgba(8, 7, 5, 0.6);
--glass: rgba(32, 29, 23, 0.86);
--c-comment: #8f8674;
--c-string: #97bd6c;
--c-keyword: #e08a6c;
--c-function: #7fb4dd;
--c-number: #6ec2ad;
--c-property: #dba679;
--c-type: #d8bb7c;
--c-punct: #a79b83;
}
/* ── 重置与基础 ─────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: calc(var(--h-header) + var(--sp-6));
-webkit-text-size-adjust: 100%;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-lg);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
background: none;
border: 0;
cursor: pointer;
}
img,
svg {
display: block;
max-width: 100%;
}
::selection {
background: var(--brand-border);
color: var(--text);
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: var(--r-sm);
}
h1,
h2,
h3,
h4 {
margin: 0;
font-family: var(--font-serif);
font-weight: 600;
line-height: 1.25;
letter-spacing: 0;
}
p {
margin: 0;
}
/* ── 通用件 ─────────────────────────────────────────────────── */
.u-shell {
width: 100%;
max-width: var(--w-page);
margin: 0 auto;
padding-inline: var(--sp-6);
}
.u-skip {
position: fixed;
z-index: 999;
top: var(--sp-3);
left: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
background: var(--ink);
color: var(--on-ink);
font-size: var(--fs-sm);
transform: translateY(-200%);
}
.u-skip:focus {
transform: translateY(0);
}
/* 跳过链接和换页会把焦点搬进 <main>,这是给读屏用的落点,
不需要围着整块正文画一圈焦点环。 */
main[tabindex='-1']:focus,
main[tabindex='-1']:focus-visible {
outline: none;
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 10px var(--sp-5);
border: 1px solid transparent;
border-radius: var(--r-md);
font-size: var(--fs-base);
font-weight: 500;
white-space: nowrap;
transition: background var(--t-base) var(--ease), border-color var(--t-base) var(--ease),
color var(--t-base) var(--ease), transform var(--t-fast) var(--ease);
}
.btn:active {
transform: scale(0.98);
}
/* 主按钮是墨色实心,hover 才转陶土 —— 与桌面端一致 */
.btn--primary {
background: var(--ink);
color: var(--on-ink);
}
.btn--primary:hover {
background: var(--brand);
color: var(--on-brand);
}
.btn--ghost {
border-color: var(--border-strong);
background: var(--surface);
color: var(--text);
}
.btn--ghost:hover {
border-color: var(--border-control);
background: var(--surface-hover);
}
.btn--quiet {
padding: 8px var(--sp-3);
color: var(--text-2);
}
.btn--quiet:hover {
background: var(--surface-hover);
color: var(--text);
}
.u-kicker {
margin: 0 0 var(--sp-3);
color: var(--brand);
font-size: var(--fs-sm);
font-weight: 500;
letter-spacing: 0.01em;
}
.u-lede {
color: var(--text-2);
font-size: var(--fs-xl);
line-height: 1.7;
}
/* ── 站点头(首页与文档共用一套)────────────────────────────── */
.site-header {
position: sticky;
z-index: var(--z-nav);
top: 0;
border-bottom: 1px solid var(--border);
background: var(--glass);
backdrop-filter: saturate(180%) blur(12px);
}
.site-header__inner {
display: flex;
align-items: center;
gap: var(--sp-4);
height: var(--h-header);
max-width: calc(var(--w-page) + 240px);
margin: 0 auto;
padding-inline: var(--sp-6);
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--sp-3);
margin-right: auto;
font-size: var(--fs-md);
font-weight: 600;
letter-spacing: -0.01em;
}
.brand img {
width: 26px;
height: 26px;
border-radius: var(--r-sm);
}
.brand b {
color: var(--brand);
font-weight: 600;
}
.site-nav {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.site-nav a {
padding: 7px var(--sp-3);
border-radius: var(--r-md);
color: var(--text-2);
font-size: var(--fs-base);
transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.site-nav a:hover {
background: var(--surface-hover);
color: var(--text);
}
.site-nav a[aria-current='page'] {
color: var(--text);
font-weight: 500;
}
.header-tools {
display: flex;
align-items: center;
gap: var(--sp-1);
padding-left: var(--sp-3);
border-left: 1px solid var(--border);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--r-md);
color: var(--text-2);
transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.icon-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.header-menu-btn {
display: none;
}
@media (max-width: 900px) {
.site-nav {
position: fixed;
z-index: var(--z-drawer);
top: var(--h-header);
right: 0;
left: 0;
flex-direction: column;
align-items: stretch;
gap: 2px;
padding: var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--bg);
box-shadow: var(--shadow-lg);
transform: translateY(-8px);
opacity: 0;
pointer-events: none;
/* opacity:0 挡得住鼠标,挡不住 Tab —— 收起来的菜单必须 visibility:hidden,
否则键盘用户会在看不见的地方连按 4 下空焦点。延迟到淡出结束再隐藏。 */
visibility: hidden;
transition: opacity var(--t-base) var(--ease), transform var(--t-base) var(--ease),
visibility 0s linear var(--t-base);
}
.site-nav[data-open='true'] {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
visibility: visible;
transition: opacity var(--t-base) var(--ease), transform var(--t-base) var(--ease),
visibility 0s;
}
.site-nav a {
padding: var(--sp-3);
font-size: var(--fs-lg);
}
.header-menu-btn {
display: inline-flex;
}
.header-download {
display: none;
}
/* 下拉导航展开时锁住背景滚动,免得菜单浮在原地、正文却滑走了 */
html:has(.site-nav[data-open='true']) {
overflow: hidden;
}
}
/* ── 搜索 ───────────────────────────────────────────────────── */
.search-scrim {
position: fixed;
z-index: var(--z-dialog);
inset: 0;
display: flex;
justify-content: center;
padding: clamp(48px, 10vh, 120px) var(--sp-5) var(--sp-5);
background: var(--scrim);
backdrop-filter: blur(2px);
}
.search-dialog {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: min(540px, 100%);
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: var(--bg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.search-dialog__field {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border);
color: var(--text-3);
}
.search-dialog__field input {
flex: 1;
min-width: 0;
border: 0;
background: none;
color: var(--text);
font-size: var(--fs-lg);
}
/* 输入框一打开就自动聚焦,整行都套个焦点环反而吵。
焦点落在它身上时给整条 field 一道底边,键盘用户仍看得到落点。 */
.search-dialog__field:focus-within {
border-bottom-color: var(--brand);
box-shadow: inset 0 -1px 0 var(--brand);
}
.search-dialog__field input:focus-visible {
outline: none;
}
.search-dialog__field input::-webkit-search-cancel-button {
display: none;
}
.search-dialog__body {
flex: 1;
padding: var(--sp-2);
overflow-y: auto;
}
.search-dialog__hint {
padding: var(--sp-8) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-base);
text-align: center;
}
.search-result {
display: block;
width: 100%;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
text-align: left;
}
.search-result[data-active='true'] {
background: var(--surface-hover);
}
.search-result__section {
display: block;
color: var(--text-3);
font-size: var(--fs-xs);
}
.search-result__title {
display: block;
margin: 1px 0 3px;
color: var(--text);
font-size: var(--fs-md);
font-weight: 500;
}
.search-result__excerpt {
display: -webkit-box;
overflow: hidden;
color: var(--text-2);
font-size: var(--fs-sm);
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.search-dialog__foot {
display: flex;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border);
color: var(--text-3);
font-size: var(--fs-xs);
}
/* ── 加载态与 404 ───────────────────────────────────────────── */
.page-loading {
min-height: 60vh;
}
.doc-loading {
padding: var(--sp-16) 0;
color: var(--text-3);
}
.not-found {
display: grid;
place-content: center;
min-height: 78vh;
padding: var(--sp-8);
text-align: center;
}
.not-found > p:first-child {
color: var(--brand);
font-family: var(--font-mono);
font-size: var(--fs-sm);
letter-spacing: 0.08em;
}
.not-found h1 {
max-width: 640px;
margin: var(--sp-4) auto var(--sp-3);
font-size: var(--fs-4xl);
}
.not-found__hint {
max-width: 480px;
margin: 0 auto;
color: var(--text-2);
}
.not-found__actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
justify-content: center;
margin-top: var(--sp-8);
}
File diff suppressed because it is too large Load Diff