concept-atlas-dense-explain 0.4.2 → 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.2",
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,7 +27,8 @@ 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
 
@@ -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
 
@@ -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
 
@@ -954,18 +956,153 @@ Chart.displayName = 'Chart';
954
956
 
955
957
  // Figures -------------------------------------------------------------------
956
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
+
957
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
+
958
1086
  return (
959
1087
  <figure className={`semantic-figure ${widgetClass(position)}`} style={widgetStyle({ width, height, x, y, position })}>
960
- {src
961
- ? <img src={src} alt={alt} loading="lazy" />
962
- : <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>}
963
1099
  {(caption || label) && (
964
1100
  <figcaption>
965
1101
  {label && <span className="figure-label">{label}</span>}
966
1102
  {caption}
967
1103
  </figcaption>
968
1104
  )}
1105
+ {zoomed && <ImageZoom src={src} alt={alt} caption={caption} label={label} onClose={() => setZoomed(false)} />}
969
1106
  </figure>
970
1107
  );
971
1108
  }
@@ -576,3 +576,25 @@ export function countBySeverity(diagnostics) {
576
576
  return acc;
577
577
  }, { error: 0, warning: 0 });
578
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
  }
@@ -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,
@@ -3552,6 +3559,156 @@ html[data-carrier='scroll'] #root {
3552
3559
  background: var(--bg-subtle);
3553
3560
  }
3554
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
+
3555
3712
  .semantic-figure figcaption {
3556
3713
  margin-top: 10px;
3557
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: {