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.
- package/README.md +8 -5
- package/bin/cli.mjs +212 -78
- package/package.json +1 -1
- package/packaged-skill/SKILL.md +14 -11
- package/packaged-skill/references/atlas-guide.mdx +5 -4
- package/packaged-skill/references/scroll-guide.mdx +16 -2
- package/template/references/atlas-guide.mdx +5 -4
- package/template/references/scroll-guide.mdx +16 -2
- package/template/src/app/App.jsx +76 -43
- package/template/src/app/search.js +14 -5
- package/template/src/app/use-appearance.js +29 -15
- package/template/src/components/FigureScope.jsx +17 -0
- package/template/src/components/MDXComponents.jsx +1063 -193
- package/template/src/components/SkinPicker.jsx +14 -6
- package/template/src/model/concept-schema.js +27 -12
- package/template/src/model/figures.js +61 -0
- package/template/src/model/node-kinds.js +9 -9
- package/template/src/model/normalize-content.js +8 -8
- package/template/src/model/relation-types.js +37 -12
- package/template/src/model/validate-content.js +308 -55
- package/template/src/scroll-main.jsx +12 -2
- package/template/src/styles/core.css +215 -56
- package/template/src/styles/packs/elastic.css +37 -4
- package/template/src/styles/packs/manuscript.css +67 -0
- package/template/src/styles/packs/shadcn.css +33 -4
- package/template/src/views/NodeExplorer.jsx +369 -316
- package/template/src/views/RelationGraph.jsx +191 -118
- package/template/vite.config.js +116 -30
|
@@ -102,8 +102,8 @@ npm run dev`}</CodeBlock>
|
|
|
102
102
|
</ScrollSection>
|
|
103
103
|
|
|
104
104
|
<ScrollSection title="补充配图与出处">
|
|
105
|
-
<ScrollProse>配图和引用让讲解从断言变成可追溯的论述<Cite id="tufte1983"
|
|
106
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
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
|
|
137
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
138
|
-
<
|
|
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"
|
|
106
|
-
<Figure src="./assets/sample-diagram.svg" alt="概念缩放示意"
|
|
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>
|
package/template/src/app/App.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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 = (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 = (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
141
|
+
}, [graph, currentNodeId, navigateToNode, moveHistory, toggleTheme]);
|
|
129
142
|
|
|
130
143
|
useEffect(() => {
|
|
131
|
-
const handlePopState =
|
|
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 =
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
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 => {
|
|
207
|
+
onKeyDown={event => {
|
|
208
|
+
if (event.key === 'Escape') setGlobalQuery('');
|
|
209
|
+
}}
|
|
195
210
|
placeholder="搜索所有节点…"
|
|
196
211
|
aria-label="搜索所有节点"
|
|
197
212
|
/>
|
|
198
|
-
{globalQuery &&
|
|
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
|
|
203
|
-
|
|
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')
|
|
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)
|
|
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
|
|
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 || '')
|
|
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 =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(() =>
|
|
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(
|
|
89
|
+
const setSkin = useCallback(skin => {
|
|
84
90
|
setAppearance(prev => ({ ...prev, skin: normalizeSkin(skin) || prev.skin }));
|
|
85
91
|
}, []);
|
|
86
|
-
const setMode = useCallback(
|
|
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(
|
|
98
|
+
const setStyle = useCallback(style => {
|
|
93
99
|
setAppearance(prev => ({ ...prev, style: normalizeStyle(style) || prev.style }));
|
|
94
100
|
}, []);
|
|
95
101
|
|
|
96
|
-
return {
|
|
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
|
+
}
|