diff --git a/frontend/bun.lock b/frontend/bun.lock
index 9ecdb59..b999e59 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -45,6 +45,7 @@
"jwt-decode": "^4.0.0",
"lucide-react": "^1.17.0",
"marked": "^18.0.5",
+ "monaco-editor": "^0.56.0",
"papaparse": "^5.5.3",
"php-serialize": "^5.1.3",
"qrcode": "^1.5.4",
@@ -459,6 +460,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
@@ -559,6 +562,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+ "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
+
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
@@ -685,6 +690,8 @@
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+ "monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
+
"moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -933,6 +940,8 @@
"codemirror/@codemirror/view": ["@codemirror/view@6.42.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg=="],
+ "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
+
"nearley/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
diff --git a/frontend/e2e/codeConverter.spec.js b/frontend/e2e/codeConverter.spec.js
index 49422b5..8e9b83b 100644
--- a/frontend/e2e/codeConverter.spec.js
+++ b/frontend/e2e/codeConverter.spec.js
@@ -103,7 +103,7 @@ test.describe('Code Converter', () => {
});
test('shows error for invalid JSON with YAML method', async ({ page }) => {
- await fillEditor(page, 'code-converter-input', 'not valid json');
+ await fillEditor(page, 'code-converter-input', '{unclosed');
// Error should appear in the output pane (border turns red)
await expectEditorText(page, 'code-converter-output', '');
});
diff --git a/frontend/e2e/codeFormatter.spec.js b/frontend/e2e/codeFormatter.spec.js
index 0d5bbfd..e851baa 100644
--- a/frontend/e2e/codeFormatter.spec.js
+++ b/frontend/e2e/codeFormatter.spec.js
@@ -103,7 +103,12 @@ test.describe('Code Formatter', () => {
await page.getByRole('button', { name: 'Load Sample' }).click();
await expectEditorNotEmpty(page, 'code-formatter-output');
- await expect(page.getByTestId('code-formatter-output').locator('.cm-gutters')).toBeVisible();
+ await expect(
+ page
+ .getByTestId('code-formatter-output')
+ .locator('.cm-gutters, .monaco-editor .margin-view-overlays')
+ .first()
+ ).toBeVisible();
});
test('filter input filters JSON output', async ({ page }) => {
@@ -135,7 +140,8 @@ test.describe('Code Formatter', () => {
const copyButton = page.locator('button[title="Copy to clipboard"]').first();
await copyButton.click();
- const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
- expect(clipboardText).toBe('copy this input');
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()))
+ .toBe('copy this input');
});
});
diff --git a/frontend/e2e/helpers/editor.js b/frontend/e2e/helpers/editor.js
index b8fd01a..fd56d67 100644
--- a/frontend/e2e/helpers/editor.js
+++ b/frontend/e2e/helpers/editor.js
@@ -17,8 +17,40 @@ export async function fillEditor(page, testId, value) {
return;
}
+ // Wait for the editor surface to mount (Monaco initializes async).
+ await expect
+ .poll(() => content.locator('.view-lines, .cm-editor').count(), { timeout: 15000 })
+ .toBeGreaterThan(0);
+
await content.click();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
+
+ const monacoLines = content.locator('.view-line');
+ if ((await monacoLines.count()) > 0) {
+ // Monaco (EditContext-based): use model.setValue() directly instead of
+ // insertText to avoid auto-indent reformatting multiline content. Map the
+ // pane's content element to its Monaco model via the exposed helper.
+ if (!value) {
+ await page.evaluate((id) => window.__monacoSetValue?.(id, ''), `${testId}-content`);
+ await page.waitForTimeout(300);
+ return;
+ }
+ const contentId = `${testId}-content`;
+ await expect
+ .poll(
+ async () =>
+ await page.evaluate(
+ ([id, v]) => window.__monacoSetValue?.(id, v) ?? false,
+ [contentId, value]
+ ),
+ { timeout: 5000 }
+ )
+ .toBe(true);
+ // Let React state sync after the model change
+ await page.waitForTimeout(300);
+ return;
+ }
+
await page.keyboard.press('Backspace');
if (value) {
await page.keyboard.type(value);
@@ -34,6 +66,17 @@ export async function readEditorText(page, testId) {
return content.inputValue();
}
+ const monacoLines = content.locator('.view-line');
+ if ((await monacoLines.count()) > 0) {
+ // Read straight from the Monaco model (mapped by content element) so tabs
+ // and exact whitespace survive; Monaco renders tabs as spaces in the DOM.
+ const modelValue = await page.evaluate(
+ (id) => window.__monacoGetValue?.(id) ?? null,
+ `${testId}-content`
+ );
+ if (modelValue != null) return modelValue;
+ }
+
return content
.locator('.cm-line')
.evaluateAll((lines) => lines.map((line) => line.textContent ?? '').join('\n'));
diff --git a/frontend/package.json b/frontend/package.json
index 0b2fdff..aedd9eb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -57,6 +57,7 @@
"jwt-decode": "^4.0.0",
"lucide-react": "^1.17.0",
"marked": "^18.0.5",
+ "monaco-editor": "^0.56.0",
"papaparse": "^5.5.3",
"php-serialize": "^5.1.3",
"qrcode": "^1.5.4",
diff --git a/frontend/src/components/inputs/MonacoCodeEditor.jsx b/frontend/src/components/inputs/MonacoCodeEditor.jsx
new file mode 100644
index 0000000..2d89b63
--- /dev/null
+++ b/frontend/src/components/inputs/MonacoCodeEditor.jsx
@@ -0,0 +1,213 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { getMonaco } from './monaco/monacoSetup';
+import { useMonacoDevtoolboxTheme } from './monaco/useMonacoTheme';
+import { MONACO_LANGUAGE_IDS } from './monaco/languages';
+
+const BASE_OPTIONS = {
+ minimap: { enabled: false },
+ fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
+ fontSize: 14,
+ lineHeight: 21,
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+ glyphMargin: false,
+ folding: false,
+ tabSize: 2,
+ smoothScrolling: true,
+ padding: { top: 8, bottom: 8 },
+ overviewRulerLanes: 0,
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
+};
+
+export default function MonacoCodeEditor({
+ value = '',
+ onChange,
+ language,
+ readOnly = false,
+ placeholder,
+ label,
+ dataTestId,
+ ariaLabel,
+ showLineNumbers = false,
+ error = false,
+ wordWrap = true,
+ className = '',
+ style = {},
+}) {
+ const containerRef = useRef(null);
+ const editorRef = useRef(null);
+ const onChangeRef = useRef(onChange);
+ const valueRef = useRef(value);
+
+ useMonacoDevtoolboxTheme();
+ const [ready, setReady] = useState(false);
+ const [loadError, setLoadError] = useState(false);
+
+ // Keep latest callbacks/props for imperative handlers (intentionally no deps)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ valueRef.current = value;
+ });
+
+ // Create the editor once
+ useEffect(() => {
+ let cancelled = false;
+ getMonaco()
+ .then((monaco) => {
+ if (cancelled || !containerRef.current || editorRef.current) return;
+ const editor = monaco.editor.create(containerRef.current, {
+ ...BASE_OPTIONS,
+ value: valueRef.current ?? '',
+ language: MONACO_LANGUAGE_IDS[language?.toLowerCase()] || 'plaintext',
+ theme: 'devtoolbox',
+ readOnly,
+ domReadOnly: readOnly,
+ lineNumbers: showLineNumbers ? 'on' : 'off',
+ contextmenu: !readOnly,
+ wordWrap: wordWrap ? 'on' : 'off',
+ ariaLabel: ariaLabel || label || placeholder || 'Code editor',
+ ...(placeholder ? { placeholder } : {}),
+ });
+ editor.onDidChangeModelContent(() => {
+ const next = editor.getValue();
+ if (next !== valueRef.current) onChangeRef.current?.(next);
+ });
+ editorRef.current = editor;
+ setReady(true);
+ })
+ .catch(() => {
+ if (!cancelled) setLoadError(true);
+ });
+ return () => {
+ cancelled = true;
+ const editor = editorRef.current;
+ if (editor) {
+ const model = editor.getModel();
+ editor.dispose();
+ if (model && !model.isDisposed()) model.dispose();
+ editorRef.current = null;
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Sync external value -> model (skip programmatic echo).
+ // NOTE: setValue resets the undo stack and cursor; acceptable for the pilot
+ // where external value changes come from tool actions (sample/clear), not
+ // concurrent editing.
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor || value === undefined) return;
+ if (editor.getValue() !== value) {
+ editor.getModel().setValue(value);
+ }
+ }, [value, ready]);
+
+ // Sync options
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ editor.updateOptions({
+ readOnly,
+ domReadOnly: readOnly,
+ lineNumbers: showLineNumbers ? 'on' : 'off',
+ wordWrap: wordWrap ? 'on' : 'off',
+ contextmenu: !readOnly,
+ ...(placeholder ? { placeholder } : {}),
+ ...(ariaLabel || label ? { ariaLabel: ariaLabel || label } : {}),
+ });
+ }, [readOnly, showLineNumbers, placeholder, ariaLabel, label, ready]);
+
+ // Sync language
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const id = MONACO_LANGUAGE_IDS[language?.toLowerCase()] || 'plaintext';
+ if (editor.getModel()?.getLanguageId() !== id) {
+ getMonaco().then((monaco) => monaco.editor.setModelLanguage(editor.getModel(), id));
+ }
+ }, [language, ready]);
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {!ready && !loadError && (
+
+ {placeholder || 'Loading editor...'}
+
+ )}
+ {loadError && (
+
+ );
+}
diff --git a/frontend/src/components/inputs/MonacoHighlightedCode.jsx b/frontend/src/components/inputs/MonacoHighlightedCode.jsx
new file mode 100644
index 0000000..261f732
--- /dev/null
+++ b/frontend/src/components/inputs/MonacoHighlightedCode.jsx
@@ -0,0 +1,180 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { getMonaco } from './monaco/monacoSetup';
+import { useMonacoDevtoolboxTheme } from './monaco/useMonacoTheme';
+import { MONACO_LANGUAGE_IDS } from './monaco/languages';
+import ToolCopyButton from './ToolCopyButton';
+
+const READ_ONLY_OPTIONS = {
+ readOnly: true,
+ domReadOnly: true,
+ minimap: { enabled: false },
+ fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
+ fontSize: 14,
+ lineHeight: 21,
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+ renderLineHighlight: 'none',
+ glyphMargin: false,
+ folding: false,
+ contextmenu: false,
+ overviewRulerLanes: 0,
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
+ padding: { top: 8, bottom: 8 },
+};
+
+export default function MonacoHighlightedCode({
+ code = '',
+ language,
+ copyable = true,
+ showLineNumbers = false,
+ className = '',
+ label,
+ dataTestId,
+ ariaLabel,
+ error = false,
+ wordWrap = true,
+}) {
+ const containerRef = useRef(null);
+ const editorRef = useRef(null);
+ const codeRef = useRef(code);
+
+ useMonacoDevtoolboxTheme();
+ const [ready, setReady] = useState(false);
+ const [loadError, setLoadError] = useState(false);
+
+ // Keep latest props for imperative handlers (intentionally no deps)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ codeRef.current = code;
+ });
+
+ // Create the editor once
+ useEffect(() => {
+ let cancelled = false;
+ getMonaco()
+ .then((monaco) => {
+ if (cancelled || !containerRef.current || editorRef.current) return;
+ const editor = monaco.editor.create(containerRef.current, {
+ ...READ_ONLY_OPTIONS,
+ value: codeRef.current ?? '',
+ language: MONACO_LANGUAGE_IDS[language?.toLowerCase()] || 'plaintext',
+ theme: 'devtoolbox',
+ lineNumbers: showLineNumbers ? 'on' : 'off',
+ wordWrap: wordWrap ? 'on' : 'off',
+ ariaLabel: ariaLabel || label || 'Read-only code output',
+ });
+ editorRef.current = editor;
+ setReady(true);
+ })
+ .catch(() => {
+ if (!cancelled) setLoadError(true);
+ });
+ return () => {
+ cancelled = true;
+ const editor = editorRef.current;
+ if (editor) {
+ const model = editor.getModel();
+ editor.dispose();
+ if (model && !model.isDisposed()) model.dispose();
+ editorRef.current = null;
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Sync code -> model
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ if (editor.getValue() !== code) {
+ editor.getModel().setValue(code);
+ }
+ }, [code, ready]);
+
+ // Sync options
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ editor.updateOptions({
+ lineNumbers: showLineNumbers ? 'on' : 'off',
+ ...(ariaLabel || label ? { ariaLabel: ariaLabel || label } : {}),
+ });
+ }, [showLineNumbers, ariaLabel, label, ready]);
+
+ // Sync language
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const id = MONACO_LANGUAGE_IDS[language?.toLowerCase()] || 'plaintext';
+ if (editor.getModel()?.getLanguageId() !== id) {
+ getMonaco().then((monaco) => monaco.editor.setModelLanguage(editor.getModel(), id));
+ }
+ }, [language, ready]);
+
+ return (
+
+ {(label || copyable) && (
+
+ {label && (
+
+ {label}
+
+ )}
+ {copyable && }
+
+ )}
+
+ {loadError && (
+
+ {code}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/inputs/ToolEditorPane.jsx b/frontend/src/components/inputs/ToolEditorPane.jsx
index df32d45..074a07e 100644
--- a/frontend/src/components/inputs/ToolEditorPane.jsx
+++ b/frontend/src/components/inputs/ToolEditorPane.jsx
@@ -1,9 +1,12 @@
-import React from 'react';
+import React, { Suspense } from 'react';
import { ToolCopyButton } from './ToolCopyButton';
import CodeEditor from './CodeEditor';
import HighlightedCode from './HighlightedCode';
import { cn } from '../../utils/cn';
+const MonacoCodeEditor = React.lazy(() => import('./MonacoCodeEditor'));
+const MonacoHighlightedCode = React.lazy(() => import('./MonacoHighlightedCode'));
+
const INDICATOR_COLORS = {
green: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#22c55e' },
blue: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#3b82f6' },
@@ -24,6 +27,8 @@ export function ToolEditorPane({
dataTestId,
ariaLabel,
className,
+ impl = 'codemirror',
+ wordWrap = true,
}) {
return (
@@ -53,14 +58,40 @@ export function ToolEditorPane({
>
{readOnly ? (
highlightOn ? (
-
+ impl === 'monaco' ? (
+
+ }
+ >
+
+
+ ) : (
+
+ )
) : (
)
+ ) : impl === 'monaco' ? (
+
onChange?.(val)}
+ language={language}
+ highlight={highlightOn}
+ placeholder={placeholder}
+ dataTestId={dataTestId}
+ ariaLabel={ariaLabel || label}
+ />
+ }
+ >
+ onChange?.(val)}
+ language={language}
+ readOnly={false}
+ showLineNumbers={showLineNumbers}
+ wordWrap={wordWrap}
+ placeholder={placeholder}
+ dataTestId={dataTestId}
+ ariaLabel={ariaLabel || label}
+ />{' '}
+
) : (
{
+ globalThis.MonacoEnvironment = {
+ getWorker(_workerId, label) {
+ if (label === 'json') return new jsonWorker.default();
+ if (label === 'css' || label === 'scss' || label === 'less')
+ return new cssWorker.default();
+ if (label === 'html' || label === 'handlebars' || label === 'razor')
+ return new htmlWorker.default();
+ return new editorWorker.default();
+ },
+ };
+ // Expose a minimal read-only snapshot plus value get/set helpers mapped
+ // by the pane's data-testid, for tests/e2e, without leaking the full
+ // editor API to page scripts. Each .monaco-editor node carries data-uri
+ // pointing to its model, so we map testId → element → URI → model.
+ if (typeof globalThis !== 'undefined') {
+ globalThis.__monacoModels = () => monaco.editor.getModels().map((m) => m.getValue());
+ globalThis.__monacoGetValue = (testId) => {
+ const el = document.querySelector(`[data-testid="${testId}"] .monaco-editor`);
+ const uri = el?.getAttribute('data-uri');
+ if (!uri) return null;
+ const model = monaco.editor.getModels().find((m) => m.uri.toString() === uri);
+ return model ? model.getValue() : null;
+ };
+ globalThis.__monacoSetValue = (testId, value) => {
+ const el = document.querySelector(`[data-testid="${testId}"] .monaco-editor`);
+ const uri = el?.getAttribute('data-uri');
+ const model = uri
+ ? monaco.editor.getModels().find((m) => m.uri.toString() === uri)
+ : null;
+ const target = model || monaco.editor.getModels()[0];
+ if (!target || target.isDisposed()) return false;
+ target.setValue(value);
+ return target.getValue() === value;
+ };
+ }
+ return monaco;
+ }
+ );
+ }
+ return monacoPromise;
+}
+
+const MONACO_TOKEN_BY_SCOPE = {
+ keyword: 'keyword',
+ string: 'string',
+ number: 'number',
+ comment: 'comment',
+ type: 'type',
+ function: 'identifier',
+ variable: 'identifier',
+ operator: 'operator',
+ punctuation: 'delimiter',
+ tag: 'tag',
+ attribute: 'attribute.name',
+ property: 'attribute.name',
+ constant: 'constant',
+ bool: 'constant.language',
+ null: 'constant.language',
+ class: 'type.identifier',
+};
+
+let colorCtx = null;
+function getColorCtx() {
+ if (colorCtx !== null) return colorCtx;
+ try {
+ colorCtx = document.createElement('canvas').getContext('2d');
+ } catch {
+ colorCtx = false;
+ }
+ return colorCtx;
+}
+
+export function normalizeColor(value) {
+ if (!value || typeof value !== 'string') return null;
+ let c = value.trim();
+ const varMatch = c.match(/^var\(\s*(--[^)]+?)\s*\)$/);
+ if (varMatch) {
+ try {
+ c = getComputedStyle(document.documentElement).getPropertyValue(varMatch[1]).trim();
+ } catch {
+ return null;
+ }
+ if (!c || c.startsWith('var(')) return null;
+ }
+ if (/^#[0-9a-f]{3,8}$/i.test(c)) {
+ const hex = c.slice(1);
+ // Valid CSS hex lengths only: #RGB(3) #RGBA(4) #RRGGBB(6) #RRGGBBAA(8)
+ if (hex.length === 3 || hex.length === 4) {
+ return '#' + [...hex].map((ch) => ch + ch).join('');
+ }
+ if (hex.length === 6 || hex.length === 8) {
+ return '#' + hex;
+ }
+ return null;
+ }
+ const ctx = getColorCtx();
+ if (!ctx) return null;
+ try {
+ ctx.fillStyle = '#000000';
+ ctx.fillStyle = c;
+ } catch {
+ return null;
+ }
+ const computed = ctx.fillStyle;
+ if (computed.startsWith('#')) return normalizeColor(computed);
+ const m = computed.match(/^rgba?\(([^)]+)\)$/i);
+ if (!m) return null;
+ const parts = m[1]
+ .split(/[,\s/]+/)
+ .filter(Boolean)
+ .map(Number);
+ if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return null;
+ const [r, g, b] = parts;
+ const hex =
+ '#' +
+ [r, g, b].map((n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('');
+ if (parts.length > 3 && parts[3] < 1) {
+ return (
+ hex +
+ Math.round(parts[3] * 255)
+ .toString(16)
+ .padStart(2, '0')
+ );
+ }
+ return hex;
+}
+
+export function applyDevtoolboxTheme(monaco, { actualType, colors, tokenColors }) {
+ const rules = (tokenColors || [])
+ .map(({ scope, color }) => {
+ const fg = normalizeColor(color);
+ return fg ? { token: MONACO_TOKEN_BY_SCOPE[scope] || scope, foreground: fg.slice(1) } : null;
+ })
+ .filter(Boolean);
+
+ const pick = (...candidates) => {
+ for (const c of candidates) {
+ const normalized = normalizeColor(c);
+ if (normalized) return normalized;
+ }
+ return actualType === 'dark' ? '#1f2428' : '#ffffff';
+ };
+
+ try {
+ monaco.editor.defineTheme('devtoolbox', {
+ base: actualType === 'dark' ? 'vs-dark' : 'vs',
+ inherit: true,
+ rules,
+ colors: {
+ 'editor.background': pick(colors?.background),
+ 'editor.foreground': pick(colors?.foreground),
+ 'editorLineNumber.foreground': pick(colors?.['muted-foreground']),
+ 'editorLineNumber.activeForeground': pick(colors?.foreground),
+ 'editor.selectionBackground': pick(colors?.accent),
+ 'editor.inactiveSelectionBackground': pick(colors?.accent),
+ 'editor.lineHighlightBackground': pick(colors?.muted),
+ 'editorCursor.foreground': pick(colors?.foreground),
+ 'editorWidget.background': pick(colors?.popover),
+ 'editorGutter.background': pick(colors?.card),
+ },
+ });
+ monaco.editor.setTheme('devtoolbox');
+ } catch {
+ // theme registration must never break the editor tree
+ }
+}
diff --git a/frontend/src/components/inputs/monaco/monacoSetup.test.js b/frontend/src/components/inputs/monaco/monacoSetup.test.js
new file mode 100644
index 0000000..7631369
--- /dev/null
+++ b/frontend/src/components/inputs/monaco/monacoSetup.test.js
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest';
+import { normalizeColor } from './monacoSetup';
+import { MONACO_LANGUAGE_IDS } from './languages';
+
+describe('normalizeColor', () => {
+ it.each([
+ ['#282c34', '#282c34'],
+ ['#fafafa', '#fafafa'],
+ ['#80ffaa40', '#80ffaa40'],
+ ['#0f8d', '#00ff88dd'],
+ ])('passes through standard hex: %s', (input, expected) => {
+ expect(normalizeColor(input)).toBe(expected);
+ });
+
+ it.each([
+ ['#fff', '#ffffff'],
+ ['#0f8', '#00ff88'],
+ ])('expands short hex: %s -> %s', (input, expected) => {
+ expect(normalizeColor(input)).toBe(expected);
+ });
+
+ it('resolves CSS variables to computed hex', () => {
+ const resolved = normalizeColor('var(--background)');
+ // jsdom returns empty for custom properties; either way it must not throw
+ // and must return null or a hex string.
+ expect(resolved === null || /^#[0-9a-f]{6,8}$/i.test(resolved)).toBe(true);
+ });
+
+ it.each([
+ [null],
+ [undefined],
+ [''],
+ ['notacolor'],
+ ['#12345'],
+ ['#1234567'],
+ ['var(--undefined-var-that-misses)'],
+ ['var(nested var(--x))'],
+ ])('rejects invalid input: %s', (input) => {
+ expect(normalizeColor(input)).toBeNull();
+ });
+});
+
+describe('MONACO_LANGUAGE_IDS', () => {
+ it.each(['json', 'xml', 'html', 'css'])('maps formatter language %s', (lang) => {
+ expect(MONACO_LANGUAGE_IDS[lang]).toBe(lang);
+ });
+
+ it('falls back to plaintext for unknown languages at the call site', () => {
+ expect(MONACO_LANGUAGE_IDS['definitely-not-a-lang']).toBeUndefined();
+ });
+});
diff --git a/frontend/src/components/inputs/monaco/useMonacoTheme.js b/frontend/src/components/inputs/monaco/useMonacoTheme.js
new file mode 100644
index 0000000..84bb114
--- /dev/null
+++ b/frontend/src/components/inputs/monaco/useMonacoTheme.js
@@ -0,0 +1,32 @@
+import { useEffect, useRef } from 'react';
+import { useTheme } from '../../../context/ThemeContext';
+import { applyDevtoolboxTheme, getMonaco } from './monacoSetup';
+
+export function useMonacoDevtoolboxTheme() {
+ const { palette, actualType } = useTheme();
+ const themeRef = useRef({ palette, actualType });
+ themeRef.current = { palette, actualType };
+
+ // Apply as soon as monaco is available (uses latest theme via ref)
+ useEffect(() => {
+ let cancelled = false;
+ getMonaco().then((monaco) => {
+ if (cancelled) return;
+ applyDevtoolboxTheme(monaco, themeRef.current);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Re-apply whenever the theme changes
+ useEffect(() => {
+ getMonaco().then((monaco) => {
+ applyDevtoolboxTheme(monaco, {
+ actualType,
+ colors: palette.colors,
+ tokenColors: palette.tokenColors,
+ });
+ });
+ }, [actualType, palette]);
+}
diff --git a/frontend/src/components/layout/ToolLayout.jsx b/frontend/src/components/layout/ToolLayout.jsx
index bd3c7a8..30a1038 100644
--- a/frontend/src/components/layout/ToolLayout.jsx
+++ b/frontend/src/components/layout/ToolLayout.jsx
@@ -73,6 +73,8 @@ export default function ToolLayout({
data-layout-direction={layout.direction}
style={{
position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
flex: 1,
minHeight: 0,
...style,
diff --git a/frontend/src/context/ThemeContext.jsx b/frontend/src/context/ThemeContext.jsx
index 0ae5a1d..8cf44b9 100644
--- a/frontend/src/context/ThemeContext.jsx
+++ b/frontend/src/context/ThemeContext.jsx
@@ -167,10 +167,11 @@ export function ThemeProvider({ children }) {
setThemeName,
theme,
actualType,
+ palette,
editorExtensions,
allThemes,
}),
- [themeMode, setThemeMode, themeName, setThemeName, theme, actualType, editorExtensions]
+ [themeMode, setThemeMode, themeName, setThemeName, theme, actualType, palette, editorExtensions]
);
return {children};
diff --git a/frontend/src/pages/CodeConverter/index.jsx b/frontend/src/pages/CodeConverter/index.jsx
index 5da6237..6d1fadd 100644
--- a/frontend/src/pages/CodeConverter/index.jsx
+++ b/frontend/src/pages/CodeConverter/index.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
+import { FileText } from 'lucide-react';
import { codeConverterAPI } from './api/codeConverterAPI';
import { ToolHeader } from '../../components/ToolUI';
import { ToolEditorPane, EditorToggle } from '../../components/inputs';
@@ -27,6 +28,9 @@ export default function CodeConverter() {
const [highlightOn, setHighlightOn] = useState(
() => localStorage.getItem(`${TOOL_KEY}-editor-highlight`) !== 'false'
);
+ const [wordWrap, setWordWrap] = useState(
+ () => localStorage.getItem(`${TOOL_KEY}-word-wrap`) !== 'false'
+ );
const [method, setMethod] = useState(METHODS[0]);
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
@@ -40,6 +44,13 @@ export default function CodeConverter() {
}
try {
const result = await codeConverterAPI.Convert(text, meth);
+ // The backend returns HTTP 200 with a structured {error} body for invalid
+ // input; treat it as an error rather than as output.
+ if (result && typeof result === 'object' && result.error) {
+ setError(result.error);
+ setOutput('');
+ return;
+ }
setOutput(result);
setError('');
} catch (err) {
@@ -92,6 +103,33 @@ export default function CodeConverter() {
+
@@ -106,6 +144,8 @@ export default function CodeConverter() {
highlightOn={highlightOn}
dataTestId="code-converter-input"
ariaLabel="Input"
+ impl="monaco"
+ wordWrap={wordWrap}
/>
diff --git a/frontend/src/pages/CodeFormatter/index.jsx b/frontend/src/pages/CodeFormatter/index.jsx
index 1f92396..fc0d789 100644
--- a/frontend/src/pages/CodeFormatter/index.jsx
+++ b/frontend/src/pages/CodeFormatter/index.jsx
@@ -137,6 +137,7 @@ function FilterBar({ value, onChange, placeholder, show, error }) {
display: 'flex',
flexDirection: 'column',
gap: '6px',
+ marginTop: '8px',
padding: '8px 12px',
backgroundColor: error ? 'rgba(239, 68, 68, 0.1)' : 'var(--muted)',
borderRadius: '6px',
@@ -189,6 +190,9 @@ export default function CodeFormatter() {
const [highlightOn, setHighlightOn] = useState(
() => localStorage.getItem(`${TOOL_KEY}-editor-highlight`) !== 'false'
);
+ const [wordWrap, setWordWrap] = useState(
+ () => localStorage.getItem(`${TOOL_KEY}-word-wrap`) !== 'false'
+ );
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [language, setLanguage] = useState(
@@ -300,6 +304,35 @@ export default function CodeFormatter() {
Minify
+
@@ -314,6 +347,8 @@ export default function CodeFormatter() {
language={language}
dataTestId="code-formatter-input"
ariaLabel="Input"
+ impl="monaco"
+ wordWrap={wordWrap}
/>