Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"electron": "electron .",
"electron-dev": "SWITCHBOARD_DATA_DIR=\"$HOME/.switchboard-dev\" electron .",
"bundle:codemirror": "esbuild public/codemirror-setup.js --bundle --outfile=public/codemirror-bundle.js --format=iife --platform=browser --minify",
"generate:light-theme": "node scripts/generate-light-theme.js",
"generate-icons": "node scripts/generate-icons.js && node scripts/generate-dmg-background.js",
"build": "npm run bundle && electron-builder",
"build:mac": "npm run bundle && electron-builder --mac && node scripts/postinstall.js",
Expand Down
36 changes: 36 additions & 0 deletions public/app-theme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// --- App color theme (dark / light / system) ---
// Loaded in <head> before the stylesheets so the right theme is in place on
// first paint. The preference lives in the global settings (appTheme); a
// copy in localStorage lets this run synchronously at startup.

(() => {
const STORAGE_KEY = 'appTheme';
const systemLight = window.matchMedia('(prefers-color-scheme: light)');
let preference = 'dark';
try { preference = localStorage.getItem(STORAGE_KEY) || 'dark'; } catch {}

function resolve(pref) {
if (pref === 'system') return systemLight.matches ? 'light' : 'dark';
return pref === 'light' ? 'light' : 'dark';
}

function apply() {
const theme = resolve(preference);
const root = document.documentElement;
if (root.dataset.theme === theme) return;
root.dataset.theme = theme;
root.style.colorScheme = theme;
document.dispatchEvent(new CustomEvent('app-theme-change', { detail: { theme } }));
}

window.getAppTheme = () => resolve(preference);
window.getAppThemePreference = () => preference;
window.setAppThemePreference = (pref) => {
preference = pref || 'dark';
try { localStorage.setItem(STORAGE_KEY, preference); } catch {}
apply();
};

systemLight.addEventListener('change', () => { if (preference === 'system') apply(); });
apply();
})();
8 changes: 8 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ window._applyTerminalTheme = (themeName) => {
entry.element.style.backgroundColor = TERMINAL_THEME.background;
}
};
document.addEventListener('app-theme-change', () => {
window._applyTerminalTheme(currentThemeName);
refreshSidebar(); // project and track colors are computed per theme
});
let searchMatchIds = null; // null = no search active; Set<string> = matched session IDs
let searchMatchProjectPaths = null; // Set<string> of project paths matched by name

Expand Down Expand Up @@ -1407,6 +1411,10 @@ setTimeout(() => {
currentThemeName = global.terminalTheme;
TERMINAL_THEME = getTerminalTheme();
}
// The settings store is the source of truth; app-theme.js only caches it.
if (global.appTheme && global.appTheme !== window.getAppThemePreference()) {
window.setAppThemePreference(global.appTheme);
}
}
})();

Expand Down
72 changes: 54 additions & 18 deletions public/codemirror-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { languages } from '@codemirror/language-data';
import { syntaxHighlighting, HighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { highlightSelectionMatches } from '@codemirror/search';
import { dracula } from '@ddietr/codemirror-themes/theme/dracula';
import { githubLight } from '@ddietr/codemirror-themes/theme/github-light';
import { tags } from '@lezer/highlight';
import { MergeView, unifiedMergeView } from '@codemirror/merge';
import { javascript } from '@codemirror/lang-javascript';
Expand Down Expand Up @@ -35,6 +36,46 @@ const appThemePatch = EditorView.theme({
},
}, { dark: true });

const markdownExtrasLight = HighlightStyle.define([
{ tag: tags.monospace, color: '#0a6e8a' },
]);

const appThemePatchLight = EditorView.theme({
'&': { height: '100%', fontSize: '12.5px' },
'.cm-content': { padding: '20px 8px' },
'.cm-scroller': {
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(0,0,0,0.15) transparent',
},
}, { dark: false });

// ── Light/dark editor theme ──────────────────────────────────────────
// Every editor puts its theme in one shared compartment and registers
// itself while mounted, so an app theme change can restyle open editors.

const themeCompartment = new Compartment();
const liveViews = new Set();
const trackView = ViewPlugin.define(view => {
liveViews.add(view);
return { destroy() { liveViews.delete(view); } };
});

function themeExtensions() {
const light = typeof window.getAppTheme === 'function' && window.getAppTheme() === 'light';
return light
? [githubLight, syntaxHighlighting(markdownExtrasLight), appThemePatchLight]
: [dracula, syntaxHighlighting(markdownExtras), appThemePatch];
}

function editorTheme() {
return [trackView, themeCompartment.of(themeExtensions())];
}

document.addEventListener('app-theme-change', () => {
const effects = themeCompartment.reconfigure(themeExtensions());
for (const view of liveViews) view.dispatch({ effects });
});

// ── Custom floating search bar (matches xterm search bar style) ──────

const setSearchQuery = StateEffect.define();
Expand Down Expand Up @@ -68,9 +109,14 @@ const searchHighlighter = ViewPlugin.fromClass(class {
}
}, { decorations: v => v.decorations });

const cmSearchTheme = EditorView.theme({
'.cm-find-match': { backgroundColor: '#515C6A', borderRadius: '2px' },
'.cm-find-match-active': { backgroundColor: '#EAA549', borderRadius: '2px' },
// A base theme, so &dark/&light can follow the active editor theme.
const cmSearchTheme = EditorView.baseTheme({
'.cm-find-match': { borderRadius: '2px' },
'.cm-find-match-active': { borderRadius: '2px' },
'&dark .cm-find-match': { backgroundColor: '#515C6A' },
'&dark .cm-find-match-active': { backgroundColor: '#EAA549' },
'&light .cm-find-match': { backgroundColor: '#fff0a8' },
'&light .cm-find-match-active': { backgroundColor: '#f5b13d' },
});

function cmFloatingSearch() {
Expand Down Expand Up @@ -335,9 +381,7 @@ function createPlanEditor(parent) {
cmSaveKeymap,
cmFloatingSearch(),
markdown({ base: markdownLanguage, codeLanguages: languages }),
dracula,
syntaxHighlighting(markdownExtras),
appThemePatch,
editorTheme(),
wrapCompartment.of(EditorView.lineWrapping),
],
});
Expand Down Expand Up @@ -406,9 +450,7 @@ function createReadOnlyViewer(parent, content, filename) {
cmSaveDomHandler,
cmFloatingSearch(),
langExt,
dracula,
syntaxHighlighting(markdownExtras),
appThemePatch,
editorTheme(),
],
});
return new EditorView({ state, parent });
Expand Down Expand Up @@ -444,9 +486,7 @@ function createEditableViewer(parent, content, filename, { wrap = false } = {})
cmSaveKeymap,
cmFloatingSearch(),
langExt,
dracula,
syntaxHighlighting(markdownExtras),
appThemePatch,
editorTheme(),
wrapCompartment.of(wrap ? EditorView.lineWrapping : []),
],
});
Expand All @@ -471,9 +511,7 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) {
cmFindDomHandler,
cmFloatingSearch(),
langExt,
dracula,
syntaxHighlighting(markdownExtras),
appThemePatch,
editorTheme(),
];

return new MergeView({
Expand Down Expand Up @@ -513,9 +551,7 @@ function createUnifiedMergeViewer(parent, originalContent, modifiedContent, file
cmSaveDomHandler,
cmFloatingSearch(),
langExt,
dracula,
syntaxHighlighting(markdownExtras),
appThemePatch,
editorTheme(),
unifiedMergeView({
original: originalContent,
gutter: true,
Expand Down
3 changes: 3 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
<link rel="stylesheet" href="../node_modules/@xterm/xterm/css/xterm.css">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="whats-new.css">
<link rel="stylesheet" href="theme-light.generated.css">
<link rel="stylesheet" href="theme-light.css">
<script src="app-theme.js"></script>
</head>
<body>
<div id="app-container">
Expand Down
12 changes: 9 additions & 3 deletions public/projects-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,21 +500,27 @@ function projectInitials(name) {
return (words[0][0] + words[1][0]).toUpperCase();
}

// Pastel text on a dark tint reads on the dark theme; the light theme needs
// the same hue darker.
const hueFg = (h, s) => window.getAppTheme() === 'light' ? `hsl(${h},${s - 15}%,36%)` : `hsl(${h},${s}%,72%)`;

function markHtml(project, size = 28, extraClass = '') {
const h = projectHue(project.id);
const fontSize = Math.round(size * 0.4);
return `<span class="proj-mark ${extraClass}" style="width:${size}px;height:${size}px;font-size:${fontSize}px;background:hsla(${h},70%,60%,0.18);color:hsl(${h},80%,72%);">${escapeHtml(projectInitials(project.name))}</span>`;
return `<span class="proj-mark ${extraClass}" style="width:${size}px;height:${size}px;font-size:${fontSize}px;background:hsla(${h},70%,60%,0.18);color:${hueFg(h, 80)};">${escapeHtml(projectInitials(project.name))}</span>`;
}

/**
* One color per track, spread around the wheel from the project's own hue so
* two tracks in a project never share one. General is grey.
*/
function trackColors(project, track) {
if (!track) return { fg: '#9090a8', bg: 'rgba(255,255,255,0.08)' };
if (!track) return window.getAppTheme() === 'light'
? { fg: '#5d5d73', bg: 'rgba(20,20,40,0.07)' }
: { fg: '#9090a8', bg: 'rgba(255,255,255,0.08)' };
const index = Math.max(0, (project.tracks || []).findIndex(t => t.id === track.id));
const h = Math.round((projectHue(project.id) + 40 + index * 137.5) % 360);
return { fg: `hsl(${h},78%,72%)`, bg: `hsla(${h},70%,60%,0.16)` };
return { fg: hueFg(h, 78), bg: `hsla(${h},70%,60%,0.16)` };
}

function trackTagHtml(project, track) {
Expand Down
19 changes: 19 additions & 0 deletions public/settings-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
const visCountValue = fieldValue('visibleSessionCount', 10);
const maxAgeValue = fieldValue('sessionMaxAgeDays', 3);
const themeValue = fieldValue('terminalTheme', 'switchboard');
const appThemeValue = fieldValue('appTheme', 'dark');
const mcpEmulationValue = fieldValue('mcpEmulation', true);
// Global-only: where new projects get their folder (projects.js). Empty
// means the default, ~/Switchboard.
Expand Down Expand Up @@ -254,6 +255,20 @@
${!isProject ? `<div class="settings-section">
<div class="settings-section-title">Application</div>

<div class="settings-field">
<div class="settings-field-info">
<span class="settings-label">Appearance</span>
<div class="settings-description">Light or dark interface. System follows your OS setting.</div>
</div>
<div class="settings-field-control">
<select class="settings-select" id="sv-app-theme">
${[['dark', 'Dark'], ['light', 'Light'], ['system', 'System']].map(([key, label]) =>
`<option value="${key}" ${appThemeValue === key ? 'selected' : ''}>${label}</option>`
).join('')}
</select>
</div>
</div>

<div class="settings-field">
<div class="settings-field-info">
<span class="settings-label">Terminal Theme</span>
Expand Down Expand Up @@ -453,6 +468,7 @@
settings.visibleSessionCount = parseInt(settingsViewerBody.querySelector('#sv-visible-count').value) || 10;
settings.sessionMaxAgeDays = parseInt(settingsViewerBody.querySelector('#sv-max-age').value) || 3;
settings.terminalTheme = settingsViewerBody.querySelector('#sv-terminal-theme').value || 'switchboard';
settings.appTheme = settingsViewerBody.querySelector('#sv-app-theme').value || 'dark';
settings.mcpEmulation = settingsViewerBody.querySelector('#sv-mcp-emulation').checked;
settings.shellProfile = settingsViewerBody.querySelector('#sv-shell-profile').value || 'auto';
settings.projectsRoot = settingsViewerBody.querySelector('#sv-projects-root').value.trim();
Expand All @@ -477,6 +493,9 @@
if (settings.terminalTheme && typeof window._applyTerminalTheme === 'function') {
window._applyTerminalTheme(settings.terminalTheme);
}
if (typeof window.setAppThemePreference === 'function') {
window.setAppThemePreference(settings.appTheme);
}
if (typeof refreshSidebar === 'function') refreshSidebar();
}

Expand Down
28 changes: 26 additions & 2 deletions public/terminal-themes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// --- Terminal themes ---
const TERMINAL_THEMES = {
switchboard: {
label: 'Switchboard',
label: 'Switchboard (follows appearance)',
background: '#1a1a2e', foreground: '#e0e0e0', cursor: '#e94560', selectionBackground: '#3a3a5e',
black: '#1a1a2e', red: '#e94560', green: '#0dff00', yellow: '#f5a623', blue: '#7b68ee', magenta: '#c678dd', cyan: '#56b6c2', white: '#c5c8c6',
brightBlack: '#555568', brightRed: '#ff6b81', brightGreen: '#69ff69', brightYellow: '#ffd93d', brightBlue: '#8fa8ff', brightMagenta: '#d19afc', brightCyan: '#7ee8e8', brightWhite: '#eaeaea',
Expand Down Expand Up @@ -42,10 +42,34 @@ const TERMINAL_THEMES = {
black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5',
brightBlack: '#002b36', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#fdf6e3',
},
solarizedLight: {
label: 'Solarized Light',
background: '#fdf6e3', foreground: '#657b83', cursor: '#586e75', selectionBackground: '#eee8d5',
black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5',
brightBlack: '#002b36', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#fdf6e3',
},
githubLight: {
label: 'GitHub Light',
background: '#ffffff', foreground: '#1f2328', cursor: '#044289', selectionBackground: '#b6e3ff',
black: '#24292f', red: '#cf222e', green: '#116329', yellow: '#4d2d00', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#6e7781',
brightBlack: '#57606a', brightRed: '#a40e26', brightGreen: '#1a7f37', brightYellow: '#633c01', brightBlue: '#218bff', brightMagenta: '#a475f9', brightCyan: '#3192aa', brightWhite: '#8c959f',
},
};

// The default "Switchboard" theme follows the app's light/dark appearance.
const SWITCHBOARD_LIGHT_TERMINAL_THEME = {
label: 'Switchboard',
background: '#fafafc', foreground: '#1d1d26', cursor: '#d6304f', selectionBackground: '#c9cdf5',
black: '#1d1d26', red: '#c4314b', green: '#1a8a2e', yellow: '#9a6a00', blue: '#4a52d6', magenta: '#9b3fb5', cyan: '#1f8191', white: '#6b6b80',
brightBlack: '#8a8aa0', brightRed: '#e0455f', brightGreen: '#22a83a', brightYellow: '#b88500', brightBlue: '#5f68e8', brightMagenta: '#b05acb', brightCyan: '#2a9caf', brightWhite: '#9a9aae',
};

let currentThemeName = 'switchboard';
function getTerminalTheme() {
return TERMINAL_THEMES[currentThemeName] || TERMINAL_THEMES.switchboard;
if (currentThemeName === 'switchboard' || !TERMINAL_THEMES[currentThemeName]) {
const light = typeof window.getAppTheme === 'function' && window.getAppTheme() === 'light';
return light ? SWITCHBOARD_LIGHT_TERMINAL_THEME : TERMINAL_THEMES.switchboard;
}
return TERMINAL_THEMES[currentThemeName];
}
let TERMINAL_THEME = getTerminalTheme();
3 changes: 3 additions & 0 deletions public/theme-light.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* Light theme: hand-tuned fixes on top of theme-light.generated.css.
* The generated file maps every dark color automatically; rules here cover
* the places where that mapping isn't good enough. */
Loading