diff --git a/README.md b/README.md index 58172d2..61308b3 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ 不是读书 App,不是笔记工具,不是 AI 聊天产品 —— 更接近一座**可以走进去的、还在生长的碑林**。 -This repository holds the 老树 product front-end. The first page implemented is +This repository holds the 老树 product front-end. Two pages are implemented: **我的树 (My Tree)** — a reader's personal archive, rendered as an abstract, slowly -growing tree. +growing tree — and **源氏物语 (The Tale of Genji)** — a book's "biography" told as a +scroll-revealed timeline of its thousand-year transmission. ## 我的树 (My Tree) @@ -29,6 +30,21 @@ 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. +## 源氏物语 · 一本书的传记 + +A book detail page ported from the same pattern as the unbuilt `design/project/老树 +· 道德经.dc.html` prototype, but for a different theme: Murasaki Shikibu's 11th-century +*Tale of Genji*, generally regarded as the world's first great novel. + +The book is told as a vertical timeline — twelve real, dateable moments in its +transmission, from the earliest textual trace (a 1008 diary entry) through the split +into two rival manuscript lineages (青表纸本 vs 河内本, ~1225–1255), Motoori Norinaga's +"物哀" (mono no aware), Arthur Waley's and Royall Tyler's English translations, and +Yamato Waki's fourteen-year manga adaptation. Nodes reveal on scroll +(`IntersectionObserver`, honouring `prefers-reduced-motion`). The final node, "this +book is still being written," carries reader annotations in the same 朱砂/青绿 +(入木/青芽) convention as 我的树. Visit `/genji` (linked from 我的树's nav). + ## Develop ```bash @@ -44,13 +60,16 @@ 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/theme.css` | Design tokens (墨字朱批 palette), day/night themes, keyframes | +| `src/Genji.tsx` | The 源氏物语 book-biography page component + its timeline/ring data | +| `src/Genji.css` | Page layout, timeline reveal, comparison box, reader rings | +| `src/theme.css` | Design tokens (墨字朱批 palette), day/night themes, shared header/nav chrome | +| `src/main.tsx` | Renders `MyTree` at `/`, `Genji` at `/genji` — a plain path switch, no router dependency yet | | `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. | ## Not yet built -`design/project/` holds prototypes for the rest of the product: 首页书架, 书籍详情页, -阅读页 (the design's stated priority — its "signature" screen with the time-slider), -落笔流程, 句子房间, 考据, 分享卡, 守树人. `我的树` links out to `/read` and `/inscribe` -as placeholders for those routes. +`design/project/` holds prototypes for the rest of the product: 首页书架, 阅读页 (the +design's stated priority — its "signature" screen with the time-slider), 落笔流程, +句子房间, 考据, 分享卡, 守树人. `我的树` and `源氏物语` link out to `/read`, `/inscribe` +and `/kaoju` as placeholders for those routes. diff --git a/src/Genji.css b/src/Genji.css new file mode 100644 index 0000000..ffb085e --- /dev/null +++ b/src/Genji.css @@ -0,0 +1,330 @@ +/* 源氏物语 · 一本书的传记 — page styles. + Same pattern as the unbuilt 老树 · 道德经.dc.html prototype: a hero, then a + vertical timeline of who kept the book alive, revealing node-by-node on + scroll. Kept inside the design 禁则: no counts, no gradients, no glow. */ + +.book-hero { + max-width: 720px; + margin: 0 auto; + padding: 88px 44px 96px 44px; + position: relative; + box-sizing: border-box; +} + +.book-hero-side { + 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; +} + +.book-eyebrow { + font-size: 12px; + color: var(--ink-light); + letter-spacing: 0.3em; + margin-bottom: 32px; +} + +.book-title { + font-family: 'Noto Serif SC', serif; + font-size: 60px; + font-weight: 400; + letter-spacing: 0.18em; + line-height: 1.3; + margin: 0; +} + +.book-meta { + 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; +} + +.book-tagline { + margin-top: 40px; + font-family: 'Noto Serif SC', serif; + font-size: 21px; + letter-spacing: 0.05em; +} + +.book-cta { + margin-top: 36px; + font-size: 13px; + letter-spacing: 0.14em; +} + +/* ---- timeline ---- */ + +.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); +} + +.t-node { + position: relative; + margin-bottom: 76px; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.7s ease, transform 0.7s ease; +} +.t-node.is-visible { + opacity: 1; + transform: none; +} +.t-node:last-child { + margin-bottom: 0; +} + +.t-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; +} + +.t-dot { + position: absolute; + left: -24px; + top: 6px; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.t-who { + font-size: 14px; + font-weight: 500; + letter-spacing: 0.04em; + margin-bottom: 10px; +} + +.t-story { + font-family: 'Noto Serif SC', serif; + font-size: 14.5px; + line-height: 2.05; + letter-spacing: 0.02em; + color: var(--ink); +} + +.t-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; +} + +/* ---- special: a fork in the text ---- */ + +.t-special { + margin-top: 20px; + border: 0.5px solid var(--line); + border-radius: 8px; + padding: 20px 22px; +} + +.t-special-label { + font-size: 11.5px; + letter-spacing: 0.24em; + color: var(--cinnabar); + margin-bottom: 16px; +} + +.t-special-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 24px; + font-family: 'Noto Serif SC', serif; + font-size: 13.5px; + line-height: 1.9; +} + +.t-special-head { + font-size: 11px; + font-family: 'Noto Sans SC', sans-serif; + color: var(--ink-light); + letter-spacing: 0.06em; +} + +.t-special-note { + margin-top: 16px; + font-size: 12.5px; + line-height: 1.9; + color: var(--ink-light); +} + +/* ---- today: reader rings ---- */ + +.t-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); +} + +.t-enter { + margin-top: 10px; + font-size: 13px; + letter-spacing: 0.14em; +} + +/* ---- ask the keeper ---- */ + +.t-ask-off { + margin-top: 14px; + text-align: right; + font-size: 12px; + letter-spacing: 0.08em; +} + +.t-ask-on { + margin-top: 14px; + text-align: right; + font-family: 'Noto Serif SC', serif; + font-size: 12.5px; + color: var(--ink-light); +} + +.ask-cta { + color: var(--cinnabar); + cursor: pointer; +} + +.book-footer { + text-align: center; + padding: 0 44px 96px 44px; + font-size: 12px; + letter-spacing: 0.2em; + color: var(--ink-light); +} + +.ask-keeper { + 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; +} +.ask-keeper:hover { + color: var(--cinnabar); +} + +@media (max-width: 640px) { + .book-hero, + .timeline-wrap { + padding-left: 24px; + padding-right: 24px; + } + .book-hero-side { + display: none; + } + .book-title { + font-size: 42px; + } + .timeline { + padding-left: 96px; + } + .timeline-rail { + left: 80px; + } + .t-year { + left: -96px; + width: 66px; + font-size: 11.5px; + } + .t-special-grid { + grid-template-columns: 1fr; + gap: 4px 0; + } + .t-special-head:nth-child(2) { + margin-top: 8px; + } +} diff --git a/src/Genji.tsx b/src/Genji.tsx new file mode 100644 index 0000000..acfa86d --- /dev/null +++ b/src/Genji.tsx @@ -0,0 +1,370 @@ +import { Fragment, useCallback, useEffect, useRef, useState } from 'react' +import './Genji.css' + +/** + * 源氏物语 · 一本书的传记 (a book's biography) + * + * Port of the same pattern as the unbuilt `老树 · 道德经.dc.html` prototype — + * a book's detail page told as a timeline of who kept it alive, not a blurb. + * Content here is a different theme: Murasaki Shikibu's 11th-century Tale of + * Genji, generally regarded as the world's first great novel. + * + * Each node is a real, dateable moment in the book's transmission. The final + * node is "still being written" and carries reader annotations (年轮), + * mirroring 我的树's 朱砂/青绿 leaf convention: 入木 = enshrined, 青芽 = sprout. + */ + +type SpecialBox = { + /** heading, e.g. "经文在此改变" */ + label: string + columns: [string, string] + rows: [string, string][] + note?: string +} + +type TimelineNode = { + year: string + who: string + story: string + /** a short, safely-quotable anchor — a term or chapter title, not invented prose */ + anchor?: string + /** a moment where the text itself forked or was re-founded */ + special?: SpecialBox + /** marks the final "still being written" node — renders reader rings below it */ + today?: boolean + /** renders the year in cinnabar, for turning points */ + cinnabar?: boolean +} + +type Ring = { + name: string + badge: '注' | '疑' | '证' | '驳' + tag: '入木' | '青芽' + date: string + text: string + wood?: boolean +} + +const NODES: TimelineNode[] = [ + { + year: '约 1008 年', + who: '藤原道长的一句调笑', + story: + '现存最早提到这部小说存在的文字,来自紫式部自己的日记:一条天皇朝的权臣藤原道长路过她的房间,笑着把她比作书中人物。日记没提书名,但后世学者认定,那时《源氏物语》至少已写到了「若紫」一帖。', + anchor: '若紫', + }, + { + year: '平安末期(约 12 世纪)', + who: '无名画师与书家', + story: + '紫式部的亲笔手稿从未留存于世。留下来的最古老的具象证据,是几卷绘卷:词书与大和绘交替,把文字之外的表情画了出来。四卷残卷后来分藏于德川美术馆与五岛美术馆,今天都是日本国宝。', + }, + { + year: '约 1225 年', + who: '藤原定家校订「青表纸本」', + story: + '和歌世家出身的藤原定家,凭个人学识与手头能找到的写本,校定出一部他认为「雅正」的文本,因封面用浅蓝纸而得名「青表纸本」。他未必想过,这份私人校本会成为此后八百年绝大多数印本的祖先。', + }, + { + year: '约 1255 年', + who: '源光行、亲行父子完成「河内本」', + story: + '几乎同一时代,另一支学者广搜诸本、以「存异」而非「求正」为原则,编成「河内本」。镰仓中期它一度比青表纸本流传更广——一部没有作者亲笔的书,从此有了两个说法不完全相同的自己。', + special: { + label: '一部书,两个祖本', + columns: ['青表纸本 · 藤原定家系统', '河内本 · 源光行、亲行系统'], + rows: [ + ['凭个人学识校订,力求「雅正」一种读法', '广搜诸本,宁可「存异」也不轻易删定'], + ['镰仓晚期起渐成主流', '镰仓中期一度流传更广'], + ['今天几乎所有排印本、译本的底本', '多数抄本后世散佚,仅存零篇'], + ], + note: '两支文本字句多有出入——今天读到的「源氏」,其实是一场持续了八百年的校勘选择。', + }, + cinnabar: true, + }, + { + year: '江户中期(18 世纪)', + who: '本居宣长提出「物哀」', + story: + '在此之前,《源氏物语》长期被读作劝善惩恶的教训之书。国学家本居宣长反其道而言:这本书教人的不是道德,而是「知物哀」——感于物而心为之动。这个词后来成了理解整部作品,乃至日本古典美学的钥匙。', + anchor: '物の哀れ', + }, + { + year: '江户时代', + who: '香道座上的「源氏香」', + story: + '贵族女性的香道游戏里,五炷香的组合方式恰有五十二种,被一一对应到五十四帖中除首尾外的五十二个帖名,画成图案。这些「源氏香之图」后来印上和服与家纹,不识字也能在生活里认得这本书。', + }, + { + year: '1925–1933 年', + who: 'Arthur Waley 六卷英译', + story: + '汉学家兼日本学者 Arthur Waley 译出 The Tale of Genji,分六卷陆续出版。英语世界第一次完整读到这部常被称为「世界最早长篇小说」的作品——此后西方每一个新译本,都要先回答:同不同意 Waley 的取舍。', + }, + { + year: '1939 年起,三度重译', + who: '谷崎润一郎的「现代语译」', + story: + '古典日语对多数现代日本人已如外语。小说家谷崎润一郎将全书译为当代日文,此后又两次重译修订,前后跨越二十余年——一位当世文豪,把一千年前的宫廷日语,重新交回给普通读者。', + }, + { + year: '1979–1993 年', + who: '山本和纪《浅梦》连载十四年', + story: + '漫画家やまと和纪把全书画成少女漫画《あさきゆめみし》(浅梦),连载十四年才完结。它让《源氏物语》第一次成为一代日本少女的枕边书——比任何学术译本传播得都远。', + }, + { + year: '2001 年', + who: 'Royall Tyler 新英译本', + story: + '学者 Royall Tyler 推出新译本,附大量脚注,力图还原平安宫廷的官职称谓、四季节令与含蓄措辞。相比 Waley 的意译,这个版本把「难读」也一并译了出来。', + }, + { + year: '2008 年', + who: '「源氏物语千年纪」', + story: + '以 1008 年那则日记为起点,日本各地——尤其是京都——举办了一整年的纪念活动,称这本书满一千岁。一份私人日记里的调笑,一千年后成了一个国家的纪念日。', + cinnabar: true, + }, + { + year: '今 · 2026', + who: '这本书仍在被写下', + story: '时间线的最后一站,是正在生长的年轮。最近入木与新生的批注:', + today: true, + cinnabar: true, + }, +] + +const RINGS: Ring[] = [ + { + name: '沈一苇', + badge: '证', + tag: '入木', + date: '2024', + wood: true, + text: '光源氏一生追的都是「像谁」的人——像已故的桐壶更衣。这两年才懂,他爱的从来不是替身本身,是替身没法替的那部分空缺。', + }, + { + name: '周菡', + badge: '疑', + tag: '青芽', + date: '2025', + text: '物哀,是先知道会散,才敢去爱吗?还是爱到深处,才不得不知道会散?', + }, + { + name: '陈拾', + badge: '注', + tag: '青芽', + date: '2026', + text: '雲隠一帖有目无文——源氏之死,作者干脆不写。空白比任何一句话都更响。', + }, +] + +function useRevealOnScroll(count: number) { + const [seen, setSeen] = useState>({}) + const ioRef = useRef(null) + + useEffect(() => { + const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + if (reduced || !window.IntersectionObserver) { + const all: Record = {} + for (let i = 0; i < count; i++) all[i] = true + setSeen(all) + return + } + ioRef.current = new IntersectionObserver( + (entries) => { + setSeen((prev) => { + let changed = false + const next = { ...prev } + entries.forEach((entry) => { + if (entry.isIntersecting) { + const idx = Number(entry.target.getAttribute('data-node')) + if (!next[idx]) { + next[idx] = true + changed = true + ioRef.current?.unobserve(entry.target) + } + } + }) + return changed ? next : prev + }) + }, + { threshold: 0.12 }, + ) + const observe = () => document.querySelectorAll('[data-node]').forEach((el) => ioRef.current?.observe(el)) + const t1 = setTimeout(observe, 150) + const t2 = setTimeout(observe, 1200) + return () => { + clearTimeout(t1) + clearTimeout(t2) + ioRef.current?.disconnect() + } + }, [count]) + + return seen +} + +export default function Genji() { + const [night, setNight] = useState(false) + const [askOpen, setAskOpen] = useState(null) + const seen = useRevealOnScroll(NODES.length) + + useEffect(() => { + document.title = '老树 · 源氏物语 · 一本书的传记' + }, []) + + const toggleNight = useCallback(() => { + setNight((prev) => { + const next = !prev + if (next) document.body.setAttribute('data-night', '1') + else document.body.removeAttribute('data-night') + return next + }) + }, []) + + return ( +
+
+ + + 老树 + 千年经典的活体年轮库 + + +
+ +
+ +
書架 · 日本古典 · 物語
+

源氏物語

+
+ 成书于 11 世纪初 · 五十四帖 · 已生长约 1,020 年 · 世界最早的长篇小说之一 +
+
五十四帖,一千年读不尽。
+ +
+ +
+
一本书的传记
+
+
+ {NODES.map((n, i) => { + const visible = !!seen[i] + return ( +
+
+ {n.year} +
+
+
{n.who}
+
{n.story}
+ + {n.anchor &&
{n.anchor}
} + + {n.special && ( +
+
{n.special.label}
+
+
{n.special.columns[0]}
+
{n.special.columns[1]}
+ {n.special.rows.map((row, ri) => ( + +
{row[0]}
+
{row[1]}
+
+ ))} +
+ {n.special.note &&
{n.special.note}
} +
+ )} + + {n.today && ( +
+ {RINGS.map((r, ri) => ( +
+
+ + {r.name} + + + {r.badge} + + + {r.tag} + + {r.date} +
+
{r.text}
+
+ ))} + +
+ )} + + {!n.today && + (askOpen === i ? ( +
+ 读是自由的。落笔与问路,需要一个名字。 → 留下名字 +
+ ) : ( +
+ setAskOpen(i)}> + 与守树人细究此节 → + +
+ ))} +
+ ) + })} +
+
+ +
一千年过去,物哀未散。
+ + + 问守树人 + +
+ ) +} diff --git a/src/MyTree.css b/src/MyTree.css index b2d034f..a00f41d 100644 --- a/src/MyTree.css +++ b/src/MyTree.css @@ -27,55 +27,6 @@ body[data-night='1']::before { opacity: 0.35; } -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 26px 44px; -} - -.brand { - display: flex; - align-items: center; - gap: 12px; - text-decoration: none; -} - -.brand-name { - font-family: 'Noto Serif SC', serif; - font-size: 17px; - font-weight: 500; - letter-spacing: 0.3em; - color: var(--ink); -} - -.nav { - display: flex; - gap: 28px; - font-size: 13px; - color: var(--ink-light); -} - -/* Quiet links that warm to full ink on hover. */ -.link-muted { - color: var(--ink-light); - text-decoration: none; - cursor: pointer; - transition: color 0.3s ease; -} -.link-muted:hover { - color: var(--ink); -} - -.link-cinnabar { - color: var(--cinnabar); - text-decoration: none; - transition: color 0.3s ease; -} -.link-cinnabar:hover { - color: var(--seal); -} - .stage { max-width: 640px; margin: 0 auto; @@ -406,7 +357,6 @@ body[data-night='1']::before { } @media (max-width: 520px) { - .header, .stage { padding-left: 24px; padding-right: 24px; diff --git a/src/MyTree.tsx b/src/MyTree.tsx index e773e3c..e0b63e2 100644 --- a/src/MyTree.tsx +++ b/src/MyTree.tsx @@ -178,6 +178,9 @@ export default function MyTree() { 老树