concept-atlas-dense-explain 2.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/cli.mjs +197 -80
- package/package.json +1 -1
- package/packaged-skill/SKILL.md +8 -7
- package/packaged-skill/references/scroll-guide.mdx +14 -0
- package/template/references/scroll-guide.mdx +14 -0
- package/template/src/app/App.jsx +132 -44
- package/template/src/app/search.js +14 -5
- package/template/src/app/use-appearance.js +29 -15
- package/template/src/components/MDXComponents.jsx +1029 -196
- package/template/src/components/SkinPicker.jsx +14 -6
- package/template/src/model/concept-schema.js +27 -12
- 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 +282 -59
- package/template/src/scroll-main.jsx +8 -1
- package/template/src/styles/core.css +295 -12
- package/template/src/styles/packs/elastic.css +37 -0
- package/template/src/styles/packs/manuscript.css +67 -0
- package/template/src/styles/packs/shadcn.css +33 -0
- package/template/src/views/NodeExplorer.jsx +364 -293
- package/template/src/views/RelationGraph.jsx +391 -131
- package/template/vite.config.js +32 -20
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,11 +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
54
|
const [nav, setNav] = useState(() => ({
|
|
48
55
|
entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
|
|
49
|
-
index: 0
|
|
56
|
+
index: 0
|
|
50
57
|
}));
|
|
51
58
|
// Popstate handlers must read the *current* history state, not the closure
|
|
52
59
|
// captured when the effect was created.
|
|
@@ -57,35 +64,64 @@ export function App({ mdxContent, initialData }) {
|
|
|
57
64
|
|
|
58
65
|
const searchResults = useMemo(
|
|
59
66
|
() => searchNodes(Array.from(graph.nodes.values()), globalQuery),
|
|
60
|
-
[globalQuery, graph.nodes]
|
|
67
|
+
[globalQuery, graph.nodes]
|
|
61
68
|
);
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
70
|
+
// Combobox state for the global search. The highlight resets whenever the
|
|
71
|
+
// query changes so the first arrow key lands on the first result.
|
|
72
|
+
const [activeResultIndex, setActiveResultIndex] = useState(-1);
|
|
73
|
+
const searchOpen = globalQuery.trim().length > 0 && searchResults.length > 0;
|
|
74
|
+
const activeResultId =
|
|
75
|
+
searchOpen && activeResultIndex >= 0 && searchResults[activeResultIndex]
|
|
76
|
+
? `global-search-option-${searchResults[activeResultIndex].id}`
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setActiveResultIndex(-1);
|
|
81
|
+
}, [globalQuery]);
|
|
82
|
+
|
|
83
|
+
const navigateToNode = useCallback(
|
|
84
|
+
(nodeId, { replace = false } = {}) => {
|
|
85
|
+
if (!nodeId || !graph.nodes.has(nodeId)) return;
|
|
86
|
+
const next = pushNode(nav, nodeId);
|
|
87
|
+
setCurrentNodeId(nodeId);
|
|
88
|
+
setNav(next);
|
|
89
|
+
const nextHash = `#node=${encodeURIComponent(nodeId)}`;
|
|
90
|
+
// Each browser entry remembers which in-app history index it maps to, so
|
|
91
|
+
// popstate can restore the exact entry even when hashes repeat.
|
|
92
|
+
const historyState = { atlasIndex: next.index };
|
|
93
|
+
if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
|
|
94
|
+
if (replace) window.history.replaceState(historyState, '', nextHash);
|
|
95
|
+
else window.history.pushState(historyState, '', nextHash);
|
|
96
|
+
},
|
|
97
|
+
[nav, graph.nodes]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const selectSearchResult = useCallback(
|
|
101
|
+
node => {
|
|
102
|
+
if (!node) return;
|
|
103
|
+
navigateToNode(node.id);
|
|
104
|
+
setGlobalQuery('');
|
|
105
|
+
setActiveResultIndex(-1);
|
|
106
|
+
},
|
|
107
|
+
[navigateToNode]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const moveHistory = useCallback(
|
|
111
|
+
direction => {
|
|
112
|
+
// Browser history mirrors the in-app entries one-for-one (every navigation
|
|
113
|
+
// pushState'd the matching { atlasIndex }), so step through the real
|
|
114
|
+
// browser history. pushState here used to fork the two stacks and grow
|
|
115
|
+
// browser history unboundedly; the popstate handler applies the result.
|
|
116
|
+
if (!stepHistory(nav, direction)) return;
|
|
117
|
+
window.history.go(direction);
|
|
118
|
+
},
|
|
119
|
+
[nav]
|
|
120
|
+
);
|
|
85
121
|
|
|
86
122
|
// Keyboard navigation shortcuts
|
|
87
123
|
useEffect(() => {
|
|
88
|
-
const handleKeyDown =
|
|
124
|
+
const handleKeyDown = e => {
|
|
89
125
|
if (e.isComposing) return;
|
|
90
126
|
|
|
91
127
|
// Alt + arrows move through browsing history. Handled before the modifier
|
|
@@ -103,7 +139,8 @@ export function App({ mdxContent, initialData }) {
|
|
|
103
139
|
|
|
104
140
|
// Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
|
|
105
141
|
const target = document.activeElement;
|
|
106
|
-
const typing =
|
|
142
|
+
const typing =
|
|
143
|
+
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
|
|
107
144
|
if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
|
|
108
145
|
|
|
109
146
|
const key = e.key.toLowerCase();
|
|
@@ -124,25 +161,25 @@ export function App({ mdxContent, initialData }) {
|
|
|
124
161
|
|
|
125
162
|
window.addEventListener('keydown', handleKeyDown);
|
|
126
163
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
127
|
-
}, [graph, currentNodeId,
|
|
164
|
+
}, [graph, currentNodeId, navigateToNode, moveHistory, toggleTheme]);
|
|
128
165
|
|
|
129
166
|
useEffect(() => {
|
|
130
|
-
const handlePopState =
|
|
167
|
+
const handlePopState = event => {
|
|
131
168
|
const current = navRef.current;
|
|
132
169
|
const nodeId = readNodeFromHash();
|
|
133
170
|
if (nodeId && !graph.nodes.has(nodeId)) return; // stale/foreign hash: ignore
|
|
134
|
-
const tagged =
|
|
135
|
-
? event.state.atlasIndex
|
|
136
|
-
: null;
|
|
171
|
+
const tagged =
|
|
172
|
+
event.state && Number.isInteger(event.state.atlasIndex) ? event.state.atlasIndex : null;
|
|
137
173
|
const taggedValid = tagged !== null && tagged >= 0 && tagged < current.entries.length;
|
|
138
174
|
let next = null;
|
|
139
175
|
if (nodeId) {
|
|
140
176
|
// Prefer the index stamped on the entry: entries may contain the same
|
|
141
177
|
// node twice, and lastIndexOf alone would resolve backwards steps to a
|
|
142
178
|
// later duplicate.
|
|
143
|
-
next =
|
|
144
|
-
|
|
145
|
-
|
|
179
|
+
next =
|
|
180
|
+
taggedValid && current.entries[tagged] === nodeId
|
|
181
|
+
? { entries: current.entries, index: tagged }
|
|
182
|
+
: syncFromLocation(current, nodeId);
|
|
146
183
|
} else if (taggedValid) {
|
|
147
184
|
// Back to the hash-less entry the page loaded on: restore it by index.
|
|
148
185
|
next = { entries: current.entries, index: tagged };
|
|
@@ -190,16 +227,67 @@ export function App({ mdxContent, initialData }) {
|
|
|
190
227
|
<input
|
|
191
228
|
value={globalQuery}
|
|
192
229
|
onChange={event => setGlobalQuery(event.target.value)}
|
|
193
|
-
onKeyDown={event => {
|
|
230
|
+
onKeyDown={event => {
|
|
231
|
+
if (event.key === 'Escape') {
|
|
232
|
+
setGlobalQuery('');
|
|
233
|
+
setActiveResultIndex(-1);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!searchOpen) return;
|
|
237
|
+
if (event.key === 'ArrowDown') {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
setActiveResultIndex(index => (index + 1) % searchResults.length);
|
|
240
|
+
} else if (event.key === 'ArrowUp') {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
setActiveResultIndex(index =>
|
|
243
|
+
index <= 0 ? searchResults.length - 1 : index - 1
|
|
244
|
+
);
|
|
245
|
+
} else if (event.key === 'Enter' && activeResultIndex >= 0) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
selectSearchResult(searchResults[activeResultIndex]);
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
194
250
|
placeholder="搜索所有节点…"
|
|
195
251
|
aria-label="搜索所有节点"
|
|
252
|
+
role="combobox"
|
|
253
|
+
aria-expanded={searchOpen}
|
|
254
|
+
aria-controls="global-search-results"
|
|
255
|
+
aria-autocomplete="list"
|
|
256
|
+
aria-activedescendant={activeResultId}
|
|
196
257
|
/>
|
|
197
|
-
{globalQuery &&
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
{
|
|
201
|
-
|
|
202
|
-
|
|
258
|
+
{globalQuery && (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => {
|
|
262
|
+
setGlobalQuery('');
|
|
263
|
+
setActiveResultIndex(-1);
|
|
264
|
+
}}
|
|
265
|
+
aria-label="清除搜索"
|
|
266
|
+
>
|
|
267
|
+
<X size={13} />
|
|
268
|
+
</button>
|
|
269
|
+
)}
|
|
270
|
+
{searchOpen && (
|
|
271
|
+
<div id="global-search-results" className="global-search-results" role="listbox">
|
|
272
|
+
{searchResults.map((node, index) => (
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
key={node.id}
|
|
276
|
+
id={`global-search-option-${node.id}`}
|
|
277
|
+
className={index === activeResultIndex ? 'active' : ''}
|
|
278
|
+
onMouseEnter={() => setActiveResultIndex(index)}
|
|
279
|
+
onClick={() => selectSearchResult(node)}
|
|
280
|
+
role="option"
|
|
281
|
+
aria-selected={index === activeResultIndex}
|
|
282
|
+
>
|
|
283
|
+
<span>{node.title}</span>
|
|
284
|
+
<small>
|
|
285
|
+
{node.level}
|
|
286
|
+
{node.kind && NODE_KINDS[node.kind]
|
|
287
|
+
? ` · ${NODE_KINDS[node.kind].label}`
|
|
288
|
+
: ''}{' '}
|
|
289
|
+
· {node.summary || node.id}
|
|
290
|
+
</small>
|
|
203
291
|
</button>
|
|
204
292
|
))}
|
|
205
293
|
</div>
|
|
@@ -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
|
}
|