hd-garden 0.14.3 → 0.14.6

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 (38) hide show
  1. package/.agents/skills/garden-compose/SKILL.md +3 -3
  2. package/.agents/skills/garden-compose/references/layout-recipes.md +19 -4
  3. package/.agents/skills/garden-compose/references/measured-connectors.js +94 -0
  4. package/.agents/skills/garden-compose/references/rendering-model.md +10 -3
  5. package/.agents/skills/garden-compose/references/visual-selection.md +1 -0
  6. package/.agents/skills/garden-compose/references/workflow.md +7 -0
  7. package/app/components/PostReadingView.tsx +1 -1
  8. package/app/components/SiteHeader.tsx +8 -0
  9. package/app/components/TableOfContents.tsx +50 -14
  10. package/app/globals.css +10 -64
  11. package/app/lib/i18n.ts +1 -1
  12. package/app/lib/reading-navigation.ts +4 -0
  13. package/app/styles/a2.css +14 -1
  14. package/app/styles/shell.css +27 -4
  15. package/bin/garden.mjs +8 -2
  16. package/docs/handbook/components/compose.md +2 -0
  17. package/docs/handbook/en/components/compose.md +2 -0
  18. package/docs/handbook/en/reference/compose.md +7 -0
  19. package/docs/handbook/reference/compose.md +7 -0
  20. package/editors/dist/garden-typora-0.14.6.zip +0 -0
  21. package/editors/dist/typora/main.js +76 -77
  22. package/editors/dist/typora/manifest.json +7 -3
  23. package/editors/dist/typora/style.css +32 -18
  24. package/editors/dist/vscode/{garden-ui-0.14.3.vsix → garden-ui-0.14.6.vsix} +0 -0
  25. package/package.json +3 -2
  26. package/scripts/handbook-facts.mjs +5 -4
  27. package/scripts/pack-editors.mjs +8 -1
  28. package/scripts/rendered-site.mjs +130 -0
  29. package/scripts/sync-editor-version.mjs +5 -5
  30. package/templates/skills-en/garden-compose/SKILL.md +3 -3
  31. package/ui/compose/compose.css +6 -8
  32. package/ui/host.mjs +0 -1
  33. package/ui/preview/dist/garden-ui.css +32 -18
  34. package/ui/preview/dist/garden-ui.esm.js +77 -78
  35. package/ui/preview/dist/garden-ui.iife.js +77 -78
  36. package/ui/react/components/chart.tsx +2 -1
  37. package/ui/react/components/sandbox.tsx +3 -0
  38. package/ui/react/ui.css +12 -9
@@ -8,14 +8,14 @@ description: "将原文与一句效果或参考图,编译为 Garden Compose
8
8
  ## 短决策链:问题 → 约束 → 推荐路线 → 原文 → 验证
9
9
  1. 作者只需原文+一句效果/参考图+可选不要什么。分开事实、效果与修改范围;保留措辞、顺序、完整段落和资源,不补摘要、数据、因果。普通 Markdown 足够就不加组件。
10
10
  2. 选型必读 `references/visual-selection.md`。AI 内部先判断移动阅读任务(扫读/顺序/对照/空间关系),必读 `references/mobile-contract.md`,写桌面+窄屏约束卡,再取**唯一推荐路线**;不是限定合法组件的白名单。只在事实关系、隐藏内容、阅读顺序等重大取舍不明时询问;其余按已记录默认收敛,不让作者填技术表单。
11
- 3. 按路线只读 `references/layout-recipes.md` 对应小节;陌生组合/参考图再读 `references/rendering-model.md`。需要新组合时读 `references/composition-patterns.md`,仍先建立共享容器。frame/tone/size是正交选择,不因微调换组件家族。
11
+ 3. 原文关键数值+标签/单位用`metric`;独立解释类别用`equal-cards`,不造数值。标题+徽标用`row align="center" justify="between" wrap="nowrap"`:justify管主轴、align管交叉轴,标题保留内容宽;nowrap仅约束局部,长标题仍须窄屏验收。按路线只读 `references/layout-recipes.md` 对应小节;陌生组合/参考图再读 `references/rendering-model.md`。需要新组合时读 `references/composition-patterns.md`,仍先建立共享容器。frame/tone/size是正交选择,不因微调换组件家族。
12
12
  4. 作者组件只用 `garden compose`;内部静态/语义/交互能力按需读 `references/static-play.md`。语义字段、图标、允许子项以 `npx --no-install garden ui schema` 为准,按需存到 `.garden`,不预读整本目录。Markdown/安全HTML岛保存完整块内容;受限表达式不等于任意JS。
13
13
 
14
14
  ## 写入与验证
15
15
  1. 必读 `references/workflow.md`:scan,保存id/行号、完整围栏base hash与原文。首次插入意图块和已有块apply不同;未编译意图不是完成品。
16
- 2. 按 `references/rendering-model.md` 的集中禁令与配方生成候选。正文默认继承文章动态字体/字号/行高;不缩字凑版。显式size只响应明确要求或真实层级。框读frames、颜色读colors;不要把场景框、内部边框和底色混为一谈。
16
+ 2. 先按 `references/rendering-model.md` 量宿主可用宽度与各层padding,再生成候选;不把主题/运行时挤压误诊成原文过长。正文默认继承文章动态字体/字号/行高;不缩字凑版。显式size只响应明确要求或真实层级。框读frames、颜色读colors;不要把场景框、内部边框和底色混为一谈。
17
17
  3. 属性级微调优先,但当前apply仍替换完整围栏:候选文件+稳定id(没有才用scan行号)+原完整base hash;冲突即重读,不绕过保护。不误伤邻块或改写intent原话。
18
18
  4. `scripts/check.mjs` 与 `garden check`保持原契约;有内部约束卡可另跑 `scripts/lint-layout.mjs`(workflow说明)。风险提示不等于语法错误;静态属性可能被丢弃,解析通过不等于生效。
19
- 5. 所有新布局都默认承担移动端责任,不等作者指出手机问题。按mobile-contract先验最窄屏和放大正文,再在完整主题与真实CSS顺序下检查桌面、computed样式、几何及阅读密度;交互另测。分别报告:结构/解析、渲染、浏览器几何/交互、独立AI生成试验。没有对应证据就写未验证。
19
+ 5. 所有新布局都默认承担移动端责任,不等作者指出手机问题。按mobile-contract先验最窄屏和放大正文,再在完整主题与真实CSS顺序下检查桌面、computed样式、几何及阅读密度;交互另测正常首页Link进入、返回再进、后退/前进与直接链接;sandbox不能以强刷或_top代替修复。静态构建自动验导出资源,也可用 `garden verify --rendered` 重跑,但它不证明浏览器导航。分别报告:结构/解析、渲染、浏览器几何/交互、独立AI生成试验。没有对应证据就写未验证。
20
20
 
21
21
  脚本仅使用已安装本地引擎,不下载包、不默认调用模型或导出全目录。缺依赖就报告;可复用维护经验只提出候选,不在文章任务里自行改技能。
@@ -14,7 +14,7 @@ Use `<grid cols={3} gap={3}>` with three direct `<card>` children. `cols` accept
14
14
 
15
15
  三个直接 card 子元素配 `grid cols={3} gap={3}`;cols 为 1–6。等宽来自等分网格;当前主题在视口 ≤800px 时收为单列,不强制窄屏横排。
16
16
 
17
- - `gap` and `padding`: 0–4 → 0/4/8/12/16px. A restrained consistent default is `padding={3} radius="md" border="subtle" background="surface"` on each card. Apply the same tokens to every card unless a distinct style was requested. / 未指定差异样式时,为所有卡片使用同一组 token。
17
+ - `gap` and `padding`: 0–4 → 0/4/8/12/16px. A restrained consistent default is `padding={3} radius="md" border="subtle" background="transparent"` on each card. Preserve grouping borders; do not add fill merely because items are peers. Choose explicit `background="surface"` only for intentional reading groups or requested hierarchy. / 默认透明并保留合理分组边框;内卡surface按阅读分组或明确层级需要选择,不是所有并列项都加底。
18
18
  - Supported radii: none/sm/md/lg/xl/full. Borders: true/false/subtle/none/strong. Backgrounds include surface, surface-secondary, surface-accent, surface-tip, surface-muted, paper, well, transparent. Consult tokens only when needed.
19
19
  - Equal width does not mean equal word count, forced line breaks or fixed heights. Grid items stretch within their row by normal CSS grid behavior; content remains natural, without height clamps or filler text. / 不为等宽删改原文、补字或强插换行,也不把多行内容裁成固定高度。
20
20
  - Icons and semantic colors are optional, not mandatory decoration. Do not invent icons or inject arbitrary CSS. / 图标与颜色可选,不强行装饰。
@@ -30,15 +30,15 @@ Use `<grid cols={3} gap={3}>` with three direct `<card>` children. `cols` accept
30
30
  </intent>
31
31
  <figure frame="none">
32
32
  <grid cols={3} gap={3}>
33
- <card padding={3} radius="md" border="subtle" background="surface">
33
+ <card padding={3} radius="md" border="subtle" background="transparent">
34
34
  <title>人员</title>
35
35
  <text>解决的是团队到底需要哪几类人,各自管什么、不管什么,他们如何进行合作。</text>
36
36
  </card>
37
- <card padding={3} radius="md" border="subtle" background="surface">
37
+ <card padding={3} radius="md" border="subtle" background="transparent">
38
38
  <title>流程</title>
39
39
  <text>从冒出一个点子到软件上线,先迈左脚还是先迈右脚,怎么用最低成本试错。</text>
40
40
  </card>
41
- <card padding={3} radius="md" border="subtle" background="surface">
41
+ <card padding={3} radius="md" border="subtle" background="transparent">
42
42
  <title>产品</title>
43
43
  <text>一款真正的好产品本身长什么样,有哪些绝不能碰的死穴。</text>
44
44
  </card>
@@ -113,3 +113,18 @@ Use this for independent explanations, not a workflow. Derive the alignment hier
113
113
  - centered-chip:只承诺局部贴标。完整问句、长标题、双语标签和放大字体必须实测;不自动把所有正文居中。长扫描清单优先开始对齐的连续阅读,必要时用局部重点居中,不通过sm来压高度。
114
114
  - prose-list / frameless:保持真实标记、顺序、正文基线和自然段落;去掉无意义的多层场景表面不是删掉所有必要分组。
115
115
  同一树能响应式重排时使用已有机制;若需要尚未支持的断点条件对齐/拓扑,不复制两套文字隐藏一套、不发明媒体属性。采用兼容两端且保真的最小方案并注明视觉取舍;无法满足作者明确硬约束时标未完成,而非让作者做设备调参。
116
+
117
+ ## Source-backed metrics · 原文指标
118
+ Use `metric` for a source-backed number plus label/unit/caption; use equal-cards for independent explanatory categories. Do not invent values or convert prose-only peers into metrics. / 原文数值+标签/单位/说明用metric,独立解释类别用equal-cards;不造数据。
119
+
120
+ ## Title and badge · 标题与徽标
121
+ Use `row align="center" justify="between" wrap="nowrap"` with a title and badge. Explicit justify controls distribution; align controls the cross axis. Between/around title leaves remain content-sized; nowrap keeps this local row, not the whole article, together. Verify long titles at the actual narrow width. / between让标题与徽标两端分布,align管交叉轴;标题保留内容宽,nowrap只约束这一局部,长标题仍须窄屏实测。
122
+
123
+ ## Measured DOM connectors · 可复用的实测连线
124
+
125
+ - First choose native `relations/entity/edge` when its compact ring and detail interaction match the reader's task. It is not an arbitrary-position graph router. / 环形关系+详情合适时优先原生relations,不承诺自由拓扑排版。
126
+ - Custom topology, branch ports or exact placement: keep the same Compose fence and use an offline `sandbox`. Keep labels in ordinary HTML, inheriting its body font; SVG draws **lines only**, never a whole text diagram scaled to fit. The iframe is an isolated document: article web fonts are not automatically available. / 自定义拓扑用同一compose入口内的sandbox;文字留在DOM,SVG只画线,不缩放整张文字图。iframe不自动继承文章web字体,须如实验证。
127
+ - Reuse `measured-connectors.js` inside the sandbox's literal JS, then call `mountMeasuredConnectors(root, [{from:'a',to:'b'}, {from:'a',to:'c',route:'elbow'}, {from:'b',to:'c',route:'elbow',dashed:true}])`. The root needs `position:relative`; node elements need unique `data-node` ids. CSS owns layout, the recipe owns measurement. No new engine component/API or network import is involved. / 根容器定位,节点唯一id;CSS布局与测量连线分工,不发明组件或远程依赖。
128
+ - It uses ResizeObserver, font readiness and SVG screen-coordinate conversion. It returns a disposer; remount on topology changes. Supported for a stable node set: straight edges by default; `route:"elbow"` draws an axis-aligned, midpoint-bend path between measured boundary anchors; `dashed:true` adds a 6/4 dash pattern independently. All update on node/root resize, font readiness and position-only node style/class changes; dragging controls and animation are not supplied. **Not supported:** automatic obstacle avoidance, edge labels, arrowheads, dragging or automatic graph layout. Nodes must not overlap. Choose a readable non-crossing arrangement; if hard requirements need these missing behaviors, implement and test them explicitly rather than claiming this recipe supplies them. / 支持实测直线、`route:'elbow'`轴对齐中点转折与独立`dashed:true`虚线;基于真实节点框重测两端,随字体/容器重排及节点style/class移动更新;不提供自动避障、边标签、箭头、拖拽或自动排图,不能把端点测试当成完整复杂图验收。
129
+ - Protect readable node widths before choosing a narrow layout. For spatial relationships that must retain their arrangement, wrap the graph in a labelled, keyboard-focusable local viewport (`max-width:100%; overflow-x:auto`); a two-column graph can use `min-width:34em` and natural word wrapping (`overflow-wrap:break-word; word-break:normal`). Font enlargement increases that floor, not shrinks type. Resize the viewport, not below the node floor. If the task permits reflow, explicitly reroute edges so they do not run through intervening nodes. This is recipe CSS, not a new engine API. / 空间关系需保留时用有标签、可键盘访问的局部横滚;节点保留可读最小宽,正文自然换词,不以anywhere挤成碎词、不缩字。可重排的任务另验改序与连线,不把直排后穿过中间节点当成功。
130
+ - Verify actual endpoints, elbow segment axes and dash style after reflow and enlarged text at 300/344/375/430 and desktop widths; inspect crossings, reading order, scrollbar ownership and fullscreen separately. `tests/measured-connectors-browser.test.mjs` verifies the foundation, not every topology. / 按真实节点框验端点,另审交叉、阅读顺序、滚动归属与全屏;测试基础能力不等于所有拓扑都已验收。
@@ -0,0 +1,94 @@
1
+ // Offline sandbox recipe, not a Compose component or an automatic graph router.
2
+ // root: positioned DOM container; nodes: unique data-node ids.
3
+ // edges: {from,to,route?:"straight"|"elbow",dashed?:boolean}; no obstacle router.
4
+ function mountMeasuredConnectors(root, edges) {
5
+ const namespace = 'http://www.w3.org/2000/svg';
6
+ const nodes = new Map([...root.querySelectorAll('[data-node]')].map(node => [node.dataset.node, node]));
7
+ if (nodes.size !== root.querySelectorAll('[data-node]').length) throw new Error('Duplicate connector node id');
8
+ for (const edge of edges) {
9
+ if (!nodes.has(edge.from) || !nodes.has(edge.to)) throw new Error('Unknown connector node id');
10
+ if (edge.route !== undefined && !['straight', 'elbow'].includes(edge.route)) throw new Error('Unknown connector route');
11
+ if (edge.dashed !== undefined && typeof edge.dashed !== 'boolean') throw new Error('Connector dashed must be boolean');
12
+ }
13
+ const svg = document.createElementNS(namespace, 'svg');
14
+ svg.setAttribute('aria-hidden', 'true');
15
+ svg.classList.add('connector-lines');
16
+ Object.assign(svg.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', overflow: 'visible', pointerEvents: 'none' });
17
+ const paths = edges.map(edge => {
18
+ const line = document.createElementNS(namespace, 'path');
19
+ line.dataset.from = edge.from;
20
+ line.dataset.to = edge.to;
21
+ line.dataset.route = edge.route ?? 'straight';
22
+ if (edge.dashed) line.setAttribute('stroke-dasharray', '6 4');
23
+ line.setAttribute('fill', 'none');
24
+ line.setAttribute('stroke', 'currentColor');
25
+ line.setAttribute('stroke-width', '1.5');
26
+ svg.append(line);
27
+ return line;
28
+ });
29
+ root.prepend(svg);
30
+ let active = true;
31
+ let scheduled = 0;
32
+ function draw() {
33
+ scheduled = 0;
34
+ if (!active || !svg.isConnected) return;
35
+ const matrix = svg.getScreenCTM();
36
+ if (!matrix) return;
37
+ const inverse = matrix.inverse();
38
+ for (const [index, edge] of edges.entries()) {
39
+ const from = nodes.get(edge.from).getBoundingClientRect();
40
+ const to = nodes.get(edge.to).getBoundingClientRect();
41
+ const a = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
42
+ const b = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
43
+ // Prefer a genuinely separated axis: tall peer cards can overlap vertically.
44
+ // A center-only choice could send the first elbow segment back into its node.
45
+ const separatedX = from.right <= to.left || to.right <= from.left;
46
+ const separatedY = from.bottom <= to.top || to.bottom <= from.top;
47
+ const horizontal = separatedX !== separatedY ? separatedX : Math.abs(b.x - a.x) > Math.abs(b.y - a.y);
48
+ if (horizontal) {
49
+ const forward = b.x > a.x;
50
+ a.x = forward ? from.right : from.left;
51
+ b.x = forward ? to.left : to.right;
52
+ } else {
53
+ const forward = b.y > a.y;
54
+ a.y = forward ? from.bottom : from.top;
55
+ b.y = forward ? to.top : to.bottom;
56
+ }
57
+ const start = new DOMPoint(a.x, a.y).matrixTransform(inverse);
58
+ const end = new DOMPoint(b.x, b.y).matrixTransform(inverse);
59
+ const points = [start];
60
+ if (edge.route === 'elbow') {
61
+ // Mid-gap orthogonal route, recalculated from actual node bounds.
62
+ // Intentionally no obstacle search or automatic node placement.
63
+ if (horizontal) {
64
+ const x = (start.x + end.x) / 2;
65
+ points.push({ x, y: start.y }, { x, y: end.y });
66
+ } else {
67
+ const y = (start.y + end.y) / 2;
68
+ points.push({ x: start.x, y }, { x: end.x, y });
69
+ }
70
+ }
71
+ points.push(end);
72
+ paths[index].setAttribute('d', points.map((point, i) => `${i ? 'L' : 'M'}${point.x},${point.y}`).join(' '));
73
+ }
74
+ }
75
+ function schedule() { if (active && !scheduled) scheduled = requestAnimationFrame(draw); }
76
+ const observer = new ResizeObserver(schedule);
77
+ observer.observe(root);
78
+ for (const node of nodes.values()) observer.observe(node);
79
+ const changes = new MutationObserver(schedule);
80
+ // Position-only style/class changes do not trigger ResizeObserver.
81
+ // Do not observe path `d` writes: measurement must not reschedule itself.
82
+ changes.observe(root, { attributes: true, attributeFilter: ['style', 'class'], subtree: true });
83
+ window.addEventListener('resize', schedule);
84
+ document.fonts?.ready.then(schedule);
85
+ schedule();
86
+ return () => {
87
+ active = false;
88
+ cancelAnimationFrame(scheduled);
89
+ observer.disconnect();
90
+ changes.disconnect();
91
+ window.removeEventListener('resize', schedule);
92
+ svg.remove();
93
+ };
94
+ }
@@ -6,14 +6,19 @@
6
6
  - 关系决定共同容器:一个图标对应的标题与所有正文要属于一个重复单元;标题和正文必须同在右侧容器。分隔整条目时 divider 放两条 row 之间,而非右栏内。
7
7
  - row 默认 wrap/stretch、8px gap;直接 card/stack/title/text 共分可用宽度,icon 固定不缩。窄屏默认会改行流;需要持续图标栏用 wrap="nowrap" 并测试不溢出。
8
8
  - row > stack 有 **12px 顶部 padding**;不能假定任意嵌套同样表现。group 为 width:100%、min-width:0 的纵向容器,能在固定 icon 旁收缩,支持 gap,避开该上下文顶距。prose 不能凭空赋予未实现的 gap 行为。
9
- - align="center" 可能同时触发容器对齐与 text-align,并向子孙继承。当前 title/text 的显式 align="start" 会保留并输出 data-align。用一个共享 group 和显式开始对齐,不要把 title 独立成横向等分栏、正文又放回全宽。
9
+ - row 的 align 管交叉轴,justify 管主轴;显式 justify 始终优先,不被 align="center" 或徽标的默认居中覆盖。文字仍可能继承 text-align。当前 title/text 的显式 align="start" 会保留并输出 data-align。用一个共享 group 和显式开始对齐,不要把 title 独立成横向等分栏、正文又放回全宽。
10
10
  - icon 名查 schema/icons 实际目录;不存在报错。SVG width 属性不等于最终像素:当前 CSS sm13、md15、lg18、xl22px,小卡上下文另有覆盖,必须测 computed/rect。
11
11
  - 多段正文分别放 text,或用 markdown/html 岛保存真实 p。text 内空行是 inline break,不是独立段落。图标签有截断/行钳制时不能塞完整正文去丢字。
12
12
 
13
+ ## Diagnose the host before rewriting content · 先诊断宿主
14
+ Measure viewport → `.article-reading-column` → `.garden-ui-body` → iframe; record each width and computed padding. Do not infer content width from a desktop breakpoint. The reading track takes priority over side rails; a frameless runtime has no body inset; fullscreen controls occupy a top band outside the frame. Post mobile TOC entries are in normal flow before the article. / 依次量视口、正文栏、运行时正文与iframe,不先删字或缩字。侧栏让位正文,frame="none"不再叠运行时内距,全屏按钮占外部顶栏,手机目录入口在正文前正常排版。
15
+
16
+ For a title and trailing badge, use `<row align="center" justify="between" wrap="nowrap"><title align="start">…</title><badge>…</badge></row>`. Its text/title children are content-sized and may shrink/wrap naturally, not width:100% columns. Explicit fills, scene framing and fullscreen are independent choices. / 标题+尾部徽标直接组合已有属性;不靠换组件、固定标题宽度或缩字绕开。
17
+
13
18
  ## 集中禁令与风险分级
14
19
  - **必选结构(已选icon-rail)**:icon和一个group同属row,标题与全部正文/列表同属group;分隔线在row之间。不要正文逃出右栏,不用row>stack替代group来碰运气。
15
20
  - **无效属性**:stack不因direction="row"变横向;用真实row。未知静态属性可能归一化丢失,当前lint不能从归一化树找回它们;对照源属性与schema,缺少未知属性诊断是引擎需求,不谎称已机器覆盖。
16
- - **风险而非全局错误**:row>stack的12px顶距、居中row直接title/text的flex:1、center同时改变主轴/交叉轴/文字、nowrap长链、显式size和叠框。这些在其他语境可能有意,必须结合内部约束卡判断,不能一律禁用。
21
+ - **风险而非全局错误**:row>stack的12px顶距、居中row直接title/text的flex:1、center仍可能向后代继承文字居中(但不覆盖显式justify)、nowrap长链、显式size和叠框。这些在其他语境可能有意,必须结合内部约束卡判断,不能一律禁用。
17
22
  - **箭头看实际绘制方向**:right是源意图,不必等于当前屏幕方向。真实SVG默认向下;桌面旋转为right,≤800px取消旋转配合默认row转column。先看computed transform/屏幕向量,不把data-dir当几何证据。nowrap让row保持横向却不自动保留箭头旋转,须专项检查。
18
23
  - **必须浏览器**:icon glyph光学齐平不是节点矩形相等;内容宽居中、完整主题字号/列表间距、溢出与手机回退不能从AST推断。用computed与rect及截图分别取证。
19
24
 
@@ -50,7 +55,7 @@ The authoritative, demand-loaded catalog is `npx garden ui schema`: actual seman
50
55
 
51
56
  First identify what belongs together. A heading and all its paragraphs must share a container. Next choose columns and proportions, then alignments and typography. Only then add surfaces and decorative details.
52
57
 
53
- A static `row` wraps by default. Its direct text/title/stack/card children share available width (`flex: 1 1 0`), whereas an icon is content-sized. A heading placed directly in that row is a sibling column, not the heading of a paragraph below the row. `align="center"` on a row changes more than vertical placement: theme rules also center text. A centered stack propagates text alignment. For an editorial list use explicit start alignment on children of a shared right-hand group; do not center each title independently.
58
+ A static `row` wraps by default. Its direct text/title/stack/card children share available width (`flex: 1 1 0`), whereas an icon is content-sized. A heading placed directly in that row is a sibling column, not the heading of a paragraph below the row. `align="center"` controls the cross axis and can inherit centered text; an explicit `justify` independently controls the main axis and takes precedence over default badge centering. A centered stack propagates text alignment. For an editorial list use explicit start alignment on children of a shared right-hand group; do not center each title independently.
54
59
 
55
60
  Direct `row > stack` has 12px top padding in the current theme. A `group` is a width:100%, min-width:0 vertical container that shrinks beside the fixed icon and reads gap without that contextual padding; use it when icon and heading must begin on the same top line. Explicit `align="start"` survives normalization and emits data-align.
56
61
 
@@ -118,6 +123,8 @@ Use `markdown` or `html` islands for full paragraphs, lists, headings, blockquot
118
123
 
119
124
  Safe HTML supports allowlisted local `style` properties, including useful layout and typography; inspect the generated schema for the actual set. Unsupported tags/properties, events, unsafe URLs, executable scripts and network CSS functions report diagnostics. Do not expand the article allowlist into arbitrary execution. An intentional executable demo uses `sandbox` inside the **same Compose entry**, with literal html/css/js and no parent origin access. Sandbox layout is its own document, not inherited article prose.
120
125
 
126
+ For measured node connections, load layout-recipes.md and measured-connectors.js: straight, midpoint orthogonal elbow and optional dashed edges follow real DOM bounds. This is a sandbox recipe, not automatic graph layout or obstacle avoidance.
127
+
121
128
  ## Verification levels
122
129
 
123
130
  1. Run `npx garden ui check <file>` (and `npx garden check <file>` for real file paths). Preserve original text/resources and inspect diagnostics.
@@ -14,6 +14,7 @@ Load when choosing how to explain source content, not for every repair. Use the
14
14
  | 图标窄栏、标题正文共左缘 | icon-rail:stack重复row(icon+group),divider在row间 | 不把正文放row外;不当流程 |
15
15
  | 短阶段从左到右 | flow-row:row+真实顺序箭头;窄屏/长内容回退flow-stack | 不靠缩字/nowrap强撑手机 |
16
16
  | 长阶段或纵向顺序 | flow-stack:stack+下箭头 | 无顺序的条目不加箭头 |
17
+ | 原文关键数值+标签/单位 | metric:保留原始值、单位与说明 | 不造数据;无数值的解释类别仍用equal-cards |
17
18
  | 并列等宽卡片 | equal-cards:grid+直接card | 非真实共同维度不走compare |
18
19
  | 图标贴标题,两者一起居中 | centered-chip:内容宽标题壳+内部图标标题行,下方正文独立 | 不只给全宽row设center;候选须浏览器验收 |
19
20
  | 跟正文一样的列表 | prose-list:普通Markdown;场景内用markdown岛 | 不写假圆点;不为列表手感换到compare |
@@ -44,3 +44,10 @@ node .agents/skills/garden-compose/scripts/lint-layout.mjs content/posts/post.md
44
44
  ## Mobile acceptance · AI内部,不要求作者补规格
45
45
  几何卡补可选`"mobile":{"readingMode":"sequence","reflow":true}`;readingMode可选sequence/scan-list/comparison/spatial,reflow为布尔。lint只在显式reflow的flow-row中拒绝场景顶层流向row的nowrap(不误禁内部贴标row);icon-rail局部nowrap不被误禁。scan-list的成组居中只给风险提示,不凭AST断言过高或难读。
46
46
  浏览器验证遵守mobile-contract:记录实际CSS viewport、内容宽、正常/放大正文、每层滚动与实际箭头向量,并人工审查整组阅读节奏。保留首稿和源hash,失败后的修订单独编号,不能把修后结果算首轮成功。默认check参数/退出状态不变,mobile字段不是runtime DIL属性。
47
+
48
+ ## Built artifact gate · 最终产物门
49
+ `STATIC_EXPORT=1 garden build` now checks the final export automatically. `garden verify --rendered` reruns it without rebuilding: actual HTML stylesheet/script links, literal JS imports (including lazy chunks), Compose/runtime CSS and per-page client navigation payloads. Missing assets or corrupt payloads fail. / 静态构建后自动检查真实HTML/CSS/JS依赖及导航payload,缺失即失败;不是只看源代码导入。
50
+
51
+ This is asset integrity, **not** visual or lifecycle acceptance. Complete-theme browser tests and the real site's normal Link → article → home → re-entry → history/direct sequence remain separate gates. Record iframe height messages from its own contentWindow, srcdoc length and computed geometry; a populated srcdoc alone does not prove execution. Do not claim navigation fixed from a direct-link pass. / 资源完整≠样式生效≠生命周期通过;iframe需记录实际消息、srcdoc与几何,不能凭直接链接成功宣称客户端导航已修好。
52
+
53
+ Measured connector recipes: verify boundary endpoints after enlarged labels and container resize; for elbow edges also verify every segment is axis-aligned, and dashed edges retain their dash style. Do not infer obstacle avoidance from endpoint tests. / 连线需验实测端点、肘线各段轴对齐与虚线样式;另审交叉,不能据此宣称自动避障。
@@ -110,6 +110,7 @@ export function PostReadingView({
110
110
  ) : null}
111
111
  </header>
112
112
  <div className="article-reading-layout">
113
+ <TableOfContents headings={headings} />
113
114
  <div className="article-reading-column">
114
115
  <ArticleMeasure />
115
116
  <div className="e-content">
@@ -125,7 +126,6 @@ export function PostReadingView({
125
126
  />
126
127
  <Comments slug={commentsSlug} />
127
128
  </div>
128
- <TableOfContents headings={headings} />
129
129
  </div>
130
130
  </article>
131
131
  </main>
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
4
+
3
5
  import Link from "next/link";
4
6
  import { usePathname } from "next/navigation";
5
7
  import {
@@ -316,6 +318,7 @@ export function SiteHeader() {
316
318
  <>
317
319
  {/* Targets for the no-JavaScript menu links below. Fixed and outside the sticky header, so
318
320
  following #sh-menu-on or #sh-menu-off never scrolls the page (the TOC drawer works the same way). */}
321
+ <span className="sh-menu-target" id={TOC_CLOSED_ID} aria-hidden="true" />
319
322
  <span className="sh-menu-target" id="sh-menu-on" aria-hidden="true" />
320
323
  <span className="sh-menu-target" id="sh-menu-off" aria-hidden="true" />
321
324
  <header className="sh-header" id="site-header">
@@ -333,6 +336,11 @@ export function SiteHeader() {
333
336
  ))}
334
337
  </nav>
335
338
  <div className="sh-tools">
339
+ <span id={TOC_CONTROL_ID} className="sh-reading-control">
340
+ <a className="toc-header-nojs" href={`#${TOC_PANEL_ID}`} aria-label={t.toc}>
341
+ {t.tocShort}
342
+ </a>
343
+ </span>
336
344
  <button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label={t.searchArticles} aria-expanded={searchOpen} aria-controls="site-search-dialog">
337
345
  <svg viewBox="0 0 20 20" aria-hidden="true">
338
346
  <circle cx="9" cy="9" r="5.8" />
@@ -6,10 +6,13 @@ import {
6
6
  useId,
7
7
  useRef,
8
8
  useState,
9
+ useSyncExternalStore,
9
10
  type MouseEvent,
10
11
  type ReactNode,
11
12
  } from "react";
12
- import { List, X } from "@phosphor-icons/react";
13
+ import { createPortal } from "react-dom";
14
+ import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
15
+ import { X } from "@phosphor-icons/react";
13
16
  import { ui, type UiLang } from "../lib/i18n";
14
17
 
15
18
  type Heading = { depth: number; text: string; id: string; no?: string };
@@ -114,6 +117,20 @@ const scrollToHeading = (id: string) => {
114
117
  html.style.scrollBehavior = previousBehavior;
115
118
  };
116
119
 
120
+ // The server-rendered header owns the slot; this component owns one drawer/control.
121
+ // Observe slot replacement during client navigation, never poll or duplicate the TOC.
122
+ const readControlHost = () => document.getElementById(TOC_CONTROL_ID);
123
+ const serverControlHost = () => null;
124
+ function subscribeControlHost(changed: () => void) {
125
+ let host = readControlHost();
126
+ const observer = new MutationObserver(() => {
127
+ const next = readControlHost();
128
+ if (next !== host) { host = next; changed(); }
129
+ });
130
+ observer.observe(document.body, { childList: true, subtree: true });
131
+ return () => observer.disconnect();
132
+ }
133
+
117
134
  export function TableOfContents({
118
135
  headings,
119
136
  label,
@@ -131,9 +148,11 @@ export function TableOfContents({
131
148
  () => new Set(),
132
149
  );
133
150
  const [mobileOpen, setMobileOpen] = useState(false);
134
- const panelId = useId();
151
+ const panelId = TOC_PANEL_ID;
152
+ const controlHost = useSyncExternalStore(subscribeControlHost, readControlHost, serverControlHost);
135
153
  const listId = useId();
136
154
  const listRef = useRef<HTMLOListElement>(null);
155
+ const panelRef = useRef<HTMLElement>(null);
137
156
  const mobileTriggerRef = useRef<HTMLButtonElement>(null);
138
157
  const mobileCloseRef = useRef<HTMLButtonElement>(null);
139
158
  const restoreMobileTriggerFocusRef = useRef(false);
@@ -283,7 +302,22 @@ export function TableOfContents({
283
302
  mobileCloseRef.current?.focus({ preventScroll: true });
284
303
  });
285
304
  const handleKeyDown = (event: KeyboardEvent) => {
286
- if (event.key === "Escape") closeMobileToc(true);
305
+ if (event.key === "Escape") {
306
+ event.preventDefault();
307
+ closeMobileToc(true);
308
+ }
309
+ if (event.key !== "Tab") return;
310
+ const controls = Array.from(panelRef.current?.querySelectorAll<HTMLElement>("a[href], button:not([disabled])") ?? [])
311
+ .filter((control) => control.getClientRects().length > 0 && getComputedStyle(control).visibility !== "hidden");
312
+ const first = controls[0];
313
+ const last = controls.at(-1);
314
+ if (event.shiftKey && document.activeElement === first && last) {
315
+ event.preventDefault();
316
+ last.focus();
317
+ } else if (!event.shiftKey && document.activeElement === last && first) {
318
+ event.preventDefault();
319
+ first.focus();
320
+ }
287
321
  };
288
322
  const handleViewportChange = () => {
289
323
  if (!mobileQuery.matches) setMobileOpen(false);
@@ -346,6 +380,13 @@ export function TableOfContents({
346
380
  setActiveId(id);
347
381
  closeMobileToc();
348
382
  trackAnchorLayout(id);
383
+ const heading = document.getElementById(id);
384
+ if (heading) {
385
+ const temporaryTabIndex = !heading.hasAttribute("tabindex");
386
+ if (temporaryTabIndex) heading.tabIndex = -1;
387
+ heading.focus({ preventScroll: true });
388
+ if (temporaryTabIndex) heading.addEventListener("blur", () => heading.removeAttribute("tabindex"), { once: true });
389
+ }
349
390
  };
350
391
 
351
392
  const renderNode = (node: TocNode, path: number[]): ReactNode => {
@@ -395,7 +436,7 @@ export function TableOfContents({
395
436
 
396
437
  return (
397
438
  <>
398
- <button
439
+ {controlHost ? createPortal(<button
399
440
  ref={mobileTriggerRef}
400
441
  className="toc-mobile-trigger"
401
442
  type="button"
@@ -406,16 +447,8 @@ export function TableOfContents({
406
447
  data-mobile-open={mobileOpen}
407
448
  onClick={() => setMobileOpen(true)}
408
449
  >
409
- <List size={17} weight="bold" aria-hidden="true" />
410
- <span>{t.tocShort}</span>
411
- </button>
412
- {/* Without JavaScript the button above does nothing. This link opens the
413
- drawer through :target instead; CSS shows it only when scripting is
414
- off, so with JavaScript nothing here is visible. */}
415
- <a className="toc-nojs-trigger" id={`${panelId}-open`} href={`#${panelId}`} aria-label={`${t.open}${label}`}>
416
- <List size={17} weight="bold" aria-hidden="true" />
417
450
  <span>{t.tocShort}</span>
418
- </a>
451
+ </button>, controlHost) : null}
419
452
  <button
420
453
  className="toc-mobile-backdrop"
421
454
  type="button"
@@ -425,6 +458,9 @@ export function TableOfContents({
425
458
  onClick={() => closeMobileToc(true)}
426
459
  />
427
460
  <aside
461
+ ref={panelRef}
462
+ role={mobileOpen ? "dialog" : undefined}
463
+ aria-modal={mobileOpen ? true : undefined}
428
464
  className="toc toc-rail"
429
465
  id={panelId}
430
466
  aria-label={label}
@@ -441,7 +477,7 @@ export function TableOfContents({
441
477
  >
442
478
  <X size={18} aria-hidden="true" />
443
479
  </button>
444
- <a className="toc-nojs-close" href={`#${panelId}-open`} aria-label={`${t.close}${label}`}>
480
+ <a className="toc-nojs-close" href={`#${TOC_CLOSED_ID}`} aria-label={`${t.close}${label}`}>
445
481
  <X size={18} aria-hidden="true" />
446
482
  </a>
447
483
  </div>
package/app/globals.css CHANGED
@@ -3917,62 +3917,10 @@ html[data-theme="dark"] .search-footer {
3917
3917
  overflow: visible;
3918
3918
  }
3919
3919
 
3920
- .toc-mobile-trigger,
3921
- .toc-nojs-trigger {
3922
- position: fixed;
3923
- right: calc(16px + env(safe-area-inset-right));
3924
- bottom: calc(16px + env(safe-area-inset-bottom));
3925
- z-index: 70;
3926
- min-height: 44px;
3927
- padding: 0 18px 0 15px;
3928
- display: inline-flex;
3929
- align-items: center;
3930
- gap: 8px;
3931
- border: 1px solid #d9d5cc;
3932
- border-radius: 999px;
3933
- background: rgb(255 255 255 / 94%);
3934
- box-shadow: 0 2px 10px rgb(27 26 24 / 10%);
3935
- color: var(--ink);
3936
- font: 600 14px/1 var(--sans);
3937
- letter-spacing: 0;
3938
- touch-action: manipulation;
3939
- cursor: pointer;
3940
- transition: opacity 160ms ease, transform 160ms ease, visibility 0s;
3941
- }
3942
-
3943
- /* The floating contents button sits over the bottom 76px of the screen:
3944
- keep that much room after the last line, and keep anchors clear of it. */
3945
- html:has(.toc-mobile-trigger) {
3946
- scroll-padding-bottom: calc(76px + env(safe-area-inset-bottom));
3947
- }
3948
-
3949
- body:has(.toc-mobile-trigger) main {
3950
- padding-bottom: calc(76px + env(safe-area-inset-bottom));
3951
- }
3952
-
3953
- :root[data-theme="dark"] .toc-mobile-trigger,
3954
- html[data-theme="dark"] .toc-mobile-trigger,
3955
- :root[data-theme="dark"] .toc-nojs-trigger,
3956
- html[data-theme="dark"] .toc-nojs-trigger {
3957
- background: rgb(30 29 26 / 94%);
3958
- border-color: var(--rule-strong);
3959
- color: var(--ink);
3960
- }
3961
-
3962
- .toc-mobile-trigger:hover,
3963
- .toc-mobile-trigger:focus-visible,
3964
- .toc-nojs-trigger:hover,
3965
- .toc-nojs-trigger:focus-visible {
3966
- transform: translateY(-2px);
3967
- }
3968
-
3969
3920
  .toc-mobile-trigger:focus-visible,
3921
+ .toc-header-nojs:focus-visible,
3970
3922
  .toc-mobile-close:focus-visible,
3971
- .toc-nojs-trigger:focus-visible,
3972
- .toc-nojs-close:focus-visible {
3973
- outline: 2px solid var(--accent);
3974
- outline-offset: 3px;
3975
- }
3923
+ .toc-nojs-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
3976
3924
 
3977
3925
  .toc-mobile-trigger[data-mobile-open="true"] {
3978
3926
  opacity: 0;
@@ -4054,8 +4002,8 @@ html[data-theme="dark"] .search-footer {
4054
4002
 
4055
4003
  .toc-mobile-close,
4056
4004
  .toc-nojs-close {
4057
- width: 34px;
4058
- height: 34px;
4005
+ width: 44px;
4006
+ height: 44px;
4059
4007
  padding: 0;
4060
4008
  display: grid;
4061
4009
  place-items: center;
@@ -4088,9 +4036,13 @@ html[data-theme="dark"] .search-footer {
4088
4036
  padding on post pages but not on column pages. */
4089
4037
  .toc .toc-header,
4090
4038
  .toc .toc-list {
4091
- padding-inline: 20px;
4039
+ padding-left: max(20px, env(safe-area-inset-left));
4040
+ padding-right: max(20px, env(safe-area-inset-right));
4092
4041
  }
4093
4042
 
4043
+ .toc .toc-item-row > a { display: flex; align-items: center; min-height: 44px; padding-block: 8px; }
4044
+ .toc .toc-section-toggle { min-width: 44px; min-height: 44px; }
4045
+
4094
4046
  .toc li {
4095
4047
  margin: 10px 0;
4096
4048
  font-size: 13px;
@@ -5707,12 +5659,6 @@ html[data-theme="dark"] .search-footer {
5707
5659
  display: none;
5708
5660
  }
5709
5661
 
5710
- .toc-nojs-trigger {
5711
- display: inline-flex;
5712
- color: var(--ink);
5713
- text-decoration: none;
5714
- }
5715
-
5716
5662
  .toc-nojs-close {
5717
5663
  display: grid;
5718
5664
  text-decoration: none;
@@ -5725,7 +5671,7 @@ html[data-theme="dark"] .search-footer {
5725
5671
  transition: opacity 180ms ease, visibility 0s;
5726
5672
  }
5727
5673
 
5728
- body:has(.toc:target) .toc-nojs-trigger {
5674
+ body:has(.toc:target) .toc-header-nojs {
5729
5675
  opacity: 0;
5730
5676
  visibility: hidden;
5731
5677
  pointer-events: none;
package/app/lib/i18n.ts CHANGED
@@ -319,7 +319,7 @@ const en: typeof zh = {
319
319
  columnFallbackDesc: "A series of posts in reading order.",
320
320
  columnNewestFirst: (title: string | undefined) => `${title}, newest first`,
321
321
  toc: "Contents",
322
- tocShort: "Contents",
322
+ tocShort: "TOC",
323
323
  tocPage: "On this page",
324
324
  open: "Open ",
325
325
  close: "Close ",
@@ -0,0 +1,4 @@
1
+ // One primary reading navigation per page; shared by the server header and client drawer.
2
+ export const TOC_PANEL_ID = "reading-contents";
3
+ export const TOC_CONTROL_ID = "reading-contents-control";
4
+ export const TOC_CLOSED_ID = "reading-contents-closed";
package/app/styles/a2.css CHANGED
@@ -274,7 +274,7 @@ body {
274
274
  --rail: 200px;
275
275
  --gap: 72px;
276
276
  display: grid;
277
- grid-template-columns: minmax(0, var(--rail)) minmax(0, var(--col)) var(--rail);
277
+ grid-template-columns: minmax(0, 1fr) minmax(0, var(--col)) minmax(0, 1fr);
278
278
  column-gap: var(--gap);
279
279
  justify-content: center;
280
280
  align-items: start;
@@ -300,6 +300,8 @@ body {
300
300
  }
301
301
 
302
302
  .post-reading-page .article-reading-layout > .toc {
303
+ min-width: 0;
304
+ overflow-wrap: anywhere;
303
305
  grid-column: 3;
304
306
  grid-row: 1;
305
307
  margin-top: 0;
@@ -1582,6 +1584,9 @@ html[data-theme="dark"] .g-quiz-out.ok { color: #8fd1a0; }
1582
1584
  html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
1583
1585
  .g-quiz-ex { margin-top: 8px; font-size: 15px; color: var(--soft); }
1584
1586
  .g-play { border: 1px solid var(--rule); border-radius: var(--r); padding: 14px 16px; margin: 24px 0; background: transparent; }
1587
+ /* Fullscreen controls own a top band, not a second frame or a narrow text rail. */
1588
+ .ux-fs > .ux-fsbar + .g-play { padding: 44px 0 0; border: 0; }
1589
+ .g-play > .garden-ui { margin: 0; }
1585
1590
  .g-play-f { margin: 0 0 10px; font-size: 14px; color: var(--soft); }
1586
1591
  .g-range { display: grid; grid-template-columns: 110px minmax(0, 1fr) 64px; gap: 12px; align-items: center; font-size: 14px; color: var(--muted); margin: 6px 0; }
1587
1592
  .g-range input { width: 100%; accent-color: var(--blue); min-height: 32px; }
@@ -2782,3 +2787,11 @@ html[data-theme="dark"] .ref-demo { --rd-s: #262624; --rd-p: #1f1e1d; --rd-i: #e
2782
2787
  display: none;
2783
2788
  }
2784
2789
  }
2790
+
2791
+ /* Desktop rail spacing must not replace the mobile drawer's home-indicator inset. */
2792
+ @media (max-width: 900px) {
2793
+ .post-reading-page .article-reading-layout > .toc,
2794
+ .garden-doc-page > .toc {
2795
+ padding: 0 0 calc(18px + env(safe-area-inset-bottom));
2796
+ }
2797
+ }
@@ -36,15 +36,15 @@ img,svg{max-width:100%}
36
36
  /* ---------- header ---------- */
37
37
  .sh-header{position:sticky;top:0;z-index:40;background:var(--paper);border-bottom:1px solid transparent;transition:border-color .2s ease}
38
38
  .sh-header.is-scrolled{border-bottom-color:var(--rule)}
39
- .sh-bar{max-width:var(--shell);margin:0 auto;height:var(--hdr);padding:0 32px;display:flex;align-items:center;gap:28px}
40
- .sh-word{display:inline-flex;align-items:center;gap:10px;font:600 var(--fs-lead)/1 var(--serif);color:var(--ink);text-decoration:none;white-space:nowrap;padding:8px 0}
39
+ .sh-bar{max-width:var(--shell);margin:0 auto;min-height:var(--hdr);height:auto;padding:0 32px;display:flex;align-items:center;gap:28px}
40
+ .sh-word{display:inline-flex;align-items:center;gap:10px;font:600 var(--fs-lead)/1 var(--serif);color:var(--ink);text-decoration:none;white-space:normal;overflow-wrap:anywhere;min-width:0;flex:0 1 auto;line-height:1.2;padding:8px 0}
41
41
  .sh-mark{display:inline-flex;flex:none}
42
42
  .sh-mark svg{display:block;height:26px;width:auto}
43
- .sh-nav{display:flex;gap:4px;margin-left:auto}
43
+ .sh-nav{display:flex;flex-shrink:0;gap:4px;margin-left:auto}
44
44
  .sh-nav a{font-size:15px;color:var(--soft);text-decoration:none;padding:8px 12px;border-radius:var(--r);transition:color .15s ease,background-color .15s ease}
45
45
  .sh-nav a:hover{color:var(--ink);background:var(--tint)}
46
46
  .sh-nav a[aria-current="page"],.sh-nav a[aria-current="true"]{color:var(--blue);font-weight:600}
47
- .sh-tools{display:flex;align-items:center;gap:8px}
47
+ .sh-tools{display:flex;flex-shrink:0;align-items:center;gap:8px}
48
48
  /* A hairline box so the entry reads as a field, not a stray word. */
49
49
  .sh-search{display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;height:34px;padding:0 10px 0 12px;border:1px solid var(--rule);background:transparent;color:var(--soft);border-radius:8px;font:400 15px/1 var(--sans);cursor:pointer;appearance:none;transition:color .15s ease,border-color .15s ease}
50
50
  .sh-search:hover{color:var(--ink);border-color:var(--rule-strong)}
@@ -456,3 +456,26 @@ a.sh-lang:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
456
456
  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB","Noto Sans SC","Noto Sans CJK SC","Microsoft YaHei",system-ui,sans-serif;
457
457
  --serif:"Source Serif 4",Georgia,"Times New Roman","Source Han Serif SC","Noto Serif SC","Noto Serif CJK SC","Songti SC","STSong",serif;
458
458
  }
459
+
460
+ /* Reading navigation lives in the existing sticky header, never over prose.
461
+ The server fallback works without JS; hydration portals the stateful control
462
+ into the same reserved slot. No duplicate article-start button or extra band. */
463
+ .sh-reading-control { display: none; }
464
+ .toc-header-nojs { display: none; }
465
+ @media (max-width: 900px) {
466
+ body:has(.toc-rail) .sh-reading-control { display: inline-flex; flex: 0 0 44px; min-width: 44px; min-height: 44px; align-items: center; justify-content: center; }
467
+ .sh-reading-control .toc-mobile-trigger,
468
+ .sh-reading-control .toc-header-nojs {
469
+ position: static; display: inline-flex; width: 44px; min-width: 44px; height: 44px; margin: 0; padding: 0;
470
+ align-items: center; justify-content: center; border: 0; border-radius: var(--r);
471
+ background: transparent; box-shadow: none; color: var(--ink); font: 600 13px/1 var(--sans); text-decoration: none;
472
+ }
473
+ .sh-reading-control .toc-header-nojs { display: none; }
474
+ .sh-reading-control .toc-mobile-trigger:hover,
475
+ .sh-reading-control .toc-mobile-trigger:focus-visible { transform: none; background: var(--tint); }
476
+ .sh-reading-control .toc-mobile-trigger[data-mobile-open="true"] { visibility: visible; opacity: 1; pointer-events: none; transform: none; }
477
+ .sh-bar { padding-left: max(20px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); }
478
+ }
479
+ @media (max-width: 900px) and (scripting: none) {
480
+ .sh-reading-control .toc-header-nojs { display: inline-flex; }
481
+ }