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 @@