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
|
|
16
|
-
console.log(' npx concept-atlas-dense-explain render <input.mdx
|
|
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
|
-
|
|
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
|
|
90
|
+
const severity = item.severity === 'error' ? 'error' : 'warn ';
|
|
49
91
|
const where = `${item.line}:${item.column}`;
|
|
50
|
-
console.error(`${
|
|
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
|
|
155
|
-
const
|
|
156
|
-
const
|
|
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 =
|
|
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
|
|
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
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
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 (!
|
|
181
|
-
console.error('Provide
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
192
|
-
},
|
|
193
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
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.
|
|
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:
|
|
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
|
-
|
|
962
|
-
|
|
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: #
|
|
29
|
-
--text-dim: #
|
|
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: #
|
|
97
|
-
--text-muted: #
|
|
98
|
-
--text-dim: #
|
|
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
|
|
191
|
-
.global-search input { width: 100%; border: 1px solid var(--
|
|
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(--
|
|
195
|
-
.global-search-results button { display: grid; gap: 2px; width: 100%; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary
|
|
196
|
-
.global-search-results button:hover { background:
|
|
197
|
-
.global-search-results small { color: var(--text-muted
|
|
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(
|
|
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(--
|
|
2342
|
+
border: 1px solid var(--border-subtle);
|
|
2343
2343
|
border-radius: 12px;
|
|
2344
2344
|
padding: 12px 14px;
|
|
2345
|
-
background: var(--surface
|
|
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
|
|
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:
|
|
2354
|
-
.evidence-observes { color: var(--text-secondary
|
|
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-
|
|
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
|
|
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:
|
|
2365
|
-
.tradeoff-option span { color: var(--text-secondary
|
|
2366
|
-
.tradeoff-option b { color: var(--text-
|
|
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(
|
|
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);
|
package/template/vite.config.js
CHANGED
|
@@ -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: {
|