pi-studio 0.9.35 → 0.9.37

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/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.37] — 2026-08-01
8
+
9
+ ### Added
10
+ - Added lazy Lucide and Logos icon-pack support for Mermaid diagrams in live previews, standalone HTML exports, and PDF exports through Mermaid CLI.
11
+ - Added browser regression coverage for real SVG icon rendering and dark/light contrast correction.
12
+
13
+ ### Changed
14
+ - Pinned browser Mermaid rendering to 11.16.0 and pinned the icon-pack data used by browser and PDF exports.
15
+ - Made Mermaid icon paints and node labels meet a 4.5:1 contrast target against their rendered surfaces while retaining readable semantic colours; PDF preprocessing now corrects `classDef`/`style` label colours and targets literal Logos SVG fills over solid custom fills.
16
+ - Resolve Mermaid CLI from `MERMAID_CLI_PATH`, Pi's shared npm binary directory, or `PATH`, in that order.
17
+
18
+ ### Fixed
19
+ - Surface Mermaid icon-pack load failures as visible diagram errors instead of silently rendering empty icon nodes.
20
+
21
+ ## [0.9.36] — 2026-07-13
22
+
23
+ ### Changed
24
+ - Added `max` to the Studio footer Pi thinking selector to match newer Pi thinking levels.
25
+
7
26
  ## [0.9.35] — 2026-07-13
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -50,7 +50,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
50
50
  - shows/hides annotation markers in preview
51
51
  - strips markers before send (optional)
52
52
  - saves `.annotated.md`
53
- - Renders Markdown/LaTeX/code previews (math + Mermaid) plus lightweight CSV/TSV table previews, theme-synced with pi, with copy buttons for code blocks and blockquotes
53
+ - Renders Markdown/LaTeX/code previews (math + Mermaid) plus lightweight CSV/TSV table previews, theme-synced with pi, with copy buttons for code blocks and blockquotes; Mermaid previews include Lucide/Logos icon nodes and accessible label contrast over custom fills
54
54
  - Renders straight, unfenced interactive HTML in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
55
55
  - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks, with a Focus action for temporarily enlarging the embedded viewer
56
56
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
@@ -122,6 +122,30 @@ caption: Optional caption
122
122
 
123
123
  `path` must point to a local `.pdf` within the current Studio resource directory. Relative paths resolve from the opened document's directory, or from Studio's working dir for non-file-backed content. `page` is an initial page hint for the browser PDF viewer, and `height` controls the embedded frame height in pixels. Use normal Markdown links for PDFs when embedding is not useful.
124
124
 
125
+ ### Mermaid icons
126
+
127
+ Studio's live previews and standalone HTML exports support Mermaid's `lucide:*` and `logos:*` icon nodes. The corresponding Iconify packs load lazily, so Studio does not fetch either pack unless a diagram uses it. Mermaid and the browser icon-pack URLs are pinned to tested versions.
128
+
129
+ ````md
130
+ ```mermaid
131
+ flowchart LR
132
+ source@{ icon: "lucide:file-code-2", form: "rounded", label: "Source", pos: "b", h: 56 }
133
+ github@{ icon: "logos:github-icon", form: "rounded", label: "GitHub", pos: "b", h: 56 }
134
+ source -->|publish| github
135
+ ```
136
+ ````
137
+
138
+ Studio also corrects low-contrast Mermaid icon paints and node labels after browser rendering while preserving authored colours that already meet a 4.5:1 contrast ratio. For PDF output, Studio adds or corrects label colours on `classDef` and `style` declarations with solid hex/RGB fills and supplies targeted icon CSS to Mermaid CLI; this also covers Logos icons whose SVG paths use literal fills.
139
+
140
+ PDF icon nodes require Mermaid CLI 11.12+ so Studio can use pinned icon-pack URLs. Install a current CLI, or set `MERMAID_CLI_PATH` to one:
141
+
142
+ ```bash
143
+ npm install -g @mermaid-js/mermaid-cli@11.16.0
144
+ mmdc --version
145
+ ```
146
+
147
+ Studio only passes icon-pack arguments when a diagram actually references `lucide:` or `logos:`, so ordinary Mermaid PDF diagrams remain compatible with older CLI versions.
148
+
125
149
  ## Notes
126
150
 
127
151
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
@@ -135,7 +159,7 @@ caption: Optional caption
135
159
  - `brew install pandoc`
136
160
  - install TeX Live/MacTeX for PDF export
137
161
  - Export subprocess timeouts default to bounded values and can be tuned with `PI_STUDIO_PANDOC_TIMEOUT_MS`, `PI_STUDIO_LATEX_TIMEOUT_MS`, `PI_STUDIO_MERMAID_TIMEOUT_MS`, and `PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES` for unusually large embedded-asset HTML exports.
138
- - Mermaid diagrams in exported PDFs may also require Mermaid CLI (`mmdc` / `@mermaid-js/mermaid-cli`) when you want diagram blocks rendered as diagrams rather than left as code.
162
+ - Mermaid diagrams in exported PDFs require Mermaid CLI (`mmdc` / `@mermaid-js/mermaid-cli`) when you want diagram blocks rendered as diagrams rather than left as code; PDF icon nodes require Mermaid CLI 11.12+.
139
163
 
140
164
  ## License
141
165
 
@@ -2731,7 +2731,10 @@
2731
2731
  throw new Error("Studio annotation helpers failed to load.");
2732
2732
  }
2733
2733
  const EMPTY_OVERLAY_LINE = "\u200b";
2734
- const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
2734
+ const mermaidHelpers = globalThis.PiStudioMermaidHelpers || null;
2735
+ const MERMAID_CDN_URL = mermaidHelpers && mermaidHelpers.MERMAID_CDN_URL
2736
+ ? mermaidHelpers.MERMAID_CDN_URL
2737
+ : "https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.esm.min.mjs";
2735
2738
  const MATHJAX_CDN_URL = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js";
2736
2739
  const PDFJS_CDN_URL = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.min.mjs";
2737
2740
  const PDFJS_WORKER_CDN_URL = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.worker.min.mjs";
@@ -2749,6 +2752,9 @@
2749
2752
  const PDF_PREVIEW_RENDER_FAIL_MESSAGE = "PDF figure preview could not be rendered.";
2750
2753
  let mermaidModulePromise = null;
2751
2754
  let mermaidInitialized = false;
2755
+ const mermaidIconRegistry = mermaidHelpers && typeof mermaidHelpers.createIconPackRegistry === "function"
2756
+ ? mermaidHelpers.createIconPackRegistry()
2757
+ : null;
2752
2758
  let mathJaxPromise = null;
2753
2759
  let pdfJsPromise = null;
2754
2760
 
@@ -3322,7 +3328,7 @@
3322
3328
  }
3323
3329
 
3324
3330
  function getPiThinkingLevels() {
3325
- return ["off", "minimal", "low", "medium", "high", "xhigh"];
3331
+ return ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
3326
3332
  }
3327
3333
 
3328
3334
  function renderFooterModelMenu() {
@@ -3339,7 +3345,9 @@
3339
3345
  modelOptionsHtml.unshift("<option value='" + escapeHtml(currentValue) + "' selected>" + escapeHtml(label || "current model") + "</option>");
3340
3346
  }
3341
3347
  const thinking = piThinkingLevel || "off";
3342
- const thinkingOptionsHtml = getPiThinkingLevels().map((level) => {
3348
+ const thinkingLevels = getPiThinkingLevels();
3349
+ if (thinking && !thinkingLevels.includes(thinking)) thinkingLevels.push(thinking);
3350
+ const thinkingOptionsHtml = thinkingLevels.map((level) => {
3343
3351
  return "<option value='" + escapeHtml(level) + "'" + (level === thinking ? " selected" : "") + ">Thinking: " + escapeHtml(level) + "</option>";
3344
3352
  });
3345
3353
  footerModelMenuEl.innerHTML = ""
@@ -8241,8 +8249,12 @@
8241
8249
  if (!mermaidApi) {
8242
8250
  throw new Error("Mermaid module did not expose a default export.");
8243
8251
  }
8252
+ if (!mermaidHelpers || !mermaidIconRegistry) {
8253
+ throw new Error("Studio Mermaid helpers failed to load.");
8254
+ }
8244
8255
 
8245
8256
  if (!mermaidInitialized) {
8257
+ mermaidIconRegistry.register(mermaidApi);
8246
8258
  mermaidApi.initialize(MERMAID_CONFIG);
8247
8259
  mermaidInitialized = true;
8248
8260
  }
@@ -8272,7 +8284,7 @@
8272
8284
  return;
8273
8285
  }
8274
8286
 
8275
- mermaidBlocks.forEach((preEl) => {
8287
+ const wrappers = Array.from(mermaidBlocks).map((preEl) => {
8276
8288
  const codeEl = preEl.querySelector("code");
8277
8289
  const source = codeEl ? codeEl.textContent : preEl.textContent;
8278
8290
 
@@ -8307,20 +8319,29 @@
8307
8319
  wrapper.appendChild(toolbarEl);
8308
8320
  wrapper.appendChild(diagramEl);
8309
8321
  preEl.replaceWith(wrapper);
8322
+ return wrapper;
8310
8323
  });
8311
8324
 
8312
- const diagramNodes = Array.from(targetEl.querySelectorAll(".mermaid"));
8325
+ const diagramNodes = wrappers
8326
+ .map((wrapper) => wrapper.querySelector(".mermaid"))
8327
+ .filter(Boolean);
8313
8328
  if (diagramNodes.length === 0) return;
8314
8329
 
8315
8330
  try {
8331
+ mermaidIconRegistry.clearError();
8316
8332
  await mermaidApi.run({ nodes: diagramNodes });
8333
+ const iconPackError = mermaidIconRegistry.getError();
8334
+ if (iconPackError) throw iconPackError;
8335
+ mermaidHelpers.applyAccessibleColors(targetEl);
8317
8336
  } catch (error) {
8318
8337
  try {
8319
- await mermaidApi.run();
8320
- } catch (fallbackError) {
8321
- console.error("Mermaid render failed:", fallbackError || error);
8322
- appendMermaidNotice(targetEl, MERMAID_RENDER_FAIL_MESSAGE);
8338
+ mermaidHelpers.applyAccessibleColors(targetEl);
8339
+ } catch (contrastError) {
8340
+ console.warn("Mermaid contrast correction failed after a partial render:", contrastError);
8323
8341
  }
8342
+ mermaidHelpers.renderFailures(wrappers, error);
8343
+ console.error("Mermaid render failed:", error);
8344
+ appendMermaidNotice(targetEl, MERMAID_RENDER_FAIL_MESSAGE);
8324
8345
  }
8325
8346
  }
8326
8347
 
@@ -0,0 +1,267 @@
1
+ (() => {
2
+ const MERMAID_VERSION = "11.16.0";
3
+ const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@" + MERMAID_VERSION + "/dist/mermaid.esm.min.mjs";
4
+ const ICON_PACKS = Object.freeze([
5
+ Object.freeze({
6
+ name: "lucide",
7
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/lucide@1.2.120/icons.json",
8
+ }),
9
+ Object.freeze({
10
+ name: "logos",
11
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/logos@1.2.11/icons.json",
12
+ }),
13
+ ]);
14
+ const MINIMUM_TEXT_CONTRAST = 4.5;
15
+
16
+ function normalizeError(error) {
17
+ return error instanceof Error ? error : new Error(String(error));
18
+ }
19
+
20
+ function parseRgb(value) {
21
+ const match = String(value || "").match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/);
22
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
23
+ }
24
+
25
+ function isOpaqueColor(value) {
26
+ if (!parseRgb(value)) return false;
27
+ const alphaMatch = String(value || "").match(/^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/);
28
+ return !alphaMatch || Number(alphaMatch[1]) >= 1;
29
+ }
30
+
31
+ function relativeLuminance(color) {
32
+ const linear = color.map(function(channel) {
33
+ const value = channel / 255;
34
+ return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
35
+ });
36
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
37
+ }
38
+
39
+ function contrastRatio(foreground, background) {
40
+ const foregroundRgb = Array.isArray(foreground) ? foreground : parseRgb(foreground);
41
+ const backgroundRgb = Array.isArray(background) ? background : parseRgb(background);
42
+ if (!foregroundRgb || !backgroundRgb) return 1;
43
+ const lighter = Math.max(relativeLuminance(foregroundRgb), relativeLuminance(backgroundRgb));
44
+ const darker = Math.min(relativeLuminance(foregroundRgb), relativeLuminance(backgroundRgb));
45
+ return (lighter + 0.05) / (darker + 0.05);
46
+ }
47
+
48
+ function toRgb(color) {
49
+ return "rgb(" + color.map(function(channel) { return Math.round(channel); }).join(", ") + ")";
50
+ }
51
+
52
+ function ensureReadableColor(foregroundCss, backgroundCss, minimumContrast) {
53
+ const foreground = parseRgb(foregroundCss);
54
+ const background = parseRgb(backgroundCss);
55
+ const targetContrast = Number.isFinite(minimumContrast) ? minimumContrast : MINIMUM_TEXT_CONTRAST;
56
+ if (!foreground || !background || contrastRatio(foreground, background) >= targetContrast) {
57
+ return foregroundCss;
58
+ }
59
+
60
+ const readableCandidates = [[0, 0, 0], [255, 255, 255]].flatMap(function(target) {
61
+ for (let step = 1; step <= 20; step += 1) {
62
+ const amount = step / 20;
63
+ const color = foreground.map(function(channel, index) {
64
+ return channel + (target[index] - channel) * amount;
65
+ });
66
+ if (contrastRatio(color, background) >= targetContrast) return [{ amount: amount, color: color }];
67
+ }
68
+ return [];
69
+ });
70
+ readableCandidates.sort(function(left, right) { return left.amount - right.amount; });
71
+ if (readableCandidates.length > 0) return toRgb(readableCandidates[0].color);
72
+
73
+ const black = [0, 0, 0];
74
+ const white = [255, 255, 255];
75
+ return toRgb(contrastRatio(black, background) >= contrastRatio(white, background) ? black : white);
76
+ }
77
+
78
+ function createIconPackRegistry(options) {
79
+ const config = options && typeof options === "object" ? options : {};
80
+ const packs = Array.isArray(config.packs) ? config.packs : ICON_PACKS;
81
+ const fetchImpl = typeof config.fetch === "function"
82
+ ? config.fetch
83
+ : (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null);
84
+ const pending = new Map();
85
+ let loadError = null;
86
+ let registeredApi = null;
87
+
88
+ function load(pack) {
89
+ if (!fetchImpl) {
90
+ const error = new Error("Browser fetch API is unavailable for Mermaid icon packs.");
91
+ loadError = error;
92
+ return Promise.reject(error);
93
+ }
94
+ if (!pending.has(pack.name)) {
95
+ const request = Promise.resolve()
96
+ .then(function() { return fetchImpl(pack.url); })
97
+ .then(function(response) {
98
+ if (!response || !response.ok) {
99
+ const status = response && Number.isFinite(response.status) ? response.status : "unknown";
100
+ throw new Error("Failed to load Mermaid icon pack " + pack.name + ": HTTP " + status);
101
+ }
102
+ return response.json();
103
+ })
104
+ .catch(function(error) {
105
+ const normalized = normalizeError(error);
106
+ loadError = normalized;
107
+ pending.delete(pack.name);
108
+ throw normalized;
109
+ });
110
+ pending.set(pack.name, request);
111
+ }
112
+ return pending.get(pack.name);
113
+ }
114
+
115
+ function register(mermaidApi) {
116
+ if (registeredApi === mermaidApi) return;
117
+ if (!mermaidApi || typeof mermaidApi.registerIconPacks !== "function") {
118
+ throw new Error("This Mermaid build does not support registered icon packs.");
119
+ }
120
+ mermaidApi.registerIconPacks(packs.map(function(pack) {
121
+ return { name: pack.name, loader: function() { return load(pack); } };
122
+ }));
123
+ registeredApi = mermaidApi;
124
+ }
125
+
126
+ return Object.freeze({
127
+ clearError: function() { loadError = null; },
128
+ getError: function() { return loadError; },
129
+ register: register,
130
+ });
131
+ }
132
+
133
+ function getDomContext(root) {
134
+ const documentRef = root && root.ownerDocument
135
+ ? root.ownerDocument
136
+ : (typeof document === "object" ? document : null);
137
+ const windowRef = documentRef && documentRef.defaultView
138
+ ? documentRef.defaultView
139
+ : (typeof window === "object" ? window : null);
140
+ if (!documentRef || !windowRef || typeof windowRef.getComputedStyle !== "function") return null;
141
+ return {
142
+ documentRef: documentRef,
143
+ windowRef: windowRef,
144
+ ElementCtor: windowRef.Element,
145
+ HTMLElementCtor: windowRef.HTMLElement,
146
+ getStyle: windowRef.getComputedStyle.bind(windowRef),
147
+ };
148
+ }
149
+
150
+ function findOpaqueFill(root, context) {
151
+ if (!context || !(root instanceof context.ElementCtor)) return null;
152
+ const shape = Array.from(root.querySelectorAll("rect, polygon, path, circle, ellipse")).find(function(candidate) {
153
+ return isOpaqueColor(context.getStyle(candidate).fill);
154
+ });
155
+ return shape ? context.getStyle(shape).fill : null;
156
+ }
157
+
158
+ function findOpaqueBackground(element, fallback, context) {
159
+ if (!context) return fallback;
160
+ let current = element instanceof context.ElementCtor ? element : null;
161
+ while (current) {
162
+ const background = context.getStyle(current).backgroundColor;
163
+ if (current instanceof context.HTMLElementCtor && isOpaqueColor(background)) return background;
164
+ current = current.parentElement;
165
+ }
166
+ return fallback;
167
+ }
168
+
169
+ function applyAccessibleColors(root) {
170
+ if (!root || typeof root.querySelectorAll !== "function") return { iconNodes: 0, shapeNodes: 0 };
171
+ const context = getDomContext(root);
172
+ if (!context) return { iconNodes: 0, shapeNodes: 0 };
173
+ const body = context.documentRef.body;
174
+ const pageBackground = body ? context.getStyle(body).backgroundColor : "rgb(255, 255, 255)";
175
+ let iconNodes = 0;
176
+ let shapeNodes = 0;
177
+
178
+ root.querySelectorAll(".mermaid-container .icon-shape").forEach(function(node) {
179
+ const icon = node.querySelector("svg");
180
+ if (!icon) return;
181
+ iconNodes += 1;
182
+ const semanticColor = context.getStyle(icon).color;
183
+ const iconSurface = findOpaqueFill(node.firstElementChild, context)
184
+ || findOpaqueBackground(icon, pageBackground, context);
185
+ const iconColor = ensureReadableColor(semanticColor, iconSurface);
186
+ icon.style.setProperty("color", iconColor, "important");
187
+ icon.querySelectorAll("g, path, rect, polygon, circle, ellipse, line, polyline").forEach(function(paintedElement) {
188
+ const paintedStyle = context.getStyle(paintedElement);
189
+ if (isOpaqueColor(paintedStyle.fill)) {
190
+ paintedElement.style.setProperty("fill", ensureReadableColor(paintedStyle.fill, iconSurface), "important");
191
+ }
192
+ if (isOpaqueColor(paintedStyle.stroke)) {
193
+ paintedElement.style.setProperty("stroke", ensureReadableColor(paintedStyle.stroke, iconSurface), "important");
194
+ }
195
+ });
196
+ node.querySelectorAll(".labelBkg, .nodeLabel").forEach(function(label) {
197
+ if (!(label instanceof context.HTMLElementCtor)) return;
198
+ const labelSurface = findOpaqueBackground(label, pageBackground, context);
199
+ const labelColor = ensureReadableColor(semanticColor, labelSurface);
200
+ label.style.setProperty("color", labelColor, "important");
201
+ });
202
+ });
203
+
204
+ root.querySelectorAll(".mermaid-container .node:not(.icon-shape)").forEach(function(node) {
205
+ const shape = Array.from(node.querySelectorAll("rect, polygon, path, circle, ellipse")).find(function(candidate) {
206
+ const fill = context.getStyle(candidate).fill;
207
+ return fill && fill !== "none" && fill !== "rgba(0, 0, 0, 0)";
208
+ });
209
+ if (!shape) return;
210
+ shapeNodes += 1;
211
+ const shapeFill = context.getStyle(shape).fill;
212
+ node.querySelectorAll(".nodeLabel").forEach(function(label) {
213
+ if (!(label instanceof context.HTMLElementCtor)) return;
214
+ const labelColor = ensureReadableColor(context.getStyle(label).color, shapeFill);
215
+ label.style.setProperty("color", labelColor, "important");
216
+ });
217
+ });
218
+
219
+ return { iconNodes: iconNodes, shapeNodes: shapeNodes };
220
+ }
221
+
222
+ function getFailedIconPackName(message) {
223
+ const match = String(message || "").match(/Mermaid icon pack\s+(lucide|logos)\b/i);
224
+ return match ? match[1].toLowerCase() : null;
225
+ }
226
+
227
+ function sourceUsesIconPack(source, packName) {
228
+ if (!packName) return false;
229
+ const escapedName = packName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
230
+ return new RegExp("\\bicon\\s*:\\s*[\\\"']" + escapedName + ":", "i").test(String(source || ""));
231
+ }
232
+
233
+ function renderFailures(wrappers, error) {
234
+ const message = normalizeError(error).message;
235
+ const failedIconPack = getFailedIconPackName(message);
236
+ Array.from(wrappers || []).forEach(function(wrapper) {
237
+ if (!wrapper || typeof wrapper.querySelector !== "function") return;
238
+ const diagram = wrapper.querySelector(".mermaid");
239
+ const documentRef = wrapper.ownerDocument || (typeof document === "object" ? document : null);
240
+ if (!diagram || !documentRef) return;
241
+ const source = wrapper.dataset && typeof wrapper.dataset.mermaidSource === "string"
242
+ ? wrapper.dataset.mermaidSource
243
+ : "";
244
+ const hasRenderedSvg = Boolean(diagram.querySelector("svg"));
245
+ if (hasRenderedSvg && !sourceUsesIconPack(source, failedIconPack)) return;
246
+ const failure = documentRef.createElement("pre");
247
+ failure.className = "mermaid-error";
248
+ failure.setAttribute("role", "alert");
249
+ failure.textContent = "Mermaid render failed: " + message + (source ? "\n\n" + source : "");
250
+ diagram.replaceWith(failure);
251
+ });
252
+ return message;
253
+ }
254
+
255
+ globalThis.PiStudioMermaidHelpers = Object.freeze({
256
+ ICON_PACKS: ICON_PACKS,
257
+ MERMAID_CDN_URL: MERMAID_CDN_URL,
258
+ MERMAID_VERSION: MERMAID_VERSION,
259
+ MINIMUM_TEXT_CONTRAST: MINIMUM_TEXT_CONTRAST,
260
+ applyAccessibleColors: applyAccessibleColors,
261
+ contrastRatio: contrastRatio,
262
+ createIconPackRegistry: createIconPackRegistry,
263
+ ensureReadableColor: ensureReadableColor,
264
+ parseRgb: parseRgb,
265
+ renderFailures: renderFailures,
266
+ });
267
+ })();
package/client/studio.css CHANGED
@@ -2819,6 +2819,18 @@
2819
2819
  height: auto;
2820
2820
  }
2821
2821
 
2822
+ .rendered-markdown .mermaid-error {
2823
+ margin: 0;
2824
+ padding: 12px 14px;
2825
+ border: 1px solid var(--error);
2826
+ border-radius: 8px;
2827
+ background: var(--panel-2);
2828
+ color: var(--error);
2829
+ text-align: left;
2830
+ white-space: pre-wrap;
2831
+ overflow-wrap: anywhere;
2832
+ }
2833
+
2822
2834
  .plain-markdown {
2823
2835
  margin: 0;
2824
2836
  white-space: pre-wrap;
package/index.ts CHANGED
@@ -31,6 +31,11 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
32
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
33
33
  import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
34
+ import {
35
+ buildStudioMermaidCliIconArgs,
36
+ buildStudioMermaidPdfIconContrastCss,
37
+ ensureStudioMermaidSourceContrast,
38
+ } from "./shared/studio-mermaid.js";
34
39
 
35
40
  type Lens = "writing" | "code";
36
41
  type RequestedLens = Lens | "auto";
@@ -44,9 +49,11 @@ type StudioReplRuntime = "shell" | "python" | "ipython" | "julia" | "r" | "ghci"
44
49
  type StudioQuizAngle = "general" | "scientist" | "mathematician" | "statistician" | "developer" | "reviewer";
45
50
  type StudioQuizScope = "selection" | "editor" | "file" | "folder" | "repo";
46
51
  type StudioQuizThinking = "off" | "minimal" | "low" | "medium" | "high";
52
+ type StudioPiThinkingLevel = ModelThinkingLevel | "max";
47
53
 
48
54
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
49
55
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
56
+ const STUDIO_MERMAID_HELPERS_URL = new URL("./client/studio-mermaid-helpers.js", import.meta.url);
50
57
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
51
58
 
52
59
  interface StudioServerState {
@@ -358,7 +365,7 @@ interface PiModelSelectRequestMessage {
358
365
 
359
366
  interface PiThinkingLevelRequestMessage {
360
367
  type: "pi_thinking_level_request";
361
- level: ModelThinkingLevel;
368
+ level: StudioPiThinkingLevel;
362
369
  }
363
370
 
364
371
  interface PiThemeSelectRequestMessage {
@@ -643,6 +650,13 @@ const STUDIO_PROMPT_METADATA_CUSTOM_TYPE = "pi-studio/direct-prompt";
643
650
  const STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY = "doc:blank:blank";
644
651
  const STUDIO_PERSISTENT_STATE_DIR = join(getAgentDir(), "pi-studio");
645
652
  const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-state.json");
653
+ const PI_STUDIO_MERMAID_CLI_PATH = join(
654
+ getAgentDir(),
655
+ "npm",
656
+ "node_modules",
657
+ ".bin",
658
+ process.platform === "win32" ? "mmdc.cmd" : "mmdc",
659
+ );
646
660
 
647
661
  type StudioSubprocessResult = {
648
662
  code: number | null;
@@ -5819,14 +5833,25 @@ function getStudioMermaidPdfTheme(): "default" | "forest" | "dark" | "neutral" {
5819
5833
  return "default";
5820
5834
  }
5821
5835
 
5836
+ function getStudioMermaidCliCommand(): string {
5837
+ return process.env.MERMAID_CLI_PATH?.trim()
5838
+ || (existsSync(PI_STUDIO_MERMAID_CLI_PATH) ? PI_STUDIO_MERMAID_CLI_PATH : "mmdc");
5839
+ }
5840
+
5822
5841
  async function renderStudioMermaidDiagramForPdf(source: string, workDir: string, blockNumber: number): Promise<string> {
5823
- const mermaidCommand = process.env.MERMAID_CLI_PATH?.trim() || "mmdc";
5842
+ const mermaidCommand = getStudioMermaidCliCommand();
5824
5843
  const mermaidTheme = getStudioMermaidPdfTheme();
5825
5844
  const inputPath = join(workDir, `mermaid-diagram-${blockNumber}.mmd`);
5826
5845
  const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
5846
+ const iconCssPath = join(workDir, `mermaid-diagram-${blockNumber}.css`);
5827
5847
 
5828
- await writeFile(inputPath, source, "utf-8");
5848
+ const preparedSource = ensureStudioMermaidSourceContrast(source);
5849
+ const iconContrastCss = buildStudioMermaidPdfIconContrastCss(preparedSource);
5850
+ await writeFile(inputPath, preparedSource, "utf-8");
5851
+ if (iconContrastCss) await writeFile(iconCssPath, iconContrastCss, "utf-8");
5829
5852
  const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
5853
+ if (iconContrastCss) args.push("-C", iconCssPath);
5854
+ args.push(...buildStudioMermaidCliIconArgs(preparedSource));
5830
5855
  const result = await runStudioSubprocess(mermaidCommand, args, {
5831
5856
  timeoutMs: STUDIO_MERMAID_TIMEOUT_MS,
5832
5857
  label: "Mermaid CLI",
@@ -6316,10 +6341,18 @@ function buildStudioHtmlMermaidConfig(vars: Record<string, string>): Record<stri
6316
6341
 
6317
6342
  function buildStudioStandaloneHtmlMermaidScript(vars: Record<string, string>): string {
6318
6343
  const mermaidConfigJson = JSON.stringify(buildStudioHtmlMermaidConfig(vars)).replace(/</g, "\\u003c");
6344
+ const mermaidHelpersSource = readFileSync(STUDIO_MERMAID_HELPERS_URL, "utf-8").replace(/<\/script/gi, "<\\/script");
6319
6345
  return `<script>
6346
+ ${mermaidHelpersSource}
6347
+ </script>
6348
+ <script>
6320
6349
  (() => {
6321
- const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
6322
6350
  const MERMAID_CONFIG = ${mermaidConfigJson};
6351
+ const mermaidHelpers = globalThis.PiStudioMermaidHelpers || null;
6352
+
6353
+ function setMermaidRenderResult(status, error) {
6354
+ window.__piStudioMermaidRenderResult = error ? { status, error } : { status };
6355
+ }
6323
6356
 
6324
6357
  function appendMermaidWarning(message) {
6325
6358
  const documentEl = document.querySelector(".studio-export-document") || document.body;
@@ -6331,32 +6364,56 @@ function buildStudioStandaloneHtmlMermaidScript(vars: Record<string, string>): s
6331
6364
  }
6332
6365
 
6333
6366
  function prepareMermaidBlocks() {
6334
- const preBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
6335
- preBlocks.forEach((preEl) => {
6336
- const source = preEl.querySelector("code") ? preEl.querySelector("code").textContent : preEl.textContent;
6367
+ return Array.from(document.querySelectorAll("pre.mermaid")).map((preEl) => {
6368
+ const codeEl = preEl.querySelector("code");
6369
+ const source = codeEl ? codeEl.textContent : preEl.textContent;
6337
6370
  const wrapper = document.createElement("div");
6338
6371
  wrapper.className = "mermaid-container";
6372
+ wrapper.dataset.mermaidSource = source || "";
6339
6373
  const diagramEl = document.createElement("div");
6340
6374
  diagramEl.className = "mermaid";
6341
6375
  diagramEl.textContent = source || "";
6342
6376
  wrapper.appendChild(diagramEl);
6343
6377
  preEl.replaceWith(wrapper);
6378
+ return wrapper;
6344
6379
  });
6345
- return Array.from(document.querySelectorAll(".mermaid"));
6346
6380
  }
6347
6381
 
6348
6382
  async function renderMermaid() {
6349
- const nodes = prepareMermaidBlocks();
6350
- if (nodes.length === 0) return;
6383
+ const wrappers = prepareMermaidBlocks();
6384
+ if (wrappers.length === 0) {
6385
+ setMermaidRenderResult("skipped");
6386
+ return;
6387
+ }
6388
+ setMermaidRenderResult("pending");
6351
6389
  try {
6352
- const module = await import(MERMAID_CDN_URL);
6390
+ if (!mermaidHelpers) throw new Error("Studio Mermaid helpers failed to load.");
6391
+ const module = await import(mermaidHelpers.MERMAID_CDN_URL);
6353
6392
  const mermaidApi = module && module.default ? module.default : null;
6354
6393
  if (!mermaidApi) throw new Error("Mermaid module did not expose a default export.");
6394
+ const iconRegistry = mermaidHelpers.createIconPackRegistry();
6395
+ iconRegistry.register(mermaidApi);
6355
6396
  mermaidApi.initialize(MERMAID_CONFIG);
6356
- await mermaidApi.run({ nodes });
6397
+ iconRegistry.clearError();
6398
+ await mermaidApi.run({ nodes: wrappers.map((wrapper) => wrapper.querySelector(".mermaid")) });
6399
+ const iconPackError = iconRegistry.getError();
6400
+ if (iconPackError) throw iconPackError;
6401
+ mermaidHelpers.applyAccessibleColors(document);
6402
+ setMermaidRenderResult("success");
6357
6403
  } catch (error) {
6404
+ if (mermaidHelpers) {
6405
+ try {
6406
+ mermaidHelpers.applyAccessibleColors(document);
6407
+ } catch (contrastError) {
6408
+ console.warn("Mermaid contrast correction failed after a partial render:", contrastError);
6409
+ }
6410
+ }
6411
+ const message = mermaidHelpers
6412
+ ? mermaidHelpers.renderFailures(wrappers, error)
6413
+ : (error instanceof Error ? error.message : String(error));
6414
+ setMermaidRenderResult("failed", message);
6358
6415
  console.error("Mermaid render failed:", error);
6359
- appendMermaidWarning("Mermaid renderer unavailable. Showing mermaid source text.");
6416
+ appendMermaidWarning("Mermaid render failed. Showing diagram source text.");
6360
6417
  }
6361
6418
  }
6362
6419
 
@@ -8445,7 +8502,7 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8445
8502
 
8446
8503
  if (msg.type === "pi_thinking_level_request" && typeof msg.level === "string") {
8447
8504
  const level = msg.level.trim().toLowerCase();
8448
- if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh") {
8505
+ if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max") {
8449
8506
  return {
8450
8507
  type: "pi_thinking_level_request",
8451
8508
  level,
@@ -10361,6 +10418,7 @@ function buildStudioHtml(
10361
10418
  const cssVarsBlock = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
10362
10419
  const stylesheetHref = `/studio.css?token=${encodeURIComponent(studioToken ?? "")}`;
10363
10420
  const annotationHelpersScriptHref = `/studio-annotation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10421
+ const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10364
10422
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
10365
10423
  const faviconHref = buildStudioFaviconDataUri(style);
10366
10424
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
@@ -10777,6 +10835,7 @@ ${cssVarsBlock}
10777
10835
  window.__PI_STUDIO_BOOT__ = ${bootConfigJson};
10778
10836
  </script>
10779
10837
  <script src="${annotationHelpersScriptHref}"></script>
10838
+ <script src="${mermaidHelpersScriptHref}"></script>
10780
10839
  <script src="${clientScriptHref}"></script>
10781
10840
  </body>
10782
10841
  </html>`;
@@ -11076,17 +11135,17 @@ export default function (pi: ExtensionAPI) {
11076
11135
  }
11077
11136
  };
11078
11137
 
11079
- const getThinkingLevelSafe = (): ModelThinkingLevel | undefined => {
11138
+ const getThinkingLevelSafe = (): StudioPiThinkingLevel | undefined => {
11080
11139
  try {
11081
- return pi.getThinkingLevel() as ModelThinkingLevel;
11140
+ return pi.getThinkingLevel() as StudioPiThinkingLevel;
11082
11141
  } catch {
11083
11142
  return undefined;
11084
11143
  }
11085
11144
  };
11086
11145
 
11087
- const setThinkingLevelSafe = (level: ModelThinkingLevel) => {
11088
- // Pi's CLI/model config support "off" as a thinking level; some extension API typings still expose the narrower reasoning-only type.
11089
- (pi.setThinkingLevel as (nextLevel: ModelThinkingLevel) => void)(level);
11146
+ const setThinkingLevelSafe = (level: StudioPiThinkingLevel) => {
11147
+ // Pi's CLI/model config support "off" and newer levels such as "max"; some extension API typings still expose a narrower reasoning-only type.
11148
+ (pi.setThinkingLevel as (nextLevel: StudioPiThinkingLevel) => void)(level);
11090
11149
  };
11091
11150
 
11092
11151
  const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined }) => {
@@ -13961,7 +14020,7 @@ export default function (pi: ExtensionAPI) {
13961
14020
  return;
13962
14021
  }
13963
14022
 
13964
- if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-client.js") {
14023
+ if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-mermaid-helpers.js" || requestUrl.pathname === "/studio-client.js") {
13965
14024
  const token = requestUrl.searchParams.get("token") ?? "";
13966
14025
  if (token !== serverState.token) {
13967
14026
  respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
@@ -13977,10 +14036,14 @@ export default function (pi: ExtensionAPI) {
13977
14036
 
13978
14037
  const targetUrl = requestUrl.pathname === "/studio-annotation-helpers.js"
13979
14038
  ? STUDIO_ANNOTATION_HELPERS_URL
13980
- : STUDIO_CLIENT_URL;
14039
+ : requestUrl.pathname === "/studio-mermaid-helpers.js"
14040
+ ? STUDIO_MERMAID_HELPERS_URL
14041
+ : STUDIO_CLIENT_URL;
13981
14042
  const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
13982
14043
  ? "studio annotation helper script"
13983
- : "studio client script";
14044
+ : requestUrl.pathname === "/studio-mermaid-helpers.js"
14045
+ ? "studio Mermaid helper script"
14046
+ : "studio client script";
13984
14047
 
13985
14048
  try {
13986
14049
  const clientScript = readFileSync(targetUrl, "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.35",
3
+ "version": "0.9.37",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,8 +50,12 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@earendil-works/pi-coding-agent": "^0.74.0",
53
+ "@iconify-json/logos": "1.2.11",
54
+ "@iconify-json/lucide": "1.2.120",
53
55
  "@types/node": "^24.3.0",
54
56
  "@types/ws": "^8.18.1",
57
+ "mermaid": "11.16.0",
58
+ "puppeteer-core": "24.22.3",
55
59
  "typescript": "^5.7.3"
56
60
  }
57
61
  }
@@ -0,0 +1,254 @@
1
+ export const STUDIO_MERMAID_ICON_PACKS = Object.freeze([
2
+ Object.freeze({
3
+ name: "lucide",
4
+ packageName: "@iconify-json/lucide",
5
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/lucide@1.2.120/icons.json",
6
+ }),
7
+ Object.freeze({
8
+ name: "logos",
9
+ packageName: "@iconify-json/logos",
10
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/logos@1.2.11/icons.json",
11
+ }),
12
+ ]);
13
+
14
+ export function usesSupportedStudioMermaidIconPack(source) {
15
+ return /@\{(?:(?!\}).)*\bicon\s*:\s*["'](?:lucide|logos):[^"']+["'](?:(?!\}).)*\}/is.test(String(source || ""));
16
+ }
17
+
18
+ export function buildStudioMermaidCliIconArgs(source) {
19
+ return usesSupportedStudioMermaidIconPack(source)
20
+ ? [
21
+ "--iconPacksNamesAndUrls",
22
+ ...STUDIO_MERMAID_ICON_PACKS.map((pack) => `${pack.name}#${pack.url}`),
23
+ ]
24
+ : [];
25
+ }
26
+
27
+ function parseStudioMermaidCssColor(value) {
28
+ const source = String(value || "").trim().replace(/\s*!important\s*$/i, "");
29
+ const hex = source.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
30
+ if (hex) {
31
+ const digits = hex[1].length === 3
32
+ ? hex[1].split("").map((digit) => digit + digit).join("")
33
+ : hex[1];
34
+ return [
35
+ Number.parseInt(digits.slice(0, 2), 16),
36
+ Number.parseInt(digits.slice(2, 4), 16),
37
+ Number.parseInt(digits.slice(4, 6), 16),
38
+ ];
39
+ }
40
+ const rgb = source.match(/^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i);
41
+ return rgb
42
+ ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])].map((channel) => Math.max(0, Math.min(255, channel)))
43
+ : null;
44
+ }
45
+
46
+ function studioMermaidRelativeLuminance(color) {
47
+ const linear = color.map((channel) => {
48
+ const value = channel / 255;
49
+ return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
50
+ });
51
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
52
+ }
53
+
54
+ function studioMermaidContrastRatio(foreground, background) {
55
+ const lighter = Math.max(studioMermaidRelativeLuminance(foreground), studioMermaidRelativeLuminance(background));
56
+ const darker = Math.min(studioMermaidRelativeLuminance(foreground), studioMermaidRelativeLuminance(background));
57
+ return (lighter + 0.05) / (darker + 0.05);
58
+ }
59
+
60
+ function formatStudioMermaidHexColor(color) {
61
+ return `#${color.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
62
+ }
63
+
64
+ function ensureStudioMermaidReadableColor(foreground, background) {
65
+ if (studioMermaidContrastRatio(foreground, background) >= 4.5) return foreground;
66
+ const candidates = [[0, 0, 0], [255, 255, 255]].flatMap((target) => {
67
+ for (let step = 1; step <= 20; step += 1) {
68
+ const amount = step / 20;
69
+ const color = foreground.map((channel, index) => channel + (target[index] - channel) * amount);
70
+ if (studioMermaidContrastRatio(color, background) >= 4.5) return [{ amount, color }];
71
+ }
72
+ return [];
73
+ });
74
+ candidates.sort((left, right) => left.amount - right.amount);
75
+ if (candidates.length > 0) return candidates[0].color;
76
+ const black = [0, 0, 0];
77
+ const white = [255, 255, 255];
78
+ return studioMermaidContrastRatio(black, background) >= studioMermaidContrastRatio(white, background)
79
+ ? black
80
+ : white;
81
+ }
82
+
83
+ function splitStudioMermaidStyleDeclarations(source) {
84
+ source = String(source || "");
85
+ const declarations = [];
86
+ let start = 0;
87
+ let depth = 0;
88
+ for (let index = 0; index < source.length; index += 1) {
89
+ const char = source[index];
90
+ if (char === "(") depth += 1;
91
+ if (char === ")" && depth > 0) depth -= 1;
92
+ if ((char === "," || char === ";") && depth === 0) {
93
+ const declaration = source.slice(start, index).trim();
94
+ if (declaration) declarations.push(declaration);
95
+ start = index + 1;
96
+ }
97
+ }
98
+ const tail = source.slice(start).trim();
99
+ if (tail) declarations.push(tail);
100
+ return declarations;
101
+ }
102
+
103
+ function getStudioMermaidStyleDeclaration(declarations, property) {
104
+ const propertyLower = property.toLowerCase();
105
+ return declarations.find((declaration) => {
106
+ const separator = declaration.indexOf(":");
107
+ return separator >= 0 && declaration.slice(0, separator).trim().toLowerCase() === propertyLower;
108
+ }) || null;
109
+ }
110
+
111
+ function getStudioMermaidStyleValue(declaration) {
112
+ if (!declaration) return "";
113
+ const separator = declaration.indexOf(":");
114
+ return separator >= 0 ? declaration.slice(separator + 1).trim() : "";
115
+ }
116
+
117
+ function upsertStudioMermaidStyleDeclaration(declarations, property, value) {
118
+ const existing = getStudioMermaidStyleDeclaration(declarations, property);
119
+ const corrected = `${property}:${value}`;
120
+ if (existing) {
121
+ declarations[declarations.indexOf(existing)] = corrected;
122
+ } else {
123
+ declarations.push(corrected);
124
+ }
125
+ }
126
+
127
+ function correctStudioMermaidStyleContrast(styleSource, options = {}) {
128
+ const hadSemicolon = /;\s*$/.test(styleSource);
129
+ const declarations = splitStudioMermaidStyleDeclarations(styleSource);
130
+ const fillDeclaration = getStudioMermaidStyleDeclaration(declarations, "fill");
131
+ const background = parseStudioMermaidCssColor(getStudioMermaidStyleValue(fillDeclaration));
132
+ if (!background) return styleSource;
133
+
134
+ const colorDeclaration = getStudioMermaidStyleDeclaration(declarations, "color");
135
+ const authoredForeground = colorDeclaration
136
+ ? parseStudioMermaidCssColor(getStudioMermaidStyleValue(colorDeclaration))
137
+ : null;
138
+ if (colorDeclaration && !authoredForeground) return styleSource;
139
+
140
+ const fallbackForeground = studioMermaidContrastRatio([0, 0, 0], background) >= studioMermaidContrastRatio([255, 255, 255], background)
141
+ ? [0, 0, 0]
142
+ : [255, 255, 255];
143
+ const readable = ensureStudioMermaidReadableColor(authoredForeground || fallbackForeground, background);
144
+ upsertStudioMermaidStyleDeclaration(declarations, "color", formatStudioMermaidHexColor(readable));
145
+
146
+ if (options.correctIconStroke) {
147
+ const strokeDeclaration = getStudioMermaidStyleDeclaration(declarations, "stroke");
148
+ const authoredStroke = parseStudioMermaidCssColor(getStudioMermaidStyleValue(strokeDeclaration));
149
+ if (!strokeDeclaration || authoredStroke) {
150
+ const readableStroke = ensureStudioMermaidReadableColor(authoredStroke || readable, background);
151
+ upsertStudioMermaidStyleDeclaration(declarations, "stroke", formatStudioMermaidHexColor(readableStroke));
152
+ }
153
+ }
154
+
155
+ return declarations.join(",") + (hadSemicolon ? ";" : "");
156
+ }
157
+
158
+ function collectStudioMermaidIconStyleTargets(source) {
159
+ const iconNodeIds = new Set();
160
+ const iconNodePattern = /\b([A-Za-z_][\w-]*)\s*@\{(?:(?!\}).)*\bicon\s*:\s*["'](?:lucide|logos):[^"']+["'](?:(?!\}).)*\}/gis;
161
+ let iconNodeMatch;
162
+ while ((iconNodeMatch = iconNodePattern.exec(source)) !== null) {
163
+ iconNodeIds.add(iconNodeMatch[1]);
164
+ }
165
+
166
+ const iconClassNames = new Set();
167
+ const classNamesByNode = new Map();
168
+ for (const line of source.split(/\r?\n/)) {
169
+ const classMatch = line.match(/^\s*class\s+(\S+)\s+(\S+)\s*;?\s*$/i);
170
+ if (!classMatch) continue;
171
+ const nodeIds = classMatch[1].split(",").map((value) => value.trim()).filter(Boolean);
172
+ const classNames = classMatch[2].replace(/;$/, "").split(",").map((value) => value.trim()).filter(Boolean);
173
+ nodeIds.forEach((nodeId) => {
174
+ const assigned = classNamesByNode.get(nodeId) || [];
175
+ classNamesByNode.set(nodeId, [...assigned, ...classNames]);
176
+ });
177
+ if (!nodeIds.some((nodeId) => iconNodeIds.has(nodeId))) continue;
178
+ classNames.forEach((className) => {
179
+ iconClassNames.add(className);
180
+ });
181
+ }
182
+ return { iconNodeIds, iconClassNames, classNamesByNode };
183
+ }
184
+
185
+ export function ensureStudioMermaidSourceContrast(source) {
186
+ const text = String(source || "");
187
+ const { iconNodeIds, iconClassNames } = collectStudioMermaidIconStyleTargets(text);
188
+ return text
189
+ .split(/(\r?\n)/)
190
+ .map((part) => {
191
+ if (/^\r?\n$/.test(part)) return part;
192
+ const match = part.match(/^(\s*)(classDef|style)\s+(\S+)\s+(.*?)(\s*)$/i);
193
+ if (!match) return part;
194
+ const targets = match[3].replace(/;$/, "").split(",").map((value) => value.trim()).filter(Boolean);
195
+ const correctIconStroke = match[2].toLowerCase() === "classdef"
196
+ ? targets.some((target) => iconClassNames.has(target))
197
+ : targets.some((target) => iconNodeIds.has(target));
198
+ const correctedStyles = correctStudioMermaidStyleContrast(match[4], { correctIconStroke });
199
+ return `${match[1]}${match[2]} ${match[3]} ${correctedStyles}${match[5]}`;
200
+ })
201
+ .join("");
202
+ }
203
+
204
+ function collectStudioMermaidStylesByTarget(source, directiveName) {
205
+ const stylesByTarget = new Map();
206
+ for (const line of source.split(/\r?\n/)) {
207
+ const match = line.match(/^\s*(classDef|style)\s+(\S+)\s+(.*?)\s*$/i);
208
+ if (!match || match[1].toLowerCase() !== directiveName.toLowerCase()) continue;
209
+ const targets = match[2].replace(/;$/, "").split(",").map((value) => value.trim()).filter(Boolean);
210
+ targets.forEach((target) => stylesByTarget.set(target, match[3]));
211
+ }
212
+ return stylesByTarget;
213
+ }
214
+
215
+ function getStudioMermaidIconPaintColor(styleSource) {
216
+ const declarations = splitStudioMermaidStyleDeclarations(styleSource);
217
+ const fill = parseStudioMermaidCssColor(getStudioMermaidStyleValue(getStudioMermaidStyleDeclaration(declarations, "fill")));
218
+ if (!fill) return null;
219
+ const stroke = parseStudioMermaidCssColor(getStudioMermaidStyleValue(getStudioMermaidStyleDeclaration(declarations, "stroke")));
220
+ const color = parseStudioMermaidCssColor(getStudioMermaidStyleValue(getStudioMermaidStyleDeclaration(declarations, "color")));
221
+ return stroke || color ? formatStudioMermaidHexColor(stroke || color) : null;
222
+ }
223
+
224
+ function buildStudioMermaidIconPaintRule(selector, color) {
225
+ const paintedElements = ["g", "path", "rect", "polygon", "circle", "ellipse", "line", "polyline"];
226
+ const fillSelectors = paintedElements.map((element) => `${selector} svg ${element}[fill]:not([fill="none"])`).join(",\n");
227
+ const strokeSelectors = paintedElements.map((element) => `${selector} svg ${element}[stroke]:not([stroke="none"])`).join(",\n");
228
+ return [
229
+ `${selector} svg { color: ${color} !important; }`,
230
+ `${fillSelectors} { fill: ${color} !important; }`,
231
+ `${strokeSelectors} { stroke: ${color} !important; }`,
232
+ ].join("\n");
233
+ }
234
+
235
+ export function buildStudioMermaidPdfIconContrastCss(source) {
236
+ const preparedSource = ensureStudioMermaidSourceContrast(source);
237
+ const { iconNodeIds, classNamesByNode } = collectStudioMermaidIconStyleTargets(preparedSource);
238
+ if (iconNodeIds.size === 0) return "";
239
+ const classStyles = collectStudioMermaidStylesByTarget(preparedSource, "classDef");
240
+ const directStyles = collectStudioMermaidStylesByTarget(preparedSource, "style");
241
+ const rulesBySelector = new Map();
242
+
243
+ for (const iconNodeId of iconNodeIds) {
244
+ const classNames = classNamesByNode.get(iconNodeId) || [];
245
+ const classPaint = [...classNames].reverse()
246
+ .map((className) => getStudioMermaidIconPaintColor(classStyles.get(className)))
247
+ .find(Boolean);
248
+ const directPaint = getStudioMermaidIconPaintColor(directStyles.get(iconNodeId));
249
+ const paint = directPaint || classPaint;
250
+ if (paint) rulesBySelector.set(`.icon-shape[id*="flowchart-${iconNodeId}-"]`, paint);
251
+ }
252
+
253
+ return Array.from(rulesBySelector, ([selector, color]) => buildStudioMermaidIconPaintRule(selector, color)).join("\n");
254
+ }