mviz 1.7.0-pre.1 → 1.7.0-pre.3

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/dist/cli.js CHANGED
@@ -110,8 +110,7 @@ async function main() {
110
110
  console.log(' --lint, -l Validate input without generating output');
111
111
  console.log(' --theme, -t <file> Load custom theme from YAML file');
112
112
  console.log(' --allow-errors Continue generating output even with lint errors');
113
- console.log(' --embed Strip page chrome (red bar, title, theme toggle) for embedding');
114
- console.log(' --fragment Emit an HTML fragment (no DOCTYPE/html/head/body); implies --embed');
113
+ console.log(' --embed Strip page chrome + run embed post-processes for iframe-embedded output');
115
114
  console.log(' --help, -h Show this help message');
116
115
  console.log('');
117
116
  console.log('Examples:');
@@ -125,16 +124,13 @@ async function main() {
125
124
  // Parse flags
126
125
  const lintOnly = args.includes('--lint') || args.includes('-l');
127
126
  const allowErrors = args.includes('--allow-errors');
128
- const fragmentFlag = args.includes('--fragment');
129
- // Fragment mode implies embed — no chrome inside a fragment.
130
- const embedFlag = fragmentFlag || args.includes('--embed');
127
+ const embedFlag = args.includes('--embed');
131
128
  const { themeFile, remainingArgs: afterTheme } = parseThemeFlag(args);
132
129
  const { outputFile, remainingArgs } = parseOutputFlag(afterTheme);
133
130
  const filteredArgs = remainingArgs.filter((a) => a !== '--lint' &&
134
131
  a !== '-l' &&
135
132
  a !== '--allow-errors' &&
136
- a !== '--embed' &&
137
- a !== '--fragment');
133
+ a !== '--embed');
138
134
  // Load custom theme if specified
139
135
  let customTheme;
140
136
  if (themeFile) {
@@ -177,8 +173,7 @@ async function main() {
177
173
  console.error(' --lint, -l Validate input without generating output');
178
174
  console.error(' --theme, -t <file> Load custom theme from YAML file');
179
175
  console.error(' --allow-errors Continue generating output even with lint errors');
180
- console.error(' --embed Strip page chrome (red bar, title, theme toggle) for embedding');
181
- console.error(' --fragment Emit an HTML fragment (no DOCTYPE/html/head/body); implies --embed');
176
+ console.error(' --embed Strip page chrome + run embed post-processes for iframe-embedded output');
182
177
  console.error(' --help, -h Show this help message');
183
178
  process.exit(1);
184
179
  }
@@ -193,11 +188,11 @@ async function main() {
193
188
  // JSON input - lint then generate
194
189
  const spec = JSON.parse(trimmed);
195
190
  lintSpec(spec, lintMode);
196
- html = await generateChartAsync(spec, { fragment: fragmentFlag });
191
+ html = await generateChartAsync(spec);
197
192
  }
198
193
  else if (trimmed.startsWith('---') || trimmed.includes('```')) {
199
194
  // Markdown input - parser handles linting internally (async for mermaid)
200
- const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode, embedFlag, fragmentFlag);
195
+ const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode, embedFlag);
201
196
  html = result.html;
202
197
  errors = result.errors;
203
198
  }
@@ -205,7 +200,7 @@ async function main() {
205
200
  // Try JSON anyway - lint then generate
206
201
  const spec = JSON.parse(trimmed);
207
202
  lintSpec(spec, lintMode);
208
- html = await generateChartAsync(spec, { fragment: fragmentFlag });
203
+ html = await generateChartAsync(spec);
209
204
  }
210
205
  // Check for errors (unless --allow-errors is set)
211
206
  if (errors.length > 0 && !allowErrors) {
@@ -222,9 +217,11 @@ async function main() {
222
217
  }
223
218
  }
224
219
  else if (outputFile) {
225
- // Write to file and print success message
220
+ // Write to file and print success message. Goes to stderr so the
221
+ // message doesn't corrupt captured output when callers pipe through
222
+ // `-o /dev/stdout` for inline-rendering workflows (issue #247, #275).
226
223
  writeFileSync(resolve(outputFile), html);
227
- console.log(`✓ Created ${outputFile}`);
224
+ console.error(`✓ Created ${outputFile}`);
228
225
  }
229
226
  else {
230
227
  // No -o flag: require it unless piping
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Embed-mode post-processing (issue #272 / #247).
3
+ *
4
+ * `--embed` produces a complete HTML document but strips the page chrome
5
+ * (red accent bar, title row, theme toggle) so the output can be tucked
6
+ * inside another product's iframe (mdw-turbo Prism tiles, dashboard cells,
7
+ * etc.). This module is the second half of that work: a set of
8
+ * post-processes that run on the assembled HTML to make the embedded
9
+ * output as lean and host-friendly as possible.
10
+ *
11
+ * What it does:
12
+ * - Lift runtime style-injection IIFEs (table init creates a `<style>`
13
+ * element at runtime — extract its body into the inline `<style>` so
14
+ * pruning sees it and there's no FOUC).
15
+ * - Prune unused CSS rules + minify the inline `<style>` block.
16
+ * - Drop the ECharts CDN script tag entirely when no `echarts.init`
17
+ * survives — table-only / KPI-only dashboards don't need ECharts and
18
+ * the cold bundle is ~1 MB. Embeds that do use charts keep the full
19
+ * bundle (no variant downsizing — feature parity with the standalone
20
+ * report matters more than bundle size for iframe-embed consumers).
21
+ * - Drop the `marked.min.js` CDN script tag when no `marked.parse(...)`
22
+ * call survives. textarea / note / text components emit inline scripts
23
+ * that call `marked.parse`, so the script must stay whenever any of
24
+ * those components is rendered.
25
+ * - Drop the sparkline-tooltip listener (`.spark-hit` handler) when no
26
+ * `.spark-hit` element survives. Tables with sparkline columns still
27
+ * emit these, so we can't blanket-strip in embed mode.
28
+ * - Minify inline `<script>` whitespace (string/regex-literal-safe).
29
+ * - Collapse element whitespace between top-level tags.
30
+ *
31
+ * What it deliberately does NOT do:
32
+ * - Strip `<!DOCTYPE>` / `<html>` / `<head>` / `<body>` wrappers. Embed
33
+ * mode emits a complete HTML document — embedding hosts like iframe
34
+ * `srcdoc` need that to render. (claude.ai's `visualize:show_widget`
35
+ * would require fragmentation, but the show_widget integration goes
36
+ * through a different path — see the skill's chat-native rendering
37
+ * section, not this code.)
38
+ */
39
+ /**
40
+ * Apply embed-mode post-processing to an assembled HTML document.
41
+ *
42
+ * Idempotent for the most part — successive calls are safe.
43
+ */
44
+ export declare function postProcessEmbed(html: string): string;
45
+ //# sourceMappingURL=embed-postprocess.d.ts.map
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Embed-mode post-processing (issue #272 / #247).
3
+ *
4
+ * `--embed` produces a complete HTML document but strips the page chrome
5
+ * (red accent bar, title row, theme toggle) so the output can be tucked
6
+ * inside another product's iframe (mdw-turbo Prism tiles, dashboard cells,
7
+ * etc.). This module is the second half of that work: a set of
8
+ * post-processes that run on the assembled HTML to make the embedded
9
+ * output as lean and host-friendly as possible.
10
+ *
11
+ * What it does:
12
+ * - Lift runtime style-injection IIFEs (table init creates a `<style>`
13
+ * element at runtime — extract its body into the inline `<style>` so
14
+ * pruning sees it and there's no FOUC).
15
+ * - Prune unused CSS rules + minify the inline `<style>` block.
16
+ * - Drop the ECharts CDN script tag entirely when no `echarts.init`
17
+ * survives — table-only / KPI-only dashboards don't need ECharts and
18
+ * the cold bundle is ~1 MB. Embeds that do use charts keep the full
19
+ * bundle (no variant downsizing — feature parity with the standalone
20
+ * report matters more than bundle size for iframe-embed consumers).
21
+ * - Drop the `marked.min.js` CDN script tag when no `marked.parse(...)`
22
+ * call survives. textarea / note / text components emit inline scripts
23
+ * that call `marked.parse`, so the script must stay whenever any of
24
+ * those components is rendered.
25
+ * - Drop the sparkline-tooltip listener (`.spark-hit` handler) when no
26
+ * `.spark-hit` element survives. Tables with sparkline columns still
27
+ * emit these, so we can't blanket-strip in embed mode.
28
+ * - Minify inline `<script>` whitespace (string/regex-literal-safe).
29
+ * - Collapse element whitespace between top-level tags.
30
+ *
31
+ * What it deliberately does NOT do:
32
+ * - Strip `<!DOCTYPE>` / `<html>` / `<head>` / `<body>` wrappers. Embed
33
+ * mode emits a complete HTML document — embedding hosts like iframe
34
+ * `srcdoc` need that to render. (claude.ai's `visualize:show_widget`
35
+ * would require fragmentation, but the show_widget integration goes
36
+ * through a different path — see the skill's chat-native rendering
37
+ * section, not this code.)
38
+ */
39
+ import { randomBytes } from 'node:crypto';
40
+ /**
41
+ * Generate a random nonce for protect-and-restore placeholders. Used to
42
+ * keep the markers we substitute in (during template-literal protection,
43
+ * block protection, etc.) from colliding with user-authored content that
44
+ * might happen to contain a hand-typed `__MVIZ_PROTECT_…__` string.
45
+ *
46
+ * 16 hex chars = 64 random bits. Collision probability with arbitrary
47
+ * user text is vanishingly small.
48
+ */
49
+ function randomNonce() {
50
+ return randomBytes(8).toString('hex');
51
+ }
52
+ /**
53
+ * Apply embed-mode post-processing to an assembled HTML document.
54
+ *
55
+ * Idempotent for the most part — successive calls are safe.
56
+ */
57
+ export function postProcessEmbed(html) {
58
+ let out = html;
59
+ // (Note: the PNG export menu — `.mviz-export-menu` CSS + the export
60
+ // IIFE in core/export.ts — never reaches this pass. `templates.ts`
61
+ // omits both when embed=true, before this module runs. We rely on
62
+ // that gate rather than carrying a defensive strip here.)
63
+ // 1. Lift any runtime style-injection IIFEs the table init code uses
64
+ // (`var s = document.createElement('style'); s.textContent = "…"; head.appendChild(s);`).
65
+ // Avoids a tool-call's worth of script execution latency and saves the
66
+ // IIFE wrapping bytes. The extracted CSS is merged into the main
67
+ // <style> block so the CSS pruner (step 2) still sees it.
68
+ out = inlineRuntimeStyleInjection(out);
69
+ // 2. Prune unused CSS rules + minify whitespace. The dashboard CSS carries
70
+ // rules for every component type mviz supports — markdown, sparkline,
71
+ // table, alert, etc. A typical embed uses two or three of those, so
72
+ // most rules are dead weight. We scan the body HTML for the class names
73
+ // that actually appear, then drop CSS rules whose selectors only
74
+ // reference classes that aren't present.
75
+ out = pruneAndMinifyStyle(out);
76
+ // 3. Drop the ECharts CDN <script> when no `echarts.init` survives the
77
+ // earlier transforms. Table-only / KPI-only dashboards don't need
78
+ // ECharts and the bundle is ~1 MB cold — meaningful win for those
79
+ // cases. We deliberately do NOT pick a smaller bundle variant
80
+ // (`simple.min.js` / `common.min.js`) — the chart-type → bundle map
81
+ // is brittle, and embed consumers usually want feature parity with
82
+ // the standalone HTML report.
83
+ out = dropEchartsScriptIfUnused(out);
84
+ // 3b. Drop the marked CDN <script> when no `marked.parse(...)` call
85
+ // survives. textarea / note / text components emit inline scripts
86
+ // that call marked.parse, so the script tag has to stay whenever
87
+ // those components are present. Dashboards without them (charts +
88
+ // KPIs + tables only) can drop the ~50 KB CDN fetch.
89
+ out = dropMarkedScriptIfUnused(out);
90
+ // 4. Drop the sparkline tooltip JS when no `.spark-hit` elements survive.
91
+ // Tables with sparkline columns DO emit `.spark-hit` rectangles with
92
+ // `data-idx` / `data-val` and need the hover handler to fire — so we
93
+ // can't blanket-strip in embed mode (same pattern as marked / ECharts).
94
+ out = dropSparklineTooltipIfUnused(out);
95
+ // (Note: an earlier draft also folded dead-boolean ternaries
96
+ // `true ? "$" : ''` → `"$"` and collapsed `{show:false, …}` sibling
97
+ // config. Both were post-process regex passes that couldn't reliably
98
+ // distinguish mviz-emitted JS code from user-authored strings inside
99
+ // chart data — e.g. a bar label `literal true ? 'yes' : 'no' text`
100
+ // would get folded into `literal 'yes' text`. The byte win was small
101
+ // (~200 B/chart) and the correctness risk was real, so both passes
102
+ // were removed. The right home for these optimizations is the codegen
103
+ // sites in core/formatting.ts and friends — out of scope for embed.)
104
+ // 5. Minify inline scripts. mviz emits chart init code with indentation
105
+ // + blank lines for human readability; embed hosts don't need that,
106
+ // and every byte costs latency (especially when a language model is
107
+ // re-emitting the captured output verbatim).
108
+ out = minifyInlineScripts(out);
109
+ // 6. Collapse trivial whitespace between top-level elements (line breaks
110
+ // + leading indentation outside of <script>/<style> blocks).
111
+ out = collapseElementWhitespace(out);
112
+ return out.trim();
113
+ }
114
+ /**
115
+ * Lift any `createElement('style')` + `head.appendChild` IIFEs into the
116
+ * main inline <style> block. mviz emits the table sortable styles via a
117
+ * runtime injection IIFE; in fragment mode we can hoist them so the styles
118
+ * are present at parse time (no FOUC) and the IIFE bytes vanish.
119
+ *
120
+ * Pattern matched (after JS minification):
121
+ * (function(){var s=document.createElement('style');s.textContent="…";document.head.appendChild(s);})();
122
+ */
123
+ function inlineRuntimeStyleInjection(html) {
124
+ // Find the first <style>…</style> block we can merge into. If none, bail.
125
+ const styleMatch = /<style>([\s\S]*?)<\/style>/i.exec(html);
126
+ if (!styleMatch)
127
+ return html;
128
+ const iifeRe = /\(function\(\)\{\s*var\s+\w+\s*=\s*document\.createElement\(\s*['"]style['"]\s*\)\s*;\s*\w+\.textContent\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*;\s*document\.head\.appendChild\(\s*\w+\s*\)\s*;?\s*\}\)\(\);?/g;
129
+ const extracted = [];
130
+ const stripped = html.replace(iifeRe, (_m, literal) => {
131
+ // Unescape the JS string literal into raw CSS text.
132
+ const css = jsLiteralToString(literal);
133
+ extracted.push(css);
134
+ return '';
135
+ });
136
+ if (extracted.length === 0)
137
+ return html;
138
+ const mergedCss = styleMatch[1] + '\n' + extracted.join('\n');
139
+ return stripped.replace(/<style>[\s\S]*?<\/style>/i, `<style>${mergedCss}</style>`);
140
+ }
141
+ /**
142
+ * Convert a JS string literal (with surrounding quotes + escape sequences)
143
+ * back to its raw value. Handles \n, \t, \r, \\, \', \", \uXXXX, octal-free.
144
+ */
145
+ function jsLiteralToString(literal) {
146
+ // Strip the surrounding quote.
147
+ const quote = literal[0];
148
+ if (quote !== '"' && quote !== "'")
149
+ return literal;
150
+ const body = literal.slice(1, -1);
151
+ let out = '';
152
+ for (let i = 0; i < body.length; i++) {
153
+ const c = body[i];
154
+ if (c !== '\\') {
155
+ out += c;
156
+ continue;
157
+ }
158
+ const next = body[i + 1] ?? '';
159
+ if (next === 'n') {
160
+ out += '\n';
161
+ i++;
162
+ }
163
+ else if (next === 't') {
164
+ out += '\t';
165
+ i++;
166
+ }
167
+ else if (next === 'r') {
168
+ out += '\r';
169
+ i++;
170
+ }
171
+ else if (next === '\\') {
172
+ out += '\\';
173
+ i++;
174
+ }
175
+ else if (next === "'") {
176
+ out += "'";
177
+ i++;
178
+ }
179
+ else if (next === '"') {
180
+ out += '"';
181
+ i++;
182
+ }
183
+ else if (next === 'u' && body.length >= i + 6) {
184
+ const code = parseInt(body.slice(i + 2, i + 6), 16);
185
+ if (!isNaN(code)) {
186
+ out += String.fromCharCode(code);
187
+ i += 5;
188
+ }
189
+ else {
190
+ out += next;
191
+ i++;
192
+ }
193
+ }
194
+ else {
195
+ out += next;
196
+ i++;
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ /**
202
+ * Drop the ECharts CDN `<script src>` tag when no `echarts.init` call
203
+ * survives the earlier transforms — i.e. table-only / KPI-only dashboards
204
+ * that don't actually need ECharts. Embeds that DO use charts keep the
205
+ * full `echarts.min.js` bundle (no variant downsizing — embed consumers
206
+ * usually want feature parity with the standalone report).
207
+ */
208
+ function dropEchartsScriptIfUnused(html) {
209
+ if (/\becharts\.init\b/.test(html))
210
+ return html;
211
+ return html.replace(/<script src="[^"]*echarts[^"]*"><\/script>\s*/gi, '');
212
+ }
213
+ /**
214
+ * Drop the `marked.min.js` CDN `<script>` tag when no `marked.parse(...)`
215
+ * caller survives. textarea / note / text components emit inline scripts
216
+ * that call `marked.parse`, so we cannot blanket-strip the script in embed
217
+ * mode — that would leave the inline scripts throwing `ReferenceError`.
218
+ */
219
+ function dropMarkedScriptIfUnused(html) {
220
+ if (/\bmarked\.parse\s*\(/.test(html))
221
+ return html;
222
+ return html.replace(/<script src="[^"]*marked[^"]*"><\/script>\s*/gi, '');
223
+ }
224
+ /**
225
+ * Drop the sparkline tooltip JS (`.spark-hit` `mouseenter`/`mouseleave`
226
+ * handlers) when no `.spark-hit` element survives in the body. Tables
227
+ * with sparkline columns DO emit `.spark-hit` rectangles and need this
228
+ * script to react to hovers — so this can't be blanket-stripped in
229
+ * embed mode. Same shape as the marked / ECharts script drops.
230
+ */
231
+ function dropSparklineTooltipIfUnused(html) {
232
+ // The listener script itself references `spark-hit` as a querySelector
233
+ // argument, so a plain substring check would always match. Mask out
234
+ // <script> bodies first and check for `spark-hit` references in the
235
+ // remaining markup (table cells emit it as a class).
236
+ const withoutScripts = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
237
+ if (/\bspark-hit\b/.test(withoutScripts))
238
+ return html;
239
+ // The listener is bracketed by distinctive comment markers emitted by
240
+ // `generateDashboardHtml` (see templates.ts). Markers avoid the problem
241
+ // of matching nested `});` inside the arrow-function body with a
242
+ // non-balanced regex.
243
+ return html.replace(/\/\/\s*---\s*mviz sparkline tooltip start\s*---[\s\S]*?\/\/\s*---\s*mviz sparkline tooltip end\s*---\s*/g, '');
244
+ }
245
+ /**
246
+ * Prune unused CSS rules from the inline `<style>` block, then minify the
247
+ * remaining CSS by collapsing whitespace.
248
+ *
249
+ * Heuristic — for each top-level rule (including those inside `@media`):
250
+ * - Extract the class names referenced in the selector list.
251
+ * - If at least one is present in the body HTML (or in a known
252
+ * always-keep list like `:root`, `html`, `body`, `*`, `@media`), keep
253
+ * the rule. Otherwise drop it.
254
+ *
255
+ * Then collapse runs of whitespace to single spaces, and strip whitespace
256
+ * adjacent to CSS punctuation (`{`, `}`, `;`, `:`, `,`).
257
+ */
258
+ function pruneAndMinifyStyle(html) {
259
+ const styleRe = /<style>([\s\S]*?)<\/style>/i;
260
+ const m = styleRe.exec(html);
261
+ if (!m)
262
+ return html;
263
+ const css = m[1] ?? '';
264
+ // Body content for class-presence checks: everything between </style> and
265
+ // the trailing scripts. We include the whole post-style remainder so chart
266
+ // `<div id="…">` class hints on grid-item rows are caught.
267
+ const body = html.slice(m.index + m[0].length);
268
+ const classesInBody = collectClassNames(body);
269
+ const pruned = pruneCssRules(css, classesInBody);
270
+ const minified = minifyCss(pruned);
271
+ return html.slice(0, m.index) + `<style>${minified}</style>` + body;
272
+ }
273
+ /** Collect all `class="…"` tokens from an HTML body. */
274
+ function collectClassNames(body) {
275
+ const out = new Set();
276
+ const classAttrRe = /\bclass\s*=\s*"([^"]*)"/g;
277
+ let cm;
278
+ while ((cm = classAttrRe.exec(body)) !== null) {
279
+ for (const cls of (cm[1] ?? '').split(/\s+/)) {
280
+ if (cls)
281
+ out.add(cls);
282
+ }
283
+ }
284
+ return out;
285
+ }
286
+ /** Always-keep selector prefixes/keywords that have no class to test. */
287
+ const KEEP_PREFIXES = [':root', '*', 'html', 'body', '@media', '@keyframes', '@font-face', '@supports', '@import'];
288
+ function pruneCssRules(css, classes) {
289
+ // Split CSS into top-level rules: a selector list up to a `{`, then a
290
+ // balanced body up to the matching `}`. Handle @media by recursively
291
+ // pruning its inner rules with the same class set.
292
+ const out = [];
293
+ let i = 0;
294
+ while (i < css.length) {
295
+ // Skip whitespace.
296
+ while (i < css.length && /\s/.test(css[i] ?? ''))
297
+ i++;
298
+ if (i >= css.length)
299
+ break;
300
+ // Comment.
301
+ if (css.startsWith('/*', i)) {
302
+ const end = css.indexOf('*/', i + 2);
303
+ i = end === -1 ? css.length : end + 2;
304
+ continue;
305
+ }
306
+ // Find the next `{` (selector end) or `;` (at-rule like @import).
307
+ const braceIdx = css.indexOf('{', i);
308
+ const semiIdx = css.indexOf(';', i);
309
+ if (braceIdx === -1 || (semiIdx !== -1 && semiIdx < braceIdx)) {
310
+ // Standalone at-rule like `@import url(…);` — keep it.
311
+ const ruleEnd = semiIdx === -1 ? css.length : semiIdx + 1;
312
+ out.push(css.slice(i, ruleEnd));
313
+ i = ruleEnd;
314
+ continue;
315
+ }
316
+ const selector = css.slice(i, braceIdx).trim();
317
+ // Find balanced closing brace.
318
+ let depth = 1;
319
+ let j = braceIdx + 1;
320
+ while (j < css.length && depth > 0) {
321
+ const ch = css[j];
322
+ if (ch === '{')
323
+ depth++;
324
+ else if (ch === '}')
325
+ depth--;
326
+ if (depth > 0)
327
+ j++;
328
+ }
329
+ const body = css.slice(braceIdx + 1, j);
330
+ i = j + 1;
331
+ if (selector.startsWith('@media') || selector.startsWith('@supports')) {
332
+ // Recurse: prune inner rules with the same class set.
333
+ const innerPruned = pruneCssRules(body, classes).trim();
334
+ if (innerPruned)
335
+ out.push(`${selector} { ${innerPruned} }`);
336
+ continue;
337
+ }
338
+ if (selector.startsWith('@')) {
339
+ // Other at-rules (@keyframes, @font-face): keep as-is.
340
+ out.push(`${selector} {${body}}`);
341
+ continue;
342
+ }
343
+ if (selectorMatchesAnyClass(selector, classes)) {
344
+ out.push(`${selector} {${body}}`);
345
+ }
346
+ // else: drop the rule.
347
+ }
348
+ return out.join('\n');
349
+ }
350
+ /**
351
+ * Decide whether a selector list should be kept given the set of classes
352
+ * actually present in the body. Selectors with no class reference (e.g. `*`,
353
+ * `html, body`, `:root`) are always kept. A selector list is kept if at
354
+ * least one comma-separated selector references a present class, or if any
355
+ * selector has no class reference at all (it targets tags / ids / globals).
356
+ */
357
+ function selectorMatchesAnyClass(selectorList, classes) {
358
+ const selectors = selectorList.split(',').map((s) => s.trim());
359
+ for (const sel of selectors) {
360
+ if (KEEP_PREFIXES.some((p) => sel.startsWith(p)))
361
+ return true;
362
+ const classRefs = [...sel.matchAll(/\.([A-Za-z_][\w-]*)/g)].map((m) => m[1] ?? '');
363
+ if (classRefs.length === 0) {
364
+ // No class reference — targets a tag or id selector. Keep.
365
+ return true;
366
+ }
367
+ // Keep only if every required class in this selector is present.
368
+ if (classRefs.every((c) => classes.has(c)))
369
+ return true;
370
+ }
371
+ return false;
372
+ }
373
+ function minifyCss(css) {
374
+ return css
375
+ .replace(/\/\*[\s\S]*?\*\//g, '') // comments
376
+ .replace(/\s+/g, ' ') // collapse whitespace
377
+ .replace(/\s*([{}:;,>])\s*/g, '$1') // strip spaces around punctuation
378
+ .replace(/;}/g, '}') // drop trailing semicolons
379
+ .trim();
380
+ }
381
+ /**
382
+ * Apply conservative whitespace minification to every `<script>` block
383
+ * (skipping `<script src=…>` which has no body). String literals and regex
384
+ * literals are preserved verbatim; everything else is folded into a single
385
+ * stream with line comments stripped and run-of-whitespace collapsed.
386
+ */
387
+ function minifyInlineScripts(html) {
388
+ return html.replace(/<script>([\s\S]*?)<\/script>/gi, (_match, body) => {
389
+ return `<script>${minifyJs(body)}</script>`;
390
+ });
391
+ }
392
+ /**
393
+ * Conservative JS whitespace minifier. Walks the source character-by-character
394
+ * so it can leave string and regex literals untouched. Strips:
395
+ * - `// line comments` (to end of line)
396
+ * - `/* block comments * /`
397
+ * - leading/trailing whitespace
398
+ * - runs of whitespace (collapsed to single space, or removed entirely when
399
+ * adjacent to non-identifier punctuation)
400
+ *
401
+ * Does NOT do symbol renaming or dead-code elimination — purely whitespace.
402
+ */
403
+ function minifyJs(src) {
404
+ const out = [];
405
+ const n = src.length;
406
+ let i = 0;
407
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
408
+ const isIdent = (c) => /[A-Za-z0-9_$]/.test(c);
409
+ while (i < n) {
410
+ const c = src[i] ?? '';
411
+ const c1 = src[i + 1] ?? '';
412
+ // Line comment.
413
+ if (c === '/' && c1 === '/') {
414
+ while (i < n && src[i] !== '\n')
415
+ i++;
416
+ continue;
417
+ }
418
+ // Block comment.
419
+ if (c === '/' && c1 === '*') {
420
+ const end = src.indexOf('*/', i + 2);
421
+ i = end === -1 ? n : end + 2;
422
+ continue;
423
+ }
424
+ // String literal — copy verbatim, honoring escapes.
425
+ if (c === '"' || c === "'" || c === '`') {
426
+ const quote = c;
427
+ out.push(quote);
428
+ i++;
429
+ while (i < n) {
430
+ const ch = src[i] ?? '';
431
+ out.push(ch);
432
+ if (ch === '\\') {
433
+ // Copy the escaped char (if any) and continue.
434
+ if (i + 1 < n) {
435
+ out.push(src[i + 1] ?? '');
436
+ i += 2;
437
+ continue;
438
+ }
439
+ }
440
+ i++;
441
+ if (ch === quote)
442
+ break;
443
+ }
444
+ continue;
445
+ }
446
+ // Whitespace — collapse to a single space, but drop entirely when both
447
+ // neighbours are non-identifier punctuation (`,`, `;`, `{`, `}`, etc.).
448
+ if (isWs(c)) {
449
+ let j = i;
450
+ while (j < n && isWs(src[j] ?? ''))
451
+ j++;
452
+ const prev = out.length > 0 ? (out[out.length - 1] ?? '') : '';
453
+ const next = src[j] ?? '';
454
+ if (prev && next && (isIdent(prev) || prev === ')' || prev === ']') && (isIdent(next) || next === '/')) {
455
+ // Need a separator to avoid token merging (e.g. `var x` → `varx`).
456
+ out.push(' ');
457
+ }
458
+ i = j;
459
+ continue;
460
+ }
461
+ out.push(c);
462
+ i++;
463
+ }
464
+ return out.join('').trim();
465
+ }
466
+ /**
467
+ * Collapse the line breaks + leading indentation that mviz uses between
468
+ * top-level elements in its templates. Conservative — only strips runs of
469
+ * `\n` + spaces that sit *between* tags. Leaves whitespace inside `<script>`,
470
+ * `<style>`, `<pre>`, and `<textarea>` alone.
471
+ */
472
+ function collapseElementWhitespace(html) {
473
+ // Protect <pre>, <textarea>, <script>, <style> blocks so we don't touch
474
+ // their contents. Sub them out, collapse, then put back. The placeholder
475
+ // uses a printable marker keyed by a per-call random nonce — so user
476
+ // content that happens to contain a literal `__MVIZ_PROTECT_…__` string
477
+ // can't accidentally get replaced by the restore pass. (The plain-text
478
+ // form also keeps the source file from being misclassified as binary by
479
+ // tools like rg/file — an earlier draft used literal NUL bytes.)
480
+ const protectedBlocks = [];
481
+ const nonce = randomNonce();
482
+ const protectRe = /<(script|style|pre|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi;
483
+ const placeholder = (idx) => `__MVIZ_PROTECT_${nonce}_${idx}__`;
484
+ const masked = html.replace(protectRe, (m) => {
485
+ protectedBlocks.push(m);
486
+ return placeholder(protectedBlocks.length - 1);
487
+ });
488
+ const collapsed = masked.replace(/>\s+</g, '><').trim();
489
+ const restoreRe = new RegExp(`__MVIZ_PROTECT_${nonce}_(\\d+)__`, 'g');
490
+ return collapsed.replace(restoreRe, (_m, n) => {
491
+ return protectedBlocks[parseInt(n, 10)] ?? '';
492
+ });
493
+ }
494
+ //# sourceMappingURL=embed-postprocess.js.map
@@ -4,11 +4,6 @@
4
4
  * Routes chart/component specs to the appropriate generator.
5
5
  */
6
6
  import type { ChartSpec, ComponentSpec } from '../types.js';
7
- export interface GenerateOptions {
8
- /** Emit a self-contained HTML fragment (no DOCTYPE/html/head/body) suitable
9
- * for embedding inside hosts like claude.ai's `visualize:show_widget`. */
10
- fragment?: boolean;
11
- }
12
7
  import '../charts/index.js';
13
8
  import '../components/index.js';
14
9
  /**
@@ -21,11 +16,11 @@ export declare function isAsyncChartType(type: string): boolean;
21
16
  * Note: This will throw an error for async chart types (like mermaid).
22
17
  * Use generateChartAsync for those.
23
18
  */
24
- export declare function generateChart(spec: ChartSpec | ComponentSpec, options?: GenerateOptions): string;
19
+ export declare function generateChart(spec: ChartSpec | ComponentSpec): string;
25
20
  /**
26
21
  * Generate HTML output for a chart or component spec (async version)
27
22
  *
28
23
  * This handles both sync and async chart types.
29
24
  */
30
- export declare function generateChartAsync(spec: ChartSpec | ComponentSpec, options?: GenerateOptions): Promise<string>;
25
+ export declare function generateChartAsync(spec: ChartSpec | ComponentSpec): Promise<string>;
31
26
  //# sourceMappingURL=dispatcher.d.ts.map
@@ -7,7 +7,6 @@ import { CHART_REGISTRY, ASYNC_CHART_REGISTRY } from '../charts/registry.js';
7
7
  import { COMPONENT_REGISTRY } from '../components/registry.js';
8
8
  import { convertColumnarFormat } from './converter.js';
9
9
  import { UnknownComponentType } from '../core/exceptions.js';
10
- import { toFragment } from '../core/fragment.js';
11
10
  // Import chart and component modules to trigger registration
12
11
  import '../charts/index.js';
13
12
  import '../components/index.js';
@@ -37,7 +36,7 @@ export function isAsyncChartType(type) {
37
36
  * Note: This will throw an error for async chart types (like mermaid).
38
37
  * Use generateChartAsync for those.
39
38
  */
40
- export function generateChart(spec, options = {}) {
39
+ export function generateChart(spec) {
41
40
  // Convert columnar format if needed
42
41
  const converted = convertColumnarFormat(spec);
43
42
  const type = converted.type;
@@ -45,7 +44,7 @@ export function generateChart(spec, options = {}) {
45
44
  if (ASYNC_CHART_REGISTRY.has(type)) {
46
45
  throw new Error(`Chart type '${type}' requires async rendering. Use generateChartAsync instead.`);
47
46
  }
48
- return finalize(dispatchSync(type, converted), options);
47
+ return dispatchSync(type, converted);
49
48
  }
50
49
  function dispatchSync(type, converted) {
51
50
  // Try chart registry first
@@ -62,24 +61,21 @@ function dispatchSync(type, converted) {
62
61
  const suggestions = getSimilarTypes(type);
63
62
  throw new UnknownComponentType(type, suggestions);
64
63
  }
65
- function finalize(html, options) {
66
- return options.fragment ? toFragment(html) : html;
67
- }
68
64
  /**
69
65
  * Generate HTML output for a chart or component spec (async version)
70
66
  *
71
67
  * This handles both sync and async chart types.
72
68
  */
73
- export async function generateChartAsync(spec, options = {}) {
69
+ export async function generateChartAsync(spec) {
74
70
  // Convert columnar format if needed
75
71
  const converted = convertColumnarFormat(spec);
76
72
  const type = converted.type;
77
73
  // Try async chart registry first
78
74
  const asyncChartGenerator = ASYNC_CHART_REGISTRY.get(type);
79
75
  if (asyncChartGenerator) {
80
- return finalize(await asyncChartGenerator(converted), options);
76
+ return asyncChartGenerator(converted);
81
77
  }
82
78
  // Fall back to sync paths
83
- return finalize(dispatchSync(type, converted), options);
79
+ return dispatchSync(type, converted);
84
80
  }
85
81
  //# sourceMappingURL=dispatcher.js.map
@@ -35,7 +35,6 @@ function createDefaultFrontmatter(lines, baseTheme) {
35
35
  orientation: 'portrait',
36
36
  printMode: false,
37
37
  embed: false,
38
- fragment: false,
39
38
  currency: undefined,
40
39
  remainingLines: lines,
41
40
  frontmatterEndLine: 0,
@@ -72,8 +71,6 @@ function applyFrontmatterLine(target, line) {
72
71
  target.printMode = isTruthy(val);
73
72
  else if (key === 'embed')
74
73
  target.embed = isTruthy(val);
75
- else if (key === 'fragment')
76
- target.fragment = isTruthy(val);
77
74
  else if (key === 'currency')
78
75
  target.currency = val.toUpperCase();
79
76
  }
@@ -18,13 +18,11 @@ export interface ParsedFrontmatter {
18
18
  continuous: boolean;
19
19
  orientation: PageOrientation;
20
20
  printMode: boolean;
21
- /** When true, omit page chrome (red bar, title row, theme toggle) so the
22
- * output can be embedded inside another host (e.g. a small iframe tile). */
21
+ /** When true, omit page chrome (red bar, title row, theme toggle) and
22
+ * run the embed post-processes (CSS prune, ECharts right-size, JS
23
+ * minify, transparent backgrounds, etc.) so the output can be embedded
24
+ * inside another host's frame (e.g. iframe tiles, dashboard cells). */
23
25
  embed: boolean;
24
- /** When true, emit a self-contained HTML fragment with no
25
- * `<!DOCTYPE>`/`<html>`/`<head>`/`<body>` wrappers. Implies `embed`.
26
- * Designed for hosts like claude.ai's `visualize:show_widget`. */
27
- fragment: boolean;
28
26
  currency: string | undefined;
29
27
  /** Lines of the markdown body after the closing `---`. */
30
28
  remainingLines: string[];
@@ -25,11 +25,11 @@ export type { MermaidBlock, ParseResult } from './parser-types.js';
25
25
  * Synchronous variant — mermaid blocks are emitted as placeholders and must be
26
26
  * resolved by `parseMarkdownToDashboardAsync` for final rendering.
27
27
  */
28
- export declare function parseMarkdownToDashboard(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean, fragmentOverride?: boolean): ParseResult;
28
+ export declare function parseMarkdownToDashboard(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean): ParseResult;
29
29
  /**
30
30
  * Async variant that resolves mermaid placeholders to inline SVG.
31
31
  */
32
- export declare function parseMarkdownToDashboardAsync(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean, fragmentOverride?: boolean): Promise<{
32
+ export declare function parseMarkdownToDashboardAsync(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean): Promise<{
33
33
  html: string;
34
34
  errors: string[];
35
35
  }>;
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { renderMermaid, renderMermaidAscii } from 'beautiful-mermaid';
20
20
  import { getThemeColors } from '../core/themes.js';
21
- import { toFragment } from '../core/fragment.js';
21
+ import { postProcessEmbed } from '../core/embed-postprocess.js';
22
22
  import { generateDashboardHtml, generateTestHarnessHtml, generateTestHarnessJs } from './templates.js';
23
23
  import { parseFrontmatter, parseMarkdown } from './markdown-parser.js';
24
24
  import { loadSections } from './block-loader.js';
@@ -30,7 +30,7 @@ import { renderSections } from './renderer.js';
30
30
  * Synchronous variant — mermaid blocks are emitted as placeholders and must be
31
31
  * resolved by `parseMarkdownToDashboardAsync` for final rendering.
32
32
  */
33
- export function parseMarkdownToDashboard(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false, fragmentOverride = false) {
33
+ export function parseMarkdownToDashboard(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false) {
34
34
  // 1. Parse: markdown -> raw IR
35
35
  const parsed = parseMarkdown(markdown, baseTheme);
36
36
  const { frontmatter, sections: rawSections, pageTitle } = parsed;
@@ -50,24 +50,28 @@ export function parseMarkdownToDashboard(markdown, baseTheme = 'light', baseDir,
50
50
  });
51
51
  errors.push(...layoutResult.errors);
52
52
  // 4. Render: IR -> componentsHtml + scripts (+ collect test items, mermaid blocks)
53
- const fragmentMode = frontmatter.fragment || fragmentOverride;
53
+ // Embed flag is ignored in testMode — the visual test harness has its
54
+ // own theme toggle which relies on full chart options (window.chartInstances
55
+ // / window.chartOptions populated). Embed-mode chart scripts skip those,
56
+ // so honoring `embed: true` here would silently break the harness toggle.
57
+ const embedMode = (frontmatter.embed || embedOverride) && !testMode;
54
58
  const renderOpts = {
55
59
  theme: frontmatter.theme,
56
60
  testMode,
57
- fragment: fragmentMode,
61
+ embed: embedMode,
58
62
  };
59
63
  if (customTheme)
60
64
  renderOpts.customTheme = customTheme;
61
65
  if (frontmatter.currency)
62
66
  renderOpts.currency = frontmatter.currency;
63
67
  const rendered = renderSections(loaded.sections, renderOpts);
64
- // 5. Assemble final page. Fragment mode implies embed (no chrome inside a
65
- // fragment hosts supply their own framing).
66
- const embedMode = fragmentMode || frontmatter.embed || embedOverride;
68
+ // 5. Assemble final page. Embed mode drops the page chrome (red bar,
69
+ // title row, theme toggle) and runs a battery of post-processes that
70
+ // shrink + lean the body for iframe-embedded contexts.
67
71
  const fullDoc = testMode
68
72
  ? assembleTestHarness(pageTitle, frontmatter.theme, rendered.componentsHtml, rendered.chartScripts, rendered.testItems)
69
- : generateDashboardHtml(pageTitle, [rendered.componentsHtml], [rendered.chartScripts], frontmatter.theme, '', frontmatter.continuous, customTheme, frontmatter.orientation, embedMode, fragmentMode);
70
- const html = fragmentMode ? toFragment(fullDoc) : fullDoc;
73
+ : generateDashboardHtml(pageTitle, [rendered.componentsHtml], [rendered.chartScripts], frontmatter.theme, '', frontmatter.continuous, customTheme, frontmatter.orientation, embedMode);
74
+ const html = embedMode ? postProcessEmbed(fullDoc) : fullDoc;
71
75
  return { html, errors, mermaidBlocks: rendered.mermaidBlocks };
72
76
  }
73
77
  /**
@@ -96,8 +100,8 @@ function buildNav(typesCount) {
96
100
  /**
97
101
  * Async variant that resolves mermaid placeholders to inline SVG.
98
102
  */
99
- export async function parseMarkdownToDashboardAsync(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false, fragmentOverride = false) {
100
- const result = parseMarkdownToDashboard(markdown, baseTheme, baseDir, strict, testMode, customTheme, lintMode, embedOverride, fragmentOverride);
103
+ export async function parseMarkdownToDashboardAsync(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false) {
104
+ const result = parseMarkdownToDashboard(markdown, baseTheme, baseDir, strict, testMode, customTheme, lintMode, embedOverride);
101
105
  if (result.mermaidBlocks.length === 0) {
102
106
  return { html: result.html, errors: result.errors };
103
107
  }
@@ -18,8 +18,9 @@ export interface RenderOptions {
18
18
  customTheme?: CustomTheme;
19
19
  currency?: string;
20
20
  /** When true, emit slimmer chart scripts (no light/dark options dict, no
21
- * body-class theme check) for fragment output (issue #247). */
22
- fragment?: boolean;
21
+ * body-class theme check) for embed output. Embed widgets are one-shot
22
+ * snapshots — no theme toggle to react to. */
23
+ embed?: boolean;
23
24
  }
24
25
  export interface RenderResult {
25
26
  componentsHtml: string;
@@ -111,7 +111,7 @@ function renderByType(compType, spec, args, ctx) {
111
111
  return renderSimpleComponent(compType, spec, args, ctx);
112
112
  }
113
113
  function renderEchartsAndCollect(compType, spec, args, ctx) {
114
- const { html, script } = renderEchartsComponent(compType, spec, args.chartId, args.colSpan, args.itemHeight, args.anchorId, ctx.options.customTheme, ctx.options.fragment ?? false, ctx.options.theme === 'dark' ? 'dark' : 'light');
114
+ const { html, script } = renderEchartsComponent(compType, spec, args.chartId, args.colSpan, args.itemHeight, args.anchorId, ctx.options.customTheme, ctx.options.embed ?? false, ctx.options.theme === 'dark' ? 'dark' : 'light');
115
115
  if (!html)
116
116
  return `<div class="grid-item"><p>Error rendering ${compType}</p></div>`;
117
117
  if (script)
@@ -167,7 +167,7 @@ function trackTestItem(ctx, chartId, compType, spec, section) {
167
167
  // =============================================================================
168
168
  // ECharts component rendering
169
169
  // =============================================================================
170
- function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId, customTheme, fragment = false, baseTheme = 'light') {
170
+ function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId, customTheme, embed = false, baseTheme = 'light') {
171
171
  const title = spec.title ?? '';
172
172
  const titleHtml = title ? `<h3 class="chart-title">${escapeHtml(title)}</h3>` : '';
173
173
  const builder = getOptionsBuilder(compType);
@@ -175,43 +175,43 @@ function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, an
175
175
  return { html: null, script: null };
176
176
  const chartHeight = itemHeight - (title ? 24 : 0);
177
177
  const fontFamily = getFontsWithCustom(customTheme).family;
178
- // Fragment mode also drops JSON pretty-print indentation — every byte the
179
- // model has to emit verbatim costs latency in widget-host scenarios.
180
- const optionJsonLight = buildEchartsOption(spec, baseTheme, chartHeight, customTheme, fontFamily, builder, fragment);
178
+ // Embed mode also drops JSON pretty-print indentation — every byte costs
179
+ // bandwidth + parse time inside the embedding host's iframe.
180
+ const optionJsonLight = buildEchartsOption(spec, baseTheme, chartHeight, customTheme, fontFamily, builder, embed);
181
181
  if (!optionJsonLight)
182
182
  return { html: null, script: null };
183
- // Skip the dark variant entirely in fragment mode — fragments are one-shot
184
- // snapshots, not theme-switchable surfaces. ~3KB of JSON per chart saved.
185
- const optionJsonDark = fragment ? null : buildEchartsOption(spec, 'dark', chartHeight, customTheme, fontFamily, builder, false);
183
+ // Skip the dark variant entirely in embed mode — embed widgets are one-shot
184
+ // snapshots, not theme-switchable surfaces. ~3 KB of JSON per chart saved.
185
+ const optionJsonDark = embed ? null : buildEchartsOption(spec, 'dark', chartHeight, customTheme, fontFamily, builder, false);
186
186
  const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
187
187
  const html = `
188
188
  <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
189
189
  ${titleHtml}
190
190
  <div id="${chartId}" style="width: 100%; height: ${chartHeight}px;"></div>
191
191
  </div>`;
192
- return { html, script: generateChartScript(chartId, optionJsonLight, optionJsonDark, fragment) };
192
+ return { html, script: generateChartScript(chartId, optionJsonLight, optionJsonDark, embed) };
193
193
  }
194
194
  function buildEchartsOption(spec, theme, chartHeight, customTheme, fontFamily, builder,
195
- // Drop pretty-print indentation when true — used for fragment outputs to
196
- // shrink what downstream tools (and language models) have to emit verbatim.
195
+ // Drop pretty-print indentation when true — used for embed outputs to
196
+ // shrink what the host iframe has to parse.
197
197
  minify = false) {
198
198
  const themedSpec = { ...spec, theme, height: chartHeight, customTheme };
199
199
  const option = builder(themedSpec);
200
200
  if (!option)
201
201
  return null;
202
- // Skip the per-chart fontFamily injection in fragment mode — SVG inherits
203
- // from the parent container (and ECharts renders SVG in mviz). Saves ~50
204
- // bytes per chart per option object that downstream tools have to emit.
202
+ // Skip the per-chart fontFamily injection in embed mode — SVG inherits
203
+ // from the parent container (and ECharts renders SVG in mviz). Saves
204
+ // ~50 bytes per chart per option object.
205
205
  if (!minify) {
206
206
  option.textStyle = { ...(option.textStyle ?? {}), fontFamily };
207
207
  }
208
208
  return serializeOption(option, { pretty: !minify });
209
209
  }
210
- function generateChartScript(chartId, optionJsonLight, optionJsonDark, fragment = false) {
211
- // Fragment mode: drop the chartOptions dict and the dark variant entirely.
212
- // Fragment outputs are one-shot snapshots — no theme toggle button, no
210
+ function generateChartScript(chartId, optionJsonLight, optionJsonDark, embed = false) {
211
+ // Embed mode: drop the chartOptions dict and the dark variant entirely.
212
+ // Embed widgets are one-shot snapshots — no theme toggle button, no
213
213
  // host-driven theme swap to react to — so the light option is what renders.
214
- if (fragment) {
214
+ if (embed) {
215
215
  return `
216
216
  (function() {
217
217
  var chart = echarts.init(document.getElementById('${chartId}'), null, {renderer: 'svg'});
@@ -9,11 +9,11 @@ export declare function escapeHtml(text: string): string;
9
9
  /**
10
10
  * Generate the dashboard CSS with theme variables.
11
11
  */
12
- export declare function generateDashboardCss(customTheme?: CustomTheme, orientation?: PageOrientation, fragment?: boolean): string;
12
+ export declare function generateDashboardCss(customTheme?: CustomTheme, orientation?: PageOrientation, embed?: boolean): string;
13
13
  /**
14
14
  * Generate complete dashboard HTML.
15
15
  */
16
- export declare function generateDashboardHtml(pageTitle: string, componentsHtml: string[], chartScripts: string[], theme?: string, extraScripts?: string, continuous?: boolean, customTheme?: CustomTheme, orientation?: PageOrientation, embed?: boolean, fragment?: boolean): string;
16
+ export declare function generateDashboardHtml(pageTitle: string, componentsHtml: string[], chartScripts: string[], theme?: string, extraScripts?: string, continuous?: boolean, customTheme?: CustomTheme, orientation?: PageOrientation, embed?: boolean): string;
17
17
  /**
18
18
  * Generate CSS for the test harness.
19
19
  */
@@ -17,17 +17,18 @@ export function escapeHtml(text) {
17
17
  /**
18
18
  * Generate the dashboard CSS with theme variables.
19
19
  */
20
- export function generateDashboardCss(customTheme, orientation = 'portrait', fragment = false) {
20
+ export function generateDashboardCss(customTheme, orientation = 'portrait', embed = false) {
21
21
  const colorsLight = getThemeColorsWithCustom('light', customTheme);
22
22
  const colorsDark = getThemeColorsWithCustom('dark', customTheme);
23
23
  const paletteLight = getPaletteWithCustom('light', customTheme);
24
24
  const fonts = getFontsWithCustom(customTheme);
25
25
  const linkColor = getLinkColor();
26
26
  const dashboardMaxWidth = orientation === 'landscape' ? PRINT_LANDSCAPE_WIDTH : PRINT_PORTRAIT_WIDTH;
27
- // Fragment mode (issue #247) — skip the Google Fonts `@import` (the host
28
- // already supplies its own font stack and the network roundtrip is wasted)
29
- // and the print stylesheet (irrelevant in widget hosts).
30
- const fontImport = fragment ? '' : `@import url('${fonts.import}');`;
27
+ // Embed mode (issues #272 / #247) — skip the Google Fonts `@import` (the
28
+ // host already supplies its own font stack and the network roundtrip is
29
+ // wasted) and the print stylesheet (irrelevant inside iframe-embedded
30
+ // widgets).
31
+ const fontImport = embed ? '' : `@import url('${fonts.import}');`;
31
32
  return `
32
33
  ${fontImport}
33
34
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -56,7 +57,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
56
57
 
57
58
  html, body {
58
59
  width: 100%; min-height: 100%;
59
- ${fragment ? '' : 'background-color: var(--bg);'}
60
+ ${embed ? '' : 'background-color: var(--bg);'}
60
61
  font-family: var(--font-family);
61
62
  color: var(--text);
62
63
  font-size: 12px;
@@ -154,7 +155,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
154
155
  .row > * { grid-column: span 1; }
155
156
  .grid-item { overflow-x: auto; -webkit-overflow-scrolling: touch; }
156
157
  }
157
- ${fragment ? '' : `@media print {
158
+ ${embed ? '' : `@media print {
158
159
  @page { size: letter ${orientation}; margin: 0.5in; }
159
160
  .dashboard { max-width: 100%; padding: 0; }
160
161
  .row { gap: 6px; margin-bottom: 6px; break-inside: avoid; page-break-inside: avoid; }
@@ -167,7 +168,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
167
168
  .title-row { break-after: avoid; page-break-after: avoid; }
168
169
  }`}
169
170
  .grid-item {
170
- ${fragment ? '' : 'background: var(--bg);'}
171
+ ${embed ? '' : 'background: var(--bg);'}
171
172
  padding: 4px;
172
173
  transition: background-color 0.3s;
173
174
  }
@@ -362,22 +363,24 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
362
363
  /**
363
364
  * Generate complete dashboard HTML.
364
365
  */
365
- export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, theme = 'light', extraScripts = '', continuous = false, customTheme, orientation = 'portrait', embed = false, fragment = false) {
366
+ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, theme = 'light', extraScripts = '', continuous = false, customTheme, orientation = 'portrait', embed = false) {
366
367
  const initialThemeClass = theme === 'dark' ? 'theme-dark' : 'theme-light';
367
368
  const continuousClass = continuous ? ' dashboard-continuous' : '';
368
369
  const embedClass = embed ? ' dashboard-embed' : '';
369
- const css = generateDashboardCss(customTheme, orientation, fragment);
370
- // Fragment mode (issue #247) deliberately drops mviz's PNG-export menu and
371
- // the theme-toggle function: the menu fights iframe-sandbox host behavior
372
- // (document.body mutations, download attribute) and the toggle's button is
373
- // already stripped by embed mode, leaving the function orphaned.
374
- const exportCss = fragment ? '' : generateExportCss();
375
- const exportJs = fragment ? '' : generateExportJs();
376
- // marked.min.js is only needed if a markdown/text component is rendered.
377
- // Fragment hosts (notably claude.ai's `visualize:show_widget`) rarely use
378
- // those, and the script-tag fetch dominates load time. Skip it.
379
- const markedScript = fragment ? '' : '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>';
380
- const themeToggleJs = fragment
370
+ const css = generateDashboardCss(customTheme, orientation, embed);
371
+ // Embed mode deliberately drops mviz's PNG-export menu and the theme-
372
+ // toggle function: the menu fights iframe-sandbox host behavior
373
+ // (document.body mutations, download attribute) and the toggle's button
374
+ // is already stripped by embed mode, leaving the function orphaned.
375
+ const exportCss = embed ? '' : generateExportCss();
376
+ const exportJs = embed ? '' : generateExportJs();
377
+ // marked.min.js is only needed if a markdown/text component is rendered
378
+ // (textarea + note + text components emit inline scripts that call
379
+ // `marked.parse(...)`). For embed mode we always include the script tag
380
+ // here and let the post-process drop it later — that pass scans the body
381
+ // for `marked.parse` and only strips when no caller survives.
382
+ const markedScript = '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>';
383
+ const themeToggleJs = embed
381
384
  ? ''
382
385
  : `// Theme toggle function
383
386
  function toggleTheme() {
@@ -445,7 +448,13 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
445
448
 
446
449
  ${chartScripts.join('')}
447
450
 
448
- ${fragment ? '' : `// Sparkline tooltip interactivity
451
+ // --- mviz sparkline tooltip start ---
452
+ // Always emitted; the embed post-process drops everything between the
453
+ // start/end markers when no .spark-hit element survives the rest of
454
+ // the pipeline, so table sparkline cells in embedded output still get
455
+ // hover handlers. The markers are a delimiter rather than a regex
456
+ // brace-walker because the listener has nested closing braces inside
457
+ // the arrow-function body and a non-balanced regex matches the wrong one.
449
458
  document.querySelectorAll('.spark-hit').forEach(hit => {
450
459
  hit.addEventListener('mouseenter', function() {
451
460
  const tooltip = this.closest('.spark-tooltip');
@@ -459,7 +468,8 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
459
468
  tooltip.setAttribute('data-tooltip', tooltip.dataset.default);
460
469
  }
461
470
  });
462
- });`}
471
+ });
472
+ // --- mviz sparkline tooltip end ---
463
473
 
464
474
  ${exportJs}
465
475
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mviz",
3
- "version": "1.7.0-pre.1",
3
+ "version": "1.7.0-pre.3",
4
4
  "description": "A chart & report builder designed for use by AI.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",