diff --git a/README.md b/README.md index 58172d2..64066fd 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,30 @@ dark atmosphere. Everything respects `prefers-reduced-motion`. Per the design bible there are **no counts anywhere** — quantity is expressed by form, never a number badge — and cinnabar, the colour of the human hand, is used sparingly. +## 一本经的传记 (Book Biography) + +A second page, reachable from 我的树's nav (`心经 · 一本经的传记`) or directly at +`/book/heart-sutra`: the **书籍详情页** design — a book's biography — implemented +for the **心经 (Heart Sutra)**, the shortest and most-repeated text in the +Mahayana canon. + +Where 我的树 is one reader's private archive, this page is a book's public +history: a vertical timeline from its Indian Prajñāpāramitā roots through +Kumārajīva's 402 CE translation, Xuanzang's 649 CE definitive version, Huairen's +672 CE Wang Xizhi calligraphy stele, anonymous Dunhuang manuscripts, Edward +Conze's 1948 critical Sanskrit edition, its adoption into Western Zen practice, +Jan Nattier's 1992 "Chinese apocryphal text" hypothesis (rendered as a +side-by-side **经文在此改变**, "the text changes here", comparison — the same +device the design uses for the *Tao Te Ching*'s Mawangdui silk manuscripts), +Thich Nhat Hanh's 2014 retranslation, and finally today's still-growing **年轮** +of readers' annotations. + +The component (`src/BookBio.tsx`) is book-agnostic — it renders whatever +`BookBioData` (`src/books/types.ts`) it's given. Adding another themed book +(a Western philosophy text, 源氏物语, or anything else) is a matter of writing +a new `src/books/*.ts` data file and a route in `src/main.tsx`; no page code +needs to change. + ## Develop ```bash @@ -44,6 +68,10 @@ npm run preview # serve the production build | --- | --- | | `src/MyTree.tsx` | The 我的树 page component + its branch/annotation data | | `src/MyTree.css` | Page layout, the SVG tree, hover reveal, growth rings | +| `src/BookBio.tsx` | The 一本书的传记 page — book-agnostic; renders a `BookBioData` | +| `src/BookBio.css` | Its layout: hero, timeline, 经文在此改变 comparison, growth rings | +| `src/books/types.ts` | The `BookBioData` shape a themed book plugs in | +| `src/books/heartSutra.ts` | Content for 心经 (Heart Sutra) — the first themed book | | `src/theme.css` | Design tokens (墨字朱批 palette), day/night themes, keyframes | | `APP_NOTES.md` | Detailed notes on this page | | `design/` | The original Claude Design handoff bundle — chat transcript + all page prototypes. The source of truth for the pages not yet built. | diff --git a/package-lock.json b/package-lock.json index e07cb30..c6846f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.4" }, "devDependencies": { "@types/react": "^18.3.12", @@ -793,6 +794,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1627,6 +1637,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", diff --git a/package.json b/package.json index eade0c2..0cbd1db 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.4" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/src/BookBio.css b/src/BookBio.css new file mode 100644 index 0000000..f508a2d --- /dev/null +++ b/src/BookBio.css @@ -0,0 +1,387 @@ +/* BookBio — 一本书的传记 (a book's biography). + Ported from design/project/老树 · 道德经.dc.html. Book-agnostic: content + comes entirely from the BookBioData passed in; nothing here names a book. */ + +.bio-page { + position: relative; + z-index: 1; + min-height: 100vh; + font-family: 'Noto Sans SC', sans-serif; + color: var(--ink); +} + +.bio-page .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 26px 44px; +} + +.bio-page .brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; +} + +.bio-page .brand-name { + font-family: 'Noto Serif SC', serif; + font-size: 17px; + font-weight: 500; + letter-spacing: 0.3em; + color: var(--ink); +} + +.bio-page .brand-sub { + font-size: 12px; + color: var(--ink-light); + letter-spacing: 0.08em; + margin-left: 4px; +} + +.bio-page .nav { + display: flex; + gap: 28px; + font-size: 13px; + color: var(--ink-light); +} + +.bio-page .link-muted { + color: var(--ink-light); + text-decoration: none; + cursor: pointer; + transition: color 0.3s ease; +} +.bio-page .link-muted:hover { + color: var(--ink); +} + +.bio-page .link-cinnabar { + color: var(--cinnabar); + text-decoration: none; + transition: color 0.3s ease; +} +.bio-page .link-cinnabar:hover { + color: var(--seal); +} + +/* ---- hero ---- */ + +.bio-hero { + max-width: 720px; + margin: 0 auto; + padding: 88px 44px 96px 44px; + position: relative; + box-sizing: border-box; +} + +.hero-epigraph { + position: absolute; + right: 44px; + top: 80px; + writing-mode: vertical-rl; + font-family: 'Noto Serif SC', serif; + font-size: 15px; + letter-spacing: 0.5em; + color: var(--ink-light); + opacity: 0.55; + line-height: 2; +} + +.hero-shelf { + font-size: 12px; + color: var(--ink-light); + letter-spacing: 0.3em; + margin-bottom: 32px; +} + +.hero-title { + font-family: 'Noto Serif SC', serif; + font-size: 60px; + font-weight: 400; + letter-spacing: 0.18em; + line-height: 1.3; +} + +.hero-colophon { + margin-top: 20px; + font-family: Spectral, 'Noto Sans SC', serif; + font-feature-settings: 'onum'; + font-size: 13px; + color: var(--ink-light); + letter-spacing: 0.06em; +} + +.hero-lead { + margin-top: 40px; + font-family: 'Noto Serif SC', serif; + font-size: 21px; + letter-spacing: 0.05em; +} + +.hero-cta { + margin-top: 36px; + font-size: 13px; + letter-spacing: 0.14em; +} + +/* ---- timeline ---- */ + +.bio-timeline-wrap { + max-width: 720px; + margin: 0 auto; + padding: 0 44px 60px 44px; + box-sizing: border-box; +} + +.timeline-label { + font-size: 12px; + letter-spacing: 0.5em; + color: var(--ink-light); + margin-bottom: 56px; +} + +.timeline { + position: relative; + padding-left: 140px; +} + +.timeline-rail { + position: absolute; + left: 120px; + top: 8px; + bottom: 8px; + width: 1px; + background: var(--line); +} + +.node { + position: relative; + margin-bottom: 76px; + transition: opacity 0.7s ease, transform 0.7s ease; +} + +.node-year { + position: absolute; + left: -140px; + top: 3px; + width: 106px; + text-align: right; + font-family: Spectral, 'Noto Sans SC', serif; + font-feature-settings: 'onum'; + font-size: 13px; + letter-spacing: 0.04em; +} + +.node-dot { + position: absolute; + left: -24px; + top: 6px; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.node-who { + font-size: 14px; + font-weight: 500; + letter-spacing: 0.04em; + margin-bottom: 10px; +} + +.node-story { + font-family: 'Noto Serif SC', serif; + font-size: 14.5px; + line-height: 2.05; + letter-spacing: 0.02em; + color: var(--ink); +} + +.node-anchor { + margin-top: 16px; + font-family: 'Noto Serif SC', serif; + font-size: 15px; + line-height: 1.9; + color: var(--ink); + border-left: 2px solid var(--u-soft); + padding-left: 14px; +} + +/* ---- 经文在此改变 : side-by-side comparison ---- */ + +.compare { + margin-top: 20px; + border: 0.5px solid var(--line); + border-radius: 8px; + padding: 20px 22px; +} + +.compare-label { + font-size: 11.5px; + letter-spacing: 0.24em; + color: var(--cinnabar); + margin-bottom: 16px; +} + +.compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 24px; + font-family: 'Noto Serif SC', serif; + font-size: 13.5px; + line-height: 1.9; +} + +.compare-head { + font-size: 11px; + font-family: 'Noto Sans SC', sans-serif; + color: var(--ink-light); + letter-spacing: 0.1em; +} + +.compare-link { + margin-top: 16px; + font-size: 12px; + letter-spacing: 0.1em; +} + +/* ---- today: growth rings ---- */ + +.rings { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.ring-card { + border: 0.5px solid var(--line); + border-radius: 8px; + padding: 16px 18px; +} + +.ring-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 8px; +} + +.ring-name { + font-size: 13px; + font-weight: 500; +} + +.ring-badge { + font-size: 11px; + border: 0.5px solid; + border-radius: 4px; + padding: 1px 6px; +} + +.ring-tag { + font-size: 11px; + letter-spacing: 0.1em; +} + +.ring-date { + font-family: Spectral, serif; + font-feature-settings: 'onum'; + font-size: 12px; + color: var(--ink-light); + margin-left: auto; +} + +.ring-text { + font-family: 'Noto Serif SC', serif; + font-size: 13.5px; + line-height: 1.9; + color: var(--ink); +} + +.rings-cta { + margin-top: 10px; + font-size: 13px; + letter-spacing: 0.14em; +} + +/* ---- 与守树人细究此节 ---- */ + +.ask-off { + margin-top: 14px; + text-align: right; +} + +.ask-trigger { + font-size: 12px; + letter-spacing: 0.08em; + color: var(--ink-light); + cursor: pointer; + transition: color 0.3s ease; +} +.ask-trigger:hover { + color: var(--cinnabar); +} + +.ask-on { + margin-top: 14px; + text-align: right; + font-family: 'Noto Serif SC', serif; + font-size: 12.5px; + color: var(--ink-light); +} + +.bio-footer { + text-align: center; + padding: 0 44px 96px 44px; + font-size: 12px; + letter-spacing: 0.2em; + color: var(--ink-light); +} + +.keeper-link { + position: fixed; + right: 28px; + bottom: 22px; + font-size: 12px; + letter-spacing: 0.1em; + color: var(--ink-light); + text-decoration: none; + transition: color 0.3s ease; + z-index: 20; +} +.keeper-link:hover { + color: var(--cinnabar); +} + +@media (max-width: 640px) { + .bio-hero, + .bio-timeline-wrap { + padding-left: 24px; + padding-right: 24px; + } + .hero-epigraph { + display: none; + } + .hero-title { + font-size: 42px; + } + .timeline { + padding-left: 0; + } + .timeline-rail { + display: none; + } + .node-year { + position: static; + width: auto; + text-align: left; + margin-bottom: 4px; + } + .node-dot { + display: none; + } + .compare-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/BookBio.tsx b/src/BookBio.tsx new file mode 100644 index 0000000..011da9f --- /dev/null +++ b/src/BookBio.tsx @@ -0,0 +1,230 @@ +import { Fragment, useCallback, useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import type { BookBioData } from './books/types' +import './BookBio.css' + +/** + * 一本书的传记 (a book's biography) — a scrolling timeline of the moments a + * text passed through a hand: the one who wrote it, the ones who copied, + * translated, argued over, and are still annotating it today. + * + * Ported from `design/project/老树 · 道德经.dc.html`. That prototype is + * book-specific (道德经); this component is not — any book's `BookBioData` + * (see `src/books/`) renders the same biography shape. + */ +export default function BookBio({ book }: { book: BookBioData }) { + const [night, setNight] = useState(() => document.body.hasAttribute('data-night')) + const [seen, setSeen] = useState>({}) + const [wallOpen, setWallOpen] = useState(null) + const ioRef = useRef(null) + + const toggleNight = useCallback(() => { + setNight((prev) => { + const next = !prev + if (next) document.body.setAttribute('data-night', '1') + else document.body.removeAttribute('data-night') + return next + }) + }, []) + + useEffect(() => { + const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + if (reduced || !window.IntersectionObserver) { + setSeen(Object.fromEntries(book.nodes.map((_, i) => [i, true]))) + return + } + const io = new IntersectionObserver( + (entries) => { + setSeen((prev) => { + let changed = false + const next = { ...prev } + for (const entry of entries) { + if (entry.isIntersecting) { + const idx = Number(entry.target.getAttribute('data-node')) + if (!next[idx]) { + next[idx] = true + changed = true + io.unobserve(entry.target) + } + } + } + return changed ? next : prev + }) + }, + { threshold: 0.12 }, + ) + ioRef.current = io + const observe = () => + document.querySelectorAll('[data-node]').forEach((el) => io.observe(el)) + const t1 = setTimeout(observe, 150) + const t2 = setTimeout(observe, 1200) + return () => { + clearTimeout(t1) + clearTimeout(t2) + io.disconnect() + } + // book.nodes.length is stable per mounted book; re-running per book instance is intended. + }, [book.nodes.length]) + + return ( +
+
+ + + 老树 + 千年经典的活体年轮库 + + +
+ +
+ +
{book.shelfLabel}
+
{book.title}
+
{book.colophon}
+
{book.lead}
+ +
+ +
+
{book.biographyLabel}
+
+
+ {book.nodes.map((n, i) => { + const revealed = !!seen[i] + return ( +
+
+ {n.year} +
+
+
{n.who}
+
{n.story}
+ + {n.anchor &&
{n.anchor}
} + + {n.compare && ( +
+
经文在此改变
+
+
{n.compare.columns[0]}
+
{n.compare.columns[1]}
+ {n.compare.rows.map((row, ri) => ( + +
{row[0]}
+
{row[1]}
+
+ ))} +
+ +
+ )} + + {n.today && ( +
+ {book.rings.map((r, ri) => { + const color = r.state === '入木' ? 'var(--cinnabar)' : 'var(--verdigris)' + return ( +
+
+ + {r.name} + + + {r.genre} + + + {r.state} + + {r.date} +
+
{r.text}
+
+ ) + })} + +
+ )} + + {!n.today && + (wallOpen === i ? ( +
+ 读是自由的。落笔与问路,需要一个名字。 → 留下名字 +
+ ) : ( +
+ setWallOpen(i)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setWallOpen(i) + } + }} + > + 与守树人细究此节 → + +
+ ))} +
+ ) + })} +
+
+ +
{book.footerLine}
+ + + 问守树人 + +
+ ) +} diff --git a/src/MyTree.tsx b/src/MyTree.tsx index e773e3c..abe1bf6 100644 --- a/src/MyTree.tsx +++ b/src/MyTree.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { Link } from 'react-router-dom' import './MyTree.css' /** @@ -181,6 +182,9 @@ export default function MyTree() { 回到阅读 + + 心经 · 一本经的传记 + by providing one + * of these; the component itself carries no book-specific content. + */ + +export type TimelineNode = { + /** year / era label shown in the left rail (e.g. "公元 649 年") */ + year: string + /** who this moment belongs to */ + who: string + /** the story of this moment, in prose */ + story: string + /** an anchored line of the text itself, quoted under the story */ + anchor?: string + /** marks a turning point — rendered in cinnabar rather than muted ink */ + turning?: boolean + /** "经文在此改变" — a side-by-side comparison of two versions of the text */ + compare?: { + columns: [string, string] + rows: [string, string][] + linkLabel: string + linkHref: string + } + /** the final "today" node — annotations replace the story as living growth */ + today?: boolean +} + +export type Ring = { + name: string + genre: '注' | '疑' | '证' | '驳' + state: '入木' | '青芽' + date: string + text: string +} + +export type BookBioData = { + slug: string + title: string + /** e.g. "書架 · 般若" */ + shelfLabel: string + /** vertical decorative line beside the hero title */ + epigraph: string + /** meta line under the title: origin, length, age */ + colophon: string + /** the one-line thesis under the colophon */ + lead: string + ctaLabel: string + ctaHref: string + biographyLabel: string + nodes: TimelineNode[] + rings: Ring[] + footerLine: string +} diff --git a/src/main.tsx b/src/main.tsx index 8daa4f9..beac4b0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,18 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter, Route, Routes } from 'react-router-dom' import './theme.css' import MyTree from './MyTree' +import BookBio from './BookBio' +import heartSutra from './books/heartSutra' createRoot(document.getElementById('root')!).render( - + + + } /> + } /> + + , )