hd-garden 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,7 @@ export const metadata = {
23
23
 
24
24
  export default function AboutPage() {
25
25
  return (
26
- <main className="page-shell about-page">
26
+ <main className="sh-main about-page" id="content">
27
27
  <PageIntro
28
28
  eyebrow={t.aboutEyebrow}
29
29
  title={about.title}
@@ -1,34 +1,18 @@
1
- import Link from "next/link";
2
- import { CoverImage } from "../../components/CoverImage";
3
1
  import type { Metadata } from "next";
4
2
  import { FEED_ALTERNATES } from "../../lib/page-alternates";
5
3
  import { notFound } from "next/navigation";
6
4
  import {
7
- extractHeadings,
8
- estimateWordCount,
9
5
  findPost,
10
- formatDate,
11
6
  postHref,
12
7
  posts,
13
8
  } from "../../lib/content";
14
- import { ArticleMeasure } from "../../components/ArticleMeasure";
15
- import { ArticleReferences } from "../../components/ArticleReferences";
16
- import { ArticleTitle, originalUrl } from "../../components/ArticleTitle";
17
- import { MarkdownArticle } from "../../components/MarkdownArticle";
18
- import { TableOfContents } from "../../components/TableOfContents";
19
- import { Comments } from "../../components/Comments";
20
- import { Webmentions } from "../../components/Webmentions";
21
- import { ArticleTags } from "../../components/ArticleTags";
22
- import { ArticlePager } from "../../components/ArticlePager";
9
+ import { PostReadingView } from "../../components/PostReadingView";
23
10
  import { adjacentByDate } from "../../../ui/garden/adjacent.mjs";
24
11
  import {
25
12
  absoluteUrl,
26
13
  getSiteUrl,
27
- webmentionApiEndpoint,
28
14
  } from "../../lib/site";
29
- import { githubUrl, siteConfig } from "../../site.config";
30
15
  import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
31
- import { siteLang, ui } from "../../lib/i18n";
32
16
 
33
17
  export function generateStaticParams() {
34
18
  return staticParams(posts.map((post) => ({ path: post.path.split("/") })), { path: [STATIC_PLACEHOLDER] });
@@ -80,72 +64,19 @@ export default async function PostPage({
80
64
  if (isStaticPlaceholder(path)) return null;
81
65
  notFound();
82
66
  }
83
- const headings = extractHeadings(post.content, { numbering: post.numbering });
84
67
  const baseUrl = await getSiteUrl();
85
68
  const pathname = postHref(post);
86
69
  const canonicalUrl = absoluteUrl(pathname, baseUrl);
87
- const wordCount = estimateWordCount(post.content);
88
70
  const { prev, next } = adjacentByDate(posts, (item: { path: string }) => item.path === post.path);
89
- const t = ui();
90
71
 
91
72
  return (
92
- <main className="reading-page post-reading-page">
93
- <article className="post-article h-entry">
94
- <Link className="u-url sr-only" href={pathname}>
95
- {siteLang === "en" ? t.permalinkLabel(post.title) : <>{post.title} 永久链接</>}
96
- </Link>
97
- <a
98
- className="p-author h-card sr-only"
99
- href={githubUrl}
100
- rel="author"
101
- >
102
- {siteConfig.author.name}
103
- </a>
104
- <header className="article-header article-summary-row hero">
105
- <ArticleTitle title={post.title} />
106
- <ul className="meta article-meta">
107
- <li>{siteConfig.author.name}</li>
108
- <li>
109
- <time className="dt-published" dateTime={post.date}>
110
- {formatDate(post.date)}
111
- </time>
112
- </li>
113
- {post.updated ? <li>{siteLang === "en" ? t.updatedOn(formatDate(post.updated)) : <>更新于 {formatDate(post.updated)}</>}</li> : null}
114
- <li>{t.wordCount(wordCount)}</li>
115
- {originalUrl(post) ? (
116
- <li>
117
- <a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">{t.originalLink}</a>
118
- </li>
119
- ) : null}
120
- </ul>
121
- <ArticleTags tags={post.tags} />
122
- {post.cover && (
123
- <figure className="article-hero article-cover cover-card cover-img">
124
- <CoverImage className="u-photo" src={post.cover} image={post.coverImage} alt={t.coverLabel(post.title)} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
125
- </figure>
126
- )}
127
- </header>
128
- <div className="article-reading-layout">
129
- <div className="article-reading-column">
130
- <ArticleMeasure />
131
- <div className="e-content">
132
- {post.description ? <p className="lead quote-plain p-summary">{post.description}</p> : null}
133
- <MarkdownArticle content={post.content} numbering={post.numbering} />
134
- </div>
135
- <ArticleReferences references={post.references} />
136
- <Webmentions
137
- target={canonicalUrl}
138
- endpoint={webmentionApiEndpoint()}
139
- />
140
- <ArticlePager
141
- prev={prev ? { href: postHref(prev), title: prev.title } : null}
142
- next={next ? { href: postHref(next), title: next.title } : null}
143
- />
144
- <Comments slug={post.path} />
145
- </div>
146
- <TableOfContents headings={headings} />
147
- </div>
148
- </article>
149
- </main>
73
+ <PostReadingView
74
+ post={post}
75
+ pathname={pathname}
76
+ canonicalUrl={canonicalUrl}
77
+ commentsSlug={post.path}
78
+ prev={prev ? { href: postHref(prev), title: prev.title } : null}
79
+ next={next ? { href: postHref(next), title: next.title } : null}
80
+ />
150
81
  );
151
82
  }
package/app/blog/page.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Suspense } from "react";
2
2
  import { siteConfig, siteDocumentTitle } from "../site.config";
3
3
  import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
4
+ import { PageIntro } from "../components/PageIntro";
4
5
  import { posts } from "../lib/content";
5
6
  import { archivePosts, archiveSeries } from "../lib/archive";
6
7
  import { pageAlternates } from "../lib/page-alternates";
@@ -16,12 +17,12 @@ export default function BlogIndex() {
16
17
  const t = ui();
17
18
  const list = archivePosts();
18
19
  const series = archiveSeries();
20
+ const deck =
21
+ siteConfig.pages.blog.subtitle?.trim() ||
22
+ (siteLang === "en" ? t.blogDeck(posts.length) : `阶段性的完整沉淀,关于工程、架构与技术思考。共 ${posts.length} 篇,按年份归档。`);
19
23
  return (
20
24
  <main className="sh-main" id="content">
21
- <div className="sh-head">
22
- <h1 id="sh-title">{t.blogHeading}</h1>
23
- <p className="sh-deck">{siteLang === "en" ? t.blogDeck(posts.length) : <>阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</>}</p>
24
- </div>
25
+ <PageIntro eyebrow={t.blogEyebrow} title={t.blogHeading} subtitle={deck} />
25
26
  <Suspense fallback={<BlogArchiveFallback posts={list} series={series} />}>
26
27
  <BlogArchive posts={list} series={series} />
27
28
  </Suspense>
@@ -1,35 +1,21 @@
1
- import Link from "next/link";
2
- import { ArticlePager } from "../../../components/ArticlePager";
3
- import { CoverImage } from "../../../components/CoverImage";
4
1
  import type { Metadata } from "next";
5
2
  import { FEED_ALTERNATES } from "../../../lib/page-alternates";
6
3
  import { notFound } from "next/navigation";
7
- import { ArrowLeft } from "@phosphor-icons/react/ssr";
8
4
  import {
9
5
  columns,
10
- extractHeadings,
11
6
  findColumnEntry,
12
- formatDate,
13
7
  columnHref,
14
8
  columnEntryPath,
15
9
  } from "../../../lib/content";
16
- import { ArticleMeasure } from "../../../components/ArticleMeasure";
17
- import { ArticleReferences } from "../../../components/ArticleReferences";
18
- import { ArticleTitle, originalUrl } from "../../../components/ArticleTitle";
19
- import { MarkdownArticle } from "../../../components/MarkdownArticle";
20
- import { TableOfContents } from "../../../components/TableOfContents";
21
- import { Comments } from "../../../components/Comments";
22
- import { Webmentions } from "../../../components/Webmentions";
23
- import { ArticleTags } from "../../../components/ArticleTags";
10
+ import { PostReadingView } from "../../../components/PostReadingView";
24
11
  import {
25
12
  absoluteUrl,
26
13
  getSiteUrl,
27
- webmentionApiEndpoint,
28
14
  } from "../../../lib/site";
29
- import { githubUrl, siteConfig } from "../../../site.config";
30
- import { siteHref } from "../../../lib/site-href";
31
15
  import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
32
- import { columnStatusLabel, siteLang, ui } from "../../../lib/i18n";
16
+ import { columnStatusLabel, ui } from "../../../lib/i18n";
17
+
18
+ const stripColumnIndex = (title: string) => title.replace(/^\d+\s*·\s*/u, "");
33
19
 
34
20
  export function generateStaticParams() {
35
21
  return staticParams(
@@ -51,14 +37,15 @@ export async function generateMetadata({
51
37
  if (!entry) return {};
52
38
  const baseUrl = await getSiteUrl();
53
39
  const pathname = columnHref(entry);
40
+ const title = stripColumnIndex(entry.title);
54
41
  return {
55
- title: entry.title.replace(/^\d+\s*·\s*/u, ""),
42
+ title,
56
43
  description: entry.description,
57
44
  alternates: { canonical: pathname, types: FEED_ALTERNATES },
58
45
  openGraph: {
59
46
  type: "article",
60
47
  url: absoluteUrl(pathname, baseUrl),
61
- title: entry.title.replace(/^\d+\s*·\s*/u, ""),
48
+ title,
62
49
  description: entry.description,
63
50
  publishedTime: new Date(entry.date).toISOString(),
64
51
  modifiedTime: entry.updated
@@ -69,7 +56,7 @@ export async function generateMetadata({
69
56
  },
70
57
  twitter: {
71
58
  card: "summary_large_image",
72
- title: entry.title.replace(/^\d+\s*·\s*/u, ""),
59
+ title,
73
60
  description: entry.description,
74
61
  images: entry.cover ? [entry.cover] : ["/og.png"],
75
62
  },
@@ -92,81 +79,32 @@ export default async function ColumnEntryPage({
92
79
  const currentIndex = series.findIndex((item) => item.path === entry.path);
93
80
  const prev = series[currentIndex - 1] ?? null;
94
81
  const next = series[currentIndex + 1] ?? null;
95
- const headings = extractHeadings(entry.content, { numbering: entry.numbering });
96
82
  const baseUrl = await getSiteUrl();
97
83
  const pathname = columnHref(entry);
98
84
  const canonicalUrl = absoluteUrl(pathname, baseUrl);
99
85
  const t = ui();
86
+ const displayTitle = stripColumnIndex(entry.title);
100
87
 
101
88
  return (
102
- <main className="column-reading-page">
103
- <aside className="series-nav">
104
- <Link href={siteHref("/columns")} className="back-link">
105
- <ArrowLeft size={15} />
106
- {t.allColumnsBack}
107
- </Link>
108
- <p>{t.seriesEyebrow}</p>
109
- <h2>{entry.columnTitle}</h2>
110
- <ol>
111
- {listed.map((item) => (
112
- <li key={item.path} className={item.path === entry.path ? "active" : ""}>
113
- <Link href={columnHref(item)}>{item.title}</Link>
114
- </li>
115
- ))}
116
- </ol>
117
- <small>{ui().columnSideCount(series.length, columnStatusLabel(entry.columnStatus))}</small>
118
- </aside>
119
-
120
- <article className="article-column series-article h-entry">
121
- <Link className="u-url sr-only" href={pathname}>
122
- {siteLang === "en" ? t.permalinkLabel(entry.title) : <>{entry.title} 永久链接</>}
123
- </Link>
124
- <a
125
- className="p-author h-card sr-only"
126
- href={githubUrl}
127
- rel="author"
128
- >
129
- {siteConfig.author.name}
130
- </a>
131
- <header className="article-header hero">
132
- <ArticleTitle title={entry.title.replace(/^\d+\s*·\s*/u, "")} />
133
- <ul className="meta article-meta">
134
- <li>{siteConfig.author.name}</li>
135
- <li>
136
- <time className="dt-published" dateTime={entry.date}>
137
- {formatDate(entry.date)}
138
- </time>
139
- </li>
140
- {originalUrl(entry) ? (
141
- <li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">{t.originalLink}</a></li>
142
- ) : null}
143
- </ul>
144
- <ArticleTags tags={entry.tags} />
145
- {entry.cover && (
146
- <figure className="article-hero article-cover cover-img">
147
- <CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={siteLang === "en" ? t.coverLabel(entry.title) : `${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
148
- </figure>
149
- )}
150
- </header>
151
- <ArticleMeasure />
152
- <div className="e-content">
153
- {entry.description ? <p className="lead p-summary">{entry.description}</p> : null}
154
- <MarkdownArticle content={entry.content} numbering={entry.numbering} />
155
- </div>
156
- <ArticleReferences references={entry.references} />
157
- <Webmentions
158
- target={canonicalUrl}
159
- endpoint={webmentionApiEndpoint()}
160
- />
161
- <ArticlePager
162
- label={ui().columnPagerLabel}
163
- prev={prev ? { href: columnHref(prev), title: prev.title } : null}
164
- next={next ? { href: columnHref(next), title: next.title } : null}
165
- />
166
- <Comments slug={`${column}:${entry.path}`} />
167
- </article>
168
-
169
- <TableOfContents headings={headings} />
170
- </main>
89
+ <PostReadingView
90
+ post={entry}
91
+ pathname={pathname}
92
+ canonicalUrl={canonicalUrl}
93
+ title={displayTitle}
94
+ commentsSlug={`${column}:${entry.path}`}
95
+ pagerLabel={t.columnPagerLabel}
96
+ prev={prev ? { href: columnHref(prev), title: stripColumnIndex(prev.title) } : null}
97
+ next={next ? { href: columnHref(next), title: stripColumnIndex(next.title) } : null}
98
+ series={{
99
+ columnTitle: entry.columnTitle ?? column,
100
+ statusLabel: t.columnSideCount(series.length, columnStatusLabel(entry.columnStatus)),
101
+ items: listed.map((item) => ({
102
+ path: item.path,
103
+ href: columnHref(item),
104
+ title: stripColumnIndex(item.title),
105
+ current: item.path === entry.path,
106
+ })),
107
+ }}
108
+ />
171
109
  );
172
110
  }
@@ -2,6 +2,7 @@
2
2
  import Link from "next/link";
3
3
  import { columnHref, columns } from "../lib/content";
4
4
  import { ContentEmptyState } from "../components/ContentEmptyState";
5
+ import { PageIntro } from "../components/PageIntro";
5
6
  import { siteConfig, siteDocumentTitle } from "../site.config";
6
7
  import { pageAlternates } from "../lib/page-alternates";
7
8
  import { columnStatusLabel, ui } from "../lib/i18n";
@@ -40,10 +41,11 @@ export default function ColumnsPage() {
40
41
 
41
42
  return (
42
43
  <main className="sh-main" id="content">
43
- <div className="sh-head">
44
- <h1>{siteConfig.pages.columns.title || t.homeColumns}</h1>
45
- {siteConfig.pages.columns.subtitle ? <p className="sh-deck">{siteConfig.pages.columns.subtitle}</p> : null}
46
- </div>
44
+ <PageIntro
45
+ eyebrow={t.columnsEyebrow}
46
+ title={siteConfig.pages.columns.title || t.homeColumns}
47
+ subtitle={siteConfig.pages.columns.subtitle}
48
+ />
47
49
  {grouped.length ? grouped.map(({ slug, first, entries }) => (
48
50
  <section className={first.columnCover ? "sh-series" : "sh-series no-cover"} aria-labelledby={slug} key={slug}>
49
51
  {first.columnCover ? (
@@ -7,15 +7,20 @@ type PageIntroProps = {
7
7
  children?: ReactNode;
8
8
  };
9
9
 
10
- /** Shared heading treatment for every top-level public page. */
10
+ /**
11
+ * Shared heading for top-level list pages (blog / thoughts / columns).
12
+ * Same eyebrow + h1 + deck typography and padding so tab switches do not jump.
13
+ * Empty subtitle reserves no space.
14
+ */
11
15
  export function PageIntro({
12
16
  eyebrow,
13
17
  title,
14
18
  subtitle,
15
19
  children,
16
20
  }: PageIntroProps) {
21
+ const deck = subtitle?.trim();
17
22
  return (
18
- <header className="page-intro">
23
+ <header className="page-intro sh-head">
19
24
  {eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
20
25
  <h1>
21
26
  {title.split("\n").map((line, index) => (
@@ -25,8 +30,8 @@ export function PageIntro({
25
30
  </span>
26
31
  ))}
27
32
  </h1>
28
- {subtitle && <p>{subtitle}</p>}
29
- {children && <div className="page-intro-actions">{children}</div>}
33
+ {deck ? <p className="sh-deck">{deck}</p> : null}
34
+ {children ? <div className="page-intro-actions">{children}</div> : null}
30
35
  </header>
31
36
  );
32
37
  }
@@ -0,0 +1,133 @@
1
+ import Link from "next/link";
2
+ import { CoverImage } from "./CoverImage";
3
+ import { ArticleMeasure } from "./ArticleMeasure";
4
+ import { ArticleReferences } from "./ArticleReferences";
5
+ import { ArticleTitle, originalUrl } from "./ArticleTitle";
6
+ import { MarkdownArticle } from "./MarkdownArticle";
7
+ import { TableOfContents } from "./TableOfContents";
8
+ import { Comments } from "./Comments";
9
+ import { Webmentions } from "./Webmentions";
10
+ import { ArticleTags } from "./ArticleTags";
11
+ import { ArticlePager } from "./ArticlePager";
12
+ import { SeriesEdgeNav, type SeriesEdgeItem } from "./SeriesEdgeNav";
13
+ import { formatDate, estimateWordCount, extractHeadings } from "../lib/content";
14
+ import { githubUrl, siteConfig } from "../site.config";
15
+ import { siteLang, ui } from "../lib/i18n";
16
+ import { webmentionApiEndpoint } from "../lib/site";
17
+ import type { ContentEntry } from "../lib/content";
18
+
19
+ export type PostReadingPagerLink = { href: string; title: string } | null;
20
+
21
+ export type PostReadingSeries = {
22
+ columnTitle: string;
23
+ statusLabel?: string;
24
+ items: SeriesEdgeItem[];
25
+ };
26
+
27
+ /**
28
+ * Shared article body for /blog/... and /columns/... .
29
+ * Column routes only differ by mounting SeriesEdgeNav on the left.
30
+ */
31
+ export function PostReadingView({
32
+ post,
33
+ pathname,
34
+ canonicalUrl,
35
+ title,
36
+ commentsSlug,
37
+ prev,
38
+ next,
39
+ pagerLabel,
40
+ series,
41
+ }: {
42
+ post: ContentEntry;
43
+ pathname: string;
44
+ canonicalUrl: string;
45
+ /** Display title; defaults to post.title */
46
+ title?: string;
47
+ commentsSlug: string;
48
+ prev?: PostReadingPagerLink;
49
+ next?: PostReadingPagerLink;
50
+ pagerLabel?: string;
51
+ series?: PostReadingSeries;
52
+ }) {
53
+ const displayTitle = title ?? post.title;
54
+ const numbering = (post as ContentEntry & { numbering?: boolean }).numbering;
55
+ const headings = extractHeadings(post.content, { numbering });
56
+ const wordCount = estimateWordCount(post.content);
57
+ const t = ui();
58
+
59
+ return (
60
+ <main className="reading-page post-reading-page">
61
+ {series ? (
62
+ <SeriesEdgeNav
63
+ columnTitle={series.columnTitle}
64
+ statusLabel={series.statusLabel}
65
+ items={series.items}
66
+ />
67
+ ) : null}
68
+ <article className="post-article h-entry">
69
+ <Link className="u-url sr-only" href={pathname}>
70
+ {siteLang === "en" ? t.permalinkLabel(displayTitle) : <>{displayTitle} 永久链接</>}
71
+ </Link>
72
+ <a className="p-author h-card sr-only" href={githubUrl} rel="author">
73
+ {siteConfig.author.name}
74
+ </a>
75
+ <header className="article-header article-summary-row hero">
76
+ <ArticleTitle title={displayTitle} />
77
+ <ul className="meta article-meta">
78
+ <li>{siteConfig.author.name}</li>
79
+ <li>
80
+ <time className="dt-published" dateTime={post.date}>
81
+ {formatDate(post.date)}
82
+ </time>
83
+ </li>
84
+ {post.updated ? (
85
+ <li>
86
+ {siteLang === "en" ? t.updatedOn(formatDate(post.updated)) : <>更新于 {formatDate(post.updated)}</>}
87
+ </li>
88
+ ) : null}
89
+ <li>{t.wordCount(wordCount)}</li>
90
+ {originalUrl(post) ? (
91
+ <li>
92
+ <a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">
93
+ {t.originalLink}
94
+ </a>
95
+ </li>
96
+ ) : null}
97
+ </ul>
98
+ <ArticleTags tags={post.tags} />
99
+ {post.cover ? (
100
+ <figure className="article-hero article-cover cover-card cover-img">
101
+ <CoverImage
102
+ className="u-photo"
103
+ src={post.cover}
104
+ image={post.coverImage}
105
+ alt={t.coverLabel(displayTitle)}
106
+ sizes="(max-width: 852px) calc(100vw - 40px), 812px"
107
+ priority
108
+ />
109
+ </figure>
110
+ ) : null}
111
+ </header>
112
+ <div className="article-reading-layout">
113
+ <div className="article-reading-column">
114
+ <ArticleMeasure />
115
+ <div className="e-content">
116
+ {post.description ? <p className="lead quote-plain p-summary">{post.description}</p> : null}
117
+ <MarkdownArticle content={post.content} numbering={numbering} />
118
+ </div>
119
+ <ArticleReferences references={post.references} />
120
+ <Webmentions target={canonicalUrl} endpoint={webmentionApiEndpoint()} />
121
+ <ArticlePager
122
+ label={pagerLabel}
123
+ prev={prev ?? null}
124
+ next={next ?? null}
125
+ />
126
+ <Comments slug={commentsSlug} />
127
+ </div>
128
+ <TableOfContents headings={headings} />
129
+ </div>
130
+ </article>
131
+ </main>
132
+ );
133
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent as ReactKeyboardEvent,
11
+ } from "react";
12
+
13
+ export type SeriesEdgeItem = {
14
+ path: string;
15
+ href: string;
16
+ title: string;
17
+ current?: boolean;
18
+ };
19
+
20
+ /**
21
+ * Left-edge Raschka ticks for column articles.
22
+ * Flyout is the article list only (no column chrome).
23
+ * Opens on hover or click; stays open until click-outside or Escape.
24
+ */
25
+ export function SeriesEdgeNav({
26
+ columnTitle,
27
+ items,
28
+ }: {
29
+ columnTitle: string;
30
+ items: SeriesEdgeItem[];
31
+ /** @deprecated kept optional so callers can pass it; not shown in the flyout */
32
+ statusLabel?: string;
33
+ lang?: string;
34
+ }) {
35
+ const panelId = useId();
36
+ const rootRef = useRef<HTMLElement>(null);
37
+ const [open, setOpen] = useState(false);
38
+ const currentIndex = Math.max(0, items.findIndex((item) => item.current));
39
+
40
+ const close = useCallback(() => setOpen(false), []);
41
+
42
+ useEffect(() => {
43
+ if (!open) return;
44
+ const onPointerDown = (event: PointerEvent) => {
45
+ const root = rootRef.current;
46
+ if (!root) return;
47
+ if (event.target instanceof Node && root.contains(event.target)) return;
48
+ close();
49
+ };
50
+ const onKeyDown = (event: KeyboardEvent) => {
51
+ if (event.key === "Escape") close();
52
+ };
53
+ document.addEventListener("pointerdown", onPointerDown);
54
+ document.addEventListener("keydown", onKeyDown);
55
+ return () => {
56
+ document.removeEventListener("pointerdown", onPointerDown);
57
+ document.removeEventListener("keydown", onKeyDown);
58
+ };
59
+ }, [close, open]);
60
+
61
+ const onTicksKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
62
+ if (event.key === "Enter" || event.key === " ") {
63
+ event.preventDefault();
64
+ setOpen((value) => !value);
65
+ } else if (event.key === "Escape") {
66
+ close();
67
+ }
68
+ };
69
+
70
+ return (
71
+ <nav
72
+ ref={rootRef}
73
+ className="series-edge"
74
+ aria-label={columnTitle}
75
+ data-open={open ? "true" : "false"}
76
+ >
77
+ <button
78
+ type="button"
79
+ className="series-edge-ticks"
80
+ aria-controls={panelId}
81
+ aria-expanded={open}
82
+ aria-label={columnTitle}
83
+ onPointerEnter={() => setOpen(true)}
84
+ onFocus={() => setOpen(true)}
85
+ onClick={() => setOpen(true)}
86
+ onKeyDown={onTicksKeyDown}
87
+ >
88
+ {items.map((item, index) => (
89
+ <i
90
+ key={item.path}
91
+ className={index === currentIndex ? "hot" : undefined}
92
+ aria-hidden="true"
93
+ />
94
+ ))}
95
+ </button>
96
+ {/* Hit bridge so the pointer can cross ticks → panel without a dead zone. */}
97
+ <div
98
+ className="series-edge-bridge"
99
+ aria-hidden="true"
100
+ onPointerEnter={() => setOpen(true)}
101
+ />
102
+ <div
103
+ className="series-edge-fly"
104
+ id={panelId}
105
+ onPointerEnter={() => setOpen(true)}
106
+ >
107
+ <ol className="series-edge-list">
108
+ {items.map((item) => (
109
+ <li key={item.path} className={item.current ? "cur" : undefined}>
110
+ <Link href={item.href} aria-current={item.current ? "page" : undefined}>
111
+ {item.title}
112
+ </Link>
113
+ </li>
114
+ ))}
115
+ </ol>
116
+ </div>
117
+ </nav>
118
+ );
119
+ }
package/app/lib/i18n.ts CHANGED
@@ -141,6 +141,7 @@ const zh = {
141
141
  homeColumnCountStatus: (count: number, status: string) => `${count} 篇,${status}`,
142
142
  columnDefaultStatus: "持续更新",
143
143
  // Blog archive
144
+ blogEyebrow: "BLOG / 文章",
144
145
  blogHeading: "文章",
145
146
  blogDeck: (n: number) => `阶段性的完整沉淀,关于工程、架构与技术思考。共 ${n} 篇,按年份归档。`,
146
147
  unknownYear: "未知",
@@ -170,6 +171,7 @@ const zh = {
170
171
  seriesEyebrow: "主题专栏",
171
172
  seriesCountStatus: (count: number, status: string) => `${count} 篇 · ${status}`,
172
173
  // Columns index
174
+ columnsEyebrow: "COLUMNS / 专栏",
173
175
  columnsFallbackTitle: "专栏",
174
176
  columnsMeta: (count: number, status: string) => `阅读清单,${count} 篇,${status}`,
175
177
  columnsFallbackDesc: "按顺序组织的专题文章系列。",
@@ -195,7 +197,7 @@ const zh = {
195
197
  audioFallback: "你的浏览器不支持音频播放。",
196
198
  thoughtPermalinkShort: "永久链接",
197
199
  // About & GitHub profile
198
- aboutEyebrow: "关于",
200
+ aboutEyebrow: "ABOUT / 关于",
199
201
  aboutFallbackHeading: "关于",
200
202
  aboutFallbackParagraphs: [
201
203
  "一名工程师。平时写代码,也把想清楚的问题记下来。",
@@ -424,6 +426,7 @@ const en: typeof zh = {
424
426
  homeColumnCountStatus: (count: number, status: string) => `${count} ${count === 1 ? "post" : "posts"}, ${status}`,
425
427
  columnDefaultStatus: "Ongoing",
426
428
  // Blog archive
429
+ blogEyebrow: "BLOG",
427
430
  blogHeading: "Posts",
428
431
  blogDeck: (n: number) => `Essays on engineering, architecture, and systems thinking. ${n} ${n === 1 ? "post" : "posts"}, archived by year.`,
429
432
  unknownYear: "Unknown",
@@ -453,6 +456,7 @@ const en: typeof zh = {
453
456
  seriesEyebrow: "Column",
454
457
  seriesCountStatus: (count: number, status: string) => `${count} ${count === 1 ? "post" : "posts"} · ${status}`,
455
458
  // Columns index
459
+ columnsEyebrow: "COLUMNS",
456
460
  columnsFallbackTitle: "Columns",
457
461
  columnsMeta: (count: number, status: string) => `Reading list · ${count} ${count === 1 ? "post" : "posts"} · ${status}`,
458
462
  columnsFallbackDesc: "A series of posts organized in reading order.",
@@ -478,7 +482,7 @@ const en: typeof zh = {
478
482
  audioFallback: "Your browser does not support audio playback.",
479
483
  thoughtPermalinkShort: "Permalink",
480
484
  // About & GitHub profile
481
- aboutEyebrow: "About",
485
+ aboutEyebrow: "ABOUT",
482
486
  aboutFallbackHeading: "About",
483
487
  aboutFallbackParagraphs: [
484
488
  "An engineer who writes code and writes down problems once they are clear.",
package/app/page.tsx CHANGED
@@ -43,7 +43,9 @@ export default function Home() {
43
43
  <main className="sh-main" id="content">
44
44
  <section className="sh-intro">
45
45
  <h1>{siteConfig.pages.home.title}</h1>
46
- <p>{siteConfig.pages.home.subtitle || siteConfig.description}</p>
46
+ {siteConfig.pages.home.subtitle?.trim() ? (
47
+ <p>{siteConfig.pages.home.subtitle}</p>
48
+ ) : null}
47
49
  <div className="links">
48
50
  <Link href={siteHref("/about")}>{t.homeAbout}</Link>
49
51
  <a href={githubUrl} rel="me">GitHub</a>
package/app/styles/a2.css CHANGED
@@ -2636,3 +2636,207 @@ html[data-theme="dark"] .ref-demo { --rd-s: #262624; --rd-p: #1f1e1d; --rd-i: #e
2636
2636
  .ref-demo[data-name="figure"] .ref-pane[data-p="v"] { grid-column: 1; grid-row: 1; }
2637
2637
  .ref-demo[data-name="figure"] .ref-pane[data-p="c"] { grid-column: 1; grid-row: 2; background: var(--rd-s); border-top: 1px solid var(--rd-r); }
2638
2638
  }
2639
+
2640
+ /* ===== v0.10.1 tip keep (after git checkout of a2.css from 9a80f7e5) ===== */
2641
+
2642
+ /* List pages: PageIntro rides on .sh-head; keep eyebrow in the same vertical rhythm. */
2643
+ .page-intro.sh-head {
2644
+ padding: 72px 0 36px;
2645
+ max-width: var(--list);
2646
+ width: 100%;
2647
+ }
2648
+
2649
+ .page-intro.sh-head .eyebrow {
2650
+ margin: 0 0 14px;
2651
+ color: var(--muted);
2652
+ font: 600 11px / 1.4 var(--sans);
2653
+ letter-spacing: 0.12em;
2654
+ text-transform: uppercase;
2655
+ }
2656
+
2657
+ .page-intro.sh-head h1 {
2658
+ margin: 0;
2659
+ font: 700 var(--fs-display) / 1.2 var(--sans);
2660
+ color: var(--ink);
2661
+ letter-spacing: 0;
2662
+ text-wrap: balance;
2663
+ }
2664
+
2665
+ .page-intro.sh-head .sh-deck {
2666
+ margin: 14px 0 0;
2667
+ }
2668
+
2669
+ .page-intro.sh-head .page-intro-actions {
2670
+ margin-top: 22px;
2671
+ }
2672
+
2673
+ .thoughts-page,
2674
+ .about-page {
2675
+ width: auto;
2676
+ max-width: none;
2677
+ }
2678
+
2679
+ .about-page {
2680
+ padding-bottom: 120px;
2681
+ }
2682
+
2683
+ /* Keep PageIntro at --list; focus card sticks to the right edge. */
2684
+ .about-page .about-grid {
2685
+ width: 100%;
2686
+ max-width: none;
2687
+ display: grid;
2688
+ grid-template-columns: minmax(0, var(--list)) minmax(12rem, 17rem);
2689
+ column-gap: 48px;
2690
+ row-gap: 36px;
2691
+ justify-content: space-between;
2692
+ align-items: start;
2693
+ }
2694
+
2695
+ @media (max-width: 900px) {
2696
+ .about-page .about-grid {
2697
+ grid-template-columns: 1fr;
2698
+ }
2699
+ }
2700
+
2701
+ /* SeriesEdgeNav: fixed to viewport left on column posts that share PostReadingView.
2702
+ post-reading-page already lays out article + TOC; ticks are position:fixed so
2703
+ they do not need a grid track. */
2704
+ .post-reading-page > .series-edge {
2705
+ --edge-window-inset: 12px;
2706
+ position: fixed;
2707
+ top: calc(var(--hdr) + 12px);
2708
+ bottom: 16px;
2709
+ left: max(var(--edge-window-inset), env(safe-area-inset-left, 0px));
2710
+ z-index: 15;
2711
+ display: flex;
2712
+ align-items: center;
2713
+ pointer-events: none;
2714
+ transform: none;
2715
+ margin: 0;
2716
+ padding: 0;
2717
+ }
2718
+
2719
+ .series-edge-ticks {
2720
+ pointer-events: auto;
2721
+ appearance: none;
2722
+ border: 0;
2723
+ background: transparent;
2724
+ box-shadow: none;
2725
+ display: flex;
2726
+ flex-direction: column;
2727
+ justify-content: center;
2728
+ align-items: center;
2729
+ gap: 8px;
2730
+ padding: 12px 6px;
2731
+ margin: 0;
2732
+ cursor: pointer;
2733
+ color: inherit;
2734
+ font: inherit;
2735
+ border-radius: 0;
2736
+ }
2737
+
2738
+ .series-edge-ticks i {
2739
+ display: block;
2740
+ width: 11px;
2741
+ height: 1.5px;
2742
+ border-radius: 1px;
2743
+ background: color-mix(in srgb, var(--ink) 32%, transparent);
2744
+ transition: background 0.15s ease, width 0.15s ease;
2745
+ }
2746
+
2747
+ .series-edge-ticks i.hot {
2748
+ width: 15px;
2749
+ background: color-mix(in srgb, var(--ink) 70%, transparent);
2750
+ }
2751
+
2752
+ .series-edge[data-open="true"] .series-edge-ticks i,
2753
+ .series-edge:focus-within .series-edge-ticks i {
2754
+ background: color-mix(in srgb, var(--ink) 52%, transparent);
2755
+ }
2756
+
2757
+ .series-edge[data-open="true"] .series-edge-ticks i.hot,
2758
+ .series-edge:focus-within .series-edge-ticks i.hot {
2759
+ background: var(--ink);
2760
+ }
2761
+
2762
+ .series-edge-bridge {
2763
+ pointer-events: auto;
2764
+ position: absolute;
2765
+ top: 0;
2766
+ bottom: 0;
2767
+ left: 100%;
2768
+ width: 28px;
2769
+ }
2770
+
2771
+ .series-edge-fly {
2772
+ pointer-events: none;
2773
+ position: absolute;
2774
+ top: 50%;
2775
+ left: calc(100% + 4px);
2776
+ transform: translate(0, -50%);
2777
+ width: 16rem;
2778
+ max-height: min(72vh, calc(100vh - var(--hdr) - 40px));
2779
+ margin: 0;
2780
+ padding: 0.7rem 0.85rem 0.75rem;
2781
+ border: 0;
2782
+ border-radius: 10px;
2783
+ background: var(--paper);
2784
+ box-shadow:
2785
+ 0 1px 2px color-mix(in srgb, var(--ink) 6%, transparent),
2786
+ 0 10px 28px color-mix(in srgb, var(--ink) 10%, transparent);
2787
+ opacity: 0;
2788
+ visibility: hidden;
2789
+ overflow: auto;
2790
+ transition: opacity 0.16s ease, visibility 0.16s ease;
2791
+ }
2792
+
2793
+ .series-edge[data-open="true"],
2794
+ .series-edge:focus-within {
2795
+ z-index: 28;
2796
+ }
2797
+
2798
+ .series-edge[data-open="true"] .series-edge-fly,
2799
+ .series-edge:focus-within .series-edge-fly {
2800
+ pointer-events: auto;
2801
+ opacity: 1;
2802
+ visibility: visible;
2803
+ }
2804
+
2805
+ .series-edge-list {
2806
+ list-style: none;
2807
+ margin: 0;
2808
+ padding: 0;
2809
+ }
2810
+
2811
+ .series-edge-list a {
2812
+ display: block;
2813
+ padding: 0.4rem 0.35rem;
2814
+ margin: 0 -0.35rem;
2815
+ border-radius: 7px;
2816
+ color: var(--soft);
2817
+ font-size: 13px;
2818
+ line-height: 1.35;
2819
+ text-decoration: none;
2820
+ white-space: nowrap;
2821
+ overflow: hidden;
2822
+ text-overflow: ellipsis;
2823
+ }
2824
+
2825
+ .series-edge-list a:hover {
2826
+ background: color-mix(in srgb, var(--ink) 4%, transparent);
2827
+ color: var(--ink);
2828
+ }
2829
+
2830
+ .series-edge-list li.cur a {
2831
+ background: color-mix(in srgb, var(--ink) 6%, transparent);
2832
+ color: var(--ink);
2833
+ font-weight: 600;
2834
+ }
2835
+
2836
+ @media (max-width: 900px) {
2837
+ /* The desktop rule is `.post-reading-page > .series-edge { display: flex }`.
2838
+ Match that specificity so the rail stays off small screens. */
2839
+ .post-reading-page > .series-edge {
2840
+ display: none;
2841
+ }
2842
+ }
@@ -36,7 +36,7 @@ export default async function ThoughtsPage({ params }: { params: Promise<{ n: st
36
36
  notFound();
37
37
  }
38
38
  return (
39
- <main className="page-shell thoughts-page">
39
+ <main className="sh-main thoughts-page" id="content">
40
40
  <PageIntro eyebrow={ui().thoughtsEyebrow} title={siteConfig.pages.thoughts.title} subtitle={siteConfig.pages.thoughts.subtitle} />
41
41
  <ThoughtFeed page={page} />
42
42
  </main>
@@ -16,7 +16,7 @@ export default function ThoughtsPage() {
16
16
  const thoughts = getPublishedThoughts();
17
17
 
18
18
  return (
19
- <main className="page-shell thoughts-page">
19
+ <main className="sh-main thoughts-page" id="content">
20
20
  <PageIntro
21
21
  eyebrow={ui().thoughtsEyebrow}
22
22
  title={siteConfig.pages.thoughts.title}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -2,12 +2,14 @@
2
2
  * The header's site navigation. Hrefs carry the trailing slash: a static
3
3
  * export serves these pages at /blog/ and so on, and the host answers /blog
4
4
  * with a 301 first (new tab, no JavaScript, crawlers).
5
+ *
6
+ * /tags/ stays as a deep route (article tag chips still link there) but is
7
+ * not listed in the primary nav.
5
8
  */
6
9
  export const SITE_NAV = [
7
10
  { href: "/blog/", label: "文章" },
8
11
  { href: "/thoughts/", label: "随想" },
9
12
  { href: "/columns/", label: "专栏" },
10
- { href: "/tags/", label: "标签" },
11
13
  { href: "/about/", label: "关于" },
12
14
  ];
13
15