pi-studio 0.9.51 → 0.9.52

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,21 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.52] — 2026-08-28
8
+
9
+ ### Added
10
+ - Add a visible **Enlarge** control for embedded PDF cards, plus **Copy path** alongside PDF actions that operate on the computer running Pi.
11
+
12
+ ### Changed
13
+ - Offer true browser fullscreen only when the current browser exposes the Fullscreen API, while retaining Studio's in-page image, PDF, and HTML focus viewers everywhere.
14
+ - Keep ordinary images and rendered PDF figures unobscured by using their hover zoom cursor and whole-media click target instead of overlay buttons.
15
+
16
+ ### Fixed
17
+ - Restore a clear, explicitly sized H1–H6 hierarchy in rendered Markdown without allowing application-header styles to override document headings.
18
+ - Apply click-to-focus image behavior consistently to Side-question and Quiz Markdown as well as the main Editor, Response, and Working surfaces.
19
+ - Keep image, rendered-PDF, and PDF-card actions interactive after the response pane resets its scroll position by adding clone-safe delegated handling; preserve rendered PDF canvas pixels across the reset so inline and enlarged figures do not become blank; show contextual success/failure feedback for host PDF actions.
20
+ - Hide side-thread-only Markdown save, copy, and editor actions from ordinary Response, Editor Preview, and REPL export menus, with a runtime guard against stale menu activation.
21
+
7
22
  ## [0.9.51] — 2026-08-26
8
23
 
9
24
  ### Added
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace for working with prompts, responses, live working details, Markdown and LaTeX documents, interactive HTML previews, code files, REPL sessions, and other common text-based files side by side. Annotate responses and files, add local comments, write, edit, run prompts, send code to a REPL, browse prompt and response history, request critiques, and use live preview for code, Markdown, LaTeX, and interactive HTML.
4
4
 
5
+ See [`ROADMAP.md`](./ROADMAP.md) for the current release plan.
6
+
5
7
  ## Quick demo
6
8
 
7
9
  [Watch the 2-minute demo (MP4, 2x speed, no audio)](https://github.com/omaclaren/pi-studio/releases/latest/download/pi-studio-demo-2min.mp4)
@@ -52,10 +54,10 @@ _The video shows an earlier version of the Studio interface. The basic workflow
52
54
  - shows/hides annotation markers in preview
53
55
  - strips markers before send (optional)
54
56
  - saves `.annotated.md`
55
- - 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
57
+ - Renders Markdown/LaTeX/code previews (math + Mermaid) plus lightweight CSV/TSV table previews, theme-synced with pi, with an explicit H1–H6 hierarchy, zoomable images across Studio-owned Markdown surfaces, and copy buttons for code blocks and blockquotes; Mermaid previews include Lucide/Logos icon nodes and accessible label contrast over custom fills
56
58
  - Adds a contextual **Editor (Quarto Preview)** right-pane view for file-backed `.qmd`, `.md`, and `.markdown` documents. Studio checks Quarto and the document/project configuration before showing an explicit start action, launches a single loopback `quarto preview` process with `--no-execute`, embeds Quarto's authoritative saved-file output without restyling it, and provides open-in-browser, restart, stop, logs, and unsaved-editor warnings. If Quarto is missing, the view remains available with an actionable dependency message.
57
59
  - Renders straight, unfenced interactive HTML in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
58
- - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks and opens existing PDFs directly with `/studio <path.pdf>`, with Focus, browser-tab, system-viewer, show-in-folder, manual refresh, and opt-in stable-file auto-refresh actions
60
+ - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks and opens existing PDFs directly with `/studio <path.pdf>`, with visible enlarge, browser-tab, system-viewer, show-in-folder, copy-path, manual refresh, and opt-in stable-file auto-refresh actions; true browser fullscreen is offered only when the browser supports it
59
61
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
60
62
  - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML into the source file directory, Studio working directory, or Pi session directory; PDF export can open in a Studio preview tab or the default PDF viewer, and HTML export can open in the default browser or in a new Studio editor tab for inspection/commenting, while preserving authored HTML previews as HTML and rendering CSV/TSV editor previews as tables
61
63
  - 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. Agent tools `studio_export_pdf` and `studio_export_html` expose the same export pipeline for remote/Telegram-style sessions.
package/ROADMAP.md ADDED
@@ -0,0 +1,74 @@
1
+ # Pi Studio roadmap
2
+
3
+ This is the current planning source of truth for Studio work. `NEXT-STAGE.md` is historical; `TODO.md` tracks smaller backlog items that are not yet assigned to a release.
4
+
5
+ ## Release approach
6
+
7
+ Studio development follows two tracks:
8
+
9
+ - **Stable `0.9.x` releases** contain one coherent family of fixes or incremental features and keep `main` releasable.
10
+ - **`0.10.0`** is reserved for the buffer-first document architecture. Risky prereleases should use the npm `next` tag before replacing `latest`.
11
+
12
+ Changes should be based on capabilities rather than browser, terminal host, or user-agent detection. Muxy, ordinary Chromium browsers, embedded WebKit views, and SSH/headless use are validation environments, not separate product variants.
13
+
14
+ Each release should finish with:
15
+
16
+ 1. focused regression tests plus the full test suite;
17
+ 2. TypeScript and JavaScript syntax checks;
18
+ 3. production dependency audit;
19
+ 4. exact package-content and byte-level artifact audit;
20
+ 5. a fresh npm installation and naturally rendered Studio smoke test.
21
+
22
+ Publishing, pushing, and tagging remain explicit release actions rather than automatic consequences of merging work.
23
+
24
+ ## 0.9.52 — Rendering and media consistency
25
+
26
+ A focused portability and affordance pass:
27
+
28
+ - scope application-header typography so it cannot override rendered Markdown;
29
+ - define an explicit, visibly ordered H1–H6 scale;
30
+ - apply image focus consistently across Studio-owned same-document Markdown surfaces;
31
+ - give embedded PDF cards and rendered PDF figures an obvious enlarge action;
32
+ - expose true browser fullscreen only when the Fullscreen API is available, while retaining Studio's in-page focus viewer everywhere;
33
+ - make local PDF actions clearly refer to the computer running Pi and provide **Copy path** alongside system-viewer and file-manager actions.
34
+
35
+ This release must not add Muxy-specific branches or attempt to instrument opaque Quarto/authored-HTML iframe contents.
36
+
37
+ ## 0.9.53 — Resource locations
38
+
39
+ Replace the single-root assumption with explicit resource grants:
40
+
41
+ - grant the current document directory automatically;
42
+ - allow an exact file or additional folder to be granted explicitly;
43
+ - keep grants session-scoped by default;
44
+ - store canonical server-side paths and retain traversal and symlink-escape checks;
45
+ - use the same grants for Markdown resources, local links, PDFs, Files view, and Side questions;
46
+ - explain that paths, Finder/file-manager actions, and system viewers belong to the computer running Pi;
47
+ - offer **Allow this file**, **Allow this folder for this Studio session**, and **Cancel** when an explicit local link crosses the current boundary.
48
+
49
+ The resource-location registry should be workspace-level rather than tied to the current editor document so it remains compatible with multiple buffers. Remembering grants per project can follow after the session-only model is proven.
50
+
51
+ ## 0.10.0 — Buffer-first editing
52
+
53
+ The first architectural `0.10` release should add:
54
+
55
+ - a `StudioBufferStore` with stable document identity;
56
+ - recovery schema v2 with migration from the single-document schema;
57
+ - buffer-specific text/baseline, dirty state, cursor, selection, scroll, preview context, annotations, and resource directory;
58
+ - tabs at ordinary widths and a compact searchable buffer picker at narrow widths;
59
+ - MRU cycling, next/previous, close, and reopen commands;
60
+ - originating buffer ID and revision targeting for asynchronous Pi work.
61
+
62
+ PDFs remain preview documents, and conversation history remains session-global. Split panes, arbitrary layouts, project trees, and simultaneous per-buffer Pi conversations are outside the initial `0.10.0` scope.
63
+
64
+ ## Validation matrix
65
+
66
+ Cross-environment checks should cover, where relevant:
67
+
68
+ - full and editor-only Studio;
69
+ - file-backed and detached documents;
70
+ - ordinary Chromium and embedded WebKit browser surfaces;
71
+ - local, SSH, and `--no-browser` Pi sessions;
72
+ - paths with spaces, Unicode, traversal attempts, and symlinks;
73
+ - supported and unavailable browser APIs;
74
+ - browser reconstruction, refresh recovery, and clean shutdown.
@@ -296,6 +296,7 @@
296
296
  let studioPdfFocusOpenLinkEl = null;
297
297
  let studioPdfFocusSystemViewerBtn = null;
298
298
  let studioPdfFocusRevealBtn = null;
299
+ let studioPdfFocusCopyPathBtn = null;
299
300
  let studioPdfFocusAutoRefreshBtn = null;
300
301
  let studioPdfFocusFullscreenBtn = null;
301
302
  let studioPdfFocusCloseBtn = null;
@@ -305,6 +306,7 @@
305
306
  let studioPdfFocusSourceCard = null;
306
307
  let studioPdfFocusStandaloneAutoRefreshState = null;
307
308
  const studioPdfCardAutoRefreshStates = new WeakMap();
309
+ const studioPdfActionFeedbackStates = new WeakMap();
308
310
  const STUDIO_PDF_AUTO_REFRESH_INTERVAL_MS = 1_000;
309
311
  const STUDIO_PDF_AUTO_REFRESH_STABLE_OBSERVATIONS = 2;
310
312
  let studioHtmlFocusOverlayEl = null;
@@ -6715,10 +6717,22 @@
6715
6717
  button.setAttribute("aria-pressed", focused ? "true" : "false");
6716
6718
  }
6717
6719
 
6720
+ function isStudioFullscreenAvailable(element) {
6721
+ return Boolean(
6722
+ element
6723
+ && typeof element.requestFullscreen === "function"
6724
+ && typeof document.exitFullscreen === "function"
6725
+ );
6726
+ }
6727
+
6718
6728
  function syncStudioHtmlFocusFullscreenButton() {
6719
6729
  const button = studioHtmlFocusFullscreenBtn;
6720
6730
  if (!button) return;
6721
6731
  const shell = studioHtmlFocusShellEl;
6732
+ const available = isStudioFullscreenAvailable(shell);
6733
+ button.hidden = !available;
6734
+ button.setAttribute("aria-hidden", available ? "false" : "true");
6735
+ if (!available) return;
6722
6736
  const isFullscreen = Boolean(shell && document.fullscreenElement && document.fullscreenElement === shell);
6723
6737
  button.replaceChildren(makeStudioUiRefreshIcon(isFullscreen ? "fullscreen-exit" : "fullscreen"));
6724
6738
  const label = isFullscreen ? "Exit fullscreen" : "Fullscreen";
@@ -7191,9 +7205,9 @@
7191
7205
  systemViewerBtn.type = "button";
7192
7206
  systemViewerBtn.className = "studio-pdf-focus-btn studio-pdf-focus-system-viewer";
7193
7207
  systemViewerBtn.textContent = "System viewer";
7194
- systemViewerBtn.title = "Open the local PDF in the operating system's default PDF viewer.";
7208
+ systemViewerBtn.title = "Open the local PDF in the default viewer on the computer running Pi.";
7195
7209
  systemViewerBtn.addEventListener("click", () => {
7196
- void runStudioPdfLocalAction("system-viewer", studioPdfFocusResourceQuery);
7210
+ void runStudioPdfLocalAction("system-viewer", studioPdfFocusResourceQuery, systemViewerBtn);
7197
7211
  });
7198
7212
  actions.appendChild(systemViewerBtn);
7199
7213
 
@@ -7201,12 +7215,22 @@
7201
7215
  revealBtn.type = "button";
7202
7216
  revealBtn.className = "studio-pdf-focus-btn studio-pdf-focus-reveal";
7203
7217
  revealBtn.textContent = "Show in folder";
7204
- revealBtn.title = "Reveal the local PDF in Finder or the system file manager.";
7218
+ revealBtn.title = "Reveal the local PDF in the file manager on the computer running Pi.";
7205
7219
  revealBtn.addEventListener("click", () => {
7206
- void runStudioPdfLocalAction("reveal", studioPdfFocusResourceQuery);
7220
+ void runStudioPdfLocalAction("reveal", studioPdfFocusResourceQuery, revealBtn);
7207
7221
  });
7208
7222
  actions.appendChild(revealBtn);
7209
7223
 
7224
+ const copyPathBtn = document.createElement("button");
7225
+ copyPathBtn.type = "button";
7226
+ copyPathBtn.className = "studio-pdf-focus-btn studio-pdf-focus-copy-path";
7227
+ copyPathBtn.textContent = "Copy path";
7228
+ copyPathBtn.title = "Copy the PDF path on the computer running Pi.";
7229
+ copyPathBtn.addEventListener("click", () => {
7230
+ void copyStudioPdfResourcePath(studioPdfFocusResourceQuery, copyPathBtn);
7231
+ });
7232
+ actions.appendChild(copyPathBtn);
7233
+
7210
7234
  const refreshBtn = document.createElement("button");
7211
7235
  refreshBtn.type = "button";
7212
7236
  refreshBtn.className = "studio-pdf-focus-btn studio-pdf-focus-refresh";
@@ -7282,6 +7306,7 @@
7282
7306
  studioPdfFocusOpenLinkEl = openLink;
7283
7307
  studioPdfFocusSystemViewerBtn = systemViewerBtn;
7284
7308
  studioPdfFocusRevealBtn = revealBtn;
7309
+ studioPdfFocusCopyPathBtn = copyPathBtn;
7285
7310
  studioPdfFocusAutoRefreshBtn = autoRefreshBtn;
7286
7311
  studioPdfFocusFullscreenBtn = fullscreenBtn;
7287
7312
  studioPdfFocusCloseBtn = closeBtn;
@@ -7576,9 +7601,33 @@
7576
7601
  return setStudioPdfAutoRefreshEnabled(state, !state.enabled);
7577
7602
  }
7578
7603
 
7579
- async function runStudioPdfLocalAction(action, resourceQuery) {
7604
+ function flashStudioPdfActionFeedback(buttonEl, label, level) {
7605
+ if (!(buttonEl instanceof HTMLButtonElement)) return;
7606
+ const existing = studioPdfActionFeedbackStates.get(buttonEl) || null;
7607
+ if (existing && existing.timer) window.clearTimeout(existing.timer);
7608
+ const state = {
7609
+ baselineText: existing ? existing.baselineText : buttonEl.textContent,
7610
+ baselineAriaLabel: existing ? existing.baselineAriaLabel : buttonEl.getAttribute("aria-label"),
7611
+ timer: null,
7612
+ };
7613
+ studioPdfActionFeedbackStates.set(buttonEl, state);
7614
+ buttonEl.textContent = String(label || "Done");
7615
+ buttonEl.dataset.studioActionFeedback = level === "warning" ? "warning" : "success";
7616
+ buttonEl.setAttribute("aria-label", String(label || "Done"));
7617
+ state.timer = window.setTimeout(() => {
7618
+ if (studioPdfActionFeedbackStates.get(buttonEl) !== state) return;
7619
+ buttonEl.textContent = state.baselineText;
7620
+ if (state.baselineAriaLabel === null) buttonEl.removeAttribute("aria-label");
7621
+ else buttonEl.setAttribute("aria-label", state.baselineAriaLabel);
7622
+ delete buttonEl.dataset.studioActionFeedback;
7623
+ studioPdfActionFeedbackStates.delete(buttonEl);
7624
+ }, 2_200);
7625
+ }
7626
+
7627
+ async function runStudioPdfLocalAction(action, resourceQuery, actionButton) {
7580
7628
  const query = normalizeStudioPdfResourceQuery(resourceQuery);
7581
7629
  if (!query) {
7630
+ flashStudioPdfActionFeedback(actionButton, "Unavailable", "warning");
7582
7631
  setStatus("Could not resolve this PDF's local path.", "warning");
7583
7632
  return false;
7584
7633
  }
@@ -7589,11 +7638,13 @@
7589
7638
  method: "POST",
7590
7639
  body: JSON.stringify(query),
7591
7640
  });
7641
+ flashStudioPdfActionFeedback(actionButton, openInSystemViewer ? "Opened ✓" : "Shown ✓", "success");
7592
7642
  setStatus(payload && payload.message
7593
7643
  ? payload.message
7594
7644
  : (openInSystemViewer ? "Opened PDF in the system viewer." : "Revealed PDF in the file manager."), "success");
7595
7645
  return true;
7596
7646
  } catch (error) {
7647
+ flashStudioPdfActionFeedback(actionButton, "Failed", "warning");
7597
7648
  setStatus((error && error.message)
7598
7649
  ? error.message
7599
7650
  : (openInSystemViewer ? "Could not open PDF in the system viewer." : "Could not reveal PDF in the file manager."), "warning");
@@ -7605,6 +7656,31 @@
7605
7656
  const available = Boolean(normalizeStudioPdfResourceQuery(studioPdfFocusResourceQuery));
7606
7657
  if (studioPdfFocusSystemViewerBtn) studioPdfFocusSystemViewerBtn.disabled = !available;
7607
7658
  if (studioPdfFocusRevealBtn) studioPdfFocusRevealBtn.disabled = !available;
7659
+ if (studioPdfFocusCopyPathBtn) studioPdfFocusCopyPathBtn.disabled = !available;
7660
+ }
7661
+
7662
+ async function copyStudioPdfResourcePath(resourceQuery, actionButton) {
7663
+ const query = normalizeStudioPdfResourceQuery(resourceQuery);
7664
+ if (!query) {
7665
+ flashStudioPdfActionFeedback(actionButton, "Unavailable", "warning");
7666
+ setStatus("Could not resolve this PDF's local path.", "warning");
7667
+ return false;
7668
+ }
7669
+ try {
7670
+ const payload = await fetchStudioJson("/local-preview-link", {
7671
+ query: { ...query, action: "resolve" },
7672
+ });
7673
+ const path = payload && typeof payload.path === "string" ? payload.path : "";
7674
+ if (!path) throw new Error("Studio did not return a PDF path.");
7675
+ if (!(await writeTextToClipboard(path))) throw new Error("Clipboard write failed.");
7676
+ flashStudioPdfActionFeedback(actionButton, "Copied ✓", "success");
7677
+ setStatus("Copied PDF path from the computer running Pi.", "success");
7678
+ return true;
7679
+ } catch (error) {
7680
+ flashStudioPdfActionFeedback(actionButton, "Failed", "warning");
7681
+ setStatus((error && error.message) ? error.message : "Could not copy this PDF's local path.", "warning");
7682
+ return false;
7683
+ }
7608
7684
  }
7609
7685
 
7610
7686
  function buildRefreshedStudioPdfViewerUrl(value) {
@@ -7731,6 +7807,10 @@
7731
7807
 
7732
7808
  function syncStudioPdfFocusFullscreenButton() {
7733
7809
  if (!studioPdfFocusFullscreenBtn) return;
7810
+ const available = isStudioFullscreenAvailable(studioPdfFocusDialogEl);
7811
+ studioPdfFocusFullscreenBtn.hidden = !available;
7812
+ studioPdfFocusFullscreenBtn.setAttribute("aria-hidden", available ? "false" : "true");
7813
+ if (!available) return;
7734
7814
  const isFullscreen = Boolean(document.fullscreenElement && studioPdfFocusDialogEl && document.fullscreenElement === studioPdfFocusDialogEl);
7735
7815
  studioPdfFocusFullscreenBtn.replaceChildren(makeStudioUiRefreshIcon(isFullscreen ? "fullscreen-exit" : "fullscreen"));
7736
7816
  const label = isFullscreen ? "Exit fullscreen" : "Fullscreen";
@@ -7789,6 +7869,16 @@
7789
7869
  studioPdfFocusFrameEl.title = String(title || "PDF focus viewer").trim() || "PDF focus viewer";
7790
7870
  }
7791
7871
 
7872
+ function getStudioPdfCardResourceQuery(card) {
7873
+ return card && card.dataset
7874
+ ? normalizeStudioPdfResourceQuery({
7875
+ path: card.dataset.studioPdfPath || "",
7876
+ sourcePath: card.dataset.studioPdfSourcePath || "",
7877
+ resourceDir: card.dataset.studioPdfResourceDir || "",
7878
+ })
7879
+ : null;
7880
+ }
7881
+
7792
7882
  function openStudioPdfFocusFromButton(buttonEl) {
7793
7883
  if (!buttonEl) return false;
7794
7884
  const card = buttonEl.closest && buttonEl.closest(".studio-pdf-card");
@@ -7798,13 +7888,7 @@
7798
7888
  || String(card && card.dataset ? (card.dataset.studioPdfTitle || "") : "").trim()
7799
7889
  || "PDF preview";
7800
7890
  const sourceFrame = card && typeof card.querySelector === "function" ? card.querySelector("iframe.studio-pdf-frame") : null;
7801
- const resourceQuery = card && card.dataset
7802
- ? normalizeStudioPdfResourceQuery({
7803
- path: card.dataset.studioPdfPath || "",
7804
- sourcePath: card.dataset.studioPdfSourcePath || "",
7805
- resourceDir: card.dataset.studioPdfResourceDir || "",
7806
- })
7807
- : null;
7891
+ const resourceQuery = getStudioPdfCardResourceQuery(card);
7808
7892
  if (!viewerUrl) return false;
7809
7893
  openStudioPdfFocusViewer(viewerUrl, title, sourceFrame, resourceQuery, card);
7810
7894
  return true;
@@ -7824,6 +7908,73 @@
7824
7908
  }
7825
7909
  }
7826
7910
 
7911
+ function consumeStudioPreviewMediaEvent(event) {
7912
+ event.preventDefault();
7913
+ event.stopPropagation();
7914
+ if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7915
+ }
7916
+
7917
+ function handleStudioPreviewMediaKeydown(event) {
7918
+ if (!event || (event.key !== "Enter" && event.key !== " ")) return;
7919
+ const target = event.target;
7920
+ const imageEl = target instanceof Element ? target.closest("img.studio-image-focus-target") : null;
7921
+ if (!imageEl) return;
7922
+ consumeStudioPreviewMediaEvent(event);
7923
+ if (!openPreviewImageElementInFocus(imageEl)) setStatus("Could not open image focus view.", "warning");
7924
+ }
7925
+
7926
+ function handleStudioPreviewMediaActivation(event) {
7927
+ const target = event && event.target;
7928
+ if (!(target instanceof Element)) return;
7929
+
7930
+ const imageEl = target.closest("img.studio-image-focus-target");
7931
+ if (imageEl) {
7932
+ consumeStudioPreviewMediaEvent(event);
7933
+ if (!openPreviewImageElementInFocus(imageEl)) setStatus("Could not open image focus view.", "warning");
7934
+ return;
7935
+ }
7936
+
7937
+ const pdfFigureEl = target.closest(".studio-pdf-preview-focus-target");
7938
+ if (pdfFigureEl && !target.closest("button, a")) {
7939
+ consumeStudioPreviewMediaEvent(event);
7940
+ if (!openPreviewPdfFigureInFocus(pdfFigureEl)) setStatus("Could not enlarge this PDF figure preview.", "warning");
7941
+ return;
7942
+ }
7943
+
7944
+ const actionBtn = target.closest(
7945
+ ".studio-pdf-card-system-viewer, .studio-pdf-card-reveal, .studio-pdf-card-copy-path, .studio-pdf-card-refresh, .studio-pdf-card-auto-refresh"
7946
+ );
7947
+ if (!actionBtn) return;
7948
+ const card = actionBtn.closest(".studio-pdf-card");
7949
+ if (!card) return;
7950
+ consumeStudioPreviewMediaEvent(event);
7951
+ const resourceQuery = getStudioPdfCardResourceQuery(card);
7952
+ if (actionBtn.classList.contains("studio-pdf-card-system-viewer")) {
7953
+ void runStudioPdfLocalAction("system-viewer", resourceQuery, actionBtn);
7954
+ return;
7955
+ }
7956
+ if (actionBtn.classList.contains("studio-pdf-card-reveal")) {
7957
+ void runStudioPdfLocalAction("reveal", resourceQuery, actionBtn);
7958
+ return;
7959
+ }
7960
+ if (actionBtn.classList.contains("studio-pdf-card-copy-path")) {
7961
+ void copyStudioPdfResourcePath(resourceQuery, actionBtn);
7962
+ return;
7963
+ }
7964
+ if (actionBtn.classList.contains("studio-pdf-card-refresh")) {
7965
+ const refreshed = refreshStudioPdfCard(card);
7966
+ flashStudioPdfActionFeedback(actionBtn, refreshed ? "Refreshed ✓" : "Failed", refreshed ? "success" : "warning");
7967
+ if (!refreshed) setStatus("Could not refresh this PDF preview.", "warning");
7968
+ return;
7969
+ }
7970
+ const state = ensureStudioPdfCardAutoRefreshState(card, resourceQuery);
7971
+ if (!state) {
7972
+ setStatus("Could not resolve this PDF for auto-refresh.", "warning");
7973
+ return;
7974
+ }
7975
+ setStudioPdfCardAutoRefresh(card, !state.enabled);
7976
+ }
7977
+
7827
7978
  function isStudioImageFocusOpen() {
7828
7979
  return Boolean(studioImageFocusOverlayEl && studioImageFocusOverlayEl.hidden === false);
7829
7980
  }
@@ -7976,6 +8127,10 @@
7976
8127
 
7977
8128
  function syncStudioImageFocusFullscreenButton() {
7978
8129
  if (!studioImageFocusFullscreenBtn) return;
8130
+ const available = isStudioFullscreenAvailable(studioImageFocusDialogEl);
8131
+ studioImageFocusFullscreenBtn.hidden = !available;
8132
+ studioImageFocusFullscreenBtn.setAttribute("aria-hidden", available ? "false" : "true");
8133
+ if (!available) return;
7979
8134
  const isFullscreen = Boolean(document.fullscreenElement && studioImageFocusDialogEl && document.fullscreenElement === studioImageFocusDialogEl);
7980
8135
  studioImageFocusFullscreenBtn.replaceChildren(makeStudioUiRefreshIcon(isFullscreen ? "fullscreen-exit" : "fullscreen"));
7981
8136
  const label = isFullscreen ? "Exit fullscreen" : "Fullscreen";
@@ -8212,6 +8367,39 @@
8212
8367
  });
8213
8368
  }
8214
8369
 
8370
+ function openPreviewPdfFigureInFocus(previewEl) {
8371
+ const canvas = previewEl && typeof previewEl.querySelector === "function"
8372
+ ? previewEl.querySelector("canvas")
8373
+ : null;
8374
+ if (!(canvas instanceof HTMLCanvasElement)) return false;
8375
+ try {
8376
+ const dataUrl = canvas.toDataURL("image/png");
8377
+ const title = String(previewEl.getAttribute("title") || "PDF figure preview").trim() || "PDF figure preview";
8378
+ return openStudioImageFocusViewer(dataUrl, title);
8379
+ } catch {
8380
+ return false;
8381
+ }
8382
+ }
8383
+
8384
+ function decoratePreviewPdfFigures(targetEl) {
8385
+ if (!targetEl || typeof targetEl.querySelectorAll !== "function") return;
8386
+ const previews = Array.from(targetEl.querySelectorAll(".studio-pdf-preview"));
8387
+ previews.forEach((previewEl) => {
8388
+ if (!(previewEl instanceof HTMLElement)) return;
8389
+ if (previewEl.dataset && previewEl.dataset.studioPdfFigureFocusDecorated === "1") return;
8390
+ if (!previewEl.querySelector("canvas")) return;
8391
+ previewEl.classList.add("studio-pdf-preview-focus-target");
8392
+ previewEl.title = "PDF figure preview (page 1). Click to enlarge.";
8393
+ if (previewEl.dataset) previewEl.dataset.studioPdfFigureFocusDecorated = "1";
8394
+
8395
+ previewEl.addEventListener("click", (event) => {
8396
+ if (event.target instanceof Element && event.target.closest("button, a")) return;
8397
+ event.preventDefault();
8398
+ if (!openPreviewPdfFigureInFocus(previewEl)) setStatus("Could not enlarge this PDF figure preview.", "warning");
8399
+ });
8400
+ });
8401
+ }
8402
+
8215
8403
  function createStudioPdfCard(block, useEditorResourceContext) {
8216
8404
  const options = block && block.options ? block.options : {};
8217
8405
  const path = String(options.path || "").trim();
@@ -8244,12 +8432,13 @@
8244
8432
  focusBtn.type = "button";
8245
8433
  focusBtn.className = "studio-pdf-card-action studio-pdf-card-focus";
8246
8434
  focusBtn.title = "Open this PDF in a larger Studio overlay.";
8247
- focusBtn.setAttribute("aria-label", "Focus PDF");
8435
+ focusBtn.setAttribute("aria-label", "Enlarge PDF");
8248
8436
  if (focusBtn.dataset) {
8249
8437
  focusBtn.dataset.studioPdfViewerUrl = viewerUrl;
8250
8438
  focusBtn.dataset.studioPdfTitle = title;
8251
8439
  }
8252
8440
  focusBtn.appendChild(makeStudioUiRefreshIcon("focus"));
8441
+ focusBtn.appendChild(document.createTextNode("Enlarge"));
8253
8442
  focusBtn.addEventListener("click", handleStudioPdfFocusButtonClick);
8254
8443
  titleGroup.appendChild(focusBtn);
8255
8444
  }
@@ -8276,11 +8465,11 @@
8276
8465
  systemViewerBtn.type = "button";
8277
8466
  systemViewerBtn.className = "studio-pdf-card-action studio-pdf-card-system-viewer";
8278
8467
  systemViewerBtn.textContent = "System viewer";
8279
- systemViewerBtn.title = "Open the local PDF in the operating system's default PDF viewer.";
8468
+ systemViewerBtn.title = "Open the local PDF in the default viewer on the computer running Pi.";
8280
8469
  systemViewerBtn.addEventListener("click", (event) => {
8281
8470
  event.preventDefault();
8282
8471
  event.stopPropagation();
8283
- void runStudioPdfLocalAction("system-viewer", resourceQuery);
8472
+ void runStudioPdfLocalAction("system-viewer", resourceQuery, systemViewerBtn);
8284
8473
  });
8285
8474
  actions.appendChild(systemViewerBtn);
8286
8475
 
@@ -8288,14 +8477,26 @@
8288
8477
  revealBtn.type = "button";
8289
8478
  revealBtn.className = "studio-pdf-card-action studio-pdf-card-reveal";
8290
8479
  revealBtn.textContent = "Show in folder";
8291
- revealBtn.title = "Reveal the local PDF in Finder or the system file manager.";
8480
+ revealBtn.title = "Reveal the local PDF in the file manager on the computer running Pi.";
8292
8481
  revealBtn.addEventListener("click", (event) => {
8293
8482
  event.preventDefault();
8294
8483
  event.stopPropagation();
8295
- void runStudioPdfLocalAction("reveal", resourceQuery);
8484
+ void runStudioPdfLocalAction("reveal", resourceQuery, revealBtn);
8296
8485
  });
8297
8486
  actions.appendChild(revealBtn);
8298
8487
 
8488
+ const copyPathBtn = document.createElement("button");
8489
+ copyPathBtn.type = "button";
8490
+ copyPathBtn.className = "studio-pdf-card-action studio-pdf-card-copy-path";
8491
+ copyPathBtn.textContent = "Copy path";
8492
+ copyPathBtn.title = "Copy the PDF path on the computer running Pi.";
8493
+ copyPathBtn.addEventListener("click", (event) => {
8494
+ event.preventDefault();
8495
+ event.stopPropagation();
8496
+ void copyStudioPdfResourcePath(resourceQuery, copyPathBtn);
8497
+ });
8498
+ actions.appendChild(copyPathBtn);
8499
+
8299
8500
  const refreshBtn = document.createElement("button");
8300
8501
  refreshBtn.type = "button";
8301
8502
  refreshBtn.className = "studio-pdf-card-action studio-pdf-card-refresh";
@@ -9238,6 +9439,32 @@
9238
9439
  });
9239
9440
  }
9240
9441
 
9442
+ function copyResponsePaneCanvasState(sourceEl, replacementEl) {
9443
+ if (!sourceEl || !replacementEl || typeof sourceEl.querySelectorAll !== "function" || typeof replacementEl.querySelectorAll !== "function") {
9444
+ return false;
9445
+ }
9446
+ const sourceCanvases = Array.from(sourceEl.querySelectorAll("canvas"));
9447
+ const replacementCanvases = Array.from(replacementEl.querySelectorAll("canvas"));
9448
+ if (sourceCanvases.length !== replacementCanvases.length) return false;
9449
+
9450
+ for (let index = 0; index < sourceCanvases.length; index += 1) {
9451
+ const sourceCanvas = sourceCanvases[index];
9452
+ const replacementCanvas = replacementCanvases[index];
9453
+ if (!(sourceCanvas instanceof HTMLCanvasElement) || !(replacementCanvas instanceof HTMLCanvasElement)) return false;
9454
+ if (sourceCanvas.width < 1 || sourceCanvas.height < 1) continue;
9455
+ try {
9456
+ replacementCanvas.width = sourceCanvas.width;
9457
+ replacementCanvas.height = sourceCanvas.height;
9458
+ const context = replacementCanvas.getContext("2d");
9459
+ if (!context) return false;
9460
+ context.drawImage(sourceCanvas, 0, 0);
9461
+ } catch {
9462
+ return false;
9463
+ }
9464
+ }
9465
+ return true;
9466
+ }
9467
+
9241
9468
  function replaceResponsePaneWithClone() {
9242
9469
  const currentEl = critiqueViewEl;
9243
9470
  if (!currentEl || !currentEl.parentNode || typeof currentEl.cloneNode !== "function") {
@@ -9245,7 +9472,7 @@
9245
9472
  }
9246
9473
 
9247
9474
  const replacement = currentEl.cloneNode(true);
9248
- if (!replacement || replacement.nodeType !== 1) {
9475
+ if (!replacement || replacement.nodeType !== 1 || !copyResponsePaneCanvasState(currentEl, replacement)) {
9249
9476
  return currentEl;
9250
9477
  }
9251
9478
 
@@ -10019,6 +10246,10 @@
10019
10246
 
10020
10247
  function exportRightPaneFormat(format) {
10021
10248
  closeExportPreviewMenu();
10249
+ if (String(format || "").startsWith("side-markdown-") && rightView !== "side-questions") {
10250
+ setStatus("Side-thread Markdown export is available only in Side questions.", "warning");
10251
+ return false;
10252
+ }
10022
10253
  if (format === "side-markdown-save") {
10023
10254
  return saveSideQuestionTranscriptMarkdown();
10024
10255
  }
@@ -10259,6 +10490,7 @@
10259
10490
  await renderAnnotationMathInElement(targetEl);
10260
10491
  decoratePdfEmbeds(targetEl);
10261
10492
  await renderPdfPreviewsInElement(targetEl);
10493
+ decoratePreviewPdfFigures(targetEl);
10262
10494
  const annotationMode = (pane === "source" || pane === "response")
10263
10495
  ? (annotationsEnabled ? "highlight" : "hide")
10264
10496
  : "none";
@@ -12141,6 +12373,7 @@
12141
12373
  await renderMermaidInElement(target);
12142
12374
  await renderMathFallbackInElement(target);
12143
12375
  decorateCopyablePreviewBlocks(target);
12376
+ decoratePreviewImages(target);
12144
12377
  } catch (error) {
12145
12378
  console.error("Side-question markdown preview failed:", error);
12146
12379
  target.classList.add("side-question-markdown-failed");
@@ -15015,9 +15248,11 @@
15015
15248
  await renderAnnotationMathInElement(target);
15016
15249
  decoratePdfEmbeds(target);
15017
15250
  await renderPdfPreviewsInElement(target);
15251
+ decoratePreviewPdfFigures(target);
15018
15252
  await renderMermaidInElement(target);
15019
15253
  await renderMathFallbackInElement(target);
15020
15254
  decorateCopyablePreviewBlocks(target);
15255
+ decoratePreviewImages(target);
15021
15256
  if (preserveScroll) restoreQuizScrollTopSoon(scrollTop);
15022
15257
  } catch (error) {
15023
15258
  console.error("Quiz markdown preview render failed:", error);
@@ -23552,6 +23787,9 @@
23552
23787
  });
23553
23788
  }
23554
23789
 
23790
+ document.addEventListener("click", handleStudioPreviewMediaActivation, true);
23791
+ document.addEventListener("keydown", handleStudioPreviewMediaKeydown, true);
23792
+
23555
23793
  document.addEventListener("click", (event) => {
23556
23794
  const target = event.target;
23557
23795
  const focusBtn = target instanceof Element ? target.closest(".studio-pdf-card-focus") : null;
package/client/studio.css CHANGED
@@ -29,7 +29,7 @@
29
29
  justify-content: space-between;
30
30
  }
31
31
 
32
- h1 {
32
+ body > header h1 {
33
33
  margin: 0;
34
34
  font-size: 18px;
35
35
  font-weight: 600;
@@ -202,6 +202,10 @@
202
202
  font-weight: 450;
203
203
  }
204
204
 
205
+ .export-preview-menu button[hidden] {
206
+ display: none !important;
207
+ }
208
+
205
209
  .export-preview-menu button:not(:disabled):hover,
206
210
  .export-preview-menu button:not(:disabled):focus-visible {
207
211
  background: var(--panel-2);
@@ -1182,6 +1186,7 @@
1182
1186
  .rendered-markdown h4,
1183
1187
  .rendered-markdown h5,
1184
1188
  .rendered-markdown h6 {
1189
+ display: block;
1185
1190
  margin-top: 1.2em;
1186
1191
  margin-bottom: 0.5em;
1187
1192
  line-height: 1.25;
@@ -1190,17 +1195,39 @@
1190
1195
  }
1191
1196
 
1192
1197
  .rendered-markdown h1 {
1193
- font-size: 1.6em;
1198
+ font-size: 1.7em;
1199
+ font-weight: 720;
1194
1200
  border-bottom: 0;
1195
1201
  padding-bottom: 0;
1196
1202
  }
1197
1203
 
1198
1204
  .rendered-markdown h2 {
1199
- font-size: 1.25em;
1205
+ font-size: 1.4em;
1206
+ font-weight: 700;
1200
1207
  border-bottom: 0;
1201
1208
  padding-bottom: 0;
1202
1209
  }
1203
1210
 
1211
+ .rendered-markdown h3 {
1212
+ font-size: 1.2em;
1213
+ font-weight: 680;
1214
+ }
1215
+
1216
+ .rendered-markdown h4 {
1217
+ font-size: 1.08em;
1218
+ font-weight: 660;
1219
+ }
1220
+
1221
+ .rendered-markdown h5 {
1222
+ font-size: 1em;
1223
+ font-weight: 650;
1224
+ }
1225
+
1226
+ .rendered-markdown h6 {
1227
+ font-size: 0.92em;
1228
+ font-weight: 650;
1229
+ }
1230
+
1204
1231
  .rendered-markdown #title-block-header {
1205
1232
  text-align: center;
1206
1233
  margin-bottom: 2em;
@@ -1767,6 +1794,7 @@
1767
1794
  }
1768
1795
 
1769
1796
  .rendered-markdown .studio-pdf-preview {
1797
+ position: relative;
1770
1798
  display: block;
1771
1799
  max-width: 100%;
1772
1800
  border: 1px solid var(--md-table-border);
@@ -1776,6 +1804,10 @@
1776
1804
  line-height: 0;
1777
1805
  }
1778
1806
 
1807
+ .rendered-markdown .studio-pdf-preview-focus-target {
1808
+ cursor: zoom-in;
1809
+ }
1810
+
1779
1811
  .rendered-markdown .studio-pdf-preview[data-fig-align="center"] {
1780
1812
  margin-left: auto;
1781
1813
  margin-right: auto;
@@ -1881,6 +1913,19 @@
1881
1913
  border-color: var(--control-border);
1882
1914
  }
1883
1915
 
1916
+ .rendered-markdown button.studio-pdf-card-action[data-studio-action-feedback="success"],
1917
+ .studio-pdf-focus-btn[data-studio-action-feedback="success"] {
1918
+ background: var(--accent-soft);
1919
+ color: var(--accent);
1920
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--control-border));
1921
+ }
1922
+
1923
+ .rendered-markdown button.studio-pdf-card-action[data-studio-action-feedback="warning"],
1924
+ .studio-pdf-focus-btn[data-studio-action-feedback="warning"] {
1925
+ color: var(--warn);
1926
+ border-color: color-mix(in srgb, var(--warn) 45%, var(--control-border));
1927
+ }
1928
+
1884
1929
  .rendered-markdown button.studio-pdf-card-auto-refresh[aria-pressed="true"] {
1885
1930
  background: transparent;
1886
1931
  color: var(--accent);
@@ -1896,13 +1941,14 @@
1896
1941
  }
1897
1942
 
1898
1943
  .rendered-markdown button.studio-pdf-card-focus {
1899
- width: 29px;
1944
+ width: auto;
1900
1945
  min-width: 29px;
1901
1946
  min-height: 29px;
1902
- padding: 0;
1947
+ padding: 0 8px;
1903
1948
  display: inline-flex;
1904
1949
  align-items: center;
1905
1950
  justify-content: center;
1951
+ gap: 5px;
1906
1952
  }
1907
1953
 
1908
1954
  .rendered-markdown button.studio-pdf-card-focus .studio-refresh-icon {
@@ -2075,6 +2121,10 @@
2075
2121
  height: 14px;
2076
2122
  }
2077
2123
 
2124
+ .studio-pdf-focus-fullscreen[hidden] {
2125
+ display: none !important;
2126
+ }
2127
+
2078
2128
  .studio-pdf-focus-fullscreen,
2079
2129
  .studio-pdf-focus-close {
2080
2130
  width: 29px;
@@ -5429,7 +5479,7 @@
5429
5479
  gap: 9px;
5430
5480
  }
5431
5481
 
5432
- body.studio-ui-refresh h1 {
5482
+ body.studio-ui-refresh > header h1 {
5433
5483
  font-size: 16px;
5434
5484
  }
5435
5485
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.51",
3
+ "version": "0.9.52",
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",
@@ -24,6 +24,7 @@
24
24
  "themes",
25
25
  "README.md",
26
26
  "CHANGELOG.md",
27
+ "ROADMAP.md",
27
28
  "WORKFLOW.md",
28
29
  "assets/screenshots",
29
30
  "assets/demo"