diff --git a/CLAUDE.md b/CLAUDE.md
index 412192df2..6a0bc2647 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,6 +20,7 @@
- For parameterized strings use `StringUtils.format(Strings.KEY, arg0, arg1)` with `{0}`, `{1}` placeholders.
- Keys use UPPER_SNAKE_CASE grouped by feature prefix (e.g. `AI_CHAT_*`).
- Only `src/nls/root/strings.js` (English) needs manual edits — other locales are auto-translated by GitHub Actions.
+- **Exception — Markdown viewer iframe** (`src-mdviewer/`): Has its own i18n system. Strings go in `src-mdviewer/src/locales/en.json` (root), not `src/nls/`. Other locale files in that folder are auto-translated by GitHub Actions. Use `t("key")` / `tp("key", { param })` from `src-mdviewer/src/core/i18n.js`.
- Never compare `$(el).text()` against English strings for logic — use data attributes or CSS classes instead.
## Phoenix MCP (Desktop App Testing)
diff --git a/docs/API-Reference/search/FindUtils.md b/docs/API-Reference/search/FindUtils.md
index 2d805e27f..a7084ef2b 100644
--- a/docs/API-Reference/search/FindUtils.md
+++ b/docs/API-Reference/search/FindUtils.md
@@ -107,6 +107,24 @@ enable/disable instant search
## isInstantSearchDisabled() ⇒ boolean
if instant search is disabled, this will return true we can only do instant search through worker
+**Kind**: global function
+
+
+## setIndexingSuspended(suspended)
+Set whether indexing has been suspended due to cache size limit
+
+**Kind**: global function
+
+| Param | Type | Description |
+| --- | --- | --- |
+| suspended | boolean | true if indexing was suspended |
+
+
+
+## isIndexingSuspended() ⇒ boolean
+Check if indexing was suspended due to cache size limit.
+When true, Find in Files should not perform searches.
+
**Kind**: global function
diff --git a/src-mdviewer/CLAUDE-markdown-viewer.md b/src-mdviewer/CLAUDE-markdown-viewer.md
index 61fbe6fdd..d72e72ded 100644
--- a/src-mdviewer/CLAUDE-markdown-viewer.md
+++ b/src-mdviewer/CLAUDE-markdown-viewer.md
@@ -21,11 +21,18 @@ Test Runner Window
- `src-mdviewer/src/bridge.js` — postMessage bridge between Phoenix and md iframe. Handles file switching, content sync, keyboard shortcuts, edit mode.
- `src-mdviewer/src/core/doc-cache.js` — Document DOM cache with LRU eviction for file switching.
- `src-mdviewer/src/components/editor.js` — Contenteditable WYSIWYG editing, Turndown HTML→Markdown conversion.
-- `src-mdviewer/src/components/embedded-toolbar.js` — Reader/edit toggle, cursor sync, format buttons.
+- `src-mdviewer/src/components/embedded-toolbar.js` — Reader/edit toggle, cursor sync, theme toggle, format buttons.
- `src-mdviewer/src/components/format-bar.js` — Floating format bar on text selection (bold, italic, underline, link).
- `src-mdviewer/src/components/link-popover.js` — Link popover for editing/removing links in edit mode.
- `src-mdviewer/src/components/viewer.js` — Reader mode click handling, link interception, copy buttons.
-- `src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js` — Phoenix-side sync: CM↔iframe content, cursor, scroll, selection.
+- `src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js` — Phoenix-side sync: CM↔iframe content, cursor, scroll, selection, theme.
+
+### Translations / i18n
+- The md viewer iframe has its **own** i18n system separate from Phoenix's `src/nls/` strings.
+- Root strings (English) are in `src-mdviewer/src/locales/en.json` — edit this file for new/changed strings.
+- Other locale files in `src-mdviewer/src/locales/` are **auto-translated by GitHub Actions** — do not edit them manually.
+- Use `t("key.subkey")` and `tp("key", { param })` from `src-mdviewer/src/core/i18n.js` for string lookups.
+- Phoenix-side strings (e.g. preference descriptions) still go in `src/nls/root/strings.js` as usual.
## Communication: postMessage (reliable in both directions)
diff --git a/src-mdviewer/index.html b/src-mdviewer/index.html
index a03bf493c..33b47bf68 100644
--- a/src-mdviewer/index.html
+++ b/src-mdviewer/index.html
@@ -5,12 +5,12 @@
+ font-src 'self' data: http://localhost:* https://localhost:* phtauri: https://phtauri.localhost https://*.phcode.dev;
+ connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:* phtauri: https://phtauri.localhost https://*.phcode.dev;" />
diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js
index 102901dc9..ee35477ca 100644
--- a/src-mdviewer/src/bridge.js
+++ b/src-mdviewer/src/bridge.js
@@ -806,11 +806,17 @@ function handleReloadFile(data) {
// --- Theme, edit mode, locale ---
-function handleSetTheme(_data) {
- // Force light theme for a paper-like appearance regardless of editor theme.
- // The theme infrastructure is preserved for future use.
- // Theme is set in index.html (data-theme="light") so no action needed here.
- // Avoid setting attributes/styles to prevent reflows that reset scroll position.
+function handleSetTheme(data) {
+ const { theme } = data;
+ const newScheme = theme === "dark" ? "dark" : "light";
+ // Skip if already applied to avoid reflows that can reset scroll position
+ if (document.documentElement.getAttribute("data-theme") === theme &&
+ document.documentElement.style.colorScheme === newScheme) {
+ return;
+ }
+ document.documentElement.setAttribute("data-theme", theme);
+ document.documentElement.style.colorScheme = newScheme;
+ setState({ theme });
}
function handleSetEditMode(data) {
diff --git a/src-mdviewer/src/components/embedded-toolbar.js b/src-mdviewer/src/components/embedded-toolbar.js
index 9e229fce7..214dbfb99 100644
--- a/src-mdviewer/src/components/embedded-toolbar.js
+++ b/src-mdviewer/src/components/embedded-toolbar.js
@@ -32,7 +32,9 @@ import {
Link2Off,
Printer,
Image as ImageIcon,
- Upload
+ Upload,
+ Sun,
+ Moon
} from "lucide";
import { on, emit } from "../core/events.js";
import { getState, setState } from "../core/state.js";
@@ -45,11 +47,11 @@ let collapseLevel = 0; // 0=expanded, 1=blocks, 2=blocks+lists, 3=all
// Width thresholds for progressive collapse
const THRESHOLD_BLOCKS = 640; // collapse block elements + image first
-const THRESHOLD_LISTS = 520; // then lists
-const THRESHOLD_TEXT = 500; // finally text formatting (all dropdowns collapsed)
+const THRESHOLD_LISTS = 590; // then lists
+const THRESHOLD_TEXT = 590; // finally text formatting (all dropdowns collapsed)
const allIcons = { Bold, Italic, Strikethrough, Underline, Code, Link, List, ListOrdered,
- ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer, Image: ImageIcon, Upload };
+ ListChecks, Quote, Minus, Table, FileCode, ChevronDown, Type, MoreHorizontal, Pencil, BookOpen, Link2, Link2Off, Printer, Image: ImageIcon, Upload, Sun, Moon };
export function initEmbeddedToolbar() {
toolbar = document.getElementById("toolbar");
@@ -58,6 +60,7 @@ export function initEmbeddedToolbar() {
render();
on("state:editMode", () => render());
+ on("state:theme", () => render());
on("editor:selection-state", updateFormatState);
on("state:locale", () => render());
}
@@ -80,8 +83,12 @@ function render() {
}
function renderReadMode() {
+ const isDark = getState().theme === "dark";
toolbar.innerHTML = ``;
createIcons({ icons: allIcons, attrs: { class: "" } });
- // Remove data-lucide from replaced SVGs to prevent warnings on subsequent createIcons calls
toolbar.querySelectorAll("svg[data-lucide]").forEach(svg => svg.removeAttribute("data-lucide"));
+ wireThemeToggle();
wireCursorSyncButton();
wirePrintButton();
@@ -192,9 +199,13 @@ function renderEditMode(level) {
${imageSection}
`;
+ const isDark = getState().theme === "dark";
toolbar.innerHTML = ``;
createIcons({ icons: allIcons, attrs: { class: "" } });
- // Remove data-lucide from replaced SVGs to prevent warnings on subsequent createIcons calls
toolbar.querySelectorAll("svg[data-lucide]").forEach(svg => svg.removeAttribute("data-lucide"));
wireFormatButtons();
wireBlockTypeSelect();
wireDropdowns();
+ wireThemeToggle();
wireCursorSyncButton();
wirePrintButton();
wireDoneButton();
@@ -307,6 +318,22 @@ function wireCursorSyncButton() {
}
}
+function wireThemeToggle() {
+ const toggleBtn = document.getElementById("emb-theme-toggle");
+ if (toggleBtn) {
+ toggleBtn.addEventListener("click", () => {
+ const current = getState().theme || "light";
+ const newTheme = current === "light" ? "dark" : "light";
+ // Send to parent (Phoenix) for persistence
+ window.parent.postMessage({
+ type: "MDVIEWR_EVENT",
+ eventName: "mdviewrThemeToggle",
+ theme: newTheme
+ }, "*");
+ });
+ }
+}
+
function wirePrintButton() {
const printBtn = document.getElementById("emb-print-btn");
if (printBtn) {
diff --git a/src-mdviewer/src/core/doc-cache.js b/src-mdviewer/src/core/doc-cache.js
index 8462e3423..e771f1236 100644
--- a/src-mdviewer/src/core/doc-cache.js
+++ b/src-mdviewer/src/core/doc-cache.js
@@ -176,6 +176,10 @@ export function saveActiveScrollPos() {
const entry = cache.get(activeFilePath);
if (!entry) return;
+ // Don't overwrite scroll position if viewer is hidden (e.g. panel closed)
+ // — hidden elements report scrollTop = 0 which would destroy the saved value.
+ if (!viewerContainer.offsetParent && viewerContainer.scrollTop === 0) return;
+
entry.scrollPos = viewerContainer.scrollTop;
// Also save source line for reload scenarios (DOM rebuilt, pixel pos unreliable)
diff --git a/src-mdviewer/src/locales/en.json b/src-mdviewer/src/locales/en.json
index dc47807a6..d73a32539 100644
--- a/src-mdviewer/src/locales/en.json
+++ b/src-mdviewer/src/locales/en.json
@@ -1,7 +1,7 @@
{
"toolbar": {
"open": "Open File",
- "theme": "Toggle Theme",
+ "theme": "Toggle Dark/Light Theme",
"toc": "Table of Contents",
"search": "Search",
"focus": "Focus Mode",
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
index 29c7fe6b3..78f90080b 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
+++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
@@ -132,6 +132,13 @@ define(function (require, exports, module) {
case "mdviewrCursorSyncToggle":
_cursorSyncEnabled = !!data.enabled;
break;
+ case "mdviewrThemeToggle":
+ sendThemeOverride(data.theme);
+ // Persist via StateManager (accessed through main.js callback)
+ if (_onThemeToggle) {
+ _onThemeToggle(data.theme);
+ }
+ break;
case "mdviewrImageUploadRequest":
_handleImageUploadFromIframe(data);
break;
@@ -409,6 +416,10 @@ define(function (require, exports, module) {
iframeWindow.postMessage(msg, "*");
}
+ // User's explicit theme choice (null = use editor theme)
+ let _themeOverride = null;
+ let _onThemeToggle = null;
+
function _sendTheme() {
if (!_active || !_iframeReady) {
return;
@@ -418,14 +429,24 @@ define(function (require, exports, module) {
return;
}
- const currentTheme = ThemeManager.getCurrentTheme();
- const isDark = currentTheme && currentTheme.dark;
+ let theme;
+ if (_themeOverride) {
+ theme = _themeOverride;
+ } else {
+ const currentTheme = ThemeManager.getCurrentTheme();
+ theme = (currentTheme && currentTheme.dark) ? "dark" : "light";
+ }
iframeWindow.postMessage({
type: "MDVIEWR_SET_THEME",
- theme: isDark ? "dark" : "light"
+ theme: theme
}, "*");
}
+ function sendThemeOverride(theme) {
+ _themeOverride = theme;
+ _sendTheme();
+ }
+
function _sendLocale() {
if (!_active || !_iframeReady) {
return;
@@ -1098,4 +1119,6 @@ define(function (require, exports, module) {
exports.setEditMode = setEditMode;
exports.setIframeReadyHandler = setIframeReadyHandler;
exports.setCursorSyncEnabled = setCursorSyncEnabled;
+ exports.sendThemeOverride = sendThemeOverride;
+ exports.setThemeToggleHandler = function(handler) { _onThemeToggle = handler; };
});
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js
index 51523aaf9..d92ffb64d 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/main.js
+++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js
@@ -83,6 +83,10 @@ define(function (require, exports, module) {
const StateManager = PreferencesManager.stateManager;
const STATE_CUSTOM_SERVER_BANNER_ACK = "customServerBannerDone";
+ const PREF_MD_THEME = "mdViewerTheme";
+ PreferencesManager.definePreference(PREF_MD_THEME, "string", "light", {
+ description: Strings.MD_VIEWER_THEME_DESCRIPTION
+ });
let customServerModalBar;
const isBrowser = !Phoenix.isNativeApp;
@@ -788,6 +792,11 @@ define(function (require, exports, module) {
$modeBtn = $panel.find("#livePreviewModeBtn");
$previewBtn = $panel.find("#previewModeLivePreviewButton");
+ // Markdown theme toggle — persist user choice
+ MarkdownSync.setThemeToggleHandler((theme) => {
+ PreferencesManager.set(PREF_MD_THEME, theme);
+ });
+
$panel.find(".live-preview-settings-banner-btn").on("click", ()=>{
CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS);
Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "settingsBtnBanner", "click");
@@ -919,6 +928,9 @@ define(function (require, exports, module) {
_isMdviewrActive = true;
MarkdownSync.activate(currentDoc, $iframe, baseURL);
+ // Apply persisted theme preference
+ const savedTheme = PreferencesManager.get(PREF_MD_THEME) || "light";
+ MarkdownSync.sendThemeOverride(savedTheme);
// Sync preview mode and edit mode for reuse case where iframe is already ready
_updateLPControlsForMdviewer();
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index 4a75bee35..a0d119bac 100644
--- a/src/nls/root/strings.js
+++ b/src/nls/root/strings.js
@@ -161,6 +161,7 @@ define({
"LIVE_DEV_OPEN_ERROR_TITLE": "Error Opening Live Preview in {0}",
"LIVE_DEV_OPEN_ERROR_MESSAGE": "Make sure that {0} browser is installed and try again.",
"LIVE_DEV_CLICK_TO_PIN_UNPIN": "Pin or Unpin Preview Page",
+ "MD_VIEWER_THEME_DESCRIPTION": "Theme for the Markdown viewer (light or dark)",
"LIVE_DEV_STATUS_TIP_SYNC_ERROR": "Live Preview (not updating due to syntax error)",
"LIVE_DEV_SETTINGS": "Live Preview Settings\u2026",
"LIVE_DEV_SETTINGS_BANNER": "Set up a custom server to live preview `{0}` and other server-rendered files (PHP, JSP, etc.)",
diff --git a/test/spec/md-editor-edit-integ-test.js b/test/spec/md-editor-edit-integ-test.js
index 7399de92d..0e40fc006 100644
--- a/test/spec/md-editor-edit-integ-test.js
+++ b/test/spec/md-editor-edit-integ-test.js
@@ -210,6 +210,8 @@ define(function (require, exports, module) {
// In reader mode: checkboxes should be disabled
await _enterReaderMode();
+ await awaitsFor(() => _getCheckboxes().length > 0,
+ "checkboxes to appear in reader mode");
let checkboxes = _getCheckboxes();
expect(checkboxes.length).toBeGreaterThan(0);
for (const cb of checkboxes) {
@@ -218,6 +220,8 @@ define(function (require, exports, module) {
// In edit mode: checkboxes should be enabled
await _enterEditMode();
+ await awaitsFor(() => _getCheckboxes().length > 0,
+ "checkboxes to appear in edit mode");
checkboxes = _getCheckboxes();
expect(checkboxes.length).toBeGreaterThan(0);
for (const cb of checkboxes) {
diff --git a/test/spec/md-editor-integ-test.js b/test/spec/md-editor-integ-test.js
index d0da270c3..e6620034b 100644
--- a/test/spec/md-editor-integ-test.js
+++ b/test/spec/md-editor-integ-test.js
@@ -774,12 +774,13 @@ define(function (require, exports, module) {
// Verify edit mode preserved
await _assertMdEditMode(true);
- // Verify scroll position preserved (wider tolerance for CI)
+ // Verify scroll position preserved (wider tolerance for CI).
+ // Wait longer as theme/content sync after reopen can cause transient reflows.
await awaitsFor(() => {
const scroll = _getViewerScrollTop();
return scroll > 10 && Math.abs(scroll - scrollBefore) < 150;
- }, "scroll position to be preserved after panel reopen", 5000);
- }, 15000);
+ }, "scroll position to be preserved after panel reopen", 8000);
+ }, 20000);
it("should reload button re-render current file with fresh DOM preserving scroll and edit mode", async function () {
await _openMdFileAndWaitForPreview("long.md");