concept-atlas-dense-explain 0.4.1 → 0.5.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/bin/cli.mjs CHANGED
@@ -4,19 +4,23 @@ import { existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { build } from 'vite';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { validateMdxSource, countBySeverity } from '../template/src/model/validate-content.js';
7
+ import { validateMdxSource, countBySeverity, detectFeatures } from '../template/src/model/validate-content.js';
8
8
 
9
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
10
  const templateRoot = path.join(packageRoot, 'template');
11
11
  const args = process.argv.slice(2);
12
+ let buildCounter = 0;
12
13
 
13
14
  function usage() {
14
15
  console.log('Usage:');
15
- console.log(' npx concept-atlas-dense-explain <input.mdx> [--mode atlas|scroll] [-o output.html] [--force] [--json] [--no-validate]');
16
- console.log(' npx concept-atlas-dense-explain render <input.mdx> [--mode atlas|scroll] [-o output.html] [--force]');
16
+ console.log(' npx concept-atlas-dense-explain <input.mdx>... [--mode atlas|scroll] [-o output.html|dir] [--force] [--concurrency N] [--link-assets] [--json] [--no-validate]');
17
+ console.log(' npx concept-atlas-dense-explain render <input.mdx>... [-o output.html|dir]');
17
18
  console.log(' npx concept-atlas-dense-explain validate <input.mdx> [--mode atlas|scroll] [--strict] [--json]');
18
19
  console.log(' npx concept-atlas-dense-explain create <output.mdx> [--mode atlas|scroll] [--force]');
19
20
  console.log(' npx concept-atlas-dense-explain guide [--mode atlas|scroll] [-o output.mdx] [--force]');
21
+ console.log('');
22
+ console.log(' Multiple inputs build in parallel (default 2 at a time, cap 4); -o is then a directory.');
23
+ console.log(' --link-assets keeps figures as relative links instead of inlining them as base64.');
20
24
  }
21
25
 
22
26
  async function exists(filePath) {
@@ -37,17 +41,55 @@ function fail(message) {
37
41
  process.exit(1);
38
42
  }
39
43
 
40
- function printDiagnostics(source, options, { json }) {
44
+ const VALUE_FLAGS = new Set(['--mode', '-o', '--output', '--concurrency']);
45
+
46
+ /** Splits argv into flags, flag values and positional arguments. */
47
+ function parseFlags(argv) {
48
+ const flags = new Set();
49
+ const values = new Map();
50
+ const positional = [];
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const arg = argv[i];
53
+ if (VALUE_FLAGS.has(arg)) {
54
+ values.set(arg, argv[i + 1]);
55
+ i += 1;
56
+ } else if (arg.startsWith('-') && arg.length > 1) {
57
+ flags.add(arg);
58
+ } else {
59
+ positional.push(arg);
60
+ }
61
+ }
62
+ return { flags, values, positional };
63
+ }
64
+
65
+ /**
66
+ * A single input may name an output file; a batch needs a directory, because the
67
+ * per-target file names come from the inputs.
68
+ */
69
+ function resolveOutputs(inputs, explicit) {
70
+ const defaults = inputs.map(input => input.replace(/\.mdx$/i, '.html'));
71
+ if (!explicit) return defaults;
72
+ const target = path.resolve(explicit);
73
+ if (inputs.length === 1) return [target];
74
+ if (path.extname(target).toLowerCase() === '.html') {
75
+ fail('`-o` must be a directory when building more than one input.');
76
+ }
77
+ return inputs.map(input => path.join(target, `${path.basename(input, path.extname(input))}.html`));
78
+ }
79
+
80
+ function printDiagnostics(source, options, { json, label = null, quiet = false }) {
41
81
  const result = validateMdxSource(source, options);
82
+ if (quiet) return result;
42
83
  if (json) {
43
84
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
44
85
  return result;
45
86
  }
87
+ if (label) console.error(`\n${label}`);
46
88
  const { diagnostics, carrier, stats } = result;
47
89
  for (const item of diagnostics) {
48
- const label = item.severity === 'error' ? 'error' : 'warn ';
90
+ const severity = item.severity === 'error' ? 'error' : 'warn ';
49
91
  const where = `${item.line}:${item.column}`;
50
- console.error(`${label} ${where} ${item.code} ${item.message}`);
92
+ console.error(`${severity} ${where} ${item.code} ${item.message}`);
51
93
  }
52
94
  const { error, warning } = countBySeverity(diagnostics);
53
95
  const scope = carrier ? `${carrier} · ${stats.nodes} 节点 / ${stats.relations} 关系` : '未识别载体';
@@ -151,83 +193,179 @@ if (command === 'create' || command === 'new') {
151
193
  process.exit(0);
152
194
  }
153
195
 
154
- const json = args.includes('--json');
155
- const strict = args.includes('--strict');
156
- const skipValidate = args.includes('--no-validate');
196
+ const parsed = parseFlags(args);
197
+ const json = parsed.flags.has('--json');
198
+ const strict = parsed.flags.has('--strict');
199
+ const skipValidate = parsed.flags.has('--no-validate');
200
+ const force = parsed.flags.has('--force');
201
+ const linkAssets = parsed.flags.has('--link-assets');
202
+ const modeFlag = parsed.values.get('--mode') || null;
157
203
 
158
204
  if (command === 'validate') {
159
- const target = args[0] ? path.resolve(args[0]) : null;
205
+ const target = parsed.positional[0] ? path.resolve(parsed.positional[0]) : null;
160
206
  if (!target || path.extname(target).toLowerCase() !== '.mdx' || !(await exists(target))) {
161
207
  fail('Provide an existing .mdx file to validate.');
162
208
  }
163
209
  const source = await readFile(target, 'utf8');
164
- const modeFlag = flagValue(args, ['--mode']);
165
210
  const result = printDiagnostics(source, {
166
211
  filePath: target,
167
- mode: modeFlag || null,
212
+ mode: modeFlag,
168
213
  strict,
169
214
  assetExists: spec => existsSync(path.resolve(path.dirname(target), spec)),
170
215
  }, { json });
171
216
  process.exit(countBySeverity(result.diagnostics).error ? 1 : 0);
172
217
  }
173
218
 
174
- if (args[0] && args[0].toLowerCase() === 'render') args.shift();
175
- const input = args[0] ? path.resolve(args[0]) : null;
176
- const modeFlag = flagValue(args, ['--mode']);
177
- const output = path.resolve(flagValue(args, ['-o', '--output']) || (input ? input.replace(/\.mdx$/i, '.html') : ''));
178
- const force = args.includes('--force');
219
+ // `render` is the default command, so it may still appear as a leading token.
220
+ const positional = parsed.positional[0] && parsed.positional[0].toLowerCase() === 'render'
221
+ ? parsed.positional.slice(1)
222
+ : parsed.positional;
223
+ const inputs = positional.map(entry => path.resolve(entry));
179
224
 
180
- if (!input || path.extname(input).toLowerCase() !== '.mdx' || !(await exists(input))) {
181
- console.error('Provide an existing .mdx input file.');
225
+ if (!inputs.length) {
226
+ console.error('Provide at least one existing .mdx input file.');
182
227
  usage();
183
228
  process.exit(1);
184
229
  }
230
+ for (const input of inputs) {
231
+ if (path.extname(input).toLowerCase() !== '.mdx' || !(await exists(input))) {
232
+ console.error(`Not an existing .mdx input: ${input}`);
233
+ process.exit(1);
234
+ }
235
+ }
185
236
 
186
- const source = await readFile(input, 'utf8');
187
- const validation = printDiagnostics(source, {
188
- filePath: input,
189
- mode: modeFlag || null,
237
+ const outputs = resolveOutputs(inputs, parsed.values.get('-o') || parsed.values.get('--output'));
238
+
239
+ for (const output of outputs) {
240
+ if (await exists(output) && !force) {
241
+ console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
242
+ process.exit(1);
243
+ }
244
+ }
245
+
246
+ const multi = inputs.length > 1;
247
+ const sources = await Promise.all(inputs.map(input => readFile(input, 'utf8')));
248
+
249
+ // Validate every document before building any of them: a batch should fail as a
250
+ // batch rather than leaving half the targets rendered.
251
+ const validations = sources.map((source, index) => printDiagnostics(source, {
252
+ filePath: inputs[index],
253
+ mode: modeFlag,
190
254
  strict,
191
- assetExists: spec => existsSync(path.resolve(path.dirname(input), spec)),
192
- }, { json });
193
- const { error: errorCount } = countBySeverity(validation.diagnostics);
255
+ assetExists: spec => existsSync(path.resolve(path.dirname(inputs[index]), spec)),
256
+ }, json
257
+ ? { json: false, quiet: true }
258
+ : { json: false, label: multi ? inputs[index] : null }));
194
259
 
260
+ if (json) {
261
+ const payload = multi
262
+ ? validations.map((result, index) => ({ file: inputs[index], ...result }))
263
+ : validations[0];
264
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
265
+ }
266
+
267
+ const errorCount = validations.reduce((sum, result) => sum + countBySeverity(result.diagnostics).error, 0);
195
268
  if (errorCount && !skipValidate) {
196
269
  console.error('内容校验未通过,已停止构建。修复后重试,或用 --no-validate 强制构建。');
197
270
  process.exit(1);
198
271
  }
199
272
 
200
- const mode = modeFlag || validation.carrier;
201
- if (!mode || !['atlas', 'scroll'].includes(mode)) {
202
- console.error('Could not detect the MDX carrier; choose --mode atlas or --mode scroll.');
203
- process.exit(1);
204
- }
205
- if (await exists(output) && !force) {
206
- console.error(`Refusing to overwrite ${output}; pass --force to replace it.`);
273
+ const jobs = inputs.map((input, index) => {
274
+ const mode = modeFlag || validations[index].carrier;
275
+ if (!mode || !['atlas', 'scroll'].includes(mode)) {
276
+ console.error(`Could not detect the MDX carrier for ${input}; choose --mode atlas or --mode scroll.`);
277
+ process.exit(1);
278
+ }
279
+ if (linkAssets && path.resolve(path.dirname(outputs[index])) !== path.resolve(path.dirname(input))) {
280
+ console.error(`警告:--link-assets 下 ${outputs[index]} 不在 ${path.dirname(input)} 内,相对图片路径会失效。`);
281
+ }
282
+ return { input, output: outputs[index], mode, features: detectFeatures(sources[index]), linkAssets };
283
+ });
284
+
285
+ const limit = clampConcurrency(parsed.values.get('--concurrency'), jobs.length);
286
+ const started = Date.now();
287
+ const results = await runPool(jobs.map(job => () => buildOne(job)), limit);
288
+
289
+ const failures = results.filter(result => !result.ok);
290
+ const saved = summarizeFeatures(jobs, results);
291
+ console.log(`Built ${results.length - failures.length}/${results.length} page(s) with concurrency ${limit} in ${((Date.now() - started) / 1000).toFixed(1)}s${saved ? ` (${saved})` : ''}.`);
292
+ if (failures.length) {
293
+ for (const failure of failures) console.error(`Build failed for ${failure.input}:`, failure.error);
207
294
  process.exit(1);
208
295
  }
209
- await mkdir(path.dirname(output), { recursive: true });
210
-
211
- const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
212
- const generatedEntry = path.join(path.dirname(output), templateEntry);
213
-
214
- try {
215
- await build({
216
- root: templateRoot,
217
- configFile: path.join(templateRoot, 'vite.config.js'),
218
- resolve: { alias: { '@concept-atlas/content': input } },
219
- build: {
220
- outDir: path.dirname(output),
221
- emptyOutDir: false,
222
- rollupOptions: { input: path.join(templateRoot, templateEntry) },
223
- },
224
- });
225
- if (generatedEntry !== output) {
296
+
297
+ async function buildOne(job) {
298
+ const { input, output, mode, features, linkAssets: link } = job;
299
+ const templateEntry = mode === 'atlas' ? 'index.html' : 'scroll.html';
300
+ // Each build gets its own scratch outDir: the template always writes
301
+ // `index.html`/`scroll.html`, so concurrent builds sharing a directory would
302
+ // overwrite each other before the rename.
303
+ const scratch = path.join(path.dirname(output), `.concept-atlas-${process.pid}-${(buildCounter += 1)}`);
304
+ await mkdir(path.dirname(output), { recursive: true });
305
+ await mkdir(scratch, { recursive: true });
306
+ const define = { __ATLAS_FEATURES__: JSON.stringify(features) };
307
+ if (link) define.__ATLAS_INLINE_ASSETS__ = 'false';
308
+ try {
309
+ await build({
310
+ root: templateRoot,
311
+ configFile: path.join(templateRoot, 'vite.config.js'),
312
+ resolve: { alias: { '@concept-atlas/content': input } },
313
+ define,
314
+ build: {
315
+ outDir: scratch,
316
+ emptyOutDir: false,
317
+ rollupOptions: { input: path.join(templateRoot, templateEntry) },
318
+ },
319
+ });
226
320
  await rm(output, { force: true });
227
- await rename(generatedEntry, output);
321
+ await rename(path.join(scratch, templateEntry), output);
322
+ console.log(`Built ${mode} HTML: ${output}${describeFeatures(features)}${link ? ' [figures linked]' : ''}`);
323
+ return { ok: true, input, output };
324
+ } catch (error) {
325
+ return { ok: false, input, output, error };
326
+ } finally {
327
+ await rm(scratch, { recursive: true, force: true });
228
328
  }
229
- console.log(`Built ${mode} HTML: ${output}`);
230
- } catch (error) {
231
- console.error('Build failed:', error);
232
- process.exit(1);
329
+ }
330
+
331
+ /** Saves the mermaid/KaTeX payload when a document never renders them. */
332
+ function describeFeatures(features) {
333
+ if (features.math && features.mermaid) return '';
334
+ const dropped = [features.math ? null : 'KaTeX', features.mermaid ? null : 'Mermaid'].filter(Boolean);
335
+ return ` [no ${dropped.join('/')}]`;
336
+ }
337
+
338
+ function summarizeFeatures(jobs, results) {
339
+ const built = new Set(results.filter(result => result.ok).map(result => result.input));
340
+ const pages = jobs.filter(job => built.has(job.input));
341
+ if (!pages.length) return '';
342
+ const droppedMath = pages.filter(job => !job.features.math).length;
343
+ const droppedMermaid = pages.filter(job => !job.features.mermaid).length;
344
+ const parts = [];
345
+ if (droppedMath) parts.push(`KaTeX dropped on ${droppedMath}/${pages.length}`);
346
+ if (droppedMermaid) parts.push(`Mermaid dropped on ${droppedMermaid}/${pages.length}`);
347
+ return parts.join(', ');
348
+ }
349
+
350
+ function clampConcurrency(raw, count) {
351
+ const parsedValue = Number.parseInt(raw ?? '', 10);
352
+ const fallback = Math.min(2, count);
353
+ if (!Number.isFinite(parsedValue)) return Math.max(1, fallback);
354
+ return Math.max(1, Math.min(4, parsedValue));
355
+ }
356
+
357
+ /** Runs `tasks` with at most `limit` in flight, preserving result order. */
358
+ async function runPool(tasks, limit) {
359
+ const results = new Array(tasks.length);
360
+ let cursor = 0;
361
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
362
+ for (;;) {
363
+ const index = cursor;
364
+ cursor += 1;
365
+ if (index >= tasks.length) return;
366
+ results[index] = await tasks[index]();
367
+ }
368
+ });
369
+ await Promise.all(workers);
370
+ return results;
233
371
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concept-atlas-dense-explain",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Portable dense-explanation skill and MDX concept atlas template",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -27,12 +27,13 @@ If the user only wants the prompt/methodology and not files, still choose a shel
27
27
  ```
28
28
  Every diagnostic is `CODE line:column message`. Fix all `error`s and re-run; warnings are quality signals you should also address when cheap.
29
29
  6. Compile: `npx concept-atlas-dense-explain <file>.mdx --mode atlas|scroll [-o out.html]`. Output is a standalone HTML beside the MDX unless `-o` is given. Validation errors abort the build; use `--no-validate` only to force a knowingly broken build.
30
- 7. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
30
+ 7. 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. The batch validates everything first and builds in parallel. Builds only bundle the heavy renderers the content uses: a page with no `<Math>`/`<Mermaid>` skips KaTeX (its ~1.4MB inlined fonts) and Mermaid, shrinking a typical scroll article from ~5MB to ~250KB. Do not add dummy `<Math>`/`<Mermaid>` nodes to "enable" them — write the components only when the content needs them. Add `--link-assets` when the page carries many screenshots and size matters.
31
+ 8. Report the shell, output path, validation result (errors/warnings), and limitations. Do not claim interactions you did not verify.
31
32
 
32
33
  ## Carriers
33
34
 
34
35
  - `atlas`: `ExplainPage` → `ConceptGraph` → `ConceptNode`, plus `Children`/`ConceptRef` and cross-branch `Relation`s. Exactly one `L0` root, several `L1` branches, depth to `L3`/`L4`. Shared components live inside nodes.
35
- - `scroll`: `ScrollDocument` → `ScrollHeader` + `ScrollSection` (+ `ScrollProse`, `ScrollGrid`). Shared components live inside sections. The shell auto-builds a table of contents, reading progress, and prev/next from section titles — do not hand-build navigation.
36
+ - `scroll`: `ScrollDocument` → `ScrollHeader` + `ScrollSection` (+ `ScrollProse`, `ScrollGrid`). Shared components live inside sections. The shell auto-builds a a table of contents and reading progress from section titles — do not hand-build navigation.
36
37
  - Never make one MDX file both shells. When switching shells, convert only the outer structure.
37
38
 
38
39
  ## Component families
@@ -51,10 +52,11 @@ If the user only wants the prompt/methodology and not files, still choose a shel
51
52
  - `Relation type` must be one of `prerequisite`, `causes`, `produces`, `uses`, `implements`, `contrasts`, `depends-on`, `exception-of`, `precedes`, and each `Relation` needs a `label`. Parent/child hierarchy is implicit (via `parent` and `Children`/`ConceptRef`) — do not express it with a `Relation`.
52
53
  - **Math**: MDX parses `{ ... }` in children as expressions, so pass LaTeX with braces or backslashes through `formula`: `<Math formula="r_{\text{ann}} = (1 + r)^{12} - 1" />`, `<MathBlock formula="I(x) = -\log_2 p(x)" variables={[{symbol, description}]} />`. Brace-free children such as `<Math>\log_2 N</Math>` are fine. The validator warns (`MATH_CHILDREN_BRACES`).
53
54
  - **Chart**: `type` is `bar` | `line` | `pie`; use `data` for bar/pie and `labels` + `series={[{name, values}]}` for line. Charts follow theme colors.
54
- - **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 produces an `ASSET_MISSING` warning and a placeholder.
55
+ - **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 produces an `ASSET_MISSING` warning and a placeholder. Readers can click any figure to open it full-screen (wheel/`+`/`−` zoom, drag to pan, double-click for 1x/2x, `Esc` to close) — mention this when a page carries dense diagrams.
56
+ - **Figure size**: inlining is what makes a screenshot-heavy page large. When a document carries many images and the user cares about size, compile with `--link-assets` to keep them as relative links (measured: 1.51MB → 270KB on one page). The output then has to live beside the MDX's `assets/` directory, and the CLI warns if `-o` points elsewhere — tell the user that trade-off instead of choosing silently.
55
57
  - **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.
56
58
  - 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.
57
- - Be brief about cost: KaTeX fonts and Mermaid roughly double the single-file output (~5 MB), which is normal for an offline explainer. Mention it if the user cares about file size.
59
+ - Be brief about cost: the build only bundles the heavy optional renderers the document actually uses, so a page with no `<Math>`/`<Mermaid>` comes out around 250KB. Inlined figures are usually the largest remaining cost — a page with a dozen screenshots lands near 1–2MB, which is normal for an offline explainer. Mention it if the user cares about file size.
58
60
 
59
61
  ## Validation diagnostics
60
62
 
@@ -125,7 +125,7 @@
125
125
  </ConceptNode>
126
126
 
127
127
  <ConceptNode id="chart-demo" title="Chart:用同一数据切换三种图形" level="L2" parent="extension-family" summary="bar、line、pie 三种类型共享数据形状,按比较、趋势或占比选择。">
128
- <Definition>Chart 接收 data{label, value} 数组)或 series + labels,在 SVG 中绘制并跟随主题色。</Definition>
128
+ <Definition>Chart 接收 data(`{label, value}` 数组)或 series + labels,在 SVG 中绘制并跟随主题色。</Definition>
129
129
  <Chart title="各阶段耗时" type="bar" unit="小时" data={[{label:'收集',value:6},{label:'分析',value:14},{label:'验证',value:9},{label:'落地',value:4}]} />
130
130
  <Chart title="留存趋势" type="line" labels={['第1周','第2周','第3周','第4周']} series={[{name:'留存率',values:[100,72,58,49]}]} />
131
131
  </ConceptNode>
@@ -1,9 +1,6 @@
1
- ---
2
- title: scroll-guide
3
- ---
4
-
5
1
  <ScrollDocument spacing="comfortable" fontSize="normal">
6
2
  <ScrollHeader title="Concept Atlas 连续阅读指南" label="连续阅读 · 组件参考">同一套语义组件可以脱离图谱和节点面板,按传统文档流连续阅读。本页同时是一份可运行的组件参考:每一节演示一个组件家族,正文保持高信息密度,模型在需要比较、推导或收敛时占满可用宽度。</ScrollHeader>
3
+
7
4
  <ScrollSection title="先识别推理结构">
8
5
  <ScrollProse>模型不是内容的装饰。它们分别处理并列要素、二维定位、变量关系、论证层级和筛选过程。先确认关系形状,读者才能用最短路径验证结论。</ScrollProse>
9
6
  <LearningObjectives items={['按关系形状选择组件', '把结论与证据分开表达', '让每个区块承担不同的认知任务']} />
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import mermaid from 'mermaid';
3
4
  import katex from 'katex';
5
+ import { ZoomIn } from 'lucide-react';
4
6
  import 'katex/dist/katex.min.css';
5
7
  import { registerReferences, subscribeReferences, getReferenceIndex } from '../model/citations.js';
6
8
 
@@ -563,32 +565,16 @@ export function ScrollDocument({ spacing = 'comfortable', fontSize = 'normal', s
563
565
  '--reading-line-height': String(lineHeight ?? preset.lineHeight),
564
566
  };
565
567
 
566
- const activeIndex = items.findIndex(item => item.id === activeId);
567
- const previous = activeIndex > 0 ? items[activeIndex - 1] : null;
568
- const next = activeIndex >= 0 && activeIndex < items.length - 1 ? items[activeIndex + 1] : null;
568
+ const showToc = toc && items.length >= 2;
569
569
 
570
570
  return (
571
- <article className={`continuous-document continuous-spacing-${safeSpacing}`} data-font-size={fontSize} style={style}>
571
+ <article className={`continuous-document continuous-spacing-${safeSpacing}${showToc ? ' has-toc' : ''}`} data-font-size={fontSize} style={style}>
572
572
  {progress && <ReadingProgress />}
573
573
  <ScrollOutlineContext.Provider value={outline}>
574
- {toc && <ScrollToc items={items} activeId={activeId} />}
575
- {children}
576
- {activeIndex >= 0 && (previous || next) && (
577
- <nav className="scroll-pager" aria-label="章节导航">
578
- {previous ? (
579
- <a className="pager-prev" href={`#${previous.id}`}>
580
- <span>上一节</span>
581
- <strong>{previous.title}</strong>
582
- </a>
583
- ) : <span className="pager-spacer" />}
584
- {next ? (
585
- <a className="pager-next" href={`#${next.id}`}>
586
- <span>下一节</span>
587
- <strong>{next.title}</strong>
588
- </a>
589
- ) : <span className="pager-spacer" />}
590
- </nav>
591
- )}
574
+ {showToc && <ScrollToc items={items} activeId={activeId} />}
575
+ <div className="continuous-content">
576
+ {children}
577
+ </div>
592
578
  </ScrollOutlineContext.Provider>
593
579
  </article>
594
580
  );
@@ -768,12 +754,16 @@ NoteGrid.displayName = 'NoteGrid';
768
754
 
769
755
  // Math ----------------------------------------------------------------------
770
756
 
771
- export function Math({ formula, children }) {
757
+ // Named MathInline internally: exporting a function literally called `Math`
758
+ // would shadow the global Math object for the whole module and break Math.max
759
+ // and friends. It is exported as `Math` so MDX can use <Math>.
760
+ function MathInline({ formula, children }) {
772
761
  const tex = (formula || childrenToText(children)).trim();
773
762
  if (!tex) return null;
774
763
  return <span className="semantic-math" dangerouslySetInnerHTML={{ __html: renderTex(tex, false) }} />;
775
764
  }
776
- Math.displayName = 'Math';
765
+ MathInline.displayName = 'Math';
766
+ export { MathInline as Math };
777
767
 
778
768
  export function MathBlock({ title = '公式', formula, variables = [], children }) {
779
769
  const tex = (formula || childrenToText(children)).trim();
@@ -966,18 +956,153 @@ Chart.displayName = 'Chart';
966
956
 
967
957
  // Figures -------------------------------------------------------------------
968
958
 
959
+ const ZOOM_MIN = 0.5;
960
+ const ZOOM_MAX = 6;
961
+
962
+ /**
963
+ * Full-viewport image viewer. Rendered through a portal so it escapes the
964
+ * transformed/rotated ancestor surfaces (`position: fixed` would otherwise be
965
+ * contained by them). Supports wheel zoom, drag to pan, double-click to toggle
966
+ * 1x/2x, and Escape to close.
967
+ */
968
+ function ImageZoom({ src, alt, caption, label, onClose }) {
969
+ const [scale, setScale] = React.useState(1);
970
+ const [offset, setOffset] = React.useState({ x: 0, y: 0 });
971
+ const [dragging, setDragging] = React.useState(false);
972
+ const stageRef = React.useRef(null);
973
+ const closeRef = React.useRef(null);
974
+ const dragRef = React.useRef(null);
975
+ const movedRef = React.useRef(false);
976
+ const scaleRef = React.useRef(scale);
977
+ scaleRef.current = scale;
978
+
979
+ const clampScale = value => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, +value.toFixed(3)));
980
+ const zoomBy = delta => setScale(value => clampScale(value + delta));
981
+ const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
982
+ // Cursor-anchored zoom feels wrong at 1x; recentre whenever we return to it.
983
+ const settle = value => { if (value <= 1) setOffset({ x: 0, y: 0 }); };
984
+
985
+ React.useEffect(() => {
986
+ const stage = stageRef.current;
987
+ if (!stage) return undefined;
988
+ // React binds `wheel` passively, so preventDefault needs a native listener.
989
+ const onWheel = event => {
990
+ event.preventDefault();
991
+ const next = clampScale(scaleRef.current * Math.exp(-event.deltaY * 0.0015));
992
+ setScale(next);
993
+ settle(next);
994
+ };
995
+ stage.addEventListener('wheel', onWheel, { passive: false });
996
+ return () => stage.removeEventListener('wheel', onWheel);
997
+ }, []);
998
+
999
+ React.useEffect(() => {
1000
+ const onKeyDown = event => {
1001
+ if (event.key === 'Escape') { event.stopPropagation(); onClose(); }
1002
+ else if (event.key === '+' || event.key === '=') zoomBy(0.25);
1003
+ else if (event.key === '-' || event.key === '_') zoomBy(-0.25);
1004
+ else if (event.key === '0') reset();
1005
+ };
1006
+ window.addEventListener('keydown', onKeyDown, true);
1007
+ const { overflow } = document.body.style;
1008
+ document.body.style.overflow = 'hidden';
1009
+ closeRef.current?.focus();
1010
+ return () => {
1011
+ window.removeEventListener('keydown', onKeyDown, true);
1012
+ document.body.style.overflow = overflow;
1013
+ };
1014
+ }, [onClose]);
1015
+
1016
+ const onPointerDown = event => {
1017
+ if (event.target.closest('button')) return;
1018
+ dragRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y };
1019
+ movedRef.current = false;
1020
+ setDragging(true);
1021
+ event.currentTarget.setPointerCapture(event.pointerId);
1022
+ };
1023
+
1024
+ const onPointerMove = event => {
1025
+ const drag = dragRef.current;
1026
+ if (!drag || drag.id !== event.pointerId) return;
1027
+ const dx = event.clientX - drag.x;
1028
+ const dy = event.clientY - drag.y;
1029
+ if (!movedRef.current && Math.hypot(dx, dy) < 6) return;
1030
+ movedRef.current = true;
1031
+ setOffset({ x: drag.ox + dx, y: drag.oy + dy });
1032
+ };
1033
+
1034
+ const onPointerUp = event => {
1035
+ if (dragRef.current?.id === event.pointerId) {
1036
+ dragRef.current = null;
1037
+ setDragging(false);
1038
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
1039
+ }
1040
+ };
1041
+
1042
+ const title = alt || caption || '图片';
1043
+
1044
+ return createPortal(
1045
+ <div
1046
+ className="image-zoom-overlay"
1047
+ role="dialog"
1048
+ aria-modal="true"
1049
+ aria-label={`放大查看:${title}`}
1050
+ onClick={event => { if (event.target === event.currentTarget && !movedRef.current) onClose(); }}
1051
+ >
1052
+ <div
1053
+ ref={stageRef}
1054
+ className={`image-zoom-stage${dragging ? ' is-dragging' : ''}`}
1055
+ onPointerDown={onPointerDown}
1056
+ onPointerMove={onPointerMove}
1057
+ onPointerUp={onPointerUp}
1058
+ onPointerCancel={onPointerUp}
1059
+ onDoubleClick={() => { const next = scaleRef.current > 1 ? 1 : 2; setScale(next); settle(next); }}
1060
+ onClick={event => { if (movedRef.current) { event.stopPropagation(); movedRef.current = false; } }}
1061
+ >
1062
+ <img
1063
+ src={src}
1064
+ alt={alt || ''}
1065
+ draggable="false"
1066
+ style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }}
1067
+ />
1068
+ </div>
1069
+ {title && <p className="image-zoom-caption">{label && <b>{label}</b>}{caption || alt}</p>}
1070
+ <div className="image-zoom-toolbar">
1071
+ <button type="button" onClick={() => zoomBy(-0.25)} aria-label="缩小" title="缩小(−)">−</button>
1072
+ <span aria-live="polite">{Math.round(scale * 100)}%</span>
1073
+ <button type="button" onClick={() => zoomBy(0.25)} aria-label="放大" title="放大(+)">+</button>
1074
+ <button type="button" onClick={reset} aria-label="重置缩放" title="重置(0)">↺</button>
1075
+ <button type="button" ref={closeRef} onClick={onClose} aria-label="关闭" title="关闭(Esc)">✕</button>
1076
+ </div>
1077
+ </div>,
1078
+ document.body,
1079
+ );
1080
+ }
1081
+
969
1082
  export function Figure({ src, alt = '', caption, label, width = 'auto', height = 'auto', x = 0, y = 0, position = 'flow' }) {
1083
+ const [zoomed, setZoomed] = React.useState(false);
1084
+ const title = alt || caption || '图片';
1085
+
970
1086
  return (
971
1087
  <figure className={`semantic-figure ${widgetClass(position)}`} style={widgetStyle({ width, height, x, y, position })}>
972
- {src
973
- ? <img src={src} alt={alt} loading="lazy" />
974
- : <div className="figure-placeholder">缺少图片 src</div>}
1088
+ {src ? (
1089
+ <button
1090
+ type="button"
1091
+ className="figure-zoom-trigger"
1092
+ onClick={() => setZoomed(true)}
1093
+ aria-label={`放大查看:${title}`}
1094
+ >
1095
+ <img src={src} alt={alt} loading="lazy" />
1096
+ <span className="figure-zoom-hint" aria-hidden="true"><ZoomIn size={12} />点击放大</span>
1097
+ </button>
1098
+ ) : <div className="figure-placeholder">缺少图片 src</div>}
975
1099
  {(caption || label) && (
976
1100
  <figcaption>
977
1101
  {label && <span className="figure-label">{label}</span>}
978
1102
  {caption}
979
1103
  </figcaption>
980
1104
  )}
1105
+ {zoomed && <ImageZoom src={src} alt={alt} caption={caption} label={label} onClose={() => setZoomed(false)} />}
981
1106
  </figure>
982
1107
  );
983
1108
  }
@@ -286,6 +286,23 @@ function tokenize(source) {
286
286
  return tags;
287
287
  }
288
288
 
289
+ /** Blank out tag ranges, preserving offsets and newlines, so only text remains. */
290
+ function textRegions(source, tags) {
291
+ const ranges = tags
292
+ .map(tag => [tag.start, tag.end])
293
+ .sort((a, b) => a[0] - b[0]);
294
+ const pieces = [];
295
+ let cursor = 0;
296
+ for (const [start, end] of ranges) {
297
+ if (start < cursor) continue;
298
+ pieces.push(source.slice(cursor, start));
299
+ pieces.push(source.slice(start, end).replace(/[^\n]/g, ' '));
300
+ cursor = end;
301
+ }
302
+ pieces.push(source.slice(cursor));
303
+ return pieces.join('');
304
+ }
305
+
289
306
  function attrsToMap(attrs) {
290
307
  const map = {};
291
308
  for (const attr of attrs) map[attr.name] = attr;
@@ -421,6 +438,24 @@ export function validateMdxSource(source, options = {}) {
421
438
  }
422
439
  }
423
440
 
441
+ // MDX evaluates `{ ... }` in prose as a JavaScript expression. Authors often
442
+ // write data shapes like `{label, value}` in a sentence, which crashes the
443
+ // page with "label is not defined".
444
+ const text = textRegions(masked, tags);
445
+ for (const match of text.matchAll(/\{([^{}]*)\}/g)) {
446
+ const expression = match[1].trim();
447
+ if (!expression || expression.startsWith('/*')) continue;
448
+ if (/^[A-Za-z_$][\w$]*\s*(,[\s\S]*)?$/.test(expression)) {
449
+ add('warning', 'PROSE_EXPRESSION', `正文中的 {${expression}} 会被 MDX 当作表达式并导致运行时报错;请改成行内代码 \`{${expression}}\``, match.index, expression);
450
+ }
451
+ }
452
+
453
+ // MDX has no frontmatter support by default: a leading `---` block renders as
454
+ // a stray rule and text at the top of the page.
455
+ if (/^---\r?\n[\s\S]*?\r?\n---(\r?\n|$)/.test(source)) {
456
+ add('warning', 'FRONTMATTER_UNSUPPORTED', '文件以 --- 开头,但 MDX 不解析 frontmatter,它会被渲染成正文;请删除', 0, null);
457
+ }
458
+
424
459
  const carrier = usedComponents.has('ScrollDocument') ? 'scroll'
425
460
  : (usedComponents.has('ExplainPage') || usedComponents.has('ConceptGraph')) ? 'atlas'
426
461
  : null;
@@ -541,3 +576,25 @@ export function countBySeverity(diagnostics) {
541
576
  return acc;
542
577
  }, { error: 0, warning: 0 });
543
578
  }
579
+
580
+ /**
581
+ * Optional renderers a document actually instantiates.
582
+ *
583
+ * Mermaid and KaTeX (plus the ~1.4 MB of woff2 fonts its stylesheet inlines) are
584
+ * heavy enough that bundling them into a page which never renders a diagram or a
585
+ * formula dominates both build time and output size. The build reads these flags
586
+ * and swaps unused renderers for stubs.
587
+ *
588
+ * Detection runs on the masked source, so `<Math>` inside a fenced block or an
589
+ * inline code span does not count as usage.
590
+ */
591
+ export function detectFeatures(source) {
592
+ const used = new Set();
593
+ for (const tag of tokenize(maskIgnored(source))) {
594
+ if (tag.kind !== 'close') used.add(tag.name);
595
+ }
596
+ return {
597
+ math: used.has('Math') || used.has('MathBlock'),
598
+ mermaid: used.has('Mermaid'),
599
+ };
600
+ }
@@ -25,8 +25,8 @@
25
25
  /* Typography Colors */
26
26
  --text-primary: #f8fafc;
27
27
  --text-secondary: #cbd5e1;
28
- --text-muted: #94a3b8;
29
- --text-dim: #64748b;
28
+ --text-muted: #a7b5c7;
29
+ --text-dim: #8494a9;
30
30
  --text-accent: #38bdf8;
31
31
 
32
32
  /* Accent & Category Semantic Tones */
@@ -93,9 +93,9 @@
93
93
 
94
94
  /* Typography Colors */
95
95
  --text-primary: #0f172a;
96
- --text-secondary: #334155;
97
- --text-muted: #64748b;
98
- --text-dim: #94a3b8;
96
+ --text-secondary: #2b3a4f;
97
+ --text-muted: #4a5a70;
98
+ --text-dim: #5f6f83;
99
99
  --text-accent: #2563eb;
100
100
 
101
101
  /* Accent & Category Semantic Tones */
@@ -187,14 +187,14 @@ button:focus-visible, a:focus-visible, [role="button"]:focus-visible {
187
187
  outline-offset: 3px;
188
188
  }
189
189
 
190
- .global-search { position: relative; display: flex; align-items: center; gap: 7px; min-width: 220px; margin-left: auto; color: var(--text-muted, #94a3b8); }
191
- .global-search input { width: 100%; border: 1px solid var(--line-subtle, rgba(148,163,184,.24)); border-radius: 8px; background: var(--surface-soft, rgba(15,23,42,.3)); color: inherit; padding: 7px 28px 7px 28px; }
190
+ .global-search { position: relative; display: flex; align-items: center; gap: 7px; min-width: 220px; margin-left: auto; color: var(--text-muted); }
191
+ .global-search input { width: 100%; border: 1px solid var(--border-medium); border-radius: 8px; background: var(--bg-canvas); color: inherit; padding: 7px 28px 7px 28px; }
192
192
  .global-search > svg { position: absolute; left: 9px; pointer-events: none; }
193
193
  .global-search > button { position: absolute; right: 5px; border: 0; background: transparent; color: inherit; padding: 3px; }
194
- .global-search-results { position: absolute; z-index: 20; top: calc(100% + 6px); left: 0; right: 0; padding: 5px; border: 1px solid var(--line-subtle, rgba(148,163,184,.24)); border-radius: 10px; background: var(--surface-strong, #111827); box-shadow: 0 12px 30px rgba(0,0,0,.24); }
195
- .global-search-results button { display: grid; gap: 2px; width: 100%; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary, #e2e8f0); text-align: left; padding: 8px; }
196
- .global-search-results button:hover { background: rgba(148,163,184,.12); }
197
- .global-search-results small { color: var(--text-muted, #94a3b8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
194
+ .global-search-results { position: absolute; z-index: 20; top: calc(100% + 6px); left: 0; right: 0; padding: 5px; border: 1px solid var(--border-medium); border-radius: 10px; background: var(--bg-surface); box-shadow: var(--shadow-lg); }
195
+ .global-search-results button { display: grid; gap: 2px; width: 100%; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary); text-align: left; padding: 8px; }
196
+ .global-search-results button:hover { background: var(--bg-subtle); }
197
+ .global-search-results small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
198
198
 
199
199
  /* Optional semantic layout primitives */
200
200
  .semantic-stack { display: flex; flex-direction: column; }
@@ -1884,7 +1884,7 @@ button.flow-box {
1884
1884
 
1885
1885
  .inline-inspector-grid {
1886
1886
  display: grid;
1887
- grid-template-columns: repeat(4, minmax(180px, 1fr));
1887
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1888
1888
  gap: 14px;
1889
1889
  }
1890
1890
 
@@ -2339,31 +2339,31 @@ button.flow-box {
2339
2339
  .semantic-evidence,
2340
2340
  .semantic-invariant,
2341
2341
  .semantic-failure-mode {
2342
- border: 1px solid var(--line-subtle, rgba(148, 163, 184, .24));
2342
+ border: 1px solid var(--border-subtle);
2343
2343
  border-radius: 12px;
2344
2344
  padding: 12px 14px;
2345
- background: var(--surface-soft, rgba(15, 23, 42, .28));
2345
+ background: var(--bg-surface);
2346
2346
  }
2347
2347
 
2348
2348
  .semantic-learning-objectives ul { margin: 8px 0 0; padding-left: 20px; }
2349
2349
  .semantic-learning-objectives li { margin: 4px 0; }
2350
2350
  .semantic-key-question { display: flex; align-items: baseline; gap: 10px; border-color: rgba(56, 189, 248, .35); }
2351
- .semantic-key-question strong { color: var(--text-primary, #e2e8f0); }
2351
+ .semantic-key-question strong { color: var(--text-primary); }
2352
2352
  .semantic-evidence { display: grid; gap: 7px; border-color: rgba(52, 211, 153, .3); }
2353
- .evidence-command { overflow-x: auto; color: #a7f3d0; }
2354
- .evidence-observes { color: var(--text-secondary, #cbd5e1); }
2353
+ .evidence-command { overflow-x: auto; color: var(--text-primary); }
2354
+ .evidence-observes { color: var(--text-secondary); }
2355
2355
  .semantic-invariant { border-color: rgba(129, 140, 248, .35); }
2356
2356
  .semantic-invariant > div:last-child { margin-top: 6px; }
2357
2357
  .semantic-failure-mode { border-color: rgba(251, 146, 60, .38); }
2358
2358
  .semantic-failure-mode dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; margin: 8px 0 0; }
2359
- .semantic-failure-mode dt { color: var(--text-muted, #94a3b8); font-size: .8rem; }
2360
- .semantic-failure-mode dd { margin: 0; }
2359
+ .semantic-failure-mode dt { color: var(--text-primary); font-size: .82rem; font-weight: 700; }
2360
+ .semantic-failure-mode dd { margin: 0; color: var(--text-secondary); }
2361
2361
  .failure-details { margin-top: 10px; }
2362
- .semantic-tradeoff { border: 1px solid rgba(250, 204, 21, .3); border-radius: 12px; padding: 12px 14px; background: var(--surface-soft, rgba(15,23,42,.24)); }
2362
+ .semantic-tradeoff { border: 1px solid rgba(250, 204, 21, .3); border-radius: 12px; padding: 12px 14px; background: var(--bg-surface); }
2363
2363
  .tradeoff-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 8px; }
2364
- .tradeoff-option { display: grid; gap: 4px; padding: 9px; border-radius: 8px; background: rgba(148,163,184,.08); }
2365
- .tradeoff-option span { color: var(--text-secondary, #cbd5e1); font-size: .86rem; }
2366
- .tradeoff-option b { color: var(--text-muted, #94a3b8); font-size: .72rem; margin-right: 5px; }
2364
+ .tradeoff-option { display: grid; gap: 4px; padding: 9px; border-radius: 8px; background: var(--bg-subtle); }
2365
+ .tradeoff-option span { color: var(--text-secondary); font-size: .86rem; }
2366
+ .tradeoff-option b { color: var(--text-primary); font-size: .72rem; margin-right: 5px; }
2367
2367
 
2368
2368
  .semantic-callout {
2369
2369
  padding: 16px 20px;
@@ -2906,7 +2906,7 @@ button.flow-box {
2906
2906
 
2907
2907
  .semantic-callout.has-callout-title {
2908
2908
  display: grid;
2909
- grid-template-columns: minmax(8rem, 11rem) minmax(0, 1fr);
2909
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
2910
2910
  gap: 16px;
2911
2911
  align-items: start;
2912
2912
  }
@@ -3176,11 +3176,11 @@ html[data-carrier='scroll'] #root {
3176
3176
  justify-content: space-between;
3177
3177
  }
3178
3178
 
3179
- .continuous-document > .semantic-framework-model,
3180
- .continuous-document > .semantic-model-matrix,
3181
- .continuous-document > .semantic-model-formula,
3182
- .continuous-document > .semantic-model-pyramid,
3183
- .continuous-document > .semantic-model-funnel,
3179
+ .continuous-content > .semantic-framework-model,
3180
+ .continuous-content > .semantic-model-matrix,
3181
+ .continuous-content > .semantic-model-formula,
3182
+ .continuous-content > .semantic-model-pyramid,
3183
+ .continuous-content > .semantic-model-funnel,
3184
3184
  .continuous-section > .semantic-framework-model,
3185
3185
  .continuous-section > .semantic-model-matrix,
3186
3186
  .continuous-section > .semantic-model-formula,
@@ -3237,6 +3237,13 @@ html[data-carrier='scroll'] #root {
3237
3237
  width: 100%;
3238
3238
  }
3239
3239
 
3240
+ /* The drill-down entrance is the primary navigation affordance; give it the
3241
+ full board width so its child cards can form a proper grid instead of being
3242
+ stacked inside one third of a row. */
3243
+ .draft-board .drill-down-section {
3244
+ grid-column: 1 / -1;
3245
+ }
3246
+
3240
3247
  @media (max-width: 1120px) {
3241
3248
  .continuous-model-pair { grid-template-columns: 1fr; }
3242
3249
  .continuous-model-pair .semantic-model-pyramid,
@@ -3339,29 +3346,44 @@ html[data-carrier='scroll'] #root {
3339
3346
  transition: transform 80ms linear;
3340
3347
  }
3341
3348
 
3342
- /* ---- Table of contents ---- */
3349
+ /* ---- Table of contents (sticky sidebar) ---- */
3350
+
3351
+ .continuous-content {
3352
+ min-width: 0;
3353
+ }
3343
3354
 
3344
3355
  .scroll-toc {
3345
3356
  display: none;
3346
3357
  }
3347
3358
 
3348
- @media (min-width: 1400px) {
3349
- .scroll-toc {
3350
- position: fixed;
3351
- top: 104px;
3352
- left: max(16px, calc(50% - 560px - 220px));
3353
- z-index: 30;
3359
+ @media (min-width: 1080px) {
3360
+ .continuous-document.has-toc {
3361
+ display: grid;
3362
+ grid-template-columns: 208px minmax(0, 1fr);
3363
+ column-gap: 44px;
3364
+ align-items: start;
3365
+ }
3366
+
3367
+ .continuous-document.has-toc > .scroll-toc {
3368
+ position: sticky;
3369
+ top: 24px;
3354
3370
  display: block;
3355
- width: 190px;
3356
- max-height: calc(100vh - 160px);
3357
- overflow: auto;
3358
- padding-right: 10px;
3359
- border-right: 1px solid var(--border-subtle);
3371
+ grid-column: 1;
3372
+ align-self: start;
3373
+ max-height: calc(100vh - 48px);
3374
+ overflow-y: auto;
3375
+ padding-right: 6px;
3376
+ }
3377
+
3378
+ .continuous-document.has-toc > .continuous-content {
3379
+ grid-column: 2;
3360
3380
  }
3361
3381
  }
3362
3382
 
3363
3383
  .scroll-toc-title {
3364
- margin-bottom: 10px;
3384
+ margin: 0 0 10px;
3385
+ padding-bottom: 8px;
3386
+ border-bottom: 1px solid var(--border-subtle);
3365
3387
  color: var(--text-dim);
3366
3388
  font-size: 11px;
3367
3389
  font-weight: 700;
@@ -3373,90 +3395,54 @@ html[data-carrier='scroll'] #root {
3373
3395
  margin: 0;
3374
3396
  padding: 0;
3375
3397
  list-style: none;
3398
+ border-left: 1px solid var(--border-subtle);
3376
3399
  counter-reset: toc;
3377
3400
  }
3378
3401
 
3379
3402
  .scroll-toc-list li {
3380
- margin: 0 0 2px;
3381
3403
  counter-increment: toc;
3382
3404
  }
3383
3405
 
3384
3406
  .scroll-toc-list a {
3385
- display: block;
3386
- padding: 5px 8px 5px 22px;
3407
+ display: grid;
3408
+ grid-template-columns: 24px minmax(0, 1fr);
3409
+ gap: 6px;
3410
+ align-items: baseline;
3411
+ margin-left: -1px;
3412
+ padding: 6px 8px 6px 0;
3387
3413
  border-left: 2px solid transparent;
3388
- border-radius: var(--radius-1);
3389
3414
  color: var(--text-muted);
3390
3415
  font-size: 12.5px;
3391
- line-height: 1.4;
3416
+ line-height: 1.45;
3392
3417
  text-decoration: none;
3393
- position: relative;
3394
- transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
3418
+ transition: color 0.15s ease, border-color 0.15s ease;
3395
3419
  }
3396
3420
 
3397
3421
  .scroll-toc-list a::before {
3398
3422
  content: counter(toc, decimal-leading-zero);
3399
- position: absolute;
3400
- left: 4px;
3401
3423
  color: var(--text-dim);
3402
3424
  font-size: 10px;
3403
3425
  font-variant-numeric: tabular-nums;
3426
+ text-align: center;
3427
+ transition: color 0.15s ease;
3404
3428
  }
3405
3429
 
3406
3430
  .scroll-toc-list a:hover {
3407
3431
  color: var(--text-primary);
3408
- background: var(--bg-highlight);
3409
- }
3410
-
3411
- .scroll-toc-list li.active a {
3412
- border-left-color: var(--primary);
3413
- color: var(--text-primary);
3414
- font-weight: 600;
3415
- }
3416
-
3417
- /* ---- Section pager ---- */
3418
-
3419
- .scroll-pager {
3420
- display: grid;
3421
- grid-template-columns: 1fr 1fr;
3422
- gap: 12px;
3423
- margin-top: 8px;
3424
- padding-top: 16px;
3425
- border-top: 1px solid var(--border-subtle);
3426
3432
  }
3427
3433
 
3428
- .scroll-pager a {
3429
- display: flex;
3430
- flex-direction: column;
3431
- gap: 4px;
3432
- padding: 10px 12px;
3433
- border: 1px solid var(--border-medium);
3434
- border-radius: var(--radius-2);
3435
- background: var(--bg-surface);
3436
- text-decoration: none;
3437
- transition: border-color 0.15s ease, background 0.15s ease;
3438
- }
3439
-
3440
- .scroll-pager a:hover {
3441
- border-color: var(--border-strong);
3442
- background: var(--bg-card-hover);
3443
- }
3444
-
3445
- .scroll-pager a span {
3446
- color: var(--text-dim);
3447
- font-size: 11px;
3448
- font-weight: 600;
3434
+ .scroll-toc-list a:hover::before {
3435
+ color: var(--text-secondary);
3449
3436
  }
3450
3437
 
3451
- .scroll-pager a strong {
3438
+ .scroll-toc-list li.active a {
3439
+ border-left-color: var(--primary);
3452
3440
  color: var(--text-primary);
3453
- font-size: 13px;
3454
3441
  font-weight: 600;
3455
- line-height: 1.4;
3456
3442
  }
3457
3443
 
3458
- .scroll-pager .pager-next {
3459
- text-align: right;
3444
+ .scroll-toc-list li.active a::before {
3445
+ color: var(--primary-light);
3460
3446
  }
3461
3447
 
3462
3448
  /* ===========================================================================
@@ -3573,6 +3559,156 @@ html[data-carrier='scroll'] #root {
3573
3559
  background: var(--bg-subtle);
3574
3560
  }
3575
3561
 
3562
+ /* Click-to-zoom trigger. The wrapper is a button so the affordance is reachable
3563
+ by keyboard, but it must read as the image itself rather than as chrome. */
3564
+ .figure-zoom-trigger {
3565
+ position: relative;
3566
+ display: block;
3567
+ width: 100%;
3568
+ padding: 0;
3569
+ border: 0;
3570
+ border-radius: var(--radius-2);
3571
+ background: none;
3572
+ line-height: 0;
3573
+ cursor: zoom-in;
3574
+ }
3575
+
3576
+ .figure-zoom-trigger:focus-visible {
3577
+ outline: 2px solid var(--accent-cyan);
3578
+ outline-offset: 2px;
3579
+ }
3580
+
3581
+ .figure-zoom-hint {
3582
+ position: absolute;
3583
+ right: 8px;
3584
+ bottom: 8px;
3585
+ display: inline-flex;
3586
+ align-items: center;
3587
+ gap: 4px;
3588
+ padding: 3px 8px;
3589
+ border: 1px solid var(--border-medium);
3590
+ border-radius: var(--radius-2);
3591
+ background: color-mix(in srgb, var(--bg-surface) 88%, transparent);
3592
+ color: var(--text-secondary);
3593
+ font-size: 11px;
3594
+ line-height: 1.5;
3595
+ opacity: 0.7;
3596
+ transition: opacity 0.15s ease, color 0.15s ease;
3597
+ }
3598
+
3599
+ .figure-zoom-trigger:hover .figure-zoom-hint,
3600
+ .figure-zoom-trigger:focus-visible .figure-zoom-hint {
3601
+ color: var(--text-primary);
3602
+ opacity: 1;
3603
+ }
3604
+
3605
+ /* ---- Image zoom overlay ---- */
3606
+
3607
+ .image-zoom-overlay {
3608
+ position: fixed;
3609
+ inset: 0;
3610
+ z-index: 1000;
3611
+ display: grid;
3612
+ place-items: center;
3613
+ padding: 28px;
3614
+ background: color-mix(in srgb, #05080f 84%, transparent);
3615
+ }
3616
+
3617
+ .image-zoom-stage {
3618
+ display: grid;
3619
+ place-items: center;
3620
+ max-width: 100%;
3621
+ max-height: 100%;
3622
+ overflow: hidden;
3623
+ cursor: grab;
3624
+ touch-action: none;
3625
+ }
3626
+
3627
+ .image-zoom-stage.is-dragging { cursor: grabbing; }
3628
+
3629
+ .image-zoom-stage img {
3630
+ display: block;
3631
+ width: auto;
3632
+ height: auto;
3633
+ max-width: min(94vw, 1800px);
3634
+ max-height: 84vh;
3635
+ border-radius: var(--radius-2);
3636
+ background: var(--bg-surface);
3637
+ box-shadow: 0 24px 60px -22px rgba(0, 0, 0, 0.75);
3638
+ transition: transform 0.12s ease-out;
3639
+ will-change: transform;
3640
+ user-select: none;
3641
+ }
3642
+
3643
+ .image-zoom-stage.is-dragging img { transition: none; }
3644
+
3645
+ .image-zoom-caption {
3646
+ position: absolute;
3647
+ top: 18px;
3648
+ left: 50%;
3649
+ max-width: min(90vw, 900px);
3650
+ transform: translateX(-50%);
3651
+ color: var(--text-secondary);
3652
+ font-size: 12.5px;
3653
+ line-height: 1.6;
3654
+ text-align: center;
3655
+ }
3656
+
3657
+ .image-zoom-caption b {
3658
+ margin-right: 8px;
3659
+ color: var(--text-accent);
3660
+ }
3661
+
3662
+ .image-zoom-toolbar {
3663
+ position: absolute;
3664
+ bottom: 22px;
3665
+ left: 50%;
3666
+ display: flex;
3667
+ align-items: center;
3668
+ gap: 4px;
3669
+ padding: 5px;
3670
+ transform: translateX(-50%);
3671
+ border: 1px solid var(--border-medium);
3672
+ border-radius: var(--radius-3);
3673
+ background: var(--bg-surface);
3674
+ box-shadow: var(--shadow-pop);
3675
+ color: var(--text-secondary);
3676
+ }
3677
+
3678
+ .image-zoom-toolbar button {
3679
+ display: grid;
3680
+ place-items: center;
3681
+ width: 30px;
3682
+ height: 30px;
3683
+ border-radius: var(--radius-1);
3684
+ color: inherit;
3685
+ font-size: 14px;
3686
+ }
3687
+
3688
+ .image-zoom-toolbar button:hover {
3689
+ background: var(--bg-card-hover);
3690
+ color: var(--text-primary);
3691
+ }
3692
+
3693
+ .image-zoom-toolbar span {
3694
+ min-width: 46px;
3695
+ text-align: center;
3696
+ font-size: 12px;
3697
+ font-weight: 700;
3698
+ font-variant-numeric: tabular-nums;
3699
+ }
3700
+
3701
+ @media (prefers-reduced-motion: reduce) {
3702
+ .image-zoom-stage img { transition: none; }
3703
+ }
3704
+
3705
+ @media (max-width: 760px) {
3706
+ .image-zoom-overlay { padding: 12px; }
3707
+ .image-zoom-stage img { max-height: 74vh; }
3708
+ .image-zoom-caption { top: 10px; font-size: 12px; }
3709
+ .image-zoom-hint { opacity: 0.9; }
3710
+ }
3711
+
3576
3712
  .semantic-figure figcaption {
3577
3713
  margin-top: 10px;
3578
3714
  color: var(--text-secondary);
@@ -22,13 +22,21 @@ const MIME_TYPES = {
22
22
  * into base64 data URIs at build time. Keeps the single-file HTML self-contained
23
23
  * and offline-openable without a runtime asset loader. Remote (http/data) and
24
24
  * absolute paths are left untouched.
25
+ *
26
+ * `--link-assets` (the `__ATLAS_INLINE_ASSETS__` define) turns this off so figures
27
+ * stay relative links. That keeps the output small at the cost of the page no
28
+ * longer being self-contained: the HTML must sit beside the MDX's `assets/` dir.
25
29
  */
26
30
  function inlineMdxAssets() {
31
+ const state = { enabled: true };
27
32
  return {
28
33
  name: 'concept-atlas-inline-assets',
29
34
  enforce: 'pre',
35
+ configResolved(config) {
36
+ state.enabled = (config.define || {}).__ATLAS_INLINE_ASSETS__ !== 'false';
37
+ },
30
38
  transform(code, id) {
31
- if (!id.endsWith('.mdx')) return null;
39
+ if (!state.enabled || !id.endsWith('.mdx')) return null;
32
40
  const dir = path.dirname(id.split('?')[0]);
33
41
  let changed = false;
34
42
  const output = code.replace(/(<[A-Za-z][\w.]*\b[^>]*?\bsrc=)(["'])([^"']+)\2/g, (match, prefix, quote, src) => {
@@ -44,6 +52,73 @@ function inlineMdxAssets() {
44
52
  };
45
53
  }
46
54
 
55
+ /**
56
+ * Swaps optional renderers for stubs when the document never uses them.
57
+ *
58
+ * Mermaid and KaTeX are the two dependencies whose transform cost and payload
59
+ * dominate a build: KaTeX alone contributes ~60 woff2 files that
60
+ * vite-plugin-singlefile base64-inlines into every page (~1.4 MB), and Mermaid
61
+ * is a large share of the JS bundle. A page with no <Mermaid> or <Math> should
62
+ * not pay for either.
63
+ *
64
+ * The CLI computes `detectFeatures(source)` and passes the result as the
65
+ * `__ATLAS_FEATURES__` define; without it (e.g. the repository's own build) every
66
+ * feature stays enabled and behaviour is unchanged.
67
+ */
68
+ const OPTIONAL_FEATURES = {
69
+ mermaid: { feature: 'mermaid', stub: 'mermaid' },
70
+ katex: { feature: 'math', stub: 'katex' },
71
+ 'katex/dist/katex.min.css': { feature: 'math', stub: 'katex-css' },
72
+ };
73
+
74
+ const FEATURE_STUBS = {
75
+ mermaid: `const mermaid = {
76
+ initialize() {},
77
+ render() {
78
+ throw new Error('Mermaid is not bundled in this build: the document has no <Mermaid>.');
79
+ },
80
+ };
81
+ export default mermaid;
82
+ `,
83
+ katex: `const katex = {
84
+ renderToString() {
85
+ throw new Error('KaTeX is not bundled in this build: the document has no <Math>.');
86
+ },
87
+ };
88
+ export default katex;
89
+ `,
90
+ // The stylesheet stub is intentionally empty: dropping it is what removes the
91
+ // base64-inlined KaTeX woff2 payload from the page.
92
+ 'katex-css': '',
93
+ };
94
+
95
+ function optionalFeatures() {
96
+ let enabled = { math: true, mermaid: true };
97
+ return {
98
+ name: 'concept-atlas-optional-features',
99
+ enforce: 'pre',
100
+ configResolved(config) {
101
+ const raw = config.define && config.define.__ATLAS_FEATURES__;
102
+ if (typeof raw !== 'string') return;
103
+ try {
104
+ enabled = { math: true, mermaid: true, ...JSON.parse(raw) };
105
+ } catch {
106
+ enabled = { math: true, mermaid: true };
107
+ }
108
+ },
109
+ resolveId(source) {
110
+ const entry = OPTIONAL_FEATURES[source];
111
+ if (!entry || enabled[entry.feature] !== false) return null;
112
+ return `\0atlas-stub:${entry.stub}`;
113
+ },
114
+ load(id) {
115
+ if (!id.startsWith('\0atlas-stub:')) return null;
116
+ const stub = id.slice('\0atlas-stub:'.length);
117
+ return Object.hasOwn(FEATURE_STUBS, stub) ? FEATURE_STUBS[stub] : null;
118
+ },
119
+ };
120
+ }
121
+
47
122
  export default defineConfig({
48
123
  plugins: [
49
124
  inlineMdxAssets(),
@@ -54,6 +129,7 @@ export default defineConfig({
54
129
  }),
55
130
  },
56
131
  react(),
132
+ optionalFeatures(),
57
133
  viteSingleFile(),
58
134
  ],
59
135
  build: {