hd-garden 0.4.1 → 0.5.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.
Files changed (79) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +5 -5
  2. package/Makefile +2 -3
  3. package/app/a2/[slug]/page.tsx +0 -1
  4. package/app/a2/page.tsx +1 -1
  5. package/app/about/page.tsx +8 -12
  6. package/app/admin/page.tsx +2 -2
  7. package/app/blog/[...path]/page.tsx +4 -14
  8. package/app/blog/page.tsx +6 -3
  9. package/app/columns/[column]/[...path]/page.tsx +4 -9
  10. package/app/columns/page.tsx +2 -2
  11. package/app/components/AdminDashboard.tsx +3 -3
  12. package/app/components/ArticleMeasure.tsx +114 -42
  13. package/app/components/ArticleReferences.tsx +31 -31
  14. package/app/components/ArticleTags.tsx +17 -0
  15. package/app/components/BlogArchive.tsx +94 -50
  16. package/app/components/ContentEmptyState.tsx +6 -6
  17. package/app/components/CoverImage.tsx +34 -0
  18. package/app/components/GardenAdminGuide.tsx +1 -1
  19. package/app/components/GardenContentGuide.tsx +1 -2
  20. package/app/components/GardenMediaGuide.tsx +3 -3
  21. package/app/components/GithubProfile.tsx +1 -1
  22. package/app/components/LinkCardRefresh.tsx +44 -0
  23. package/app/components/MarkdownArticle.tsx +87 -19
  24. package/app/components/MarkdownImage.tsx +4 -2
  25. package/app/components/SiteFooter.tsx +5 -5
  26. package/app/components/SiteHeader.tsx +105 -33
  27. package/app/components/TagRedirect.tsx +17 -0
  28. package/app/garden/page.tsx +6 -6
  29. package/app/garden-modules.d.ts +17 -2
  30. package/app/globals.css +92 -136
  31. package/app/layout.tsx +1 -7
  32. package/app/lib/a2-meta.ts +10 -0
  33. package/app/lib/a2-preview.ts +0 -2
  34. package/app/lib/archive.ts +43 -0
  35. package/app/lib/content.ts +12 -43
  36. package/app/lib/fallback-site-config.ts +15 -15
  37. package/app/lib/generated-content.ts +58 -47
  38. package/app/lib/heading-number.ts +9 -0
  39. package/app/lib/remark-link-card.mjs +83 -0
  40. package/app/lib/tag-filter.ts +151 -0
  41. package/app/not-found.tsx +13 -0
  42. package/app/page.tsx +12 -29
  43. package/app/rss.xml/route.ts +2 -1
  44. package/app/styles/a2.css +159 -27
  45. package/app/styles/shell.css +54 -59
  46. package/app/styles/tokens.css +35 -22
  47. package/app/tags/page.tsx +20 -9
  48. package/bin/garden.mjs +6 -0
  49. package/package.json +1 -1
  50. package/scripts/build-content.mjs +50 -9
  51. package/scripts/check-post.mjs +0 -3
  52. package/scripts/cover-images.mjs +69 -0
  53. package/scripts/link-assets.mjs +271 -0
  54. package/scripts/link-meta-core.mjs +321 -0
  55. package/scripts/new-post.mjs +1 -3
  56. package/site.config.yaml +15 -15
  57. package/templates/site/Makefile +2 -3
  58. package/templates/site/site.config.yaml +15 -15
  59. package/ui/core/transform.mjs +4 -3
  60. package/ui/garden/card-refresh.mjs +92 -0
  61. package/ui/garden/check.mjs +2 -4
  62. package/ui/garden/enhance.mjs +57 -0
  63. package/ui/garden/markdown-links.mjs +84 -0
  64. package/ui/garden/references.mjs +21 -0
  65. package/ui/garden/search-full.mjs +131 -0
  66. package/ui/garden/search-index.mjs +102 -0
  67. package/ui/garden/search-match.mjs +96 -0
  68. package/ui/garden/title.mjs +5 -8
  69. package/ui/host.mjs +13 -13
  70. package/ui/preview/dist/garden-ui.css +11 -11
  71. package/ui/preview/dist/garden-ui.esm.js +8 -8
  72. package/ui/preview/dist/garden-ui.iife.js +8 -8
  73. package/ui/react/components/media.tsx +2 -2
  74. package/ui/react/ui.css +11 -11
  75. package/worker/og/README.md +59 -0
  76. package/worker/og/handler.mjs +147 -0
  77. package/worker/og/index.mjs +61 -0
  78. package/worker/og/wrangler.toml +18 -0
  79. package/app/components/TagIndex.tsx +0 -97
@@ -10,11 +10,79 @@ import {
10
10
  useState,
11
11
  type KeyboardEvent,
12
12
  } from "react";
13
- import Fuse, { type FuseResultMatch } from "fuse.js";
13
+ import type Fuse from "fuse.js";
14
14
  import { MagnifyingGlass, X } from "@phosphor-icons/react";
15
- import { searchRecords } from "../lib/content";
15
+ import { searchFull, searchIndexUrl } from "@garden/site-config";
16
+ import { createSearch } from "../../ui/garden/search-match.mjs";
17
+ import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
16
18
  import { siteConfig } from "../site.config";
17
19
 
20
+ /** One entry of /search-index.json: titles, tags, dates and a short excerpt, never the full body. */
21
+ type SearchRecord = {
22
+ title: string;
23
+ description: string;
24
+ section: string;
25
+ path: string;
26
+ content: string;
27
+ tags?: string[];
28
+ date?: string;
29
+ };
30
+
31
+ type SearchEngine = { records: SearchRecord[]; fuse: Fuse<SearchRecord> };
32
+ let engineLoad: Promise<SearchEngine> | null = null;
33
+
34
+ type MatchRange = readonly [number, number];
35
+ type SearchMatch = { key?: string; indices: readonly MatchRange[] };
36
+ type SearchHit = { item: SearchRecord; matches?: readonly SearchMatch[] };
37
+
38
+ let fullLoad: Promise<Map<string, string>> | null = null;
39
+
40
+ /**
41
+ * Every body as plain text. Started in the background after the page has
42
+ * loaded, or when search opens first; read from IndexedDB when this version
43
+ * was fetched before.
44
+ */
45
+ function loadFullText() {
46
+ const scope = searchFull.url.slice(0, searchFull.url.lastIndexOf("/"));
47
+ fullLoad ??= loadFullIndex({ url: searchFull.url, hash: searchFull.hash, store: createIdbStore(scope) })
48
+ .then((docs) => new Map(docs))
49
+ .catch((error: unknown) => {
50
+ fullLoad = null;
51
+ throw error;
52
+ });
53
+ return fullLoad;
54
+ }
55
+
56
+ /** Index and Fuse are fetched together, the first time search is about to open. */
57
+ function loadSearchEngine() {
58
+ engineLoad ??= Promise.all([
59
+ fetch(searchIndexUrl).then((response) => {
60
+ if (!response.ok) throw new Error(`search index ${response.status}`);
61
+ return response.json() as Promise<SearchRecord[]>;
62
+ }),
63
+ import("fuse.js"),
64
+ ])
65
+ .then(([records, { default: FuseClass }]) => ({
66
+ records,
67
+ fuse: new FuseClass(records, {
68
+ keys: [
69
+ { name: "title", weight: 0.5 },
70
+ { name: "description", weight: 0.3 },
71
+ { name: "tags", weight: 0.2 },
72
+ { name: "content", weight: 0.15 },
73
+ ],
74
+ threshold: 0.36,
75
+ ignoreLocation: true,
76
+ includeMatches: true,
77
+ }),
78
+ }))
79
+ .catch((error) => {
80
+ engineLoad = null;
81
+ throw error;
82
+ });
83
+ return engineLoad;
84
+ }
85
+
18
86
  const nav = [
19
87
  { href: "/blog", label: "文章" },
20
88
  { href: "/thoughts", label: "随想" },
@@ -23,10 +91,8 @@ const nav = [
23
91
  { href: "/about", label: "关于" },
24
92
  ];
25
93
 
26
- type MatchRange = readonly [number, number];
27
-
28
94
  function rangesFor(
29
- matches: readonly FuseResultMatch[] | undefined,
95
+ matches: readonly SearchMatch[] | undefined,
30
96
  key: string,
31
97
  ): readonly MatchRange[] {
32
98
  return matches?.find((match) => match.key === key)?.indices ?? [];
@@ -119,8 +185,8 @@ function cropMatchedText(text: string, ranges: readonly MatchRange[]) {
119
185
  }
120
186
 
121
187
  function searchPreview(
122
- item: (typeof searchRecords)[number],
123
- matches: readonly FuseResultMatch[] | undefined,
188
+ item: SearchRecord,
189
+ matches: readonly SearchMatch[] | undefined,
124
190
  ): { text: string; ranges: readonly MatchRange[] } {
125
191
  const descriptionRanges = rangesFor(matches, "description");
126
192
  if (descriptionRanges.length) {
@@ -147,7 +213,25 @@ export function SiteHeader() {
147
213
  const resultRefs = useRef<Array<HTMLAnchorElement | null>>([]);
148
214
  const previousFocusRef = useRef<HTMLElement | null>(null);
149
215
 
216
+ const [engine, setEngine] = useState<SearchEngine | null>(null);
217
+ const [engineFailed, setEngineFailed] = useState(false);
218
+ const [fullText, setFullText] = useState<Map<string, string> | null>(null);
219
+ const prefetchSearch = useCallback(() => {
220
+ loadSearchEngine().then(setEngine, () => setEngineFailed(true));
221
+ }, []);
222
+
223
+ // Full text arrives quietly once the page is done; search upgrades when it lands.
224
+ useEffect(() => {
225
+ if (saveDataOn()) return;
226
+ return whenIdleAfterLoad(() => {
227
+ loadFullText().then(setFullText, () => {});
228
+ });
229
+ }, []);
230
+
150
231
  const openSearch = useCallback(() => {
232
+ setEngineFailed(false);
233
+ loadSearchEngine().then(setEngine, () => setEngineFailed(true));
234
+ loadFullText().then(setFullText, () => {});
151
235
  previousFocusRef.current =
152
236
  document.activeElement instanceof HTMLElement
153
237
  ? document.activeElement
@@ -190,25 +274,14 @@ export function SiteHeader() {
190
274
  return () => window.removeEventListener("keydown", onKey);
191
275
  }, [closeSearch, openSearch, searchOpen]);
192
276
 
193
- const fuse = useMemo(
194
- () =>
195
- new Fuse(searchRecords, {
196
- keys: [
197
- { name: "title", weight: 0.5 },
198
- { name: "description", weight: 0.3 },
199
- { name: "content", weight: 0.15 },
200
- { name: "topic", weight: 0.05 },
201
- ],
202
- threshold: 0.36,
203
- ignoreLocation: true,
204
- includeMatches: true,
205
- }),
206
- [],
207
- );
208
-
209
- const results = query.trim()
210
- ? fuse.search(query).slice(0, 6)
211
- : searchRecords.slice(0, 5).map((item) => ({ item, matches: [] }));
277
+ // Excerpts first, full text once it is here. Typos fall back to Fuse's fuzzy match.
278
+ const matcher = useMemo(() => (engine ? createSearch(engine.records, fullText) : null), [engine, fullText]);
279
+ const results: SearchHit[] = useMemo(() => {
280
+ if (!engine || !matcher) return [];
281
+ if (!query.trim()) return engine.records.slice(0, 5).map((item) => ({ item, matches: [] }));
282
+ const exact = matcher.search(query, 6) as unknown as SearchHit[];
283
+ return exact.length ? exact : engine.fuse.search(query).slice(0, 6);
284
+ }, [engine, matcher, query]);
212
285
 
213
286
  useEffect(() => {
214
287
  if (!searchOpen) return;
@@ -294,7 +367,7 @@ export function SiteHeader() {
294
367
  ))}
295
368
  </nav>
296
369
  <div className="sh-tools">
297
- <button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} aria-label="搜索文章" aria-expanded={searchOpen} aria-controls="site-search-dialog">
370
+ <button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label="搜索文章" aria-expanded={searchOpen} aria-controls="site-search-dialog">
298
371
  <svg viewBox="0 0 20 20" aria-hidden="true">
299
372
  <circle cx="9" cy="9" r="5.8" />
300
373
  <path d="M13.3 13.3 17 17" />
@@ -302,7 +375,7 @@ export function SiteHeader() {
302
375
  <span className="lbl">搜索文章</span>
303
376
  <kbd><span className="kbd-mod kbd-mac">⌘</span><span className="kbd-mod kbd-other">Ctrl</span>{" "}K</kbd>
304
377
  </button>
305
- <button className="sh-icon sh-search-m" type="button" onClick={openSearch} aria-label="搜索文章">
378
+ <button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label="搜索文章">
306
379
  <svg viewBox="0 0 20 20" aria-hidden="true">
307
380
  <circle cx="9" cy="9" r="5.8" />
308
381
  <path d="M13.3 13.3 17 17" />
@@ -401,10 +474,7 @@ export function SiteHeader() {
401
474
  className={`search-result${activeResult === index ? " active" : ""}`}
402
475
  >
403
476
  <span>
404
- <HighlightedText
405
- text={result.item.topic}
406
- ranges={rangesFor(result.matches, "topic")}
407
- />
477
+ {result.item.section}
408
478
  </span>
409
479
  <strong>
410
480
  <HighlightedText
@@ -419,7 +489,9 @@ export function SiteHeader() {
419
489
  );
420
490
  })}
421
491
  {results.length === 0 && (
422
- <p className="empty-state">没有找到,换个更宽松的关键词试试。</p>
492
+ <p className="empty-state">
493
+ {engine ? "没有找到,换个更宽松的关键词试试。" : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
494
+ </p>
423
495
  )}
424
496
  </div>
425
497
  <footer className="search-footer">
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { useRouter, useSearchParams } from "next/navigation";
4
+ import { useEffect } from "react";
5
+ import { TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
6
+
7
+ /** Old `/tags?t=X` links open the same filter on `/blog?t=X`. */
8
+ export function TagRedirect() {
9
+ const router = useRouter();
10
+ const params = useSearchParams();
11
+ const tags = selectedTags(params.getAll(TAG_PARAM));
12
+ const target = tags.length ? tagFilterHref(tags) : "";
13
+ useEffect(() => {
14
+ if (target) router.replace(target);
15
+ }, [router, target]);
16
+ return null;
17
+ }
@@ -7,7 +7,7 @@ import { siteConfig } from "../site.config";
7
7
 
8
8
  export const metadata = {
9
9
  title: "Garden",
10
- description: "用 Markdown 写文章。推送到 GitHub 之后,页面、搜索和 RSS 一起生成。",
10
+ description: "用 Markdown 写文章。推送之后,页面、搜索和 RSS 一起更新。",
11
11
  };
12
12
 
13
13
  const jumps = [
@@ -45,7 +45,7 @@ const setupSteps = [
45
45
  {
46
46
  title: "发布",
47
47
  command: "make update",
48
- body: "先检查,再提交并推送。main 更新后,网站、搜索和 RSS 会重新生成。",
48
+ body: "先检查,再提交并推送。main 更新后,网站、搜索和 RSS 会一起更新。",
49
49
  },
50
50
  ];
51
51
 
@@ -54,7 +54,7 @@ export default function GardenPage() {
54
54
  <main className="page-shell garden-page">
55
55
  <PageIntro title={siteConfig.pages.garden.title} subtitle={siteConfig.pages.garden.subtitle}>
56
56
  <p className="garden-lede">
57
- 文章放在你自己的仓库里。你改文件,GitHub 构建网站。
57
+ 文章放在你自己的仓库里。你改文件,网站跟着更新。
58
58
  </p>
59
59
  <div className="garden-actions">
60
60
  <a
@@ -133,10 +133,10 @@ export default function GardenPage() {
133
133
  <header className="garden-section-heading">
134
134
  <h2 id="garden-after-title">推送之后</h2>
135
135
  </header>
136
- <p className="garden-after-lead">你的仓库,到 GitHub 构建,再到公开网站。</p>
136
+ <p className="garden-after-lead">推送之后,网站公开更新。</p>
137
137
  <div className="garden-after-copy">
138
- <p>框架画页面。你的文章不放在框架仓库里,而在你自己的内容仓库。读者打开的是生成出来的静态页。</p>
139
- <p>同一次构建会带上正文、目录、前后篇、搜索、RSS 和 sitemap。后台和评论都可以以后再加,不接也能发布。</p>
138
+ <p>文章在你自己的仓库里。读者打开的是已经发布的页面。</p>
139
+ <p>一次更新会带上正文、目录、前后篇、搜索、RSS 和 sitemap。后台和评论都可以以后再加。</p>
140
140
  </div>
141
141
  </section>
142
142
 
@@ -18,9 +18,8 @@ declare module "@garden/generated-content" {
18
18
  updated?: string;
19
19
  publishAt?: string;
20
20
  draft?: boolean;
21
- topic?: string;
22
21
  tags?: string[];
23
- references?: { title: string; url?: string }[];
22
+ references?: { title: string; url?: string; domain?: string }[];
24
23
  pinned?: boolean;
25
24
  readingTime?: string;
26
25
  kind: "post" | "column" | "thought";
@@ -34,6 +33,8 @@ declare module "@garden/generated-content" {
34
33
  columnCoverAlt?: string;
35
34
  order?: number;
36
35
  cover?: string;
36
+ /** Resized WebP copies of a local cover, when the build had sharp. */
37
+ coverImage?: { src: string; srcset: string; width: number; height: number };
37
38
  original?: string;
38
39
  mediaType?: "image" | "audio" | "video" | "link" | "text";
39
40
  media?: string | MediaItem[];
@@ -48,9 +49,22 @@ declare module "@garden/generated-content" {
48
49
  export const columns: ContentEntry[];
49
50
  export const thoughts: ContentEntry[];
50
51
  export const a2WordCounts: Record<string, number>;
52
+ export type LinkCardMeta = {
53
+ title: string;
54
+ description?: string;
55
+ siteName?: string;
56
+ author?: string;
57
+ publishedTime?: string;
58
+ image?: string;
59
+ };
60
+ export type LinkAssets = { favicons: Record<string, string>; cards: Record<string, LinkCardMeta> };
61
+ export const linkAssets: LinkAssets;
51
62
  }
52
63
 
53
64
  declare module "@garden/site-config" {
65
+ export const searchIndexUrl: string;
66
+ /** The full-text index file and the hash its contents are cached under. */
67
+ export const searchFull: { url: string; hash: string };
54
68
  export const siteConfig: {
55
69
  name: string;
56
70
  tagline: string;
@@ -59,6 +73,7 @@ declare module "@garden/site-config" {
59
73
  author: { name: string; github: string; githubBio: string; githubPinned?: string[] };
60
74
  pages: Record<string, Record<string, unknown>>;
61
75
  footer: string;
76
+ linkPreview?: { worker?: string };
62
77
  };
63
78
  export const githubUrl: string;
64
79
  }
package/app/globals.css CHANGED
@@ -3,8 +3,8 @@
3
3
  @import "./admin-ui.css";
4
4
 
5
5
  :root {
6
- --paper: #f8f8f6;
7
- --paper-deep: #eeeeeb;
6
+ --paper: #faf9f5;
7
+ --paper-deep: #f0eee6;
8
8
  --card: #ffffff;
9
9
  --ink: #202124;
10
10
  --ink-soft: #34383d;
@@ -12,28 +12,28 @@
12
12
  --faint: #a1a6ad;
13
13
  --line: #deded9;
14
14
  --line-strong: #c9c9c2;
15
- --accent: #d82d48;
16
- --accent-soft: #f9e8eb;
17
- --selection: #f3c9d1;
18
- --font-mono: var(--font-geist-mono), "SFMono-Regular", Consolas,
15
+ --accent: #30302e;
16
+ --accent-soft: #f0eee6;
17
+ --selection: #e8e5db;
18
+ --font-mono: "SFMono-Regular", Consolas,
19
19
  "Liberation Mono", "Noto Sans Mono CJK SC", monospace;
20
- --content: 700px;
20
+ --content: 740px;
21
21
  --wide: 1040px;
22
22
  }
23
23
 
24
24
  :root[data-theme="dark"] {
25
- --paper: #171819;
26
- --paper-deep: #202224;
27
- --card: #1e2022;
25
+ --paper: #1f1e1d;
26
+ --paper-deep: #262624;
27
+ --card: #262624;
28
28
  --ink: #ededeb;
29
29
  --ink-soft: #d8d8d4;
30
30
  --muted: #a6adb6;
31
31
  --faint: #747b84;
32
32
  --line: #35383b;
33
33
  --line-strong: #4b4f54;
34
- --accent: #ff5d76;
35
- --accent-soft: #3b242a;
36
- --selection: #68333e;
34
+ --accent: #eeece7;
35
+ --accent-soft: #30302e;
36
+ --selection: rgb(238 236 231 / 18%);
37
37
  }
38
38
 
39
39
  * {
@@ -252,11 +252,6 @@ button {
252
252
  margin-top: 72px;
253
253
  }
254
254
 
255
- .home-thought-list {
256
- display: grid;
257
- gap: 24px;
258
- }
259
-
260
255
  .section-heading {
261
256
  min-height: 40px;
262
257
  margin-bottom: 21px;
@@ -1423,32 +1418,6 @@ button {
1423
1418
  color: var(--accent);
1424
1419
  }
1425
1420
 
1426
- .topic-strip {
1427
- display: flex;
1428
- flex-wrap: wrap;
1429
- gap: 8px;
1430
- margin: 8px 0 38px;
1431
- }
1432
-
1433
- .topic-strip a {
1434
- padding: 6px 10px;
1435
- background: var(--paper-deep);
1436
- color: var(--muted);
1437
- font-size: 11px;
1438
- border: 1px solid transparent;
1439
- }
1440
-
1441
- .topic-strip a:hover,
1442
- .topic-strip a:focus-visible {
1443
- color: var(--ink);
1444
- border-color: var(--line-strong);
1445
- }
1446
-
1447
- .topic-strip a.active {
1448
- background: var(--ink);
1449
- color: var(--paper);
1450
- }
1451
-
1452
1421
  .archive-list {
1453
1422
  border-top: 1px solid var(--line-strong);
1454
1423
  }
@@ -1496,13 +1465,6 @@ button {
1496
1465
  border: 1px solid var(--line);
1497
1466
  }
1498
1467
 
1499
- .archive-topic {
1500
- color: var(--accent);
1501
- font-size: 10px;
1502
- font-weight: 700;
1503
- letter-spacing: 0.05em;
1504
- }
1505
-
1506
1468
  .pinned-badge {
1507
1469
  margin-left: 9px;
1508
1470
  display: inline-flex;
@@ -2089,12 +2051,14 @@ button {
2089
2051
 
2090
2052
  .markdown-body a {
2091
2053
  text-decoration: underline;
2092
- text-decoration-color: var(--accent);
2054
+ text-decoration-color: color-mix(in srgb, var(--ink) 35%, transparent);
2093
2055
  text-underline-offset: 4px;
2094
2056
  }
2095
2057
 
2058
+ /* Links stay ink; hover darkens and thickens the underline instead. */
2096
2059
  .markdown-body a:hover {
2097
- color: var(--accent);
2060
+ text-decoration-color: var(--ink);
2061
+ text-decoration-thickness: 2px;
2098
2062
  }
2099
2063
 
2100
2064
  /* A per-line border stays continuous across inline code and descenders. */
@@ -2186,25 +2150,6 @@ button {
2186
2150
  color: var(--accent);
2187
2151
  }
2188
2152
 
2189
- .markdown-body table {
2190
- width: 100%;
2191
- margin: 34px 0;
2192
- border-collapse: collapse;
2193
- font-size: 12px;
2194
- }
2195
-
2196
- .markdown-body th,
2197
- .markdown-body td {
2198
- padding: 11px 13px;
2199
- border: 1px solid var(--line);
2200
- text-align: left;
2201
- }
2202
-
2203
- .markdown-body th {
2204
- background: var(--paper-deep);
2205
- color: var(--ink);
2206
- }
2207
-
2208
2153
  .markdown-body pre {
2209
2154
  position: relative;
2210
2155
  margin: 32px 0;
@@ -2818,84 +2763,107 @@ button {
2818
2763
  color: #7f8790;
2819
2764
  }
2820
2765
 
2766
+ /* References: one quiet warm-grey card after the article. */
2821
2767
  .article-references {
2822
- margin-top: 64px;
2768
+ margin-top: 56px;
2769
+ padding: 22px 24px 16px;
2770
+ background: var(--card-hover);
2771
+ border: 1px solid var(--rule);
2772
+ border-radius: var(--r);
2823
2773
  }
2824
2774
 
2825
- .article-references > p {
2775
+ :root[data-theme="dark"] .article-references,
2776
+ html[data-theme="dark"] .article-references {
2777
+ background: var(--card);
2778
+ }
2779
+
2780
+ .article-references > h2 {
2826
2781
  margin: 0 0 14px;
2827
- color: var(--accent);
2828
- font-size: 12px;
2829
- font-weight: 700;
2782
+ padding: 0;
2783
+ border: 0;
2784
+ font: 600 17px / 1.4 var(--sans);
2785
+ color: var(--ink);
2830
2786
  }
2831
2787
 
2832
- .article-references table {
2833
- width: 100%;
2834
- border-collapse: collapse;
2835
- font-size: 12px;
2788
+ .article-references > h2::before {
2789
+ content: none;
2836
2790
  }
2837
2791
 
2838
- .article-references td {
2792
+ .article-references ol {
2793
+ list-style: none;
2794
+ margin: 0;
2839
2795
  padding: 0;
2840
- border: 1px solid var(--line);
2841
- color: var(--ink-soft);
2842
- text-align: left;
2843
- overflow-wrap: anywhere;
2796
+ counter-reset: ref;
2797
+ font-size: 14px;
2798
+ line-height: 1.6;
2799
+ color: var(--soft);
2844
2800
  }
2845
2801
 
2846
- .article-references td:first-child {
2847
- width: 1%;
2848
- color: var(--accent);
2802
+ .article-references li {
2803
+ position: relative;
2804
+ margin: 0 0 6px;
2805
+ padding-left: 2.1em;
2806
+ counter-increment: ref;
2807
+ break-inside: avoid;
2808
+ }
2809
+
2810
+ .article-references li::before {
2811
+ content: counter(ref);
2812
+ position: absolute;
2813
+ top: 0.15em;
2814
+ left: 0;
2815
+ width: 1.5em;
2816
+ color: var(--muted);
2817
+ font-size: 12px;
2849
2818
  font-variant-numeric: tabular-nums;
2850
2819
  text-align: right;
2851
- white-space: nowrap;
2852
2820
  }
2853
2821
 
2854
- .article-references td a,
2855
- .article-references td > span {
2856
- display: block;
2857
- padding: 11px 13px;
2822
+ .article-references a,
2823
+ .article-references .ref-title {
2824
+ color: var(--text);
2858
2825
  }
2859
2826
 
2860
- .article-references td:first-child a,
2861
- .article-references td:first-child > span {
2862
- padding-right: 8px;
2863
- color: var(--accent);
2864
- text-decoration: none;
2827
+ .article-references a {
2828
+ text-decoration: underline;
2829
+ text-decoration-thickness: 1px;
2830
+ text-underline-offset: 4px;
2831
+ text-decoration-color: color-mix(in srgb, var(--ink) 35%, transparent);
2832
+ transition: text-decoration-color 0.15s ease;
2865
2833
  }
2866
2834
 
2867
- .article-references td a {
2868
- color: inherit;
2869
- text-decoration: none;
2835
+ .article-references a:hover {
2836
+ text-decoration-color: var(--ink);
2837
+ text-decoration-thickness: 2px;
2870
2838
  }
2871
2839
 
2872
- .article-references td a span {
2873
- text-decoration: underline;
2874
- text-decoration-color: var(--accent);
2875
- text-underline-offset: 4px;
2840
+ .article-references .ref-domain {
2841
+ font-size: 12px;
2842
+ color: var(--muted);
2843
+ white-space: nowrap;
2876
2844
  }
2877
2845
 
2878
- .article-references td a:hover span {
2879
- color: var(--accent);
2846
+ @media (min-width: 900px) {
2847
+ .article-references.is-split ol {
2848
+ columns: 2;
2849
+ column-gap: 32px;
2850
+ }
2880
2851
  }
2881
2852
 
2882
- .article-references + .article-tags,
2883
- .article-references + .next-entry {
2884
- margin-top: 36px;
2853
+ @media (max-width: 640px) {
2854
+ .article-references {
2855
+ padding: 18px 16px 12px;
2856
+ }
2885
2857
  }
2886
2858
 
2887
- .article-tags {
2888
- display: flex;
2889
- flex-wrap: wrap;
2890
- gap: 8px;
2891
- margin: 68px 0 24px;
2859
+ @media (prefers-reduced-motion: reduce) {
2860
+ .article-references a {
2861
+ transition: none;
2862
+ }
2892
2863
  }
2893
2864
 
2894
- .article-tags span {
2895
- padding: 4px 8px;
2896
- background: var(--paper-deep);
2897
- color: var(--muted);
2898
- font-size: 10px;
2865
+ .article-references + .next-entry {
2866
+ margin-top: 36px;
2899
2867
  }
2900
2868
 
2901
2869
  .webmentions,
@@ -3078,7 +3046,7 @@ button {
3078
3046
  margin: 0 auto;
3079
3047
  padding: 58px 0 120px;
3080
3048
  display: grid;
3081
- grid-template-columns: var(--column-sidebar-width) minmax(0, 700px) var(--column-sidebar-width);
3049
+ grid-template-columns: var(--column-sidebar-width) minmax(0, var(--content)) var(--column-sidebar-width);
3082
3050
  gap: 54px;
3083
3051
  justify-content: center;
3084
3052
  }
@@ -3697,12 +3665,6 @@ button {
3697
3665
  color: var(--accent);
3698
3666
  }
3699
3667
 
3700
- .footer-framework a {
3701
- text-decoration: underline;
3702
- text-decoration-color: var(--line-strong);
3703
- text-underline-offset: 3px;
3704
- }
3705
-
3706
3668
  @media (max-width: 1080px) {
3707
3669
  .post-reading-page {
3708
3670
  --toc-width: 210px;
@@ -3917,7 +3879,7 @@ button {
3917
3879
  }
3918
3880
 
3919
3881
  .column-reading-page {
3920
- grid-template-columns: minmax(0, 700px);
3882
+ grid-template-columns: minmax(0, var(--content));
3921
3883
  }
3922
3884
 
3923
3885
  .series-nav {
@@ -4202,8 +4164,7 @@ button {
4202
4164
  gap: 4px 9px;
4203
4165
  }
4204
4166
 
4205
- .archive-meta time,
4206
- .archive-topic {
4167
+ .archive-meta time {
4207
4168
  font-size: 9px;
4208
4169
  }
4209
4170
 
@@ -4274,11 +4235,6 @@ button {
4274
4235
  font-size: 17px;
4275
4236
  }
4276
4237
 
4277
- .markdown-body table {
4278
- display: block;
4279
- overflow-x: auto;
4280
- }
4281
-
4282
4238
  .markdown-body .external-embed-card,
4283
4239
  .markdown-body .external-embed-card:has(> img) {
4284
4240
  grid-template-columns: 1fr 22px;