Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions APP_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ recreated pixel-for-pixel outside the proprietary `dc-runtime` format.

## What the page is

A reader may grow more than one tree, one per book. Below the reader's name
sits a quiet switcher — book title + a one-line attribution (author, era) —
that swaps which tree is drawn, with no page navigation. Two books are grown
so far: 《道德经》(老子) and 《源氏物语》(紫式部, 五十四帖 of it, five
branches drawn). All book content — branches, leaf states, annotations, the
周年回声 — lives in `src/books.ts` as a `Book` record; `MyTree.tsx` itself
has no book-specific content, so adding a new themed book (a sutra, a
philosophy text, another epic) means adding one entry there.

Each reader grows an abstract tree:

- **枝 (branches)** — chapters read, labelled with their number (十一, 八, 四十四…).
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ growing tree.

## 我的树 (My Tree)

A reader may grow more than one tree — one per book. A switcher below the
reader's name moves between them without leaving the page. Two are grown so
far: **《道德经》** (老子, planted 2023) and **《源氏物语》** (紫式部, planted
2024), each with its own planting date, chapters, and annotations. Adding a
new themed book is a matter of adding one entry to `src/books.ts` — the page
component itself carries no book-specific content.

Each reader grows a tree:

- **枝 (branches)** — chapters read, labelled with their number (十一, 八, 四十四…).
Expand Down Expand Up @@ -42,7 +49,8 @@ npm run preview # serve the production build

| Path | What |
| --- | --- |
| `src/MyTree.tsx` | The 我的树 page component + its branch/annotation data |
| `src/MyTree.tsx` | The 我的树 page component (book-agnostic) |
| `src/books.ts` | Per-book tree data — branches, annotations, echo. Add a book here. |
| `src/MyTree.css` | Page layout, the SVG tree, hover reveal, growth rings |
| `src/theme.css` | Design tokens (墨字朱批 palette), day/night themes, keyframes |
| `APP_NOTES.md` | Detailed notes on this page |
Expand Down
45 changes: 45 additions & 0 deletions src/MyTree.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,51 @@ body[data-night='1']::before {
letter-spacing: 0.08em;
}

/* ---- book switcher — a reader may grow more than one tree ---- */

.book-switch {
display: flex;
justify-content: center;
gap: 36px;
margin-top: 30px;
}

.book-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: none;
border: none;
border-bottom: 1px solid transparent;
padding: 0 0 8px 0;
cursor: pointer;
font-family: 'Noto Serif SC', serif;
color: var(--ink-light);
transition: color 0.3s ease, border-color 0.3s ease;
}
.book-tab:hover {
color: var(--ink);
}
.book-tab.is-current {
color: var(--ink);
border-bottom-color: var(--cinnabar);
}
.book-tab-title {
font-size: 14px;
letter-spacing: 0.08em;
}
.book-tab-subtitle {
font-family: Spectral, 'Noto Sans SC', serif;
font-feature-settings: 'onum';
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--ink-light);
}
.book-tab.is-current .book-tab-subtitle {
color: var(--verdigris);
}

/* ---- the tree ---- */

.tree-stage {
Expand Down
205 changes: 67 additions & 138 deletions src/MyTree.tsx
Original file line number Diff line number Diff line change
@@ -1,133 +1,24 @@
import { useCallback, useState } from 'react'
import './MyTree.css'
import { BOOKS, BOOK_ORDER, LEAF_COLOR, type Branch } from './books'

/**
* One branch of the abstract tree.
* 我的树 renders one book's tree at a time.
* 枝 = a chapter read; 叶 (leaf) = an annotation left;
* 朱砂 leaf = 入木 (enshrined, alongside the ancients);
* 青绿 leaf = 青芽 (a sprout, awaiting time & peers);
* thread = someone continued your line across the years.
*
* Hovering / focusing a branch quietly surfaces what you wrote there — the
* page's one concession to 探索欲, kept as an inline caption, never a bubble.
* A reader may grow more than one tree — the switcher below the eyebrow
* moves between books without leaving the page.
*/
type LeafKind = '' | 'cinnabar' | 'verdigris'
type State = '入木' | '青芽' | '读过'

type Branch = {
id: string
/** chapter label (Chinese numeral) shown on the branch */
label: string
/** chapter name for the caption */
chapter: string
/** SVG path drawn on as the branch grows */
d: string
/** stroke width — upper (younger) branches read thinner */
w: number
leaf: LeafKind
leafAt?: { x: number; y: number }
labelAt: { x: number; y: number; anchor: 'start' | 'end' }
drawDelay: string
leafDelay: string
state: State
/** 注/疑/证/驳 — only annotated branches carry a genre */
genre?: string
/** the anchored line of scripture */
source?: string
/** the annotation itself */
note?: string
/** cinnabar signature + date */
sign?: string
/** a verdigris "someone continued you" thread hangs off this branch */
thread?: { d: string; at: { x: number; y: number }; label: string }
}

// Geometry hand-tuned in a 440×360 space for an organic, drawn look.
// Lower branches are longer/older; higher branches shorter/younger.
const BRANCHES: Branch[] = [
{
id: 'ch11',
label: '十一',
chapter: '第十一章',
d: 'M 220 250 Q 276 234 332 231',
w: 1.5,
leaf: 'cinnabar',
leafAt: { x: 332, y: 231 },
labelAt: { x: 342, y: 235, anchor: 'start' },
drawDelay: '0.9s',
leafDelay: '1.7s',
state: '入木',
genre: '证',
source: '当其无,有器之用。',
note: '学陶那年最废的一只碗,是我捏得最厚的那只——几乎没有「无」,盛不下什么。',
sign: '沈一苇 · 2023 冬',
thread: { d: 'M 332 231 L 374 205', at: { x: 378, y: 201 }, label: '陈拾 · 3 年后接续' },
},
{
id: 'ch8',
label: '八',
chapter: '第八章',
d: 'M 220 214 Q 168 202 116 196',
w: 1.4,
leaf: '',
labelAt: { x: 106, y: 200, anchor: 'end' },
drawDelay: '1.15s',
leafDelay: '1.7s',
state: '读过',
source: '上善若水。',
},
{
id: 'ch44',
label: '四十四',
chapter: '第四十四章',
d: 'M 221 176 Q 268 166 312 162',
w: 1.3,
leaf: 'verdigris',
leafAt: { x: 312, y: 162 },
labelAt: { x: 322, y: 166, anchor: 'start' },
drawDelay: '1.4s',
leafDelay: '2.0s',
state: '青芽',
genre: '证',
source: '知足不辱,知止不殆。',
note: '辞掉第二份工作的那晚我写:知道停在哪里,比知道去哪里难。',
sign: '沈一苇 · 2025 春',
},
{
id: 'ch22',
label: '二十二',
chapter: '第二十二章',
d: 'M 220 146 Q 178 138 138 134',
w: 1.2,
leaf: '',
labelAt: { x: 128, y: 138, anchor: 'end' },
drawDelay: '1.65s',
leafDelay: '2.2s',
state: '读过',
source: '曲则全,枉则直。',
},
{
id: 'ch64',
label: '六十四',
chapter: '第六十四章',
d: 'M 221 116 Q 256 108 288 105',
w: 1.1,
leaf: '',
labelAt: { x: 298, y: 109, anchor: 'start' },
drawDelay: '1.9s',
leafDelay: '2.4s',
state: '读过',
source: '千里之行,始于足下。',
},
]

const LEAF_COLOR: Record<Exclude<LeafKind, ''>, string> = {
cinnabar: 'var(--cinnabar)',
verdigris: 'var(--verdigris)',
}
const READER_NAME = '沈一苇'

/** A hovered branch's detail, or the resting hint. */
function BranchDetail({ branch }: { branch: Branch | null }) {
function BranchDetail({ branch, bookTitle }: { branch: Branch | null; bookTitle: string }) {
if (!branch) {
return <div className="detail detail-rest">悬停一枝,看你在那一章写下的。</div>
}
Expand All @@ -139,7 +30,9 @@ function BranchDetail({ branch }: { branch: Branch | null }) {
{branch.state !== '读过' && <span className="state-dot" />}
{branch.state === '读过' ? '读过 · 尚未落笔' : branch.state}
</span>
<span className="detail-chapter">道德经 · {branch.chapter}</span>
<span className="detail-chapter">
{bookTitle} · {branch.chapter}
</span>
{branch.genre && <span className="genre">{branch.genre}</span>}
</div>
{branch.source && <div className="detail-source">{branch.source}</div>}
Expand All @@ -152,6 +45,7 @@ function BranchDetail({ branch }: { branch: Branch | null }) {
export default function MyTree() {
// Night ("夜读") is a whole-page atmosphere; the prototype toggled a body attribute.
const [night, setNight] = useState(false)
const [bookId, setBookId] = useState(BOOK_ORDER[0])
const [active, setActive] = useState<string | null>(null)

const toggleNight = useCallback(() => {
Expand All @@ -163,7 +57,14 @@ export default function MyTree() {
})
}, [])

const activeBranch = BRANCHES.find((b) => b.id === active) ?? null
const book = BOOKS[bookId]
const activeBranch = book.branches.find((b) => b.id === active) ?? null
const echoBranch = book.branches.find((b) => b.id === book.echo.branchId) ?? null

const selectBook = useCallback((id: string) => {
setBookId(id)
setActive(null)
}, [])

return (
<div className="page">
Expand Down Expand Up @@ -200,11 +101,37 @@ export default function MyTree() {

<main className="stage" data-screen-label="我的树">
<div className="eyebrow">我的树</div>
<div className="tree-name">沈一苇</div>
<div className="tree-planted">种于 2023 年冬</div>
<div className="tree-name">{READER_NAME}</div>

<div className="book-switch" role="tablist" aria-label="选一棵树">
{BOOK_ORDER.map((id) => {
const b = BOOKS[id]
const isCurrent = id === bookId
return (
<button
key={id}
type="button"
role="tab"
aria-selected={isCurrent}
className={`book-tab${isCurrent ? ' is-current' : ''}`}
onClick={() => selectBook(id)}
>
<span className="book-tab-title">{b.title}</span>
<span className="book-tab-subtitle">{b.subtitle}</span>
</button>
)
})}
</div>

<div className="tree-planted">{book.planted}</div>

<div className="tree-stage" onMouseLeave={() => setActive(null)}>
<svg viewBox="0 0 440 360" className="tree-svg" role="img" aria-label="沈一苇的树:五枝,一枚入木、一枚青芽。">
<svg
viewBox="0 0 440 360"
className="tree-svg"
role="img"
aria-label={`${READER_NAME}在《${book.title}》里的树:五枝,一枚入木、一枚青芽。`}
>
{/* 年轮 — growth rings at the root, echoing the mark and the metaphor */}
<g className="rings">
<ellipse cx="220" cy="320" rx="70" ry="13" className="ring" style={{ animationDelay: '0.3s' }} />
Expand All @@ -213,7 +140,7 @@ export default function MyTree() {
</g>
<line x1="150" y1="320" x2="290" y2="320" className="ground" />

<g className="sway">
<g className="sway" key={book.id}>
{/* trunk, drawn upward from the ground on load */}
<path
d="M 220 320 C 214 252, 226 178, 222 96"
Expand All @@ -222,15 +149,15 @@ export default function MyTree() {
style={{ animationDelay: '0.15s' }}
/>

{BRANCHES.map((b) => {
{book.branches.map((b) => {
const isActive = active === b.id
return (
<g
key={b.id}
className={`branch-group${isActive ? ' is-active' : ''}`}
tabIndex={0}
role="button"
aria-label={`道德经 ${b.chapter},${b.state === '读过' ? '读过,尚未落笔' : b.state}`}
aria-label={`${book.title} ${b.chapter},${b.state === '读过' ? '读过,尚未落笔' : b.state}`}
onMouseEnter={() => setActive(b.id)}
onFocus={() => setActive(b.id)}
onBlur={() => setActive(null)}
Expand Down Expand Up @@ -288,28 +215,30 @@ export default function MyTree() {
</svg>
</div>

<BranchDetail branch={activeBranch} />
<BranchDetail branch={activeBranch} bookTitle={book.title} />

<div className="legend">
枝,是读过的章。叶,是留下的年轮。
<br />
朱砂叶已入木,与古人同列。细线,是有人接续了你。
</div>

<section className="echo">
<div className="echo-label">周年回声</div>
<div className="echo-lead">一年前的你,在「知足不辱,知止不殆」之下写过一枚青芽。</div>
<div className="echo-quote">辞掉第二份工作的那晚我写:知道停在哪里,比知道去哪里难。</div>
<div className="echo-ask">现在的你,还这么读吗?</div>
<div className="echo-actions">
<a className="link-cinnabar" href="/read">
回去看看 →
</a>
<a className="link-muted" href="/inscribe">
接续一年前的自己 →
</a>
</div>
</section>
{echoBranch && (
<section className="echo" key={book.id}>
<div className="echo-label">周年回声</div>
<div className="echo-lead">{book.echo.lead}</div>
<div className="echo-quote">{book.echo.quote}</div>
<div className="echo-ask">{book.echo.ask}</div>
<div className="echo-actions">
<a className="link-cinnabar" href="/read">
回去看看 →
</a>
<a className="link-muted" href="/inscribe">
接续一年前的自己 →
</a>
</div>
</section>
)}
</main>
</div>
)
Expand Down
Loading