Refine Tabularium into a readable light documentation workspace
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m26s

This commit is contained in:
2026-10-27 15:55:59 +08:00
parent 5c48dec887
commit 75debc0068
29 changed files with 2116 additions and 54 deletions
+4
View File
@@ -30,6 +30,10 @@ tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图
- `pnpm start`:在本地预览已构建站点。
- `pnpm check`:依次执行 lint、类型检查、制图与构建。
站点默认浅色,使用本地系统字体。全站搜索支持正文关键词、`⌘/Ctrl K`、方向键与 Enter;代码块提供复制与反馈。桌面提供分组导航和滚动高亮的页内目录,小屏可展开文档菜单与页内目录。首页入口、草案提示、前后篇导航均引用现有 Markdown 内容。
参考设计截图与研究记录保存在 `design/references`;浏览器验收截图与检查说明保存在 `design/verification`。这些文件不作为站点静态资源发布。
文档标题取首个 `# 标题`,文档排序按文件名。普通 Markdown 与 GFM 表格可用,嵌入图使用 `/figures/<name>.svg`。不解析原始 HTML。
生产构建会生成文档路由;新增或修改文档后重新运行 build。开发模式按请求读取内容。
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react";
export function CodeBlock({
code,
language = "text",
}: {
code: string;
language?: string;
}) {
const [status, setStatus] = useState("复制");
async function copy() {
try {
await navigator.clipboard.writeText(code);
setStatus("已复制");
} catch {
setStatus("复制失败,请手动选择");
}
setTimeout(() => setStatus("复制"), 2400);
}
return (
<div className="code-block">
<div className="code-toolbar">
<span>{language === "sh" ? "Terminal" : language}</span>
<button onClick={copy} aria-label="复制代码">
{status}
</button>
<span className="sr-only" role="status">
{status === "复制" ? "" : status}
</span>
</div>
<pre>
<code>
{code
.replace(/\n$/, "")
.split("\n")
.map((line, i) => (
<span className="code-line" key={i}>
<span className="line-number" aria-hidden="true">
{i + 1}
</span>
<span>
{/^(pnpm|uv)\b/.test(line) ? (
<>
<span className="code-command">{line.split(" ")[0]}</span>
{line.slice(line.indexOf(" "))}
</>
) : (
line || "\u00a0"
)}
</span>
</span>
))}
</code>
</pre>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import Link from "next/link";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
import { sectionFor } from "@/lib/navigation";
export function Brand() {
return (
<Link className="brand" href="/" aria-label="Tabularium 首页">
<span className="brand-mark" aria-hidden="true">
<i />
<i />
<i />
</span>
<span>
Endor <span className="brand-divider">/</span>{" "}
<strong>Tabularium</strong>
</span>
<span className="docs-badge">文档</span>
</Link>
);
}
export function Navigation({
docs,
current,
onNavigate,
}: {
docs: DocLink[];
current?: string;
onNavigate?: () => void;
}) {
const groups = [...new Set(docs.map((d) => sectionFor(d.slug).group))];
return (
<nav aria-label="文档导航">
<Link
onClick={onNavigate}
className={`nav-home ${!current ? "selected" : ""}`}
href="/"
>
▦ <span>文档概览</span>
</Link>
{groups.map((group) => (
<div className="nav-group" key={group}>
<p>{group}</p>
{docs
.filter((d) => sectionFor(d.slug).group === group)
.map((d) => (
<Link
onClick={onNavigate}
aria-current={d.slug === current ? "page" : undefined}
className="nav-item"
href={`/docs/${d.slug}`}
key={d.slug}
>
{d.title}
{d.slug.includes("draft") && (
<span className="tiny-dot" aria-label="草案" />
)}
</Link>
))}
</div>
))}
<div className="nav-note">
<span className="status-dot" /> 与代码一起演进
<p>文字、设计与图形的共同档案。</p>
</div>
</nav>
);
}
export function Shell({
docs,
current,
children,
}: {
docs: DocLink[];
current?: string;
children: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const input = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const results = docs.filter((d) =>
`${d.title} ${d.searchText}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
);
function openSearch() {
setQuery("");
setSelected(0);
dialog.current?.showModal();
input.current?.focus();
}
function closeSearch() {
dialog.current?.close();
trigger.current?.focus();
}
useEffect(() => {
function keyboard(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
dialog.current?.showModal();
input.current?.focus();
}
}
document.addEventListener("keydown", keyboard);
return () => document.removeEventListener("keydown", keyboard);
}, []);
return (
<>
<a className="skip-link" href="#content">
跳到正文
</a>
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/">文档概览</Link>
<Link href="/docs/02-mounting-draft">架构设计</Link>
<a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">
仓库 ↗
</a>
</nav>
<button
ref={trigger}
className="search-trigger"
aria-label="搜索文档"
onClick={openSearch}
>
<span aria-hidden="true">⌕</span>
<span>搜索文档…</span>
<kbd>⌘ K</kbd>
</button>
</header>
<div className="mobile-bar">
<button
onClick={() => setMenu(!menu)}
aria-expanded={menu}
aria-controls="mobile-navigation"
>
☰ <span>文档目录</span>
<span>{menu ? "−" : "+"}</span>
</button>
</div>
{menu && (
<div className="mobile-navigation" id="mobile-navigation">
<Navigation
docs={docs}
current={current}
onNavigate={() => setMenu(false)}
/>
</div>
)}
{children}
<dialog
ref={dialog}
className="search-dialog"
aria-label="搜索文档"
onClick={(e) => {
if (e.target === e.currentTarget) closeSearch();
}}
onClose={() => trigger.current?.focus()}
>
<div className="search-input-row">
<span aria-hidden="true">⌕</span>
<input
ref={input}
value={query}
placeholder="搜索文档、命令或概念…"
aria-label="搜索文档内容"
aria-controls="search-results"
onChange={(e) => {
setQuery(e.target.value);
setSelected(0);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelected((i) =>
Math.max(0, Math.min(i + 1, results.length - 1)),
);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelected((i) => Math.max(i - 1, 0));
}
if (e.key === "Enter") {
e.preventDefault();
dialog.current
?.querySelector<HTMLAnchorElement>(
`[data-result="${selected}"]`,
)
?.click();
}
}}
/>
<button onClick={closeSearch} aria-label="关闭搜索">
Esc
</button>
</div>
<div id="search-results" className="search-results">
<p className="eyebrow" role="status">
{query ? `${results.length} 篇匹配文档` : "浏览文档"}
</p>
{results.map((d, i) => (
<Link
data-result={i}
className={
selected === i ? "search-result selected" : "search-result"
}
onClick={closeSearch}
key={d.slug}
href={`/docs/${d.slug}`}
>
<span>{sectionFor(d.slug).group}</span>
<strong>
{d.title} <span aria-hidden="true">↗</span>
</strong>
<small>{sectionFor(d.slug).description}</small>
</Link>
))}
{!results.length && (
<p className="empty-search">
没有找到相关文档。试试「节点」「SVG」或「pnpm」。
</p>
)}
</div>
<div className="search-footer">
↑ ↓ 选择 <span>Enter 打开 · Esc 关闭</span>
</div>
</dialog>
</>
);
}
+11 -9
View File
@@ -4,22 +4,24 @@ import path from "node:path";
const directory = path.resolve(process.cwd(), "../../content/docs");
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type DocLink = { slug: string; title: string };
export type DocLink = { slug: string; title: string; searchText: string };
export type Doc = DocLink & { content: string };
export async function readDoc(slug: string): Promise<Doc> {
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
const content = await fs.readFile(path.join(directory, `${slug}.md`), "utf8");
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
return { slug, title, content };
return { slug, title, content, searchText: content };
}
export async function listDocs(): Promise<DocLink[]> {
const files = (await fs.readdir(directory)).filter((file) =>
file.endsWith(".md") && validSlug.test(file.slice(0, -3)),
).sort();
return Promise.all(files.map(async (file) => {
const { slug, title } = await readDoc(file.slice(0, -3));
return { slug, title };
}));
const files = (await fs.readdir(directory))
.filter((file) => file.endsWith(".md") && validSlug.test(file.slice(0, -3)))
.sort();
return Promise.all(
files.map(async (file) => {
const { slug, title, searchText } = await readDoc(file.slice(0, -3));
return { slug, title, searchText };
}),
);
}
+58
View File
@@ -0,0 +1,58 @@
export type Section = {
group: string;
description: string;
label: string;
symbol: string;
};
export function sectionFor(slug: string): Section {
if (slug.includes("mounting"))
return {
group: "架构设计",
label: "讨论草案",
symbol: "◇",
description: "探索节点、对象与统一接入协议,记录仍在演进的设计。",
};
if (slug.includes("figures"))
return {
group: "图形档案",
label: "工具与工作流",
symbol: "⌘",
description: "从 Python 源码到可编辑 SVG,让图形与文档一起演进。",
};
return {
group: "开始使用",
label: "指南",
symbol: "↗",
description: "搭建本地工作台,添加第一篇文档,了解检查与发布流程。",
};
}
export type Heading = { id: string; text: string; level: number; line: number };
export function headingsFor(content: string): Heading[] {
let fenced = false;
const counts = new Map<string, number>();
return content.split("\n").flatMap((line, index) => {
if (/^\s*(```|~~~)/.test(line)) {
fenced = !fenced;
return [];
}
const match = !fenced && line.match(/^(#{2,3})\s+(.+?)\s*#*$/);
if (!match) return [];
const text = match[2].replace(/[`*_]/g, "");
const base =
text
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.trim()
.replace(/\s+/g, "-") || "section";
const count = counts.get(base) ?? 0;
counts.set(base, count + 1);
return [
{
id: count ? `${base}-${count}` : base,
text,
level: match[1].length,
line: index + 1,
},
];
});
}
+210 -18
View File
@@ -1,43 +1,235 @@
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import type {
GetStaticPaths,
GetStaticProps,
InferGetStaticPropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import {
Children,
isValidElement,
useEffect,
useState,
type ReactNode,
} from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { CodeBlock } from "@/components/CodeBlock";
import { headingsFor, sectionFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug } })),
fallback: false,
});
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const slug = params?.slug;
if (typeof slug !== "string") return { notFound: true };
const docs = await listDocs();
if (!docs.some((doc) => doc.slug === slug)) return { notFound: true };
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
return { props: { doc: await readDoc(slug), docs } };
};
export default function DocPage({ doc, docs }: InferGetStaticPropsType<typeof getStaticProps>) {
function textOf(value: ReactNode): string {
return Children.toArray(value)
.map((child) =>
isValidElement<{ children?: ReactNode }>(child)
? textOf(child.props.children)
: String(child),
)
.join("");
}
export default function DocPage({
doc,
docs,
}: InferGetStaticPropsType<typeof getStaticProps>) {
const headings = headingsFor(doc.content);
const [active, setActive] = useState("");
const section = sectionFor(doc.slug);
const index = docs.findIndex((d) => d.slug === doc.slug);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting);
if (visible[0]) setActive(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px" },
);
document
.querySelectorAll("article h2, article h3")
.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [doc.slug]);
const toc = (
<nav aria-label="页内目录">
{headings.map((h) => (
<a
key={h.id}
href={`#${h.id}`}
className={`${h.level === 3 ? "toc-nested" : ""} ${active === h.id ? "active" : ""}`}
aria-current={active === h.id ? "location" : undefined}
>
{h.text}
</a>
))}
</nav>
);
return (
<>
<Head><title>{doc.title} · Tabularium</title></Head>
<div className="mx-auto max-w-7xl px-6 py-8">
<header className="border-b border-stone-200 pb-6"><Link href="/" className="text-sm font-semibold tracking-widest">ENDOR / TABULARIUM</Link></header>
<a href="#content" className="sr-only focus:not-sr-only">跳到正文</a>
<div className="grid gap-10 py-10 md:grid-cols-[220px_minmax(0,1fr)] md:gap-16">
<nav aria-label="文档导航" className="flex flex-wrap gap-2 self-start md:sticky md:top-8 md:flex-col">
{docs.map((item) => <Link key={item.slug} href={`/docs/${item.slug}`} aria-current={item.slug === doc.slug ? "page" : undefined} className={`rounded-lg px-3 py-2 text-sm ${item.slug === doc.slug ? "bg-teal-50 font-medium text-teal-900" : "text-stone-600 hover:bg-stone-100"}`}>{item.title}</Link>)}
</nav>
<main id="content" className="min-w-0 max-w-3xl">
<article className="prose prose-stone max-w-none prose-headings:tracking-tight prose-a:text-teal-800 prose-pre:bg-stone-900 prose-img:rounded-xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{doc.content}</ReactMarkdown>
<Head>
<title>{doc.title} · Tabularium</title>
<meta name="description" content={section.description} />
</Head>
<Shell key={doc.slug} docs={docs} current={doc.slug}>
<div className="reading-layout">
<aside className="sidebar">
<Navigation docs={docs} current={doc.slug} />
</aside>
<main id="content" className="reading-main">
<div className="breadcrumb">
<Link href="/">文档</Link>
<span>/</span>
<span>{section.group}</span>
</div>
<div className="article-meta">
<span className="eyebrow">{section.group}</span>
{doc.slug.includes("draft") && (
<span className="draft-badge">讨论草案</span>
)}
</div>
<article className="prose prose-neutral max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children }) => (
<div className="table-scroll">
<table>{children}</table>
</div>
),
h1: ({ children }) => (
<>
<h1>{children}</h1>
{headings.length > 0 && (
<details className="mobile-toc">
<summary>本页内容</summary>
{toc}
</details>
)}
</>
),
h2: ({ children, node }) => {
const h = headings.find(
(h) => h.line === node?.position?.start.line,
);
return (
<h2 id={h?.id}>
<a className="heading-anchor" href={`#${h?.id}`}>
{children}
<span aria-hidden="true">#</span>
</a>
</h2>
);
},
h3: ({ children, node }) => {
const h = headings.find(
(h) => h.line === node?.position?.start.line,
);
return (
<h3 id={h?.id}>
<a className="heading-anchor" href={`#${h?.id}`}>
{children}
<span aria-hidden="true">#</span>
</a>
</h3>
);
},
pre: ({ children }) => {
const child = Children.toArray(children)[0];
if (
isValidElement<{
className?: string;
children?: ReactNode;
}>(child)
)
return (
<CodeBlock
code={textOf(child.props.children)}
language={child.props.className?.replace(
"language-",
"",
)}
/>
);
return <pre>{children}</pre>;
},
blockquote: ({ children }) => (
<blockquote
className={
doc.slug.includes("draft")
? "callout callout-draft"
: "callout"
}
>
<span className="callout-icon" aria-hidden="true">
ⓘ
</span>
<div>{children}</div>
</blockquote>
),
}}
>
{doc.content}
</ReactMarkdown>
</article>
<div className="article-end">
<span>文档与源码共同维护</span>
<a
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.slug}.md`}
>
查看文档源码 ↗
</a>
</div>
<nav className="pagination" aria-label="相邻文档">
{index > 0 ? (
<Link href={`/docs/${docs[index - 1].slug}`}>
<small>← 上一篇</small>
<strong>{docs[index - 1].title}</strong>
</Link>
) : (
<Link href="/">
<small>← 返回</small>
<strong>文档概览</strong>
</Link>
)}
{index < docs.length - 1 && (
<Link
className="next-page"
href={`/docs/${docs[index + 1].slug}`}
>
<small>下一篇 →</small>
<strong>{docs[index + 1].title}</strong>
</Link>
)}
</nav>
<footer className="article-footer">
ENDOR / TABULARIUM <span>记录思考,连接系统。</span>
</footer>
</main>
<aside className="toc">
<p className="eyebrow">本页内容</p>
{toc}
<div className="toc-note">
<span className="status-dot" /> {section.label}
<p>
{doc.slug.includes("draft")
? "这里记录正在讨论的设计,尚不代表现有实现。"
: "每一次理解,都成为下一步的起点。"}
</p>
</div>
</aside>
</div>
</div>
</Shell>
</>
);
}
+157 -22
View File
@@ -2,32 +2,167 @@ import type { GetStaticProps, InferGetStaticPropsType } from "next";
import Head from "next/head";
import Link from "next/link";
import { listDocs, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { CodeBlock } from "@/components/CodeBlock";
import { sectionFor } from "@/lib/navigation";
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
props: { docs: await listDocs() },
});
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
export const getStaticProps: GetStaticProps<{
docs: DocLink[];
}> = async () => ({ props: { docs: await listDocs() } });
export default function Home({
docs,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<>
<Head><title>Tabularium · Endor 文档</title><meta name="description" content="Endor 的文档、架构设计与制图档案。" /></Head>
<main className="mx-auto max-w-5xl px-6 py-16 md:py-24">
<div className="flex items-center justify-between border-b border-stone-200 pb-6 text-sm">
<span className="font-semibold tracking-widest">ENDOR / TABULARIUM</span>
<span className="rounded-full border border-stone-200 px-3 py-1 text-stone-500">文档工作台</span>
<Head>
<title>Tabularium · Endor 文档</title>
<meta
name="description"
content="Endor 的知识档案。探索开发指南、架构设计与可复现的图形工作流。"
/>
</Head>
<Shell docs={docs}>
<div className="home-layout">
<aside className="sidebar">
<Navigation docs={docs} />
</aside>
<main id="content" className="home-main">
<section className="home-hero">
<div className="hero-copy">
<p className="eyebrow">
<span className="status-dot" /> ENDOR KNOWLEDGE ARCHIVE
</p>
<h1>
理解系统。
<br />
构建下一步。
</h1>
<p className="hero-description">
欢迎来到 <strong>Tabularium</strong>,Endor 的知识档案。
<br className="desktop-break" />
从第一篇文档到系统设计,让想法有迹可循。
</p>
<div className="hero-actions">
<Link
className="primary-button"
href="/docs/01-getting-started"
>
开始阅读 <span>→</span>
</Link>
<a className="text-link" href="#explore">
探索档案 <span>↓</span>
</a>
</div>
<p className="hero-footnote">
文档 · 架构 · 图形 <span>持续生长的工作台</span>
</p>
</div>
<div className="archive-visual" aria-hidden="true">
<div className="archive-grid" />
<div className="archive-caption">TABULARIUM / 001</div>
<div className="archive-sheet sheet-back">
<span>03 — FIGURES</span>
<i />
<i />
<i />
</div>
<div className="archive-sheet sheet-middle">
<span>02 — ARCHITECTURE</span>
<i />
<i />
<i />
</div>
<div className="archive-sheet sheet-front">
<span className="sheet-label">01 / ENDOR</span>
<div className="sheet-emblem">
E<span>↗</span>
</div>
<div className="sheet-rule" />
<b>Ideas, documented.</b>
<small>知识在这里连接。</small>
</div>
<div className="archive-coordinate">
文字与图形,一起演进 <span>↗</span>
</div>
</div>
</section>
<section className="explore-section" id="explore">
<div className="section-heading">
<div>
<p className="eyebrow">EXPLORE THE ARCHIVE</p>
<h2>从这里开始探索</h2>
</div>
<span className="section-count">
{String(docs.length).padStart(2, "0")} 篇文档
</span>
</div>
<div className="doc-cards">
{docs.map((doc, index) => {
const section = sectionFor(doc.slug);
return (
<Link
className="doc-card"
key={doc.slug}
href={`/docs/${doc.slug}`}
>
<div className="card-top">
<span className={`card-icon icon-${index}`}>
{section.symbol}
</span>
<span className="card-number">0{index + 1}</span>
</div>
<p className="card-group">{section.group}</p>
<h3>
{doc.title.replace("(草案)", "")}
<span aria-hidden="true">↗</span>
</h3>
<p className="card-description">{section.description}</p>
<div className="card-bottom">
<span>{section.label}</span>
{doc.slug.includes("draft") && (
<span className="draft-badge">草案</span>
)}
</div>
</Link>
);
})}
</div>
</section>
<section className="quickstart">
<div>
<p className="eyebrow">LOCAL WORKSPACE</p>
<h2>一个命令,继续探索。</h2>
<p>
在本地启动文档工作台,
<br />
让文字和可复现的图形留在同一个仓库。
</p>
<Link
className="text-link"
href="/docs/01-getting-started#启动"
>
查看完整启动指南 <span>→</span>
</Link>
</div>
<CodeBlock
language="sh"
code={
"pnpm install --frozen-lockfile\nuv sync --project tools/figures --locked\npnpm dev\n"
}
/>
</section>
<footer className="site-footer">
<span>
Endor / <strong>Tabularium</strong>
</span>
<span>记录思考,连接系统。</span>
<a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">
在仓库中探索 ↗
</a>
</footer>
</main>
</div>
<p className="mt-16 text-sm tracking-widest text-teal-800">文档 · 设计 · 图形</p>
<h1 className="mt-4 text-5xl font-semibold tracking-tight md:text-7xl">Endor 的知识档案。</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-stone-600">记录系统概念、架构讨论和开发约定,让文字与图形一起演进。</p>
<div className="mt-14 grid gap-4 md:grid-cols-2">
{docs.map((doc, index) => (
<Link key={doc.slug} href={`/docs/${doc.slug}`} className="group rounded-xl border border-stone-200 bg-white p-6 transition hover:border-teal-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-teal-700">
<span className="font-mono text-xs text-stone-400">{String(index + 1).padStart(2, "0")}</span>
<h2 className="mt-4 text-xl font-medium group-hover:text-teal-800">{doc.title} <span aria-hidden="true">↗</span></h2>
</Link>
))}
</div>
</main>
</Shell>
</>
);
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
# 文档站参考研究 · 2026-10-27
实际使用浏览器访问并截图;Linear 使用顶部 Toggle theme 切换浅色,Tailwind 在页脚选择 Light theme,Supabase 在右上菜单选择 Light,React 页继承同域浅色设置。截图为 1280×720,未复制品牌文案或资源。
| 页面 | 截图 | 观察与落地 |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Linear Projects](https://linear.app/docs/projects) | linear-projects-light.png | 64px 顶栏、280px 左目录、约670px 正文、右侧细线目录;纯白与浅灰边界、32px 标题和14–16px正文、节制强调。落地固定顶栏、安静三栏、青绿当前项。 |
| [Tailwind Editor setup](https://tailwindcss.com/docs/editor-setup) | tailwind-editor-light-top.png、tailwind-editor-light.png | 56px顶栏、约288px左栏、约560px正文;等宽分类标签、竖线当前项、段落间宽留白、右侧章节列表;页脚显式主题选择。落地目录分组、章节锚点、精确等宽代码与语言标签。 |
| [Supabase Docs](https://supabase.com/docs) | supabase-home-light.png | 50px横向全站导航和搜索;首页标题与命令入口左右布局,下面分区标题+多列入口,边框浅而有序。落地首页快速启动、三个档案分区、明确描述和阅读入口。 |
| [Supabase React](https://supabase.com/docs/guides/getting-started/quickstarts/reactjs) | supabase-react-light.png、supabase-react-code-light.png | 左侧约316px、中间约605px、右侧目录;面包屑和大标题引导;步骤标题、带语言与复制操作的浅色代码块、低对比提示框。落地面包屑、带复制反馈的代码、独立草案提示、上下篇。 |
## 统一设计决定
- 默认浅色,系统本地字体,无 CDN。正文16px/1.9,中文阅读栏最大720px,导航13px,标题32–40px。青绿只用于品牌、链接、当前状态。
- 首页以「理解、构建、记录」为主线,突出入门,再按开始使用/架构设计/图形档案组织现有内容;不制造未存在的产品页。
- 全站顶部品牌返回首页,档案入口和仓库链接;本地全文检索支持 Cmd/Ctrl K、Escape、方向键,不向外部服务发送查询。
- 宽屏三栏、平板隐藏右目录、小屏使用可展开文档菜单与页内目录;代码和表格各自滚动,正文不溢出。
- 保持 Pages Router、pnpm、uv 与现有 CI/CD、镜像缓存、回退和18135部署入口。截图仅为研究证据,不进入站点资源。
响应式交互是对以上设计规律的本地实现决定;参考站的手机布局未单独测量。Tabularium 的手机截图和实测记录见 ../verification。
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+28
View File
@@ -0,0 +1,28 @@
# 浏览器验收 · 2026-10-27
在 `127.0.0.1:3015` 预览 Pages Router 的生产构建,使用实际浏览器操作与截图;未调用外部字体或 CDN,未修改部署架构。截图保留了改版后的首页、三篇文档、搜索及手机/平板布局。
## 检查结果
- `pnpm lint`、`pnpm typecheck`:通过。
- `UV_CACHE_DIR=/private/tmp/tabularium-uv-cache pnpm figures`:通过,SVG 与仓库一致。默认 uv 缓存路径受本机沙箱限制,使用临时缓存完成。
- `pnpm --filter @endor/docs run build --webpack`:通过,首页、三篇文档、404 与健康接口均成功生成。
- 默认 `pnpm build` 的 Turbopack 在这台机器因 PostCSS 编译子进程绑定本地端口遭 `EPERM` 中断;使用 Next.js 自带 webpack 参数完成本地构建。没有修改 package.json 或 CI 的构建命令,远程仍使用原有构建流程。
- `python3 scripts/smoke.py http://127.0.0.1:3015`:通过,覆盖健康、全部文档、生成 JS/CSS、SVG 和404。
- 中文章节锚点、重名标题后缀、代码围栏内的伪标题、行号映射和行内代码标题:使用 Node 断言通过。
- 实际输入「生命周期」只返回架构草案,Enter 打开对应文档;⌘K 打开搜索,Escape 关闭并将焦点恢复到搜索按钮;无结果时显示建议词。方向键保持选择索引边界。
- 代码复制按钮显示「已复制」状态;未将系统剪贴板内容作为验收依据。
- 手机菜单展开后可跳转并自动收起;页内目录展开后可跳转中文章节。草案标签、提示、表格和 SVG 正常呈现。
- 390px 首页和草案页、768px 入门页实测 `documentElement.scrollWidth === innerWidth`。320px 入门页页面宽320px,长命令的代码容器宽274px、内容宽320px,仅容器内部横向滚动。
- 检查浏览器捕获的 error/warn 日志为空。未进行跨浏览器测试;本次使用内置浏览器。
## 截图索引
- `home-desktop.png`、`home-desktop-full.png`:桌面首页和完整分区布局。
- `home-mobile.png`、`home-mobile-full.png`:390px 手机首页和完整纵向布局。
- `getting-started-desktop.png`、`getting-started-mobile.png`、`getting-started-tablet.png`:正文、代码块及不同尺寸的阅读布局。
- `draft-desktop.png`、`draft-mobile.png`、`draft-mobile-table.png`:草案状态、提示块、术语表与手机锚点定位。
- `figures-desktop.png`:本地 SVG 与文字的组合。
- `search-desktop.png`、`search-empty.png`:正文检索和无结果状态。
验收过程中发现并修正:辅助文字过淡、代码字号偏小、手机搜索图标缺少无障碍名称、手机目录位于正文标题之前,以及表格标题行未铺满阅读栏。截图来自同一轮渐进验收;最终重拍了受修正影响的关键视图。
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB