hd-garden 0.8.0 → 0.8.2

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 (187) hide show
  1. package/.agents/skills/garden-ui/SKILL.md +2 -2
  2. package/.env.en.example +67 -0
  3. package/README.en.md +2 -2
  4. package/README.md +2 -2
  5. package/app/a2/[slug]/page.tsx +4 -3
  6. package/app/a2/page.tsx +4 -3
  7. package/app/about/page.tsx +8 -9
  8. package/app/admin/page.tsx +7 -6
  9. package/app/admin/preview/[...path]/page.tsx +2 -1
  10. package/app/admin/ui/blocks/page.tsx +2 -1
  11. package/app/admin/ui/catalog/page.tsx +2 -1
  12. package/app/admin/ui/evals/page.tsx +2 -1
  13. package/app/admin/ui/page.tsx +2 -1
  14. package/app/blog/[...path]/page.tsx +7 -6
  15. package/app/blog/page.tsx +4 -2
  16. package/app/columns/[column]/[...path]/page.tsx +8 -7
  17. package/app/columns/page.tsx +12 -8
  18. package/app/components/AdminUiShell.tsx +4 -3
  19. package/app/components/ArticleMeasure.tsx +6 -3
  20. package/app/components/ArticleReferences.tsx +4 -1
  21. package/app/components/ArticleTags.tsx +3 -2
  22. package/app/components/BlogArchive.tsx +20 -16
  23. package/app/components/Comments.tsx +16 -10
  24. package/app/components/ContentEmptyState.tsx +29 -28
  25. package/app/components/ExternalEmbed.tsx +15 -14
  26. package/app/components/GardenContentGuide.tsx +10 -2
  27. package/app/components/GardenDocsNav.tsx +4 -2
  28. package/app/components/GiscusComments.tsx +2 -1
  29. package/app/components/GithubLiveOverview.tsx +10 -8
  30. package/app/components/GithubProfile.tsx +31 -21
  31. package/app/components/HandbookLangSwitch.tsx +8 -4
  32. package/app/components/HandbookView.tsx +92 -9
  33. package/app/components/MarkdownArticle.tsx +31 -7
  34. package/app/components/MermaidDiagram.tsx +4 -3
  35. package/app/components/SiteChrome.tsx +3 -3
  36. package/app/components/SiteFooter.tsx +1 -1
  37. package/app/components/SiteHeader.tsx +13 -4
  38. package/app/components/ThoughtCard.tsx +16 -14
  39. package/app/components/UiBlock.tsx +3 -2
  40. package/app/components/Webmentions.tsx +11 -11
  41. package/app/components/WidgetChart.tsx +8 -6
  42. package/app/components/garden/FilePreview.tsx +2 -2
  43. package/app/components/garden/GardenBlock.tsx +7 -0
  44. package/app/components/garden/GardenFile.tsx +2 -2
  45. package/app/components/garden/RefDemo.tsx +62 -0
  46. package/app/components/garden/blocks.tsx +56 -8
  47. package/app/components/garden/patterns.tsx +497 -0
  48. package/app/garden/[...slug]/page.tsx +3 -2
  49. package/app/garden/en/page.tsx +7 -7
  50. package/app/garden/page.tsx +3 -2
  51. package/app/globals.css +49 -1
  52. package/app/lib/a2-meta.ts +26 -3
  53. package/app/lib/content.ts +2 -2
  54. package/app/lib/garden-docs-meta.ts +24 -0
  55. package/app/lib/github-id.mjs +8 -0
  56. package/app/lib/handbook-switch.mjs +12 -0
  57. package/app/lib/i18n.ts +401 -2
  58. package/app/lib/rehype-field-tables.mjs +47 -0
  59. package/app/lib/remark-a2.mjs +18 -8
  60. package/app/lib/remark-link-card.mjs +22 -0
  61. package/app/lib/remark-ref-demos.mjs +42 -0
  62. package/app/lib/tag-filter.ts +9 -2
  63. package/app/not-found.tsx +32 -4
  64. package/app/page.tsx +17 -14
  65. package/app/rss.xml/route.ts +2 -1
  66. package/app/styles/a2.css +338 -0
  67. package/app/styles/shell.css +15 -2
  68. package/app/tags/page.tsx +7 -5
  69. package/app/thoughts/[slug]/page.tsx +2 -1
  70. package/app/thoughts/page/[n]/page.tsx +1 -1
  71. package/app/thoughts/page.tsx +2 -1
  72. package/app/ui-parity/page.tsx +38 -22
  73. package/bin/garden.mjs +129 -19
  74. package/docs/handbook/ai-writing.md +2 -2
  75. package/docs/handbook/assets/reading-layout-en.svg +26 -0
  76. package/docs/handbook/components/card.md +43 -22
  77. package/docs/handbook/components/chart.md +10 -26
  78. package/docs/handbook/components/choice.md +42 -0
  79. package/docs/handbook/components/compare.md +32 -17
  80. package/docs/handbook/components/figure.md +33 -46
  81. package/docs/handbook/components/file.md +10 -15
  82. package/docs/handbook/components/html.md +10 -30
  83. package/docs/handbook/components/interactive.md +9 -35
  84. package/docs/handbook/components/list.md +65 -0
  85. package/docs/handbook/components/relations.md +95 -0
  86. package/docs/handbook/components/stat.md +10 -28
  87. package/docs/handbook/components/timeline.md +13 -28
  88. package/docs/handbook/components.md +210 -19
  89. package/docs/handbook/config.md +5 -3
  90. package/docs/handbook/deploy.md +1 -1
  91. package/docs/handbook/design.md +20 -12
  92. package/docs/handbook/en/ai-writing.md +2 -2
  93. package/docs/handbook/en/components/card.md +42 -21
  94. package/docs/handbook/en/components/chart.md +10 -26
  95. package/docs/handbook/en/components/choice.md +42 -0
  96. package/docs/handbook/en/components/compare.md +30 -15
  97. package/docs/handbook/en/components/figure.md +29 -42
  98. package/docs/handbook/en/components/file.md +9 -14
  99. package/docs/handbook/en/components/html.md +9 -29
  100. package/docs/handbook/en/components/interactive.md +8 -34
  101. package/docs/handbook/en/components/list.md +65 -0
  102. package/docs/handbook/en/components/relations.md +95 -0
  103. package/docs/handbook/en/components/stat.md +9 -27
  104. package/docs/handbook/en/components/timeline.md +9 -24
  105. package/docs/handbook/en/components.md +211 -19
  106. package/docs/handbook/en/config.md +32 -30
  107. package/docs/handbook/en/deploy.md +12 -12
  108. package/docs/handbook/en/design.md +19 -12
  109. package/docs/handbook/en/index.md +2 -2
  110. package/docs/handbook/en/reference/card.md +85 -0
  111. package/docs/handbook/en/reference/chart.md +68 -0
  112. package/docs/handbook/en/reference/choice.md +69 -0
  113. package/docs/handbook/en/reference/compare.md +59 -0
  114. package/docs/handbook/en/reference/figure.md +84 -0
  115. package/docs/handbook/en/reference/file/checklist.md +17 -0
  116. package/docs/handbook/en/reference/file.md +50 -0
  117. package/docs/handbook/en/reference/html.md +62 -0
  118. package/docs/handbook/en/reference/interactive.md +71 -0
  119. package/docs/handbook/en/reference/list.md +72 -0
  120. package/docs/handbook/en/reference/relations.md +113 -0
  121. package/docs/handbook/en/reference/stat.md +60 -0
  122. package/docs/handbook/en/reference/timeline.md +56 -0
  123. package/docs/handbook/en/reference.md +26 -0
  124. package/docs/handbook/en/start.md +2 -2
  125. package/docs/handbook/en/workflow.md +6 -5
  126. package/docs/handbook/en/writing.md +3 -2
  127. package/docs/handbook/index.md +1 -1
  128. package/docs/handbook/reference/card.md +85 -0
  129. package/docs/handbook/reference/chart.md +68 -0
  130. package/docs/handbook/reference/choice.md +69 -0
  131. package/docs/handbook/reference/compare.md +59 -0
  132. package/docs/handbook/reference/figure.md +84 -0
  133. package/docs/handbook/reference/file/checklist.md +17 -0
  134. package/docs/handbook/reference/file.md +50 -0
  135. package/docs/handbook/reference/html.md +62 -0
  136. package/docs/handbook/reference/interactive.md +71 -0
  137. package/docs/handbook/reference/list.md +72 -0
  138. package/docs/handbook/reference/relations.md +113 -0
  139. package/docs/handbook/reference/stat.md +60 -0
  140. package/docs/handbook/reference/timeline.md +56 -0
  141. package/docs/handbook/reference.md +26 -0
  142. package/docs/handbook/start.md +1 -1
  143. package/docs/handbook/workflow.md +6 -5
  144. package/docs/handbook/writing.md +3 -2
  145. package/next.config.ts +6 -0
  146. package/package.json +2 -1
  147. package/public/images/garden/ref-card-book.svg +1 -0
  148. package/public/images/garden/ref-card-lamp.svg +1 -0
  149. package/scripts/build-content.mjs +37 -16
  150. package/scripts/favicon-ico.mjs +9 -6
  151. package/scripts/file-preview.mjs +10 -7
  152. package/scripts/handbook-allowlist.mjs +18 -0
  153. package/scripts/handbook-facts.mjs +305 -11
  154. package/scripts/link-assets.mjs +7 -0
  155. package/scripts/new-post.mjs +18 -9
  156. package/scripts/prepare-pages-output.mjs +30 -18
  157. package/scripts/redirects.mjs +17 -6
  158. package/templates/garden-blocks.md +67 -0
  159. package/templates/garden-snippets.plist +61 -0
  160. package/templates/site/Makefile.en +45 -0
  161. package/templates/site/README.en.md +15 -0
  162. package/templates/site/site.config.en.yaml +55 -0
  163. package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
  164. package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
  165. package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
  166. package/templates/skills-en/garden-post-review/SKILL.md +63 -0
  167. package/templates/skills-en/garden-ui/SKILL.md +118 -0
  168. package/templates/workflows/pages.en.yml +195 -0
  169. package/templates/workflows/pages.yml +195 -0
  170. package/ui/garden/catalog.mjs +205 -4
  171. package/ui/garden/check.mjs +6 -0
  172. package/ui/garden/file-block.mjs +8 -7
  173. package/ui/garden/html-lang.mjs +73 -1
  174. package/ui/garden/markdown-links.mjs +7 -4
  175. package/ui/garden/parse.mjs +45 -0
  176. package/ui/garden/relations.mjs +252 -0
  177. package/ui/garden/search-index.mjs +6 -4
  178. package/ui/garden/source-icon.mjs +54 -0
  179. package/ui/parity-fixtures.mjs +77 -0
  180. package/ui/preview/dist/garden-ui.esm.js +41 -4
  181. package/ui/preview/dist/garden-ui.iife.js +41 -4
  182. package/ui/prompt/rules.md +28 -1
  183. package/ui/react/components/chart.tsx +1 -1
  184. package/ui/react/components/display.tsx +1 -1
  185. package/ui/react/lang.tsx +4 -0
  186. package/ui/react/parity.tsx +8 -6
  187. package/worker/index.ts +8 -2
@@ -0,0 +1,497 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useId, useRef, useState, type ComponentType, type CSSProperties, type ReactNode } from "react";
4
+ import { sourceIcon } from "../../../ui/garden/source-icon.mjs";
5
+ import { circled, labelFits, legendLine, placeMarks, relationKeyAction, RELATION_COMPACT_WIDTH, RELATION_LABEL_FONT_MIN, relationEdges, relationHeights, relationIndex, relationLayout, relationLines, type RelationEdge as Edge } from "../../../ui/garden/relations.mjs";
6
+ import ReactMarkdown from "react-markdown";
7
+ import {
8
+ Bell,
9
+ BookOpen,
10
+ ChartBar,
11
+ ChatCircle,
12
+ CheckCircle,
13
+ Clock,
14
+ Eye,
15
+ FileText,
16
+ Flag,
17
+ Flask,
18
+ Gear,
19
+ Heart,
20
+ Lightbulb,
21
+ LinkSimple,
22
+ MagnifyingGlass,
23
+ MapPin,
24
+ Question,
25
+ ShieldCheck,
26
+ Star,
27
+ Target,
28
+ User,
29
+ Wallet,
30
+ XCircle,
31
+ type IconProps,
32
+ } from "@phosphor-icons/react/ssr";
33
+ import { useUi, useUiLang } from "../UiLang";
34
+ import { markdownPlugins } from "../../lib/markdown-plugins";
35
+
36
+ function Rich({ text }: { text: string }) {
37
+ return (
38
+ <ReactMarkdown remarkPlugins={markdownPlugins} components={{ p: ({ children }) => <>{children}</> }}>
39
+ {String(text ?? "")}
40
+ </ReactMarkdown>
41
+ );
42
+ }
43
+
44
+ /** Line icons an author can name in `icon:`. Unknown names draw nothing. */
45
+ export const GARDEN_ICONS: Record<string, ComponentType<IconProps>> = {
46
+ search: MagnifyingGlass,
47
+ eye: Eye,
48
+ heart: Heart,
49
+ chat: ChatCircle,
50
+ shield: ShieldCheck,
51
+ wallet: Wallet,
52
+ bell: Bell,
53
+ check: CheckCircle,
54
+ x: XCircle,
55
+ star: Star,
56
+ flag: Flag,
57
+ clock: Clock,
58
+ idea: Lightbulb,
59
+ book: BookOpen,
60
+ link: LinkSimple,
61
+ user: User,
62
+ chart: ChartBar,
63
+ gear: Gear,
64
+ file: FileText,
65
+ pin: MapPin,
66
+ flask: Flask,
67
+ target: Target,
68
+ question: Question,
69
+ };
70
+
71
+ export function GardenIcon({ name, size = 18 }: { name: unknown; size?: number }) {
72
+ const Icon = typeof name === "string" ? GARDEN_ICONS[name.trim().toLowerCase()] : undefined;
73
+ return Icon ? <Icon className="g-ic" size={size} weight="regular" aria-hidden="true" /> : null;
74
+ }
75
+
76
+ const textsOf = (value: unknown) => (value == null ? [] : Array.isArray(value) ? value.map(String) : [String(value)]);
77
+ const itemsOf = (props: Record<string, unknown>) =>
78
+ (Array.isArray(props.items) ? props.items : []).map((item) => (typeof item === "string" ? { text: item } : (item ?? {}) as Record<string, unknown>));
79
+
80
+ /* ---------- list: icon rows (compact card) or icon steps (titled rows) ---------- */
81
+
82
+ export function GardenList({ props }: { props: Record<string, unknown> }) {
83
+ const items = itemsOf(props);
84
+ const titled = items.some((item) => item.title);
85
+ return (
86
+ <figure className={`g-list ${titled ? "g-list-steps" : "g-list-rows"}`}>
87
+ {typeof props.title === "string" ? <p className="g-list-h"><Rich text={props.title} /></p> : null}
88
+ <ul>
89
+ {items.map((item, index) => (
90
+ <li key={index}>
91
+ <span className="g-list-ic"><GardenIcon name={item.icon} size={titled ? 20 : 17} /></span>
92
+ <div className="g-list-b">
93
+ {item.title ? <p className="g-list-t"><Rich text={String(item.title)} /></p> : null}
94
+ {textsOf(item.text).map((text, textIndex) => <p key={textIndex} className="g-list-x"><Rich text={text} /></p>)}
95
+ </div>
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
100
+ </figure>
101
+ );
102
+ }
103
+
104
+ /* ---------- choice: two or three option cards, pick one ---------- */
105
+
106
+ export function GardenChoice({ props }: { props: Record<string, unknown> }) {
107
+ const items = itemsOf(props);
108
+ const [picked, setPicked] = useState<number | null>(null);
109
+ const base = useId();
110
+ const title = typeof props.title === "string" ? props.title : "";
111
+ const move = (index: number, step: number) => {
112
+ const next = (index + step + count) % count;
113
+ setPicked(next);
114
+ document.getElementById(`${base}-${next}`)?.focus();
115
+ };
116
+ const none = props.none && typeof props.none === "object" ? (props.none as Record<string, unknown>) : null;
117
+ const noneLabel = none && typeof none.label === "string" ? none.label : "";
118
+ const count = items.length + (noneLabel ? 1 : 0);
119
+ const chosen = picked === null ? null : picked < items.length ? items[picked] : none;
120
+ const reply = chosen ? String(chosen.reply ?? "") : "";
121
+ return (
122
+ <figure className="g-choice">
123
+ {title ? <p className="g-choice-h" id={`${base}-h`}><Rich text={title} /></p> : null}
124
+ <div className="g-choice-group" role="radiogroup" aria-labelledby={title ? `${base}-h` : undefined}>
125
+ <div className="g-choice-opts" data-n={items.length}>
126
+ {items.map((item, index) => {
127
+ const on = picked === index;
128
+ return (
129
+ <button
130
+ key={index}
131
+ id={`${base}-${index}`}
132
+ type="button"
133
+ role="radio"
134
+ aria-checked={on}
135
+ tabIndex={on || (picked === null && index === 0) ? 0 : -1}
136
+ className="g-choice-opt"
137
+ onClick={() => setPicked(index)}
138
+ onKeyDown={(event) => {
139
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); move(index, 1); }
140
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); move(index, -1); }
141
+ }}
142
+ >
143
+ {item.icon ? <span className="g-choice-ic"><GardenIcon name={item.icon} size={26} /></span> : null}
144
+ {item.value != null ? <span className="g-choice-v">{String(item.value)}</span> : null}
145
+ {item.text ? <span className="g-choice-x"><Rich text={String(item.text)} /></span> : null}
146
+ {item.label ? <span className="g-choice-l"><span className="g-choice-dot" aria-hidden="true" />{String(item.label)}</span> : null}
147
+ </button>
148
+ );
149
+ })}
150
+ </div>
151
+ {noneLabel ? (
152
+ <button
153
+ id={`${base}-${items.length}`}
154
+ type="button"
155
+ role="radio"
156
+ aria-checked={picked === items.length}
157
+ tabIndex={picked === items.length ? 0 : -1}
158
+ className="g-choice-none"
159
+ onClick={() => setPicked(items.length)}
160
+ onKeyDown={(event) => {
161
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); move(items.length, 1); }
162
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); move(items.length, -1); }
163
+ }}
164
+ >{noneLabel}</button>
165
+ ) : null}
166
+ </div>
167
+ {items.some((item) => item.reply) || none?.reply ? <p className="g-choice-reply" aria-live="polite" hidden={!reply}>{reply ? <Rich text={reply} /> : null}</p> : null}
168
+ {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
169
+ </figure>
170
+ );
171
+ }
172
+
173
+ /* ---------- relations: 3-6 key elements on a circle, with explicit directed links ---------- */
174
+
175
+ type Box = { x: number; y: number; w: number; h: number };
176
+ type Pt = { x: number; y: number };
177
+ type Drawn = { d: string; dashed: boolean; both: boolean; label: string; at: Pt };
178
+
179
+ const center = (box: Box): Pt => ({ x: box.x + box.w / 2, y: box.y + box.h / 2 });
180
+
181
+ /** Where the ray from the box centre towards `to` leaves the box (plus a small gap). */
182
+ function edge(box: Box, to: Pt, gap = 6): Pt {
183
+ const c = center(box);
184
+ const dx = to.x - c.x;
185
+ const dy = to.y - c.y;
186
+ if (!dx && !dy) return c;
187
+ const scale = Math.min(dx ? (box.w / 2 + gap) / Math.abs(dx) : Infinity, dy ? (box.h / 2 + gap) / Math.abs(dy) : Infinity);
188
+ return { x: c.x + dx * scale, y: c.y + dy * scale };
189
+ }
190
+
191
+ type Placed = Drawn & { badge: number };
192
+
193
+ let measureCanvas: HTMLCanvasElement | null = null;
194
+ function textWidth(text: string, font: string) {
195
+ measureCanvas ??= document.createElement("canvas");
196
+ const ctx = measureCanvas.getContext("2d");
197
+ if (!ctx) return text.length * 7;
198
+ ctx.font = font;
199
+ return ctx.measureText(text).width;
200
+ }
201
+
202
+ /** The longest start of `full`, cut after a word, that fits in `lines` lines of `el` with "…" added; null when it all fits. */
203
+ function fitLines(el: HTMLElement, full: string, lines: number): string | null {
204
+ const probe = el.cloneNode(false) as HTMLElement;
205
+ probe.style.cssText = `display:block;-webkit-line-clamp:none;overflow:visible;position:absolute;visibility:hidden;left:0;top:0;width:${el.clientWidth}px`;
206
+ el.parentElement?.appendChild(probe);
207
+ const style = getComputedStyle(probe);
208
+ const line = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.35;
209
+ const fits = (text: string) => { probe.textContent = text; return probe.scrollHeight <= line * lines + 1 && probe.scrollWidth <= probe.clientWidth + 1; };
210
+ let result: string | null = null;
211
+ if (!fits(full)) {
212
+ // CJK text has no spaces: every character is a place to cut; otherwise cut between words.
213
+ const parts = /\s/u.test(full.trim()) ? full.split(/(?<=\s)/u) : [...full];
214
+ let lo = 0;
215
+ let hi = parts.length - 1;
216
+ while (lo < hi) {
217
+ const mid = Math.ceil((lo + hi) / 2);
218
+ if (fits(`${parts.slice(0, mid).join("").trimEnd()}\u2060…`)) lo = mid; else hi = mid - 1;
219
+ }
220
+ if (lo === 0 && parts.length > 1) {
221
+ // Even the first word is too wide: cut inside it as a last resort.
222
+ const chars = [...parts[0].trimEnd()];
223
+ let c = chars.length;
224
+ while (c > 1 && !fits(`${chars.slice(0, c).join("")}\u2060…`)) c -= 1;
225
+ result = `${chars.slice(0, c).join("")}\u2060…`;
226
+ } else result = `${parts.slice(0, Math.max(lo, 1)).join("").trimEnd()}\u2060…`;
227
+ }
228
+ probe.remove();
229
+ return result;
230
+ }
231
+
232
+ export function GardenRelations({ props }: { props: Record<string, unknown> }) {
233
+ const t = useUi();
234
+ const lang = useUiLang();
235
+ const items = itemsOf(props).slice(0, 6); // parse rejects counts outside 3..6
236
+ const titles = items.map((item) => String(item.title ?? ""));
237
+ const edges = relationEdges(props, titles);
238
+ const lines = relationLines(edges, titles);
239
+ const n = items.length;
240
+ const centre = props.center == null ? -1 : relationIndex(props.center, titles);
241
+ const layout = relationLayout(n, centre);
242
+ const ringRef = useRef<HTMLDivElement>(null);
243
+ const [drawn, setDrawn] = useState<Placed[]>([]);
244
+ const [size, setSize] = useState({ w: 0, h: 0 });
245
+ const [compact, setCompact] = useState(false);
246
+ const [open, setOpen] = useState<number | null>(null);
247
+ // Titles and descriptions that do not fit their line limit, cut at a word with "…" (index -> [title, text]).
248
+ const [clipped, setClipped] = useState<Record<number, [string | null, string | null]>>({});
249
+ const fullText = useRef<{ key: string; text: [string, string][] } | null>(null);
250
+ const base = useId().replace(/:/gu, "");
251
+ const edgeKey = JSON.stringify({ edges, centre, n });
252
+
253
+ useEffect(() => {
254
+ const ring = ringRef.current;
255
+ if (!ring) return;
256
+ const { edges: parsed, centre: middle, n: count } = JSON.parse(edgeKey) as { edges: Edge[]; centre: number; n: number };
257
+ let lastWidth = -1;
258
+ const draw = () => {
259
+ const width = ring.clientWidth;
260
+ if (!width || width === lastWidth) return;
261
+ lastWidth = width;
262
+ const small = width < RELATION_COMPACT_WIDTH;
263
+ // Apply the compact styles before measuring, so node sizes match what React renders next.
264
+ const figure = ring.closest("figure");
265
+ if (small) figure?.setAttribute("data-compact", "true");
266
+ else figure?.removeAttribute("data-compact");
267
+ setCompact(small);
268
+ const { spots, outer } = relationLayout(count, middle, small);
269
+ const nodes = [...ring.querySelectorAll<HTMLElement>(":scope > .g-rel-node")];
270
+ // Same layout at every width: place each node by percent of the width, then size the canvas to the nodes.
271
+ nodes.forEach((node, i) => { node.style.left = `${spots[i].x}%`; node.style.width = `${spots[i].w}%`; });
272
+ // Cut long text ourselves, at a word, so the "…" line stays centred (Chrome's line-clamp centres
273
+ // the last line as if the cut words were still there). The CSS clamp stays as the no-script fallback.
274
+ if (!fullText.current || fullText.current.key !== edgeKey) {
275
+ fullText.current = { key: edgeKey, text: nodes.map((node) => [node.querySelector(".g-rel-t")?.textContent ?? "", node.querySelector(".g-rel-x")?.textContent ?? ""]) };
276
+ }
277
+ const cut: Record<number, [string | null, string | null]> = {};
278
+ nodes.forEach((node, i) => {
279
+ const pair: [string | null, string | null] = [null, null];
280
+ (["g-rel-t", "g-rel-x"] as const).forEach((cls, k) => {
281
+ const el = node.querySelector<HTMLElement>(`.${cls}`);
282
+ const full = fullText.current?.text[i][k] ?? "";
283
+ if (!el || !full || getComputedStyle(el).display === "none") return;
284
+ pair[k] = fitLines(el, full, k === 0 ? 2 : small ? 2 : 3);
285
+ });
286
+ if (pair[0] !== null || pair[1] !== null) cut[i] = pair;
287
+ });
288
+ setClipped(cut);
289
+ const minRy = small ? Math.max(90, width * 0.26) : Math.min(200, Math.max(120, width * 0.2)) * (middle >= 0 ? 1.25 : 1);
290
+ const heights = nodes.map((node) => node.offsetHeight);
291
+ const place = (ry: number) => {
292
+ const { ys, height } = relationHeights(spots, heights, small ? 40 : 48, ry);
293
+ nodes.forEach((node, i) => { node.style.top = `${ys[i]}px`; });
294
+ ring.style.height = `${Math.ceil(height)}px`;
295
+ const frame = ring.getBoundingClientRect();
296
+ const boxes = nodes.map((node) => {
297
+ const r = node.getBoundingClientRect();
298
+ return { x: r.left - frame.left, y: r.top - frame.top, w: r.width, h: r.height };
299
+ });
300
+ return { frame, boxes };
301
+ };
302
+ // Every line keeps a visible stretch between its two boxes (24px narrow, 48px wide):
303
+ // open the ring up a step at a time until the shortest line is long enough.
304
+ const target = small ? 24 : 48;
305
+ const shortest = (boxes: Box[]) => Math.min(Infinity, ...parsed.map((link) => {
306
+ const a = edge(boxes[link.from], center(boxes[link.to]), link.both ? (small ? 7 : 8) : 6);
307
+ const b = edge(boxes[link.to], center(boxes[link.from]), small ? 7 : 8);
308
+ return Math.hypot(b.x - a.x, b.y - a.y);
309
+ }));
310
+ let ry = minRy;
311
+ let { frame, boxes } = place(ry);
312
+ for (let step = 0; step < 10; step += 1) {
313
+ const short = shortest(boxes);
314
+ if (short >= target) break;
315
+ ry += Math.max(12, (target - short) * 1.5);
316
+ ({ frame, boxes } = place(ry));
317
+ }
318
+ const hub = { x: frame.width / 2, y: frame.height / 2 };
319
+ // Edges between the same pair of nodes each get their own arc, so a dashed line never hides under a solid one.
320
+ const pairKey = (l: Edge) => (l.from < l.to ? `${l.from}-${l.to}` : `${l.to}-${l.from}`);
321
+ const pairCount = new Map<string, number>();
322
+ const pairIndex = parsed.map((l) => { const k = pairKey(l); const i = pairCount.get(k) ?? 0; pairCount.set(k, i + 1); return i; });
323
+ const geometry = parsed.map((link, index) => {
324
+ const from = boxes[link.from];
325
+ const to = boxes[link.to];
326
+ const cf = center(from);
327
+ const ct = center(to);
328
+ const endGap = small ? 7 : 8;
329
+ const gapStart = link.both ? endGap : 6;
330
+ const rf = spots[link.from].ring;
331
+ const rt = spots[link.to].ring;
332
+ const neighbours = rf >= 0 && rt >= 0 && outer > 3 && (Math.abs(rf - rt) === 1 || Math.abs(rf - rt) === outer - 1);
333
+ const total = pairCount.get(pairKey(link)) ?? 1;
334
+ const mid = { x: (cf.x + ct.x) / 2, y: (cf.y + ct.y) / 2 };
335
+ const span = Math.hypot(ct.x - cf.x, ct.y - cf.y) || 1;
336
+ // Normal in a direction that does not depend on which end is "from", so pair offsets spread apart.
337
+ const lo = link.from < link.to ? cf : ct;
338
+ const hi = link.from < link.to ? ct : cf;
339
+ let nx = -(hi.y - lo.y) / span;
340
+ let ny = (hi.x - lo.x) / span;
341
+ let bow = 0;
342
+ if (neighbours) {
343
+ // Next to each other on the outer ring: a gentle arc that bows outward, at every width.
344
+ if (nx * (mid.x - hub.x) + ny * (mid.y - hub.y) < 0) { nx = -nx; ny = -ny; }
345
+ bow = Math.min(small ? 22 : 40, span / 4);
346
+ }
347
+ if (total > 1) bow += (pairIndex[index] - (total - 1) / 2) * (small ? 30 : 40) * 2;
348
+ if (!bow) {
349
+ const a = edge(from, ct, gapStart);
350
+ const b = edge(to, cf, endGap);
351
+ const at = (k: number) => ({ x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k });
352
+ return { d: `M${a.x},${a.y} L${b.x},${b.y}`, a, b, at, link };
353
+ }
354
+ const c = { x: mid.x + nx * bow, y: mid.y + ny * bow };
355
+ const a = edge(from, c, gapStart);
356
+ const b = edge(to, c, endGap);
357
+ const at = (k: number) => ({ x: (1 - k) ** 2 * a.x + 2 * (1 - k) * k * c.x + k ** 2 * b.x, y: (1 - k) ** 2 * a.y + 2 * (1 - k) * k * c.y + k ** 2 * b.y });
358
+ return { d: `M${a.x},${a.y} Q${c.x},${c.y} ${b.x},${b.y}`, a, b, at, link };
359
+ });
360
+ // Labels that do not fit on their line at 12px become circled numbers listed under the graph.
361
+ // Every mark gets a spot that overlaps no node and no other mark.
362
+ const font = `500 ${RELATION_LABEL_FONT_MIN + 0.5}px ${getComputedStyle(ring).fontFamily}`;
363
+ const marks = geometry.map((g) => {
364
+ if (!g.link.label) return { a: g.a, b: g.b, at: g.at, w: 0, h: 0, badge: false };
365
+ const size = { width: textWidth(g.link.label, font) + 12, height: 20 };
366
+ return { a: g.a, b: g.b, at: g.at, w: size.width, h: size.height, badge: !labelFits(size, g.a, g.b, g.at(0.5), []) };
367
+ });
368
+ const spots2 = placeMarks(marks, boxes, { w: frame.width, h: frame.height });
369
+ let badge = 0;
370
+ setDrawn(geometry.map((g, i) => {
371
+ const spot = spots2[i];
372
+ const number = spot?.badge ? badge++ : -1;
373
+ return { d: g.d, dashed: g.link.dashed, both: g.link.both, label: g.link.label, at: spot ?? g.at(0.5), badge: number };
374
+ }));
375
+ setSize({ w: frame.width, h: frame.height });
376
+ };
377
+ draw();
378
+ if (typeof ResizeObserver === "undefined") return;
379
+ const observer = new ResizeObserver(() => draw());
380
+ observer.observe(ring);
381
+ return () => observer.disconnect();
382
+ }, [edgeKey]);
383
+
384
+ const label = typeof props.title === "string" ? props.title : t.relations;
385
+ const legend = drawn.map((line, index) => ({ line, edge: edges[index] })).filter(({ line }) => line.badge >= 0);
386
+ return (
387
+ <figure
388
+ className="g-rel"
389
+ role="group"
390
+ aria-label={label}
391
+ data-compact={compact ? "true" : undefined}
392
+ onKeyDown={(event) => {
393
+ // Esc closes the details panel and puts focus back on the node that opened it.
394
+ const action = relationKeyAction(event.key, open);
395
+ if (!action.close) return;
396
+ event.preventDefault();
397
+ setOpen(null);
398
+ if (action.focus !== null) ringRef.current?.querySelectorAll<HTMLElement>(":scope > .g-rel-node")[action.focus]?.focus();
399
+ }}
400
+ >
401
+ {typeof props.title === "string" ? <p className="g-rel-h"><Rich text={props.title} /></p> : null}
402
+ <div className="g-rel-ring" ref={ringRef} data-n={n} data-center={layout.centre >= 0 ? "true" : undefined}>
403
+ {items.map((item, index) => {
404
+ const spot = layout.spots[index];
405
+ return (
406
+ <button
407
+ type="button"
408
+ className="g-rel-node"
409
+ key={index}
410
+ data-i={index + 1}
411
+ data-center={index === layout.centre ? "true" : undefined}
412
+ aria-expanded={open === index}
413
+ aria-controls={`${base}-d`}
414
+ tabIndex={0}
415
+ onClick={() => setOpen(open === index ? null : index)}
416
+ style={{ "--x": `${spot.x}%`, "--y": `${50 + spot.s * 38}%`, "--w": `${spot.w}%` } as CSSProperties}
417
+ >
418
+ <span className="g-rel-t">{clipped[index]?.[0] ?? <Rich text={titles[index]} />}</span>
419
+ {item.text ? <span className="g-rel-x">{clipped[index]?.[1] ?? <Rich text={String(item.text)} />}</span> : null}
420
+ {lines[index].length ? (
421
+ <span className="g-rel-out">
422
+ {lines[index].map((line, k) => (
423
+ <span key={k}>
424
+ {k ? " · " : null}
425
+ {line.mark} {line.to}{line.label ? ` ${line.label}` : ""}
426
+ </span>
427
+ ))}
428
+ </span>
429
+ ) : null}
430
+ </button>
431
+ );
432
+ })}
433
+ {size.w ? (
434
+ <svg className="g-rel-svg" width={size.w} height={size.h} viewBox={`0 0 ${size.w} ${size.h}`} aria-hidden="true">
435
+ <defs>
436
+ <marker id={`${base}-a`} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
437
+ <path d="M1,1 L9,5 L1,9 Z" />
438
+ </marker>
439
+ </defs>
440
+ {drawn.map((line, index) => (
441
+ <path
442
+ key={index}
443
+ d={line.d}
444
+ className={line.dashed ? "dash" : undefined}
445
+ markerEnd={`url(#${base}-a)`}
446
+ markerStart={line.both ? `url(#${base}-a)` : undefined}
447
+ />
448
+ ))}
449
+ </svg>
450
+ ) : null}
451
+ {drawn.map((line, index) => line.label ? (
452
+ <span
453
+ key={`l${index}`}
454
+ className={line.badge >= 0 ? "g-rel-label g-rel-badge" : "g-rel-label"}
455
+ aria-hidden="true"
456
+ style={{ left: `${line.at.x}px`, top: `${line.at.y}px` }}
457
+ >{line.badge >= 0 ? circled(line.badge) : line.label}</span>
458
+ ) : null)}
459
+ </div>
460
+ {open !== null ? (
461
+ <div className="g-rel-detail" id={`${base}-d`} aria-live="polite">
462
+ <span className="g-rel-detail-t"><Rich text={titles[open]} /></span>
463
+ {items[open]?.text ? <span className="g-rel-detail-x"><Rich text={String(items[open].text)} /></span> : null}
464
+ </div>
465
+ ) : null}
466
+ {legend.length ? (
467
+ <ol className="g-rel-legend" aria-label={t.relationsLegend}>
468
+ {legend.map(({ line, edge: e }) => <li key={line.badge}>{legendLine(line.badge, e, titles, lang === "en" ? "en" : "zh")}</li>)}
469
+ </ol>
470
+ ) : null}
471
+ {typeof props.caption === "string" ? <figcaption><Rich text={props.caption} /></figcaption> : null}
472
+ </figure>
473
+ );
474
+ }
475
+
476
+ export function VerdictIcon({ verdict }: { verdict: string }): ReactNode {
477
+ if (verdict === "good") return <CheckCircle className="g-ic g-verdict-ic good" size={20} aria-hidden="true" />;
478
+ if (verdict === "bad") return <XCircle className="g-ic g-verdict-ic bad" size={20} aria-hidden="true" />;
479
+ return null;
480
+ }
481
+
482
+ /** The site's mark: its build-time favicon, else the registrable domain's first letter. */
483
+ type Favicons = Record<string, { file?: string } | string>;
484
+ const FaviconContext = createContext<Favicons>({});
485
+
486
+ /** The build-time favicons, handed down from the server so client blocks match inline links. */
487
+ export function FaviconProvider({ favicons, children }: { favicons?: Favicons; children: ReactNode }) {
488
+ return <FaviconContext.Provider value={favicons ?? {}}>{children}</FaviconContext.Provider>;
489
+ }
490
+
491
+ export function SourceMark({ href, icon }: { href: string; icon?: string }) {
492
+ const mark = sourceIcon(href, useContext(FaviconContext), icon);
493
+ return mark.src
494
+ // eslint-disable-next-line @next/next/no-img-element -- a 14px site icon
495
+ ? <img className="g-src-fav" src={mark.src} alt="" width={14} height={14} decoding="async" aria-hidden="true" />
496
+ : <span className="g-src-ic" aria-hidden="true">{mark.letter}</span>;
497
+ }
@@ -1,6 +1,7 @@
1
1
  import { notFound } from "next/navigation";
2
2
  import { HandbookDoc, handbookDocMetadata } from "../../components/HandbookView";
3
3
  import { gardenDocs } from "../../lib/content";
4
+ import { siteLang } from "../../lib/i18n";
4
5
  import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
5
6
 
6
7
  const find = (slug: string[]) => gardenDocs.find((doc) => doc.path === slug.join("/"));
@@ -14,7 +15,7 @@ export function generateStaticParams() {
14
15
 
15
16
  export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }) {
16
17
  const { slug } = await params;
17
- return handbookDocMetadata("zh", find(slug));
18
+ return handbookDocMetadata(siteLang, find(slug));
18
19
  }
19
20
 
20
21
  export default async function GardenDocPage({ params }: { params: Promise<{ slug: string[] }> }) {
@@ -24,5 +25,5 @@ export default async function GardenDocPage({ params }: { params: Promise<{ slug
24
25
  if (isStaticPlaceholder(slug)) return null;
25
26
  notFound();
26
27
  }
27
- return <HandbookDoc lang="zh" doc={doc} />;
28
+ return <HandbookDoc lang={siteLang} doc={doc} />;
28
29
  }
@@ -1,14 +1,14 @@
1
1
  import { notFound } from "next/navigation";
2
- import { HandbookOverview, handbookLanguages } from "../../components/HandbookView";
2
+ import { HandbookOverview, handbookLanguages, handbookOverviewMetadata } from "../../components/HandbookView";
3
3
  import { pageAlternates } from "../../lib/page-alternates";
4
- import { HANDBOOK_UI } from "../../lib/garden-docs-meta";
5
4
  import { gardenIndexDocEn } from "../../lib/content";
6
5
 
7
- export const metadata = {
8
- title: HANDBOOK_UI.en.name,
9
- description: gardenIndexDocEn?.description ?? "",
10
- alternates: pageAlternates("/garden/en", handbookLanguages("en", "index")),
11
- };
6
+ void pageAlternates;
7
+ void handbookLanguages;
8
+
9
+ export async function generateMetadata() {
10
+ return handbookOverviewMetadata("en", gardenIndexDocEn);
11
+ }
12
12
 
13
13
  export default function GardenEnPage() {
14
14
  if (!gardenIndexDocEn) notFound();
@@ -2,13 +2,14 @@ import { HandbookOverview, handbookLanguages } from "../components/HandbookView"
2
2
  import { pageAlternates } from "../lib/page-alternates";
3
3
  import { HANDBOOK_UI } from "../lib/garden-docs-meta";
4
4
  import { gardenIndexDoc } from "../lib/content";
5
+ import { siteLang } from "../lib/i18n";
5
6
 
6
7
  export const metadata = {
7
- title: HANDBOOK_UI.zh.name,
8
+ title: HANDBOOK_UI[siteLang].name,
8
9
  description: gardenIndexDoc?.description ?? "",
9
10
  alternates: pageAlternates("/garden", handbookLanguages("zh", "index")),
10
11
  };
11
12
 
12
13
  export default function GardenPage() {
13
- return <HandbookOverview lang="zh" />;
14
+ return <HandbookOverview lang={siteLang} />;
14
15
  }
package/app/globals.css CHANGED
@@ -2160,6 +2160,22 @@ button {
2160
2160
  margin: 7px 0;
2161
2161
  }
2162
2162
 
2163
+ /* Loose lists (items separated by blank lines) render li > p. The item keeps
2164
+ the list rhythm; paragraphs only space apart inside the same item. */
2165
+ .markdown-body :is(ol, ul):not(.g-steps) > li > p {
2166
+ margin: 0;
2167
+ }
2168
+
2169
+ .markdown-body li > p + p,
2170
+ .markdown-body li > p + :is(pre, blockquote, figure, .table-wrap),
2171
+ .markdown-body li > :is(pre, blockquote, figure, .table-wrap) + p {
2172
+ margin-top: 12px;
2173
+ }
2174
+
2175
+ .markdown-body li > p:empty {
2176
+ display: none;
2177
+ }
2178
+
2163
2179
  .markdown-body li::marker {
2164
2180
  color: var(--accent);
2165
2181
  }
@@ -2611,7 +2627,7 @@ button {
2611
2627
 
2612
2628
  .markdown-body .mermaid-block {
2613
2629
  margin: 34px 0;
2614
- background: var(--card);
2630
+ background: transparent;
2615
2631
  border: 1px solid var(--line);
2616
2632
  }
2617
2633
 
@@ -3923,6 +3939,16 @@ html[data-theme="dark"] .search-footer {
3923
3939
  transition: opacity 160ms ease, transform 160ms ease, visibility 0s;
3924
3940
  }
3925
3941
 
3942
+ /* The floating contents button sits over the bottom 76px of the screen:
3943
+ keep that much room after the last line, and keep anchors clear of it. */
3944
+ html:has(.toc-mobile-trigger) {
3945
+ scroll-padding-bottom: calc(76px + env(safe-area-inset-bottom));
3946
+ }
3947
+
3948
+ body:has(.toc-mobile-trigger) main {
3949
+ padding-bottom: calc(76px + env(safe-area-inset-bottom));
3950
+ }
3951
+
3926
3952
  :root[data-theme="dark"] .toc-mobile-trigger,
3927
3953
  html[data-theme="dark"] .toc-mobile-trigger,
3928
3954
  :root[data-theme="dark"] .toc-nojs-trigger,
@@ -5714,3 +5740,25 @@ html[data-theme="dark"] .search-footer {
5714
5740
  transition: transform 220ms ease, visibility 0s;
5715
5741
  }
5716
5742
  }
5743
+
5744
+ /* Mermaid sits on the page: no fills, body-text ink for lines and labels, so
5745
+ it reads in both themes. !important beats the svg's own id-scoped styles. */
5746
+ .g-mermaid svg { background: transparent !important; }
5747
+ .g-mermaid svg :is(.node, .cluster, .actor, .labelBox, .edgeLabel, .labelBkg) :is(rect, polygon, circle, ellipse, path),
5748
+ .g-mermaid svg :is(rect.actor, .edgeLabel rect, .labelBkg, rect.background) {
5749
+ fill: transparent !important;
5750
+ stroke: var(--ink) !important;
5751
+ }
5752
+ .g-mermaid svg :is(.edgeLabel, .labelBkg) rect { stroke: none !important; }
5753
+ .g-mermaid svg :is(.edgeLabel, .labelBkg) { background: transparent !important; }
5754
+ .g-mermaid svg :is(.flowchart-link, .edgePath path, .messageLine0, .messageLine1, .relation, .transition, line) {
5755
+ stroke: var(--ink) !important;
5756
+ }
5757
+ .g-mermaid svg :is(marker path, .arrowheadPath, .arrowMarkerPath, marker) {
5758
+ fill: var(--ink) !important;
5759
+ stroke: var(--ink) !important;
5760
+ }
5761
+ .g-mermaid svg :is(text, tspan, .nodeLabel, .edgeLabel, .label, .messageText, .actor > tspan) {
5762
+ fill: var(--ink) !important;
5763
+ color: var(--ink) !important;
5764
+ }