@@ -2309,6 +2397,7 @@
No comments yet
+
diff --git a/script.js b/script.js
index 4c25a960..9a856795 100644
--- a/script.js
+++ b/script.js
@@ -943,6 +943,18 @@ document.addEventListener("DOMContentLoaded", async function () {
const aboutVersion = document.getElementById("about-version");
const aboutReleaseNotes = document.getElementById("about-release-notes");
const privateModeToggle = document.getElementById("private-mode-toggle");
+ const visualSettingsButton = document.getElementById("visual-settings-button");
+ const menuVisualSettingsButton = document.getElementById("menu-visual-settings-button");
+ const mobileVisualSettingsButton = document.getElementById("mobile-visual-settings-button");
+ const visualSettingsModal = document.getElementById("visual-settings-modal");
+ const visualSettingsCloseBtn = document.getElementById("visual-settings-close-btn");
+ const visualSettingsCloseIcon = document.getElementById("visual-settings-close-icon");
+ const visualSettingsResetBtn = document.getElementById("visual-settings-reset-btn");
+ const selectHljsTheme = document.getElementById("visual-setting-hljs-theme");
+ const selectMathFont = document.getElementById("visual-setting-math-font");
+ const selectMermaidTheme = document.getElementById("visual-setting-mermaid-theme");
+ const selectMermaidLook = document.getElementById("visual-setting-mermaid-look");
+ const selectEmojiSkinTone = document.getElementById("visual-setting-emoji-skin-tone");
const storageSettingsButton = document.getElementById("storage-settings-button");
const trashSettingsButton = document.getElementById("trash-settings-button");
const storageSettingsModal = document.getElementById("storage-settings-modal");
@@ -24031,6 +24043,13 @@ ${selector} .arrowheadPath {
});
}
+ if (mobileVisualSettingsButton) {
+ mobileVisualSettingsButton.addEventListener('click', function() {
+ closeMobileMenu();
+ openVisualSettingsModal(mobileVisualSettingsButton);
+ });
+ }
+
if (mobileStorageSettingsButton) {
mobileStorageSettingsButton.addEventListener('click', function() {
closeMobileMenu();
@@ -24045,6 +24064,157 @@ ${selector} .arrowheadPath {
});
}
+ // Visual Library Settings (Issue #129)
+ function populateVisualSettingsOptions() {
+ const options = (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.getOptions === 'function')
+ ? window.visualSettingsAdapters.getOptions()
+ : (window.visualSettingsStore ? window.visualSettingsStore.DEFAULT_OPTIONS : null);
+
+ if (!options) return;
+
+ const list = [
+ { el: selectHljsTheme, opts: options.hljsTheme },
+ { el: selectMathFont, opts: options.mathFont },
+ { el: selectMermaidTheme, opts: options.mermaidTheme },
+ { el: selectMermaidLook, opts: options.mermaidLook },
+ { el: selectEmojiSkinTone, opts: options.emojiSkinTone }
+ ];
+
+ list.forEach(function(item) {
+ if (!item.el || !item.opts) return;
+ const currentVal = item.el.value;
+ item.el.innerHTML = '';
+ item.opts.forEach(function(opt) {
+ const val = typeof opt === 'object' && opt !== null ? opt.value : opt;
+ const label = typeof opt === 'object' && opt !== null ? opt.label : opt;
+ const optEl = document.createElement('option');
+ optEl.value = val;
+ optEl.textContent = label;
+ item.el.appendChild(optEl);
+ });
+ if (currentVal) item.el.value = currentVal;
+ });
+ }
+
+ function syncVisualSettingsUI() {
+ if (!window.visualSettingsStore) return;
+ const settings = window.visualSettingsStore.getSettings();
+ if (selectHljsTheme && settings.hljsTheme) selectHljsTheme.value = settings.hljsTheme;
+ if (selectMathFont && settings.mathFont) selectMathFont.value = settings.mathFont;
+ if (selectMermaidTheme && settings.mermaidTheme) selectMermaidTheme.value = settings.mermaidTheme;
+ if (selectMermaidLook && settings.mermaidLook) selectMermaidLook.value = settings.mermaidLook;
+ if (selectEmojiSkinTone && settings.emojiSkinTone) selectEmojiSkinTone.value = settings.emojiSkinTone;
+ }
+
+ function openVisualSettingsModal(opener) {
+ if (!visualSettingsModal) return;
+ populateVisualSettingsOptions();
+ syncVisualSettingsUI();
+ openAppModal(visualSettingsModal, {
+ focusTarget: selectHljsTheme,
+ returnFocus: opener || document.activeElement,
+ onClose: closeVisualSettingsModal
+ });
+ }
+
+ function closeVisualSettingsModal() {
+ if (visualSettingsModal) closeAppModal(visualSettingsModal);
+ }
+
+ async function applyVisualSettings(settings, changedKey) {
+ if (window.visualSettingsAdapters) {
+ try {
+ if (changedKey === 'hljsTheme') {
+ await window.visualSettingsAdapters.applyHighlightTheme(settings.hljsTheme);
+ } else if (changedKey === 'mathFont') {
+ await window.visualSettingsAdapters.applyMathFont(settings.mathFont);
+ } else if (changedKey === 'mermaidTheme' || changedKey === 'mermaidLook') {
+ await window.visualSettingsAdapters.applyMermaid({
+ theme: settings.mermaidTheme,
+ look: settings.mermaidLook
+ });
+ } else if (changedKey === 'emojiSkinTone') {
+ await window.visualSettingsAdapters.applyEmojiSkinTone(settings.emojiSkinTone);
+ } else {
+ if (typeof window.visualSettingsAdapters.applyAll === 'function') {
+ await window.visualSettingsAdapters.applyAll(settings);
+ } else {
+ await Promise.all([
+ window.visualSettingsAdapters.applyHighlightTheme && window.visualSettingsAdapters.applyHighlightTheme(settings.hljsTheme),
+ window.visualSettingsAdapters.applyMathFont && window.visualSettingsAdapters.applyMathFont(settings.mathFont),
+ window.visualSettingsAdapters.applyMermaid && window.visualSettingsAdapters.applyMermaid({ theme: settings.mermaidTheme, look: settings.mermaidLook }),
+ window.visualSettingsAdapters.applyEmojiSkinTone && window.visualSettingsAdapters.applyEmojiSkinTone(settings.emojiSkinTone)
+ ]);
+ }
+ }
+ } catch (err) {
+ console.warn('Error applying visual setting via adapter:', err);
+ }
+ }
+ renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+
+ if (visualSettingsButton) {
+ visualSettingsButton.addEventListener('click', function() {
+ openVisualSettingsModal(visualSettingsButton);
+ });
+ }
+
+ if (menuVisualSettingsButton) {
+ menuVisualSettingsButton.addEventListener('click', function() {
+ const settingsToggle = document.getElementById('workspaceSettingsDropdown');
+ if (settingsToggle && window.bootstrap && bootstrap.Dropdown) {
+ bootstrap.Dropdown.getOrCreateInstance(settingsToggle).hide();
+ }
+ openVisualSettingsModal(menuVisualSettingsButton);
+ });
+ }
+
+ if (visualSettingsCloseBtn) visualSettingsCloseBtn.addEventListener('click', closeVisualSettingsModal);
+ if (visualSettingsCloseIcon) visualSettingsCloseIcon.addEventListener('click', closeVisualSettingsModal);
+
+ if (selectHljsTheme) {
+ selectHljsTheme.addEventListener('change', function() {
+ if (window.visualSettingsStore) window.visualSettingsStore.updateSetting('hljsTheme', selectHljsTheme.value);
+ });
+ }
+ if (selectMathFont) {
+ selectMathFont.addEventListener('change', function() {
+ if (window.visualSettingsStore) window.visualSettingsStore.updateSetting('mathFont', selectMathFont.value);
+ });
+ }
+ if (selectMermaidTheme) {
+ selectMermaidTheme.addEventListener('change', function() {
+ if (window.visualSettingsStore) window.visualSettingsStore.updateSetting('mermaidTheme', selectMermaidTheme.value);
+ });
+ }
+ if (selectMermaidLook) {
+ selectMermaidLook.addEventListener('change', function() {
+ if (window.visualSettingsStore) window.visualSettingsStore.updateSetting('mermaidLook', selectMermaidLook.value);
+ });
+ }
+ if (selectEmojiSkinTone) {
+ selectEmojiSkinTone.addEventListener('change', function() {
+ if (window.visualSettingsStore) window.visualSettingsStore.updateSetting('emojiSkinTone', selectEmojiSkinTone.value);
+ });
+ }
+
+ if (visualSettingsResetBtn) {
+ visualSettingsResetBtn.addEventListener('click', function() {
+ if (window.visualSettingsStore) {
+ window.visualSettingsStore.resetSettings();
+ syncVisualSettingsUI();
+ }
+ });
+ }
+
+ if (window.visualSettingsStore) {
+ window.visualSettingsStore.onSettingsChange(function(settings, changedKey) {
+ syncVisualSettingsUI();
+ applyVisualSettings(settings, changedKey);
+ });
+ }
+
if (storageSettingsButton) {
storageSettingsButton.addEventListener('click', function() {
const settingsToggle = document.getElementById('workspaceSettingsDropdown');
@@ -24527,6 +24697,13 @@ ${selector} .arrowheadPath {
updateMapThemes();
updateStlThemes();
+ if (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.onAppThemeChange === 'function') {
+ try {
+ window.visualSettingsAdapters.onAppThemeChange(theme);
+ } catch (err) {
+ console.warn('Error in visualSettingsAdapters.onAppThemeChange:', err);
+ }
+ }
});
async function nativeSaveMarkdown() {
@@ -32303,6 +32480,16 @@ ${selector} .arrowheadPath {
activate: releaseNotesLaunch.activate
});
}
+
+ // Initialize Visual Library Settings (Issue #129)
+ window.renderMarkdown = renderMarkdown;
+ populateVisualSettingsOptions();
+ syncVisualSettingsUI();
+ if (window.visualSettingsStore) {
+ const initialVisualSettings = window.visualSettingsStore.getSettings();
+ await applyVisualSettings(initialVisualSettings, '*');
+ }
+
document.documentElement.dataset.appReady = 'true';
window.dispatchEvent(new Event('markdown-viewer:ready'));
});
diff --git a/styles.css b/styles.css
index 5671d94c..a9041bd1 100644
--- a/styles.css
+++ b/styles.css
@@ -14522,3 +14522,158 @@ body.release-notes-active .mobile-stats-container {
min-height: 0;
overflow: hidden;
}
+
+/* ==========================================================================
+ Visual Library Settings Modal (Issue #129)
+ ========================================================================== */
+
+.visual-settings-box {
+ width: min(94vw, 560px);
+ max-width: 560px;
+}
+
+.visual-settings-body {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px 20px;
+ max-height: calc(85vh - 120px);
+ overflow-y: auto;
+}
+
+.visual-setting-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.visual-setting-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--ui-font-md);
+ font-weight: 600;
+ color: var(--text-color);
+ margin-bottom: 2px;
+}
+
+.visual-setting-label i {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ color: var(--text-secondary);
+}
+
+.visual-setting-desc {
+ font-size: var(--ui-font-sm);
+ color: var(--text-secondary);
+ line-height: 1.4;
+ margin: 0;
+}
+
+.visual-setting-select {
+ display: block;
+ width: 100%;
+ min-height: 38px;
+ padding: 8px 12px;
+ font-size: var(--ui-font-md);
+ line-height: 1.5;
+ color: var(--text-color);
+ background-color: var(--bg-color);
+ background-clip: padding-box;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ cursor: pointer;
+}
+
+.visual-setting-select:focus,
+.visual-setting-select:focus-visible {
+ outline: 0;
+ border-color: #0969da;
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.25);
+}
+
+.visual-settings-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 20px;
+ border-top: 1px solid var(--border-color);
+ background-color: var(--bg-color);
+ border-bottom-left-radius: inherit;
+ border-bottom-right-radius: inherit;
+}
+
+.visual-settings-actions-left {
+ display: flex;
+ align-items: center;
+}
+
+.visual-settings-actions-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Dark mode styling for visual settings */
+[data-theme="dark"] .visual-setting-select {
+ background-color: #161b22;
+ color: #c9d1d9;
+ border-color: #30363d;
+}
+
+[data-theme="dark"] .visual-setting-select:focus,
+[data-theme="dark"] .visual-setting-select:focus-visible {
+ border-color: #58a6ff;
+ box-shadow: 0 0 0 3px rgba(56, 139, 253, 0.3);
+}
+
+[data-theme="dark"] .visual-setting-select option {
+ background-color: #161b22;
+ color: #c9d1d9;
+}
+
+[data-theme="dark"] .visual-settings-actions {
+ background-color: var(--bg-color);
+ border-top-color: #30363d;
+}
+
+/* Mobile responsiveness */
+@media (max-width: 768px) {
+ .visual-settings-box {
+ width: 96vw;
+ margin: 8px auto;
+ }
+
+ .visual-settings-body {
+ padding: 14px 16px;
+ gap: 14px;
+ max-height: calc(85vh - 140px);
+ }
+
+ .visual-setting-select {
+ min-height: 44px; /* Better touch target */
+ font-size: var(--ui-font-base, 14px);
+ }
+
+ .visual-settings-actions {
+ padding: 12px 16px;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .visual-settings-actions-left,
+ .visual-settings-actions-right {
+ width: 100%;
+ }
+
+ .visual-settings-actions-left button,
+ .visual-settings-actions-right button {
+ width: 100%;
+ min-height: 40px;
+ justify-content: center;
+ }
+}
diff --git a/tests/helpers/static-build-check.mjs b/tests/helpers/static-build-check.mjs
index b27fdc15..f3b471d3 100644
--- a/tests/helpers/static-build-check.mjs
+++ b/tests/helpers/static-build-check.mjs
@@ -49,12 +49,16 @@ const syntaxCheckedFiles = [
'workspace-storage.js',
'preview-worker.js',
'sw.js',
+ ...(fs.existsSync(path.join(rootDir, 'visual-settings-store.js')) ? ['visual-settings-store.js'] : []),
+ ...(fs.existsSync(path.join(rootDir, 'visual-settings-adapters.js')) ? ['visual-settings-adapters.js'] : []),
'assets/seo-metadata.mjs',
'seo/welcome-content.mjs',
'desktop-app/resources/js/main.js',
'desktop-app/resources/js/script.js',
'desktop-app/resources/js/workspace-storage.js',
'desktop-app/resources/js/preview-worker.js',
+ ...(fs.existsSync(path.join(rootDir, 'desktop-app/resources/js/visual-settings-store.js')) ? ['desktop-app/resources/js/visual-settings-store.js'] : []),
+ ...(fs.existsSync(path.join(rootDir, 'desktop-app/resources/js/visual-settings-adapters.js')) ? ['desktop-app/resources/js/visual-settings-adapters.js'] : []),
'assets/i18n/generate-ui-locales.mjs',
'assets/i18n/audit-ui-locales.mjs'
];
@@ -66,12 +70,20 @@ for (const relativePath of syntaxCheckedFiles) {
});
}
-for (const [sourcePath, desktopPath] of [
+const syncedDesktopFiles = [
['script.js', 'desktop-app/resources/js/script.js'],
['workspace-storage.js', 'desktop-app/resources/js/workspace-storage.js'],
['preview-worker.js', 'desktop-app/resources/js/preview-worker.js'],
['styles.css', 'desktop-app/resources/styles.css']
-]) {
+];
+if (fs.existsSync(path.join(rootDir, 'visual-settings-store.js'))) {
+ syncedDesktopFiles.push(['visual-settings-store.js', 'desktop-app/resources/js/visual-settings-store.js']);
+}
+if (fs.existsSync(path.join(rootDir, 'visual-settings-adapters.js'))) {
+ syncedDesktopFiles.push(['visual-settings-adapters.js', 'desktop-app/resources/js/visual-settings-adapters.js']);
+}
+
+for (const [sourcePath, desktopPath] of syncedDesktopFiles) {
const source = fs.readFileSync(path.join(rootDir, sourcePath));
const desktopCopy = fs.readFileSync(path.join(rootDir, desktopPath));
if (!source.equals(desktopCopy)) {
diff --git a/tests/unit/visual-settings-store.test.mjs b/tests/unit/visual-settings-store.test.mjs
new file mode 100644
index 00000000..23e0fc53
--- /dev/null
+++ b/tests/unit/visual-settings-store.test.mjs
@@ -0,0 +1,112 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+// Mock localStorage and window in Node environment
+const mockStorage = new Map();
+global.localStorage = {
+ getItem: (key) => (mockStorage.has(key) ? mockStorage.get(key) : null),
+ setItem: (key, val) => mockStorage.set(key, String(val)),
+ removeItem: (key) => mockStorage.delete(key),
+ clear: () => mockStorage.clear()
+};
+global.window = {};
+
+// Import store
+const { createRequire } = await import('node:module');
+const require = createRequire(import.meta.url);
+const store = require('../../visual-settings-store.js');
+
+test('visualSettingsStore - returns initial defaults', () => {
+ mockStorage.clear();
+ store.resetSettings();
+ const settings = store.getSettings();
+ assert.equal(settings.hljsTheme, 'auto');
+ assert.equal(settings.mathFont, 'mathjax-modern');
+ assert.equal(settings.mermaidTheme, 'auto');
+ assert.equal(settings.mermaidLook, 'classic');
+ assert.equal(settings.emojiSkinTone, 'default');
+});
+
+test('visualSettingsStore - updateSetting updates value, persists, and notifies listeners', () => {
+ mockStorage.clear();
+ store.resetSettings();
+
+ let changeNotified = false;
+ let notifiedKey = null;
+ let notifiedVal = null;
+ let notifiedSettings = null;
+
+ const unsubscribe = store.onSettingsChange((settings, key, val) => {
+ changeNotified = true;
+ notifiedKey = key;
+ notifiedVal = val;
+ notifiedSettings = settings;
+ });
+
+ const success = store.updateSetting('hljsTheme', 'monokai');
+ assert.equal(success, true);
+ assert.equal(store.getSettings().hljsTheme, 'monokai');
+ assert.equal(changeNotified, true);
+ assert.equal(notifiedKey, 'hljsTheme');
+ assert.equal(notifiedVal, 'monokai');
+ assert.equal(notifiedSettings.hljsTheme, 'monokai');
+
+ // Verify saved in localStorage
+ const saved = JSON.parse(mockStorage.get(store.STORAGE_KEY));
+ assert.equal(saved.hljsTheme, 'monokai');
+
+ unsubscribe();
+});
+
+test('visualSettingsStore - rejects invalid values or unknown keys', () => {
+ store.resetSettings();
+
+ // Invalid value
+ const badValSuccess = store.updateSetting('mermaidTheme', 'nonexistent-theme');
+ assert.equal(badValSuccess, false);
+ assert.equal(store.getSettings().mermaidTheme, 'auto');
+
+ // Unknown key
+ const badKeySuccess = store.updateSetting('unknownKey', 'something');
+ assert.equal(badKeySuccess, false);
+});
+
+test('visualSettingsStore - resetSettings restores defaults and saves', () => {
+ store.updateSetting('mermaidLook', 'handDrawn');
+ store.updateSetting('emojiSkinTone', 'tone3');
+ assert.equal(store.getSettings().mermaidLook, 'handDrawn');
+ assert.equal(store.getSettings().emojiSkinTone, 'tone3');
+
+ let resetNotified = false;
+ const unsub = store.onSettingsChange((settings, key) => {
+ if (key === '*') resetNotified = true;
+ });
+
+ const resetResult = store.resetSettings();
+ assert.equal(resetResult.mermaidLook, 'classic');
+ assert.equal(resetResult.emojiSkinTone, 'default');
+ assert.equal(resetNotified, true);
+
+ const saved = JSON.parse(mockStorage.get(store.STORAGE_KEY));
+ assert.equal(saved.mermaidLook, 'classic');
+ assert.equal(saved.emojiSkinTone, 'default');
+
+ unsub();
+});
+
+test('visualSettingsStore - validation recovers from corrupted or invalid localStorage data', () => {
+ mockStorage.set(store.STORAGE_KEY, JSON.stringify({
+ hljsTheme: 'invalid-hljs',
+ mathFont: 'mathjax-stix2',
+ mermaidTheme: 'invalid-mermaid',
+ corruptKey: 123
+ }));
+
+ // Re-run validation
+ const validated = store.validateSettings(JSON.parse(mockStorage.get(store.STORAGE_KEY)));
+ assert.equal(validated.hljsTheme, 'auto'); // Fell back to default
+ assert.equal(validated.mathFont, 'mathjax-stix2'); // Kept valid value
+ assert.equal(validated.mermaidTheme, 'auto'); // Fell back to default
+ assert.equal(validated.mermaidLook, 'classic'); // Restored missing key
+ assert.equal(validated.emojiSkinTone, 'default'); // Restored missing key
+});
diff --git a/visual-settings-store.js b/visual-settings-store.js
new file mode 100644
index 00000000..a73167be
--- /dev/null
+++ b/visual-settings-store.js
@@ -0,0 +1,183 @@
+(function () {
+ 'use strict';
+
+ const STORAGE_KEY = 'mdv.visualSettings.v1';
+
+ const DEFAULT_SETTINGS = Object.freeze({
+ hljsTheme: 'auto',
+ mathFont: 'mathjax-modern',
+ mermaidTheme: 'auto',
+ mermaidLook: 'classic',
+ emojiSkinTone: 'default'
+ });
+
+ const DEFAULT_OPTIONS = Object.freeze({
+ hljsTheme: Object.freeze([
+ 'auto', 'github', 'github-dark', 'monokai', 'dracula',
+ 'atom-one-dark', 'atom-one-light', 'solarized-dark', 'solarized-light',
+ 'vs', 'vs2015', 'nord', 'tokyo-night-dark', 'tokyo-night-light', 'default'
+ ]),
+ mathFont: Object.freeze([
+ 'mathjax-modern', 'mathjax-tex', 'mathjax-stix2', 'mathjax-asana',
+ 'mathjax-bonum', 'mathjax-dejavu', 'mathjax-fira', 'mathjax-pagella',
+ 'mathjax-schola', 'mathjax-termes'
+ ]),
+ mermaidTheme: Object.freeze([
+ 'auto', 'default', 'neutral', 'dark', 'forest', 'base'
+ ]),
+ mermaidLook: Object.freeze([
+ 'classic', 'handDrawn'
+ ]),
+ emojiSkinTone: Object.freeze([
+ 'default', 'tone1', 'tone2', 'tone3', 'tone4', 'tone5'
+ ])
+ });
+
+ function getEffectiveDefaults() {
+ if (typeof window !== 'undefined' && window.visualSettingsAdapters && typeof window.visualSettingsAdapters.getDefaults === 'function') {
+ try {
+ const defs = window.visualSettingsAdapters.getDefaults();
+ if (defs && typeof defs === 'object') {
+ return Object.assign({}, DEFAULT_SETTINGS, defs);
+ }
+ } catch (e) {
+ console.warn('Error reading defaults from visualSettingsAdapters:', e);
+ }
+ }
+ return Object.assign({}, DEFAULT_SETTINGS);
+ }
+
+ function getAllowedValues(key) {
+ if (typeof window !== 'undefined' && window.visualSettingsAdapters && typeof window.visualSettingsAdapters.getOptions === 'function') {
+ try {
+ const opts = window.visualSettingsAdapters.getOptions();
+ if (opts && Array.isArray(opts[key])) {
+ return opts[key].map(item => (typeof item === 'object' && item !== null ? item.value : item));
+ }
+ } catch (e) {
+ console.warn('Error reading options from visualSettingsAdapters:', e);
+ }
+ }
+ return DEFAULT_OPTIONS[key] || [];
+ }
+
+ function validateSettings(settings) {
+ const defaults = getEffectiveDefaults();
+ const valid = {};
+ for (const key of Object.keys(DEFAULT_SETTINGS)) {
+ const allowed = getAllowedValues(key);
+ const val = settings && settings[key];
+ if (typeof val === 'string' && allowed.includes(val)) {
+ valid[key] = val;
+ } else {
+ valid[key] = defaults[key];
+ }
+ }
+ return valid;
+ }
+
+ function loadFromStorage() {
+ const defaults = getEffectiveDefaults();
+ try {
+ if (typeof localStorage === 'undefined') return defaults;
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return defaults;
+ const parsed = JSON.parse(raw);
+ return validateSettings(parsed);
+ } catch (e) {
+ console.warn('Failed to parse visual settings from localStorage:', e);
+ return defaults;
+ }
+ }
+
+ function saveToStorage(settings) {
+ try {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+ }
+ } catch (e) {
+ console.warn('Failed to save visual settings to localStorage:', e);
+ }
+ }
+
+ let currentSettings = loadFromStorage();
+ const listeners = new Set();
+
+ function notify(changedKey, changedValue) {
+ const settingsSnapshot = getSettings();
+ listeners.forEach(cb => {
+ try {
+ cb(settingsSnapshot, changedKey, changedValue);
+ } catch (e) {
+ console.error('Error in visual settings change listener:', e);
+ }
+ });
+ }
+
+ function getSettings() {
+ return Object.assign({}, currentSettings);
+ }
+
+ function updateSetting(key, value) {
+ if (!Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) {
+ console.warn('Unknown visual setting key:', key);
+ return false;
+ }
+ const allowed = getAllowedValues(key);
+ if (!allowed.includes(value)) {
+ console.warn(`Invalid value "${value}" for visual setting "${key}"`);
+ return false;
+ }
+ if (currentSettings[key] === value) {
+ return true;
+ }
+ currentSettings[key] = value;
+ saveToStorage(currentSettings);
+ notify(key, value);
+ return true;
+ }
+
+ function resetSettings() {
+ const defaults = getEffectiveDefaults();
+ currentSettings = Object.assign({}, defaults);
+ saveToStorage(currentSettings);
+ notify('*', null);
+ return getSettings();
+ }
+
+ function onSettingsChange(cb) {
+ if (typeof cb === 'function') {
+ listeners.add(cb);
+ }
+ return function unsubscribe() {
+ listeners.delete(cb);
+ };
+ }
+
+ // Refresh settings when adapters become available or options change
+ function refreshValidation() {
+ currentSettings = validateSettings(currentSettings);
+ saveToStorage(currentSettings);
+ }
+
+ const store = {
+ STORAGE_KEY,
+ DEFAULT_SETTINGS,
+ DEFAULT_OPTIONS,
+ getSettings,
+ updateSetting,
+ resetSettings,
+ onSettingsChange,
+ validateSettings,
+ getAllowedValues,
+ refreshValidation
+ };
+
+ if (typeof window !== 'undefined') {
+ window.visualSettingsStore = store;
+ }
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = store;
+ }
+})();
From 302c0514b9edba20a3c8e687a35a80ec15619270 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 10 Oct 2026 20:43:57 +0530
Subject: [PATCH 02/15] feat(settings): implement visual library adapters for
hljs, MathJax, Mermaid, JoyPixels
---
desktop-app/resources/index.html | 2 +
desktop-app/resources/js/script.js | 38 +-
index.html | 2 +
script.js | 38 +-
tests/unit/visual-settings-adapters.test.mjs | 215 +++++++++
visual-settings-adapters.js | 433 +++++++++++++++++++
6 files changed, 714 insertions(+), 14 deletions(-)
create mode 100644 tests/unit/visual-settings-adapters.test.mjs
create mode 100644 visual-settings-adapters.js
diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html
index f366daf8..f101008c 100644
--- a/desktop-app/resources/index.html
+++ b/desktop-app/resources/index.html
@@ -7,6 +7,7 @@
+
@@ -2302,6 +2303,7 @@
No comments yet
+
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index 9a856795..b80b55b6 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -111,6 +111,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// PERF-002: Lazy script loader for optional heavy libraries
const CDN_INTEGRITY = Object.freeze({
'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js': 'sha384-yQ4mmBBT+vhTAwjFH0toJXNYJ6O4usWnt6EPIdWwrRvx2V/n5lXuDZQwQFeSFydF',
+ 'https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7/tex-mml-chtml.js': 'sha384-2f5bAKjuFIbbQ+0O9aSu5W1OyLA4q80QrDEvjXDwJexjU9VqoJhMgw19pYVpSJab',
'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js': 'sha384-M5jmNxKC9EVnuqeMwRHvFuYUE8Hhp0TgBruj/GZRkYtiMrCRgH7yvv5KY+Owi7TW',
'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js': 'sha384-JcnsjUPPylna1s1fvi1u12X5qjY5OL56iySh75FdtrwhO/SWXgMjoVqcKyIIWOLk',
'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js': 'sha384-ZZ1pncU3bQe8y31yfZdMFdSpttDoPmOZg2wguVK9almUodir1PghgT0eY7Mrty8H',
@@ -188,7 +189,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// CDN URLs for lazy-loaded libraries
const CDN = {
mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js',
- mathjax: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js',
+ mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7/tex-mml-chtml.js',
jspdf: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js',
@@ -2249,22 +2250,28 @@ document.addEventListener("DOMContentLoaded", async function () {
applyDirectionToContent(initialDirection);
updateDirectionToggleUI(initialDirection);
- // Track last Mermaid theme to avoid redundant re-initialization (PERF-005)
+ // Track last Mermaid theme and look to avoid redundant re-initialization (PERF-005)
let _lastMermaidTheme = null;
let _mermaidThemeReinitTimeout = null;
let _themeTransitionTimeout = null;
const initMermaid = (forceReinit, themeOverride) => {
if (typeof mermaid === 'undefined') return; // PERF-002: Not loaded yet
const currentTheme = themeOverride || document.documentElement.getAttribute("data-theme");
- const mermaidTheme = currentTheme === "dark" ? "dark" : "default";
+ const config = window.visualSettingsAdapters
+ ? window.visualSettingsAdapters.getMermaidConfig(currentTheme)
+ : { theme: currentTheme === "dark" ? "dark" : "default", look: "classic" };
+ const mermaidTheme = config.theme;
+ const mermaidLook = config.look || "classic";
- // Skip re-initialization if theme hasn't changed (PERF-005)
- if (!forceReinit && _lastMermaidTheme === mermaidTheme) return;
- _lastMermaidTheme = mermaidTheme;
+ // Skip re-initialization if theme & look haven't changed (PERF-005)
+ const configKey = `${mermaidTheme}:${mermaidLook}`;
+ if (!forceReinit && _lastMermaidTheme === configKey) return;
+ _lastMermaidTheme = configKey;
mermaid.initialize({
startOnLoad: false,
theme: mermaidTheme,
+ look: mermaidLook,
securityLevel: 'strict',
flowchart: { useMaxWidth: true, htmlLabels: true },
fontSize: 16,
@@ -4121,6 +4128,9 @@ document.addEventListener("DOMContentLoaded", async function () {
function configureMathJax() {
if (window.MathJax && typeof MathJax.typesetPromise === 'function') return;
if (window.MathJax && MathJax.loader && MathJax.tex) return;
+ const initialFont = window.visualSettingsAdapters
+ ? window.visualSettingsAdapters.getCurrentMathFont()
+ : 'mathjax-modern';
window.MathJax = {
startup: {
typeset: false
@@ -4128,6 +4138,12 @@ document.addEventListener("DOMContentLoaded", async function () {
options: {
a11y: { inTabOrder: false }
},
+ output: {
+ font: initialFont
+ },
+ chtml: {
+ font: initialFont
+ },
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
@@ -15026,6 +15042,7 @@ ${selector} .arrowheadPath {
}
function renderMarkdown(options) {
+ window.renderMarkdown = renderMarkdown;
stopActiveAbcPlayback();
options = options || {};
const rawVal = markdownEditor.value;
@@ -17070,7 +17087,10 @@ ${selector} .arrowheadPath {
while ((match = emojiRegex.exec(text)) !== null) {
const shortcode = match[1];
- const emoji = joypixels.shortnameToUnicode(`:${shortcode}:`);
+ const effectiveShortcode = (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.resolveEmojiShortcode === 'function')
+ ? window.visualSettingsAdapters.resolveEmojiShortcode(shortcode)
+ : shortcode;
+ const emoji = joypixels.shortnameToUnicode(`:${effectiveShortcode}:`);
if (emoji !== `:${shortcode}:`) { // If conversion was successful
hasEmoji = true;
@@ -24695,6 +24715,10 @@ ${selector} .arrowheadPath {
}
}
+ if (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.onAppThemeChange === 'function') {
+ window.visualSettingsAdapters.onAppThemeChange(theme);
+ }
+
updateMapThemes();
updateStlThemes();
if (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.onAppThemeChange === 'function') {
diff --git a/index.html b/index.html
index 1bf06f32..8eab72b1 100644
--- a/index.html
+++ b/index.html
@@ -14,6 +14,7 @@
+
@@ -2397,6 +2398,7 @@
No comments yet
+
diff --git a/script.js b/script.js
index 9a856795..b80b55b6 100644
--- a/script.js
+++ b/script.js
@@ -111,6 +111,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// PERF-002: Lazy script loader for optional heavy libraries
const CDN_INTEGRITY = Object.freeze({
'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js': 'sha384-yQ4mmBBT+vhTAwjFH0toJXNYJ6O4usWnt6EPIdWwrRvx2V/n5lXuDZQwQFeSFydF',
+ 'https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7/tex-mml-chtml.js': 'sha384-2f5bAKjuFIbbQ+0O9aSu5W1OyLA4q80QrDEvjXDwJexjU9VqoJhMgw19pYVpSJab',
'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js': 'sha384-M5jmNxKC9EVnuqeMwRHvFuYUE8Hhp0TgBruj/GZRkYtiMrCRgH7yvv5KY+Owi7TW',
'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js': 'sha384-JcnsjUPPylna1s1fvi1u12X5qjY5OL56iySh75FdtrwhO/SWXgMjoVqcKyIIWOLk',
'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js': 'sha384-ZZ1pncU3bQe8y31yfZdMFdSpttDoPmOZg2wguVK9almUodir1PghgT0eY7Mrty8H',
@@ -188,7 +189,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// CDN URLs for lazy-loaded libraries
const CDN = {
mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js',
- mathjax: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js',
+ mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7/tex-mml-chtml.js',
jspdf: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js',
@@ -2249,22 +2250,28 @@ document.addEventListener("DOMContentLoaded", async function () {
applyDirectionToContent(initialDirection);
updateDirectionToggleUI(initialDirection);
- // Track last Mermaid theme to avoid redundant re-initialization (PERF-005)
+ // Track last Mermaid theme and look to avoid redundant re-initialization (PERF-005)
let _lastMermaidTheme = null;
let _mermaidThemeReinitTimeout = null;
let _themeTransitionTimeout = null;
const initMermaid = (forceReinit, themeOverride) => {
if (typeof mermaid === 'undefined') return; // PERF-002: Not loaded yet
const currentTheme = themeOverride || document.documentElement.getAttribute("data-theme");
- const mermaidTheme = currentTheme === "dark" ? "dark" : "default";
+ const config = window.visualSettingsAdapters
+ ? window.visualSettingsAdapters.getMermaidConfig(currentTheme)
+ : { theme: currentTheme === "dark" ? "dark" : "default", look: "classic" };
+ const mermaidTheme = config.theme;
+ const mermaidLook = config.look || "classic";
- // Skip re-initialization if theme hasn't changed (PERF-005)
- if (!forceReinit && _lastMermaidTheme === mermaidTheme) return;
- _lastMermaidTheme = mermaidTheme;
+ // Skip re-initialization if theme & look haven't changed (PERF-005)
+ const configKey = `${mermaidTheme}:${mermaidLook}`;
+ if (!forceReinit && _lastMermaidTheme === configKey) return;
+ _lastMermaidTheme = configKey;
mermaid.initialize({
startOnLoad: false,
theme: mermaidTheme,
+ look: mermaidLook,
securityLevel: 'strict',
flowchart: { useMaxWidth: true, htmlLabels: true },
fontSize: 16,
@@ -4121,6 +4128,9 @@ document.addEventListener("DOMContentLoaded", async function () {
function configureMathJax() {
if (window.MathJax && typeof MathJax.typesetPromise === 'function') return;
if (window.MathJax && MathJax.loader && MathJax.tex) return;
+ const initialFont = window.visualSettingsAdapters
+ ? window.visualSettingsAdapters.getCurrentMathFont()
+ : 'mathjax-modern';
window.MathJax = {
startup: {
typeset: false
@@ -4128,6 +4138,12 @@ document.addEventListener("DOMContentLoaded", async function () {
options: {
a11y: { inTabOrder: false }
},
+ output: {
+ font: initialFont
+ },
+ chtml: {
+ font: initialFont
+ },
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
@@ -15026,6 +15042,7 @@ ${selector} .arrowheadPath {
}
function renderMarkdown(options) {
+ window.renderMarkdown = renderMarkdown;
stopActiveAbcPlayback();
options = options || {};
const rawVal = markdownEditor.value;
@@ -17070,7 +17087,10 @@ ${selector} .arrowheadPath {
while ((match = emojiRegex.exec(text)) !== null) {
const shortcode = match[1];
- const emoji = joypixels.shortnameToUnicode(`:${shortcode}:`);
+ const effectiveShortcode = (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.resolveEmojiShortcode === 'function')
+ ? window.visualSettingsAdapters.resolveEmojiShortcode(shortcode)
+ : shortcode;
+ const emoji = joypixels.shortnameToUnicode(`:${effectiveShortcode}:`);
if (emoji !== `:${shortcode}:`) { // If conversion was successful
hasEmoji = true;
@@ -24695,6 +24715,10 @@ ${selector} .arrowheadPath {
}
}
+ if (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.onAppThemeChange === 'function') {
+ window.visualSettingsAdapters.onAppThemeChange(theme);
+ }
+
updateMapThemes();
updateStlThemes();
if (window.visualSettingsAdapters && typeof window.visualSettingsAdapters.onAppThemeChange === 'function') {
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
new file mode 100644
index 00000000..2ba4c9c1
--- /dev/null
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -0,0 +1,215 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import vm from 'node:vm';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '../..');
+
+function createAdapterContext() {
+ const domListeners = new Map();
+ const createdElements = [];
+
+ const documentElement = {
+ getAttribute(name) {
+ return this._attrs?.[name] || null;
+ },
+ setAttribute(name, val) {
+ this._attrs = this._attrs || {};
+ this._attrs[name] = val;
+ }
+ };
+
+ const head = {
+ appendChild(el) {
+ createdElements.push(el);
+ return el;
+ }
+ };
+
+ const document = {
+ documentElement,
+ head,
+ createElement(tag) {
+ let _href = '';
+ const el = {
+ tagName: tag.toUpperCase(),
+ id: '',
+ rel: '',
+ textContent: '',
+ onload: null,
+ onerror: null,
+ get href() { return _href; },
+ set href(val) {
+ _href = val;
+ setTimeout(() => { if (this.onload) this.onload(); }, 1);
+ },
+ setAttribute(k, v) { this[k] = v; },
+ getAttribute(k) { return this[k] || null; },
+ removeAttribute(k) { delete this[k]; }
+ };
+ return el;
+ },
+ getElementById(id) {
+ return createdElements.find(e => e.id === id) || null;
+ },
+ querySelector(selector) {
+ return null;
+ },
+ querySelectorAll(selector) {
+ return [];
+ }
+ };
+
+ class MockMutationObserver {
+ constructor(cb) { this.cb = cb; }
+ observe() {}
+ disconnect() {}
+ }
+
+ const context = {
+ window: {},
+ document,
+ MutationObserver: MockMutationObserver,
+ console: { warn: () => {}, log: () => {} },
+ setTimeout,
+ clearTimeout
+ };
+ context.window = context;
+
+ const code = fs.readFileSync(path.join(rootDir, 'visual-settings-adapters.js'), 'utf8');
+ vm.runInNewContext(code, context);
+
+ return { context, adapters: context.window.visualSettingsAdapters };
+}
+
+test('getOptions() returns valid metadata for all visual settings', () => {
+ const { adapters } = createAdapterContext();
+ const options = adapters.getOptions();
+
+ assert.ok(options.hljsTheme && Array.isArray(options.hljsTheme));
+ assert.ok(options.mathFont && Array.isArray(options.mathFont));
+ assert.ok(options.mermaidTheme && Array.isArray(options.mermaidTheme));
+ assert.ok(options.mermaidLook && Array.isArray(options.mermaidLook));
+ assert.ok(options.emojiSkinTone && Array.isArray(options.emojiSkinTone));
+
+ // Check required values exist
+ const hljsValues = options.hljsTheme.map(o => o.value);
+ assert.ok(hljsValues.includes('auto'));
+ assert.ok(hljsValues.includes('github'));
+ assert.ok(hljsValues.includes('monokai'));
+ assert.ok(hljsValues.includes('dracula'));
+
+ const mathValues = options.mathFont.map(o => o.value);
+ assert.ok(mathValues.includes('mathjax-modern'));
+ assert.ok(mathValues.includes('mathjax-stix2'));
+
+ const mermaidThemes = options.mermaidTheme.map(o => o.value);
+ assert.ok(mermaidThemes.includes('auto'));
+ assert.ok(mermaidThemes.includes('forest'));
+ assert.ok(mermaidThemes.includes('dark'));
+
+ const mermaidLooks = options.mermaidLook.map(o => o.value);
+ assert.ok(mermaidLooks.includes('classic'));
+ assert.ok(mermaidLooks.includes('handDrawn'));
+
+ const tones = options.emojiSkinTone.map(o => o.value);
+ assert.ok(tones.includes('default'));
+ assert.ok(tones.includes('tone1'));
+ assert.ok(tones.includes('tone5'));
+});
+
+test('getDefaults() returns standard default visual settings', () => {
+ const { adapters } = createAdapterContext();
+ const defaults = JSON.parse(JSON.stringify(adapters.getDefaults()));
+
+ assert.deepEqual(defaults, {
+ hljsTheme: 'auto',
+ mathFont: 'mathjax-modern',
+ mermaidTheme: 'auto',
+ mermaidLook: 'classic',
+ emojiSkinTone: 'default'
+ });
+});
+
+test('resolveEmojiShortcode handles skin tones accurately', () => {
+ const { context, adapters } = createAdapterContext();
+
+ // Mock joypixels emojiList
+ context.joypixels = {
+ emojiList: {
+ ':wave:': {},
+ ':wave_tone1:': {},
+ ':wave_tone2:': {},
+ ':wave_tone3:': {},
+ ':wave_tone4:': {},
+ ':wave_tone5:': {},
+ ':thumbsup:': {},
+ ':thumbsup_tone1:': {},
+ ':smile:': {}
+ }
+ };
+
+ // When default tone is active
+ adapters.applyEmojiSkinTone('default');
+ assert.equal(adapters.resolveEmojiShortcode('wave'), 'wave');
+ assert.equal(adapters.resolveEmojiShortcode('smile'), 'smile');
+
+ // When tone1 is active
+ adapters.applyEmojiSkinTone('tone1');
+ assert.equal(adapters.resolveEmojiShortcode('wave'), 'wave_tone1');
+ assert.equal(adapters.resolveEmojiShortcode('thumbsup'), 'thumbsup_tone1');
+ // Emoji that does NOT support tone is untouched
+ assert.equal(adapters.resolveEmojiShortcode('smile'), 'smile');
+ // Emoji with existing tone modifier is untouched
+ assert.equal(adapters.resolveEmojiShortcode('wave_tone3'), 'wave_tone3');
+
+ // When tone5 is active
+ adapters.applyEmojiSkinTone('tone5');
+ assert.equal(adapters.resolveEmojiShortcode('wave'), 'wave_tone5');
+ assert.equal(adapters.resolveEmojiShortcode('smile'), 'smile');
+});
+
+test('applyMermaid handles auto theme and look style', async () => {
+ const { context, adapters } = createAdapterContext();
+
+ context.document.documentElement.setAttribute('data-theme', 'dark');
+ await adapters.applyMermaid({ theme: 'auto', look: 'handDrawn' });
+
+ let config = adapters.getMermaidConfig();
+ assert.equal(config.theme, 'dark');
+ assert.equal(config.look, 'handDrawn');
+
+ context.document.documentElement.setAttribute('data-theme', 'light');
+ adapters.onAppThemeChange('light');
+ config = adapters.getMermaidConfig();
+ assert.equal(config.theme, 'default');
+ assert.equal(config.look, 'handDrawn');
+
+ // Explicit theme overrides auto
+ await adapters.applyMermaid({ theme: 'forest', look: 'classic' });
+ config = adapters.getMermaidConfig();
+ assert.equal(config.theme, 'forest');
+ assert.equal(config.look, 'classic');
+});
+
+test('applyHighlightTheme manages stylesheet and auto theme', async () => {
+ const { context, adapters } = createAdapterContext();
+
+ context.document.documentElement.setAttribute('data-theme', 'light');
+ await adapters.applyHighlightTheme('monokai');
+ let link = context.document.getElementById('mdv-hljs-theme-link');
+ assert.ok(link);
+ assert.ok(link.href.includes('monokai.min.css'));
+
+ await adapters.applyHighlightTheme('auto');
+ link = context.document.getElementById('mdv-hljs-theme-link');
+ assert.ok(link.href.includes('github.min.css'));
+
+ context.document.documentElement.setAttribute('data-theme', 'dark');
+ adapters.onAppThemeChange('dark');
+ link = context.document.getElementById('mdv-hljs-theme-link');
+ assert.ok(link.href.includes('github-dark.min.css'));
+});
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
new file mode 100644
index 00000000..c2319929
--- /dev/null
+++ b/visual-settings-adapters.js
@@ -0,0 +1,433 @@
+/**
+ * visual-settings-adapters.js
+ * Library adapters for Markdown Viewer visual settings (Issue #129).
+ * Manages runtime theming and visual configurations for:
+ * - highlight.js: theme stylesheets (auto, github, monokai, dracula, etc.)
+ * - MathJax: mathematical typesetting fonts (Modern, STIX2, Asana, Bonum, etc.)
+ * - Mermaid: diagram themes (auto, default, dark, forest, neutral, base) & looks (classic, handDrawn)
+ * - JoyPixels: emoji Fitzpatrick skin tones (default, tone1 - tone5)
+ */
+
+(function () {
+ 'use strict';
+
+ // Allowed options metadata
+ const OPTIONS = Object.freeze({
+ hljsTheme: [
+ { value: 'auto', label: 'Auto (Follow App)' },
+ { value: 'github', label: 'GitHub' },
+ { value: 'github-dark', label: 'GitHub Dark' },
+ { value: 'monokai', label: 'Monokai' },
+ { value: 'dracula', label: 'Dracula' },
+ { value: 'atom-one-dark', label: 'Atom One Dark' },
+ { value: 'atom-one-light', label: 'Atom One Light' },
+ { value: 'solarized-dark', label: 'Solarized Dark' },
+ { value: 'solarized-light', label: 'Solarized Light' },
+ { value: 'vs', label: 'Visual Studio' },
+ { value: 'vs2015', label: 'Visual Studio 2015' },
+ { value: 'nord', label: 'Nord' },
+ { value: 'tokyo-night-dark', label: 'Tokyo Night Dark' },
+ { value: 'tokyo-night-light', label: 'Tokyo Night Light' },
+ { value: 'default', label: 'Default' }
+ ],
+ mathFont: [
+ { value: 'mathjax-modern', label: 'Computer Modern (TeX)' },
+ { value: 'mathjax-tex', label: 'TeX (Classic)' },
+ { value: 'mathjax-stix2', label: 'STIX Two' },
+ { value: 'mathjax-asana', label: 'Asana Math' },
+ { value: 'mathjax-bonum', label: 'Gyre Bonum' },
+ { value: 'mathjax-dejavu', label: 'DejaVu Math' },
+ { value: 'mathjax-fira', label: 'Fira Math' },
+ { value: 'mathjax-pagella', label: 'Gyre Pagella' },
+ { value: 'mathjax-schola', label: 'Gyre Schola' },
+ { value: 'mathjax-termes', label: 'Gyre Termes' }
+ ],
+ mermaidTheme: [
+ { value: 'auto', label: 'Auto (Follow App)' },
+ { value: 'default', label: 'Default' },
+ { value: 'neutral', label: 'Neutral' },
+ { value: 'dark', label: 'Dark' },
+ { value: 'forest', label: 'Forest' },
+ { value: 'base', label: 'Base' }
+ ],
+ mermaidLook: [
+ { value: 'classic', label: 'Classic' },
+ { value: 'handDrawn', label: 'Hand Drawn' }
+ ],
+ emojiSkinTone: [
+ { value: 'default', label: 'Default (Yellow)' },
+ { value: 'tone1', label: 'Light (Tone 1)' },
+ { value: 'tone2', label: 'Medium-Light (Tone 2)' },
+ { value: 'tone3', label: 'Medium (Tone 3)' },
+ { value: 'tone4', label: 'Medium-Dark (Tone 4)' },
+ { value: 'tone5', label: 'Dark (Tone 5)' }
+ ]
+ });
+
+ const DEFAULTS = Object.freeze({
+ hljsTheme: 'auto',
+ mathFont: 'mathjax-modern',
+ mermaidTheme: 'auto',
+ mermaidLook: 'classic',
+ emojiSkinTone: 'default'
+ });
+
+ // Active state
+ let _activeHljsTheme = DEFAULTS.hljsTheme;
+ let _activeMathFont = DEFAULTS.mathFont;
+ let _activeMermaidTheme = DEFAULTS.mermaidTheme;
+ let _activeMermaidLook = DEFAULTS.mermaidLook;
+ let _activeEmojiSkinTone = DEFAULTS.emojiSkinTone;
+
+ const HLJS_CDN_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/';
+ const MATHJAX_FONT_CDN_BASE = 'https://cdn.jsdelivr.net/npm/';
+
+ function getEffectiveAppTheme() {
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
+ }
+
+ function resolveHljsThemeName(theme) {
+ if (theme === 'auto') {
+ return getEffectiveAppTheme() === 'dark' ? 'github-dark' : 'github';
+ }
+ return theme || 'github';
+ }
+
+ function resolveMermaidThemeName(theme) {
+ if (theme === 'auto') {
+ return getEffectiveAppTheme() === 'dark' ? 'dark' : 'default';
+ }
+ return theme || 'default';
+ }
+
+ // Ensure helper styling so highlight.js themes integrate cleanly with the editor preview
+ function ensureHljsStyleHelper() {
+ if (!document.getElementById('mdv-hljs-style-helper')) {
+ const style = document.createElement('style');
+ style.id = 'mdv-hljs-style-helper';
+ style.textContent = `
+ .markdown-body pre:has(> code.hljs) {
+ background-color: transparent !important;
+ padding: 0 !important;
+ }
+ .markdown-body pre > code.hljs {
+ border-radius: 6px;
+ padding: 16px !important;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+ }
+
+ /**
+ * Apply highlight.js theme stylesheet.
+ * Dynamically manages the
in .
+ */
+ function applyHighlightTheme(theme) {
+ _activeHljsTheme = theme || DEFAULTS.hljsTheme;
+ ensureHljsStyleHelper();
+
+ const effectiveName = resolveHljsThemeName(_activeHljsTheme);
+ const href = `${HLJS_CDN_BASE}${effectiveName}.min.css`;
+
+ let link = document.getElementById('mdv-hljs-theme-link');
+ if (!link) {
+ link = document.createElement('link');
+ link.id = 'mdv-hljs-theme-link';
+ link.rel = 'stylesheet';
+ document.head.appendChild(link);
+ }
+
+ if (link.getAttribute('href') === href) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ link.onload = () => resolve();
+ link.onerror = () => {
+ console.warn(`[VisualAdapters] Failed to load highlight.js theme: ${href}`);
+ resolve(); // resolve gracefully so UI does not stall
+ };
+ link.href = href;
+ });
+ }
+
+ /**
+ * Dynamically loads a script tag if not already loaded.
+ */
+ const _loadedScripts = new Set();
+ function loadScriptOnce(src) {
+ if (_loadedScripts.has(src)) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const existing = document.querySelector(`script[src="${src}"]`);
+ if (existing) {
+ _loadedScripts.add(src);
+ return resolve();
+ }
+ const script = document.createElement('script');
+ script.src = src;
+ script.charset = 'UTF-8';
+ script.crossOrigin = 'anonymous';
+ script.onload = () => {
+ _loadedScripts.add(src);
+ resolve();
+ };
+ script.onerror = (err) => {
+ console.warn(`[VisualAdapters] Script load failed: ${src}`, err);
+ reject(err);
+ };
+ document.head.appendChild(script);
+ });
+ }
+
+ /**
+ * Apply MathJax mathematical font.
+ */
+ async function applyMathFont(font) {
+ _activeMathFont = font || DEFAULTS.mathFont;
+
+ if (!window.MathJax) {
+ return;
+ }
+
+ // Configure font in MathJax configuration object
+ window.MathJax.config = window.MathJax.config || {};
+ window.MathJax.config.output = window.MathJax.config.output || {};
+ window.MathJax.config.output.font = _activeMathFont;
+ window.MathJax.config.chtml = window.MathJax.config.chtml || {};
+ window.MathJax.config.chtml.font = _activeMathFont;
+
+ // If MathJax startup has completed, switch the font and re-typeset
+ if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ try {
+ // Load external font script if needed (Modern is bundled by default)
+ if (_activeMathFont !== 'mathjax-modern') {
+ const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
+ await loadScriptOnce(fontUrl).catch(() => {});
+ }
+
+ const newOutputJax = window.MathJax.startup.getOutputJax();
+ if (newOutputJax) {
+ window.MathJax.startup.output = newOutputJax;
+ if (typeof window.MathJax.startup.getDocument === 'function') {
+ window.MathJax.startup.document = window.MathJax.startup.getDocument();
+ }
+ }
+
+ // Re-render math in preview if present
+ const preview = document.getElementById('markdown-preview');
+ if (preview && preview.querySelector('mjx-container, .math-block, .math-inline')) {
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ } else if (typeof window.MathJax.typesetPromise === 'function') {
+ await window.MathJax.typesetPromise([preview]);
+ }
+ }
+ } catch (err) {
+ console.warn('[VisualAdapters] MathJax font switch error:', err);
+ }
+ }
+ }
+
+ /**
+ * Apply Mermaid theme and look drawing style.
+ */
+ async function applyMermaid(options) {
+ if (options) {
+ if (typeof options.theme !== 'undefined') _activeMermaidTheme = options.theme;
+ if (typeof options.look !== 'undefined') _activeMermaidLook = options.look;
+ }
+
+ if (typeof mermaid === 'undefined') {
+ return;
+ }
+
+ const effectiveTheme = resolveMermaidThemeName(_activeMermaidTheme);
+ const effectiveLook = _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic';
+
+ try {
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: effectiveTheme,
+ look: effectiveLook,
+ securityLevel: 'strict',
+ flowchart: { useMaxWidth: true, htmlLabels: true },
+ fontSize: 16,
+ gantt: { useWidth: 1200 }
+ });
+ } catch (err) {
+ console.warn('[VisualAdapters] Mermaid initialize error:', err);
+ }
+
+ // Re-render existing Mermaid nodes in the preview
+ const preview = document.getElementById('markdown-preview');
+ if (preview) {
+ const mermaidNodes = preview.querySelectorAll('.mermaid');
+ if (mermaidNodes.length > 0) {
+ mermaidNodes.forEach((node) => {
+ // Restore source code to avoid rendering already rendered SVG
+ const originalCode = node.getAttribute('data-original-code');
+ if (originalCode) {
+ node.innerHTML = decodeURIComponent(originalCode)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ }
+ node.removeAttribute('data-processed');
+
+ const container = node.closest('.mermaid-container');
+ if (container) {
+ const oldToolbar = container.querySelector('.mermaid-toolbar');
+ if (oldToolbar) oldToolbar.remove();
+ }
+ });
+
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+ }
+ }
+ }
+
+ /**
+ * Resolves an emoji shortcode to include the active Fitzpatrick skin tone modifier
+ * if the emoji supports skin tone modifiers, otherwise returns the original shortcode.
+ * Shortcodes with explicit tone modifiers are left untouched.
+ */
+ function resolveEmojiShortcode(shortcode) {
+ if (!_activeEmojiSkinTone || _activeEmojiSkinTone === 'default') {
+ return shortcode;
+ }
+
+ // If already has an explicit skin tone suffix, preserve it
+ if (/_tone[1-5]$/.test(shortcode)) {
+ return shortcode;
+ }
+
+ // Check JoyPixels library list if available
+ const toneCandidate = `${shortcode}_${_activeEmojiSkinTone}`;
+ const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ if (joy && joy.emojiList) {
+ if (joy.emojiList[`:${toneCandidate}:`]) {
+ return toneCandidate;
+ }
+ return shortcode;
+ }
+
+ // If JoyPixels isn't loaded yet, candidate will be validated when loaded
+ return toneCandidate;
+ }
+
+ /**
+ * Apply JoyPixels emoji skin tone.
+ */
+ function applyEmojiSkinTone(tone) {
+ _activeEmojiSkinTone = tone || DEFAULTS.emojiSkinTone;
+
+ // Wrap joypixels.shortnameToUnicode if loaded
+ const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ if (joy && typeof joy.shortnameToUnicode === 'function' && !joy._visualAdapterWrapped) {
+ const origShortnameToUnicode = joy.shortnameToUnicode.bind(joy);
+ joy.shortnameToUnicode = function (str) {
+ if (_activeEmojiSkinTone && _activeEmojiSkinTone !== 'default') {
+ str = str.replace(/:([\w+-]+):/g, function (match, code) {
+ const resolved = resolveEmojiShortcode(code);
+ return `:${resolved}:`;
+ });
+ }
+ return origShortnameToUnicode(str);
+ };
+ joy._visualAdapterWrapped = true;
+ }
+
+ // Re-render preview so shortcodes reflect new tone
+ const preview = document.getElementById('markdown-preview');
+ if (preview && typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+ }
+
+ /**
+ * React to application light/dark theme toggle.
+ * Idempotently re-applies any setting configured as 'auto'.
+ */
+ function onAppThemeChange(newAppTheme) {
+ if (_activeHljsTheme === 'auto') {
+ applyHighlightTheme('auto');
+ }
+ if (_activeMermaidTheme === 'auto') {
+ applyMermaid({ theme: 'auto', look: _activeMermaidLook });
+ }
+ }
+
+ // Observe theme changes on documentElement
+ const themeObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
+ onAppThemeChange(getEffectiveAppTheme());
+ }
+ }
+ });
+
+ if (document.documentElement) {
+ themeObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme']
+ });
+ }
+
+ /**
+ * Apply all settings simultaneously.
+ */
+ async function applyAll(settings) {
+ if (!settings) return;
+ const tasks = [];
+ if (typeof settings.hljsTheme !== 'undefined') {
+ tasks.push(applyHighlightTheme(settings.hljsTheme));
+ }
+ if (typeof settings.mathFont !== 'undefined') {
+ tasks.push(applyMathFont(settings.mathFont));
+ }
+ if (typeof settings.mermaidTheme !== 'undefined' || typeof settings.mermaidLook !== 'undefined') {
+ tasks.push(applyMermaid({
+ theme: settings.mermaidTheme !== undefined ? settings.mermaidTheme : _activeMermaidTheme,
+ look: settings.mermaidLook !== undefined ? settings.mermaidLook : _activeMermaidLook
+ }));
+ }
+ if (typeof settings.emojiSkinTone !== 'undefined') {
+ tasks.push(applyEmojiSkinTone(settings.emojiSkinTone));
+ }
+
+ await Promise.all(tasks);
+
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+ }
+
+ // Public API exported to window.visualSettingsAdapters
+ window.visualSettingsAdapters = {
+ getOptions: () => JSON.parse(JSON.stringify(OPTIONS)),
+ getDefaults: () => Object.assign({}, DEFAULTS),
+ getCurrentSettings: () => ({
+ hljsTheme: _activeHljsTheme,
+ mathFont: _activeMathFont,
+ mermaidTheme: _activeMermaidTheme,
+ mermaidLook: _activeMermaidLook,
+ emojiSkinTone: _activeEmojiSkinTone
+ }),
+ getCurrentMathFont: () => _activeMathFont,
+ getMermaidConfig: (themeOverride) => ({
+ theme: resolveMermaidThemeName(_activeMermaidTheme),
+ look: _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic'
+ }),
+ resolveEmojiShortcode,
+ applyHighlightTheme,
+ applyMathFont,
+ applyMermaid,
+ applyEmojiSkinTone,
+ applyAll,
+ onAppThemeChange
+ };
+
+})();
From dc79db1f7c5f000cef6ff8b41253d96e362f5d55 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 10 Oct 2026 21:03:11 +0530
Subject: [PATCH 03/15] test: add comprehensive E2E tests for visual settings
panel
---
tests/e2e/visual-settings.spec.js | 210 ++++++++++++++++++++++++++++++
1 file changed, 210 insertions(+)
create mode 100644 tests/e2e/visual-settings.spec.js
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
new file mode 100644
index 00000000..8c205a5c
--- /dev/null
+++ b/tests/e2e/visual-settings.spec.js
@@ -0,0 +1,210 @@
+const { test, expect } = require('@playwright/test');
+const { openApp, waitForAppReady } = require('../helpers/app.js');
+
+test.describe('Visual Library Settings Panel (Issue #129)', () => {
+ const SAMPLE_DOC = `
+# Visual Settings Verification
+
+## Code Block
+\`\`\`javascript
+function calculateSum(a, b) {
+ return a + b;
+}
+\`\`\`
+
+## Math
+Here is inline math: $E = mc^2$
+
+And block math:
+$$
+\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
+$$
+
+## Diagram
+\`\`\`mermaid
+graph TD
+ A[Start] --> B{Decision}
+ B -->|Yes| C[Success]
+ B -->|No| D[Retry]
+\`\`\`
+
+## Emoji
+Greetings: :wave: :thumbsup: :smile:
+`;
+
+ test('panel opens and closes via header button, close button, and Esc key with focus trap', async ({ page }) => {
+ await openApp(page);
+
+ const visualBtn = page.locator('#visual-settings-button');
+ await expect(visualBtn).toBeVisible();
+
+ // Open panel
+ await visualBtn.click();
+ const modal = page.locator('#visual-settings-modal');
+ await expect(modal).toBeVisible();
+ await expect(modal).toHaveAttribute('aria-hidden', 'false');
+
+ // Check first control has focus
+ const hljsSelect = page.locator('#visual-setting-hljs-theme');
+ await expect(hljsSelect).toBeFocused();
+
+ // Verify all 5 controls are present with proper labels
+ await expect(page.locator('#visual-setting-hljs-theme')).toBeVisible();
+ await expect(page.locator('#visual-setting-math-font')).toBeVisible();
+ await expect(page.locator('#visual-setting-mermaid-theme')).toBeVisible();
+ await expect(page.locator('#visual-setting-mermaid-look')).toBeVisible();
+ await expect(page.locator('#visual-setting-emoji-skin-tone')).toBeVisible();
+
+ // Close via Esc key
+ await page.keyboard.press('Escape');
+ await expect(modal).not.toBeVisible();
+ await expect(modal).toHaveAttribute('aria-hidden', 'true');
+
+ // Reopen and close via Close button
+ await visualBtn.click();
+ await expect(modal).toBeVisible();
+ await page.locator('#visual-settings-close-btn').click();
+ await expect(modal).not.toBeVisible();
+
+ // Reopen and close via X icon
+ await visualBtn.click();
+ await expect(modal).toBeVisible();
+ await page.locator('#visual-settings-close-icon').click();
+ await expect(modal).not.toBeVisible();
+ });
+
+ test('all settings update live, re-render preview, persist across reloads, and reset', async ({ page }) => {
+ await openApp(page);
+
+ // Set editor content with code, math, mermaid, and emoji
+ const editor = page.locator('#markdown-editor');
+ await editor.fill(SAMPLE_DOC);
+ await editor.dispatchEvent('input');
+
+ // Open settings panel
+ await page.locator('#visual-settings-button').click();
+ await expect(page.locator('#visual-settings-modal')).toBeVisible();
+
+ // 1. Change highlight.js theme
+ const hljsSelect = page.locator('#visual-setting-hljs-theme');
+ await hljsSelect.selectOption('monokai');
+
+ // 2. Change MathJax font
+ const mathFontSelect = page.locator('#visual-setting-math-font');
+ await mathFontSelect.selectOption('mathjax-stix2');
+
+ // 3. Change Mermaid theme and look
+ const mermaidThemeSelect = page.locator('#visual-setting-mermaid-theme');
+ await mermaidThemeSelect.selectOption('forest');
+
+ const mermaidLookSelect = page.locator('#visual-setting-mermaid-look');
+ await mermaidLookSelect.selectOption('handDrawn');
+
+ // 4. Change JoyPixels skin tone
+ const emojiToneSelect = page.locator('#visual-setting-emoji-skin-tone');
+ await emojiToneSelect.selectOption('tone3');
+
+ // Verify localStorage persistence
+ const storedRaw = await page.evaluate(() => localStorage.getItem('mdv.visualSettings.v1'));
+ expect(storedRaw).toBeTruthy();
+ const stored = JSON.parse(storedRaw);
+ expect(stored.hljsTheme).toBe('monokai');
+ expect(stored.mathFont).toBe('mathjax-stix2');
+ expect(stored.mermaidTheme).toBe('forest');
+ expect(stored.mermaidLook).toBe('handDrawn');
+ expect(stored.emojiSkinTone).toBe('tone3');
+
+ // Close modal
+ await page.locator('#visual-settings-close-btn').click();
+ await expect(page.locator('#visual-settings-modal')).not.toBeVisible();
+
+ // Reload page and check persistence
+ await page.reload();
+ await waitForAppReady(page);
+
+ // Verify settings persisted in store
+ const reloadedSettings = await page.evaluate(() => window.visualSettingsStore.getSettings());
+ expect(reloadedSettings.hljsTheme).toBe('monokai');
+ expect(reloadedSettings.mathFont).toBe('mathjax-stix2');
+ expect(reloadedSettings.mermaidTheme).toBe('forest');
+ expect(reloadedSettings.mermaidLook).toBe('handDrawn');
+ expect(reloadedSettings.emojiSkinTone).toBe('tone3');
+
+ // Verify modal selects reflect persisted settings
+ await page.locator('#visual-settings-button').click();
+ await expect(page.locator('#visual-setting-hljs-theme')).toHaveValue('monokai');
+ await expect(page.locator('#visual-setting-math-font')).toHaveValue('mathjax-stix2');
+ await expect(page.locator('#visual-setting-mermaid-theme')).toHaveValue('forest');
+ await expect(page.locator('#visual-setting-mermaid-look')).toHaveValue('handDrawn');
+ await expect(page.locator('#visual-setting-emoji-skin-tone')).toHaveValue('tone3');
+
+ // Test "Reset to defaults"
+ await page.locator('#visual-settings-reset-btn').click();
+
+ // Verify UI reset
+ await expect(page.locator('#visual-setting-hljs-theme')).toHaveValue('auto');
+ await expect(page.locator('#visual-setting-math-font')).toHaveValue('mathjax-modern');
+ await expect(page.locator('#visual-setting-mermaid-theme')).toHaveValue('auto');
+ await expect(page.locator('#visual-setting-mermaid-look')).toHaveValue('classic');
+ await expect(page.locator('#visual-setting-emoji-skin-tone')).toHaveValue('default');
+
+ // Verify storage reset
+ const resetStored = await page.evaluate(() => JSON.parse(localStorage.getItem('mdv.visualSettings.v1')));
+ expect(resetStored.hljsTheme).toBe('auto');
+ expect(resetStored.mathFont).toBe('mathjax-modern');
+ expect(resetStored.mermaidTheme).toBe('auto');
+ expect(resetStored.mermaidLook).toBe('classic');
+ expect(resetStored.emojiSkinTone).toBe('default');
+
+ await page.locator('#visual-settings-close-btn').click();
+ });
+
+ test('visual settings work in dark mode and mobile viewport', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 });
+ await openApp(page);
+
+ // Open mobile menu
+ const mobileMenuToggle = page.locator('#mobile-menu-toggle');
+ await mobileMenuToggle.click();
+ await expect(page.locator('#mobile-menu-panel')).toBeVisible();
+
+ // Open settings section inside mobile menu
+ const mobileSettingsToggle = page.locator('[data-mobile-menu-section-toggle][aria-controls="mobile-menu-settings-panel"]');
+ await mobileSettingsToggle.click();
+
+ // Click mobile visual settings button
+ const mobileVisualBtn = page.locator('#mobile-visual-settings-button');
+ await expect(mobileVisualBtn).toBeVisible();
+ await mobileVisualBtn.click();
+
+ // Modal opens on mobile
+ const modal = page.locator('#visual-settings-modal');
+ await expect(modal).toBeVisible();
+
+ // Change setting on mobile
+ await page.locator('#visual-setting-hljs-theme').selectOption('dracula');
+ await page.locator('#visual-settings-close-btn').click();
+ await expect(modal).not.toBeVisible();
+
+ // Switch to dark appearance
+ await page.evaluate(() => {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ const toggle = document.getElementById('theme-toggle');
+ if (toggle) toggle.click();
+ });
+
+ // Reopen and verify dark mode styling
+ await mobileMenuToggle.click();
+ await mobileSettingsToggle.click();
+ await mobileVisualBtn.click();
+ await expect(modal).toBeVisible();
+
+ const selectBg = await page.locator('#visual-setting-hljs-theme').evaluate(el => {
+ return window.getComputedStyle(el).backgroundColor;
+ });
+ expect(selectBg).toBeTruthy();
+
+ await page.locator('#visual-settings-close-btn').click();
+ });
+});
From 2296a16f4bdb81666d307e3648b44914d0a20a40 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 10 Oct 2026 21:51:47 +0530
Subject: [PATCH 04/15] chore(desktop): sync visual-settings-adapters to
desktop resources
---
.../resources/js/visual-settings-adapters.js | 433 ++++++++++++++++++
1 file changed, 433 insertions(+)
create mode 100644 desktop-app/resources/js/visual-settings-adapters.js
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
new file mode 100644
index 00000000..c2319929
--- /dev/null
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -0,0 +1,433 @@
+/**
+ * visual-settings-adapters.js
+ * Library adapters for Markdown Viewer visual settings (Issue #129).
+ * Manages runtime theming and visual configurations for:
+ * - highlight.js: theme stylesheets (auto, github, monokai, dracula, etc.)
+ * - MathJax: mathematical typesetting fonts (Modern, STIX2, Asana, Bonum, etc.)
+ * - Mermaid: diagram themes (auto, default, dark, forest, neutral, base) & looks (classic, handDrawn)
+ * - JoyPixels: emoji Fitzpatrick skin tones (default, tone1 - tone5)
+ */
+
+(function () {
+ 'use strict';
+
+ // Allowed options metadata
+ const OPTIONS = Object.freeze({
+ hljsTheme: [
+ { value: 'auto', label: 'Auto (Follow App)' },
+ { value: 'github', label: 'GitHub' },
+ { value: 'github-dark', label: 'GitHub Dark' },
+ { value: 'monokai', label: 'Monokai' },
+ { value: 'dracula', label: 'Dracula' },
+ { value: 'atom-one-dark', label: 'Atom One Dark' },
+ { value: 'atom-one-light', label: 'Atom One Light' },
+ { value: 'solarized-dark', label: 'Solarized Dark' },
+ { value: 'solarized-light', label: 'Solarized Light' },
+ { value: 'vs', label: 'Visual Studio' },
+ { value: 'vs2015', label: 'Visual Studio 2015' },
+ { value: 'nord', label: 'Nord' },
+ { value: 'tokyo-night-dark', label: 'Tokyo Night Dark' },
+ { value: 'tokyo-night-light', label: 'Tokyo Night Light' },
+ { value: 'default', label: 'Default' }
+ ],
+ mathFont: [
+ { value: 'mathjax-modern', label: 'Computer Modern (TeX)' },
+ { value: 'mathjax-tex', label: 'TeX (Classic)' },
+ { value: 'mathjax-stix2', label: 'STIX Two' },
+ { value: 'mathjax-asana', label: 'Asana Math' },
+ { value: 'mathjax-bonum', label: 'Gyre Bonum' },
+ { value: 'mathjax-dejavu', label: 'DejaVu Math' },
+ { value: 'mathjax-fira', label: 'Fira Math' },
+ { value: 'mathjax-pagella', label: 'Gyre Pagella' },
+ { value: 'mathjax-schola', label: 'Gyre Schola' },
+ { value: 'mathjax-termes', label: 'Gyre Termes' }
+ ],
+ mermaidTheme: [
+ { value: 'auto', label: 'Auto (Follow App)' },
+ { value: 'default', label: 'Default' },
+ { value: 'neutral', label: 'Neutral' },
+ { value: 'dark', label: 'Dark' },
+ { value: 'forest', label: 'Forest' },
+ { value: 'base', label: 'Base' }
+ ],
+ mermaidLook: [
+ { value: 'classic', label: 'Classic' },
+ { value: 'handDrawn', label: 'Hand Drawn' }
+ ],
+ emojiSkinTone: [
+ { value: 'default', label: 'Default (Yellow)' },
+ { value: 'tone1', label: 'Light (Tone 1)' },
+ { value: 'tone2', label: 'Medium-Light (Tone 2)' },
+ { value: 'tone3', label: 'Medium (Tone 3)' },
+ { value: 'tone4', label: 'Medium-Dark (Tone 4)' },
+ { value: 'tone5', label: 'Dark (Tone 5)' }
+ ]
+ });
+
+ const DEFAULTS = Object.freeze({
+ hljsTheme: 'auto',
+ mathFont: 'mathjax-modern',
+ mermaidTheme: 'auto',
+ mermaidLook: 'classic',
+ emojiSkinTone: 'default'
+ });
+
+ // Active state
+ let _activeHljsTheme = DEFAULTS.hljsTheme;
+ let _activeMathFont = DEFAULTS.mathFont;
+ let _activeMermaidTheme = DEFAULTS.mermaidTheme;
+ let _activeMermaidLook = DEFAULTS.mermaidLook;
+ let _activeEmojiSkinTone = DEFAULTS.emojiSkinTone;
+
+ const HLJS_CDN_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/';
+ const MATHJAX_FONT_CDN_BASE = 'https://cdn.jsdelivr.net/npm/';
+
+ function getEffectiveAppTheme() {
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
+ }
+
+ function resolveHljsThemeName(theme) {
+ if (theme === 'auto') {
+ return getEffectiveAppTheme() === 'dark' ? 'github-dark' : 'github';
+ }
+ return theme || 'github';
+ }
+
+ function resolveMermaidThemeName(theme) {
+ if (theme === 'auto') {
+ return getEffectiveAppTheme() === 'dark' ? 'dark' : 'default';
+ }
+ return theme || 'default';
+ }
+
+ // Ensure helper styling so highlight.js themes integrate cleanly with the editor preview
+ function ensureHljsStyleHelper() {
+ if (!document.getElementById('mdv-hljs-style-helper')) {
+ const style = document.createElement('style');
+ style.id = 'mdv-hljs-style-helper';
+ style.textContent = `
+ .markdown-body pre:has(> code.hljs) {
+ background-color: transparent !important;
+ padding: 0 !important;
+ }
+ .markdown-body pre > code.hljs {
+ border-radius: 6px;
+ padding: 16px !important;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+ }
+
+ /**
+ * Apply highlight.js theme stylesheet.
+ * Dynamically manages the
in .
+ */
+ function applyHighlightTheme(theme) {
+ _activeHljsTheme = theme || DEFAULTS.hljsTheme;
+ ensureHljsStyleHelper();
+
+ const effectiveName = resolveHljsThemeName(_activeHljsTheme);
+ const href = `${HLJS_CDN_BASE}${effectiveName}.min.css`;
+
+ let link = document.getElementById('mdv-hljs-theme-link');
+ if (!link) {
+ link = document.createElement('link');
+ link.id = 'mdv-hljs-theme-link';
+ link.rel = 'stylesheet';
+ document.head.appendChild(link);
+ }
+
+ if (link.getAttribute('href') === href) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ link.onload = () => resolve();
+ link.onerror = () => {
+ console.warn(`[VisualAdapters] Failed to load highlight.js theme: ${href}`);
+ resolve(); // resolve gracefully so UI does not stall
+ };
+ link.href = href;
+ });
+ }
+
+ /**
+ * Dynamically loads a script tag if not already loaded.
+ */
+ const _loadedScripts = new Set();
+ function loadScriptOnce(src) {
+ if (_loadedScripts.has(src)) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const existing = document.querySelector(`script[src="${src}"]`);
+ if (existing) {
+ _loadedScripts.add(src);
+ return resolve();
+ }
+ const script = document.createElement('script');
+ script.src = src;
+ script.charset = 'UTF-8';
+ script.crossOrigin = 'anonymous';
+ script.onload = () => {
+ _loadedScripts.add(src);
+ resolve();
+ };
+ script.onerror = (err) => {
+ console.warn(`[VisualAdapters] Script load failed: ${src}`, err);
+ reject(err);
+ };
+ document.head.appendChild(script);
+ });
+ }
+
+ /**
+ * Apply MathJax mathematical font.
+ */
+ async function applyMathFont(font) {
+ _activeMathFont = font || DEFAULTS.mathFont;
+
+ if (!window.MathJax) {
+ return;
+ }
+
+ // Configure font in MathJax configuration object
+ window.MathJax.config = window.MathJax.config || {};
+ window.MathJax.config.output = window.MathJax.config.output || {};
+ window.MathJax.config.output.font = _activeMathFont;
+ window.MathJax.config.chtml = window.MathJax.config.chtml || {};
+ window.MathJax.config.chtml.font = _activeMathFont;
+
+ // If MathJax startup has completed, switch the font and re-typeset
+ if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ try {
+ // Load external font script if needed (Modern is bundled by default)
+ if (_activeMathFont !== 'mathjax-modern') {
+ const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
+ await loadScriptOnce(fontUrl).catch(() => {});
+ }
+
+ const newOutputJax = window.MathJax.startup.getOutputJax();
+ if (newOutputJax) {
+ window.MathJax.startup.output = newOutputJax;
+ if (typeof window.MathJax.startup.getDocument === 'function') {
+ window.MathJax.startup.document = window.MathJax.startup.getDocument();
+ }
+ }
+
+ // Re-render math in preview if present
+ const preview = document.getElementById('markdown-preview');
+ if (preview && preview.querySelector('mjx-container, .math-block, .math-inline')) {
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ } else if (typeof window.MathJax.typesetPromise === 'function') {
+ await window.MathJax.typesetPromise([preview]);
+ }
+ }
+ } catch (err) {
+ console.warn('[VisualAdapters] MathJax font switch error:', err);
+ }
+ }
+ }
+
+ /**
+ * Apply Mermaid theme and look drawing style.
+ */
+ async function applyMermaid(options) {
+ if (options) {
+ if (typeof options.theme !== 'undefined') _activeMermaidTheme = options.theme;
+ if (typeof options.look !== 'undefined') _activeMermaidLook = options.look;
+ }
+
+ if (typeof mermaid === 'undefined') {
+ return;
+ }
+
+ const effectiveTheme = resolveMermaidThemeName(_activeMermaidTheme);
+ const effectiveLook = _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic';
+
+ try {
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: effectiveTheme,
+ look: effectiveLook,
+ securityLevel: 'strict',
+ flowchart: { useMaxWidth: true, htmlLabels: true },
+ fontSize: 16,
+ gantt: { useWidth: 1200 }
+ });
+ } catch (err) {
+ console.warn('[VisualAdapters] Mermaid initialize error:', err);
+ }
+
+ // Re-render existing Mermaid nodes in the preview
+ const preview = document.getElementById('markdown-preview');
+ if (preview) {
+ const mermaidNodes = preview.querySelectorAll('.mermaid');
+ if (mermaidNodes.length > 0) {
+ mermaidNodes.forEach((node) => {
+ // Restore source code to avoid rendering already rendered SVG
+ const originalCode = node.getAttribute('data-original-code');
+ if (originalCode) {
+ node.innerHTML = decodeURIComponent(originalCode)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ }
+ node.removeAttribute('data-processed');
+
+ const container = node.closest('.mermaid-container');
+ if (container) {
+ const oldToolbar = container.querySelector('.mermaid-toolbar');
+ if (oldToolbar) oldToolbar.remove();
+ }
+ });
+
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+ }
+ }
+ }
+
+ /**
+ * Resolves an emoji shortcode to include the active Fitzpatrick skin tone modifier
+ * if the emoji supports skin tone modifiers, otherwise returns the original shortcode.
+ * Shortcodes with explicit tone modifiers are left untouched.
+ */
+ function resolveEmojiShortcode(shortcode) {
+ if (!_activeEmojiSkinTone || _activeEmojiSkinTone === 'default') {
+ return shortcode;
+ }
+
+ // If already has an explicit skin tone suffix, preserve it
+ if (/_tone[1-5]$/.test(shortcode)) {
+ return shortcode;
+ }
+
+ // Check JoyPixels library list if available
+ const toneCandidate = `${shortcode}_${_activeEmojiSkinTone}`;
+ const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ if (joy && joy.emojiList) {
+ if (joy.emojiList[`:${toneCandidate}:`]) {
+ return toneCandidate;
+ }
+ return shortcode;
+ }
+
+ // If JoyPixels isn't loaded yet, candidate will be validated when loaded
+ return toneCandidate;
+ }
+
+ /**
+ * Apply JoyPixels emoji skin tone.
+ */
+ function applyEmojiSkinTone(tone) {
+ _activeEmojiSkinTone = tone || DEFAULTS.emojiSkinTone;
+
+ // Wrap joypixels.shortnameToUnicode if loaded
+ const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ if (joy && typeof joy.shortnameToUnicode === 'function' && !joy._visualAdapterWrapped) {
+ const origShortnameToUnicode = joy.shortnameToUnicode.bind(joy);
+ joy.shortnameToUnicode = function (str) {
+ if (_activeEmojiSkinTone && _activeEmojiSkinTone !== 'default') {
+ str = str.replace(/:([\w+-]+):/g, function (match, code) {
+ const resolved = resolveEmojiShortcode(code);
+ return `:${resolved}:`;
+ });
+ }
+ return origShortnameToUnicode(str);
+ };
+ joy._visualAdapterWrapped = true;
+ }
+
+ // Re-render preview so shortcodes reflect new tone
+ const preview = document.getElementById('markdown-preview');
+ if (preview && typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+ }
+
+ /**
+ * React to application light/dark theme toggle.
+ * Idempotently re-applies any setting configured as 'auto'.
+ */
+ function onAppThemeChange(newAppTheme) {
+ if (_activeHljsTheme === 'auto') {
+ applyHighlightTheme('auto');
+ }
+ if (_activeMermaidTheme === 'auto') {
+ applyMermaid({ theme: 'auto', look: _activeMermaidLook });
+ }
+ }
+
+ // Observe theme changes on documentElement
+ const themeObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
+ onAppThemeChange(getEffectiveAppTheme());
+ }
+ }
+ });
+
+ if (document.documentElement) {
+ themeObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme']
+ });
+ }
+
+ /**
+ * Apply all settings simultaneously.
+ */
+ async function applyAll(settings) {
+ if (!settings) return;
+ const tasks = [];
+ if (typeof settings.hljsTheme !== 'undefined') {
+ tasks.push(applyHighlightTheme(settings.hljsTheme));
+ }
+ if (typeof settings.mathFont !== 'undefined') {
+ tasks.push(applyMathFont(settings.mathFont));
+ }
+ if (typeof settings.mermaidTheme !== 'undefined' || typeof settings.mermaidLook !== 'undefined') {
+ tasks.push(applyMermaid({
+ theme: settings.mermaidTheme !== undefined ? settings.mermaidTheme : _activeMermaidTheme,
+ look: settings.mermaidLook !== undefined ? settings.mermaidLook : _activeMermaidLook
+ }));
+ }
+ if (typeof settings.emojiSkinTone !== 'undefined') {
+ tasks.push(applyEmojiSkinTone(settings.emojiSkinTone));
+ }
+
+ await Promise.all(tasks);
+
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
+ }
+
+ // Public API exported to window.visualSettingsAdapters
+ window.visualSettingsAdapters = {
+ getOptions: () => JSON.parse(JSON.stringify(OPTIONS)),
+ getDefaults: () => Object.assign({}, DEFAULTS),
+ getCurrentSettings: () => ({
+ hljsTheme: _activeHljsTheme,
+ mathFont: _activeMathFont,
+ mermaidTheme: _activeMermaidTheme,
+ mermaidLook: _activeMermaidLook,
+ emojiSkinTone: _activeEmojiSkinTone
+ }),
+ getCurrentMathFont: () => _activeMathFont,
+ getMermaidConfig: (themeOverride) => ({
+ theme: resolveMermaidThemeName(_activeMermaidTheme),
+ look: _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic'
+ }),
+ resolveEmojiShortcode,
+ applyHighlightTheme,
+ applyMathFont,
+ applyMermaid,
+ applyEmojiSkinTone,
+ applyAll,
+ onAppThemeChange
+ };
+
+})();
From 68bd8bcd345740e5ab1ac18ed8c94b9e4f865f70 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sun, 11 Oct 2026 14:00:32 +0530
Subject: [PATCH 05/15] fix(build): normalize sitemap CRLF line endings for
cross-platform build (P9)
---
seo/generate-sitemap.mjs | 5 +++--
tests/helpers/static-build-check.mjs | 4 +++-
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/seo/generate-sitemap.mjs b/seo/generate-sitemap.mjs
index 2ea8c5d8..345e40bd 100644
--- a/seo/generate-sitemap.mjs
+++ b/seo/generate-sitemap.mjs
@@ -32,8 +32,9 @@ export function buildSitemap() {
if (process.argv[1] && path.resolve(process.argv[1]) === modulePath) {
const expected = buildSitemap();
if (process.argv.includes('--check')) {
- const actual = fs.readFileSync(sitemapPath, 'utf8');
- if (actual !== expected) {
+ const actual = fs.readFileSync(sitemapPath, 'utf8').replace(/\r\n/g, '\n');
+ const normalizedExpected = expected.replace(/\r\n/g, '\n');
+ if (actual !== normalizedExpected) {
throw new Error('sitemap.xml is stale. Run: npm run seo:sitemap');
}
console.log('SEO sitemap is current.');
diff --git a/tests/helpers/static-build-check.mjs b/tests/helpers/static-build-check.mjs
index f3b471d3..3e0132d4 100644
--- a/tests/helpers/static-build-check.mjs
+++ b/tests/helpers/static-build-check.mjs
@@ -14,6 +14,8 @@ const requiredFiles = [
'index.html',
'404.html',
'workspace-storage.js',
+ 'visual-settings-adapters.js',
+ 'visual-settings-store.js',
'script.js',
'preview-worker.js',
'styles.css',
@@ -123,7 +125,7 @@ if (SEO_LOCALES.length !== 15) {
throw new Error(`Expected 15 supported SEO locales, found ${SEO_LOCALES.length}.`);
}
-if (sitemapXml !== buildSitemap()) {
+if (sitemapXml.replace(/\r\n/g, '\n') !== buildSitemap().replace(/\r\n/g, '\n')) {
throw new Error('sitemap.xml does not match the canonical locale configuration. Run npm run seo:sitemap.');
}
From 9667c4df83eafd237eb6965811a70e6d4290ae96 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sun, 11 Oct 2026 14:00:56 +0530
Subject: [PATCH 06/15] fix(distribution): include visual settings scripts and
seo modules in Docker and SW (P3)
---
Dockerfile | 4 ++++
sw.js | 4 ++++
2 files changed, 8 insertions(+)
diff --git a/Dockerfile b/Dockerfile
index dc3f73f4..8c028681 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,8 @@ FROM nginx:alpine
# PERF-019: Only copy necessary web files (exclude .git, desktop-app, wiki, etc.)
COPY index.html /usr/share/nginx/html/
COPY workspace-storage.js /usr/share/nginx/html/
+COPY visual-settings-adapters.js /usr/share/nginx/html/
+COPY visual-settings-store.js /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
COPY styles.css /usr/share/nginx/html/
COPY sw.js /usr/share/nginx/html/
@@ -12,6 +14,8 @@ COPY manifest.json /usr/share/nginx/html/
COPY robots.txt /usr/share/nginx/html/
COPY sitemap.xml /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
+COPY seo/locales.mjs /usr/share/nginx/html/seo/
+COPY seo/welcome-content.mjs /usr/share/nginx/html/seo/
# Create a custom nginx configuration with compression and security
# PERF-020: Added gzip compression for text-based assets
diff --git a/sw.js b/sw.js
index c8ac6549..8fb37a14 100644
--- a/sw.js
+++ b/sw.js
@@ -6,6 +6,8 @@ const CRITICAL_ASSETS = [
'./',
'./index.html',
'./workspace-storage.js',
+ './visual-settings-adapters.js',
+ './visual-settings-store.js',
'./script.js',
'./preview-worker.js',
'./styles.css',
@@ -31,6 +33,8 @@ const NETWORK_FIRST_LOCAL_PATHS = new Set([
'/',
'/index.html',
'/workspace-storage.js',
+ '/visual-settings-adapters.js',
+ '/visual-settings-store.js',
'/script.js',
'/preview-worker.js',
'/styles.css',
From 4512558a706e0cc304db8215c98a421a308ffc6b Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sun, 11 Oct 2026 14:01:14 +0530
Subject: [PATCH 07/15] fix(preview): invalidate split preview cache on setting
changes and debounce renders (P6, P8)
---
desktop-app/resources/js/script.js | 34 ++++++++++++++++++++++++++++--
script.js | 34 ++++++++++++++++++++++++++++--
2 files changed, 64 insertions(+), 4 deletions(-)
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index b80b55b6..92d9577a 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -4161,7 +4161,15 @@ document.addEventListener("DOMContentLoaded", async function () {
configureMathJax();
if (!mathJaxLoadPromise) {
- mathJaxLoadPromise = loadScript(CDN.mathjax)
+ const initialFont = window.visualSettingsAdapters
+ ? window.visualSettingsAdapters.getCurrentMathFont()
+ : 'mathjax-modern';
+ const fontPreloadPromise = (initialFont && initialFont !== 'mathjax-modern' && window.visualSettingsAdapters)
+ ? window.visualSettingsAdapters.applyMathFont(initialFont)
+ : Promise.resolve();
+
+ mathJaxLoadPromise = fontPreloadPromise
+ .then(function() { return loadScript(CDN.mathjax); })
.then(function() {
return MathJax.startup && MathJax.startup.promise
? MathJax.startup.promise
@@ -24141,6 +24149,8 @@ ${selector} .arrowheadPath {
if (visualSettingsModal) closeAppModal(visualSettingsModal);
}
+ let _visualSettingsDebounceTimer = null;
+
async function applyVisualSettings(settings, changedKey) {
if (window.visualSettingsAdapters) {
try {
@@ -24171,7 +24181,27 @@ ${selector} .arrowheadPath {
console.warn('Error applying visual setting via adapter:', err);
}
}
- renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+
+ // Invalidate secondary split document preview cache (P6)
+ documentSplitPreviewContent = null;
+ if (secondarySplitTabId && typeof renderDocumentSplitView === 'function') {
+ renderDocumentSplitView();
+ }
+
+ // Check if re-render is needed according to adapter contract (e.g. hljsTheme does not need markdown AST re-render)
+ const needsRender = !window.visualSettingsAdapters ||
+ typeof window.visualSettingsAdapters.needsRerender !== 'function' ||
+ window.visualSettingsAdapters.needsRerender(changedKey);
+
+ if (needsRender) {
+ if (_visualSettingsDebounceTimer) {
+ clearTimeout(_visualSettingsDebounceTimer);
+ }
+ _visualSettingsDebounceTimer = setTimeout(function() {
+ _visualSettingsDebounceTimer = null;
+ renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }, 50);
+ }
}
if (visualSettingsButton) {
diff --git a/script.js b/script.js
index b80b55b6..92d9577a 100644
--- a/script.js
+++ b/script.js
@@ -4161,7 +4161,15 @@ document.addEventListener("DOMContentLoaded", async function () {
configureMathJax();
if (!mathJaxLoadPromise) {
- mathJaxLoadPromise = loadScript(CDN.mathjax)
+ const initialFont = window.visualSettingsAdapters
+ ? window.visualSettingsAdapters.getCurrentMathFont()
+ : 'mathjax-modern';
+ const fontPreloadPromise = (initialFont && initialFont !== 'mathjax-modern' && window.visualSettingsAdapters)
+ ? window.visualSettingsAdapters.applyMathFont(initialFont)
+ : Promise.resolve();
+
+ mathJaxLoadPromise = fontPreloadPromise
+ .then(function() { return loadScript(CDN.mathjax); })
.then(function() {
return MathJax.startup && MathJax.startup.promise
? MathJax.startup.promise
@@ -24141,6 +24149,8 @@ ${selector} .arrowheadPath {
if (visualSettingsModal) closeAppModal(visualSettingsModal);
}
+ let _visualSettingsDebounceTimer = null;
+
async function applyVisualSettings(settings, changedKey) {
if (window.visualSettingsAdapters) {
try {
@@ -24171,7 +24181,27 @@ ${selector} .arrowheadPath {
console.warn('Error applying visual setting via adapter:', err);
}
}
- renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+
+ // Invalidate secondary split document preview cache (P6)
+ documentSplitPreviewContent = null;
+ if (secondarySplitTabId && typeof renderDocumentSplitView === 'function') {
+ renderDocumentSplitView();
+ }
+
+ // Check if re-render is needed according to adapter contract (e.g. hljsTheme does not need markdown AST re-render)
+ const needsRender = !window.visualSettingsAdapters ||
+ typeof window.visualSettingsAdapters.needsRerender !== 'function' ||
+ window.visualSettingsAdapters.needsRerender(changedKey);
+
+ if (needsRender) {
+ if (_visualSettingsDebounceTimer) {
+ clearTimeout(_visualSettingsDebounceTimer);
+ }
+ _visualSettingsDebounceTimer = setTimeout(function() {
+ _visualSettingsDebounceTimer = null;
+ renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }, 50);
+ }
}
if (visualSettingsButton) {
From 254ddc4cb62f97bac8cafce6bbd2c628a8e5121b Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sun, 11 Oct 2026 14:01:27 +0530
Subject: [PATCH 08/15] fix(adapters): resolve MathJax fonts, Mermaid export
theme, Base16 styles, theme contrast, and emoji tones (P1, P2, P4, P5, P7,
P8)
---
.../resources/js/visual-settings-adapters.js | 328 +++++++++++++-----
visual-settings-adapters.js | 328 +++++++++++++-----
2 files changed, 498 insertions(+), 158 deletions(-)
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index c2319929..b537db25 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -82,7 +82,44 @@
const HLJS_CDN_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/';
const MATHJAX_FONT_CDN_BASE = 'https://cdn.jsdelivr.net/npm/';
+ // Mapping for highlight.js themes on cdnjs 11.9.0 (dracula & solarized live under base16/)
+ const HLJS_THEME_PATHS = Object.freeze({
+ 'github': 'github.min.css',
+ 'github-dark': 'github-dark.min.css',
+ 'monokai': 'monokai.min.css',
+ 'dracula': 'base16/dracula.min.css',
+ 'atom-one-dark': 'atom-one-dark.min.css',
+ 'atom-one-light': 'atom-one-light.min.css',
+ 'solarized-dark': 'base16/solarized-dark.min.css',
+ 'solarized-light': 'base16/solarized-light.min.css',
+ 'vs': 'vs.min.css',
+ 'vs2015': 'vs2015.min.css',
+ 'nord': 'nord.min.css',
+ 'tokyo-night-dark': 'tokyo-night-dark.min.css',
+ 'tokyo-night-light': 'tokyo-night-light.min.css',
+ 'default': 'default.min.css'
+ });
+
+ // Fallback background and foreground colors for syntax themes
+ const HLJS_THEME_COLORS = Object.freeze({
+ 'github': { bg: '#ffffff', color: '#24292e' },
+ 'github-dark': { bg: '#0d1117', color: '#c9d1d9' },
+ 'monokai': { bg: '#272822', color: '#dddddd' },
+ 'dracula': { bg: '#282a36', color: '#f8f8f2' },
+ 'atom-one-dark': { bg: '#282c34', color: '#abb2bf' },
+ 'atom-one-light': { bg: '#fafafa', color: '#383a42' },
+ 'solarized-dark': { bg: '#002b36', color: '#839496' },
+ 'solarized-light': { bg: '#fdf6e3', color: '#657b83' },
+ 'vs': { bg: '#ffffff', color: '#000000' },
+ 'vs2015': { bg: '#1e1e1e', color: '#dcdcdc' },
+ 'nord': { bg: '#2e3440', color: '#d8dee9' },
+ 'tokyo-night-dark': { bg: '#1a1b26', color: '#a9b1d6' },
+ 'tokyo-night-light': { bg: '#d5d6db', color: '#343b58' },
+ 'default': { bg: '#f3f3f3', color: '#444444' }
+ });
+
function getEffectiveAppTheme() {
+ if (typeof document === 'undefined' || !document.documentElement) return 'light';
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
@@ -93,30 +130,79 @@
return theme || 'github';
}
- function resolveMermaidThemeName(theme) {
+ function resolveMermaidThemeName(theme, themeOverride) {
if (theme === 'auto') {
- return getEffectiveAppTheme() === 'dark' ? 'dark' : 'default';
+ const effective = themeOverride || getEffectiveAppTheme();
+ return effective === 'dark' ? 'dark' : 'default';
}
return theme || 'default';
}
- // Ensure helper styling so highlight.js themes integrate cleanly with the editor preview
- function ensureHljsStyleHelper() {
- if (!document.getElementById('mdv-hljs-style-helper')) {
- const style = document.createElement('style');
+ // Update dynamic helper stylesheet so code blocks adopt the theme's background and contrast
+ function updateHljsHelperStyle(effectiveName) {
+ if (typeof document === 'undefined') return;
+ let style = document.getElementById('mdv-hljs-style-helper');
+ if (!style) {
+ style = document.createElement('style');
style.id = 'mdv-hljs-style-helper';
- style.textContent = `
- .markdown-body pre:has(> code.hljs) {
- background-color: transparent !important;
- padding: 0 !important;
+ if (document.head) document.head.appendChild(style);
+ }
+
+ const fallback = HLJS_THEME_COLORS[effectiveName] || HLJS_THEME_COLORS['github'];
+ let bg = fallback.bg;
+ let fg = fallback.color;
+
+ if (document.body && typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
+ try {
+ const probe = document.createElement('div');
+ probe.className = 'hljs';
+ probe.style.position = 'fixed';
+ probe.style.top = '-9999px';
+ probe.style.left = '-9999px';
+ probe.style.visibility = 'hidden';
+ document.body.appendChild(probe);
+ const cs = window.getComputedStyle(probe);
+ if (cs && cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
+ bg = cs.backgroundColor;
}
- .markdown-body pre > code.hljs {
- border-radius: 6px;
- padding: 16px !important;
+ if (cs && cs.color && cs.color !== 'rgba(0, 0, 0, 0)' && cs.color !== 'transparent') {
+ fg = cs.color;
}
- `;
- document.head.appendChild(style);
+ document.body.removeChild(probe);
+ } catch (_) {}
}
+
+ style.textContent = `
+ .markdown-body pre:has(> code.hljs) {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .markdown-body pre > code.hljs,
+ .markdown-body code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+ @media print {
+ .markdown-body pre:has(> code.hljs),
+ .markdown-body pre > code.hljs,
+ .markdown-body code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+ }
+ `;
+ }
+
+ function ensureHljsStyleHelper() {
+ const effectiveName = resolveHljsThemeName(_activeHljsTheme);
+ updateHljsHelperStyle(effectiveName);
}
/**
@@ -125,27 +211,36 @@
*/
function applyHighlightTheme(theme) {
_activeHljsTheme = theme || DEFAULTS.hljsTheme;
- ensureHljsStyleHelper();
-
const effectiveName = resolveHljsThemeName(_activeHljsTheme);
- const href = `${HLJS_CDN_BASE}${effectiveName}.min.css`;
+ updateHljsHelperStyle(effectiveName);
+
+ if (typeof document === 'undefined') return Promise.resolve();
+
+ const subpath = HLJS_THEME_PATHS[effectiveName] || `${effectiveName}.min.css`;
+ const href = `${HLJS_CDN_BASE}${subpath}`;
let link = document.getElementById('mdv-hljs-theme-link');
if (!link) {
link = document.createElement('link');
link.id = 'mdv-hljs-theme-link';
link.rel = 'stylesheet';
- document.head.appendChild(link);
+ link.crossOrigin = 'anonymous';
+ if (document.head) document.head.appendChild(link);
}
if (link.getAttribute('href') === href) {
+ updateHljsHelperStyle(effectiveName);
return Promise.resolve();
}
- return new Promise((resolve, reject) => {
- link.onload = () => resolve();
+ return new Promise((resolve) => {
+ link.onload = () => {
+ updateHljsHelperStyle(effectiveName);
+ resolve();
+ };
link.onerror = () => {
console.warn(`[VisualAdapters] Failed to load highlight.js theme: ${href}`);
+ updateHljsHelperStyle(effectiveName);
resolve(); // resolve gracefully so UI does not stall
};
link.href = href;
@@ -158,6 +253,7 @@
const _loadedScripts = new Set();
function loadScriptOnce(src) {
if (_loadedScripts.has(src)) return Promise.resolve();
+ if (typeof document === 'undefined') return Promise.resolve();
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
@@ -176,7 +272,8 @@
console.warn(`[VisualAdapters] Script load failed: ${src}`, err);
reject(err);
};
- document.head.appendChild(script);
+ if (document.head) document.head.appendChild(script);
+ else if (document.body) document.body.appendChild(script);
});
}
@@ -186,7 +283,7 @@
async function applyMathFont(font) {
_activeMathFont = font || DEFAULTS.mathFont;
- if (!window.MathJax) {
+ if (typeof window === 'undefined' || !window.MathJax) {
return;
}
@@ -197,15 +294,51 @@
window.MathJax.config.chtml = window.MathJax.config.chtml || {};
window.MathJax.config.chtml.font = _activeMathFont;
- // If MathJax startup has completed, switch the font and re-typeset
- if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ // Load external font script if needed (Modern is bundled by default)
+ if (_activeMathFont !== 'mathjax-modern') {
+ const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
try {
- // Load external font script if needed (Modern is bundled by default)
- if (_activeMathFont !== 'mathjax-modern') {
- const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
- await loadScriptOnce(fontUrl).catch(() => {});
- }
+ await loadScriptOnce(fontUrl);
+ } catch (err) {
+ console.warn(`[VisualAdapters] MathJax font load failed (${_activeMathFont}), falling back to bundled Modern:`, err);
+ _activeMathFont = 'mathjax-modern';
+ }
+ }
+
+ // Extract registered font class and activate its fontData
+ let fontClass = null;
+ if (window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts) {
+ const fontPkg = window.MathJax._.output.fonts[_activeMathFont];
+ if (fontPkg && fontPkg.chtml_ts) {
+ fontClass = Object.values(fontPkg.chtml_ts)[0] || null;
+ }
+ }
+
+ // If font class was not found (e.g. offline fallback), fall back to bundled modern font
+ if (!fontClass && _activeMathFont !== 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
+ _activeMathFont = 'mathjax-modern';
+ const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
+ if (modernPkg && modernPkg.chtml_ts) {
+ fontClass = Object.values(modernPkg.chtml_ts)[0] || null;
+ }
+ }
+ // Update MathJax configuration with effective font
+ window.MathJax.config.output.font = _activeMathFont;
+ window.MathJax.config.chtml.font = _activeMathFont;
+
+ if (fontClass) {
+ window.MathJax.config.chtml.fontData = fontClass;
+ } else if (_activeMathFont === 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
+ const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
+ if (modernPkg && modernPkg.chtml_ts) {
+ window.MathJax.config.chtml.fontData = Object.values(modernPkg.chtml_ts)[0] || null;
+ }
+ }
+
+ // If MathJax startup has completed, switch output jax, document and re-typeset
+ if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ try {
const newOutputJax = window.MathJax.startup.getOutputJax();
if (newOutputJax) {
window.MathJax.startup.output = newOutputJax;
@@ -215,12 +348,19 @@
}
// Re-render math in preview if present
- const preview = document.getElementById('markdown-preview');
- if (preview && preview.querySelector('mjx-container, .math-block, .math-inline')) {
- if (typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
- } else if (typeof window.MathJax.typesetPromise === 'function') {
- await window.MathJax.typesetPromise([preview]);
+ if (typeof document !== 'undefined') {
+ const preview = document.getElementById('markdown-preview');
+ const splitPreview = document.getElementById('document-split-preview');
+ const roots = [];
+ if (preview && preview.querySelector('mjx-container, .math-block, .math-inline')) roots.push(preview);
+ if (splitPreview && splitPreview.querySelector('mjx-container, .math-block, .math-inline')) roots.push(splitPreview);
+
+ if (roots.length > 0) {
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ } else if (typeof window.MathJax.typesetPromise === 'function') {
+ await window.MathJax.typesetPromise(roots);
+ }
}
}
} catch (err) {
@@ -260,30 +400,31 @@
}
// Re-render existing Mermaid nodes in the preview
- const preview = document.getElementById('markdown-preview');
- if (preview) {
- const mermaidNodes = preview.querySelectorAll('.mermaid');
- if (mermaidNodes.length > 0) {
- mermaidNodes.forEach((node) => {
- // Restore source code to avoid rendering already rendered SVG
- const originalCode = node.getAttribute('data-original-code');
- if (originalCode) {
- node.innerHTML = decodeURIComponent(originalCode)
- .replace(/&/g, '&')
- .replace(//g, '>');
- }
- node.removeAttribute('data-processed');
+ if (typeof document !== 'undefined') {
+ const preview = document.getElementById('markdown-preview');
+ if (preview) {
+ const mermaidNodes = preview.querySelectorAll('.mermaid');
+ if (mermaidNodes.length > 0) {
+ mermaidNodes.forEach((node) => {
+ const originalCode = node.getAttribute('data-original-code');
+ if (originalCode) {
+ node.innerHTML = decodeURIComponent(originalCode)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ }
+ node.removeAttribute('data-processed');
+
+ const container = node.closest('.mermaid-container');
+ if (container) {
+ const oldToolbar = container.querySelector('.mermaid-toolbar');
+ if (oldToolbar) oldToolbar.remove();
+ }
+ });
- const container = node.closest('.mermaid-container');
- if (container) {
- const oldToolbar = container.querySelector('.mermaid-toolbar');
- if (oldToolbar) oldToolbar.remove();
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
}
- });
-
- if (typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
}
}
}
@@ -306,7 +447,7 @@
// Check JoyPixels library list if available
const toneCandidate = `${shortcode}_${_activeEmojiSkinTone}`;
- const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ const joy = typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined);
if (joy && joy.emojiList) {
if (joy.emojiList[`:${toneCandidate}:`]) {
return toneCandidate;
@@ -314,7 +455,6 @@
return shortcode;
}
- // If JoyPixels isn't loaded yet, candidate will be validated when loaded
return toneCandidate;
}
@@ -324,8 +464,7 @@
function applyEmojiSkinTone(tone) {
_activeEmojiSkinTone = tone || DEFAULTS.emojiSkinTone;
- // Wrap joypixels.shortnameToUnicode if loaded
- const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ const joy = typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined);
if (joy && typeof joy.shortnameToUnicode === 'function' && !joy._visualAdapterWrapped) {
const origShortnameToUnicode = joy.shortnameToUnicode.bind(joy);
joy.shortnameToUnicode = function (str) {
@@ -340,10 +479,11 @@
joy._visualAdapterWrapped = true;
}
- // Re-render preview so shortcodes reflect new tone
- const preview = document.getElementById('markdown-preview');
- if (preview && typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ if (typeof document !== 'undefined') {
+ const preview = document.getElementById('markdown-preview');
+ if (preview && typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
}
}
@@ -361,21 +501,46 @@
}
// Observe theme changes on documentElement
- const themeObserver = new MutationObserver((mutations) => {
- for (const mutation of mutations) {
- if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
- onAppThemeChange(getEffectiveAppTheme());
+ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && document.documentElement) {
+ const themeObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
+ onAppThemeChange(getEffectiveAppTheme());
+ }
}
- }
- });
+ });
- if (document.documentElement) {
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
}
+ /**
+ * Check if setting change needs a markdown document re-render.
+ */
+ function needsRerender(key) {
+ if (key === 'hljsTheme') return false;
+ return true;
+ }
+
+ /**
+ * Generic apply method for any setting key.
+ */
+ async function apply(key, value) {
+ if (key === 'hljsTheme') {
+ return applyHighlightTheme(value);
+ } else if (key === 'mathFont') {
+ return applyMathFont(value);
+ } else if (key === 'mermaidTheme') {
+ return applyMermaid({ theme: value, look: _activeMermaidLook });
+ } else if (key === 'mermaidLook') {
+ return applyMermaid({ theme: _activeMermaidTheme, look: value });
+ } else if (key === 'emojiSkinTone') {
+ return applyEmojiSkinTone(value);
+ }
+ }
+
/**
* Apply all settings simultaneously.
*/
@@ -399,14 +564,10 @@
}
await Promise.all(tasks);
-
- if (typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
- }
}
// Public API exported to window.visualSettingsAdapters
- window.visualSettingsAdapters = {
+ const adapters = {
getOptions: () => JSON.parse(JSON.stringify(OPTIONS)),
getDefaults: () => Object.assign({}, DEFAULTS),
getCurrentSettings: () => ({
@@ -418,9 +579,11 @@
}),
getCurrentMathFont: () => _activeMathFont,
getMermaidConfig: (themeOverride) => ({
- theme: resolveMermaidThemeName(_activeMermaidTheme),
+ theme: resolveMermaidThemeName(_activeMermaidTheme, themeOverride),
look: _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic'
}),
+ needsRerender,
+ apply,
resolveEmojiShortcode,
applyHighlightTheme,
applyMathFont,
@@ -430,4 +593,11 @@
onAppThemeChange
};
+ if (typeof window !== 'undefined') {
+ window.visualSettingsAdapters = adapters;
+ }
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = adapters;
+ }
+
})();
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index c2319929..b537db25 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -82,7 +82,44 @@
const HLJS_CDN_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/';
const MATHJAX_FONT_CDN_BASE = 'https://cdn.jsdelivr.net/npm/';
+ // Mapping for highlight.js themes on cdnjs 11.9.0 (dracula & solarized live under base16/)
+ const HLJS_THEME_PATHS = Object.freeze({
+ 'github': 'github.min.css',
+ 'github-dark': 'github-dark.min.css',
+ 'monokai': 'monokai.min.css',
+ 'dracula': 'base16/dracula.min.css',
+ 'atom-one-dark': 'atom-one-dark.min.css',
+ 'atom-one-light': 'atom-one-light.min.css',
+ 'solarized-dark': 'base16/solarized-dark.min.css',
+ 'solarized-light': 'base16/solarized-light.min.css',
+ 'vs': 'vs.min.css',
+ 'vs2015': 'vs2015.min.css',
+ 'nord': 'nord.min.css',
+ 'tokyo-night-dark': 'tokyo-night-dark.min.css',
+ 'tokyo-night-light': 'tokyo-night-light.min.css',
+ 'default': 'default.min.css'
+ });
+
+ // Fallback background and foreground colors for syntax themes
+ const HLJS_THEME_COLORS = Object.freeze({
+ 'github': { bg: '#ffffff', color: '#24292e' },
+ 'github-dark': { bg: '#0d1117', color: '#c9d1d9' },
+ 'monokai': { bg: '#272822', color: '#dddddd' },
+ 'dracula': { bg: '#282a36', color: '#f8f8f2' },
+ 'atom-one-dark': { bg: '#282c34', color: '#abb2bf' },
+ 'atom-one-light': { bg: '#fafafa', color: '#383a42' },
+ 'solarized-dark': { bg: '#002b36', color: '#839496' },
+ 'solarized-light': { bg: '#fdf6e3', color: '#657b83' },
+ 'vs': { bg: '#ffffff', color: '#000000' },
+ 'vs2015': { bg: '#1e1e1e', color: '#dcdcdc' },
+ 'nord': { bg: '#2e3440', color: '#d8dee9' },
+ 'tokyo-night-dark': { bg: '#1a1b26', color: '#a9b1d6' },
+ 'tokyo-night-light': { bg: '#d5d6db', color: '#343b58' },
+ 'default': { bg: '#f3f3f3', color: '#444444' }
+ });
+
function getEffectiveAppTheme() {
+ if (typeof document === 'undefined' || !document.documentElement) return 'light';
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
@@ -93,30 +130,79 @@
return theme || 'github';
}
- function resolveMermaidThemeName(theme) {
+ function resolveMermaidThemeName(theme, themeOverride) {
if (theme === 'auto') {
- return getEffectiveAppTheme() === 'dark' ? 'dark' : 'default';
+ const effective = themeOverride || getEffectiveAppTheme();
+ return effective === 'dark' ? 'dark' : 'default';
}
return theme || 'default';
}
- // Ensure helper styling so highlight.js themes integrate cleanly with the editor preview
- function ensureHljsStyleHelper() {
- if (!document.getElementById('mdv-hljs-style-helper')) {
- const style = document.createElement('style');
+ // Update dynamic helper stylesheet so code blocks adopt the theme's background and contrast
+ function updateHljsHelperStyle(effectiveName) {
+ if (typeof document === 'undefined') return;
+ let style = document.getElementById('mdv-hljs-style-helper');
+ if (!style) {
+ style = document.createElement('style');
style.id = 'mdv-hljs-style-helper';
- style.textContent = `
- .markdown-body pre:has(> code.hljs) {
- background-color: transparent !important;
- padding: 0 !important;
+ if (document.head) document.head.appendChild(style);
+ }
+
+ const fallback = HLJS_THEME_COLORS[effectiveName] || HLJS_THEME_COLORS['github'];
+ let bg = fallback.bg;
+ let fg = fallback.color;
+
+ if (document.body && typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
+ try {
+ const probe = document.createElement('div');
+ probe.className = 'hljs';
+ probe.style.position = 'fixed';
+ probe.style.top = '-9999px';
+ probe.style.left = '-9999px';
+ probe.style.visibility = 'hidden';
+ document.body.appendChild(probe);
+ const cs = window.getComputedStyle(probe);
+ if (cs && cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
+ bg = cs.backgroundColor;
}
- .markdown-body pre > code.hljs {
- border-radius: 6px;
- padding: 16px !important;
+ if (cs && cs.color && cs.color !== 'rgba(0, 0, 0, 0)' && cs.color !== 'transparent') {
+ fg = cs.color;
}
- `;
- document.head.appendChild(style);
+ document.body.removeChild(probe);
+ } catch (_) {}
}
+
+ style.textContent = `
+ .markdown-body pre:has(> code.hljs) {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .markdown-body pre > code.hljs,
+ .markdown-body code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+ @media print {
+ .markdown-body pre:has(> code.hljs),
+ .markdown-body pre > code.hljs,
+ .markdown-body code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+ }
+ `;
+ }
+
+ function ensureHljsStyleHelper() {
+ const effectiveName = resolveHljsThemeName(_activeHljsTheme);
+ updateHljsHelperStyle(effectiveName);
}
/**
@@ -125,27 +211,36 @@
*/
function applyHighlightTheme(theme) {
_activeHljsTheme = theme || DEFAULTS.hljsTheme;
- ensureHljsStyleHelper();
-
const effectiveName = resolveHljsThemeName(_activeHljsTheme);
- const href = `${HLJS_CDN_BASE}${effectiveName}.min.css`;
+ updateHljsHelperStyle(effectiveName);
+
+ if (typeof document === 'undefined') return Promise.resolve();
+
+ const subpath = HLJS_THEME_PATHS[effectiveName] || `${effectiveName}.min.css`;
+ const href = `${HLJS_CDN_BASE}${subpath}`;
let link = document.getElementById('mdv-hljs-theme-link');
if (!link) {
link = document.createElement('link');
link.id = 'mdv-hljs-theme-link';
link.rel = 'stylesheet';
- document.head.appendChild(link);
+ link.crossOrigin = 'anonymous';
+ if (document.head) document.head.appendChild(link);
}
if (link.getAttribute('href') === href) {
+ updateHljsHelperStyle(effectiveName);
return Promise.resolve();
}
- return new Promise((resolve, reject) => {
- link.onload = () => resolve();
+ return new Promise((resolve) => {
+ link.onload = () => {
+ updateHljsHelperStyle(effectiveName);
+ resolve();
+ };
link.onerror = () => {
console.warn(`[VisualAdapters] Failed to load highlight.js theme: ${href}`);
+ updateHljsHelperStyle(effectiveName);
resolve(); // resolve gracefully so UI does not stall
};
link.href = href;
@@ -158,6 +253,7 @@
const _loadedScripts = new Set();
function loadScriptOnce(src) {
if (_loadedScripts.has(src)) return Promise.resolve();
+ if (typeof document === 'undefined') return Promise.resolve();
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
@@ -176,7 +272,8 @@
console.warn(`[VisualAdapters] Script load failed: ${src}`, err);
reject(err);
};
- document.head.appendChild(script);
+ if (document.head) document.head.appendChild(script);
+ else if (document.body) document.body.appendChild(script);
});
}
@@ -186,7 +283,7 @@
async function applyMathFont(font) {
_activeMathFont = font || DEFAULTS.mathFont;
- if (!window.MathJax) {
+ if (typeof window === 'undefined' || !window.MathJax) {
return;
}
@@ -197,15 +294,51 @@
window.MathJax.config.chtml = window.MathJax.config.chtml || {};
window.MathJax.config.chtml.font = _activeMathFont;
- // If MathJax startup has completed, switch the font and re-typeset
- if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ // Load external font script if needed (Modern is bundled by default)
+ if (_activeMathFont !== 'mathjax-modern') {
+ const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
try {
- // Load external font script if needed (Modern is bundled by default)
- if (_activeMathFont !== 'mathjax-modern') {
- const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
- await loadScriptOnce(fontUrl).catch(() => {});
- }
+ await loadScriptOnce(fontUrl);
+ } catch (err) {
+ console.warn(`[VisualAdapters] MathJax font load failed (${_activeMathFont}), falling back to bundled Modern:`, err);
+ _activeMathFont = 'mathjax-modern';
+ }
+ }
+
+ // Extract registered font class and activate its fontData
+ let fontClass = null;
+ if (window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts) {
+ const fontPkg = window.MathJax._.output.fonts[_activeMathFont];
+ if (fontPkg && fontPkg.chtml_ts) {
+ fontClass = Object.values(fontPkg.chtml_ts)[0] || null;
+ }
+ }
+
+ // If font class was not found (e.g. offline fallback), fall back to bundled modern font
+ if (!fontClass && _activeMathFont !== 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
+ _activeMathFont = 'mathjax-modern';
+ const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
+ if (modernPkg && modernPkg.chtml_ts) {
+ fontClass = Object.values(modernPkg.chtml_ts)[0] || null;
+ }
+ }
+ // Update MathJax configuration with effective font
+ window.MathJax.config.output.font = _activeMathFont;
+ window.MathJax.config.chtml.font = _activeMathFont;
+
+ if (fontClass) {
+ window.MathJax.config.chtml.fontData = fontClass;
+ } else if (_activeMathFont === 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
+ const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
+ if (modernPkg && modernPkg.chtml_ts) {
+ window.MathJax.config.chtml.fontData = Object.values(modernPkg.chtml_ts)[0] || null;
+ }
+ }
+
+ // If MathJax startup has completed, switch output jax, document and re-typeset
+ if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ try {
const newOutputJax = window.MathJax.startup.getOutputJax();
if (newOutputJax) {
window.MathJax.startup.output = newOutputJax;
@@ -215,12 +348,19 @@
}
// Re-render math in preview if present
- const preview = document.getElementById('markdown-preview');
- if (preview && preview.querySelector('mjx-container, .math-block, .math-inline')) {
- if (typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
- } else if (typeof window.MathJax.typesetPromise === 'function') {
- await window.MathJax.typesetPromise([preview]);
+ if (typeof document !== 'undefined') {
+ const preview = document.getElementById('markdown-preview');
+ const splitPreview = document.getElementById('document-split-preview');
+ const roots = [];
+ if (preview && preview.querySelector('mjx-container, .math-block, .math-inline')) roots.push(preview);
+ if (splitPreview && splitPreview.querySelector('mjx-container, .math-block, .math-inline')) roots.push(splitPreview);
+
+ if (roots.length > 0) {
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ } else if (typeof window.MathJax.typesetPromise === 'function') {
+ await window.MathJax.typesetPromise(roots);
+ }
}
}
} catch (err) {
@@ -260,30 +400,31 @@
}
// Re-render existing Mermaid nodes in the preview
- const preview = document.getElementById('markdown-preview');
- if (preview) {
- const mermaidNodes = preview.querySelectorAll('.mermaid');
- if (mermaidNodes.length > 0) {
- mermaidNodes.forEach((node) => {
- // Restore source code to avoid rendering already rendered SVG
- const originalCode = node.getAttribute('data-original-code');
- if (originalCode) {
- node.innerHTML = decodeURIComponent(originalCode)
- .replace(/&/g, '&')
- .replace(//g, '>');
- }
- node.removeAttribute('data-processed');
+ if (typeof document !== 'undefined') {
+ const preview = document.getElementById('markdown-preview');
+ if (preview) {
+ const mermaidNodes = preview.querySelectorAll('.mermaid');
+ if (mermaidNodes.length > 0) {
+ mermaidNodes.forEach((node) => {
+ const originalCode = node.getAttribute('data-original-code');
+ if (originalCode) {
+ node.innerHTML = decodeURIComponent(originalCode)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ }
+ node.removeAttribute('data-processed');
+
+ const container = node.closest('.mermaid-container');
+ if (container) {
+ const oldToolbar = container.querySelector('.mermaid-toolbar');
+ if (oldToolbar) oldToolbar.remove();
+ }
+ });
- const container = node.closest('.mermaid-container');
- if (container) {
- const oldToolbar = container.querySelector('.mermaid-toolbar');
- if (oldToolbar) oldToolbar.remove();
+ if (typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
}
- });
-
- if (typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
}
}
}
@@ -306,7 +447,7 @@
// Check JoyPixels library list if available
const toneCandidate = `${shortcode}_${_activeEmojiSkinTone}`;
- const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ const joy = typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined);
if (joy && joy.emojiList) {
if (joy.emojiList[`:${toneCandidate}:`]) {
return toneCandidate;
@@ -314,7 +455,6 @@
return shortcode;
}
- // If JoyPixels isn't loaded yet, candidate will be validated when loaded
return toneCandidate;
}
@@ -324,8 +464,7 @@
function applyEmojiSkinTone(tone) {
_activeEmojiSkinTone = tone || DEFAULTS.emojiSkinTone;
- // Wrap joypixels.shortnameToUnicode if loaded
- const joy = typeof joypixels !== 'undefined' ? joypixels : window.joypixels;
+ const joy = typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined);
if (joy && typeof joy.shortnameToUnicode === 'function' && !joy._visualAdapterWrapped) {
const origShortnameToUnicode = joy.shortnameToUnicode.bind(joy);
joy.shortnameToUnicode = function (str) {
@@ -340,10 +479,11 @@
joy._visualAdapterWrapped = true;
}
- // Re-render preview so shortcodes reflect new tone
- const preview = document.getElementById('markdown-preview');
- if (preview && typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ if (typeof document !== 'undefined') {
+ const preview = document.getElementById('markdown-preview');
+ if (preview && typeof window.renderMarkdown === 'function') {
+ window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
+ }
}
}
@@ -361,21 +501,46 @@
}
// Observe theme changes on documentElement
- const themeObserver = new MutationObserver((mutations) => {
- for (const mutation of mutations) {
- if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
- onAppThemeChange(getEffectiveAppTheme());
+ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && document.documentElement) {
+ const themeObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
+ onAppThemeChange(getEffectiveAppTheme());
+ }
}
- }
- });
+ });
- if (document.documentElement) {
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
}
+ /**
+ * Check if setting change needs a markdown document re-render.
+ */
+ function needsRerender(key) {
+ if (key === 'hljsTheme') return false;
+ return true;
+ }
+
+ /**
+ * Generic apply method for any setting key.
+ */
+ async function apply(key, value) {
+ if (key === 'hljsTheme') {
+ return applyHighlightTheme(value);
+ } else if (key === 'mathFont') {
+ return applyMathFont(value);
+ } else if (key === 'mermaidTheme') {
+ return applyMermaid({ theme: value, look: _activeMermaidLook });
+ } else if (key === 'mermaidLook') {
+ return applyMermaid({ theme: _activeMermaidTheme, look: value });
+ } else if (key === 'emojiSkinTone') {
+ return applyEmojiSkinTone(value);
+ }
+ }
+
/**
* Apply all settings simultaneously.
*/
@@ -399,14 +564,10 @@
}
await Promise.all(tasks);
-
- if (typeof window.renderMarkdown === 'function') {
- window.renderMarkdown({ force: true, forceAdvancedPostProcess: true });
- }
}
// Public API exported to window.visualSettingsAdapters
- window.visualSettingsAdapters = {
+ const adapters = {
getOptions: () => JSON.parse(JSON.stringify(OPTIONS)),
getDefaults: () => Object.assign({}, DEFAULTS),
getCurrentSettings: () => ({
@@ -418,9 +579,11 @@
}),
getCurrentMathFont: () => _activeMathFont,
getMermaidConfig: (themeOverride) => ({
- theme: resolveMermaidThemeName(_activeMermaidTheme),
+ theme: resolveMermaidThemeName(_activeMermaidTheme, themeOverride),
look: _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic'
}),
+ needsRerender,
+ apply,
resolveEmojiShortcode,
applyHighlightTheme,
applyMathFont,
@@ -430,4 +593,11 @@
onAppThemeChange
};
+ if (typeof window !== 'undefined') {
+ window.visualSettingsAdapters = adapters;
+ }
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = adapters;
+ }
+
})();
From 7299598f63a4a28bcab0c9436cc880f8be94345b Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sun, 11 Oct 2026 14:01:35 +0530
Subject: [PATCH 09/15] test(e2e): add regression tests for visual settings
review issues (P1-P7)
---
tests/e2e/visual-settings.spec.js | 191 ++++++++++++++++++++++++++++++
tests/helpers/app.js | 46 +++----
2 files changed, 215 insertions(+), 22 deletions(-)
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
index 8c205a5c..5b448c85 100644
--- a/tests/e2e/visual-settings.spec.js
+++ b/tests/e2e/visual-settings.spec.js
@@ -71,6 +71,14 @@ Greetings: :wave: :thumbsup: :smile:
await expect(modal).toBeVisible();
await page.locator('#visual-settings-close-icon').click();
await expect(modal).not.toBeVisible();
+
+ // Open via workspace settings dropdown menu
+ await page.locator('#workspaceSettingsDropdown').click();
+ await expect(page.locator('#menu-visual-settings-button')).toBeVisible();
+ await page.locator('#menu-visual-settings-button').click();
+ await expect(modal).toBeVisible();
+ await page.locator('#visual-settings-close-btn').click();
+ await expect(modal).not.toBeVisible();
});
test('all settings update live, re-render preview, persist across reloads, and reset', async ({ page }) => {
@@ -207,4 +215,187 @@ Greetings: :wave: :thumbsup: :smile:
await page.locator('#visual-settings-close-btn').click();
});
+
+ test('P4 & P5: all 14 highlight.js themes resolve valid stylesheets and dark themes have readable backgrounds in light mode', async ({ page }) => {
+ await openApp(page);
+
+ // Verify all 14 theme options exist
+ const options = await page.evaluate(() => window.visualSettingsAdapters.getOptions().hljsTheme.map(o => o.value));
+ const themeValues = options.filter(v => v !== 'auto');
+ expect(themeValues.length).toBe(14);
+
+ // Test base16 themes (P4)
+ for (const theme of ['dracula', 'solarized-dark', 'solarized-light']) {
+ await page.evaluate(async (t) => {
+ await window.visualSettingsAdapters.applyHighlightTheme(t);
+ }, theme);
+ const linkHref = await page.locator('#mdv-hljs-theme-link').getAttribute('href');
+ expect(linkHref).toContain(`base16/${theme}.min.css`);
+ }
+
+ // Set sample code in light mode (P5)
+ await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
+ const editor = page.locator('#markdown-editor');
+ await editor.fill('```javascript\nfunction test() { return 42; }\n```');
+ await editor.dispatchEvent('input');
+ await page.waitForTimeout(500);
+
+ // Apply Monokai
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyHighlightTheme('monokai');
+ });
+
+ const preBg = await page.locator('#markdown-preview pre').first().evaluate(el => {
+ return window.getComputedStyle(el).backgroundColor;
+ });
+ const codeColor = await page.locator('#markdown-preview pre code').first().evaluate(el => {
+ return window.getComputedStyle(el).color;
+ });
+
+ // Dark theme should NOT be transparent in light mode
+ expect(preBg).not.toBe('rgba(0, 0, 0, 0)');
+ expect(preBg).not.toBe('transparent');
+ expect(preBg).toContain('39, 40, 34'); // #272822
+ expect(codeColor).toContain('221, 221, 221'); // #ddd
+ });
+
+ test('P1: MathJax font switches to STIX Two and updates output jax font data live and on reload', async ({ page }) => {
+ await openApp(page, '/', { mockMathJax: false });
+
+ // Fill math content
+ const editor = page.locator('#markdown-editor');
+ await editor.fill('$$E = mc^2$$');
+ await editor.dispatchEvent('input');
+ await page.locator('#markdown-preview mjx-container').first().waitFor({ timeout: 15_000 });
+
+ // Apply STIX Two font live
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyMathFont('mathjax-stix2');
+ });
+
+ const fontState = await page.evaluate(() => {
+ const mj = window.MathJax;
+ const font = mj?.startup?.output?.font;
+ const fontPkg = mj?._?.output?.fonts?.[window.visualSettingsAdapters?.getCurrentMathFont()];
+ const fontClassKey = fontPkg?.chtml_ts ? Object.keys(fontPkg.chtml_ts)[0] : null;
+ return {
+ activeFont: window.visualSettingsAdapters?.getCurrentMathFont(),
+ hasFontData: !!mj?.config?.chtml?.fontData,
+ fontClassKey,
+ cssFontPrefix: font?.cssFontPrefix
+ };
+ });
+
+ expect(fontState.activeFont).toBe('mathjax-stix2');
+ expect(fontState.hasFontData).toBe(true);
+ expect(fontState.fontClassKey).toBe('MathJaxStix2Font');
+ expect(fontState.cssFontPrefix).toBe('STX');
+ });
+
+ test('P6 & P7: split preview refreshes emoji, math, mermaid, and syntax themes across both panes', async ({ page }) => {
+ await openApp(page, '/', { mockMathJax: false });
+
+ // Primary document with emoji, math formula, mermaid diagram, and code block
+ const primaryContent = '# Primary\n\n:wave:\n\n$$\\sum_{i=1}^n i$$\n\n```mermaid\ngraph TD\n A-->B\n```\n\n```javascript\nconst a = 1;\n```';
+ const secondaryContent = '# Secondary\n\n:wave:\n\n$$\\int_0^\\infty x dx$$\n\n```mermaid\ngraph TD\n C-->D\n```\n\n```javascript\nconst b = 2;\n```';
+
+ const editor = page.locator('#markdown-editor');
+ await editor.fill(primaryContent);
+ await editor.dispatchEvent('input');
+ await page.waitForTimeout(1000);
+
+ // Create a second tab for split view test (P6)
+ await page.locator('#tab-new-btn').click();
+ await editor.fill(secondaryContent);
+ await editor.dispatchEvent('input');
+ await page.waitForTimeout(500);
+
+ // Open first tab in split view
+ const secondTabId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id');
+ await page.locator('#tab-list .tab-item').first().click({ button: 'right' });
+ await page.getByRole('menuitem', { name: 'Open in split view' }).click();
+ await page.locator('#document-split-destination').selectOption(secondTabId);
+ await page.locator('#document-split-modal-confirm').click();
+
+ // Switch to preview mode to see split preview
+ await page.locator('button.view-toggle-btn[data-view-mode="preview"]').click();
+
+ // Verify initial yellow wave in both panes
+ await expect(page.locator('#markdown-preview')).toContainText('👋');
+ await expect(page.locator('#document-split-preview')).toContainText('👋');
+
+ // Verify math, mermaid, and code blocks rendered in both panes
+ await expect(page.locator('#markdown-preview mjx-container')).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('#document-split-preview mjx-container')).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('#markdown-preview .mermaid svg')).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('#document-split-preview .mermaid svg')).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('#markdown-preview pre code.hljs')).toBeVisible();
+ await expect(page.locator('#document-split-preview pre code.hljs')).toBeVisible();
+
+ // 1. Change emoji skin tone to tone3
+ await page.locator('#visual-settings-button').click();
+ await page.locator('#visual-setting-emoji-skin-tone').selectOption('tone3');
+ await page.locator('#visual-settings-close-btn').click();
+
+ // Verify both primary and split preview refresh to tone3 (P6 & P7)
+ await expect.poll(async () => {
+ const primary = await page.locator('#markdown-preview').textContent();
+ const secondary = await page.locator('#document-split-preview').textContent();
+ return { primary: primary.includes('👋🏽'), secondary: secondary.includes('👋🏽') };
+ }, { timeout: 10_000 }).toEqual({ primary: true, secondary: true });
+
+ // 2. Change hljs theme to dracula (Base16 theme)
+ await page.locator('#visual-settings-button').click();
+ await page.locator('#visual-setting-hljs-theme').selectOption('dracula');
+ await page.locator('#visual-settings-close-btn').click();
+
+ await expect.poll(async () => {
+ return page.evaluate(() => document.getElementById('mdv-hljs-theme-link')?.href || '');
+ }, { timeout: 10_000 }).toContain('styles/base16/dracula.min.css');
+
+ // 3. Change math font to STIX Two
+ await page.locator('#visual-settings-button').click();
+ await page.locator('#visual-setting-math-font').selectOption('mathjax-stix2');
+ await page.locator('#visual-settings-close-btn').click();
+
+ await expect.poll(async () => {
+ return page.evaluate(() => {
+ const adapters = window.visualSettingsAdapters;
+ return adapters ? adapters.getCurrentMathFont() : null;
+ });
+ }, { timeout: 15_000 }).toBe('mathjax-stix2');
+
+ // 4. Change mermaid theme to dark and look to handDrawn
+ await page.locator('#visual-settings-button').click();
+ await page.locator('#visual-setting-mermaid-theme').selectOption('dark');
+ await page.locator('#visual-setting-mermaid-look').selectOption('handDrawn');
+ await page.locator('#visual-settings-close-btn').click();
+
+ await expect.poll(async () => {
+ return page.evaluate(() => window.visualSettingsAdapters?.getMermaidConfig());
+ }, { timeout: 10_000 }).toMatchObject({ theme: 'dark', look: 'handDrawn' });
+
+ // 5. Verify reload persistence
+ await page.reload();
+ await page.waitForLoadState('domcontentloaded');
+
+ const persisted = await page.evaluate(() => window.visualSettingsStore.getSettings());
+ expect(persisted.emojiSkinTone).toBe('tone3');
+ expect(persisted.hljsTheme).toBe('dracula');
+ expect(persisted.mathFont).toBe('mathjax-stix2');
+ expect(persisted.mermaidTheme).toBe('dark');
+ expect(persisted.mermaidLook).toBe('handDrawn');
+ });
+
+ test('P3: Dockerfile includes visual settings scripts and distribution assets are complete', async ({ page }) => {
+ const fs = require('fs');
+ const path = require('path');
+ const dockerfile = fs.readFileSync(path.resolve(__dirname, '../../Dockerfile'), 'utf8');
+ expect(dockerfile).toContain('COPY visual-settings-adapters.js');
+ expect(dockerfile).toContain('COPY visual-settings-store.js');
+
+ const sw = fs.readFileSync(path.resolve(__dirname, '../../sw.js'), 'utf8');
+ expect(sw).toContain('visual-settings-adapters.js');
+ expect(sw).toContain('visual-settings-store.js');
+ });
});
diff --git a/tests/helpers/app.js b/tests/helpers/app.js
index f31a45d9..9c5eecf9 100644
--- a/tests/helpers/app.js
+++ b/tests/helpers/app.js
@@ -33,7 +33,7 @@ async function appVersion() {
return appVersionPromise;
}
-async function installLocalBrowserAssets(page) {
+async function installLocalBrowserAssets(page, options = {}) {
if (routedPages.has(page)) return;
routedPages.add(page);
@@ -50,26 +50,28 @@ async function installLocalBrowserAssets(page) {
});
}
- await page.addInitScript(() => {
- window.MathJax = {
- startup: { promise: Promise.resolve() },
- typesetPromise(targets = []) {
- targets.forEach(target => {
- if (target.querySelector('mjx-container[data-test-renderer]')) return;
- const rendered = document.createElement('mjx-container');
- rendered.dataset.testRenderer = 'true';
- rendered.textContent = 'Rendered math';
- target.appendChild(rendered);
- });
- return Promise.resolve();
- },
- typesetClear(targets = []) {
- targets.forEach(target => {
- target.querySelectorAll('mjx-container[data-test-renderer]').forEach(node => node.remove());
- });
- }
- };
- });
+ if (options.mockMathJax !== false) {
+ await page.addInitScript(() => {
+ window.MathJax = {
+ startup: { promise: Promise.resolve() },
+ typesetPromise(targets = []) {
+ targets.forEach(target => {
+ if (target.querySelector('mjx-container[data-test-renderer]')) return;
+ const rendered = document.createElement('mjx-container');
+ rendered.dataset.testRenderer = 'true';
+ rendered.textContent = 'Rendered math';
+ target.appendChild(rendered);
+ });
+ return Promise.resolve();
+ },
+ typesetClear(targets = []) {
+ targets.forEach(target => {
+ target.querySelectorAll('mjx-container[data-test-renderer]').forEach(node => node.remove());
+ });
+ }
+ };
+ });
+ }
}
async function waitForAppReady(page) {
@@ -91,7 +93,7 @@ function isCriticalBrowserMessage(message) {
async function openApp(page, url = '/', options = {}) {
const expectEditorVisible = options.expectEditorVisible !== false;
const criticalErrors = [];
- await installLocalBrowserAssets(page);
+ await installLocalBrowserAssets(page, options);
if (options.showReleaseNotes !== true) {
const version = await appVersion();
await page.addInitScript(currentVersion => {
From e5a4a6104022cf0aeafccd52819fa6bf44f6627f Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sun, 11 Oct 2026 14:24:38 +0530
Subject: [PATCH 10/15] fix(lint): resolve DeepScan static analysis warnings
---
desktop-app/resources/js/visual-settings-adapters.js | 9 ++-------
tests/unit/visual-settings-adapters.test.mjs | 1 -
visual-settings-adapters.js | 9 ++-------
3 files changed, 4 insertions(+), 15 deletions(-)
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index b537db25..8c50ca95 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -200,11 +200,6 @@
`;
}
- function ensureHljsStyleHelper() {
- const effectiveName = resolveHljsThemeName(_activeHljsTheme);
- updateHljsHelperStyle(effectiveName);
- }
-
/**
* Apply highlight.js theme stylesheet.
* Dynamically manages the
in .
@@ -318,7 +313,7 @@
if (!fontClass && _activeMathFont !== 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
_activeMathFont = 'mathjax-modern';
const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
- if (modernPkg && modernPkg.chtml_ts) {
+ if (modernPkg.chtml_ts) {
fontClass = Object.values(modernPkg.chtml_ts)[0] || null;
}
}
@@ -331,7 +326,7 @@
window.MathJax.config.chtml.fontData = fontClass;
} else if (_activeMathFont === 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
- if (modernPkg && modernPkg.chtml_ts) {
+ if (modernPkg.chtml_ts) {
window.MathJax.config.chtml.fontData = Object.values(modernPkg.chtml_ts)[0] || null;
}
}
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
index 2ba4c9c1..2263303d 100644
--- a/tests/unit/visual-settings-adapters.test.mjs
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -9,7 +9,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '../..');
function createAdapterContext() {
- const domListeners = new Map();
const createdElements = [];
const documentElement = {
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index b537db25..8c50ca95 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -200,11 +200,6 @@
`;
}
- function ensureHljsStyleHelper() {
- const effectiveName = resolveHljsThemeName(_activeHljsTheme);
- updateHljsHelperStyle(effectiveName);
- }
-
/**
* Apply highlight.js theme stylesheet.
* Dynamically manages the
in .
@@ -318,7 +313,7 @@
if (!fontClass && _activeMathFont !== 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
_activeMathFont = 'mathjax-modern';
const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
- if (modernPkg && modernPkg.chtml_ts) {
+ if (modernPkg.chtml_ts) {
fontClass = Object.values(modernPkg.chtml_ts)[0] || null;
}
}
@@ -331,7 +326,7 @@
window.MathJax.config.chtml.fontData = fontClass;
} else if (_activeMathFont === 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
- if (modernPkg && modernPkg.chtml_ts) {
+ if (modernPkg.chtml_ts) {
window.MathJax.config.chtml.fontData = Object.values(modernPkg.chtml_ts)[0] || null;
}
}
From 7df4973253f909247c8d9e203405ddf5129bb3a4 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 11:37:00 +0530
Subject: [PATCH 11/15] fix(desktop): keep desktop MathJax bundle and
configuration compatible (P1)
---
desktop-app/prepare.js | 12 ++-
desktop-app/resources/js/script.js | 15 ++--
.../resources/js/visual-settings-adapters.js | 12 ++-
script.js | 15 ++--
tests/e2e/visual-settings.spec.js | 75 +++++++++++++++++++
tests/unit/visual-settings-adapters.test.mjs | 33 ++++++++
visual-settings-adapters.js | 12 ++-
7 files changed, 155 insertions(+), 19 deletions(-)
diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js
index 5df657f9..2027b792 100644
--- a/desktop-app/prepare.js
+++ b/desktop-app/prepare.js
@@ -271,9 +271,9 @@ async function prepareOfflineDependencies() {
hash: null
},
{
- url: "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js",
- dest: path.join(LIBS_DIR, "tex-mml-chtml.min.js"),
- hash: null
+ url: "https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7/tex-mml-chtml.js",
+ dest: path.join(LIBS_DIR, "tex-mml-chtml.js"),
+ hash: "sha384-2f5bAKjuFIbbQ+0O9aSu5W1OyLA4q80QrDEvjXDwJexjU9VqoJhMgw19pYVpSJab"
},
{
url: "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js",
@@ -314,6 +314,12 @@ async function prepareOfflineDependencies() {
// Wait for all downloads and cryptographic validations to finish
try {
await Promise.all(downloads);
+ // Keep backwards-compatible copy for tex-mml-chtml.min.js
+ const mathjaxDest = path.join(LIBS_DIR, "tex-mml-chtml.js");
+ const mathjaxMinDest = path.join(LIBS_DIR, "tex-mml-chtml.min.js");
+ if (fs.existsSync(mathjaxDest)) {
+ fs.copyFileSync(mathjaxDest, mathjaxMinDest);
+ }
console.log("✓ All offline libraries successfully downloaded and cryptographically validated.");
} catch (err) {
console.error("✗ Critical Security Error: Dependency integrity check failed!", err.message);
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index 92d9577a..b775309c 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -212,7 +212,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// Resolve local paths for desktop (Neutralinojs) offline support
if (typeof Neutralino !== 'undefined') {
CDN.mermaid = '/libs/mermaid.min.js';
- CDN.mathjax = '/libs/tex-mml-chtml.min.js';
+ CDN.mathjax = '/libs/tex-mml-chtml.js';
CDN.jspdf = '/libs/jspdf.umd.min.js';
CDN.html2canvas = '/libs/html2canvas.min.js';
CDN.pako = '/libs/pako.min.js';
@@ -4127,10 +4127,18 @@ document.addEventListener("DOMContentLoaded", async function () {
function configureMathJax() {
if (window.MathJax && typeof MathJax.typesetPromise === 'function') return;
- if (window.MathJax && MathJax.loader && MathJax.tex) return;
+ if (window.MathJax && MathJax.loader && MathJax.tex) {
+ if (window.MathJax.chtml && typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
+ return;
+ }
const initialFont = window.visualSettingsAdapters
? window.visualSettingsAdapters.getCurrentMathFont()
: 'mathjax-modern';
+ if (window.MathJax && window.MathJax.chtml && typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
window.MathJax = {
startup: {
typeset: false
@@ -4141,9 +4149,6 @@ document.addEventListener("DOMContentLoaded", async function () {
output: {
font: initialFont
},
- chtml: {
- font: initialFont
- },
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index 8c50ca95..bb432848 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -287,7 +287,9 @@
window.MathJax.config.output = window.MathJax.config.output || {};
window.MathJax.config.output.font = _activeMathFont;
window.MathJax.config.chtml = window.MathJax.config.chtml || {};
- window.MathJax.config.chtml.font = _activeMathFont;
+ if (typeof window.MathJax.config.chtml.font === 'string') {
+ delete window.MathJax.config.chtml.font;
+ }
// Load external font script if needed (Modern is bundled by default)
if (_activeMathFont !== 'mathjax-modern') {
@@ -320,7 +322,9 @@
// Update MathJax configuration with effective font
window.MathJax.config.output.font = _activeMathFont;
- window.MathJax.config.chtml.font = _activeMathFont;
+ if (typeof window.MathJax.config.chtml.font === 'string') {
+ delete window.MathJax.config.chtml.font;
+ }
if (fontClass) {
window.MathJax.config.chtml.fontData = fontClass;
@@ -332,7 +336,9 @@
}
// If MathJax startup has completed, switch output jax, document and re-typeset
- if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ const isMathJax3 = (window.MathJax.version && String(window.MathJax.version).startsWith('3.')) ||
+ (window.MathJax._ && !window.MathJax._.output?.fonts);
+ if (!isMathJax3 && window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
try {
const newOutputJax = window.MathJax.startup.getOutputJax();
if (newOutputJax) {
diff --git a/script.js b/script.js
index 92d9577a..b775309c 100644
--- a/script.js
+++ b/script.js
@@ -212,7 +212,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// Resolve local paths for desktop (Neutralinojs) offline support
if (typeof Neutralino !== 'undefined') {
CDN.mermaid = '/libs/mermaid.min.js';
- CDN.mathjax = '/libs/tex-mml-chtml.min.js';
+ CDN.mathjax = '/libs/tex-mml-chtml.js';
CDN.jspdf = '/libs/jspdf.umd.min.js';
CDN.html2canvas = '/libs/html2canvas.min.js';
CDN.pako = '/libs/pako.min.js';
@@ -4127,10 +4127,18 @@ document.addEventListener("DOMContentLoaded", async function () {
function configureMathJax() {
if (window.MathJax && typeof MathJax.typesetPromise === 'function') return;
- if (window.MathJax && MathJax.loader && MathJax.tex) return;
+ if (window.MathJax && MathJax.loader && MathJax.tex) {
+ if (window.MathJax.chtml && typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
+ return;
+ }
const initialFont = window.visualSettingsAdapters
? window.visualSettingsAdapters.getCurrentMathFont()
: 'mathjax-modern';
+ if (window.MathJax && window.MathJax.chtml && typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
window.MathJax = {
startup: {
typeset: false
@@ -4141,9 +4149,6 @@ document.addEventListener("DOMContentLoaded", async function () {
output: {
font: initialFont
},
- chtml: {
- font: initialFont
- },
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
index 5b448c85..b6b55a00 100644
--- a/tests/e2e/visual-settings.spec.js
+++ b/tests/e2e/visual-settings.spec.js
@@ -398,4 +398,79 @@ Greetings: :wave: :thumbsup: :smile:
expect(sw).toContain('visual-settings-adapters.js');
expect(sw).toContain('visual-settings-store.js');
});
+
+ test('P1: MathJax configuration is compatible with desktop MathJax bundle and legacy bundles', async ({ page }) => {
+ // 1. Verify desktop prepare.js downloads MathJax 4 bundle with SRI hash
+ const fs = require('fs');
+ const path = require('path');
+ const prepareSource = fs.readFileSync(path.resolve(__dirname, '../../desktop-app/prepare.js'), 'utf8');
+ expect(prepareSource).toContain('https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7/tex-mml-chtml.js');
+ expect(prepareSource).toContain('sha384-2f5bAKjuFIbbQ+0O9aSu5W1OyLA4q80QrDEvjXDwJexjU9VqoJhMgw19pYVpSJab');
+
+ // 2. Verify configureMathJax does not pass font name string to chtml.font
+ const testPage = await page.context().newPage();
+ await testPage.addInitScript(() => {
+ window.__initialChtmlFont = null;
+ let _mj = undefined;
+ Object.defineProperty(window, 'MathJax', {
+ configurable: true,
+ enumerable: true,
+ get: () => _mj,
+ set: (v) => {
+ _mj = v;
+ if (v && v.chtml) {
+ window.__initialChtmlFont = v.chtml.font;
+ }
+ }
+ });
+ });
+
+ await openApp(testPage, '/', { mockMathJax: false });
+ const initialChtmlFont = await testPage.evaluate(() => window.__initialChtmlFont);
+ expect(typeof initialChtmlFont).not.toBe('string');
+ await testPage.close();
+
+ // 3. Verify MathJax 3 bundle dependency is compatible with configuration without crashing startup
+ const legacyPage = await page.context().newPage();
+ const errors = [];
+ legacyPage.on('pageerror', err => errors.push(err.message));
+ legacyPage.on('console', msg => {
+ if (msg.type() === 'error') errors.push(msg.text());
+ });
+ await legacyPage.setContent(`
+
+
+
$$x^2$$
`);
+ await legacyPage.waitForTimeout(1500);
+
+ const legacyResult = await legacyPage.evaluate(async () => {
+ if (window.MathJax?.startup?.promise) {
+ await window.MathJax.startup.promise;
+ }
+ let rendered = false;
+ if (typeof window.MathJax?.typesetPromise === 'function') {
+ await window.MathJax.typesetPromise([document.getElementById('math')]);
+ rendered = !!document.querySelector('mjx-container');
+ }
+ return {
+ hasTypesetPromise: typeof window.MathJax?.typesetPromise === 'function',
+ rendered
+ };
+ });
+ expect(errors.filter(e => e.includes('adaptiveCSS')).length).toBe(0);
+ expect(legacyResult.hasTypesetPromise).toBe(true);
+ expect(legacyResult.rendered).toBe(true);
+ await legacyPage.close();
+ });
});
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
index 2263303d..64945f82 100644
--- a/tests/unit/visual-settings-adapters.test.mjs
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -33,6 +33,7 @@ function createAdapterContext() {
head,
createElement(tag) {
let _href = '';
+ let _src = '';
const el = {
tagName: tag.toUpperCase(),
id: '',
@@ -45,6 +46,11 @@ function createAdapterContext() {
_href = val;
setTimeout(() => { if (this.onload) this.onload(); }, 1);
},
+ get src() { return _src; },
+ set src(val) {
+ _src = val;
+ setTimeout(() => { if (this.onload) this.onload(); }, 1);
+ },
setAttribute(k, v) { this[k] = v; },
getAttribute(k) { return this[k] || null; },
removeAttribute(k) { delete this[k]; }
@@ -212,3 +218,30 @@ test('applyHighlightTheme manages stylesheet and auto theme', async () => {
link = context.document.getElementById('mdv-hljs-theme-link');
assert.ok(link.href.includes('github-dark.min.css'));
});
+
+test('applyMathFont maintains MathJax 3 and 4 configuration compatibility without chtml.font string', async () => {
+ const { context, adapters } = createAdapterContext();
+
+ // Test when MathJax is not yet loaded
+ context.window.MathJax = {};
+ await adapters.applyMathFont('mathjax-stix2');
+
+ assert.equal(context.window.MathJax.config.output.font, 'mathjax-stix2');
+ assert.equal(typeof context.window.MathJax.config.chtml.font, 'undefined');
+
+ // Test when MathJax 3 is present (has version 3.x and no modular fonts)
+ context.window.MathJax = {
+ version: '3.2.2',
+ config: {
+ output: {},
+ chtml: {}
+ },
+ startup: {
+ getOutputJax: () => ({ name: 'chtml' })
+ }
+ };
+
+ await adapters.applyMathFont('mathjax-modern');
+ assert.equal(typeof context.window.MathJax.config.chtml.font, 'undefined');
+ assert.equal(context.window.MathJax.config.output.font, 'mathjax-modern');
+});
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index 8c50ca95..bb432848 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -287,7 +287,9 @@
window.MathJax.config.output = window.MathJax.config.output || {};
window.MathJax.config.output.font = _activeMathFont;
window.MathJax.config.chtml = window.MathJax.config.chtml || {};
- window.MathJax.config.chtml.font = _activeMathFont;
+ if (typeof window.MathJax.config.chtml.font === 'string') {
+ delete window.MathJax.config.chtml.font;
+ }
// Load external font script if needed (Modern is bundled by default)
if (_activeMathFont !== 'mathjax-modern') {
@@ -320,7 +322,9 @@
// Update MathJax configuration with effective font
window.MathJax.config.output.font = _activeMathFont;
- window.MathJax.config.chtml.font = _activeMathFont;
+ if (typeof window.MathJax.config.chtml.font === 'string') {
+ delete window.MathJax.config.chtml.font;
+ }
if (fontClass) {
window.MathJax.config.chtml.fontData = fontClass;
@@ -332,7 +336,9 @@
}
// If MathJax startup has completed, switch output jax, document and re-typeset
- if (window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
+ const isMathJax3 = (window.MathJax.version && String(window.MathJax.version).startsWith('3.')) ||
+ (window.MathJax._ && !window.MathJax._.output?.fonts);
+ if (!isMathJax3 && window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
try {
const newOutputJax = window.MathJax.startup.getOutputJax();
if (newOutputJax) {
From 03b428feeb2b9a760d3f94d9950619ee3581f570 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 12:48:41 +0530
Subject: [PATCH 12/15] fix(adapters): switch math font resources along with
font class (P2)
- Update MathJax.config.chtml.fontURL and dynamicPrefix when switching math fonts
- Register font package URL in MathJax loader paths (config.loader.paths, loader.paths, and _components.loader.CONFIG.paths) so dynamic files like double-struck.js resolve correctly
- Update newOutputJax.font.options.fontURL and dynamicPrefix on live switch
- Guard external font script loading to only execute after MathJax core is loaded
- Synchronize visual-settings-adapters.js to desktop-app/resources/js/
- Add unit and E2E regression tests for live font resource switching
---
.../resources/js/visual-settings-adapters.js | 73 +++++++++++++++++--
tests/e2e/visual-settings.spec.js | 72 ++++++++++++++++++
tests/unit/visual-settings-adapters.test.mjs | 56 ++++++++++++++
visual-settings-adapters.js | 73 +++++++++++++++++--
4 files changed, 262 insertions(+), 12 deletions(-)
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index bb432848..bb533916 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -282,17 +282,53 @@
return;
}
- // Configure font in MathJax configuration object
+ const isMathJax3 = (window.MathJax.version && String(window.MathJax.version).startsWith('3.')) ||
+ (window.MathJax._ && !window.MathJax._.output?.fonts);
+
+ // Configure font and complete resource configuration in MathJax
+ const fontPkgUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font`;
+ const fontWoffUrl = `${fontPkgUrl}/chtml/woff`;
+ const fontDynamicPrefix = `[${_activeMathFont}]/chtml/dynamic`;
+
+ window.MathJax.output = window.MathJax.output || {};
+ window.MathJax.output.font = _activeMathFont;
+
window.MathJax.config = window.MathJax.config || {};
window.MathJax.config.output = window.MathJax.config.output || {};
window.MathJax.config.output.font = _activeMathFont;
+
+ window.MathJax.chtml = window.MathJax.chtml || {};
window.MathJax.config.chtml = window.MathJax.config.chtml || {};
+ if (typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
if (typeof window.MathJax.config.chtml.font === 'string') {
delete window.MathJax.config.chtml.font;
}
- // Load external font script if needed (Modern is bundled by default)
- if (_activeMathFont !== 'mathjax-modern') {
+ if (!isMathJax3) {
+ window.MathJax.chtml.fontURL = fontWoffUrl;
+ window.MathJax.chtml.dynamicPrefix = fontDynamicPrefix;
+ window.MathJax.config.chtml.fontURL = fontWoffUrl;
+ window.MathJax.config.chtml.dynamicPrefix = fontDynamicPrefix;
+
+ window.MathJax.loader = window.MathJax.loader || {};
+ window.MathJax.loader.paths = window.MathJax.loader.paths || {};
+ window.MathJax.loader.paths[_activeMathFont] = fontPkgUrl;
+
+ window.MathJax.config.loader = window.MathJax.config.loader || {};
+ window.MathJax.config.loader.paths = window.MathJax.config.loader.paths || {};
+ window.MathJax.config.loader.paths[_activeMathFont] = fontPkgUrl;
+
+ if (window.MathJax._ && window.MathJax._.components && window.MathJax._.components.loader && window.MathJax._.components.loader.CONFIG && window.MathJax._.components.loader.CONFIG.paths) {
+ window.MathJax._.components.loader.CONFIG.paths[_activeMathFont] = fontPkgUrl;
+ }
+ }
+
+ // Load external font script if needed and MathJax core is already loaded.
+ // If MathJax has not loaded yet, configuring output.font and resource URLs
+ // is sufficient because MathJax's startup loader will load the font component itself.
+ if (_activeMathFont !== 'mathjax-modern' && window.MathJax._) {
const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
try {
await loadScriptOnce(fontUrl);
@@ -320,28 +356,53 @@
}
}
- // Update MathJax configuration with effective font
+ // Update MathJax configuration with effective font and complete resource configuration
+ const effectivePkgUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font`;
+ const effectiveWoffUrl = `${effectivePkgUrl}/chtml/woff`;
+ const effectiveDynamicPrefix = `[${_activeMathFont}]/chtml/dynamic`;
+
window.MathJax.config.output.font = _activeMathFont;
if (typeof window.MathJax.config.chtml.font === 'string') {
delete window.MathJax.config.chtml.font;
}
+ if (typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
+ window.MathJax.config.chtml.fontURL = effectiveWoffUrl;
+ window.MathJax.config.chtml.dynamicPrefix = effectiveDynamicPrefix;
+ window.MathJax.chtml.fontURL = effectiveWoffUrl;
+ window.MathJax.chtml.dynamicPrefix = effectiveDynamicPrefix;
+
+ window.MathJax.config.loader = window.MathJax.config.loader || {};
+ window.MathJax.config.loader.paths = window.MathJax.config.loader.paths || {};
+ window.MathJax.config.loader.paths[_activeMathFont] = effectivePkgUrl;
+ window.MathJax.loader = window.MathJax.loader || {};
+ window.MathJax.loader.paths = window.MathJax.loader.paths || {};
+ window.MathJax.loader.paths[_activeMathFont] = effectivePkgUrl;
+ if (window.MathJax._ && window.MathJax._.components && window.MathJax._.components.loader && window.MathJax._.components.loader.CONFIG && window.MathJax._.components.loader.CONFIG.paths) {
+ window.MathJax._.components.loader.CONFIG.paths[_activeMathFont] = effectivePkgUrl;
+ }
if (fontClass) {
window.MathJax.config.chtml.fontData = fontClass;
+ window.MathJax.chtml.fontData = fontClass;
} else if (_activeMathFont === 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
if (modernPkg.chtml_ts) {
window.MathJax.config.chtml.fontData = Object.values(modernPkg.chtml_ts)[0] || null;
+ window.MathJax.chtml.fontData = window.MathJax.config.chtml.fontData;
}
}
// If MathJax startup has completed, switch output jax, document and re-typeset
- const isMathJax3 = (window.MathJax.version && String(window.MathJax.version).startsWith('3.')) ||
- (window.MathJax._ && !window.MathJax._.output?.fonts);
if (!isMathJax3 && window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
try {
const newOutputJax = window.MathJax.startup.getOutputJax();
if (newOutputJax) {
+ if (newOutputJax.font && newOutputJax.font.options) {
+ newOutputJax.font.options.fontURL = effectiveWoffUrl;
+ newOutputJax.font.options.dynamicPrefix = effectiveDynamicPrefix;
+ }
window.MathJax.startup.output = newOutputJax;
if (typeof window.MathJax.startup.getDocument === 'function') {
window.MathJax.startup.document = window.MathJax.startup.getDocument();
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
index b6b55a00..c11d8d9c 100644
--- a/tests/e2e/visual-settings.spec.js
+++ b/tests/e2e/visual-settings.spec.js
@@ -473,4 +473,76 @@ Greetings: :wave: :thumbsup: :smile:
expect(legacyResult.rendered).toBe(true);
await legacyPage.close();
});
+
+ test('P2: MathJax font switches font resource URLs and dynamic font packages without failed glyph loads', async ({ page }) => {
+ const failedFontRequests = [];
+ page.on('response', resp => {
+ if (resp.status() >= 400 && (resp.url().includes('woff') || resp.url().includes('dynamic') || resp.url().includes('font'))) {
+ failedFontRequests.push({ url: resp.url(), status: resp.status() });
+ }
+ });
+
+ const wrongPackageRequests = [];
+ page.on('request', req => {
+ if (req.url().includes('mathjax-modern-font') && (req.url().includes('stx') || req.url().includes('asn'))) {
+ wrongPackageRequests.push(req.url());
+ }
+ });
+
+ await openApp(page, '/', { mockMathJax: false });
+
+ // Initial render with Modern
+ const editor = page.locator('#markdown-editor');
+ await editor.fill('$$x^2$$\n\n$$\\mathbb{R}$$');
+ await editor.dispatchEvent('input');
+ await expect(page.locator('#markdown-preview mjx-container').first()).toBeVisible({ timeout: 15_000 });
+
+ // 1. Switch to STIX Two live via adapter
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyMathFont('mathjax-stix2');
+ });
+
+ // Check font resource options on startup.output.font
+ const stixFontOptions = await page.evaluate(() => {
+ const font = window.MathJax?.startup?.output?.font;
+ return {
+ prefix: font?.cssFontPrefix,
+ fontURL: font?.options?.fontURL,
+ dynamicPrefix: font?.options?.dynamicPrefix
+ };
+ });
+ expect(stixFontOptions.prefix).toBe('STX');
+ expect(stixFontOptions.fontURL).toContain('mathjax-stix2-font/chtml/woff');
+ expect(stixFontOptions.dynamicPrefix).toBe('[mathjax-stix2]/chtml/dynamic');
+
+ // 2. Switch to Asana live with \mathbb{R}
+ let asanaDynamicLoaded = false;
+ page.on('request', req => {
+ if (req.url().includes('mathjax-asana-font') && req.url().includes('double-struck.js')) {
+ asanaDynamicLoaded = true;
+ }
+ });
+
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyMathFont('mathjax-asana');
+ });
+
+ const asanaFontOptions = await page.evaluate(() => {
+ const font = window.MathJax?.startup?.output?.font;
+ return {
+ prefix: font?.cssFontPrefix,
+ fontURL: font?.options?.fontURL,
+ dynamicPrefix: font?.options?.dynamicPrefix
+ };
+ });
+ expect(asanaFontOptions.prefix).toBe('ASNA');
+ expect(asanaFontOptions.fontURL).toContain('mathjax-asana-font/chtml/woff');
+ expect(asanaFontOptions.dynamicPrefix).toBe('[mathjax-asana]/chtml/dynamic');
+
+ // Verify equations are rendered and no failed font requests occurred
+ await expect(page.locator('#markdown-preview mjx-container')).toHaveCount(2);
+ expect(failedFontRequests).toEqual([]);
+ expect(wrongPackageRequests).toEqual([]);
+ });
});
+
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
index 64945f82..f48e8be2 100644
--- a/tests/unit/visual-settings-adapters.test.mjs
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -245,3 +245,59 @@ test('applyMathFont maintains MathJax 3 and 4 configuration compatibility withou
assert.equal(typeof context.window.MathJax.config.chtml.font, 'undefined');
assert.equal(context.window.MathJax.config.output.font, 'mathjax-modern');
});
+
+test('applyMathFont initializes complete font resource configuration in MathJax 4', async () => {
+ const { context, adapters } = createAdapterContext();
+
+ const mockOutputJax = {
+ font: {
+ name: 'mathjax-modern',
+ options: {
+ fontURL: 'https://cdn.jsdelivr.net/npm/mathjax-modern-font/chtml/woff',
+ dynamicPrefix: '[mathjax-modern]/chtml/dynamic'
+ }
+ }
+ };
+
+ context.window.MathJax = {
+ version: '4.0.0-beta.7',
+ config: {},
+ loader: { paths: {} },
+ _: {
+ components: {
+ loader: {
+ CONFIG: {
+ paths: {
+ mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7'
+ }
+ }
+ }
+ },
+ output: {
+ fonts: {
+ 'mathjax-stix2': {
+ chtml_ts: {
+ MathJaxStix2Font: class MockStixFont {}
+ }
+ }
+ }
+ }
+ },
+ startup: {
+ getOutputJax: () => mockOutputJax,
+ getDocument: () => ({})
+ }
+ };
+
+ await adapters.applyMathFont('mathjax-stix2');
+
+ const mj = context.window.MathJax;
+ assert.equal(mj.config.chtml.fontURL, 'https://cdn.jsdelivr.net/npm/mathjax-stix2-font/chtml/woff');
+ assert.equal(mj.config.chtml.dynamicPrefix, '[mathjax-stix2]/chtml/dynamic');
+ assert.equal(mj.config.loader.paths['mathjax-stix2'], 'https://cdn.jsdelivr.net/npm/mathjax-stix2-font');
+ assert.equal(mj.loader.paths['mathjax-stix2'], 'https://cdn.jsdelivr.net/npm/mathjax-stix2-font');
+ assert.equal(mj._.components.loader.CONFIG.paths['mathjax-stix2'], 'https://cdn.jsdelivr.net/npm/mathjax-stix2-font');
+ assert.equal(mockOutputJax.font.options.fontURL, 'https://cdn.jsdelivr.net/npm/mathjax-stix2-font/chtml/woff');
+ assert.equal(mockOutputJax.font.options.dynamicPrefix, '[mathjax-stix2]/chtml/dynamic');
+});
+
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index bb432848..bb533916 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -282,17 +282,53 @@
return;
}
- // Configure font in MathJax configuration object
+ const isMathJax3 = (window.MathJax.version && String(window.MathJax.version).startsWith('3.')) ||
+ (window.MathJax._ && !window.MathJax._.output?.fonts);
+
+ // Configure font and complete resource configuration in MathJax
+ const fontPkgUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font`;
+ const fontWoffUrl = `${fontPkgUrl}/chtml/woff`;
+ const fontDynamicPrefix = `[${_activeMathFont}]/chtml/dynamic`;
+
+ window.MathJax.output = window.MathJax.output || {};
+ window.MathJax.output.font = _activeMathFont;
+
window.MathJax.config = window.MathJax.config || {};
window.MathJax.config.output = window.MathJax.config.output || {};
window.MathJax.config.output.font = _activeMathFont;
+
+ window.MathJax.chtml = window.MathJax.chtml || {};
window.MathJax.config.chtml = window.MathJax.config.chtml || {};
+ if (typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
if (typeof window.MathJax.config.chtml.font === 'string') {
delete window.MathJax.config.chtml.font;
}
- // Load external font script if needed (Modern is bundled by default)
- if (_activeMathFont !== 'mathjax-modern') {
+ if (!isMathJax3) {
+ window.MathJax.chtml.fontURL = fontWoffUrl;
+ window.MathJax.chtml.dynamicPrefix = fontDynamicPrefix;
+ window.MathJax.config.chtml.fontURL = fontWoffUrl;
+ window.MathJax.config.chtml.dynamicPrefix = fontDynamicPrefix;
+
+ window.MathJax.loader = window.MathJax.loader || {};
+ window.MathJax.loader.paths = window.MathJax.loader.paths || {};
+ window.MathJax.loader.paths[_activeMathFont] = fontPkgUrl;
+
+ window.MathJax.config.loader = window.MathJax.config.loader || {};
+ window.MathJax.config.loader.paths = window.MathJax.config.loader.paths || {};
+ window.MathJax.config.loader.paths[_activeMathFont] = fontPkgUrl;
+
+ if (window.MathJax._ && window.MathJax._.components && window.MathJax._.components.loader && window.MathJax._.components.loader.CONFIG && window.MathJax._.components.loader.CONFIG.paths) {
+ window.MathJax._.components.loader.CONFIG.paths[_activeMathFont] = fontPkgUrl;
+ }
+ }
+
+ // Load external font script if needed and MathJax core is already loaded.
+ // If MathJax has not loaded yet, configuring output.font and resource URLs
+ // is sufficient because MathJax's startup loader will load the font component itself.
+ if (_activeMathFont !== 'mathjax-modern' && window.MathJax._) {
const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
try {
await loadScriptOnce(fontUrl);
@@ -320,28 +356,53 @@
}
}
- // Update MathJax configuration with effective font
+ // Update MathJax configuration with effective font and complete resource configuration
+ const effectivePkgUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font`;
+ const effectiveWoffUrl = `${effectivePkgUrl}/chtml/woff`;
+ const effectiveDynamicPrefix = `[${_activeMathFont}]/chtml/dynamic`;
+
window.MathJax.config.output.font = _activeMathFont;
if (typeof window.MathJax.config.chtml.font === 'string') {
delete window.MathJax.config.chtml.font;
}
+ if (typeof window.MathJax.chtml.font === 'string') {
+ delete window.MathJax.chtml.font;
+ }
+ window.MathJax.config.chtml.fontURL = effectiveWoffUrl;
+ window.MathJax.config.chtml.dynamicPrefix = effectiveDynamicPrefix;
+ window.MathJax.chtml.fontURL = effectiveWoffUrl;
+ window.MathJax.chtml.dynamicPrefix = effectiveDynamicPrefix;
+
+ window.MathJax.config.loader = window.MathJax.config.loader || {};
+ window.MathJax.config.loader.paths = window.MathJax.config.loader.paths || {};
+ window.MathJax.config.loader.paths[_activeMathFont] = effectivePkgUrl;
+ window.MathJax.loader = window.MathJax.loader || {};
+ window.MathJax.loader.paths = window.MathJax.loader.paths || {};
+ window.MathJax.loader.paths[_activeMathFont] = effectivePkgUrl;
+ if (window.MathJax._ && window.MathJax._.components && window.MathJax._.components.loader && window.MathJax._.components.loader.CONFIG && window.MathJax._.components.loader.CONFIG.paths) {
+ window.MathJax._.components.loader.CONFIG.paths[_activeMathFont] = effectivePkgUrl;
+ }
if (fontClass) {
window.MathJax.config.chtml.fontData = fontClass;
+ window.MathJax.chtml.fontData = fontClass;
} else if (_activeMathFont === 'mathjax-modern' && window.MathJax._ && window.MathJax._.output && window.MathJax._.output.fonts && window.MathJax._.output.fonts['mathjax-modern']) {
const modernPkg = window.MathJax._.output.fonts['mathjax-modern'];
if (modernPkg.chtml_ts) {
window.MathJax.config.chtml.fontData = Object.values(modernPkg.chtml_ts)[0] || null;
+ window.MathJax.chtml.fontData = window.MathJax.config.chtml.fontData;
}
}
// If MathJax startup has completed, switch output jax, document and re-typeset
- const isMathJax3 = (window.MathJax.version && String(window.MathJax.version).startsWith('3.')) ||
- (window.MathJax._ && !window.MathJax._.output?.fonts);
if (!isMathJax3 && window.MathJax.startup && typeof window.MathJax.startup.getOutputJax === 'function') {
try {
const newOutputJax = window.MathJax.startup.getOutputJax();
if (newOutputJax) {
+ if (newOutputJax.font && newOutputJax.font.options) {
+ newOutputJax.font.options.fontURL = effectiveWoffUrl;
+ newOutputJax.font.options.dynamicPrefix = effectiveDynamicPrefix;
+ }
window.MathJax.startup.output = newOutputJax;
if (typeof window.MathJax.startup.getDocument === 'function') {
window.MathJax.startup.document = window.MathJax.startup.getDocument();
From 57eda23fbb36b8b6956c17c866dc97c8fe57c6e5 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 17:50:12 +0530
Subject: [PATCH 13/15] fix(syntax): remove built-in token palette for external
themes and resolve auto colors in export (P2)
---
.../resources/js/visual-settings-adapters.js | 168 +++++++++++++---
desktop-app/resources/styles.css | 84 ++++----
styles.css | 84 ++++----
tests/e2e/visual-settings.spec.js | 182 ++++++++++++++++++
tests/unit/visual-settings-adapters.test.mjs | 27 ++-
visual-settings-adapters.js | 168 +++++++++++++---
6 files changed, 582 insertions(+), 131 deletions(-)
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index bb533916..5d7be170 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -123,9 +123,10 @@
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
- function resolveHljsThemeName(theme) {
+ function resolveHljsThemeName(theme, themeOverride) {
if (theme === 'auto') {
- return getEffectiveAppTheme() === 'dark' ? 'github-dark' : 'github';
+ const effective = themeOverride || getEffectiveAppTheme();
+ return effective === 'dark' ? 'github-dark' : 'github';
}
return theme || 'github';
}
@@ -172,32 +173,139 @@
} catch (_) {}
}
- style.textContent = `
- .markdown-body pre:has(> code.hljs) {
- background-color: ${bg} !important;
- color: ${fg} !important;
- border-radius: 6px;
- padding: 16px !important;
- overflow: auto;
- }
- .markdown-body pre > code.hljs,
- .markdown-body code.hljs {
- background-color: ${bg} !important;
- color: ${fg} !important;
- padding: 0 !important;
- border-radius: 0 !important;
- }
- @media print {
- .markdown-body pre:has(> code.hljs),
+ if (_activeHljsTheme === 'auto') {
+ style.textContent = `
+ /* Preview code blocks in Auto mode adopt application theme */
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre:has(> code.hljs) {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre > code.hljs,
+ .markdown-body:not(.pdf-export):not(.export-render-root) code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+
+ /* Export documents in Auto mode resolve against the selected export theme */
+ .pdf-export.theme-dark pre:has(> code.hljs),
+ .pdf-export[data-theme="dark"] pre:has(> code.hljs),
+ .export-render-root[data-theme="dark"] pre:has(> code.hljs),
+ .export-render-root.theme-dark pre:has(> code.hljs) {
+ background-color: #161b22 !important;
+ color: #c9d1d9 !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .pdf-export.theme-dark pre > code.hljs,
+ .pdf-export[data-theme="dark"] pre > code.hljs,
+ .export-render-root[data-theme="dark"] pre > code.hljs,
+ .export-render-root.theme-dark pre > code.hljs,
+ .pdf-export.theme-dark code.hljs,
+ .pdf-export[data-theme="dark"] code.hljs,
+ .export-render-root[data-theme="dark"] code.hljs,
+ .export-render-root.theme-dark code.hljs {
+ background-color: #161b22 !important;
+ color: #c9d1d9 !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+
+ .pdf-export.theme-light pre:has(> code.hljs),
+ .pdf-export[data-theme="light"] pre:has(> code.hljs),
+ .export-render-root[data-theme="light"] pre:has(> code.hljs),
+ .export-render-root.theme-light pre:has(> code.hljs) {
+ background-color: #f6f8fa !important;
+ color: #24292e !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .pdf-export.theme-light pre > code.hljs,
+ .pdf-export[data-theme="light"] pre > code.hljs,
+ .export-render-root[data-theme="light"] pre > code.hljs,
+ .export-render-root.theme-light pre > code.hljs,
+ .pdf-export.theme-light code.hljs,
+ .pdf-export[data-theme="light"] code.hljs,
+ .export-render-root[data-theme="light"] code.hljs,
+ .export-render-root.theme-light code.hljs {
+ background-color: #f6f8fa !important;
+ color: #24292e !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+
+ @media print {
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre:has(> code.hljs),
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre > code.hljs,
+ .markdown-body:not(.pdf-export):not(.export-render-root) code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+
+ html[data-browser-print-export="dark"] .markdown-body pre:has(> code.hljs),
+ html[data-browser-print-export="dark"] .markdown-body pre > code.hljs,
+ html[data-browser-print-export="dark"] .markdown-body code.hljs,
+ .pdf-export.theme-dark pre:has(> code.hljs),
+ .pdf-export.theme-dark pre > code.hljs,
+ .pdf-export.theme-dark code.hljs {
+ background-color: #161b22 !important;
+ color: #c9d1d9 !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+
+ html:not([data-browser-print-export="dark"]) .markdown-body.pdf-export.theme-light pre:has(> code.hljs),
+ html:not([data-browser-print-export="dark"]) .markdown-body.pdf-export.theme-light pre > code.hljs,
+ html:not([data-browser-print-export="dark"]) .markdown-body.pdf-export.theme-light code.hljs,
+ html[data-browser-print-export="light"] .markdown-body pre:has(> code.hljs),
+ html[data-browser-print-export="light"] .markdown-body pre > code.hljs,
+ html[data-browser-print-export="light"] .markdown-body code.hljs,
+ .pdf-export.theme-light pre:has(> code.hljs),
+ .pdf-export.theme-light pre > code.hljs,
+ .pdf-export.theme-light code.hljs {
+ background-color: #f6f8fa !important;
+ color: #24292e !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+ }
+ `;
+ } else {
+ style.textContent = `
+ .markdown-body pre:has(> code.hljs) {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
.markdown-body pre > code.hljs,
.markdown-body code.hljs {
background-color: ${bg} !important;
color: ${fg} !important;
- -webkit-print-color-adjust: exact !important;
- print-color-adjust: exact !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
}
- }
- `;
+ @media print {
+ .markdown-body pre:has(> code.hljs),
+ .markdown-body pre > code.hljs,
+ .markdown-body code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+ }
+ `;
+ }
}
/**
@@ -211,6 +319,18 @@
if (typeof document === 'undefined') return Promise.resolve();
+ if (document.documentElement) {
+ if (_activeHljsTheme !== 'auto') {
+ document.documentElement.setAttribute('data-custom-syntax-theme', _activeHljsTheme);
+ document.documentElement.setAttribute('data-hljs-theme', _activeHljsTheme);
+ } else {
+ if (typeof document.documentElement.removeAttribute === 'function') {
+ document.documentElement.removeAttribute('data-custom-syntax-theme');
+ }
+ document.documentElement.setAttribute('data-hljs-theme', 'auto');
+ }
+ }
+
const subpath = HLJS_THEME_PATHS[effectiveName] || `${effectiveName}.min.css`;
const href = `${HLJS_CDN_BASE}${subpath}`;
@@ -644,6 +764,8 @@
theme: resolveMermaidThemeName(_activeMermaidTheme, themeOverride),
look: _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic'
}),
+ getHljsTheme: (themeOverride) => resolveHljsThemeName(_activeHljsTheme, themeOverride),
+ resolveHljsThemeName,
needsRerender,
apply,
resolveEmojiShortcode,
diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css
index a9041bd1..afce7f22 100644
--- a/desktop-app/resources/styles.css
+++ b/desktop-app/resources/styles.css
@@ -4112,94 +4112,94 @@ a:focus {
background-color: var(--code-bg);
}
-/* Syntax Highlighting Mapping to GitHub Variables */
-.hljs {
+/* Syntax Highlighting Mapping to GitHub Variables (scoped to when no external syntax theme is selected) */
+html:not([data-custom-syntax-theme]) .hljs {
color: var(--color-fg-default);
}
-.hljs-doctag,
-.hljs-keyword,
-.hljs-meta .hljs-keyword,
-.hljs-template-tag,
-.hljs-template-variable,
-.hljs-type,
-.hljs-variable.language_ {
+html:not([data-custom-syntax-theme]) .hljs-doctag,
+html:not([data-custom-syntax-theme]) .hljs-keyword,
+html:not([data-custom-syntax-theme]) .hljs-meta .hljs-keyword,
+html:not([data-custom-syntax-theme]) .hljs-template-tag,
+html:not([data-custom-syntax-theme]) .hljs-template-variable,
+html:not([data-custom-syntax-theme]) .hljs-type,
+html:not([data-custom-syntax-theme]) .hljs-variable.language_ {
color: var(--color-prettylights-syntax-keyword);
}
-.hljs-title,
-.hljs-title.class_,
-.hljs-title.class_.inherited__,
-.hljs-title.function_ {
+html:not([data-custom-syntax-theme]) .hljs-title,
+html:not([data-custom-syntax-theme]) .hljs-title.class_,
+html:not([data-custom-syntax-theme]) .hljs-title.class_.inherited__,
+html:not([data-custom-syntax-theme]) .hljs-title.function_ {
color: var(--color-prettylights-syntax-entity);
}
-.hljs-attr,
-.hljs-attribute,
-.hljs-literal,
-.hljs-meta,
-.hljs-number,
-.hljs-operator,
-.hljs-variable,
-.hljs-selector-attr,
-.hljs-selector-class,
-.hljs-selector-id {
+html:not([data-custom-syntax-theme]) .hljs-attr,
+html:not([data-custom-syntax-theme]) .hljs-attribute,
+html:not([data-custom-syntax-theme]) .hljs-literal,
+html:not([data-custom-syntax-theme]) .hljs-meta,
+html:not([data-custom-syntax-theme]) .hljs-number,
+html:not([data-custom-syntax-theme]) .hljs-operator,
+html:not([data-custom-syntax-theme]) .hljs-variable,
+html:not([data-custom-syntax-theme]) .hljs-selector-attr,
+html:not([data-custom-syntax-theme]) .hljs-selector-class,
+html:not([data-custom-syntax-theme]) .hljs-selector-id {
color: var(--color-prettylights-syntax-constant);
}
-.hljs-regexp,
-.hljs-string,
-.hljs-meta .hljs-string {
+html:not([data-custom-syntax-theme]) .hljs-regexp,
+html:not([data-custom-syntax-theme]) .hljs-string,
+html:not([data-custom-syntax-theme]) .hljs-meta .hljs-string {
color: var(--color-prettylights-syntax-string);
}
-.hljs-built_in,
-.hljs-symbol {
+html:not([data-custom-syntax-theme]) .hljs-built_in,
+html:not([data-custom-syntax-theme]) .hljs-symbol {
color: var(--color-prettylights-syntax-variable);
}
-.hljs-comment,
-.hljs-code,
-.hljs-formula {
+html:not([data-custom-syntax-theme]) .hljs-comment,
+html:not([data-custom-syntax-theme]) .hljs-code,
+html:not([data-custom-syntax-theme]) .hljs-formula {
color: var(--color-prettylights-syntax-comment);
}
-.hljs-name,
-.hljs-quote,
-.hljs-selector-tag,
-.hljs-selector-pseudo {
+html:not([data-custom-syntax-theme]) .hljs-name,
+html:not([data-custom-syntax-theme]) .hljs-quote,
+html:not([data-custom-syntax-theme]) .hljs-selector-tag,
+html:not([data-custom-syntax-theme]) .hljs-selector-pseudo {
color: var(--color-prettylights-syntax-entity-tag);
}
-.hljs-subst {
+html:not([data-custom-syntax-theme]) .hljs-subst {
color: var(--color-fg-default);
}
-.hljs-section {
+html:not([data-custom-syntax-theme]) .hljs-section {
color: var(--color-prettylights-syntax-markup-heading);
font-weight: bold;
}
-.hljs-bullet {
+html:not([data-custom-syntax-theme]) .hljs-bullet {
color: var(--color-prettylights-syntax-constant);
}
-.hljs-emphasis {
+html:not([data-custom-syntax-theme]) .hljs-emphasis {
color: var(--color-fg-default);
font-style: italic;
}
-.hljs-strong {
+html:not([data-custom-syntax-theme]) .hljs-strong {
color: var(--color-fg-default);
font-weight: bold;
}
-.hljs-addition {
+html:not([data-custom-syntax-theme]) .hljs-addition {
color: var(--color-prettylights-syntax-markup-inserted-text);
background-color: var(--color-prettylights-syntax-markup-inserted-bg);
}
-.hljs-deletion {
+html:not([data-custom-syntax-theme]) .hljs-deletion {
color: var(--color-prettylights-syntax-markup-deleted-text);
background-color: var(--color-prettylights-syntax-markup-deleted-bg);
}
diff --git a/styles.css b/styles.css
index a9041bd1..afce7f22 100644
--- a/styles.css
+++ b/styles.css
@@ -4112,94 +4112,94 @@ a:focus {
background-color: var(--code-bg);
}
-/* Syntax Highlighting Mapping to GitHub Variables */
-.hljs {
+/* Syntax Highlighting Mapping to GitHub Variables (scoped to when no external syntax theme is selected) */
+html:not([data-custom-syntax-theme]) .hljs {
color: var(--color-fg-default);
}
-.hljs-doctag,
-.hljs-keyword,
-.hljs-meta .hljs-keyword,
-.hljs-template-tag,
-.hljs-template-variable,
-.hljs-type,
-.hljs-variable.language_ {
+html:not([data-custom-syntax-theme]) .hljs-doctag,
+html:not([data-custom-syntax-theme]) .hljs-keyword,
+html:not([data-custom-syntax-theme]) .hljs-meta .hljs-keyword,
+html:not([data-custom-syntax-theme]) .hljs-template-tag,
+html:not([data-custom-syntax-theme]) .hljs-template-variable,
+html:not([data-custom-syntax-theme]) .hljs-type,
+html:not([data-custom-syntax-theme]) .hljs-variable.language_ {
color: var(--color-prettylights-syntax-keyword);
}
-.hljs-title,
-.hljs-title.class_,
-.hljs-title.class_.inherited__,
-.hljs-title.function_ {
+html:not([data-custom-syntax-theme]) .hljs-title,
+html:not([data-custom-syntax-theme]) .hljs-title.class_,
+html:not([data-custom-syntax-theme]) .hljs-title.class_.inherited__,
+html:not([data-custom-syntax-theme]) .hljs-title.function_ {
color: var(--color-prettylights-syntax-entity);
}
-.hljs-attr,
-.hljs-attribute,
-.hljs-literal,
-.hljs-meta,
-.hljs-number,
-.hljs-operator,
-.hljs-variable,
-.hljs-selector-attr,
-.hljs-selector-class,
-.hljs-selector-id {
+html:not([data-custom-syntax-theme]) .hljs-attr,
+html:not([data-custom-syntax-theme]) .hljs-attribute,
+html:not([data-custom-syntax-theme]) .hljs-literal,
+html:not([data-custom-syntax-theme]) .hljs-meta,
+html:not([data-custom-syntax-theme]) .hljs-number,
+html:not([data-custom-syntax-theme]) .hljs-operator,
+html:not([data-custom-syntax-theme]) .hljs-variable,
+html:not([data-custom-syntax-theme]) .hljs-selector-attr,
+html:not([data-custom-syntax-theme]) .hljs-selector-class,
+html:not([data-custom-syntax-theme]) .hljs-selector-id {
color: var(--color-prettylights-syntax-constant);
}
-.hljs-regexp,
-.hljs-string,
-.hljs-meta .hljs-string {
+html:not([data-custom-syntax-theme]) .hljs-regexp,
+html:not([data-custom-syntax-theme]) .hljs-string,
+html:not([data-custom-syntax-theme]) .hljs-meta .hljs-string {
color: var(--color-prettylights-syntax-string);
}
-.hljs-built_in,
-.hljs-symbol {
+html:not([data-custom-syntax-theme]) .hljs-built_in,
+html:not([data-custom-syntax-theme]) .hljs-symbol {
color: var(--color-prettylights-syntax-variable);
}
-.hljs-comment,
-.hljs-code,
-.hljs-formula {
+html:not([data-custom-syntax-theme]) .hljs-comment,
+html:not([data-custom-syntax-theme]) .hljs-code,
+html:not([data-custom-syntax-theme]) .hljs-formula {
color: var(--color-prettylights-syntax-comment);
}
-.hljs-name,
-.hljs-quote,
-.hljs-selector-tag,
-.hljs-selector-pseudo {
+html:not([data-custom-syntax-theme]) .hljs-name,
+html:not([data-custom-syntax-theme]) .hljs-quote,
+html:not([data-custom-syntax-theme]) .hljs-selector-tag,
+html:not([data-custom-syntax-theme]) .hljs-selector-pseudo {
color: var(--color-prettylights-syntax-entity-tag);
}
-.hljs-subst {
+html:not([data-custom-syntax-theme]) .hljs-subst {
color: var(--color-fg-default);
}
-.hljs-section {
+html:not([data-custom-syntax-theme]) .hljs-section {
color: var(--color-prettylights-syntax-markup-heading);
font-weight: bold;
}
-.hljs-bullet {
+html:not([data-custom-syntax-theme]) .hljs-bullet {
color: var(--color-prettylights-syntax-constant);
}
-.hljs-emphasis {
+html:not([data-custom-syntax-theme]) .hljs-emphasis {
color: var(--color-fg-default);
font-style: italic;
}
-.hljs-strong {
+html:not([data-custom-syntax-theme]) .hljs-strong {
color: var(--color-fg-default);
font-weight: bold;
}
-.hljs-addition {
+html:not([data-custom-syntax-theme]) .hljs-addition {
color: var(--color-prettylights-syntax-markup-inserted-text);
background-color: var(--color-prettylights-syntax-markup-inserted-bg);
}
-.hljs-deletion {
+html:not([data-custom-syntax-theme]) .hljs-deletion {
color: var(--color-prettylights-syntax-markup-deleted-text);
background-color: var(--color-prettylights-syntax-markup-deleted-bg);
}
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
index c11d8d9c..d5312262 100644
--- a/tests/e2e/visual-settings.spec.js
+++ b/tests/e2e/visual-settings.spec.js
@@ -257,6 +257,10 @@ Greetings: :wave: :thumbsup: :smile:
expect(preBg).not.toBe('transparent');
expect(preBg).toContain('39, 40, 34'); // #272822
expect(codeColor).toContain('221, 221, 221'); // #ddd
+ const fnColor = await page.locator('#markdown-preview pre code .hljs-title').first().evaluate(el => {
+ return window.getComputedStyle(el).color;
+ });
+ expect(fnColor).toBe('rgb(166, 226, 46)'); // Monokai green
});
test('P1: MathJax font switches to STIX Two and updates output jax font data live and on reload', async ({ page }) => {
@@ -544,5 +548,183 @@ Greetings: :wave: :thumbsup: :smile:
expect(failedFontRequests).toEqual([]);
expect(wrongPackageRequests).toEqual([]);
});
+
+ test('P2: resolve automatic code colors against PDF export theme', async ({ page }) => {
+ await openApp(page);
+ const editor = page.locator('#markdown-editor');
+ await editor.fill('```javascript\nfunction greet() { return "hello"; }\n```');
+ await editor.dispatchEvent('input');
+ await expect(page.locator('#markdown-preview pre code.hljs')).toBeVisible({ timeout: 15_000 });
+
+ // Ensure Auto syntax highlighting is active
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyHighlightTheme('auto');
+ });
+
+ // 1. Light app theme + Auto syntax + Dark PDF export
+ await page.evaluate(() => {
+ document.documentElement.setAttribute('data-theme', 'light');
+ window.visualSettingsAdapters.onAppThemeChange('light');
+ });
+
+ // Preview code block has light background (app theme)
+ const previewLightBg = await page.locator('#markdown-preview pre').first().evaluate(el => {
+ return window.getComputedStyle(el).backgroundColor;
+ });
+ expect(previewLightBg).not.toBe('rgb(22, 27, 34)');
+
+ // Trigger dark vector PDF export to inspect the export render snapshot
+ await page.evaluate(() => {
+ window.print = () => { window.__darkPdfPrintReady = true; };
+ });
+ await page.locator('#export-pdf').dispatchEvent('click');
+ await page.locator('#pdf-export-theme-card-dark').click();
+ await page.locator('#pdf-export-confirm').click();
+
+ await expect.poll(() => page.evaluate(() => window.__darkPdfPrintReady), { timeout: 20_000 }).toBe(true);
+
+ // Evaluate snapshot code block colors under dark export
+ const darkExportColors = await page.locator('.browser-print-export-snapshot').evaluate(root => {
+ const pre = root.querySelector('pre');
+ const code = root.querySelector('code');
+ return {
+ preBg: window.getComputedStyle(pre).backgroundColor,
+ codeColor: window.getComputedStyle(code).color
+ };
+ });
+
+ expect(darkExportColors.preBg).toBe('rgb(22, 27, 34)'); // #161b22
+ expect(darkExportColors.codeColor).toBe('rgb(201, 209, 217)'); // #c9d1d9
+
+ // Close/restore afterprint
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event('afterprint'));
+ });
+
+ // 2. Dark app theme + Auto syntax + Light PDF export
+ await page.evaluate(() => {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ window.visualSettingsAdapters.onAppThemeChange('dark');
+ window.print = () => { window.__lightPdfPrintReady = true; };
+ });
+
+ // Preview code block has dark background in dark mode
+ const previewDarkBg = await page.locator('#markdown-preview pre').first().evaluate(el => {
+ return window.getComputedStyle(el).backgroundColor;
+ });
+ expect(previewDarkBg).not.toBe('rgb(246, 248, 250)');
+
+ await page.locator('#export-pdf').dispatchEvent('click');
+ await page.locator('#pdf-export-theme-card-light').click();
+ await page.locator('#pdf-export-confirm').click();
+
+ await expect.poll(() => page.evaluate(() => window.__lightPdfPrintReady), { timeout: 20_000 }).toBe(true);
+
+ const lightExportColors = await page.locator('.browser-print-export-snapshot').evaluate(root => {
+ const pre = root.querySelector('pre');
+ const code = root.querySelector('code');
+ return {
+ preBg: window.getComputedStyle(pre).backgroundColor,
+ codeColor: window.getComputedStyle(code).color
+ };
+ });
+
+ expect(lightExportColors.preBg).toBe('rgb(246, 248, 250)'); // #f6f8fa
+ expect(lightExportColors.codeColor).toBe('rgb(36, 41, 46)'); // #24292e
+
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event('afterprint'));
+ });
+ });
+
+ test('P2: remove built-in token palette when applying syntax theme and match external stylesheets', async ({ page }) => {
+ await openApp(page);
+ const editor = page.locator('#markdown-editor');
+ await editor.fill('```javascript\nfunction hello() { return 42; }\n```');
+ await editor.dispatchEvent('input');
+ await expect(page.locator('#markdown-preview pre code.hljs')).toBeVisible({ timeout: 15_000 });
+
+ // 1. In Auto mode (light app), function title uses the built-in GitHub purple
+ await page.evaluate(async () => {
+ document.documentElement.setAttribute('data-theme', 'light');
+ await window.visualSettingsAdapters.applyHighlightTheme('auto');
+ });
+ const autoFnColor = await page.locator('#markdown-preview pre code .hljs-title').first().evaluate(el => {
+ return window.getComputedStyle(el).color;
+ });
+ expect(autoFnColor).toBe('rgb(111, 66, 193)');
+
+ // 2. Select Monokai: function name must be Monokai green, NOT legacy purple
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyHighlightTheme('monokai');
+ });
+ await expect.poll(async () => {
+ return await page.locator('#markdown-preview pre code .hljs-title').first().evaluate(el => {
+ return window.getComputedStyle(el).color;
+ });
+ }, { timeout: 10_000 }).toBe('rgb(166, 226, 46)');
+
+ // 3. Compare actual token colors against an isolated iframe containing only the selected stylesheet
+ const testThemes = ['monokai', 'dracula', 'atom-one-dark', 'solarized-dark', 'nord', 'default'];
+ for (const theme of testThemes) {
+ await page.evaluate(async (t) => {
+ await window.visualSettingsAdapters.applyHighlightTheme(t);
+ }, theme);
+
+ const comparison = await page.evaluate(async () => {
+ const link = document.getElementById('mdv-hljs-theme-link');
+ const href = link ? link.href : '';
+
+ const iframe = document.createElement('iframe');
+ iframe.style.position = 'fixed';
+ iframe.style.top = '-9999px';
+ iframe.style.left = '-9999px';
+ document.body.appendChild(iframe);
+
+ const idoc = iframe.contentDocument;
+ idoc.open();
+ idoc.write(`
function hello() { return 42; }
`);
+ idoc.close();
+
+ await new Promise(resolve => {
+ const l = idoc.querySelector('link');
+ if (l && l.sheet) return resolve();
+ if (l) {
+ l.onload = () => resolve();
+ l.onerror = () => resolve();
+ }
+ setTimeout(resolve, 1000);
+ });
+
+ const previewFn = document.querySelector('#markdown-preview pre code .hljs-title');
+ const iframeFn = idoc.querySelector('pre code .hljs-title');
+ const previewKw = document.querySelector('#markdown-preview pre code .hljs-keyword');
+ const iframeKw = idoc.querySelector('pre code .hljs-keyword');
+
+ const result = {
+ previewFnColor: previewFn ? getComputedStyle(previewFn).color : null,
+ iframeFnColor: iframeFn ? iframe.contentWindow.getComputedStyle(iframeFn).color : null,
+ previewKwColor: previewKw ? getComputedStyle(previewKw).color : null,
+ iframeKwColor: iframeKw ? iframe.contentWindow.getComputedStyle(iframeKw).color : null
+ };
+ iframe.remove();
+ return result;
+ });
+
+ expect(comparison.previewFnColor).toBe(comparison.iframeFnColor);
+ expect(comparison.previewKwColor).toBe(comparison.iframeKwColor);
+ }
+
+ // 4. Switching back to Auto restores built-in palette
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyHighlightTheme('auto');
+ });
+ await expect.poll(async () => {
+ return await page.locator('#markdown-preview pre code .hljs-title').first().evaluate(el => {
+ return window.getComputedStyle(el).color;
+ });
+ }, { timeout: 10_000 }).toBe('rgb(111, 66, 193)');
+ });
});
+
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
index f48e8be2..54755bed 100644
--- a/tests/unit/visual-settings-adapters.test.mjs
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -18,6 +18,9 @@ function createAdapterContext() {
setAttribute(name, val) {
this._attrs = this._attrs || {};
this._attrs[name] = val;
+ },
+ removeAttribute(name) {
+ if (this._attrs) delete this._attrs[name];
}
};
@@ -200,7 +203,7 @@ test('applyMermaid handles auto theme and look style', async () => {
assert.equal(config.look, 'classic');
});
-test('applyHighlightTheme manages stylesheet and auto theme', async () => {
+test('applyHighlightTheme manages stylesheet, auto theme, and export styling', async () => {
const { context, adapters } = createAdapterContext();
context.document.documentElement.setAttribute('data-theme', 'light');
@@ -208,15 +211,37 @@ test('applyHighlightTheme manages stylesheet and auto theme', async () => {
let link = context.document.getElementById('mdv-hljs-theme-link');
assert.ok(link);
assert.ok(link.href.includes('monokai.min.css'));
+ assert.equal(context.document.documentElement.getAttribute('data-custom-syntax-theme'), 'monokai');
+ assert.equal(context.document.documentElement.getAttribute('data-hljs-theme'), 'monokai');
+
+ // Test getHljsTheme and resolveHljsThemeName
+ assert.equal(adapters.resolveHljsThemeName('monokai'), 'monokai');
+ assert.equal(adapters.resolveHljsThemeName('auto', 'dark'), 'github-dark');
+ assert.equal(adapters.resolveHljsThemeName('auto', 'light'), 'github');
+ assert.equal(adapters.getHljsTheme(), 'monokai');
await adapters.applyHighlightTheme('auto');
link = context.document.getElementById('mdv-hljs-theme-link');
assert.ok(link.href.includes('github.min.css'));
+ assert.equal(adapters.getHljsTheme(), 'github');
+ assert.equal(adapters.getHljsTheme('dark'), 'github-dark');
+ assert.equal(context.document.documentElement.getAttribute('data-custom-syntax-theme'), null);
+ assert.equal(context.document.documentElement.getAttribute('data-hljs-theme'), 'auto');
+
+ // Verify mdv-hljs-style-helper content scopes preview and provides export rules
+ const helper = context.document.getElementById('mdv-hljs-style-helper');
+ assert.ok(helper);
+ assert.ok(helper.textContent.includes('.markdown-body:not(.pdf-export):not(.export-render-root)'));
+ assert.ok(helper.textContent.includes('.pdf-export.theme-dark'));
+ assert.ok(helper.textContent.includes('.pdf-export.theme-light'));
+ assert.ok(helper.textContent.includes('data-browser-print-export="dark"'));
context.document.documentElement.setAttribute('data-theme', 'dark');
adapters.onAppThemeChange('dark');
link = context.document.getElementById('mdv-hljs-theme-link');
assert.ok(link.href.includes('github-dark.min.css'));
+ assert.equal(adapters.getHljsTheme(), 'github-dark');
+ assert.equal(adapters.getHljsTheme('light'), 'github');
});
test('applyMathFont maintains MathJax 3 and 4 configuration compatibility without chtml.font string', async () => {
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index bb533916..5d7be170 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -123,9 +123,10 @@
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
- function resolveHljsThemeName(theme) {
+ function resolveHljsThemeName(theme, themeOverride) {
if (theme === 'auto') {
- return getEffectiveAppTheme() === 'dark' ? 'github-dark' : 'github';
+ const effective = themeOverride || getEffectiveAppTheme();
+ return effective === 'dark' ? 'github-dark' : 'github';
}
return theme || 'github';
}
@@ -172,32 +173,139 @@
} catch (_) {}
}
- style.textContent = `
- .markdown-body pre:has(> code.hljs) {
- background-color: ${bg} !important;
- color: ${fg} !important;
- border-radius: 6px;
- padding: 16px !important;
- overflow: auto;
- }
- .markdown-body pre > code.hljs,
- .markdown-body code.hljs {
- background-color: ${bg} !important;
- color: ${fg} !important;
- padding: 0 !important;
- border-radius: 0 !important;
- }
- @media print {
- .markdown-body pre:has(> code.hljs),
+ if (_activeHljsTheme === 'auto') {
+ style.textContent = `
+ /* Preview code blocks in Auto mode adopt application theme */
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre:has(> code.hljs) {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre > code.hljs,
+ .markdown-body:not(.pdf-export):not(.export-render-root) code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+
+ /* Export documents in Auto mode resolve against the selected export theme */
+ .pdf-export.theme-dark pre:has(> code.hljs),
+ .pdf-export[data-theme="dark"] pre:has(> code.hljs),
+ .export-render-root[data-theme="dark"] pre:has(> code.hljs),
+ .export-render-root.theme-dark pre:has(> code.hljs) {
+ background-color: #161b22 !important;
+ color: #c9d1d9 !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .pdf-export.theme-dark pre > code.hljs,
+ .pdf-export[data-theme="dark"] pre > code.hljs,
+ .export-render-root[data-theme="dark"] pre > code.hljs,
+ .export-render-root.theme-dark pre > code.hljs,
+ .pdf-export.theme-dark code.hljs,
+ .pdf-export[data-theme="dark"] code.hljs,
+ .export-render-root[data-theme="dark"] code.hljs,
+ .export-render-root.theme-dark code.hljs {
+ background-color: #161b22 !important;
+ color: #c9d1d9 !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+
+ .pdf-export.theme-light pre:has(> code.hljs),
+ .pdf-export[data-theme="light"] pre:has(> code.hljs),
+ .export-render-root[data-theme="light"] pre:has(> code.hljs),
+ .export-render-root.theme-light pre:has(> code.hljs) {
+ background-color: #f6f8fa !important;
+ color: #24292e !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
+ .pdf-export.theme-light pre > code.hljs,
+ .pdf-export[data-theme="light"] pre > code.hljs,
+ .export-render-root[data-theme="light"] pre > code.hljs,
+ .export-render-root.theme-light pre > code.hljs,
+ .pdf-export.theme-light code.hljs,
+ .pdf-export[data-theme="light"] code.hljs,
+ .export-render-root[data-theme="light"] code.hljs,
+ .export-render-root.theme-light code.hljs {
+ background-color: #f6f8fa !important;
+ color: #24292e !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ }
+
+ @media print {
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre:has(> code.hljs),
+ .markdown-body:not(.pdf-export):not(.export-render-root) pre > code.hljs,
+ .markdown-body:not(.pdf-export):not(.export-render-root) code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+
+ html[data-browser-print-export="dark"] .markdown-body pre:has(> code.hljs),
+ html[data-browser-print-export="dark"] .markdown-body pre > code.hljs,
+ html[data-browser-print-export="dark"] .markdown-body code.hljs,
+ .pdf-export.theme-dark pre:has(> code.hljs),
+ .pdf-export.theme-dark pre > code.hljs,
+ .pdf-export.theme-dark code.hljs {
+ background-color: #161b22 !important;
+ color: #c9d1d9 !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+
+ html:not([data-browser-print-export="dark"]) .markdown-body.pdf-export.theme-light pre:has(> code.hljs),
+ html:not([data-browser-print-export="dark"]) .markdown-body.pdf-export.theme-light pre > code.hljs,
+ html:not([data-browser-print-export="dark"]) .markdown-body.pdf-export.theme-light code.hljs,
+ html[data-browser-print-export="light"] .markdown-body pre:has(> code.hljs),
+ html[data-browser-print-export="light"] .markdown-body pre > code.hljs,
+ html[data-browser-print-export="light"] .markdown-body code.hljs,
+ .pdf-export.theme-light pre:has(> code.hljs),
+ .pdf-export.theme-light pre > code.hljs,
+ .pdf-export.theme-light code.hljs {
+ background-color: #f6f8fa !important;
+ color: #24292e !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+ }
+ `;
+ } else {
+ style.textContent = `
+ .markdown-body pre:has(> code.hljs) {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ border-radius: 6px;
+ padding: 16px !important;
+ overflow: auto;
+ }
.markdown-body pre > code.hljs,
.markdown-body code.hljs {
background-color: ${bg} !important;
color: ${fg} !important;
- -webkit-print-color-adjust: exact !important;
- print-color-adjust: exact !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
}
- }
- `;
+ @media print {
+ .markdown-body pre:has(> code.hljs),
+ .markdown-body pre > code.hljs,
+ .markdown-body code.hljs {
+ background-color: ${bg} !important;
+ color: ${fg} !important;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+ }
+ `;
+ }
}
/**
@@ -211,6 +319,18 @@
if (typeof document === 'undefined') return Promise.resolve();
+ if (document.documentElement) {
+ if (_activeHljsTheme !== 'auto') {
+ document.documentElement.setAttribute('data-custom-syntax-theme', _activeHljsTheme);
+ document.documentElement.setAttribute('data-hljs-theme', _activeHljsTheme);
+ } else {
+ if (typeof document.documentElement.removeAttribute === 'function') {
+ document.documentElement.removeAttribute('data-custom-syntax-theme');
+ }
+ document.documentElement.setAttribute('data-hljs-theme', 'auto');
+ }
+ }
+
const subpath = HLJS_THEME_PATHS[effectiveName] || `${effectiveName}.min.css`;
const href = `${HLJS_CDN_BASE}${subpath}`;
@@ -644,6 +764,8 @@
theme: resolveMermaidThemeName(_activeMermaidTheme, themeOverride),
look: _activeMermaidLook === 'handDrawn' ? 'handDrawn' : 'classic'
}),
+ getHljsTheme: (themeOverride) => resolveHljsThemeName(_activeHljsTheme, themeOverride),
+ resolveHljsThemeName,
needsRerender,
apply,
resolveEmojiShortcode,
From d02ff50ae3d375f8451d07895029736bd0599f81 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 18:04:00 +0530
Subject: [PATCH 14/15] fix(emoji): canonicalize emoji aliases before resolving
skin-tone variants (P2)
---
.../resources/js/visual-settings-adapters.js | 53 ++++++++++++++++-
tests/e2e/visual-settings.spec.js | 57 +++++++++++++++++++
tests/unit/visual-settings-adapters.test.mjs | 27 ++++++++-
visual-settings-adapters.js | 53 ++++++++++++++++-
4 files changed, 181 insertions(+), 9 deletions(-)
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index 5d7be170..a9a0ba78 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -612,28 +612,74 @@
}
}
+ /**
+ * Resolves a shortcode to JoyPixels' canonical shortcode name (e.g. '+1' -> 'thumbsup', 'runner' -> 'person_running').
+ */
+ function getCanonicalEmojiShortcode(code, joy) {
+ if (!code) return code;
+ const clean = code.replace(/^:|:$/g, '');
+ if (!joy) return clean;
+ const colonCode = `:${clean}:`;
+
+ // 1. If it's already a canonical key in joy.emojiList
+ if (joy.emojiList && joy.emojiList[colonCode]) {
+ return clean;
+ }
+
+ // 2. Check joy.altShortNames (native JoyPixels alias map)
+ if (joy.altShortNames) {
+ const alt = joy.altShortNames[colonCode] || joy.altShortNames[clean];
+ if (alt) {
+ return alt.replace(/^:|:$/g, '');
+ }
+ }
+
+ // 3. Fallback: scan emojiList if altShortNames is not populated
+ if (joy.emojiList) {
+ for (const [key, val] of Object.entries(joy.emojiList)) {
+ if (val && Array.isArray(val.shortnames)) {
+ if (val.shortnames.includes(colonCode) || val.shortnames.includes(clean)) {
+ return key.replace(/^:|:$/g, '');
+ }
+ }
+ }
+ }
+
+ return clean;
+ }
+
/**
* Resolves an emoji shortcode to include the active Fitzpatrick skin tone modifier
* if the emoji supports skin tone modifiers, otherwise returns the original shortcode.
+ * Canonicalizes JoyPixels aliases prior to checking tone support.
* Shortcodes with explicit tone modifiers are left untouched.
*/
function resolveEmojiShortcode(shortcode) {
+ if (!shortcode) return shortcode;
+ const clean = shortcode.replace(/^:|:$/g, '');
+
if (!_activeEmojiSkinTone || _activeEmojiSkinTone === 'default') {
return shortcode;
}
// If already has an explicit skin tone suffix, preserve it
- if (/_tone[1-5]$/.test(shortcode)) {
+ if (/_tone[1-5]$/.test(clean)) {
return shortcode;
}
- // Check JoyPixels library list if available
- const toneCandidate = `${shortcode}_${_activeEmojiSkinTone}`;
const joy = typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined);
+ const canonical = getCanonicalEmojiShortcode(clean, joy);
+ const toneCandidate = `${canonical}_${_activeEmojiSkinTone}`;
+
+ // Check JoyPixels library list if available
if (joy && joy.emojiList) {
if (joy.emojiList[`:${toneCandidate}:`]) {
return toneCandidate;
}
+ const directCandidate = `${clean}_${_activeEmojiSkinTone}`;
+ if (joy.emojiList[`:${directCandidate}:`]) {
+ return directCandidate;
+ }
return shortcode;
}
@@ -768,6 +814,7 @@
resolveHljsThemeName,
needsRerender,
apply,
+ getCanonicalEmojiShortcode: (code) => getCanonicalEmojiShortcode(code, typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined)),
resolveEmojiShortcode,
applyHighlightTheme,
applyMathFont,
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
index d5312262..230ac02c 100644
--- a/tests/e2e/visual-settings.spec.js
+++ b/tests/e2e/visual-settings.spec.js
@@ -725,6 +725,63 @@ Greetings: :wave: :thumbsup: :smile:
});
}, { timeout: 10_000 }).toBe('rgb(111, 66, 193)');
});
+
+ test('P2: canonicalize emoji aliases before adding skin-tone suffix', async ({ page }) => {
+ await openApp(page);
+ const editor = page.locator('#markdown-editor');
+ await editor.fill(':thumbsup: :+1: :runner: :wave_tone3: :smile:');
+ await editor.dispatchEvent('input');
+ await expect(page.locator('#markdown-preview')).toContainText('👋🏽', { timeout: 15_000 });
+
+ const expectedGlyphs = {
+ tone1: { thumbs: '👍🏻', runner: '🏃🏻' },
+ tone2: { thumbs: '👍🏼', runner: '🏃🏼' },
+ tone3: { thumbs: '👍🏽', runner: '🏃🏽' },
+ tone4: { thumbs: '👍🏾', runner: '🏃🏾' },
+ tone5: { thumbs: '👍🏿', runner: '🏃🏿' }
+ };
+
+ // Test across all 5 non-default skin tones in light and dark mode
+ for (const [tone, glyphs] of Object.entries(expectedGlyphs)) {
+ for (const appTheme of ['light', 'dark']) {
+ await page.evaluate(async ({ tone, appTheme }) => {
+ document.documentElement.setAttribute('data-theme', appTheme);
+ await window.visualSettingsAdapters.applyEmojiSkinTone(tone);
+ }, { tone, appTheme });
+
+ await expect.poll(async () => {
+ return await page.locator('#markdown-preview').textContent();
+ }, { timeout: 10_000 }).toContain(glyphs.thumbs);
+
+ const previewText = await page.locator('#markdown-preview').textContent();
+ // Both canonical :thumbsup: and alias :+1: must render with the chosen skin tone
+ const thumbsOccurrences = (previewText.match(new RegExp(glyphs.thumbs, 'g')) || []).length;
+ expect(thumbsOccurrences).toBe(2);
+
+ // Alias :runner: must render with the chosen skin tone (not yellow 🏃)
+ expect(previewText).toContain(glyphs.runner);
+
+ // Explicit tone :wave_tone3: preserved
+ expect(previewText).toContain('👋🏽');
+
+ // Genuinely unsupported emoji :smile: remains 😄
+ expect(previewText).toContain('😄');
+ }
+ }
+
+ // Reset to default
+ await page.evaluate(async () => {
+ await window.visualSettingsAdapters.applyEmojiSkinTone('default');
+ });
+ await expect.poll(async () => {
+ return await page.locator('#markdown-preview').textContent();
+ }, { timeout: 10_000 }).toContain('👍');
+ const defaultText = await page.locator('#markdown-preview').textContent();
+ const defaultThumbs = (defaultText.match(/👍/g) || []).length;
+ expect(defaultThumbs).toBe(2);
+ expect(defaultText).toContain('🏃');
+ expect(defaultText).toContain('👋🏽');
+ });
});
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
index 54755bed..f90a9d45 100644
--- a/tests/unit/visual-settings-adapters.test.mjs
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -145,7 +145,7 @@ test('getDefaults() returns standard default visual settings', () => {
test('resolveEmojiShortcode handles skin tones accurately', () => {
const { context, adapters } = createAdapterContext();
- // Mock joypixels emojiList
+ // Mock joypixels emojiList and altShortNames
context.joypixels = {
emojiList: {
':wave:': {},
@@ -154,29 +154,50 @@ test('resolveEmojiShortcode handles skin tones accurately', () => {
':wave_tone3:': {},
':wave_tone4:': {},
':wave_tone5:': {},
- ':thumbsup:': {},
- ':thumbsup_tone1:': {},
+ ':thumbsup:': { shortnames: [':+1:', ':thumbup:'] },
+ ':thumbsup_tone1:': { shortnames: [':+1_tone1:'] },
+ ':thumbsup_tone5:': { shortnames: [':+1_tone5:'] },
+ ':person_running:': { shortnames: [':runner:'] },
+ ':person_running_tone1:': { shortnames: [':runner_tone1:'] },
+ ':person_running_tone5:': { shortnames: [':runner_tone5:'] },
':smile:': {}
+ },
+ altShortNames: {
+ ':+1:': ':thumbsup:',
+ ':+1_tone1:': ':thumbsup_tone1:',
+ ':runner:': ':person_running:',
+ ':runner_tone1:': ':person_running_tone1:'
}
};
// When default tone is active
adapters.applyEmojiSkinTone('default');
assert.equal(adapters.resolveEmojiShortcode('wave'), 'wave');
+ assert.equal(adapters.resolveEmojiShortcode('thumbsup'), 'thumbsup');
+ assert.equal(adapters.resolveEmojiShortcode('+1'), '+1');
+ assert.equal(adapters.resolveEmojiShortcode('runner'), 'runner');
assert.equal(adapters.resolveEmojiShortcode('smile'), 'smile');
// When tone1 is active
adapters.applyEmojiSkinTone('tone1');
assert.equal(adapters.resolveEmojiShortcode('wave'), 'wave_tone1');
assert.equal(adapters.resolveEmojiShortcode('thumbsup'), 'thumbsup_tone1');
+ // Aliases canonicalize to JoyPixels canonical keys with tone
+ assert.equal(adapters.resolveEmojiShortcode('+1'), 'thumbsup_tone1');
+ assert.equal(adapters.resolveEmojiShortcode('runner'), 'person_running_tone1');
// Emoji that does NOT support tone is untouched
assert.equal(adapters.resolveEmojiShortcode('smile'), 'smile');
// Emoji with existing tone modifier is untouched
assert.equal(adapters.resolveEmojiShortcode('wave_tone3'), 'wave_tone3');
+ assert.equal(adapters.resolveEmojiShortcode('+1_tone3'), '+1_tone3');
+ assert.equal(adapters.resolveEmojiShortcode('runner_tone2'), 'runner_tone2');
// When tone5 is active
adapters.applyEmojiSkinTone('tone5');
assert.equal(adapters.resolveEmojiShortcode('wave'), 'wave_tone5');
+ assert.equal(adapters.resolveEmojiShortcode('thumbsup'), 'thumbsup_tone5');
+ assert.equal(adapters.resolveEmojiShortcode('+1'), 'thumbsup_tone5');
+ assert.equal(adapters.resolveEmojiShortcode('runner'), 'person_running_tone5');
assert.equal(adapters.resolveEmojiShortcode('smile'), 'smile');
});
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index 5d7be170..a9a0ba78 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -612,28 +612,74 @@
}
}
+ /**
+ * Resolves a shortcode to JoyPixels' canonical shortcode name (e.g. '+1' -> 'thumbsup', 'runner' -> 'person_running').
+ */
+ function getCanonicalEmojiShortcode(code, joy) {
+ if (!code) return code;
+ const clean = code.replace(/^:|:$/g, '');
+ if (!joy) return clean;
+ const colonCode = `:${clean}:`;
+
+ // 1. If it's already a canonical key in joy.emojiList
+ if (joy.emojiList && joy.emojiList[colonCode]) {
+ return clean;
+ }
+
+ // 2. Check joy.altShortNames (native JoyPixels alias map)
+ if (joy.altShortNames) {
+ const alt = joy.altShortNames[colonCode] || joy.altShortNames[clean];
+ if (alt) {
+ return alt.replace(/^:|:$/g, '');
+ }
+ }
+
+ // 3. Fallback: scan emojiList if altShortNames is not populated
+ if (joy.emojiList) {
+ for (const [key, val] of Object.entries(joy.emojiList)) {
+ if (val && Array.isArray(val.shortnames)) {
+ if (val.shortnames.includes(colonCode) || val.shortnames.includes(clean)) {
+ return key.replace(/^:|:$/g, '');
+ }
+ }
+ }
+ }
+
+ return clean;
+ }
+
/**
* Resolves an emoji shortcode to include the active Fitzpatrick skin tone modifier
* if the emoji supports skin tone modifiers, otherwise returns the original shortcode.
+ * Canonicalizes JoyPixels aliases prior to checking tone support.
* Shortcodes with explicit tone modifiers are left untouched.
*/
function resolveEmojiShortcode(shortcode) {
+ if (!shortcode) return shortcode;
+ const clean = shortcode.replace(/^:|:$/g, '');
+
if (!_activeEmojiSkinTone || _activeEmojiSkinTone === 'default') {
return shortcode;
}
// If already has an explicit skin tone suffix, preserve it
- if (/_tone[1-5]$/.test(shortcode)) {
+ if (/_tone[1-5]$/.test(clean)) {
return shortcode;
}
- // Check JoyPixels library list if available
- const toneCandidate = `${shortcode}_${_activeEmojiSkinTone}`;
const joy = typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined);
+ const canonical = getCanonicalEmojiShortcode(clean, joy);
+ const toneCandidate = `${canonical}_${_activeEmojiSkinTone}`;
+
+ // Check JoyPixels library list if available
if (joy && joy.emojiList) {
if (joy.emojiList[`:${toneCandidate}:`]) {
return toneCandidate;
}
+ const directCandidate = `${clean}_${_activeEmojiSkinTone}`;
+ if (joy.emojiList[`:${directCandidate}:`]) {
+ return directCandidate;
+ }
return shortcode;
}
@@ -768,6 +814,7 @@
resolveHljsThemeName,
needsRerender,
apply,
+ getCanonicalEmojiShortcode: (code) => getCanonicalEmojiShortcode(code, typeof joypixels !== 'undefined' ? joypixels : (typeof window !== 'undefined' ? window.joypixels : undefined)),
resolveEmojiShortcode,
applyHighlightTheme,
applyMathFont,
From a9270dc305a6fdeb19c0da67335542ca5f3714d9 Mon Sep 17 00:00:00 2001
From: itznan <113649889+itznan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 18:29:52 +0530
Subject: [PATCH 15/15] fix(mathjax): load Modern font resources when resetting
after alternate-font reload (P2)
---
.../resources/js/visual-settings-adapters.js | 19 ++++-
tests/e2e/visual-settings.spec.js | 54 +++++++++++++
tests/unit/visual-settings-adapters.test.mjs | 78 +++++++++++++++++++
visual-settings-adapters.js | 19 ++++-
4 files changed, 168 insertions(+), 2 deletions(-)
diff --git a/desktop-app/resources/js/visual-settings-adapters.js b/desktop-app/resources/js/visual-settings-adapters.js
index a9a0ba78..bf05604f 100644
--- a/desktop-app/resources/js/visual-settings-adapters.js
+++ b/desktop-app/resources/js/visual-settings-adapters.js
@@ -448,13 +448,30 @@
// Load external font script if needed and MathJax core is already loaded.
// If MathJax has not loaded yet, configuring output.font and resource URLs
// is sufficient because MathJax's startup loader will load the font component itself.
- if (_activeMathFont !== 'mathjax-modern' && window.MathJax._) {
+ const fontAlreadyLoaded = !!(
+ window.MathJax._ &&
+ window.MathJax._.output &&
+ window.MathJax._.output.fonts &&
+ window.MathJax._.output.fonts[_activeMathFont]
+ );
+
+ if (!fontAlreadyLoaded && window.MathJax._ && !isMathJax3) {
const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
try {
await loadScriptOnce(fontUrl);
} catch (err) {
console.warn(`[VisualAdapters] MathJax font load failed (${_activeMathFont}), falling back to bundled Modern:`, err);
_activeMathFont = 'mathjax-modern';
+ if (
+ window.MathJax._ &&
+ (!window.MathJax._.output || !window.MathJax._.output.fonts || !window.MathJax._.output.fonts['mathjax-modern'])
+ ) {
+ try {
+ await loadScriptOnce(`${MATHJAX_FONT_CDN_BASE}mathjax-modern-font/chtml.js`);
+ } catch (modernErr) {
+ console.warn('[VisualAdapters] MathJax modern fallback load failed:', modernErr);
+ }
+ }
}
}
diff --git a/tests/e2e/visual-settings.spec.js b/tests/e2e/visual-settings.spec.js
index 230ac02c..c3b7a7ba 100644
--- a/tests/e2e/visual-settings.spec.js
+++ b/tests/e2e/visual-settings.spec.js
@@ -549,6 +549,60 @@ Greetings: :wave: :thumbsup: :smile:
expect(wrongPackageRequests).toEqual([]);
});
+ test('P2: load Modern when resetting after an alternate-font reload', async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem('mdv.visualSettings.v1', JSON.stringify({
+ mathFont: 'mathjax-stix2'
+ }));
+ });
+
+ const failedFontRequests = [];
+ page.on('response', resp => {
+ if (resp.url().includes('mathjax') && resp.status() >= 400) {
+ failedFontRequests.push({ url: resp.url(), status: resp.status() });
+ }
+ });
+
+ await openApp(page, '/', { mockMathJax: false });
+
+ // Initial render with STIX Two
+ const editor = page.locator('#markdown-editor');
+ await editor.fill('$$x^2 + y^2 = z^2$$');
+ await editor.dispatchEvent('input');
+ await expect(page.locator('#markdown-preview mjx-container')).toBeVisible({ timeout: 15_000 });
+
+ const initialBootPrefix = await page.evaluate(() => {
+ return window.MathJax?.startup?.output?.font?.cssFontPrefix;
+ });
+ expect(initialBootPrefix).toBe('STX');
+
+ // Reset visual settings to default (Modern math font)
+ await page.locator('#visual-settings-button').click();
+ await page.locator('#visual-settings-reset-btn').click();
+
+ await expect(page.locator('#visual-setting-math-font')).toHaveValue('mathjax-modern');
+
+ await expect.poll(async () => {
+ return page.evaluate(() => {
+ const font = window.MathJax?.startup?.output?.font;
+ return {
+ prefix: font?.cssFontPrefix,
+ fontURL: font?.options?.fontURL,
+ dynamicPrefix: font?.options?.dynamicPrefix,
+ activeFont: window.visualSettingsAdapters?.getCurrentMathFont()
+ };
+ });
+ }, { timeout: 15_000 }).toEqual({
+ prefix: 'MM',
+ fontURL: 'https://cdn.jsdelivr.net/npm/mathjax-modern-font/chtml/woff',
+ dynamicPrefix: '[mathjax-modern]/chtml/dynamic',
+ activeFont: 'mathjax-modern'
+ });
+
+ await expect(page.locator('#markdown-preview mjx-container')).toBeVisible();
+ expect(failedFontRequests).toEqual([]);
+ });
+
test('P2: resolve automatic code colors against PDF export theme', async ({ page }) => {
await openApp(page);
const editor = page.locator('#markdown-editor');
diff --git a/tests/unit/visual-settings-adapters.test.mjs b/tests/unit/visual-settings-adapters.test.mjs
index f90a9d45..3ce2471e 100644
--- a/tests/unit/visual-settings-adapters.test.mjs
+++ b/tests/unit/visual-settings-adapters.test.mjs
@@ -347,3 +347,81 @@ test('applyMathFont initializes complete font resource configuration in MathJax
assert.equal(mockOutputJax.font.options.dynamicPrefix, '[mathjax-stix2]/chtml/dynamic');
});
+test('applyMathFont loads Modern font resources when resetting after alternate-font boot', async () => {
+ const { context, adapters } = createAdapterContext();
+
+ const mockOutputJax = {
+ font: {
+ name: 'mathjax-stix2',
+ options: {
+ fontURL: 'https://cdn.jsdelivr.net/npm/mathjax-stix2-font/chtml/woff',
+ dynamicPrefix: '[mathjax-stix2]/chtml/dynamic'
+ }
+ }
+ };
+
+ context.window.MathJax = {
+ version: '4.0.0-beta.7',
+ config: {},
+ loader: { paths: {} },
+ _: {
+ components: {
+ loader: {
+ CONFIG: {
+ paths: {
+ mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@4.0.0-beta.7'
+ }
+ }
+ }
+ },
+ output: {
+ fonts: {
+ 'mathjax-stix2': {
+ chtml_ts: {
+ MathJaxStix2Font: class MockStixFont {}
+ }
+ }
+ }
+ }
+ },
+ startup: {
+ getOutputJax: () => mockOutputJax,
+ getDocument: () => ({})
+ }
+ };
+
+ const origCreateElement = context.document.createElement.bind(context.document);
+ context.document.createElement = function(tag) {
+ const el = origCreateElement(tag);
+ if (tag.toLowerCase() === 'script') {
+ let _src = '';
+ Object.defineProperty(el, 'src', {
+ get() { return _src; },
+ set(val) {
+ _src = val;
+ if (val.includes('mathjax-modern-font')) {
+ context.window.MathJax._.output.fonts['mathjax-modern'] = {
+ chtml_ts: {
+ MathJaxModernFont: class MockModernFont {}
+ }
+ };
+ }
+ setTimeout(() => { if (this.onload) this.onload(); }, 1);
+ }
+ });
+ }
+ return el;
+ };
+
+ await adapters.applyMathFont('mathjax-modern');
+
+ const mj = context.window.MathJax;
+ assert.equal(mj.config.output.font, 'mathjax-modern');
+ assert.equal(mj.config.chtml.fontURL, 'https://cdn.jsdelivr.net/npm/mathjax-modern-font/chtml/woff');
+ assert.equal(mj.config.chtml.dynamicPrefix, '[mathjax-modern]/chtml/dynamic');
+ assert.equal(mj.config.loader.paths['mathjax-modern'], 'https://cdn.jsdelivr.net/npm/mathjax-modern-font');
+ assert.equal(mockOutputJax.font.options.fontURL, 'https://cdn.jsdelivr.net/npm/mathjax-modern-font/chtml/woff');
+ assert.equal(mockOutputJax.font.options.dynamicPrefix, '[mathjax-modern]/chtml/dynamic');
+ assert.ok(mj._.output.fonts['mathjax-modern']);
+});
+
diff --git a/visual-settings-adapters.js b/visual-settings-adapters.js
index a9a0ba78..bf05604f 100644
--- a/visual-settings-adapters.js
+++ b/visual-settings-adapters.js
@@ -448,13 +448,30 @@
// Load external font script if needed and MathJax core is already loaded.
// If MathJax has not loaded yet, configuring output.font and resource URLs
// is sufficient because MathJax's startup loader will load the font component itself.
- if (_activeMathFont !== 'mathjax-modern' && window.MathJax._) {
+ const fontAlreadyLoaded = !!(
+ window.MathJax._ &&
+ window.MathJax._.output &&
+ window.MathJax._.output.fonts &&
+ window.MathJax._.output.fonts[_activeMathFont]
+ );
+
+ if (!fontAlreadyLoaded && window.MathJax._ && !isMathJax3) {
const fontUrl = `${MATHJAX_FONT_CDN_BASE}${_activeMathFont}-font/chtml.js`;
try {
await loadScriptOnce(fontUrl);
} catch (err) {
console.warn(`[VisualAdapters] MathJax font load failed (${_activeMathFont}), falling back to bundled Modern:`, err);
_activeMathFont = 'mathjax-modern';
+ if (
+ window.MathJax._ &&
+ (!window.MathJax._.output || !window.MathJax._.output.fonts || !window.MathJax._.output.fonts['mathjax-modern'])
+ ) {
+ try {
+ await loadScriptOnce(`${MATHJAX_FONT_CDN_BASE}mathjax-modern-font/chtml.js`);
+ } catch (modernErr) {
+ console.warn('[VisualAdapters] MathJax modern fallback load failed:', modernErr);
+ }
+ }
}
}