concept-atlas-dense-explain 1.2.0 → 3.0.0

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.
@@ -102,8 +102,8 @@ npm run dev`}</CodeBlock>
102
102
  </ScrollSection>
103
103
 
104
104
  <ScrollSection title="补充配图与出处">
105
- <ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983" />。相对路径的图片会在构建时内联,输出仍是可离线打开的单文件。</ScrollProse>
106
- <Figure src="./assets/sample-diagram.svg" alt="概念缩放示意" label="图 1" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
105
+ <ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983" />。图片默认保持外链(`--inline-assets` 可全部内联),给图一个 id 后正文可用 <FigureRef id="zoom-scale" /> 自动引用编号。</ScrollProse>
106
+ <Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
107
107
  <ScrollGrid columns="2">
108
108
  <DecisionMatrix title="需要确认" headers={['变量','检查']} rows={[['新体验','是否改善核心任务'],['旧体验','能否满足需求'],['替换成本','迁移是否可接受']]} />
109
109
  <Compare items={[{label:'连续阅读',rows:['信息密度','适用场景'],values:['高','按章节论证']},{label:'概念图谱',rows:['信息密度','适用场景'],values:['中','概念下钻']}]} />
@@ -175,6 +175,20 @@ npm run dev`}</CodeBlock>
175
175
  </ScrollGrid>
176
176
  </ScrollSection>
177
177
 
178
+ <ScrollSection title="扩展组件:引用、清单、规格与结构">
179
+ <ScrollProse>以下组件补齐了引用、验证清单、规格表、显式层级与代码注解的表达缺口。</ScrollProse>
180
+ <Quote author="Donald Knuth" source="Literate Programming">程序首先是写给人读的,只是顺便让机器执行。</Quote>
181
+ <LastReviewed date="2026-09-29" by="架构组" note="随 ABI 变更重审" />
182
+ <Checklist title="上线前验证" items={[{text:'依赖已锁定',status:'pass'},{text:'压测通过',status:'fail',note:'P99 仍超标 12%'},{text:'回滚脚本演练',status:'todo'},{text:'灰度范围确认',status:'unknown'}]} />
183
+ <ScrollGrid columns="2">
184
+ <TreeView title="模块结构" items={[{label:'core',description:'内核',children:[{label:'scheduler'},{label:'memory',children:[{label:'allocator'}]}]}]} />
185
+ <PropertyList title="运行时参数" items={[{name:'heap',value:'4Gi',note:'按峰值负载预留'},{name:'gc',value:'zgc'},{name:'timeout',value:'30s'}]} />
186
+ </ScrollGrid>
187
+ <CodeTabs title="同一逻辑的两种写法" items={[{label:'JavaScript',language:'javascript',code:'const sum = ns.reduce((a, n) => a + n, 0);'},{label:'Rust',language:'rust',code:'let sum: i64 = ns.iter().sum();'}]} />
188
+ <AnnotatedCode language="bash" title="定位动态链接问题" code={`ldd app
189
+ readelf -d app | head`} notes={[{line:1,text:'列出动态依赖,缺失项会显示 not found'},{line:2,text:'确认 .dynamic 段与 SONAME'}]} />
190
+ </ScrollSection>
191
+
178
192
  <ScrollSection title="参考文献">
179
193
  <References title="本页引用" items={[{id:'tufte1983',authors:'Tufte, E. R.',year:'1983',title:'The Visual Display of Quantitative Information',source:'Graphics Press',note:'关于以图形压缩与呈现证据的经典论述。'}]} />
180
194
  </ScrollSection>
@@ -132,10 +132,11 @@
132
132
  <Chart title="留存趋势" type="line" labels={['第1周','第2周','第3周','第4周']} series={[{name:'留存率',values:[100,72,58,49]}]} />
133
133
  </ConceptNode>
134
134
 
135
- <ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure 把图片、题注编号和说明绑定,构建时把本地图片内联进单文件。">
136
- <Definition>相对路径的图片会在构建时转成 base64 内联,远程 URL 保持不变;label 提供“图 1”这样的编号。</Definition>
137
- <Figure src="./assets/sample-diagram.svg" alt="概念缩放示意" label="图 1" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
138
- <Boundary>大图会显著增大单文件 HTML;截图类内容建议控制尺寸。</Boundary>
135
+ <ConceptNode id="figure-demo" title="Figure:图片与题注一起出现" level="L2" parent="extension-family" summary="Figure 把图片、题注编号和说明绑定;默认外链,按需内联。">
136
+ <Definition>相对路径的图片默认保持外链,构建时用 --inline-assets 可全部内联,单张图也可用 `inline={true}` 或 `inline={false}` 覆盖;远程 URL 保持不变。给图一个 id,正文就能用 FigureRef 自动引用“图 N”。</Definition>
137
+ <Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
138
+ <Example title="自动图号">正文写“见 <FigureRef id="zoom-scale" />”,编号会随插图自动重排,不必手写“图 1”。</Example>
139
+ <Boundary>内联大图会显著增大 HTML;截图类内容建议控制尺寸,或对单张图设 `inline={false}`。</Boundary>
139
140
  </ConceptNode>
140
141
 
141
142
  <ConceptNode id="code-demo" title="CodeBlock:把命令、配置或伪代码放进正文" level="L2" parent="extension-family" summary="与绑定节点的 Implementation 不同,CodeBlock 可以出现在任何位置,并支持标题、题注、换行和行号。">
@@ -102,8 +102,8 @@ npm run dev`}</CodeBlock>
102
102
  </ScrollSection>
103
103
 
104
104
  <ScrollSection title="补充配图与出处">
105
- <ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983" />。相对路径的图片会在构建时内联,输出仍是可离线打开的单文件。</ScrollProse>
106
- <Figure src="./assets/sample-diagram.svg" alt="概念缩放示意" label="图 1" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
105
+ <ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983" />。图片默认保持外链(`--inline-assets` 可全部内联),给图一个 id 后正文可用 <FigureRef id="zoom-scale" /> 自动引用编号。</ScrollProse>
106
+ <Figure id="zoom-scale" src="./assets/sample-diagram.svg" alt="概念缩放示意" caption="概念缩放:同一主题可以从 L0 全局逐层下钻到 L4 边界反例。" />
107
107
  <ScrollGrid columns="2">
108
108
  <DecisionMatrix title="需要确认" headers={['变量','检查']} rows={[['新体验','是否改善核心任务'],['旧体验','能否满足需求'],['替换成本','迁移是否可接受']]} />
109
109
  <Compare items={[{label:'连续阅读',rows:['信息密度','适用场景'],values:['高','按章节论证']},{label:'概念图谱',rows:['信息密度','适用场景'],values:['中','概念下钻']}]} />
@@ -175,6 +175,20 @@ npm run dev`}</CodeBlock>
175
175
  </ScrollGrid>
176
176
  </ScrollSection>
177
177
 
178
+ <ScrollSection title="扩展组件:引用、清单、规格与结构">
179
+ <ScrollProse>以下组件补齐了引用、验证清单、规格表、显式层级与代码注解的表达缺口。</ScrollProse>
180
+ <Quote author="Donald Knuth" source="Literate Programming">程序首先是写给人读的,只是顺便让机器执行。</Quote>
181
+ <LastReviewed date="2026-09-29" by="架构组" note="随 ABI 变更重审" />
182
+ <Checklist title="上线前验证" items={[{text:'依赖已锁定',status:'pass'},{text:'压测通过',status:'fail',note:'P99 仍超标 12%'},{text:'回滚脚本演练',status:'todo'},{text:'灰度范围确认',status:'unknown'}]} />
183
+ <ScrollGrid columns="2">
184
+ <TreeView title="模块结构" items={[{label:'core',description:'内核',children:[{label:'scheduler'},{label:'memory',children:[{label:'allocator'}]}]}]} />
185
+ <PropertyList title="运行时参数" items={[{name:'heap',value:'4Gi',note:'按峰值负载预留'},{name:'gc',value:'zgc'},{name:'timeout',value:'30s'}]} />
186
+ </ScrollGrid>
187
+ <CodeTabs title="同一逻辑的两种写法" items={[{label:'JavaScript',language:'javascript',code:'const sum = ns.reduce((a, n) => a + n, 0);'},{label:'Rust',language:'rust',code:'let sum: i64 = ns.iter().sum();'}]} />
188
+ <AnnotatedCode language="bash" title="定位动态链接问题" code={`ldd app
189
+ readelf -d app | head`} notes={[{line:1,text:'列出动态依赖,缺失项会显示 not found'},{line:2,text:'确认 .dynamic 段与 SONAME'}]} />
190
+ </ScrollSection>
191
+
178
192
  <ScrollSection title="参考文献">
179
193
  <References title="本页引用" items={[{id:'tufte1983',authors:'Tufte, E. R.',year:'1983',title:'The Visual Display of Quantitative Information',source:'Graphics Press',note:'关于以图形压缩与呈现证据的经典论述。'}]} />
180
194
  </ScrollSection>
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo, useRef } from 'react';
1
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
2
2
  import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
3
3
  import { buildGraphModel } from '../model/concept-schema.js';
4
4
  import { extractConceptData } from '../model/normalize-content.js';
@@ -30,7 +30,14 @@ export function App({ mdxContent, initialData }) {
30
30
  });
31
31
 
32
32
  // Appearance state (skin x mode x component style), shared with the scroll carrier via localStorage
33
- const { skin, mode: theme, style, setSkin, setStyle, toggleMode } = useAppearance({ defaultMode: 'light' });
33
+ const {
34
+ skin,
35
+ mode: theme,
36
+ style,
37
+ setSkin,
38
+ setStyle,
39
+ toggleMode
40
+ } = useAppearance({ defaultMode: 'light' });
34
41
 
35
42
  const toggleTheme = toggleMode;
36
43
 
@@ -42,12 +49,11 @@ export function App({ mdxContent, initialData }) {
42
49
  })();
43
50
 
44
51
  const [currentNodeId, setCurrentNodeId] = useState(
45
- () => initialHashNode || graph.meta.rootId || '',
52
+ () => initialHashNode || graph.meta.rootId || ''
46
53
  );
47
- const [selectedLevel, setSelectedLevel] = useState(null);
48
54
  const [nav, setNav] = useState(() => ({
49
55
  entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
50
- index: 0,
56
+ index: 0
51
57
  }));
52
58
  // Popstate handlers must read the *current* history state, not the closure
53
59
  // captured when the effect was created.
@@ -58,35 +64,41 @@ export function App({ mdxContent, initialData }) {
58
64
 
59
65
  const searchResults = useMemo(
60
66
  () => searchNodes(Array.from(graph.nodes.values()), globalQuery),
61
- [globalQuery, graph.nodes],
67
+ [globalQuery, graph.nodes]
62
68
  );
63
69
 
64
- const navigateToNode = (nodeId, { replace = false } = {}) => {
65
- if (!nodeId || !graph.nodes.has(nodeId)) return;
66
- const next = pushNode(nav, nodeId);
67
- setCurrentNodeId(nodeId);
68
- setNav(next);
69
- const nextHash = `#node=${encodeURIComponent(nodeId)}`;
70
- // Each browser entry remembers which in-app history index it maps to, so
71
- // popstate can restore the exact entry even when hashes repeat.
72
- const historyState = { atlasIndex: next.index };
73
- if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
74
- if (replace) window.history.replaceState(historyState, '', nextHash);
75
- else window.history.pushState(historyState, '', nextHash);
76
- };
70
+ const navigateToNode = useCallback(
71
+ (nodeId, { replace = false } = {}) => {
72
+ if (!nodeId || !graph.nodes.has(nodeId)) return;
73
+ const next = pushNode(nav, nodeId);
74
+ setCurrentNodeId(nodeId);
75
+ setNav(next);
76
+ const nextHash = `#node=${encodeURIComponent(nodeId)}`;
77
+ // Each browser entry remembers which in-app history index it maps to, so
78
+ // popstate can restore the exact entry even when hashes repeat.
79
+ const historyState = { atlasIndex: next.index };
80
+ if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
81
+ if (replace) window.history.replaceState(historyState, '', nextHash);
82
+ else window.history.pushState(historyState, '', nextHash);
83
+ },
84
+ [nav, graph.nodes]
85
+ );
77
86
 
78
- const moveHistory = (direction) => {
79
- // Browser history mirrors the in-app entries one-for-one (every navigation
80
- // pushState'd the matching { atlasIndex }), so step through the real
81
- // browser history. pushState here used to fork the two stacks and grow
82
- // browser history unboundedly; the popstate handler applies the result.
83
- if (!stepHistory(nav, direction)) return;
84
- window.history.go(direction);
85
- };
87
+ const moveHistory = useCallback(
88
+ direction => {
89
+ // Browser history mirrors the in-app entries one-for-one (every navigation
90
+ // pushState'd the matching { atlasIndex }), so step through the real
91
+ // browser history. pushState here used to fork the two stacks and grow
92
+ // browser history unboundedly; the popstate handler applies the result.
93
+ if (!stepHistory(nav, direction)) return;
94
+ window.history.go(direction);
95
+ },
96
+ [nav]
97
+ );
86
98
 
87
99
  // Keyboard navigation shortcuts
88
100
  useEffect(() => {
89
- const handleKeyDown = (e) => {
101
+ const handleKeyDown = e => {
90
102
  if (e.isComposing) return;
91
103
 
92
104
  // Alt + arrows move through browsing history. Handled before the modifier
@@ -104,7 +116,8 @@ export function App({ mdxContent, initialData }) {
104
116
 
105
117
  // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
106
118
  const target = document.activeElement;
107
- const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
119
+ const typing =
120
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
108
121
  if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
109
122
 
110
123
  const key = e.key.toLowerCase();
@@ -125,25 +138,25 @@ export function App({ mdxContent, initialData }) {
125
138
 
126
139
  window.addEventListener('keydown', handleKeyDown);
127
140
  return () => window.removeEventListener('keydown', handleKeyDown);
128
- }, [graph, currentNodeId, nav]);
141
+ }, [graph, currentNodeId, navigateToNode, moveHistory, toggleTheme]);
129
142
 
130
143
  useEffect(() => {
131
- const handlePopState = (event) => {
144
+ const handlePopState = event => {
132
145
  const current = navRef.current;
133
146
  const nodeId = readNodeFromHash();
134
147
  if (nodeId && !graph.nodes.has(nodeId)) return; // stale/foreign hash: ignore
135
- const tagged = event.state && Number.isInteger(event.state.atlasIndex)
136
- ? event.state.atlasIndex
137
- : null;
148
+ const tagged =
149
+ event.state && Number.isInteger(event.state.atlasIndex) ? event.state.atlasIndex : null;
138
150
  const taggedValid = tagged !== null && tagged >= 0 && tagged < current.entries.length;
139
151
  let next = null;
140
152
  if (nodeId) {
141
153
  // Prefer the index stamped on the entry: entries may contain the same
142
154
  // node twice, and lastIndexOf alone would resolve backwards steps to a
143
155
  // later duplicate.
144
- next = taggedValid && current.entries[tagged] === nodeId
145
- ? { entries: current.entries, index: tagged }
146
- : syncFromLocation(current, nodeId);
156
+ next =
157
+ taggedValid && current.entries[tagged] === nodeId
158
+ ? { entries: current.entries, index: tagged }
159
+ : syncFromLocation(current, nodeId);
147
160
  } else if (taggedValid) {
148
161
  // Back to the hash-less entry the page loaded on: restore it by index.
149
162
  next = { entries: current.entries, index: tagged };
@@ -191,16 +204,38 @@ export function App({ mdxContent, initialData }) {
191
204
  <input
192
205
  value={globalQuery}
193
206
  onChange={event => setGlobalQuery(event.target.value)}
194
- onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
207
+ onKeyDown={event => {
208
+ if (event.key === 'Escape') setGlobalQuery('');
209
+ }}
195
210
  placeholder="搜索所有节点…"
196
211
  aria-label="搜索所有节点"
197
212
  />
198
- {globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
213
+ {globalQuery && (
214
+ <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索">
215
+ <X size={13} />
216
+ </button>
217
+ )}
199
218
  {searchResults.length > 0 && (
200
219
  <div className="global-search-results" role="listbox">
201
220
  {searchResults.map(node => (
202
- <button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
203
- <span>{node.title}</span><small>{node.level}{node.kind && NODE_KINDS[node.kind] ? ` · ${NODE_KINDS[node.kind].label}` : ''} · {node.summary || node.id}</small>
221
+ <button
222
+ type="button"
223
+ key={node.id}
224
+ onClick={() => {
225
+ navigateToNode(node.id);
226
+ setGlobalQuery('');
227
+ }}
228
+ role="option"
229
+ aria-selected={false}
230
+ >
231
+ <span>{node.title}</span>
232
+ <small>
233
+ {node.level}
234
+ {node.kind && NODE_KINDS[node.kind]
235
+ ? ` · ${NODE_KINDS[node.kind].label}`
236
+ : ''}{' '}
237
+ · {node.summary || node.id}
238
+ </small>
204
239
  </button>
205
240
  ))}
206
241
  </div>
@@ -239,8 +274,6 @@ export function App({ mdxContent, initialData }) {
239
274
  currentNodeId={currentNodeId}
240
275
  onSelectNode={navigateToNode}
241
276
  onSwitchView={setCurrentView}
242
- selectedLevel={selectedLevel}
243
- onSelectLevel={setSelectedLevel}
244
277
  />
245
278
  ) : (
246
279
  <RelationGraph
@@ -10,7 +10,8 @@ const MAX_LENGTH = 4000;
10
10
  * a pathological tree cannot stall the search box.
11
11
  */
12
12
  export function collectText(value, depth = 0) {
13
- if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean') return '';
13
+ if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean')
14
+ return '';
14
15
  if (typeof value === 'string' || typeof value === 'number') return String(value);
15
16
  if (Array.isArray(value)) {
16
17
  return value.map(item => collectText(item, depth + 1)).join(' ');
@@ -28,7 +29,9 @@ export function collectText(value, depth = 0) {
28
29
  return parts.join(' ');
29
30
  }
30
31
  if (typeof value === 'object') {
31
- return Object.values(value).map(item => collectText(item, depth + 1)).join(' ');
32
+ return Object.values(value)
33
+ .map(item => collectText(item, depth + 1))
34
+ .join(' ');
32
35
  }
33
36
  return '';
34
37
  }
@@ -66,14 +69,20 @@ export function nodeSearchText(node) {
66
69
  node.invariants,
67
70
  node.failureModes,
68
71
  node.tradeoffs,
69
- node.customSections,
72
+ node.customSections
70
73
  ];
71
- return parts.map(part => collectText(part)).join(' ').slice(0, MAX_LENGTH).toLowerCase();
74
+ return parts
75
+ .map(part => collectText(part))
76
+ .join(' ')
77
+ .slice(0, MAX_LENGTH)
78
+ .toLowerCase();
72
79
  }
73
80
 
74
81
  /** Rank-free substring search returning at most `limit` nodes. */
75
82
  export function searchNodes(nodes, query, limit = 8) {
76
- const needle = String(query || '').trim().toLowerCase();
83
+ const needle = String(query || '')
84
+ .trim()
85
+ .toLowerCase();
77
86
  if (!needle) return [];
78
87
  const results = [];
79
88
  for (const node of nodes) {
@@ -13,15 +13,19 @@ function normalizeMode(value, fallback = 'dark') {
13
13
  * Compile-time defaults injected via Vite `define` (CLI --skin/--default-mode
14
14
  * or CONCEPT_ATLAS_* env vars). Absent defines resolve to the carrier fallbacks.
15
15
  */
16
- const BUILD_DEFAULT_SKIN = typeof __ATLAS_DEFAULT_SKIN__ === 'string'
17
- ? (normalizeSkin(__ATLAS_DEFAULT_SKIN__) || DEFAULT_SKIN)
18
- : DEFAULT_SKIN;
19
- const BUILD_DEFAULT_MODE = typeof __ATLAS_DEFAULT_MODE__ === 'string' && (__ATLAS_DEFAULT_MODE__ === 'dark' || __ATLAS_DEFAULT_MODE__ === 'light')
20
- ? __ATLAS_DEFAULT_MODE__
21
- : null;
22
- const BUILD_DEFAULT_STYLE = typeof __ATLAS_DEFAULT_STYLE__ === 'string'
23
- ? (normalizeStyle(__ATLAS_DEFAULT_STYLE__) || DEFAULT_STYLE)
24
- : DEFAULT_STYLE;
16
+ const BUILD_DEFAULT_SKIN =
17
+ typeof __ATLAS_DEFAULT_SKIN__ === 'string'
18
+ ? normalizeSkin(__ATLAS_DEFAULT_SKIN__) || DEFAULT_SKIN
19
+ : DEFAULT_SKIN;
20
+ const BUILD_DEFAULT_MODE =
21
+ typeof __ATLAS_DEFAULT_MODE__ === 'string' &&
22
+ (__ATLAS_DEFAULT_MODE__ === 'dark' || __ATLAS_DEFAULT_MODE__ === 'light')
23
+ ? __ATLAS_DEFAULT_MODE__
24
+ : null;
25
+ const BUILD_DEFAULT_STYLE =
26
+ typeof __ATLAS_DEFAULT_STYLE__ === 'string'
27
+ ? normalizeStyle(__ATLAS_DEFAULT_STYLE__) || DEFAULT_STYLE
28
+ : DEFAULT_STYLE;
25
29
 
26
30
  function readStoredAppearance(fallbackMode) {
27
31
  const fallback = normalizeMode(fallbackMode, 'dark');
@@ -34,7 +38,7 @@ function readStoredAppearance(fallbackMode) {
34
38
  // Corrupt/missing mode falls back to the carrier default instead of
35
39
  // silently coercing to dark.
36
40
  mode: normalizeMode(parsed?.mode, fallback),
37
- style: normalizeStyle(parsed?.style) || BUILD_DEFAULT_STYLE,
41
+ style: normalizeStyle(parsed?.style) || BUILD_DEFAULT_STYLE
38
42
  };
39
43
  }
40
44
  } catch {
@@ -67,7 +71,9 @@ export { readStoredAppearance, normalizeMode };
67
71
  * localStorage key so both pages stay in sync.
68
72
  */
69
73
  export function useAppearance({ defaultMode = 'dark' } = {}) {
70
- const [appearance, setAppearance] = useState(() => readStoredAppearance(BUILD_DEFAULT_MODE || defaultMode));
74
+ const [appearance, setAppearance] = useState(() =>
75
+ readStoredAppearance(BUILD_DEFAULT_MODE || defaultMode)
76
+ );
71
77
 
72
78
  useEffect(() => {
73
79
  document.documentElement.setAttribute('data-skin', appearance.skin);
@@ -80,18 +86,26 @@ export function useAppearance({ defaultMode = 'dark' } = {}) {
80
86
  }
81
87
  }, [appearance]);
82
88
 
83
- const setSkin = useCallback((skin) => {
89
+ const setSkin = useCallback(skin => {
84
90
  setAppearance(prev => ({ ...prev, skin: normalizeSkin(skin) || prev.skin }));
85
91
  }, []);
86
- const setMode = useCallback((mode) => {
92
+ const setMode = useCallback(mode => {
87
93
  setAppearance(prev => ({ ...prev, mode: normalizeMode(mode, prev.mode) }));
88
94
  }, []);
89
95
  const toggleMode = useCallback(() => {
90
96
  setAppearance(prev => ({ ...prev, mode: prev.mode === 'dark' ? 'light' : 'dark' }));
91
97
  }, []);
92
- const setStyle = useCallback((style) => {
98
+ const setStyle = useCallback(style => {
93
99
  setAppearance(prev => ({ ...prev, style: normalizeStyle(style) || prev.style }));
94
100
  }, []);
95
101
 
96
- return { skin: appearance.skin, mode: appearance.mode, style: appearance.style, setSkin, setMode, toggleMode, setStyle };
102
+ return {
103
+ skin: appearance.skin,
104
+ mode: appearance.mode,
105
+ style: appearance.style,
106
+ setSkin,
107
+ setMode,
108
+ toggleMode,
109
+ setStyle
110
+ };
97
111
  }
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Numbering scope for figures. `App`'s atlas view provides the current node id
5
+ * so each node numbers its own figures; the scroll carrier keeps the default
6
+ * `document` scope so the whole article shares one sequence. Kept out of the
7
+ * component barrel on purpose: it is renderer plumbing, not an MDX component.
8
+ */
9
+ export const FigureScope = React.createContext('document');
10
+
11
+ export function FigureScopeProvider({ scope, children }) {
12
+ return <FigureScope.Provider value={scope || 'document'}>{children}</FigureScope.Provider>;
13
+ }
14
+
15
+ export function useFigureScope() {
16
+ return React.useContext(FigureScope);
17
+ }