concept-atlas-dense-explain 1.2.0 → 2.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 CHANGED
@@ -35,14 +35,17 @@ Mermaid loads from a CDN at runtime by default (fast builds, needs network);
35
35
  `--inline-mermaid` bakes it into the HTML for a fully offline single file, and
36
36
  `--mermaid-cdn` overrides the CDN URL. Appearance defaults can be baked with
37
37
  `--skin`, `--default-mode` and `--style`; readers can still switch in the UI.
38
+ Local figures link by default (small HTML, ship `assets/` beside the output);
39
+ `--inline-assets` bakes them in as base64 for a self-contained file, and a
40
+ per-figure `inline={true|false}` overrides that choice.
38
41
 
39
42
  Run `npx concept-atlas-dense-explain help` for the full flag list.
40
43
 
41
44
  ## Links
42
45
 
43
46
  - Repository: https://github.com/wurenrumian/concept-atlas
44
- - Framework guide: [`docs/FRAMEWORK.md`](https://github.com/wurenrumian/concept-atlas/blob/main/docs/FRAMEWORK.md)
45
- - Usage recipes: [`docs/USAGE.md`](https://github.com/wurenrumian/concept-atlas/blob/main/docs/USAGE.md)
47
+ - Framework guide: [`docs/FRAMEWORK.md`](https://github.com/wurenrumian/concept-atlas/blob/master/docs/FRAMEWORK.md)
48
+ - Usage recipes: [`docs/USAGE.md`](https://github.com/wurenrumian/concept-atlas/blob/master/docs/USAGE.md)
46
49
 
47
50
  ## License
48
51
 
package/bin/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { access, constants, copyFile, cp, mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, statSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { build } from 'vite';
6
6
  import { fileURLToPath } from 'node:url';
@@ -14,7 +14,7 @@ let buildCounter = 0;
14
14
 
15
15
  function usage() {
16
16
  console.log('Usage:');
17
- console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--force] [--concurrency N] [--link-assets] [--inline-mermaid] [--mermaid-cdn <url>] [--json] [--no-validate]');
17
+ console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [-o output.html|dir] [--force] [--concurrency N] [--inline-assets] [--inline-mermaid] [--mermaid-cdn <url>] [--json] [--no-validate]');
18
18
  console.log(' npx concept-atlas-dense-explain render <input.mdx>... [-o output.html|dir]');
19
19
  console.log(' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]');
20
20
  console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll] [--force]');
@@ -22,7 +22,7 @@ function usage() {
22
22
  console.log('');
23
23
  console.log(' --help shows this text; --version prints the package version.');
24
24
  console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
25
- console.log(' --link-assets keeps figures as relative links instead of inlining them as base64.');
25
+ console.log(' Figures are kept as relative links by default (small HTML; ship the assets/ dir beside it). --inline-assets bakes every local image into the HTML as base64 instead; a per-tag inline={true|false} on <Figure> overrides that for one image. --link-assets is kept as an explicit alias for the default.');
26
26
  console.log(' Mermaid diagrams load from a CDN at runtime by default (fast builds, needs network); --inline-mermaid bakes Mermaid into the HTML for a fully offline single file; --mermaid-cdn overrides the CDN URL.');
27
27
  console.log(` --skin bakes a default palette (${SKINS.map(skin => skin.id).join(', ')}); --default-mode bakes a default dark/light mode; --style bakes a default component style (${COMPONENT_STYLES.map(style => style.id).join(', ')}). Readers can still switch in the UI.`);
28
28
  }
@@ -31,6 +31,11 @@ async function exists(filePath) {
31
31
  try { await access(filePath, constants.F_OK); return true; } catch { return false; }
32
32
  }
33
33
 
34
+ /** Byte size of an asset, or null when it does not exist. */
35
+ function assetByteSize(filePath) {
36
+ try { return statSync(filePath).size; } catch { return null; }
37
+ }
38
+
34
39
  function flagValue(flags, names) {
35
40
  for (const name of names) {
36
41
  const index = flags.indexOf(name);
@@ -46,7 +51,7 @@ function fail(message) {
46
51
  }
47
52
 
48
53
  const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency', '--skin', '--default-mode', '--style', '--mermaid-cdn']);
49
- const BOOLEAN_FLAGS = new Set(['--force', '--json', '--strict', '--no-validate', '--link-assets', '--inline-mermaid', '--help', '-h', '--version', '-v']);
54
+ const BOOLEAN_FLAGS = new Set(['--force', '--json', '--strict', '--no-validate', '--link-assets', '--inline-assets', '--inline-mermaid', '--help', '-h', '--version', '-v']);
50
55
  const COMMAND_NAMES = ['help', 'create', 'new', 'render', 'validate', 'guide'];
51
56
 
52
57
  /** Splits argv into flags, flag values and positional arguments. */
@@ -252,6 +257,10 @@ const strict = parsed.flags.has('--strict');
252
257
  const skipValidate = parsed.flags.has('--no-validate');
253
258
  const force = parsed.flags.has('--force');
254
259
  const linkAssets = parsed.flags.has('--link-assets');
260
+ const inlineAssets = parsed.flags.has('--inline-assets');
261
+ if (linkAssets && inlineAssets) {
262
+ fail('--link-assets and --inline-assets are mutually exclusive (linking is the default).');
263
+ }
255
264
  const inlineMermaid = parsed.flags.has('--inline-mermaid');
256
265
  const mermaidCdn = parsed.values.get('--mermaid-cdn') || null;
257
266
  const modeFlag = parsed.values.get('--mode') || null;
@@ -285,7 +294,9 @@ if (command === 'validate') {
285
294
  filePath: target,
286
295
  mode: modeFlag,
287
296
  strict,
297
+ inlineAssets,
288
298
  assetExists: spec => existsSync(path.resolve(path.dirname(target), spec)),
299
+ assetSize: spec => assetByteSize(path.resolve(path.dirname(target), spec)),
289
300
  }, { json });
290
301
  process.exit(countBySeverity(result.diagnostics).error ? 1 : 0);
291
302
  }
@@ -326,7 +337,9 @@ const validations = sources.map((source, index) => printDiagnostics(source, {
326
337
  filePath: inputs[index],
327
338
  mode: modeFlag,
328
339
  strict,
340
+ inlineAssets,
329
341
  assetExists: spec => existsSync(path.resolve(path.dirname(inputs[index]), spec)),
342
+ assetSize: spec => assetByteSize(path.resolve(path.dirname(inputs[index]), spec)),
330
343
  }, json
331
344
  ? { json: false, quiet: true }
332
345
  : { json: false, label: multi ? inputs[index] : null }));
@@ -350,10 +363,13 @@ const jobs = inputs.map((input, index) => {
350
363
  console.error(`Could not detect the MDX carrier for ${input}; choose --mode atlas or --mode scroll.`);
351
364
  process.exit(1);
352
365
  }
353
- if (linkAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
354
- console.error(`警告:--link-assets 下 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效。`);
366
+ // Figures link by default. If the HTML is written outside the MDX's directory
367
+ // its relative image paths no longer resolve, so warn unless --inline-assets
368
+ // bakes them in.
369
+ if (!inlineAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
370
+ console.error(`警告:默认外链图片,但 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效;请用 --inline-assets,或把 assets/ 一并放到输出目录。`);
355
371
  }
356
- return { input, output: outputs[index], mode, title: extractPageTitle(sources[index]), features: detectFeatures(sources[index]), linkAssets };
372
+ return { input, output: outputs[index], mode, title: extractPageTitle(sources[index]), features: detectFeatures(sources[index]), inlineAssets };
357
373
  });
358
374
 
359
375
  const limit = clampConcurrency(parsed.values.get('--concurrency'), jobs.length);
@@ -369,7 +385,7 @@ if (failures.length) {
369
385
  }
370
386
 
371
387
  async function buildOne(job) {
372
- const { input, output, mode, title, features, linkAssets: link } = job;
388
+ const { input, output, mode, title, features, inlineAssets: inline } = job;
373
389
  const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
374
390
  // Each build gets its own scratch outDir: the template always writes
375
391
  // `index.html`/`scroll.html`, so concurrent builds sharing a directory would
@@ -378,7 +394,8 @@ async function buildOne(job) {
378
394
  await mkdir(path.dirname(output), { recursive: true });
379
395
  await mkdir(scratch, { recursive: true });
380
396
  const define = { __ATLAS_FEATURES__: JSON.stringify(features) };
381
- if (link) define.__ATLAS_INLINE_ASSETS__ = 'false';
397
+ // 'false' is the default (link); only --inline-assets sets 'true'.
398
+ define.__ATLAS_INLINE_ASSETS__ = inline ? 'true' : 'false';
382
399
  if (title) define.__ATLAS_PAGE_TITLE__ = JSON.stringify(title);
383
400
  if (skinFlag) define.__ATLAS_DEFAULT_SKIN__ = JSON.stringify(skinFlag);
384
401
  if (defaultModeFlag) define.__ATLAS_DEFAULT_MODE__ = JSON.stringify(defaultModeFlag);
@@ -424,7 +441,7 @@ async function buildOne(job) {
424
441
  }
425
442
  if (hasBackup) await rm(backup, { force: true });
426
443
  }
427
- console.log(`Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${link ? ' [figures linked]' : ''}`);
444
+ console.log(`Built ${mode} HTML: ${output}${title ? ` [tab: ${title}]` : ''}${describeFeatures(features)}${inline ? ' [figures inlined]' : ' [figures linked]'}`);
428
445
  return { ok: true, input, output };
429
446
  } catch (error) {
430
447
  return { ok: false, input, output, error };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Portable dense-explanation skill and MDX concept atlas template",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@ This is the only runner — there is nothing to look for. Do not search for a lo
32
32
  npx concept-atlas-dense-explain validate <file>.mdx --json
33
33
  ```
34
34
  Diagnostics are `CODE line:column message`. Fix all `error`s and re-run; address warnings when cheap.
35
- 6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--inline-mermaid] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build (`--no-validate` forces a knowingly broken build). Mermaid loads from a CDN at runtime by default (needs network); pass `--inline-mermaid` for a fully offline single file.
35
+ 6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html] [--skin <id>] [--default-mode dark|light|system] [--style <id>] [--mermaid-cdn <url>]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build (`--no-validate` forces a knowingly broken build). **Mermaid stays on its runtime CDN by default — do not pass `--inline-mermaid` on your own.** Only add `--inline-mermaid` when the user explicitly asks for a fully offline single file, since it bloats the HTML with the whole Mermaid bundle.
36
36
  7. **Appearance (optional)**: pages ship a reader-facing appearance menu — palette (`aurora` indigo, `ember` gold, `verdant` forest, `sakura` pink-plum, `noir` ink), a dark/light toggle, and a component style pack (`manuscript` editorial marginalia, `classic` boxed cards, `shadcn` hairline-bordered minimal UI, `elastic` bordered observability panels). The shipped default is aurora × manuscript × light; choices persist in localStorage across both carriers. Bake different compile-time defaults with `--skin ember --default-mode dark --style classic` (or `CONCEPT_ATLAS_SKIN` / `CONCEPT_ATLAS_DEFAULT_MODE` / `CONCEPT_ATLAS_STYLE` on the repo build); `--default-mode` honors `dark`/`light` and resolves `system` to the carrier default (`light`). Bake a default only when the user asks for one — content MDX never sets appearance.
37
37
  8. For several documents, pass them all in one call: `npx concept-atlas-dense-explain a.mdx b.mdx c.mdx -o dist --force [--concurrency 3]` (`-o` is then a directory; everything validates first, then builds in parallel). Builds bundle only the heavy renderers the content uses: no `<Math>` skips KaTeX's ~1.4MB inlined fonts, and Mermaid stays on a CDN. Never add dummy `<Math>`/`<Mermaid>` nodes to "enable" them.
38
38
  9. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
@@ -52,7 +52,7 @@ This is the only runner — there is nothing to look for. Do not search for a lo
52
52
  - Information models: `Flow`, `Timeline`, `Compare`, `DecisionMatrix`, `FrameworkModel`, `MatrixModel`, `FormulaModel`, `PyramidModel`, `FunnelModel`
53
53
  - Data and behaviour: `DataTable`, `Metric`, `StateMachine`, `DecisionTree`, `FeedbackLoop`, `CodeDiff`
54
54
  - Reading and layout: `Insight`, `Callout`, `Details`, `NoteGrid`, `Tabs`, `Columns`, `Stack`, `Grid`, `Split`, `ScrollGrid`, `ScrollPair`, `ScrollToc`
55
- - Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure` (alias `Image`), `Cite`, `References`
55
+ - Graphics and extensions: `Mermaid`, `RelationMap`, `RelationPath`, `Math`, `MathBlock`, `Chart`, `Figure` (alias `Image`), `FigureRef`, `Cite`, `References`
56
56
 
57
57
  ## Authoring rules
58
58
 
@@ -67,20 +67,22 @@ This is the only runner — there is nothing to look for. Do not search for a lo
67
67
  - **Learning loop**: `WorkedExample` holds `Step` children; give each step a `reason` (the justification) so it teaches reasoning, not just the result. `Quiz question answer` reveals the answer on click with children as the explanation. `KeyTakeaways items={[...]}` closes a section.
68
68
  - **Provenance**: `Source kind="spec|rfc|implementation|experiment|experience" label href` marks one claim's origin inline; `Confidence level="high|medium|low" basis` wraps a claim with how strongly it is established; `Term name` (or children as the definition) gives an inline hover definition.
69
69
  - **Data and behaviour**: `DataTable headers={[...]} rows={[[...]]}` is the neutral table (no comparative stance). `Metric label value unit delta trend note` is one headline number — compose several with `Grid`/`ScrollGrid`. `StateMachine states={[{id,label,terminal}]} transitions={[{from,to,event,guard}]}` expresses cycles and guards. `DecisionTree branches={[{condition,outcome,tone,branches}]}` nests branches. `FeedbackLoop type="reinforcing|balancing" nodes={[{label,description}]}` closes a loop. `CodeDiff before after language` shows a change.
70
- - **Figure**: a relative `src` (`./assets/diagram.png`) is inlined as base64 at build time so the HTML stays standalone; `http(s)` URLs stay links. Always set `alt`; add `label` and `caption` for a numbered caption. A missing relative file warns (`ASSET_MISSING`) and shows a placeholder. Readers can click a figure to open it full-screen (zoom, drag, `Esc`) — mention it for diagram-heavy pages.
71
- - **Figure size & cost**: inlining images is what makes a screenshot-heavy page large; a page with no heavy renderers otherwise lands near 250KB. When the user cares, compile with `--link-assets` to keep images as relative links (measured 1.51MB → 270KB); the output must then sit beside the MDX's `assets/`, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
70
+ - **Figure**: a relative `src` (`./assets/diagram.png`) stays a relative link by default; compile with `--inline-assets` to bake every local image in as base64, or set `inline={true}` / `inline={false}` on one figure to override (precedence: per-figure prop > global flag > link). `http(s)` URLs stay links and warn (`ASSET_REMOTE`). Always set `alt`; a missing relative file warns (`ASSET_MISSING`) and shows a placeholder. Readers can click a figure to open it full-screen (zoom, drag, `Esc`) — mention it for diagram-heavy pages.
71
+ - **Figure numbering**: give a figure an `id` and reference it with `<FigureRef id="..." />` to render "图 N". Numbering follows document order (per node in `atlas`, whole document in `scroll`) and reflows automatically, so never hand-write "图 1"; an explicit `label` still wins. A ref with no matching `id` warns (`FIGURE_REF_UNRESOLVED`).
72
+ - **Figure size & cost**: linking keeps the HTML small but requires the output to sit beside the MDX's `assets/` (the CLI warns when `-o` points elsewhere). Inlining is what makes a screenshot-heavy page large (a page with no heavy renderers otherwise lands near 250KB), and an inlined asset over 512KB warns (`ASSET_LARGE`). Decide the trade-off with the user instead of choosing silently.
72
73
  - **Cite/References**: `<Cite id="..." />` renders `[n]` from the matching item's position in `<References items={...} />`. In `scroll`, `References` can sit anywhere. In `atlas`, keep the cites and the `References` block in the same node, because node content only renders when that node is open.
73
74
  - Continuous reading is configured on the shell, not with manual CSS: `spacing="compact|comfortable|airy"` for rhythm, `fontSize="compact|normal|large|xlarge"` (or numeric `scale`/`lineHeight`) for text size.
74
75
 
75
76
  ## Validation diagnostics
76
77
 
77
- `validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_KIND`, `MECHANISM_KIND_UNVERIFIED`, `FAILURE_KIND_UNSTRUCTURED`, `FAILURE_MODE_EMPTY`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
78
+ `validate` and the build print `CODE line:column message`. Fix these `error`s before building: `UNKNOWN_COMPONENT`, `CARRIER_MISSING`, `CARRIER_CONFLICT`, `CARRIER_MODE_MISMATCH`, `NODE_MISSING_ID`, `DUPLICATE_NODE_ID`, `NODE_MISSING_TITLE`, `MISSING_PARENT`, `GRAPH_ROOT_UNRESOLVED`, `REF_MISSING_ID`, `REF_UNRESOLVED`, `RELATION_FROM_UNRESOLVED`, `RELATION_TO_UNRESOLVED`. Warnings worth fixing: `NODE_MISSING_SUMMARY`, `NODE_NO_CORE_CONTENT`, `UNKNOWN_LEVEL`, `UNKNOWN_KIND`, `MECHANISM_KIND_UNVERIFIED`, `FAILURE_KIND_UNSTRUCTURED`, `FAILURE_MODE_EMPTY`, `UNKNOWN_RELATION_TYPE`, `RELATION_MISSING_LABEL`, `RELATION_SELF`, `PROP_EXPECTS_ARRAY`, `MATH_CHILDREN_BRACES`, `PROSE_EXPRESSION`, `FRONTMATTER_UNSUPPORTED`, `GRAPH_MISSING_ROOT`, `FIGURE_MISSING_SRC`, `ASSET_MISSING`, `ASSET_REMOTE`, `ASSET_LARGE`, `FIGURE_REF_UNRESOLVED`, `FIGURE_REF_MISSING_ID`, `FIGURE_DUPLICATE_ID`, `REF_SELF`; `NO_ROOT_LEVEL`, `MULTIPLE_ROOT_LEVEL`, `MECHANISM_KIND_UNVERIFIED` and `FAILURE_KIND_UNSTRUCTURED` are warnings that `--strict` promotes to errors.
78
79
 
79
80
  ## Before you report
80
81
 
81
82
  - All `error` diagnostics resolved (or `--no-validate` explicitly justified).
82
83
  - All `ConceptRef`, `Relation` endpoints, and `ConceptGraph root` point at existing node ids.
83
84
  - Every node has `title` + `summary`; every `Relation` has a `label`.
85
+ - You left Mermaid on its CDN default and did not pass `--inline-mermaid` unless the user explicitly asked for a fully offline single file.
84
86
  - The HTML file exists at the reported path.
85
87
 
86
88
  If `npx` cannot reach the registry, report the blocker. Never copy implementation files into the skill directory.
@@ -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:['中','概念下钻']}]} />
@@ -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:['中','概念下钻']}]} />
@@ -44,7 +44,6 @@ export function App({ mdxContent, initialData }) {
44
44
  const [currentNodeId, setCurrentNodeId] = useState(
45
45
  () => initialHashNode || graph.meta.rootId || '',
46
46
  );
47
- const [selectedLevel, setSelectedLevel] = useState(null);
48
47
  const [nav, setNav] = useState(() => ({
49
48
  entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
50
49
  index: 0,
@@ -239,8 +238,6 @@ export function App({ mdxContent, initialData }) {
239
238
  currentNodeId={currentNodeId}
240
239
  onSelectNode={navigateToNode}
241
240
  onSwitchView={setCurrentView}
242
- selectedLevel={selectedLevel}
243
- onSelectLevel={setSelectedLevel}
244
241
  />
245
242
  ) : (
246
243
  <RelationGraph
@@ -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
+ }
@@ -5,6 +5,8 @@ import katex from 'katex';
5
5
  import { ZoomIn } from 'lucide-react';
6
6
  import 'katex/dist/katex.min.css';
7
7
  import { registerReferences, subscribeReferences, getReferenceIndex } from '../model/citations.js';
8
+ import { registerFigure, subscribeFigures, getFigureNumber } from '../model/figures.js';
9
+ import { useFigureScope } from './FigureScope.jsx';
8
10
 
9
11
  // Mermaid reads the active skin/mode from CSS variables so diagrams follow
10
12
  // the current appearance. A MutationObserver re-renders charts when the
@@ -1568,12 +1570,28 @@ function ImageZoom({ src, alt, caption, label, onClose }) {
1568
1570
  );
1569
1571
  }
1570
1572
 
1571
- export function Figure({ src, alt = '', caption, label, width = 'auto', height = 'auto', x = 0, y = 0, position = 'flow' }) {
1573
+ export function Figure({ id, src, alt = '', caption, label, inline, width = 'auto', height = 'auto', x = 0, y = 0, position = 'flow' }) {
1574
+ const scope = useFigureScope();
1575
+ // `inline` is a build-time hint consumed by vite's asset plugin; binding it
1576
+ // here keeps it out of the DOM and documents that the component accepts it.
1577
+ void inline;
1578
+ const number = React.useSyncExternalStore(
1579
+ subscribeFigures,
1580
+ () => getFigureNumber(scope, id),
1581
+ () => getFigureNumber(scope, id),
1582
+ );
1583
+ React.useEffect(() => { registerFigure(scope, id); }, [scope, id]);
1572
1584
  const [zoomed, setZoomed] = React.useState(false);
1573
1585
  const title = alt || caption || '图片';
1586
+ // An explicit `label` wins; otherwise a named figure numbers itself.
1587
+ const displayLabel = label || (id && number ? `图 ${number}` : undefined);
1574
1588
 
1575
1589
  return (
1576
- <figure className={`semantic-figure ${widgetClass(position)}`} style={widgetStyle({ width, height, x, y, position })}>
1590
+ <figure
1591
+ id={id ? `fig-${id}` : undefined}
1592
+ className={`semantic-figure ${widgetClass(position)}`}
1593
+ style={widgetStyle({ width, height, x, y, position })}
1594
+ >
1577
1595
  {src ? (
1578
1596
  <button
1579
1597
  type="button"
@@ -1585,19 +1603,38 @@ export function Figure({ src, alt = '', caption, label, width = 'auto', height =
1585
1603
  <span className="figure-zoom-hint" aria-hidden="true"><ZoomIn size={12} />点击放大</span>
1586
1604
  </button>
1587
1605
  ) : <div className="figure-placeholder">缺少图片 src</div>}
1588
- {(caption || label) && (
1606
+ {(caption || displayLabel) && (
1589
1607
  <figcaption>
1590
- {label && <span className="figure-label">{label}</span>}
1608
+ {displayLabel && <span className="figure-label">{displayLabel}</span>}
1591
1609
  {caption}
1592
1610
  </figcaption>
1593
1611
  )}
1594
- {zoomed && <ImageZoom src={src} alt={alt} caption={caption} label={label} onClose={() => setZoomed(false)} />}
1612
+ {zoomed && <ImageZoom src={src} alt={alt} caption={caption} label={displayLabel} onClose={() => setZoomed(false)} />}
1595
1613
  </figure>
1596
1614
  );
1597
1615
  }
1598
1616
  Figure.displayName = 'Figure';
1599
1617
  export const Image = Figure;
1600
1618
 
1619
+ /**
1620
+ * In-text reference to a numbered figure. `<Figure id="arch" />` numbers itself
1621
+ * in document order and `<FigureRef id="arch" />` renders "图 N", so inserting a
1622
+ * figure never requires renumbering the prose by hand. Links to the figure.
1623
+ */
1624
+ export function FigureRef({ id, children }) {
1625
+ const scope = useFigureScope();
1626
+ const number = React.useSyncExternalStore(
1627
+ subscribeFigures,
1628
+ () => getFigureNumber(scope, id),
1629
+ () => getFigureNumber(scope, id),
1630
+ );
1631
+ const text = children ?? (number ? `图 ${number}` : '图 ?');
1632
+ return id
1633
+ ? <a className="semantic-figure-ref" href={`#fig-${id}`}>{text}</a>
1634
+ : <span className="semantic-figure-ref" data-missing="true">{text}</span>;
1635
+ }
1636
+ FigureRef.displayName = 'FigureRef';
1637
+
1601
1638
  // Citations -----------------------------------------------------------------
1602
1639
 
1603
1640
  export function Cite({ id, children }) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Figure numbering registry shared by <Figure> and <FigureRef>.
3
+ *
4
+ * Numbers are assigned per *scope*: a scroll document numbers its figures in
5
+ * document order, while an atlas node numbers only the figures it shows. The
6
+ * <FigureScope> context supplies that key, so browsing from one node to another
7
+ * does not leak a running count between them. Registration is idempotent, so a
8
+ * figure that re-renders (or remounts under React StrictMode) keeps its number.
9
+ *
10
+ * The store is module-level for the same reason as citations.js: a <FigureRef>
11
+ * may appear before the <Figure> it points at, so the reference subscribes and
12
+ * resolves once the target registers.
13
+ */
14
+ const scopes = new Map();
15
+ let version = 0;
16
+ const listeners = new Set();
17
+
18
+ function emit() {
19
+ version += 1;
20
+ for (const listener of listeners) listener();
21
+ }
22
+
23
+ function scopeKey(scope) {
24
+ return scope || 'document';
25
+ }
26
+
27
+ /** Registers `id` at the next free position in `scope` (no-op if already there). */
28
+ export function registerFigure(scope, id) {
29
+ if (!id) return;
30
+ const key = scopeKey(scope);
31
+ const order = scopes.get(key) || [];
32
+ if (order.includes(id)) return;
33
+ order.push(id);
34
+ scopes.set(key, order);
35
+ emit();
36
+ }
37
+
38
+ /** Clears one scope, or every scope when called without arguments. */
39
+ export function resetFigures(scope) {
40
+ if (scope === undefined) scopes.clear();
41
+ else scopes.delete(scopeKey(scope));
42
+ emit();
43
+ }
44
+
45
+ export function subscribeFigures(listener) {
46
+ listeners.add(listener);
47
+ return () => listeners.delete(listener);
48
+ }
49
+
50
+ export function getFiguresVersion() {
51
+ return version;
52
+ }
53
+
54
+ /** 1-based document position of `id` in `scope`, or null when unregistered. */
55
+ export function getFigureNumber(scope, id) {
56
+ if (!id) return null;
57
+ const order = scopes.get(scopeKey(scope));
58
+ if (!order) return null;
59
+ const index = order.indexOf(id);
60
+ return index < 0 ? null : index + 1;
61
+ }
@@ -87,6 +87,7 @@ export const KNOWN_COMPONENTS = [
87
87
  'Chart',
88
88
  'Figure',
89
89
  'Image',
90
+ 'FigureRef',
90
91
  'Cite',
91
92
  'References',
92
93
  ];
@@ -101,6 +102,9 @@ export const RELATION_TYPE_SET = new Set(RELATION_TYPE_NAMES);
101
102
  export const LEVEL_NAMES = Object.keys(LEVEL_DEFS);
102
103
  export const LEVEL_SET = new Set(LEVEL_NAMES);
103
104
 
105
+ /** Inlined images above this size are reported: base64 inflates the HTML ~33%. */
106
+ export const LARGE_ASSET_BYTES = 512 * 1024;
107
+
104
108
  /** Props that must be arrays, mapped to the component that owns them. */
105
109
  export const ARRAY_PROPS = {
106
110
  Flow: ['steps'],
@@ -358,10 +362,12 @@ function attrValue(map, name) {
358
362
  * @param {'atlas'|'scroll'|null} [options.mode] Expected carrier
359
363
  * @param {boolean} [options.strict] Promote structural warnings to errors
360
364
  * @param {(file:string)=>boolean} [options.assetExists] Asset existence probe
365
+ * @param {(file:string)=>number|null} [options.assetSize] Asset size probe (bytes)
366
+ * @param {boolean} [options.inlineAssets] Whether the build inlines local images
361
367
  * @returns {{diagnostics:object[], stats:object, carrier:string|null}}
362
368
  */
363
369
  export function validateMdxSource(source, options = {}) {
364
- const { filePath = null, mode = null, strict = false, assetExists = null } = options;
370
+ const { filePath = null, mode = null, strict = false, assetExists = null, assetSize = null, inlineAssets = false } = options;
365
371
  const lineStarts = computeLineStarts(source);
366
372
  const masked = maskIgnored(source);
367
373
  const tags = tokenize(masked);
@@ -376,6 +382,8 @@ export function validateMdxSource(source, options = {}) {
376
382
  const graphRoots = [];
377
383
  const relations = [];
378
384
  const refs = [];
385
+ const figureIds = new Map();
386
+ const figureRefs = [];
379
387
  const usedComponents = new Map();
380
388
  const stack = [];
381
389
 
@@ -434,15 +442,31 @@ export function validateMdxSource(source, options = {}) {
434
442
  const map = attrsToMap(tag.attrs);
435
443
  const src = attrValue(map, 'src');
436
444
  const quoted = map.src ? map.src.quoted : false;
445
+ const figureId = attrValue(map, 'id');
446
+ if (figureId) {
447
+ if (figureIds.has(figureId)) add('warning', 'FIGURE_DUPLICATE_ID', `重复的图片 id:${figureId}`, tag.start, figureId);
448
+ else figureIds.set(figureId, tag.start);
449
+ }
437
450
  if (!src) add('warning', 'FIGURE_MISSING_SRC', 'Figure 缺少 src', tag.start, tag.name);
438
- else if (quoted && !/^(https?:|data:|\/)/i.test(src) && filePath && assetExists) {
451
+ else if (quoted && /^https?:/i.test(src)) {
452
+ add('warning', 'ASSET_REMOTE', `远程图片需要联网,离线打开时不可见:${src}`, tag.start, src);
453
+ } else if (quoted && !/^(data:|\/)/i.test(src) && filePath && assetExists) {
439
454
  const resolved = src.startsWith('.') ? src : `./${src}`;
440
455
  if (!assetExists(resolved)) {
441
456
  add('warning', 'ASSET_MISSING', `找不到图片文件:${src}(将显示占位符)`, tag.start, src);
457
+ } else if (inlineAssets && assetSize) {
458
+ const bytes = assetSize(resolved);
459
+ if (typeof bytes === 'number' && bytes > LARGE_ASSET_BYTES) {
460
+ add('warning', 'ASSET_LARGE', `图片约 ${Math.round(bytes / 1024)}KB,内联会显著增大 HTML;可用 inline={false} 或改为外链:${src}`, tag.start, src);
461
+ }
442
462
  }
443
463
  }
444
464
  }
445
465
 
466
+ if (tag.name === 'FigureRef' && tag.kind !== 'close') {
467
+ figureRefs.push({ id: attrValue(attrsToMap(tag.attrs), 'id'), offset: tag.start });
468
+ }
469
+
446
470
  if (tag.kind === 'close') {
447
471
  for (let i = stack.length - 1; i >= 0; i -= 1) {
448
472
  if (stack[i].tag.name === tag.name) {
@@ -465,6 +489,12 @@ export function validateMdxSource(source, options = {}) {
465
489
  }
466
490
  }
467
491
 
492
+ // <FigureRef> must point at a <Figure id="..."> in the same document.
493
+ for (const ref of figureRefs) {
494
+ if (!ref.id) add('warning', 'FIGURE_REF_MISSING_ID', 'FigureRef 缺少 id', ref.offset, 'FigureRef');
495
+ else if (!figureIds.has(ref.id)) add('warning', 'FIGURE_REF_UNRESOLVED', `FigureRef 指向不存在的图片 id:${ref.id}`, ref.offset, ref.id);
496
+ }
497
+
468
498
  // `{` inside <Math> children is parsed by MDX as an expression, not LaTeX.
469
499
  for (const match of masked.matchAll(/<Math(?![^>]*\/>)[^>]*>([\s\S]*?)<\/Math>/g)) {
470
500
  if (match[1].includes('{')) {
@@ -5,6 +5,7 @@ import * as Components from './components/index.js';
5
5
  import UserDocument from '@concept-atlas/content';
6
6
  import { useAppearance } from './app/use-appearance.js';
7
7
  import { SkinPicker } from './components/SkinPicker.jsx';
8
+ import { FigureScopeProvider } from './components/FigureScope.jsx';
8
9
  import './styles/concept-explain.css';
9
10
 
10
11
  const rootElement = document.getElementById('root');
@@ -27,7 +28,9 @@ function ScrollApp() {
27
28
  {theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
28
29
  </button>
29
30
  </div>
30
- <UserDocument components={Components} />
31
+ <FigureScopeProvider scope="document">
32
+ <UserDocument components={Components} />
33
+ </FigureScopeProvider>
31
34
  </main>
32
35
  );
33
36
  }
@@ -348,12 +348,14 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
348
348
  }
349
349
 
350
350
  /* ==========================================================================
351
- NODE EXPLORER (3-COLUMN EDITORIAL KNOWLEDGE LAYOUT)
351
+ NODE EXPLORER (2-COLUMN EDITORIAL KNOWLEDGE LAYOUT)
352
352
  ========================================================================== */
353
353
 
354
354
  .node-explorer-layout {
355
355
  display: grid;
356
- grid-template-columns: 280px 1fr 340px;
356
+ /* Left sidebar + the scrollable center. The floating app toolbar overlays the
357
+ top-right; app-main reserves its height so nothing sits underneath. */
358
+ grid-template-columns: 220px minmax(0, 1fr);
357
359
  height: 100%;
358
360
  min-height: 0;
359
361
  }
@@ -620,44 +622,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
620
622
  margin: 0 auto;
621
623
  }
622
624
 
623
- /* Toolbar */
624
- .center-toolbar {
625
- display: flex;
626
- align-items: center;
627
- justify-content: space-between;
628
- margin-bottom: 24px;
629
- padding-bottom: 14px;
630
- border-bottom: 1px solid var(--border-medium);
631
- }
632
-
633
- .level-indicators {
634
- display: flex;
635
- gap: 5px;
636
- }
637
-
638
- .level-pill {
639
- padding: 4px 9px;
640
- border-radius: 6px;
641
- font-size: 11px;
642
- font-weight: 700;
643
- background: var(--bg-surface);
644
- color: var(--text-dim);
645
- border: 1px solid var(--border-medium);
646
- transition: all 0.2s ease;
647
- }
648
-
649
- .level-pill:hover {
650
- border-color: var(--text-muted);
651
- color: var(--text-primary);
652
- }
653
-
654
- .level-pill.active {
655
- background: var(--primary);
656
- color: var(--on-accent);
657
- border-color: var(--primary);
658
- box-shadow: var(--shadow-sm);
659
- }
660
-
661
625
  .view-graph-btn {
662
626
  display: flex;
663
627
  align-items: center;
@@ -1298,15 +1262,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
1298
1262
  flex-direction: column;
1299
1263
  }
1300
1264
 
1301
- .center-toolbar {
1302
- flex: 0 0 auto;
1303
- gap: 10px;
1304
- min-height: 24px;
1305
- margin-bottom: 4px;
1306
- padding-bottom: 4px;
1307
- border-bottom: 0;
1308
- }
1309
-
1310
1265
  .draft-floating-tools {
1311
1266
  position: fixed;
1312
1267
  right: 10px;
@@ -1501,6 +1456,9 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
1501
1456
 
1502
1457
  .hierarchy-strip-children {
1503
1458
  padding-left: 0;
1459
+ /* The floating app toolbar hangs into the strip's top-right. Nudge the
1460
+ drill-down row below its bottom edge so its buttons are never covered. */
1461
+ padding-top: 16px;
1504
1462
  }
1505
1463
 
1506
1464
  .hierarchy-child-scroll { display: flex; gap: 5px; overflow-x: auto; min-width: 0; }
@@ -1552,10 +1510,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
1552
1510
  box-shadow: none;
1553
1511
  }
1554
1512
 
1555
- .node-explorer-layout {
1556
- grid-template-columns: 220px minmax(0, 1fr);
1557
- }
1558
-
1559
1513
  .inline-inspector {
1560
1514
  margin-top: 18px;
1561
1515
  padding: 18px 20px;
@@ -2787,7 +2741,6 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
2787
2741
  .meta-chip,
2788
2742
  .insp-pill,
2789
2743
  .inline-relation,
2790
- .level-pill,
2791
2744
  .view-graph-btn,
2792
2745
  .framework-model-element,
2793
2746
  .model-pyramid-level,
@@ -3485,7 +3438,7 @@ html[data-carrier='scroll'] #root {
3485
3438
  display: block;
3486
3439
  width: 100%;
3487
3440
  height: 100%;
3488
- background: linear-gradient(90deg, var(--primary), var(--text-accent));
3441
+ background: var(--progress-gradient);
3489
3442
  transform: scaleX(0);
3490
3443
  transform-origin: left center;
3491
3444
  transition: transform 80ms linear;
@@ -3891,6 +3844,22 @@ html[data-carrier='scroll'] #root {
3891
3844
  font-weight: 700;
3892
3845
  }
3893
3846
 
3847
+ /* In-text reference to a numbered figure; mirrors the citation affordance. */
3848
+ .semantic-figure-ref {
3849
+ color: var(--text-accent);
3850
+ font-weight: 600;
3851
+ text-decoration: none;
3852
+ border-bottom: 1px dashed currentColor;
3853
+ }
3854
+
3855
+ .semantic-figure-ref:hover {
3856
+ border-bottom-style: solid;
3857
+ }
3858
+
3859
+ .semantic-figure-ref[data-missing="true"] {
3860
+ color: var(--text-dim);
3861
+ }
3862
+
3894
3863
  .figure-placeholder {
3895
3864
  padding: 32px;
3896
3865
  border: 1px dashed var(--border-medium);
@@ -4025,7 +3994,6 @@ a.reference-title:hover { color: var(--text-accent); }
4025
3994
 
4026
3995
  /* Index chips pop in a beat after the surface they sit on. */
4027
3996
  .step-num,
4028
- .level-pill,
4029
3997
  .model-pyramid-level .framework-model-index,
4030
3998
  .framework-model-element .framework-model-index {
4031
3999
  animation: ca-pop var(--motion-base) var(--ease-out) backwards;
@@ -372,10 +372,6 @@
372
372
  border-bottom: 1px solid var(--border-subtle);
373
373
  }
374
374
 
375
- [data-style='elastic'] .reading-progress-bar {
376
- background: var(--primary);
377
- }
378
-
379
375
  [data-style='elastic'] .scroll-toc-title {
380
376
  color: var(--text-muted);
381
377
  font-weight: 600;
@@ -366,10 +366,6 @@
366
366
  border-bottom: 1px solid var(--border-subtle);
367
367
  }
368
368
 
369
- [data-style='shadcn'] .reading-progress-bar {
370
- background: var(--primary);
371
- }
372
-
373
369
  [data-style='shadcn'] .scroll-toc-title {
374
370
  color: var(--text-muted);
375
371
  font-weight: 600;
@@ -3,14 +3,13 @@ import { ChevronRight, ArrowUpRight, CornerDownRight, ArrowLeft, Network, Corner
3
3
  import { getAncestorPath, getSiblingNodes } from '../model/concept-schema.js';
4
4
  import { LEVEL_DEFS } from '../model/relation-types.js';
5
5
  import { NODE_KINDS } from '../model/node-kinds.js';
6
+ import { FigureScopeProvider } from '../components/FigureScope.jsx';
6
7
 
7
8
  export function NodeExplorer({
8
9
  graph,
9
10
  currentNodeId,
10
11
  onSelectNode,
11
12
  onSwitchView,
12
- selectedLevel,
13
- onSelectLevel
14
13
  }) {
15
14
  const { nodes, relations } = graph;
16
15
  const [canvasScale, setCanvasScale] = React.useState(1);
@@ -125,8 +124,6 @@ export function NodeExplorer({
125
124
  const childNodes = currentNode.children
126
125
  .filter(id => nodes.has(id))
127
126
  .map(id => nodes.get(id));
128
- const visibleChildNodes = selectedLevel ? childNodes.filter(node => node.level === selectedLevel) : childNodes;
129
- const visibleSiblings = selectedLevel ? siblings.filter(node => node.level === selectedLevel) : siblings;
130
127
 
131
128
  // Node-specific relations
132
129
  const outgoingRelations = relations.filter(r => r.from === currentNode.id);
@@ -168,8 +165,8 @@ export function NodeExplorer({
168
165
  <div className="side-section">
169
166
  <div className="side-label">同层节点</div>
170
167
  <div className="sibling-list">
171
- {visibleSiblings.length > 0 ? (
172
- visibleSiblings.map(sib => (
168
+ {siblings.length > 0 ? (
169
+ siblings.map(sib => (
173
170
  <button
174
171
  key={sib.id}
175
172
  className="sibling-btn"
@@ -195,6 +192,7 @@ export function NodeExplorer({
195
192
  </aside>
196
193
 
197
194
  {/* 2. Middle Column: Current Node Explanation Card */}
195
+ <FigureScopeProvider scope={currentNode.id}>
198
196
  <main className="explorer-center">
199
197
  <HierarchyStrip
200
198
  ancestorPath={ancestorPath}
@@ -203,23 +201,6 @@ export function NodeExplorer({
203
201
  onSelectNode={onSelectNode}
204
202
  />
205
203
  <div className="center-scrollable">
206
- {/* Header toolbar */}
207
- <div className="center-toolbar">
208
- <div className="level-indicators">
209
- {Object.keys(LEVEL_DEFS).map(lvl => (
210
- <button
211
- key={lvl}
212
- className={`level-pill ${(selectedLevel === lvl || (!selectedLevel && currentNode.level === lvl)) ? 'active' : ''}`}
213
- onClick={() => onSelectLevel && onSelectLevel(selectedLevel === lvl ? null : lvl)}
214
- title={LEVEL_DEFS[lvl].desc}
215
- >
216
- {lvl}
217
- </button>
218
- ))}
219
- {selectedLevel && <button className="level-pill level-pill-clear" onClick={() => onSelectLevel && onSelectLevel(null)}>全部</button>}
220
- </div>
221
- </div>
222
-
223
204
  <div
224
205
  ref={viewportRef}
225
206
  className={`draft-viewport ${isDragging ? 'is-dragging' : ''}`}
@@ -419,14 +400,14 @@ export function NodeExplorer({
419
400
  ))}
420
401
 
421
402
  {/* Sub-node Exploration Cards (Drill Down Entrance) */}
422
- {visibleChildNodes.length > 0 && (
403
+ {childNodes.length > 0 && (
423
404
  <section className="node-block drill-down-section">
424
405
  <div className="block-head">
425
406
  <h2>深入下钻:子概念节点</h2>
426
407
  <small>点击卡片探索更深机制</small>
427
408
  </div>
428
409
  <div className="subnodes-grid">
429
- {visibleChildNodes.map(child => (
410
+ {childNodes.map(child => (
430
411
  <button
431
412
  key={child.id}
432
413
  className="subnode-card"
@@ -457,6 +438,7 @@ export function NodeExplorer({
457
438
  </div>
458
439
  </div>
459
440
  </main>
441
+ </FigureScopeProvider>
460
442
  </div>
461
443
  );
462
444
  }
@@ -18,37 +18,111 @@ const MIME_TYPES = {
18
18
  '.bmp': 'image/bmp',
19
19
  };
20
20
 
21
+ // An opening/self-closing JSX tag. Quoted attribute values may contain `>`, so
22
+ // the attribute body is matched as a sequence of quoted strings or bare chars.
23
+ const JSX_TAG_RE = /<([A-Za-z][\w.]*)((?:"[^"]*"|'[^']*'|[^>"'])*)\/?>/g;
24
+ // A markdown image: ![alt](src) or ![alt](src "title"). Base64 has no spaces or
25
+ // parens, so it survives the round trip.
26
+ const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(\s+["'][^"']*["'])?\)/g;
27
+
28
+ /**
29
+ * Reads a tag's per-image `inline` prop. Returns `true`/`false` when it is set
30
+ * (bare `inline`, `inline={true}`, `inline={false}`) and `null` when absent.
31
+ * Quoted attribute values are blanked first so a value like "inline" cannot be
32
+ * mistaken for the prop itself.
33
+ */
34
+ function readInlineProp(tag) {
35
+ const stripped = tag.replace(/"[^"]*"|'[^']*'/g, '""');
36
+ const match = stripped.match(/\binline\b(?:\s*=\s*(\{[^}]*\}|[^\s/>]+))?/);
37
+ if (!match) return null;
38
+ if (match[1] === undefined) return true;
39
+ return match[1].replace(/^\{|\}$/g, '').trim() !== 'false';
40
+ }
41
+
21
42
  /**
22
- * Rewrites relative image paths in MDX component props (e.g. <Figure src="./x.png" />)
23
- * into base64 data URIs at build time. Keeps the single-file HTML self-contained
24
- * and offline-openable without a runtime asset loader. Remote (http/data) and
25
- * absolute paths are left untouched.
43
+ * Reversibly hides fenced code blocks and inline code spans so documented
44
+ * samples (`<Figure src="./x.png" />` inside a fence) are not rewritten into
45
+ * giant data URIs.
46
+ */
47
+ function maskCode(code) {
48
+ const blocks = [];
49
+ const token = index => `\u0000atlas-mask-${index}\u0000`;
50
+ const hide = (text) => { const key = token(blocks.length); blocks.push(text); return key; };
51
+ let fence = null;
52
+ const lines = code.split('\n').map((line) => {
53
+ const marker = line.match(/^\s*(`{3,}|~{3,})/);
54
+ if (fence) {
55
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
56
+ return hide(line);
57
+ }
58
+ if (marker) { fence = marker[1]; return hide(line); }
59
+ // Keep the character before the span so we never eat a JSX opening brace.
60
+ return line.replace(/([^{])`[^`\n]*`/g, match => match[0] + hide(match.slice(1)));
61
+ });
62
+ return {
63
+ masked: lines.join('\n'),
64
+ restore: text => blocks.reduce((acc, block, index) => acc.split(token(index)).join(block), text),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Resolves relative image paths on MDX components and markdown images, or leaves
70
+ * them alone. The default is to LEAVE them as relative links: the HTML stays
71
+ * small and the assets travel beside it. Inlining (a self-contained single file)
72
+ * is opt-in because it is the expensive choice, and it composes in one order:
73
+ *
74
+ * per-tag `inline` prop > `--inline-assets` global flag > default (link)
26
75
  *
27
- * `--link-assets` (the `__ATLAS_INLINE_ASSETS__` define) turns this off so figures
28
- * stay relative links. That keeps the output small at the cost of the page no
29
- * longer being self-contained: the HTML must sit beside the MDX's `assets/` dir.
76
+ * `--inline-assets` (define `__ATLAS_INLINE_ASSETS__ === 'true'`) inlines every
77
+ * local image; a per-tag `inline={true|false}` overrides that choice for one
78
+ * image. Remote (http/https), data URIs and absolute paths are never touched.
30
79
  */
31
80
  function inlineMdxAssets() {
32
- const state = { enabled: true };
81
+ const state = { defaultInline: false };
33
82
  return {
34
83
  name: 'concept-atlas-inline-assets',
35
84
  enforce: 'pre',
36
85
  configResolved(config) {
37
- state.enabled = (config.define || {}).__ATLAS_INLINE_ASSETS__ !== 'false';
86
+ const raw = (config.define || {}).__ATLAS_INLINE_ASSETS__;
87
+ const value = typeof raw === 'string' ? raw.replace(/^"([\s\S]*)"$/, '$1') : raw;
88
+ state.defaultInline = value === 'true' || value === true;
38
89
  },
39
90
  transform(code, id) {
40
- if (!state.enabled || !id.endsWith('.mdx')) return null;
91
+ if (!id.endsWith('.mdx')) return null;
41
92
  const dir = path.dirname(id.split('?')[0]);
42
- let changed = false;
43
- const output = code.replace(/(<[A-Za-z][\w.]*\b[^>]*?\bsrc=)(["'])([^"']+)\2/g, (match, prefix, quote, src) => {
44
- if (/^(https?:|data:|\/|#)/i.test(src)) return match;
93
+ const resolveLocal = (src) => {
94
+ if (/^(https?:|data:|\/|#)/i.test(src)) return null;
45
95
  const file = path.resolve(dir, src);
46
96
  const mime = MIME_TYPES[path.extname(file).toLowerCase()];
47
- if (!mime || !fs.existsSync(file)) return match;
97
+ if (!mime || !fs.existsSync(file)) return null;
98
+ return `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`;
99
+ };
100
+
101
+ const { masked, restore } = maskCode(code);
102
+ let changed = false;
103
+ let output = masked.replace(JSX_TAG_RE, (tag) => {
104
+ const srcMatch = tag.match(/\bsrc=(["'])([^"']+)\1/);
105
+ if (!srcMatch) return tag;
106
+ const prop = readInlineProp(tag);
107
+ const effective = prop === null ? state.defaultInline : prop;
108
+ if (!effective) return tag;
109
+ const uri = resolveLocal(srcMatch[2]);
110
+ if (!uri) return tag;
48
111
  changed = true;
49
- return `${prefix}${quote}data:${mime};base64,${fs.readFileSync(file).toString('base64')}${quote}`;
112
+ return tag.replace(srcMatch[0], `src=${srcMatch[1]}${uri}${srcMatch[1]}`);
50
113
  });
51
- return changed ? { code: output, map: null } : null;
114
+
115
+ // Markdown images have no prop slot, so they follow the global flag only.
116
+ if (state.defaultInline) {
117
+ output = output.replace(MARKDOWN_IMAGE_RE, (match, alt, src, title = '') => {
118
+ const uri = resolveLocal(src);
119
+ if (!uri) return match;
120
+ changed = true;
121
+ return `![${alt}](${uri}${title})`;
122
+ });
123
+ }
124
+
125
+ return changed ? { code: restore(output), map: null } : null;
52
126
  },
53
127
  };
54
128
  }