From 28e3c29a18453cf1519f1ced6cd5fae88678a5f4 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Thu, 8 Oct 2026 23:04:37 -0700 Subject: [PATCH] feat(ui): add persistent visual theme selection Add whole-extension light, dark, and high-contrast theme choices while preserving browser.cpp Dark as the default. The setting updates the shell, Monaco, and xterm immediately and persists through extension storage. Closes #106 --- package.json | 2 +- scripts/e2e-settings-memory-debug.test.mjs | 6 +- scripts/e2e-settings-visual-theme.test.mjs | 41 ++++++ specs/issue-106-visual-theme-plan.md | 161 +++++++++++++++++++++ src/ui/app.js | 15 +- src/ui/editor.js | 8 +- src/ui/index.html | 4 + src/ui/settings.mjs | 45 +++++- src/ui/styles.css | 15 ++ src/ui/terminal.js | 29 +--- src/ui/themes.mjs | 44 ++++++ 11 files changed, 338 insertions(+), 32 deletions(-) create mode 100644 scripts/e2e-settings-visual-theme.test.mjs create mode 100644 specs/issue-106-visual-theme-plan.md create mode 100644 src/ui/themes.mjs diff --git a/package.json b/package.json index f88e7bc..ecc267b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint .", "build": "npm run build:webpack && npm run build:targets", "build:firefox": "npm run build", - "test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-settings-memory-debug.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", + "test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-settings-memory-debug.test.mjs scripts/e2e-settings-visual-theme.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", "test:e2e:compiler": "npm run test:preflight-clang && node --experimental-detect-module --test scripts/e2e-compiler-link.test.mjs scripts/e2e-memory-debug-toolchain.test.mjs", "test:preflight-clang": "node scripts/preflight-clang-artifacts.js", "test:browser:chrome": "npm run test:e2e:compiler && node scripts/smoke-browser.mjs chrome", diff --git a/scripts/e2e-settings-memory-debug.test.mjs b/scripts/e2e-settings-memory-debug.test.mjs index 990f06f..21e4746 100644 --- a/scripts/e2e-settings-memory-debug.test.mjs +++ b/scripts/e2e-settings-memory-debug.test.mjs @@ -101,14 +101,14 @@ for (const [name, createStorage] of [ const storage = createStorage(); const settings = createExtensionSettings({ storage }); - assert.deepEqual(await settings.load(), { memoryDebugMode: false }); + assert.deepEqual(await settings.load(), { memoryDebugMode: false, visualTheme: 'browser-cpp-dark' }); await settings.setMemoryDebugMode(true); - assert.deepEqual(settings.get(), { memoryDebugMode: true }); + assert.deepEqual(settings.get(), { memoryDebugMode: true, visualTheme: 'browser-cpp-dark' }); assert.deepEqual((await new Promise((resolve) => { const result = storage.get(SETTINGS_STORAGE_KEY, resolve); if (result?.then) result.then(resolve); - }))[SETTINGS_STORAGE_KEY], { memoryDebugMode: true }); + }))[SETTINGS_STORAGE_KEY], { memoryDebugMode: true, visualTheme: 'browser-cpp-dark' }); }); } diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs new file mode 100644 index 0000000..ee827a9 --- /dev/null +++ b/scripts/e2e-settings-visual-theme.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_VISUAL_THEME_ID, + VISUAL_THEMES, + getVisualTheme, +} from '../src/ui/themes.mjs'; +import { createExtensionSettings } from '../src/ui/settings.mjs'; + +function createStorage(initial) { + let value = initial; + return { + async get(key) { return { [key]: value }; }, + async set(entries) { value = entries.browser_cpp_settings; }, + get value() { return value; }, + }; +} + +test('theme registry exposes the requested Monaco themes and safe fallback', () => { + assert.equal(DEFAULT_VISUAL_THEME_ID, 'browser-cpp-dark'); + assert.deepEqual(VISUAL_THEMES.map(({ id }) => id), [ + 'browser-cpp-dark', 'vs', 'vs-dark', 'hc-light', 'hc-black', + ]); + assert.equal(getVisualTheme('not-a-theme').id, DEFAULT_VISUAL_THEME_ID); + for (const theme of VISUAL_THEMES) { + assert.ok(theme.label && theme.monacoTheme && theme.terminalTheme && theme.preview); + } +}); + +test('visual theme defaults, sanitizes legacy storage, and persists immediately', async () => { + const storage = createStorage({ memoryDebugMode: true, visualTheme: 'unknown' }); + const settings = createExtensionSettings({ storage }); + assert.deepEqual(await settings.load(), { + memoryDebugMode: true, + visualTheme: DEFAULT_VISUAL_THEME_ID, + }); + await settings.setVisualTheme('vs'); + assert.deepEqual(settings.get(), { memoryDebugMode: true, visualTheme: 'vs' }); + assert.deepEqual(storage.value, { memoryDebugMode: true, visualTheme: 'vs' }); +}); diff --git a/specs/issue-106-visual-theme-plan.md b/specs/issue-106-visual-theme-plan.md new file mode 100644 index 0000000..fc51f1b --- /dev/null +++ b/specs/issue-106-visual-theme-plan.md @@ -0,0 +1,161 @@ +# Implementation Plan: Issue #106 — Visual themes + +## Scope + +Add a persistent `Visual theme` setting with `browser-cpp-dark` as the default, +and `vs`, `vs-dark`, `hc-light`, and `hc-black` as additional choices. It is the +first Settings option; five named native-radio cards switch shell CSS, Monaco, +and xterm immediately, stay in the open drawer, and survive reloads. Memory +Debug Mode and compile/run behavior remain unchanged. + +## Dependency order + +Registry → settings validation → cards/CSS → boot application → Monaco/xterm → +persisted browser flow. Each task is a gate for the task after it. + +## Risk-resolution tasks + +### 1. Define and validate one theme registry + +**Files:** `src/ui/themes.mjs`, `scripts/e2e-settings-visual-theme.test.mjs` + +Create exactly five app-theme entries, each with `id`, `label`, `monacoTheme`, +`cssTokens`, `terminalTheme`, and `preview`. Safe lookup must map missing, +malformed, and unknown values to `browser-cpp-dark`. Register only the custom +theme; use native Monaco IDs for the built-ins. + +**Gate:** tests assert the exact IDs, complete entry shapes, safe fallbacks, and +complete xterm palettes (background/foreground/cursor plus ANSI and bright ANSI +colors). This prevents registry drift, invalid settings, and Monaco redefinition. + +### 2. Extend settings storage without breaking old data + +**Files:** `src/ui/settings.mjs`, `scripts/e2e-settings-visual-theme.test.mjs` + +Add `visualTheme` and `setVisualTheme()` to the existing `browser_cpp_settings` +model, preserving both Promise and callback storage APIs. All writes include +both settings so updating Memory Debug Mode migrates old records. + +**Gate:** test default settings, legacy `{ memoryDebugMode: true }`, invalid IDs, +both storage styles, immediate subscriber notification, and persistence of both +fields. Existing Memory Debug Mode tests stay green. + +### 3. Derive cards and root palettes from that registry + +**Files:** `src/ui/themes.mjs`, `src/ui/settings.mjs`, `src/ui/styles.css`, +`scripts/e2e-settings-visual-theme.test.mjs` + +Generate cards and CSS mini-previews from registry data; do not duplicate IDs in +static markup. Scope theme variables using `data-visual-theme` on the root and +wire selection through the validated `setVisualTheme()` API from Task 2. + +**Gate:** DOM tests prove every registry ID renders exactly one labelled card, +each card updates the matching root attribute through settings, and no static ID +can drift from the registry. + +### 4. Keep visual preferences out of compiler messages + +**Files:** `src/ui/app.js`, `src/ui/toolbar.js`, +`scripts/e2e-session-persistence.test.mjs` + +Change the compile-options callback to expose only `{ memoryDebugMode }`. + +**Gate:** an `assembleCompilePayload()` test passes +`{ memoryDebugMode: true, visualTheme: 'vs' }` through compile options and +asserts the payload includes `memoryDebugMode: true` but omits `visualTheme`. +Existing Memory Debug Mode compilation coverage passes. + +### 5. Implement keyboard-accessible native-radio cards + +**Files:** `src/ui/index.html`, `src/ui/settings.mjs`, `src/ui/styles.css`, +`scripts/e2e-settings-visual-theme.test.mjs`, `scripts/smoke-browser.mjs` + +Put a Visual theme `fieldset`/`legend` before Memory Debug Mode. Each card is a +label containing a native radio; previews are decorative. Use native checked and +focus-visible styles rather than custom ARIA radio behavior. + +**Gate:** DOM/browser tests verify five labelled radios, Tab/arrow selection, +storage and root updates, an open drawer, no Apply/Save control, and visible +focus/selection in light and high-contrast themes. + +### 6. Tokenize every theme-sensitive shell surface + +**Files:** `src/ui/styles.css`, `src/ui/themes.mjs`, `scripts/smoke-browser.mjs` + +Audit and convert fixed colors in the backdrop/shadow, button states, terminal +stop control, reload UI, scrollbars, file-tree loading state, and dirty-tab +indicator. Document any remaining literal color as non-theme-sensitive. + +**Gate:** selecting `vs` changes computed toolbar, sidebar, Settings panel, +terminal-panel, and statusbar styles; high-contrast selection keeps a visible +radio focus outline. This eliminates dark-only remnants. + +### 7. Apply the stored theme before mounting Monaco and xterm + +**Files:** `src/ui/app.js`, `src/ui/themes.mjs`, `src/ui/editor.js`, +`src/ui/terminal.js`, `scripts/smoke-browser.mjs` + +Immediately after settings load, apply the resolved root attribute and pass the +same theme to both factories. Use this lookup for boot and live changes. + +**Gate:** preload storage with `vs`; smoke asserts the initial root state, +selected radio, and shell palette before Settings interaction, with both runtime +components mounted and no console error. This prevents Monaco and xterm from +mounting with the wrong stored theme; zero first-paint flash is not required. + +### 8. Switch Monaco live without losing state + +**Files:** `src/ui/editor.js`, `src/ui/themes.mjs`, `scripts/smoke-browser.mjs` + +Register `browser-cpp-dark` once and expose `setTheme(appThemeId)`, mapping via +the registry to `monaco.editor.setTheme`; never recreate the editor. + +**Gate:** smoke cycles through all five choices without errors and proves editor +content, cursor, and diagnostic markers survive. Registration is idempotent. + +### 9. Switch xterm live without losing state + +**Files:** `src/ui/terminal.js`, `src/ui/themes.mjs`, `src/ui/app.js`, +`scripts/smoke-browser.mjs` + +Accept an initial app theme in `createTerminal()` and expose `setTheme()` that +assigns `term.options.theme` on the existing instance; never dispose xterm for +a theme change. + +**Gate:** terminal content and its mounted container survive choosing `vs`; +prompt, busy state, stdin session, and workspace callbacks remain usable; +invalid IDs use the default palette. + +### 10. Verify ANSI output is readable + +**Files:** `src/ui/themes.mjs`, `src/ui/terminal.js`, `scripts/smoke-browser.mjs` + +Use sufficiently dark ANSI colors for light palettes and explicit high-contrast +foreground/background separation. Command output continues to use xterm's +palette rather than per-command theme branches. + +**Gate:** palette-shape tests reject incomplete colors and browser smoke shows +distinct readable terminal foreground/background after selecting `vs`. + +### 11. Prove the persisted end-to-end flow + +**Files:** `scripts/e2e-settings-visual-theme.test.mjs`, +`scripts/smoke-browser.mjs`, `package.json` + +Add the new focused E2E file to the standard test command. Smoke coverage must +exercise default state, keyboard light-theme selection, open drawer, storage, +reload, shell update, Monaco/xterm state survival, and no console errors. + +**Gate:** `npm run lint`, `npm run build`, `npm run test:e2e`, and—when its local +fixture is available—`npm run test:browser:chrome` all pass. + +## Completion criteria + +- [ ] Registry and Settings UI represent the same five IDs exactly once. +- [ ] Stored and live themes update shell, Monaco, and xterm without recreating + editor or terminal. +- [ ] Legacy settings, invalid IDs, compile-payload shape, and ANSI readability + have passing dedicated tests. +- [ ] Light/high-contrast browser smoke shows readable shell, editor, and + terminal surfaces with no console errors. +- [ ] The implementation PR references `closes #106` and receives human review. diff --git a/src/ui/app.js b/src/ui/app.js index f8ae3e6..726a7d8 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -35,6 +35,7 @@ import { registerPageUnload } from './page-lifecycle.mjs'; import { getExtensionVersionLabel } from '../extension-api.mjs'; import { selectRunBinaryBytes } from './build-request.mjs'; import { createExtensionSettings, initSettingsPanel } from './settings.mjs'; +import { getVisualTheme } from './themes.mjs'; // ── Boot ────────────────────────────────────────────────────────────────────── @@ -44,11 +45,19 @@ window.addEventListener('DOMContentLoaded', async () => { const extensionSettings = createExtensionSettings(); await extensionSettings.load(); + const applyVisualTheme = ({ visualTheme }) => { + const theme = getVisualTheme(visualTheme); + document.documentElement.dataset.visualTheme = theme.id; + editorAPI.setTheme(theme.monacoTheme); + terminalAPI.setTheme(theme.id); + }; + const initialTheme = getVisualTheme(extensionSettings.get().visualTheme); + document.documentElement.dataset.visualTheme = initialTheme.id; initSettingsPanel({ document, settings: extensionSettings }); // 1. Monaco editor const editorContainer = document.getElementById('editor-container'); - editorAPI.createEditor(editorContainer); + editorAPI.createEditor(editorContainer, { theme: initialTheme.monacoTheme }); // 2. Compiler web worker // The worker starts loading the Clang WASM binary immediately. @@ -59,6 +68,7 @@ window.addEventListener('DOMContentLoaded', async () => { // Pass callbacks so the terminal's `g++` command dispatches to the worker. const terminalContainer = document.getElementById('terminal-container'); terminalAPI.createTerminal(terminalContainer, { + visualTheme: initialTheme.id, onCompile: async (request) => { // Terminal builds: explicit `g++ a.cpp b.cpp` sources (or null for the // single editor buffer). Reuse the toolbar's overlay assembly so terminal @@ -142,8 +152,9 @@ window.addEventListener('DOMContentLoaded', async () => { persistState: () => persistenceGate.persistState(), persistWorkspace: () => persistenceGate.persistWorkspace(), scheduleState: () => persistenceGate.scheduleState(), - getCompileOptions: () => extensionSettings.get(), + getCompileOptions: () => ({ memoryDebugMode: extensionSettings.get().memoryDebugMode }), }); + extensionSettings.subscribe(applyVisualTheme); resetToNewProject(); // 5. Track unsaved changes diff --git a/src/ui/editor.js b/src/ui/editor.js index 9ca1c1a..01f3563 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -38,7 +38,7 @@ let _editor = null; * @param {HTMLElement} container * @returns {monaco.editor.IStandaloneCodeEditor} */ -export function createEditor(container) { +export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) { // Define a custom VS Code–like dark theme that matches our CSS palette monaco.editor.defineTheme('browser-cpp-dark', { base: 'vs-dark', @@ -71,7 +71,7 @@ export function createEditor(container) { _editor = monaco.editor.create(container, { value: '', language: 'cpp', - theme: 'browser-cpp-dark', + theme, fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace", fontLigatures: true, @@ -93,6 +93,10 @@ export function createEditor(container) { return _editor; } +export function setTheme(theme) { + monaco.editor.setTheme(theme); +} + /** Get the full text of the current document. */ export function getValue() { return _editor ? _editor.getValue() : ''; diff --git a/src/ui/index.html b/src/ui/index.html index f4b4974..5225565 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -63,6 +63,10 @@

Settings

+
+ Visual theme +
+
diff --git a/src/ui/settings.mjs b/src/ui/settings.mjs index e155ff5..6a3a3bc 100644 --- a/src/ui/settings.mjs +++ b/src/ui/settings.mjs @@ -1,9 +1,10 @@ 'use strict'; import { getExtensionAPI, getExtensionRuntimeError } from '../extension-api.mjs'; +import { DEFAULT_VISUAL_THEME_ID, VISUAL_THEMES, getVisualTheme } from './themes.mjs'; export const SETTINGS_STORAGE_KEY = 'browser_cpp_settings'; -export const DEFAULT_SETTINGS = Object.freeze({ memoryDebugMode: false }); +export const DEFAULT_SETTINGS = Object.freeze({ memoryDebugMode: false, visualTheme: DEFAULT_VISUAL_THEME_ID }); function getStorageArea() { return getExtensionAPI()?.storage?.local ?? null; @@ -12,6 +13,7 @@ function getStorageArea() { function sanitizeSettings(value) { return { memoryDebugMode: value?.memoryDebugMode === true, + visualTheme: getVisualTheme(value?.visualTheme).id, }; } @@ -83,6 +85,16 @@ export function createExtensionSettings({ } return { ...current }; }, + async setVisualTheme(themeId) { + current = { ...current, visualTheme: getVisualTheme(themeId).id }; + notify(); + try { + await storageSet(storage, { [SETTINGS_STORAGE_KEY]: { ...current } }); + } catch (error) { + onError(error); + } + return { ...current }; + }, subscribe(subscriber) { subscribers.add(subscriber); @@ -98,6 +110,7 @@ export function initSettingsPanel({ document, settings }) { const panel = document.getElementById('settings-panel'); const closeButton = document.getElementById('btn-close-settings'); const memoryDebugToggle = document.getElementById('setting-memory-debug'); + const themeCards = document.getElementById('setting-visual-theme-options'); if (!button || !backdrop || !panel || !closeButton || !memoryDebugToggle) { return { destroy() {} }; @@ -118,11 +131,39 @@ export function initSettingsPanel({ document, settings }) { if (event.key === 'Escape' && !panel.hidden) setOpen(false, true); }; const onToggleChange = () => settings.setMemoryDebugMode(memoryDebugToggle.checked); - const unsubscribe = settings.subscribe(({ memoryDebugMode }) => { + if (themeCards && document.createElement) { + for (const theme of VISUAL_THEMES) { + const label = document.createElement('label'); + label.className = 'theme-card'; + const input = document.createElement('input'); + input.type = 'radio'; + input.name = 'visual-theme'; + input.value = theme.id; + input.addEventListener('change', () => settings.setVisualTheme(input.value)); + const preview = document.createElement('span'); + preview.className = 'theme-preview'; + preview.setAttribute('aria-hidden', 'true'); + preview.style.setProperty('--preview-background', theme.preview.background); + preview.style.setProperty('--preview-foreground', theme.preview.foreground); + preview.style.setProperty('--preview-accent', theme.preview.accent); + const name = document.createElement('span'); + name.textContent = theme.label; + label.append(input, preview, name); + themeCards.append(label); + } + } + const unsubscribe = settings.subscribe(({ memoryDebugMode, visualTheme }) => { memoryDebugToggle.checked = memoryDebugMode; + if (document.documentElement) document.documentElement.dataset.visualTheme = visualTheme; + if (themeCards) { + for (const input of themeCards.querySelectorAll('input[name="visual-theme"]')) { + input.checked = input.value === visualTheme; + } + } }); memoryDebugToggle.checked = settings.get().memoryDebugMode; + if (document.documentElement) document.documentElement.dataset.visualTheme = settings.get().visualTheme; setOpen(false); button.addEventListener('click', onButtonClick); closeButton.addEventListener('click', onCloseClick); diff --git a/src/ui/styles.css b/src/ui/styles.css index abb7437..1cb177d 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -35,6 +35,11 @@ background: var(--base); } +:root[data-visual-theme="vs"] { --crust:#f6f8fa; --mantle:#fff; --base:#fff; --surface0:#d0d7de; --surface1:#afb8c1; --overlay0:#57606a; --text:#1f2328; --subtext0:#57606a; --green:#1a7f37; --red:#cf222e; --yellow:#9a6700; --blue:#0969da; --mauve:#8250df; --peach:#bc4c00; } +:root[data-visual-theme="vs-dark"] { --crust:#181818; --mantle:#202020; --base:#1e1e1e; --surface0:#2d2d30; --surface1:#3e3e42; --overlay0:#858585; --text:#d4d4d4; --subtext0:#a0a0a0; --green:#89d185; --red:#f48771; --yellow:#dcdcaa; --blue:#569cd6; --mauve:#c586c0; --peach:#ce9178; } +:root[data-visual-theme="hc-light"] { --crust:#fff; --mantle:#fff; --base:#fff; --surface0:#e5e5e5; --surface1:#6f6f6f; --overlay0:#333; --text:#000; --subtext0:#333; --green:#064; --red:#a80000; --yellow:#704d00; --blue:#0037da; --mauve:#5b00a5; --peach:#8a3100; } +:root[data-visual-theme="hc-black"] { --crust:#000; --mantle:#000; --base:#000; --surface0:#1f1f1f; --surface1:#fff; --overlay0:#d0d0d0; --text:#fff; --subtext0:#fff; --green:#0f0; --red:#ff6b6b; --yellow:#ff0; --blue:#3daee9; --mauve:#ff8cff; --peach:#ffb86c; } + body { height: 100vh; overflow: hidden; @@ -213,6 +218,16 @@ select:focus { padding: 16px; } +.settings-theme-fieldset { border: 0; margin: 0 0 18px; padding: 0; } +.settings-theme-fieldset legend { font-weight: 600; margin-bottom: 10px; } +.theme-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.theme-card { display: grid; grid-template-columns: 1fr; gap: 5px; padding: 6px; border: 1px solid var(--surface1); border-radius: 5px; color: var(--text); cursor: pointer; font-size: 11px; } +.theme-card:has(input:checked) { border: 2px solid var(--blue); } +.theme-card:has(input:focus-visible) { outline: 2px solid var(--blue); outline-offset: 2px; } +.theme-card input { position: absolute; opacity: 0; } +.theme-preview { height: 34px; border: 1px solid var(--surface1); border-radius: 3px; background: linear-gradient(var(--preview-background), var(--preview-background)); box-shadow: inset 5px 0 var(--preview-accent); } +.theme-preview::after { content: 'int main()'; display: block; padding: 9px; color: var(--preview-foreground); font: 9px monospace; } + .settings-row { display: flex; align-items: flex-start; diff --git a/src/ui/terminal.js b/src/ui/terminal.js index f6e8900..4674138 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -36,6 +36,7 @@ import { collectBrowserCapabilities, selectStdinTransport, } from './browser-capabilities.mjs'; +import { getVisualTheme } from './themes.mjs'; function moduleExports(pkg) { return Object.prototype.hasOwnProperty.call(pkg, 'default') ? pkg['default'] : pkg; @@ -287,6 +288,7 @@ export function createTerminal(container, { readWorkspaceFile, onMkdir, onTouch, + visualTheme = 'browser-cpp-dark', }) { _onCompile = onCompile; _onRun = onRun; @@ -309,28 +311,7 @@ export function createTerminal(container, { fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", fontSize: 13, lineHeight: 1.4, - theme: { - background: '#11111b', - foreground: '#cdd6f4', - cursor: '#f5c2e7', - cursorAccent: '#1e1e2e', - black: '#45475a', - red: '#f38ba8', - green: '#a6e3a1', - yellow: '#f9e2af', - blue: '#89b4fa', - magenta: '#cba6f7', - cyan: '#89dceb', - white: '#bac2de', - brightBlack: '#585b70', - brightRed: '#f38ba8', - brightGreen: '#a6e3a1', - brightYellow: '#f9e2af', - brightBlue: '#89b4fa', - brightMagenta:'#cba6f7', - brightCyan: '#89dceb', - brightWhite: '#a6adc8', - }, + theme: getVisualTheme(visualTheme).terminalTheme, cursorBlink: true, scrollback: 5000, convertEol: false, @@ -369,6 +350,10 @@ export function createTerminal(container, { return term; } +export function setTheme(themeId) { + if (term) term.options.theme = { ...getVisualTheme(themeId).terminalTheme }; +} + /** Resize the terminal to fill its container (call after layout changes). */ export function fitTerminal() { fitAddon?.fit(); diff --git a/src/ui/themes.mjs b/src/ui/themes.mjs new file mode 100644 index 0000000..368d52f --- /dev/null +++ b/src/ui/themes.mjs @@ -0,0 +1,44 @@ +'use strict'; + +export const DEFAULT_VISUAL_THEME_ID = 'browser-cpp-dark'; + +const terminal = (background, foreground, accent) => ({ + background, foreground, cursor: accent, cursorAccent: background, + black: foreground, red: '#c01c28', green: '#2d7d46', yellow: '#9a6700', + blue: '#0969da', magenta: '#8250df', cyan: '#0a7b83', white: foreground, + brightBlack: '#768390', brightRed: '#d1242f', brightGreen: '#1a7f37', + brightYellow: '#bf8700', brightBlue: '#218bff', brightMagenta: '#a475f9', + brightCyan: '#1b9aaa', brightWhite: foreground, +}); + +export const VISUAL_THEMES = Object.freeze([ + { + id: 'browser-cpp-dark', label: 'browser.cpp Dark', monacoTheme: 'browser-cpp-dark', + cssTokens: { crust: '#11111b', mantle: '#181825', base: '#1e1e2e', surface0: '#313244', surface1: '#45475a', overlay0: '#6c7086', text: '#cdd6f4', subtext0: '#a6adc8', green: '#a6e3a1', red: '#f38ba8', yellow: '#f9e2af', blue: '#89b4fa', mauve: '#cba6f7', peach: '#fab387' }, + terminalTheme: terminal('#11111b', '#cdd6f4', '#f5c2e7'), preview: { background: '#1e1e2e', foreground: '#cdd6f4', accent: '#89b4fa' }, + }, + { + id: 'vs', label: 'Light', monacoTheme: 'vs', + cssTokens: { crust: '#f6f8fa', mantle: '#ffffff', base: '#ffffff', surface0: '#d0d7de', surface1: '#afb8c1', overlay0: '#57606a', text: '#1f2328', subtext0: '#57606a', green: '#1a7f37', red: '#cf222e', yellow: '#9a6700', blue: '#0969da', mauve: '#8250df', peach: '#bc4c00' }, + terminalTheme: terminal('#ffffff', '#1f2328', '#0969da'), preview: { background: '#ffffff', foreground: '#1f2328', accent: '#0969da' }, + }, + { + id: 'vs-dark', label: 'Dark', monacoTheme: 'vs-dark', + cssTokens: { crust: '#181818', mantle: '#202020', base: '#1e1e1e', surface0: '#2d2d30', surface1: '#3e3e42', overlay0: '#858585', text: '#d4d4d4', subtext0: '#a0a0a0', green: '#89d185', red: '#f48771', yellow: '#dcdcaa', blue: '#569cd6', mauve: '#c586c0', peach: '#ce9178' }, + terminalTheme: terminal('#181818', '#d4d4d4', '#aeafad'), preview: { background: '#1e1e1e', foreground: '#d4d4d4', accent: '#569cd6' }, + }, + { + id: 'hc-light', label: 'High Contrast Light', monacoTheme: 'hc-light', + cssTokens: { crust: '#ffffff', mantle: '#ffffff', base: '#ffffff', surface0: '#e5e5e5', surface1: '#6f6f6f', overlay0: '#333333', text: '#000000', subtext0: '#333333', green: '#006400', red: '#a80000', yellow: '#704d00', blue: '#0037da', mauve: '#5b00a5', peach: '#8a3100' }, + terminalTheme: terminal('#ffffff', '#000000', '#0037da'), preview: { background: '#ffffff', foreground: '#000000', accent: '#0037da' }, + }, + { + id: 'hc-black', label: 'High Contrast Dark', monacoTheme: 'hc-black', + cssTokens: { crust: '#000000', mantle: '#000000', base: '#000000', surface0: '#1f1f1f', surface1: '#ffffff', overlay0: '#d0d0d0', text: '#ffffff', subtext0: '#ffffff', green: '#00ff00', red: '#ff6b6b', yellow: '#ffff00', blue: '#3daee9', mauve: '#ff8cff', peach: '#ffb86c' }, + terminalTheme: terminal('#000000', '#ffffff', '#ffffff'), preview: { background: '#000000', foreground: '#ffffff', accent: '#ffff00' }, + }, +]); + +export function getVisualTheme(id) { + return VISUAL_THEMES.find((theme) => theme.id === id) ?? VISUAL_THEMES[0]; +}