Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions scripts/e2e-settings-memory-debug.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
}

Expand Down
41 changes: 41 additions & 0 deletions scripts/e2e-settings-visual-theme.test.mjs
Original file line number Diff line number Diff line change
@@ -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' });
});
161 changes: 161 additions & 0 deletions specs/issue-106-visual-theme-plan.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 13 additions & 2 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────────

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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() : '';
Expand Down
4 changes: 4 additions & 0 deletions src/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ <h2 id="settings-heading">Settings</h2>
</button>
</div>
<div class="settings-panel-content">
<fieldset class="settings-theme-fieldset">
<legend>Visual theme</legend>
<div id="setting-visual-theme-options" class="theme-card-grid"></div>
</fieldset>
<div class="settings-row">
<div class="settings-copy">
<div class="settings-label-line">
Expand Down
Loading
Loading