pi-studio 0.8.0 → 0.8.2

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,16 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.2] — 2026-05-13
8
+
9
+ ### Changed
10
+ - Improved dark-theme contrast for Studio dropdown arrows and the HTML artifact zoom percentage by using the stronger Studio info text colour token.
11
+
12
+ ## [0.8.1] — 2026-05-13
13
+
14
+ ### Added
15
+ - Added first-cut HTML artifact preview for straight, unfenced HTML in Studio preview panes, rendered in a sandboxed iframe with inline scripts enabled, network requests blocked by CSP, fit/capped sizing, and toolbar zoom controls; HTML export preserves authored HTML artifacts instead of converting them through Markdown.
16
+
7
17
  ## [0.8.0] — 2026-05-12
8
18
 
9
19
  ### Added
package/README.md CHANGED
@@ -35,9 +35,10 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
35
35
  - strips markers before send (optional)
36
36
  - saves `.annotated.md`
37
37
  - Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi
38
+ - Renders straight, unfenced HTML artifacts in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
38
39
  - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks
39
40
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
40
- - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML
41
+ - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML, preserving authored HTML artifacts as HTML
41
42
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file
42
43
  - Shows model/session/context usage in the footer, plus a compact-context action
43
44
 
@@ -760,6 +760,7 @@
760
760
  const PREVIEW_INPUT_DEBOUNCE_MS = 0;
761
761
  const PREVIEW_PENDING_BADGE_DELAY_MS = 220;
762
762
  const previewPendingTimers = new WeakMap();
763
+ const htmlArtifactFramesById = new Map();
763
764
  let sourcePreviewRenderTimer = null;
764
765
  let sourcePreviewRenderNonce = 0;
765
766
  let responsePreviewRenderNonce = 0;
@@ -2550,6 +2551,283 @@
2550
2551
  return "<div class='preview-error'>" + escapeHtml(String(message || "Preview rendering failed.")) + "</div>" + buildPlainMarkdownHtml(markdown, options);
2551
2552
  }
2552
2553
 
2554
+ function stripLeadingHtmlPreviewTrivia(text) {
2555
+ let source = String(text || "").replace(/^\uFEFF/, "").trimStart();
2556
+ let previous = "";
2557
+ while (source && source !== previous) {
2558
+ previous = source;
2559
+ source = source.replace(/^<!--[\s\S]*?-->\s*/, "").trimStart();
2560
+ }
2561
+ return source;
2562
+ }
2563
+
2564
+ function startsWithSingleFencedBlock(text) {
2565
+ const source = String(text || "").trimStart();
2566
+ return /^(`{3,}|~{3,})/.test(source);
2567
+ }
2568
+
2569
+ function isLikelyFullHtmlDocument(text) {
2570
+ const source = stripLeadingHtmlPreviewTrivia(text);
2571
+ if (!source) return false;
2572
+ if (/^<!doctype\s+html\b/i.test(source)) return true;
2573
+ if (/^<html(?:\s|>|$)/i.test(source)) return true;
2574
+ if (/^<body(?:\s|>|$)/i.test(source) && /<\/body\s*>/i.test(source)) return true;
2575
+ return false;
2576
+ }
2577
+
2578
+ function looksLikeHtmlMarkup(text) {
2579
+ return /<[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?>/.test(String(text || ""));
2580
+ }
2581
+
2582
+ function isHtmlArtifactPreviewText(text, language) {
2583
+ const source = String(text || "");
2584
+ if (!source.trim()) return false;
2585
+ if (startsWithSingleFencedBlock(source)) return false;
2586
+ if (isLikelyFullHtmlDocument(source)) return true;
2587
+ return normalizeFenceLanguage(language || "") === "html" && looksLikeHtmlMarkup(source);
2588
+ }
2589
+
2590
+ const HTML_ARTIFACT_PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' data: blob:; style-src 'unsafe-inline' data: blob:; img-src data: blob:; font-src data: blob:; connect-src 'none'; media-src data: blob:; object-src 'none'; frame-src data: blob:; child-src data: blob:; worker-src blob:; form-action 'none'; base-uri 'none'; navigate-to 'none'";
2591
+ const HTML_ARTIFACT_FRAME_MIN_HEIGHT = 360;
2592
+ const HTML_ARTIFACT_FRAME_FIT_CAP_HEIGHT = 1800;
2593
+ const HTML_ARTIFACT_ZOOM_MIN = 0.5;
2594
+ const HTML_ARTIFACT_ZOOM_MAX = 1.75;
2595
+ const HTML_ARTIFACT_ZOOM_STEP = 0.1;
2596
+
2597
+ function buildHtmlArtifactPreviewResizeScript(previewId) {
2598
+ const idJson = JSON.stringify(String(previewId || ""));
2599
+ return "<script>\n"
2600
+ + "(() => {\n"
2601
+ + " const PREVIEW_ID = " + idJson.replace(/<\//g, "<\\/") + ";\n"
2602
+ + " let lastHeight = 0;\n"
2603
+ + " let scheduled = false;\n"
2604
+ + " let currentZoom = 1;\n"
2605
+ + " function applyZoom(value) {\n"
2606
+ + " const next = Number(value);\n"
2607
+ + " if (!Number.isFinite(next) || next <= 0) return;\n"
2608
+ + " currentZoom = Math.max(0.25, Math.min(4, next));\n"
2609
+ + " document.documentElement.style.zoom = String(currentZoom);\n"
2610
+ + " lastHeight = 0;\n"
2611
+ + " scheduleHeight();\n"
2612
+ + " }\n"
2613
+ + " function measureHeight() {\n"
2614
+ + " const body = document.body;\n"
2615
+ + " const root = document.documentElement;\n"
2616
+ + " return Math.ceil(Math.max(\n"
2617
+ + " body ? body.scrollHeight : 0,\n"
2618
+ + " body ? body.offsetHeight : 0,\n"
2619
+ + " root ? root.scrollHeight : 0,\n"
2620
+ + " root ? root.offsetHeight : 0\n"
2621
+ + " ));\n"
2622
+ + " }\n"
2623
+ + " function sendHeight() {\n"
2624
+ + " scheduled = false;\n"
2625
+ + " const height = measureHeight();\n"
2626
+ + " if (!height || Math.abs(height - lastHeight) < 2) return;\n"
2627
+ + " lastHeight = height;\n"
2628
+ + " try { parent.postMessage({ type: 'pi-studio-html-artifact-size', id: PREVIEW_ID, height }, '*'); } catch {}\n"
2629
+ + " }\n"
2630
+ + " function scheduleHeight() {\n"
2631
+ + " if (scheduled) return;\n"
2632
+ + " scheduled = true;\n"
2633
+ + " requestAnimationFrame(sendHeight);\n"
2634
+ + " }\n"
2635
+ + " window.addEventListener('message', (event) => {\n"
2636
+ + " const data = event && event.data;\n"
2637
+ + " if (!data || typeof data !== 'object') return;\n"
2638
+ + " if (data.type !== 'pi-studio-html-artifact-zoom' || data.id !== PREVIEW_ID) return;\n"
2639
+ + " applyZoom(data.zoom);\n"
2640
+ + " });\n"
2641
+ + " window.addEventListener('load', scheduleHeight);\n"
2642
+ + " window.addEventListener('resize', scheduleHeight);\n"
2643
+ + " if (typeof ResizeObserver === 'function') {\n"
2644
+ + " const observer = new ResizeObserver(scheduleHeight);\n"
2645
+ + " observer.observe(document.documentElement);\n"
2646
+ + " if (document.body) observer.observe(document.body);\n"
2647
+ + " }\n"
2648
+ + " if (typeof MutationObserver === 'function') {\n"
2649
+ + " const observer = new MutationObserver(scheduleHeight);\n"
2650
+ + " observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });\n"
2651
+ + " }\n"
2652
+ + " scheduleHeight();\n"
2653
+ + " setTimeout(scheduleHeight, 80);\n"
2654
+ + " setTimeout(scheduleHeight, 350);\n"
2655
+ + "})();\n"
2656
+ + "<\/script>";
2657
+ }
2658
+
2659
+ function buildHtmlArtifactPreviewHeadMarkup(previewId) {
2660
+ return "<meta charset=\"utf-8\">\n"
2661
+ + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
2662
+ + "<meta http-equiv=\"Content-Security-Policy\" content=\"" + escapeHtml(HTML_ARTIFACT_PREVIEW_CSP) + "\">\n"
2663
+ + buildHtmlArtifactPreviewResizeScript(previewId);
2664
+ }
2665
+
2666
+ function buildHtmlArtifactSrcdoc(html, previewId) {
2667
+ const source = String(html || "");
2668
+ const headMarkup = buildHtmlArtifactPreviewHeadMarkup(previewId);
2669
+ if (/<head\b[^>]*>/i.test(source)) {
2670
+ return source.replace(/<head\b[^>]*>/i, (match) => match + "\n" + headMarkup + "\n");
2671
+ }
2672
+ if (/<body\b[^>]*>/i.test(source)) {
2673
+ return source.replace(/<body\b/i, "<head>\n" + headMarkup + "\n</head>\n<body");
2674
+ }
2675
+ if (/<html\b[^>]*>/i.test(source)) {
2676
+ return source.replace(/<html\b[^>]*>/i, (match) => match + "\n<head>\n" + headMarkup + "\n</head>\n");
2677
+ }
2678
+ return "<!doctype html>\n<html>\n<head>\n" + headMarkup + "\n</head>\n<body>\n" + source + "\n</body>\n</html>";
2679
+ }
2680
+
2681
+ function pruneDisconnectedHtmlArtifactFrames() {
2682
+ htmlArtifactFramesById.forEach((record, id) => {
2683
+ if (!record || !record.iframe || !record.iframe.isConnected) {
2684
+ htmlArtifactFramesById.delete(id);
2685
+ }
2686
+ });
2687
+ }
2688
+
2689
+ function handleHtmlArtifactFrameSizeMessage(event) {
2690
+ const data = event && event.data;
2691
+ if (!data || typeof data !== "object" || data.type !== "pi-studio-html-artifact-size") return;
2692
+ const id = typeof data.id === "string" ? data.id : "";
2693
+ const record = id ? htmlArtifactFramesById.get(id) : null;
2694
+ if (!record || !record.iframe || !record.iframe.isConnected) {
2695
+ if (id) htmlArtifactFramesById.delete(id);
2696
+ return;
2697
+ }
2698
+ if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
2699
+ const rawHeight = Number(data.height);
2700
+ if (!Number.isFinite(rawHeight) || rawHeight <= 0) return;
2701
+ const measuredHeight = Math.ceil(rawHeight + 2);
2702
+ const capped = measuredHeight > HTML_ARTIFACT_FRAME_FIT_CAP_HEIGHT;
2703
+ const nextHeight = Math.max(
2704
+ HTML_ARTIFACT_FRAME_MIN_HEIGHT,
2705
+ Math.min(HTML_ARTIFACT_FRAME_FIT_CAP_HEIGHT, measuredHeight),
2706
+ );
2707
+ record.iframe.style.height = nextHeight + "px";
2708
+ record.iframe.classList.toggle("is-height-capped", capped);
2709
+ if (record.shell && record.shell.style) {
2710
+ record.shell.style.minHeight = "0";
2711
+ record.shell.classList.toggle("is-height-capped", capped);
2712
+ }
2713
+ if (record.detail) {
2714
+ record.detail.textContent = "HTML artifact";
2715
+ }
2716
+ }
2717
+
2718
+ window.addEventListener("message", handleHtmlArtifactFrameSizeMessage);
2719
+
2720
+ function renderHtmlArtifactPreview(targetEl, html, pane, options) {
2721
+ if (!targetEl) return;
2722
+ const title = options && options.title ? String(options.title) : "HTML artifact preview";
2723
+ const previewId = "html_artifact_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 10);
2724
+ pruneDisconnectedHtmlArtifactFrames();
2725
+ clearPreviewJumpHighlight(targetEl);
2726
+ finishPreviewRender(targetEl);
2727
+ targetEl.innerHTML = "";
2728
+
2729
+ const shell = document.createElement("div");
2730
+ shell.className = "studio-html-artifact-shell";
2731
+
2732
+ const toolbar = document.createElement("div");
2733
+ toolbar.className = "studio-html-artifact-toolbar";
2734
+ const label = document.createElement("span");
2735
+ label.className = "studio-html-artifact-label";
2736
+ label.textContent = title;
2737
+ const detail = document.createElement("span");
2738
+ detail.className = "studio-html-artifact-detail";
2739
+ detail.textContent = "HTML artifact";
2740
+
2741
+ const tools = document.createElement("span");
2742
+ tools.className = "studio-html-artifact-tools";
2743
+ tools.appendChild(detail);
2744
+
2745
+ const zoomControls = document.createElement("span");
2746
+ zoomControls.className = "studio-html-artifact-zoom-controls";
2747
+ let artifactZoom = 1;
2748
+ let iframe = null;
2749
+ const formatZoomLabel = () => Math.round(artifactZoom * 100) + "%";
2750
+ const postArtifactZoom = () => {
2751
+ if (!iframe || !iframe.contentWindow) return;
2752
+ try {
2753
+ iframe.contentWindow.postMessage({ type: "pi-studio-html-artifact-zoom", id: previewId, zoom: artifactZoom }, "*");
2754
+ } catch {
2755
+ // Ignore iframe postMessage failures.
2756
+ }
2757
+ };
2758
+ const updateZoomUi = () => {
2759
+ zoomResetBtn.textContent = formatZoomLabel();
2760
+ zoomOutBtn.disabled = artifactZoom <= HTML_ARTIFACT_ZOOM_MIN + 0.001;
2761
+ zoomInBtn.disabled = artifactZoom >= HTML_ARTIFACT_ZOOM_MAX - 0.001;
2762
+ };
2763
+ const setArtifactZoom = (nextZoom) => {
2764
+ artifactZoom = Math.max(
2765
+ HTML_ARTIFACT_ZOOM_MIN,
2766
+ Math.min(HTML_ARTIFACT_ZOOM_MAX, Math.round(Number(nextZoom || 1) * 100) / 100),
2767
+ );
2768
+ updateZoomUi();
2769
+ postArtifactZoom();
2770
+ };
2771
+ const makeZoomButton = (text, title, onClick) => {
2772
+ const button = document.createElement("button");
2773
+ button.type = "button";
2774
+ button.className = "studio-html-artifact-zoom-btn";
2775
+ button.textContent = text;
2776
+ button.title = title;
2777
+ button.addEventListener("pointerdown", (event) => { event.stopPropagation(); });
2778
+ button.addEventListener("mousedown", (event) => { event.stopPropagation(); });
2779
+ button.addEventListener("click", (event) => {
2780
+ event.preventDefault();
2781
+ event.stopPropagation();
2782
+ onClick();
2783
+ });
2784
+ return button;
2785
+ };
2786
+ const zoomOutBtn = makeZoomButton("−", "Zoom out HTML artifact", () => setArtifactZoom(artifactZoom - HTML_ARTIFACT_ZOOM_STEP));
2787
+ const zoomResetBtn = makeZoomButton("100%", "Reset HTML artifact zoom", () => setArtifactZoom(1));
2788
+ zoomResetBtn.classList.add("studio-html-artifact-zoom-reset");
2789
+ const zoomInBtn = makeZoomButton("+", "Zoom in HTML artifact", () => setArtifactZoom(artifactZoom + HTML_ARTIFACT_ZOOM_STEP));
2790
+ zoomControls.appendChild(zoomOutBtn);
2791
+ zoomControls.appendChild(zoomResetBtn);
2792
+ zoomControls.appendChild(zoomInBtn);
2793
+ updateZoomUi();
2794
+ tools.appendChild(zoomControls);
2795
+
2796
+ toolbar.appendChild(label);
2797
+ toolbar.appendChild(tools);
2798
+ shell.appendChild(toolbar);
2799
+
2800
+ iframe = document.createElement("iframe");
2801
+ iframe.className = "studio-html-artifact-frame";
2802
+ iframe.title = title;
2803
+ iframe.loading = "lazy";
2804
+ iframe.referrerPolicy = "no-referrer";
2805
+ iframe.setAttribute("sandbox", "allow-scripts allow-modals");
2806
+ iframe.setAttribute("allow", "clipboard-write");
2807
+ iframe.addEventListener("load", () => { postArtifactZoom(); });
2808
+ iframe.srcdoc = buildHtmlArtifactSrcdoc(html, previewId);
2809
+ shell.appendChild(iframe);
2810
+ htmlArtifactFramesById.set(previewId, { iframe, shell, detail, zoomControls });
2811
+
2812
+ targetEl.appendChild(shell);
2813
+
2814
+ if (pane === "response") {
2815
+ applyPendingResponseScrollReset();
2816
+ scheduleResponsePaneRepaintNudge();
2817
+ }
2818
+ }
2819
+
2820
+ function getRightPaneHtmlArtifactSource() {
2821
+ if (rightView === "editor-preview") {
2822
+ const editorText = prepareEditorTextForPreview(sourceTextEl.value || "");
2823
+ return isHtmlArtifactPreviewText(editorText, editorLanguage) ? editorText : "";
2824
+ }
2825
+ if (rightView === "preview") {
2826
+ return isHtmlArtifactPreviewText(latestResponseMarkdown, "") ? latestResponseMarkdown : "";
2827
+ }
2828
+ return "";
2829
+ }
2830
+
2553
2831
  function stripMatchingQuotes(value) {
2554
2832
  const text = String(value || "").trim();
2555
2833
  if (text.length >= 2) {
@@ -3602,6 +3880,12 @@
3602
3880
  return;
3603
3881
  }
3604
3882
 
3883
+ const htmlArtifactSource = getRightPaneHtmlArtifactSource();
3884
+ if (htmlArtifactSource) {
3885
+ setStatus("PDF export does not support HTML artifacts yet. Export as HTML or use the browser print dialog inside the artifact.", "warning");
3886
+ return;
3887
+ }
3888
+
3605
3889
  const markdown = rightView === "editor-preview"
3606
3890
  ? prepareEditorTextForPdfExport(sourceTextEl.value)
3607
3891
  : prepareEditorTextForPreview(latestResponseMarkdown);
@@ -3761,9 +4045,10 @@
3761
4045
  return;
3762
4046
  }
3763
4047
 
3764
- const markdown = rightView === "editor-preview"
4048
+ const htmlArtifactSource = getRightPaneHtmlArtifactSource();
4049
+ const markdown = htmlArtifactSource || (rightView === "editor-preview"
3765
4050
  ? prepareEditorTextForHtmlExport(sourceTextEl.value)
3766
- : prepareEditorTextForPreview(latestResponseMarkdown);
4051
+ : prepareEditorTextForPreview(latestResponseMarkdown));
3767
4052
  if (!markdown || !markdown.trim()) {
3768
4053
  setStatus("Nothing to export yet.", "warning");
3769
4054
  return;
@@ -3773,10 +4058,10 @@
3773
4058
  const sourcePath = effectivePath || sourceState.path || "";
3774
4059
  const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
3775
4060
  const isEditorPreview = rightView === "editor-preview";
3776
- const editorHtmlLanguage = isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "";
3777
- const isLatex = isEditorPreview
4061
+ const editorHtmlLanguage = htmlArtifactSource ? "html" : (isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "");
4062
+ const isLatex = htmlArtifactSource ? false : (isEditorPreview
3778
4063
  ? editorHtmlLanguage === "latex"
3779
- : /\\documentclass\b|\\begin\{document\}/.test(markdown);
4064
+ : /\\documentclass\b|\\begin\{document\}/.test(markdown));
3780
4065
  let filenameHint = isEditorPreview ? "studio-editor-preview.html" : "studio-response-preview.html";
3781
4066
  let titleHint = isEditorPreview ? "Studio editor preview" : "Studio response preview";
3782
4067
  if (sourcePath) {
@@ -4114,6 +4399,10 @@
4114
4399
  function renderSourcePreviewNow() {
4115
4400
  if (editorView !== "preview") return;
4116
4401
  const text = prepareEditorTextForPreview(sourceTextEl.value || "");
4402
+ if (isHtmlArtifactPreviewText(text, editorLanguage)) {
4403
+ renderHtmlArtifactPreview(sourcePreviewEl, text, "source", { title: "Editor HTML artifact preview" });
4404
+ return;
4405
+ }
4117
4406
  if (supportsCodePreviewCommentsForCurrentEditor()) {
4118
4407
  renderCodePreviewWithCommentBlocks(sourcePreviewEl, text, "source");
4119
4408
  return;
@@ -4382,6 +4671,10 @@
4382
4671
  scheduleResponsePaneRepaintNudge();
4383
4672
  return;
4384
4673
  }
4674
+ if (isHtmlArtifactPreviewText(editorText, editorLanguage)) {
4675
+ renderHtmlArtifactPreview(critiqueViewEl, editorText, "response", { title: "Editor HTML artifact preview" });
4676
+ return;
4677
+ }
4385
4678
  if (supportsCodePreviewCommentsForCurrentEditor()) {
4386
4679
  renderCodePreviewWithCommentBlocks(critiqueViewEl, editorText, "response");
4387
4680
  return;
@@ -4402,6 +4695,10 @@
4402
4695
  }
4403
4696
 
4404
4697
  if (rightView === "preview") {
4698
+ if (isHtmlArtifactPreviewText(markdown, "")) {
4699
+ renderHtmlArtifactPreview(critiqueViewEl, markdown, "response", { title: "Response HTML artifact preview" });
4700
+ return;
4701
+ }
4405
4702
  const nonce = ++responsePreviewRenderNonce;
4406
4703
  beginPreviewRender(critiqueViewEl);
4407
4704
  void applyRenderedMarkdown(critiqueViewEl, markdown, "response", nonce);
@@ -4468,6 +4765,8 @@
4468
4765
  const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
4469
4766
  const exportText = rightView === "editor-preview" ? prepareEditorTextForPreview(sourceTextEl.value) : latestResponseMarkdown;
4470
4767
  const canExportPreview = rightPaneShowsPreview && Boolean(String(exportText || "").trim());
4768
+ const htmlArtifactExportSource = canExportPreview ? getRightPaneHtmlArtifactSource() : "";
4769
+ const isHtmlArtifactPreview = Boolean(htmlArtifactExportSource);
4471
4770
  if (exportPdfBtn) {
4472
4771
  exportPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
4473
4772
  exportPdfBtn.textContent = previewExportInProgress ? "Exporting…" : "Export right preview";
@@ -4477,19 +4776,27 @@
4477
4776
  exportPdfBtn.title = "Switch right pane to Response (Preview) or Editor (Preview) to export.";
4478
4777
  } else if (!canExportPreview) {
4479
4778
  exportPdfBtn.title = "Nothing to export yet.";
4779
+ } else if (isHtmlArtifactPreview) {
4780
+ exportPdfBtn.title = "This is an HTML artifact preview. Export as HTML; PDF export is not available yet.";
4480
4781
  } else {
4481
4782
  exportPdfBtn.title = "Choose PDF or HTML and export the current right-pane preview.";
4482
4783
  }
4483
4784
  }
4484
4785
  if (exportPreviewPdfBtn) {
4485
- exportPreviewPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
4786
+ exportPreviewPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview || isHtmlArtifactPreview;
4787
+ exportPreviewPdfBtn.title = isHtmlArtifactPreview
4788
+ ? "HTML artifact PDF export is not available yet."
4789
+ : "Export the current right-pane preview as PDF.";
4486
4790
  }
4487
4791
  if (exportPreviewHtmlBtn) {
4488
4792
  exportPreviewHtmlBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
4793
+ exportPreviewHtmlBtn.title = isHtmlArtifactPreview
4794
+ ? "Export the authored HTML artifact."
4795
+ : "Export the current right-pane preview as standalone HTML.";
4489
4796
  }
4490
4797
  if (exportPreviewControlsEl) {
4491
4798
  exportPreviewControlsEl.title = canExportPreview
4492
- ? "Choose a format and export the current right-pane preview."
4799
+ ? (isHtmlArtifactPreview ? "Export this HTML artifact." : "Choose a format and export the current right-pane preview.")
4493
4800
  : "Switch right pane to a non-empty preview before exporting.";
4494
4801
  }
4495
4802
  if (!canExportPreview || previewExportInProgress) {
package/client/studio.css CHANGED
@@ -77,7 +77,7 @@
77
77
 
78
78
  .export-preview-trigger::after {
79
79
  content: "⌄";
80
- color: var(--muted);
80
+ color: var(--studio-info-text, var(--text));
81
81
  font-size: 14px;
82
82
  line-height: 1;
83
83
  transform: translateY(-1px);
@@ -1510,6 +1510,114 @@
1510
1510
  background: #fff;
1511
1511
  }
1512
1512
 
1513
+ .rendered-markdown .studio-html-artifact-shell {
1514
+ display: flex;
1515
+ flex-direction: column;
1516
+ min-height: min(760px, calc(100vh - 190px));
1517
+ border: 1px solid var(--panel-border);
1518
+ border-radius: 12px;
1519
+ background: var(--panel);
1520
+ overflow: hidden;
1521
+ box-shadow: 0 1px 2px var(--shadow-color);
1522
+ }
1523
+
1524
+ .rendered-markdown .studio-html-artifact-toolbar {
1525
+ display: flex;
1526
+ align-items: center;
1527
+ justify-content: space-between;
1528
+ gap: 10px;
1529
+ padding: 8px 10px;
1530
+ border-bottom: 1px solid var(--border-subtle);
1531
+ background: var(--panel-2);
1532
+ color: var(--studio-info-text, var(--muted));
1533
+ font-family: var(--font-ui);
1534
+ font-size: 12px;
1535
+ line-height: 1.25;
1536
+ }
1537
+
1538
+ .rendered-markdown .studio-html-artifact-label {
1539
+ min-width: 0;
1540
+ overflow: hidden;
1541
+ text-overflow: ellipsis;
1542
+ white-space: nowrap;
1543
+ color: var(--text);
1544
+ font-weight: 600;
1545
+ }
1546
+
1547
+ .rendered-markdown .studio-html-artifact-tools {
1548
+ flex: 0 0 auto;
1549
+ display: inline-flex;
1550
+ align-items: center;
1551
+ gap: 10px;
1552
+ min-width: 0;
1553
+ }
1554
+
1555
+ .rendered-markdown .studio-html-artifact-detail {
1556
+ flex: 0 0 auto;
1557
+ color: var(--muted);
1558
+ font-size: 11px;
1559
+ white-space: nowrap;
1560
+ }
1561
+
1562
+ .rendered-markdown .studio-html-artifact-zoom-controls {
1563
+ flex: 0 0 auto;
1564
+ display: inline-flex;
1565
+ align-items: center;
1566
+ gap: 3px;
1567
+ padding: 2px;
1568
+ border: 1px solid var(--control-border);
1569
+ border-radius: 999px;
1570
+ background: var(--panel);
1571
+ }
1572
+
1573
+ .rendered-markdown .studio-html-artifact-zoom-btn {
1574
+ min-width: 24px;
1575
+ height: 22px;
1576
+ padding: 0 7px;
1577
+ border: 0;
1578
+ border-radius: 999px;
1579
+ background: transparent;
1580
+ color: var(--text);
1581
+ font: inherit;
1582
+ font-size: 11px;
1583
+ line-height: 1;
1584
+ cursor: pointer;
1585
+ }
1586
+
1587
+ .rendered-markdown .studio-html-artifact-zoom-btn:not(:disabled):hover,
1588
+ .rendered-markdown .studio-html-artifact-zoom-btn:not(:disabled):focus-visible {
1589
+ background: var(--control-hover-bg, var(--inline-code-bg));
1590
+ outline: none;
1591
+ }
1592
+
1593
+ .rendered-markdown .studio-html-artifact-zoom-btn:disabled {
1594
+ color: var(--muted);
1595
+ cursor: default;
1596
+ opacity: 0.45;
1597
+ }
1598
+
1599
+ .rendered-markdown .studio-html-artifact-zoom-reset {
1600
+ min-width: 42px;
1601
+ color: var(--studio-info-text, var(--text));
1602
+ }
1603
+
1604
+ .rendered-markdown .studio-html-artifact-frame {
1605
+ display: block;
1606
+ flex: 1 1 auto;
1607
+ width: 100%;
1608
+ min-height: 520px;
1609
+ border: 0;
1610
+ background: #fff;
1611
+ }
1612
+
1613
+ .rendered-markdown .studio-html-artifact-shell.is-height-capped .studio-html-artifact-toolbar {
1614
+ border-bottom-color: var(--control-border);
1615
+ }
1616
+
1617
+ .rendered-markdown .studio-html-artifact-frame.is-height-capped {
1618
+ overflow: auto;
1619
+ }
1620
+
1513
1621
  .rendered-markdown .studio-subfigure-group {
1514
1622
  margin: 1.25em auto;
1515
1623
  }
@@ -3013,7 +3121,7 @@
3013
3121
 
3014
3122
  body.studio-ui-refresh .studio-refresh-chip::after {
3015
3123
  content: "⌄";
3016
- color: var(--muted);
3124
+ color: var(--studio-info-text, var(--text));
3017
3125
  font-size: 14px;
3018
3126
  line-height: 1;
3019
3127
  transform: translateY(-1px);
package/index.ts CHANGED
@@ -3296,6 +3296,33 @@ function normalizeStudioEditorLanguage(language: string | undefined): string | u
3296
3296
  return trimmed;
3297
3297
  }
3298
3298
 
3299
+ function stripLeadingStudioHtmlTrivia(text: string): string {
3300
+ let source = String(text ?? "").replace(/^\uFEFF/, "").trimStart();
3301
+ let previous = "";
3302
+ while (source && source !== previous) {
3303
+ previous = source;
3304
+ source = source.replace(/^<!--[\s\S]*?-->\s*/, "").trimStart();
3305
+ }
3306
+ return source;
3307
+ }
3308
+
3309
+ function isStudioHtmlMarkup(text: string): boolean {
3310
+ return /<[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?>/.test(String(text ?? ""));
3311
+ }
3312
+
3313
+ function isLikelyStandaloneStudioHtml(text: string, editorLanguage?: string): boolean {
3314
+ const source = String(text ?? "");
3315
+ if (!source.trim()) return false;
3316
+ if (parseStudioSingleFencedCodeBlock(source)) return false;
3317
+
3318
+ const leading = stripLeadingStudioHtmlTrivia(source);
3319
+ if (/^<!doctype\s+html\b/i.test(leading)) return true;
3320
+ if (/^<html(?:\s|>|$)/i.test(leading)) return true;
3321
+ if (/^<body(?:\s|>|$)/i.test(leading) && /<\/body\s*>/i.test(leading)) return true;
3322
+
3323
+ return normalizeStudioEditorLanguage(editorLanguage) === "html" && isStudioHtmlMarkup(source);
3324
+ }
3325
+
3299
3326
  function parseStudioSingleFencedCodeBlock(markdown: string): { info: string; content: string } | null {
3300
3327
  const trimmed = markdown.trim();
3301
3328
  if (!trimmed) return null;
@@ -3468,6 +3495,7 @@ function isLikelyRawStudioGitDiff(markdown: string): boolean {
3468
3495
  function inferStudioPdfLanguage(markdown: string, editorLanguage?: string): string | undefined {
3469
3496
  const normalizedEditorLanguage = normalizeStudioEditorLanguage(editorLanguage);
3470
3497
  if (normalizedEditorLanguage) return normalizedEditorLanguage;
3498
+ if (isLikelyStandaloneStudioHtml(markdown)) return "html";
3471
3499
 
3472
3500
  const fenced = parseStudioSingleFencedCodeBlock(markdown);
3473
3501
  if (fenced) {
@@ -4966,6 +4994,9 @@ async function renderStudioStandaloneHtmlWithPandoc(
4966
4994
  options?: StudioHtmlRenderOptions,
4967
4995
  ): Promise<{ html: Buffer; warning?: string }> {
4968
4996
  const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
4997
+ if (!isLatex && isLikelyStandaloneStudioHtml(markdown, effectiveEditorLanguage)) {
4998
+ return { html: Buffer.from(String(markdown ?? ""), "utf-8") };
4999
+ }
4969
5000
  const source = !isLatex
4970
5001
  && effectiveEditorLanguage
4971
5002
  && effectiveEditorLanguage !== "markdown"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",