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 +19 -0
- package/README.md +26 -2
- package/client/studio-client.js +30 -9
- package/client/studio-mermaid-helpers.js +267 -0
- package/client/studio.css +12 -0
- package/index.ts +85 -22
- package/package.json +5 -1
- package/shared/studio-mermaid.js +254 -0
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
|
|
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
|
|
package/client/studio-client.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
8320
|
-
} catch (
|
|
8321
|
-
console.
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
6335
|
-
|
|
6336
|
-
const source =
|
|
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
|
|
6350
|
-
if (
|
|
6383
|
+
const wrappers = prepareMermaidBlocks();
|
|
6384
|
+
if (wrappers.length === 0) {
|
|
6385
|
+
setMermaidRenderResult("skipped");
|
|
6386
|
+
return;
|
|
6387
|
+
}
|
|
6388
|
+
setMermaidRenderResult("pending");
|
|
6351
6389
|
try {
|
|
6352
|
-
|
|
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
|
-
|
|
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
|
|
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 = ():
|
|
11138
|
+
const getThinkingLevelSafe = (): StudioPiThinkingLevel | undefined => {
|
|
11080
11139
|
try {
|
|
11081
|
-
return pi.getThinkingLevel() as
|
|
11140
|
+
return pi.getThinkingLevel() as StudioPiThinkingLevel;
|
|
11082
11141
|
} catch {
|
|
11083
11142
|
return undefined;
|
|
11084
11143
|
}
|
|
11085
11144
|
};
|
|
11086
11145
|
|
|
11087
|
-
const setThinkingLevelSafe = (level:
|
|
11088
|
-
// Pi's CLI/model config support "off"
|
|
11089
|
-
(pi.setThinkingLevel as (nextLevel:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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.
|
|
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
|
+
}
|