Expand document collections with Pretext tutorial and simplify library navigation
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m47s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m47s
This commit is contained in:
@@ -5,5 +5,5 @@ import nextTs from "eslint-config-next/typescript";
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
globalIgnores([".next/**", "out/**", "next-env.d.ts"]),
|
||||
globalIgnores([".next/**", "out/**", "next-env.d.ts", "public/materials/**"]),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"note": "Original exploratory run. Fixed order, repeated fixtures, headless DPR 1; not an official benchmark or accuracy baseline. Date: 2026-10-28 Asia/Shanghai.",
|
||||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
|
||||
"iterations": 3000,
|
||||
"rounds": 5,
|
||||
"out": [
|
||||
{
|
||||
"name": "latin",
|
||||
"chars": 1428,
|
||||
"first": {
|
||||
"calls": 62,
|
||||
"units": 529
|
||||
},
|
||||
"second": {
|
||||
"calls": 0,
|
||||
"units": 0
|
||||
},
|
||||
"layoutCalls": 0,
|
||||
"fastPath": true,
|
||||
"widths": [
|
||||
80,
|
||||
121,
|
||||
200,
|
||||
319,
|
||||
320,
|
||||
321,
|
||||
480,
|
||||
800
|
||||
],
|
||||
"heights": [
|
||||
3744,
|
||||
2592,
|
||||
1440,
|
||||
864,
|
||||
864,
|
||||
864,
|
||||
576,
|
||||
336
|
||||
],
|
||||
"domHeights": [
|
||||
3744,
|
||||
2592,
|
||||
1440,
|
||||
864,
|
||||
864,
|
||||
864,
|
||||
576,
|
||||
336
|
||||
],
|
||||
"pretextMedianMs": 1.7000000476837158,
|
||||
"domInterleavedMedianMs": 86,
|
||||
"sink": 46530000
|
||||
},
|
||||
{
|
||||
"name": "cjk",
|
||||
"chars": 408,
|
||||
"first": {
|
||||
"calls": 53,
|
||||
"units": 67
|
||||
},
|
||||
"second": {
|
||||
"calls": 0,
|
||||
"units": 0
|
||||
},
|
||||
"layoutCalls": 0,
|
||||
"fastPath": true,
|
||||
"widths": [
|
||||
80,
|
||||
121,
|
||||
200,
|
||||
319,
|
||||
320,
|
||||
321,
|
||||
480,
|
||||
800
|
||||
],
|
||||
"heights": [
|
||||
2016,
|
||||
1440,
|
||||
864,
|
||||
528,
|
||||
504,
|
||||
504,
|
||||
336,
|
||||
216
|
||||
],
|
||||
"domHeights": [
|
||||
2016,
|
||||
1440,
|
||||
864,
|
||||
528,
|
||||
504,
|
||||
504,
|
||||
336,
|
||||
216
|
||||
],
|
||||
"pretextMedianMs": 1.5,
|
||||
"domInterleavedMedianMs": 57.80000019073486,
|
||||
"sink": 26433000
|
||||
},
|
||||
{
|
||||
"name": "pre-wrap",
|
||||
"chars": 888,
|
||||
"first": {
|
||||
"calls": 50,
|
||||
"units": 497
|
||||
},
|
||||
"second": {
|
||||
"calls": 0,
|
||||
"units": 0
|
||||
},
|
||||
"layoutCalls": 0,
|
||||
"fastPath": false,
|
||||
"widths": [
|
||||
80,
|
||||
121,
|
||||
200,
|
||||
319,
|
||||
320,
|
||||
321,
|
||||
480,
|
||||
800
|
||||
],
|
||||
"heights": [
|
||||
2592,
|
||||
1728,
|
||||
864,
|
||||
864,
|
||||
864,
|
||||
864,
|
||||
576,
|
||||
576
|
||||
],
|
||||
"domHeights": [
|
||||
2592,
|
||||
1728,
|
||||
864,
|
||||
864,
|
||||
864,
|
||||
864,
|
||||
576,
|
||||
576
|
||||
],
|
||||
"pretextMedianMs": 6.299999952316284,
|
||||
"domInterleavedMedianMs": 93.90000009536743,
|
||||
"sink": 36828000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Tutorial mechanism probe, not the official performance or accuracy gate.
|
||||
// Run from any directory; see README.md for runtime and Playwright setup.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const {stripTypeScriptTypes} = require('node:module');
|
||||
const assert = require('node:assert/strict');
|
||||
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const src = path.resolve(__dirname, '../../src');
|
||||
const outputFlag = process.argv.indexOf('--output');
|
||||
if (outputFlag !== -1 && !process.argv[outputFlag + 1]) throw new Error('--output requires a file path');
|
||||
const modules = new Map();
|
||||
function compile(dir) {
|
||||
for (const entry of fs.readdirSync(dir,{withFileTypes:true})) {
|
||||
const file = path.join(dir,entry.name);
|
||||
if(entry.isDirectory()) compile(file);
|
||||
else if(file.endsWith('.ts') && !file.endsWith('.test.ts')) {
|
||||
modules.set('/'+path.relative(src,file).replace(/\.ts$/,'.js'),stripTypeScriptTypes(fs.readFileSync(file,'utf8'),{mode:'transform'}));
|
||||
}
|
||||
}
|
||||
}
|
||||
compile(src);
|
||||
const server = http.createServer((req,res)=>{
|
||||
res.setHeader('Content-Type',req.url==='/'?'text/html':'text/javascript');
|
||||
if(req.url !== '/' && !modules.has(req.url)) { res.statusCode=404; res.end('Missing module'); return; }
|
||||
res.end(req.url==='/'?'<html lang="en"><body></body></html>':modules.get(req.url));
|
||||
});
|
||||
(async()=>{
|
||||
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve)});
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({headless:true,...(process.env.CHROME_EXECUTABLE ? {executablePath:process.env.CHROME_EXECUTABLE} : {})});
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||
const result = await page.evaluate(async()=>{
|
||||
const api = await import('/layout.js');
|
||||
let calls=0,units=0;
|
||||
const original=OffscreenCanvasRenderingContext2D.prototype.measureText;
|
||||
OffscreenCanvasRenderingContext2D.prototype.measureText=function(text){calls++;units+=text.length;return original.call(this,text)};
|
||||
const font='16px "Helvetica Neue", "PingFang SC", "Geeza Pro"';
|
||||
const cases=[
|
||||
{name:'latin',text:'The quick brown fox jumps over the lazy dog. Accurate text layout needs cached measurements and careful line breaking. '.repeat(12)},
|
||||
{name:'cjk',text:'高性能文本布局的关键,是复用字体测量结果,并正确处理中文标点和换行。'.repeat(12)},
|
||||
{name:'pre-wrap',text:'A long line with\ttabs\n preserved spaces and a discretionary soft\u00adhyphen.\n'.repeat(12),options:{whiteSpace:'pre-wrap'}},
|
||||
];
|
||||
const out=[];
|
||||
for(const c of cases){
|
||||
api.clearCache();calls=0;units=0;
|
||||
const p=api.prepare(c.text,font,c.options);
|
||||
const first={calls,units};calls=0;units=0;
|
||||
api.prepare(c.text,font,c.options);
|
||||
const second={calls,units};calls=0;
|
||||
const widths=[80,121,200,319,320,321,480,800];
|
||||
const heights=widths.map(w=>api.layout(p,w,24).height);
|
||||
const layoutCalls=calls;
|
||||
const el=document.createElement('div');
|
||||
Object.assign(el.style,{font,lineHeight:'24px',overflowWrap:'break-word',whiteSpace:c.options?.whiteSpace||'normal',position:'absolute'});
|
||||
el.textContent=c.text;document.body.appendChild(el);
|
||||
const domHeights=widths.map(w=>{el.style.width=w+'px';return el.getBoundingClientRect().height});
|
||||
let sink=0;
|
||||
for(let i=0;i<3000;i++)sink+=api.layout(p,widths[i%8],24).height;
|
||||
const pretextMs=[],domMs=[];
|
||||
for(let r=0;r<5;r++){
|
||||
let t=performance.now();
|
||||
for(let i=0;i<3000;i++)sink+=api.layout(p,widths[i%8],24).height;
|
||||
pretextMs.push(performance.now()-t);
|
||||
t=performance.now();
|
||||
for(let i=0;i<3000;i++){el.style.width=widths[i%8]+'px';sink+=el.getBoundingClientRect().height;}
|
||||
domMs.push(performance.now()-t);
|
||||
}
|
||||
el.remove();
|
||||
const withSegments=api.prepareWithSegments(c.text,font,c.options);
|
||||
const lineApiCounts=widths.map(w=>api.layoutWithLines(withSegments,w,24).lines.length);
|
||||
const median=a=>a.sort((a,b)=>a-b)[2];
|
||||
out.push({name:c.name,chars:c.text.length,first,second,layoutCalls,fastPath:p.simpleLineWalkFastPath,widths,heights,domHeights,lineApiCounts,pretextMedianMs:median(pretextMs),domInterleavedMedianMs:median(domMs),sink});
|
||||
}
|
||||
return {userAgent:navigator.userAgent,devicePixelRatio,language:document.documentElement.lang,font,iterations:3000,rounds:5,out};
|
||||
});
|
||||
for(const row of result.out){
|
||||
assert.ok(row.first.calls > 0, row.name+': first preparation must measure');
|
||||
assert.equal(row.second.calls,0,row.name+': repeated preparation must reuse Canvas metrics');
|
||||
assert.equal(row.layoutCalls,0,row.name+': layout must not measure');
|
||||
assert.deepEqual(row.heights,row.domHeights,row.name+': heights must match DOM for these fixtures');
|
||||
assert.deepEqual(row.lineApiCounts,row.heights.map(h=>h/24),row.name+': line APIs must agree');
|
||||
}
|
||||
if(outputFlag !== -1) fs.writeFileSync(path.resolve(process.argv[outputFlag + 1]),JSON.stringify(result,null,2)+'\n');
|
||||
console.log(JSON.stringify(result,null,2));
|
||||
} finally {if(browser)await browser.close();server.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -141,6 +141,9 @@ async function main() {
|
||||
"/",
|
||||
"/docs/00-endor-concepts-draft",
|
||||
"/figures/endor-architecture.svg",
|
||||
"/docs/pretext/01-chapter",
|
||||
"/materials/pretext/verify.cjs",
|
||||
"/materials/pretext/results.chrome156.json",
|
||||
`/_next/data/${buildId}/index.json`,
|
||||
`/_next/data/${buildId}/docs/00-endor-concepts-draft.json`,
|
||||
]) {
|
||||
@@ -155,7 +158,7 @@ async function main() {
|
||||
path,
|
||||
);
|
||||
}
|
||||
for (const path of ["/api/private", "/api/unknown"])
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search"])
|
||||
assert.equal((await request(path)).status, 401, path);
|
||||
assert.equal(
|
||||
(
|
||||
@@ -176,13 +179,18 @@ async function main() {
|
||||
const headers = {
|
||||
Authorization: "Bearer " + signSession(config, undefined, 600),
|
||||
};
|
||||
const searchResponse = await request("/api/search", { headers });
|
||||
assert.equal(searchResponse.status, 200);
|
||||
const searchIndex = await searchResponse.json();
|
||||
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "pretext/04-chapter" && doc.searchText.includes("getSegmentMetrics")));
|
||||
let home = "";
|
||||
for (const route of routes) {
|
||||
const response = await request(route, { headers });
|
||||
assert.equal(response.status, 200, route);
|
||||
if (route === "/") home = await response.text();
|
||||
}
|
||||
assert.ok(home.includes("TABULARIUM"));
|
||||
assert.ok(home.includes("资料库"));
|
||||
assert.ok(home.includes("/docs/pretext/00-overview"));
|
||||
const assets = new Set(
|
||||
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
|
||||
m[1].replaceAll("&", "&"),
|
||||
@@ -191,6 +199,11 @@ async function main() {
|
||||
assert.ok(assets.size);
|
||||
for (const path of assets)
|
||||
assert.equal((await request(path, { headers })).status, 200, path);
|
||||
for (const name of ["verify.cjs", "results.chrome156.json"]) {
|
||||
const response = await request("/materials/pretext/" + name, { headers });
|
||||
assert.equal(response.status, 200, name);
|
||||
assert.ok((await response.text()).length > 100);
|
||||
}
|
||||
for (const name of readdirSync(join(__dirname, "public/figures"))) {
|
||||
if (!name.endsWith(".svg")) continue;
|
||||
const response = await request("/figures/" + name, { headers });
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
// One stroke style for controls and navigation; labels carry the meaning.
|
||||
export function Icon({ name, size = 18, style }: {
|
||||
name: "search" | "arrow" | "book" | "code" | "menu" | "close";
|
||||
size?: number; style?: CSSProperties;
|
||||
}) {
|
||||
const paths = {
|
||||
search: <><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4 4" /></>,
|
||||
arrow: <path d="M5 12h14m-6-6 6 6-6 6" />,
|
||||
book: <><path d="M12 5c-3-2-6-2-9-1v15c3-1 6-1 9 1 3-2 6-2 9-1V4c-3-1-6-1-9 1Z" /><path d="M12 5v15" /></>,
|
||||
code: <path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16" />,
|
||||
menu: <path d="M4 6h16M4 12h16M4 18h16" />,
|
||||
close: <path d="m6 6 12 12M6 18 18 6" />,
|
||||
};
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={style}>{paths[name]}</svg>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { DocLink } from "@/lib/docs";
|
||||
import { sectionFor } from "@/lib/navigation";
|
||||
|
||||
|
||||
export function Brand() {
|
||||
return (
|
||||
@@ -28,7 +29,7 @@ export function Navigation({
|
||||
current?: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const groups = [...new Set(docs.map((d) => sectionFor(d.slug).group))];
|
||||
const groups = [...new Set(docs.map((d) => d.group))];
|
||||
return (
|
||||
<nav aria-label="文档导航">
|
||||
<Link
|
||||
@@ -36,13 +37,13 @@ export function Navigation({
|
||||
className={`nav-home ${!current ? "selected" : ""}`}
|
||||
href="/"
|
||||
>
|
||||
▦ <span>文档概览</span>
|
||||
<Icon name="book" size={17} /> <span>资料库</span>
|
||||
</Link>
|
||||
{groups.map((group) => (
|
||||
<div className="nav-group" key={group}>
|
||||
<p>{group}</p>
|
||||
{docs
|
||||
.filter((d) => sectionFor(d.slug).group === group)
|
||||
.filter((d) => d.group === group)
|
||||
.map((d) => (
|
||||
<Link
|
||||
onClick={onNavigate}
|
||||
@@ -52,17 +53,13 @@ export function Navigation({
|
||||
key={d.slug}
|
||||
>
|
||||
{d.title}
|
||||
{sectionFor(d.slug).label.includes("草案") && (
|
||||
{d.draft && (
|
||||
<span className="tiny-dot" aria-label="草案" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-note">
|
||||
<span className="status-dot" /> 与代码一起演进
|
||||
<p>文字、设计与图形的共同档案。</p>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -79,18 +76,32 @@ export function Shell({
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchDocs, setSearchDocs] = useState(docs);
|
||||
const [searchLoaded, setSearchLoaded] = useState(false);
|
||||
const [searchStatus, setSearchStatus] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const results = docs.filter((d) =>
|
||||
`${d.title} ${d.searchText}`
|
||||
const results = searchDocs.filter((d) =>
|
||||
`${d.title} ${d.description} ${d.searchText ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query.trim().toLocaleLowerCase()),
|
||||
);
|
||||
function openSearch() {
|
||||
async function openSearch() {
|
||||
setQuery("");
|
||||
setSelected(0);
|
||||
dialog.current?.showModal();
|
||||
input.current?.focus();
|
||||
if (searchLoaded) return;
|
||||
setSearchStatus("正在加载全文索引…");
|
||||
try {
|
||||
const response = await fetch("/api/search");
|
||||
if (!response.ok) throw new Error("Search unavailable");
|
||||
setSearchDocs(await response.json());
|
||||
setSearchLoaded(true);
|
||||
setSearchStatus("");
|
||||
} catch {
|
||||
setSearchStatus("全文搜索暂时不可用,当前仅搜索标题和摘要。关闭后重新打开可重试。");
|
||||
}
|
||||
}
|
||||
function closeSearch() {
|
||||
dialog.current?.close();
|
||||
@@ -100,8 +111,7 @@ export function Shell({
|
||||
function keyboard(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
dialog.current?.showModal();
|
||||
input.current?.focus();
|
||||
trigger.current?.click();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", keyboard);
|
||||
@@ -115,7 +125,7 @@ export function Shell({
|
||||
<header className="site-header">
|
||||
<Brand />
|
||||
<nav className="top-nav" aria-label="主导航">
|
||||
<Link href="/">文档概览</Link>
|
||||
<Link href="/">资料库</Link>
|
||||
<Link href="/docs/00-endor-concepts-draft">架构设计</Link>
|
||||
<a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">
|
||||
仓库 ↗
|
||||
@@ -127,7 +137,7 @@ export function Shell({
|
||||
aria-label="搜索文档"
|
||||
onClick={openSearch}
|
||||
>
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<Icon name="search" />
|
||||
<span>搜索文档…</span>
|
||||
<kbd>⌘ K</kbd>
|
||||
</button>
|
||||
@@ -154,7 +164,7 @@ export function Shell({
|
||||
aria-expanded={menu}
|
||||
aria-controls="mobile-navigation"
|
||||
>
|
||||
☰ <span>文档目录</span>
|
||||
<Icon name="menu" size={16} /> <span>文档目录</span>
|
||||
<span>{menu ? "−" : "+"}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -178,7 +188,7 @@ export function Shell({
|
||||
onClose={() => trigger.current?.focus()}
|
||||
>
|
||||
<div className="search-input-row">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<Icon name="search" />
|
||||
<input
|
||||
ref={input}
|
||||
value={query}
|
||||
@@ -214,6 +224,7 @@ export function Shell({
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
{searchStatus && <p className="search-status" role="status">{searchStatus}</p>}
|
||||
<div id="search-results" className="search-results">
|
||||
<p className="eyebrow" role="status">
|
||||
{query ? `${results.length} 篇匹配文档` : "浏览文档"}
|
||||
@@ -228,11 +239,11 @@ export function Shell({
|
||||
key={d.slug}
|
||||
href={`/docs/${d.slug}`}
|
||||
>
|
||||
<span>{sectionFor(d.slug).group}</span>
|
||||
<span>{d.group}</span>
|
||||
<strong>
|
||||
{d.title} <span aria-hidden="true">↗</span>
|
||||
</strong>
|
||||
<small>{sectionFor(d.slug).description}</small>
|
||||
<small>{d.description}</small>
|
||||
</Link>
|
||||
))}
|
||||
{!results.length && (
|
||||
|
||||
+30
-13
@@ -1,27 +1,44 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import catalog from "../../../../content/catalog.json";
|
||||
|
||||
const directory = path.resolve(process.cwd(), "../../content/docs");
|
||||
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export type DocLink = { slug: string; title: string; searchText: string };
|
||||
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
type Metadata = { group?: string; description?: string; draft?: boolean; featured?: boolean };
|
||||
export type DocLink = {
|
||||
slug: string; title: string; searchText?: string;
|
||||
group: string; collection: string; description: string; draft: boolean; featured: boolean;
|
||||
};
|
||||
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, searchText: content };
|
||||
const collection = catalog.collections.find((c) => !c.path || slug.startsWith(`${c.path}/`))!;
|
||||
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[slug] ?? {};
|
||||
return { slug, title, content, searchText: content,
|
||||
group: metadata.group ?? collection.title, collection: collection.title,
|
||||
description: metadata.description ?? collection.description,
|
||||
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
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, searchText } = await readDoc(file.slice(0, -3));
|
||||
return { slug, title, searchText };
|
||||
}),
|
||||
);
|
||||
async function walk(folder: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(path.join(directory, folder), { withFileTypes: true });
|
||||
const files = await Promise.all(entries.map(async (entry) => {
|
||||
const relative = folder ? `${folder}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) return walk(relative);
|
||||
if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3)))
|
||||
return [relative.slice(0, -3)];
|
||||
return [];
|
||||
}));
|
||||
return files.flat();
|
||||
}
|
||||
return Promise.all((await walk("")).sort().map(async (slug) => {
|
||||
const { content: _, searchText: __, ...doc } = await readDoc(slug);
|
||||
void _; void __;
|
||||
return doc;
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,45 +1,3 @@
|
||||
export type Section = {
|
||||
group: string;
|
||||
description: string;
|
||||
label: string;
|
||||
symbol: string;
|
||||
};
|
||||
export function sectionFor(slug: string): Section {
|
||||
if (slug.includes("endor-purpose"))
|
||||
return {
|
||||
group: "项目讨论",
|
||||
label: "讨论草案",
|
||||
symbol: "○",
|
||||
description: "逐步讨论 Endor 的意义:从程序入口走向用户与 AI 的对象操作。",
|
||||
};
|
||||
if (slug.includes("endor-concepts"))
|
||||
return {
|
||||
group: "架构设计",
|
||||
label: "系统架构",
|
||||
symbol: "◇",
|
||||
description: "从全球入口、区域运行时到对象数据,了解 Endor 的系统架构。",
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { listDocs, readDoc } from "@/lib/docs";
|
||||
|
||||
// The proxy authenticates this endpoint, including index content.
|
||||
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
const docs = await listDocs();
|
||||
const index = await Promise.all(docs.map(async (doc) => ({
|
||||
...doc, searchText: (await readDoc(doc.slug)).content,
|
||||
})));
|
||||
response.status(200).json(index);
|
||||
}
|
||||
@@ -18,15 +18,15 @@ import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
|
||||
import { Shell, Navigation } from "@/components/Shell";
|
||||
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { headingsFor, sectionFor } from "@/lib/navigation";
|
||||
import { headingsFor } from "@/lib/navigation";
|
||||
|
||||
type Props = { doc: Doc; docs: DocLink[] };
|
||||
export const getStaticPaths: GetStaticPaths = async () => ({
|
||||
paths: (await listDocs()).map(({ slug }) => ({ params: { slug } })),
|
||||
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
|
||||
fallback: false,
|
||||
});
|
||||
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
|
||||
const slug = params?.slug;
|
||||
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
|
||||
if (typeof slug !== "string") return { notFound: true };
|
||||
const docs = await listDocs();
|
||||
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
|
||||
@@ -47,8 +47,9 @@ export default function DocPage({
|
||||
}: 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);
|
||||
|
||||
const siblings = docs.filter((d) => d.collection === doc.collection);
|
||||
const index = siblings.findIndex((d) => d.slug === doc.slug);
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -80,7 +81,7 @@ export default function DocPage({
|
||||
<>
|
||||
<Head>
|
||||
<title>{doc.title} · Tabularium</title>
|
||||
<meta name="description" content={section.description} />
|
||||
<meta name="description" content={doc.description} />
|
||||
</Head>
|
||||
<Shell key={doc.slug} docs={docs} current={doc.slug}>
|
||||
<div className="reading-layout">
|
||||
@@ -91,13 +92,8 @@ export default function DocPage({
|
||||
<div className="breadcrumb">
|
||||
<Link href="/">文档</Link>
|
||||
<span>/</span>
|
||||
<span>{section.group}</span>
|
||||
</div>
|
||||
<div className="article-meta">
|
||||
<span className="eyebrow">{section.group}</span>
|
||||
{section.label.includes("草案") && (
|
||||
<span className="draft-badge">讨论草案</span>
|
||||
)}
|
||||
<span>{doc.group}</span>
|
||||
{doc.draft && <span className="draft-badge">草案</span>}
|
||||
</div>
|
||||
<article className="prose prose-neutral max-w-none">
|
||||
<ReactMarkdown
|
||||
@@ -183,7 +179,7 @@ export default function DocPage({
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className={
|
||||
section.label.includes("草案")
|
||||
doc.draft
|
||||
? "callout callout-draft"
|
||||
: "callout"
|
||||
}
|
||||
@@ -200,7 +196,7 @@ export default function DocPage({
|
||||
</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`}
|
||||
>
|
||||
@@ -209,9 +205,9 @@ export default function DocPage({
|
||||
</div>
|
||||
<nav className="pagination" aria-label="相邻文档">
|
||||
{index > 0 ? (
|
||||
<Link href={`/docs/${docs[index - 1].slug}`}>
|
||||
<Link href={`/docs/${siblings[index - 1].slug}`}>
|
||||
<small>← 上一篇</small>
|
||||
<strong>{docs[index - 1].title}</strong>
|
||||
<strong>{siblings[index - 1].title}</strong>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/">
|
||||
@@ -219,31 +215,20 @@ export default function DocPage({
|
||||
<strong>文档概览</strong>
|
||||
</Link>
|
||||
)}
|
||||
{index < docs.length - 1 && (
|
||||
{index < siblings.length - 1 && (
|
||||
<Link
|
||||
className="next-page"
|
||||
href={`/docs/${docs[index + 1].slug}`}
|
||||
href={`/docs/${siblings[index + 1].slug}`}
|
||||
>
|
||||
<small>下一篇 →</small>
|
||||
<strong>{docs[index + 1].title}</strong>
|
||||
<strong>{siblings[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>
|
||||
{section.label.includes("草案")
|
||||
? "这里记录正在讨论的设计,尚不代表现有实现。"
|
||||
: "每一次理解,都成为下一步的起点。"}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Shell>
|
||||
+51
-160
@@ -1,168 +1,59 @@
|
||||
import type { GetStaticProps, InferGetStaticPropsType } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { listDocs, type DocLink } from "@/lib/docs";
|
||||
import { Shell, Navigation } from "@/components/Shell";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { sectionFor } from "@/lib/navigation";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
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>
|
||||
<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>
|
||||
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
|
||||
props: { docs: await listDocs() },
|
||||
});
|
||||
const PAGE_SIZE = 8;
|
||||
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const [collection, setCollection] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const collections = [...new Set(docs.map((doc) => doc.collection))];
|
||||
const filtered = docs.filter((doc) => (!collection || doc.collection === collection) &&
|
||||
`${doc.title} ${doc.description}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
const featured = docs.filter((doc) => doc.featured).slice(0, 3);
|
||||
return <>
|
||||
<Head><title>Tabularium · 资料库</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="library-intro">
|
||||
<h1>资料库</h1>
|
||||
<p>系统设计、源码研究与学习笔记。</p>
|
||||
</section>
|
||||
<section className="featured-section" aria-labelledby="featured-title">
|
||||
<h2 id="featured-title">推荐阅读</h2>
|
||||
<div className="featured-grid">{featured.map((doc) => <Link className="featured-link" href={`/docs/${doc.slug}`} key={doc.slug}>
|
||||
<Icon name={doc.collection === "Endor 文档" ? "book" : "code"} size={23} />
|
||||
<h3>{doc.title.replace("(草案)", "")}</h3>
|
||||
<p>{doc.description}</p>
|
||||
<Icon name="arrow" />
|
||||
</Link>)}</div>
|
||||
</section>
|
||||
<section className="library-section" id="library" aria-labelledby="library-title">
|
||||
<h2 id="library-title">全部资料</h2>
|
||||
<div className="library-tools">
|
||||
<div className="collection-filters" role="group" aria-label="资料分类">
|
||||
{["", ...collections].map((name) => <button key={name} aria-pressed={collection === name} onClick={() => { setCollection(name); setPage(0); }}>{name || "全部"}</button>)}
|
||||
</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>
|
||||
{section.label.includes("草案") && (
|
||||
<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>
|
||||
</Shell>
|
||||
</>
|
||||
);
|
||||
<label className="library-search"><Icon name="search" /><input aria-label="筛选资料" placeholder="筛选资料…" value={query} onChange={(event) => {setQuery(event.target.value); setPage(0);}} /></label>
|
||||
</div>
|
||||
<ul className="library-list">{filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE).map((doc) => <li key={doc.slug}>
|
||||
<Link href={`/docs/${doc.slug}`}><span className="library-row-title">{doc.title.replace("(草案)", "")}{doc.draft && <span className="draft-badge">草案</span>}</span><span className="library-row-group">{doc.group}</span><Icon name="arrow" size={16} /></Link>
|
||||
</li>)}</ul>
|
||||
{filtered.length === 0 && <p className="library-empty">没有找到相关资料。</p>}
|
||||
{filtered.length > PAGE_SIZE && <nav className="library-pagination" aria-label="资料分页"><button disabled={page === 0} onClick={() => setPage(page - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / PAGE_SIZE)}</span><button disabled={(page + 1) * PAGE_SIZE >= filtered.length} onClick={() => setPage(page + 1)}>下一页</button></nav>}
|
||||
</section>
|
||||
<footer className="site-footer"><span>Tabularium</span><a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">文档仓库 ↗</a></footer>
|
||||
</main>
|
||||
</div>
|
||||
</Shell>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ button {
|
||||
}
|
||||
.nav-group > p {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
color: #363f39;
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
@@ -220,348 +220,12 @@ button {
|
||||
background: #c2a56e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-note {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 45px 10px 0;
|
||||
padding-top: 20px;
|
||||
font-size: 10px;
|
||||
color: #78847b;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.nav-note p {
|
||||
color: #a0a8a1;
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #378467;
|
||||
border-radius: 50%;
|
||||
margin-right: 7px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.home-main {
|
||||
min-width: 0;
|
||||
padding: 0 48px;
|
||||
}
|
||||
.home-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 66px 0 58px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1.8px;
|
||||
color: #849087;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
margin: 0;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: clamp(36px, 3.5vw, 52px);
|
||||
line-height: 1.28;
|
||||
letter-spacing: -2px;
|
||||
font-weight: 550;
|
||||
margin: 25px 0 19px;
|
||||
}
|
||||
.hero-description {
|
||||
font-size: 15px;
|
||||
line-height: 2;
|
||||
color: #637066;
|
||||
}
|
||||
.hero-description strong {
|
||||
font-weight: 500;
|
||||
color: #4e5f54;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.primary-button {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
align-items: center;
|
||||
color: white;
|
||||
background: #2c7057;
|
||||
border: 1px solid #24644d;
|
||||
box-shadow: 0 2px 3px #204d5010;
|
||||
border-radius: 6px;
|
||||
padding: 10px 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.primary-button:hover {
|
||||
background: #235c47;
|
||||
}
|
||||
.text-link {
|
||||
color: #5b6d60;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.text-link:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
.hero-footnote {
|
||||
color: #a3aba4;
|
||||
font-size: 9px;
|
||||
margin-top: 34px;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
.hero-footnote span {
|
||||
border-left: 1px solid #dce3dc;
|
||||
margin-left: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.archive-visual {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
background: radial-gradient(ellipse, #edf3eb 0, transparent 70%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.archive-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(#d8e1d530 1px, transparent 1px),
|
||||
linear-gradient(90deg, #d8e1d530 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
mask-image: radial-gradient(ellipse, black, transparent 75%);
|
||||
}
|
||||
.archive-caption {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 12px;
|
||||
font:
|
||||
8px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 2px;
|
||||
color: #9aab99;
|
||||
}
|
||||
.archive-sheet {
|
||||
position: absolute;
|
||||
width: 170px;
|
||||
height: 205px;
|
||||
left: calc(50% - 85px);
|
||||
top: 52px;
|
||||
border: 1px solid #d1dbcd;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 25px #6073550c;
|
||||
padding: 20px;
|
||||
}
|
||||
.sheet-back {
|
||||
transform: translate(35px, -16px) rotate(12deg);
|
||||
background: #edf1e8;
|
||||
color: #a7b19e;
|
||||
}
|
||||
.sheet-middle {
|
||||
transform: translate(16px, -5px) rotate(5deg);
|
||||
background: #f6f8f0;
|
||||
color: #9ba890;
|
||||
}
|
||||
.archive-sheet > span:first-child {
|
||||
font:
|
||||
8px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.archive-sheet i {
|
||||
display: block;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
background: #d5decf;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.sheet-front {
|
||||
transform: rotate(-7deg);
|
||||
background: linear-gradient(140deg, #fffef8, #f4f6eb);
|
||||
}
|
||||
.sheet-label {
|
||||
color: #839679;
|
||||
}
|
||||
.sheet-emblem {
|
||||
font-size: 65px;
|
||||
font-family: Georgia, serif;
|
||||
color: #56764b;
|
||||
line-height: 1.2;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.sheet-emblem span {
|
||||
font:
|
||||
28px ui-sans-serif,
|
||||
system-ui;
|
||||
vertical-align: top;
|
||||
margin-left: 14px;
|
||||
color: #9aaa8c;
|
||||
}
|
||||
.sheet-rule {
|
||||
height: 1px;
|
||||
background: #d9e0d0;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
.archive-sheet b {
|
||||
font:
|
||||
12px Georgia,
|
||||
serif;
|
||||
color: #637759;
|
||||
}
|
||||
.archive-sheet small {
|
||||
display: block;
|
||||
color: #a1ad96;
|
||||
font-size: 8px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.archive-coordinate {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 15px;
|
||||
right: 10px;
|
||||
font-size: 8px;
|
||||
color: #9aaa98;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.explore-section {
|
||||
padding: 35px 0;
|
||||
scroll-margin-top: 90px;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.section-heading h2,
|
||||
.quickstart h2 {
|
||||
margin: 10px 0 0;
|
||||
font-size: 20px;
|
||||
font-weight: 550;
|
||||
letter-spacing: -0.6px;
|
||||
}
|
||||
.section-count {
|
||||
font:
|
||||
10px ui-monospace,
|
||||
monospace;
|
||||
color: #9ca59d;
|
||||
}
|
||||
.doc-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.doc-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 21px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(#fff, #fcfdfb);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
.doc-card:hover {
|
||||
border-color: #a5bdac;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-icon {
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
background: #eef4ef;
|
||||
border: 1px solid #e2eae1;
|
||||
border-radius: 7px;
|
||||
color: #587d5d;
|
||||
}
|
||||
.icon-1 {
|
||||
color: #8b7e60;
|
||||
background: #f6f3ec;
|
||||
border-color: #eee8dd;
|
||||
}
|
||||
.icon-2 {
|
||||
color: #6f8291;
|
||||
background: #eff3f6;
|
||||
border-color: #e5eaf0;
|
||||
}
|
||||
.card-number {
|
||||
font:
|
||||
9px ui-monospace,
|
||||
monospace;
|
||||
color: #b1b8b1;
|
||||
}
|
||||
.card-group {
|
||||
font-size: 11px;
|
||||
color: #73816f;
|
||||
margin: 22px 0 6px;
|
||||
}
|
||||
.doc-card h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 550;
|
||||
letter-spacing: -0.3px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.doc-card h3 > span {
|
||||
color: #a0aaa1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-description {
|
||||
color: #69766b;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
margin: 10px 0 22px;
|
||||
flex: 1;
|
||||
}
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: #a1aaa1;
|
||||
border-top: 1px solid #eef1ed;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.draft-badge {
|
||||
color: #9a7c40;
|
||||
background: #faf6e9;
|
||||
border: 1px solid #eee4c9;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quickstart {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 35px 0 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 35px;
|
||||
align-items: center;
|
||||
}
|
||||
.quickstart p:not(.eyebrow) {
|
||||
font-size: 12px;
|
||||
color: #6d796d;
|
||||
line-height: 1.9;
|
||||
margin: 13px 0 20px;
|
||||
}
|
||||
.eyebrow { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0; }
|
||||
.draft-badge { color: #8c733e; background: #faf6e9; border: 1px solid #eee4c9; border-radius: 4px; padding: 2px 6px; font-size: 12px; white-space: nowrap; }
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
@@ -569,7 +233,7 @@ button {
|
||||
gap: 15px;
|
||||
padding: 24px 0;
|
||||
color: #a1aaa1;
|
||||
font-size: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.site-footer strong {
|
||||
font-weight: 500;
|
||||
@@ -598,17 +262,6 @@ button {
|
||||
.breadcrumb > span:nth-child(2) {
|
||||
color: #c6cec7;
|
||||
}
|
||||
.article-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.article-meta .eyebrow {
|
||||
font-size: 10px;
|
||||
color: var(--green);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.prose {
|
||||
font-size: 16px;
|
||||
line-height: 1.95;
|
||||
@@ -779,7 +432,7 @@ button {
|
||||
background: transparent;
|
||||
color: #5b6f55;
|
||||
font:
|
||||
11px/2.2 ui-monospace,
|
||||
13px/1.9 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
monospace;
|
||||
@@ -805,9 +458,6 @@ button {
|
||||
color: #337c57;
|
||||
font-weight: 600;
|
||||
}
|
||||
.quickstart .code-block {
|
||||
margin: 0;
|
||||
}
|
||||
.toc {
|
||||
padding: 47px 25px 30px 5px;
|
||||
position: sticky;
|
||||
@@ -817,8 +467,8 @@ button {
|
||||
font-size: 13px;
|
||||
}
|
||||
.toc > .eyebrow {
|
||||
letter-spacing: 1px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0;
|
||||
font-size: 13px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.toc nav {
|
||||
@@ -840,19 +490,6 @@ button {
|
||||
.toc nav a.toc-nested {
|
||||
padding-left: 25px;
|
||||
}
|
||||
.toc-note {
|
||||
font-size: 10px;
|
||||
margin-top: 35px;
|
||||
color: #83947f;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 20px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.toc-note p {
|
||||
font-size: 10px;
|
||||
color: #a5aea3;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.article-end {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
@@ -887,28 +524,13 @@ button {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.pagination strong {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #526c55;
|
||||
}
|
||||
.next-page {
|
||||
text-align: right;
|
||||
}
|
||||
.article-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 24px 0;
|
||||
color: #a7b0a5;
|
||||
font:
|
||||
8px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.article-footer span {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.search-dialog {
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
@@ -1005,9 +627,6 @@ button {
|
||||
.home-main {
|
||||
padding: 0 70px;
|
||||
}
|
||||
.home-hero {
|
||||
padding: 85px 0 70px;
|
||||
}
|
||||
.reading-layout {
|
||||
grid-template-columns: 250px minmax(0, 1fr) 235px;
|
||||
}
|
||||
@@ -1031,23 +650,6 @@ button {
|
||||
.reading-main {
|
||||
padding: 30px 40px 0;
|
||||
}
|
||||
.home-hero {
|
||||
gap: 10px;
|
||||
}
|
||||
.archive-sheet {
|
||||
width: 145px;
|
||||
height: 190px;
|
||||
left: calc(50% - 72px);
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 38px;
|
||||
}
|
||||
.doc-card {
|
||||
padding: 16px;
|
||||
}
|
||||
.card-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
.top-nav a:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
@@ -1121,19 +723,9 @@ button {
|
||||
.mobile-navigation .nav-group {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.mobile-navigation .nav-note {
|
||||
display: none;
|
||||
}
|
||||
.home-main {
|
||||
padding: 0 30px;
|
||||
}
|
||||
.home-hero {
|
||||
padding: 45px 0;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 42px;
|
||||
}
|
||||
.reading-main {
|
||||
max-width: 800px;
|
||||
padding: 28px 35px 0;
|
||||
@@ -1173,72 +765,6 @@ button {
|
||||
.home-main {
|
||||
padding: 0 22px;
|
||||
}
|
||||
.home-hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 26px;
|
||||
padding: 35px 0 30px;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 42px;
|
||||
letter-spacing: -1.5px;
|
||||
margin: 23px 0 18px;
|
||||
}
|
||||
.hero-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
.hero-actions {
|
||||
margin-top: 23px;
|
||||
}
|
||||
.hero-footnote {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.archive-visual {
|
||||
height: 240px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
.archive-sheet {
|
||||
top: 30px;
|
||||
height: 180px;
|
||||
}
|
||||
.sheet-emblem {
|
||||
font-size: 52px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.archive-coordinate {
|
||||
bottom: 0;
|
||||
}
|
||||
.section-heading h2,
|
||||
.quickstart h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
.doc-cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.doc-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
.doc-card h3 {
|
||||
font-size: 17px;
|
||||
}
|
||||
.quickstart {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 23px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
.quickstart p:not(.eyebrow) br {
|
||||
display: none;
|
||||
}
|
||||
.quickstart p:not(.eyebrow) {
|
||||
font-size: 12px;
|
||||
}
|
||||
.site-footer {
|
||||
flex-wrap: wrap;
|
||||
font-size: 9px;
|
||||
@@ -1264,9 +790,6 @@ button {
|
||||
.prose h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.article-meta {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.pagination {
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1292,9 +815,6 @@ button {
|
||||
padding: 13px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.article-footer {
|
||||
font-size: 7px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -1532,3 +1052,56 @@ button {
|
||||
.logout-button:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* A bounded reading shelf and paged catalog keep the home stable as it grows. */
|
||||
.home-main { max-width: 1120px; width: 100%; margin: 0 auto; }
|
||||
.library-intro { padding: 64px 0 42px; }
|
||||
.library-intro h1 { margin: 0 0 14px; font-size: 38px; font-weight: 600; letter-spacing: -1px; }
|
||||
.library-intro p { margin: 0; font-size: 16px; color: var(--muted); }
|
||||
.featured-section h2, .library-section h2 { font-size: 19px; font-weight: 550; margin: 0 0 22px; }
|
||||
.featured-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.featured-link { position: relative; border: 1px solid var(--line); border-radius: 8px; padding: 26px; }
|
||||
.featured-link:hover { border-color: #a4b7aa; background: #fafcfb; }
|
||||
.featured-link > svg:first-child { color: var(--green); margin-bottom: 18px; }
|
||||
.featured-link h3 { font-size: 18px; font-weight: 550; line-height: 1.6; margin: 0 0 10px; padding-right: 18px; }
|
||||
.featured-link p { color: var(--muted); font-size: 14px; line-height: 1.8; margin: 0; }
|
||||
.featured-link > svg:last-child { position: absolute; top: 28px; right: 24px; color: var(--muted); }
|
||||
.library-section { padding: 46px 0 32px; }
|
||||
.library-tools { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||
.collection-filters { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.collection-filters button { font-size: 14px; padding: 8px 12px; border-radius: 5px; color: var(--muted); }
|
||||
.collection-filters button[aria-pressed="true"] { background: #eef4f0; color: var(--green); }
|
||||
.library-search { display: flex; align-items: center; gap: 8px; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 9px 12px; }
|
||||
.library-search input { min-width: 0; width: 145px; outline: 0; font-size: 14px; }
|
||||
.library-search:focus-within { border-color: var(--green); }
|
||||
.library-list { padding: 0; margin: 0; list-style: none; border-top: 1px solid var(--line); }
|
||||
.library-list li { border-bottom: 1px solid var(--line); }
|
||||
.library-list a { display: flex; align-items: center; gap: 18px; padding: 20px 4px; }
|
||||
.library-list a:hover { color: var(--green); background: #fafcfb; }
|
||||
.library-row-title { flex: 1; font-size: 15px; line-height: 1.7; }
|
||||
.library-row-title .draft-badge { margin-left: 10px; }
|
||||
.library-row-group { font-size: 13px; color: var(--muted); }
|
||||
.library-list svg { flex-shrink: 0; color: #87948c; }
|
||||
.library-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 22px; margin: 22px 0 0; font-size: 14px; color: var(--muted); }
|
||||
.library-pagination button { padding: 8px; }
|
||||
.library-pagination button:disabled { opacity: .35; cursor: default; }
|
||||
.library-empty { padding: 30px 0; color: var(--muted); }
|
||||
.site-footer { font-size: 13px; color: var(--muted); }
|
||||
.article-end { font-size: 13px; }
|
||||
.search-trigger svg { flex-shrink: 0; }
|
||||
@media (max-width: 560px) {
|
||||
.library-intro { padding: 36px 0; }
|
||||
.library-intro h1 { font-size: 32px; }
|
||||
.featured-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.featured-link { padding: 22px; }
|
||||
.featured-link h3 { font-size: 17px; }
|
||||
.library-section { padding-top: 36px; }
|
||||
.library-tools { align-items: stretch; }
|
||||
.library-search { width: 100%; }
|
||||
.library-search input { width: 100%; }
|
||||
.library-list a { gap: 10px; padding: 16px 0; }
|
||||
.library-row-group { display: none; }
|
||||
.pagination strong { font-size: 13px; }
|
||||
.site-footer { font-size: 13px; }
|
||||
}
|
||||
.search-status { padding: 12px 20px 0; font-size: 13px; color: var(--muted); }
|
||||
|
||||
Reference in New Issue
Block a user