feat: introduced a token-based theming system across the UI

* feat: added themes system

* feat: smart sidebar auto-hide for files/diff tabs + lower files sidebar threshold

* feat: added Checkbox component and update theming

- Add reusable Checkbox component for toggles across UI
- Replace several inputs with Checkbox in settings and commands panels
- Add DiffIcon and apply surface/border theming to key UI areas

* feat: Add convert-vscode-theme.cjs to convert VS Code themes to OpenChamber format

* refactor: remove unused permission logic from ChatInput

- Remove unused permission rules parsing logic from ChatInput
- Memoize renderTheme in DiffWorkerProvider to avoid unnecessary recalculations
- Remove forceOpaque helper in vscode theme adapter

* fix: guard VSCode theme loading in MarkdownRenderer

* feat: add custom user themes loading and reload

- Load user themes from ~/.config/openchamber/themes at runtime
- Expose /api/config/themes to fetch custom themes
- Allow theme reloading from Settings → Theme → Reload themes in the UI
This commit is contained in:
Bohdan Triapitsyn
2026-02-01 18:29:34 +02:00
committed by GitHub
parent 5bb2c56bc0
commit ddedc02687
147 changed files with 9853 additions and 2435 deletions
+216 -4
View File
@@ -65,6 +65,163 @@ const normalizeDirectoryPath = (value) => {
};
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
const MAX_THEME_JSON_BYTES = 512 * 1024;
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
const isValidThemeColor = (value) => isNonEmptyString(value);
const normalizeThemeJson = (raw) => {
if (!raw || typeof raw !== 'object') {
return null;
}
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null;
const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null;
if (!metadata || !colors) {
return null;
}
const id = metadata.id;
const name = metadata.name;
const variant = metadata.variant;
if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) {
return null;
}
const primary = colors.primary;
const surface = colors.surface;
const interactive = colors.interactive;
const status = colors.status;
const syntax = colors.syntax;
const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null;
const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null;
if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) {
return null;
}
// Minimal fields required by CSSVariableGenerator and diff/syntax rendering.
const required = [
primary.base,
primary.foreground,
surface.background,
surface.foreground,
surface.muted,
surface.mutedForeground,
surface.elevated,
surface.elevatedForeground,
surface.subtle,
interactive.border,
interactive.selection,
interactive.selectionForeground,
interactive.focusRing,
interactive.hover,
status.error,
status.errorForeground,
status.errorBackground,
status.errorBorder,
status.warning,
status.warningForeground,
status.warningBackground,
status.warningBorder,
status.success,
status.successForeground,
status.successBackground,
status.successBorder,
status.info,
status.infoForeground,
status.infoBackground,
status.infoBorder,
syntaxBase.background,
syntaxBase.foreground,
syntaxBase.keyword,
syntaxBase.string,
syntaxBase.number,
syntaxBase.function,
syntaxBase.variable,
syntaxBase.type,
syntaxBase.comment,
syntaxBase.operator,
syntaxHighlights.diffAdded,
syntaxHighlights.diffRemoved,
syntaxHighlights.lineNumber,
];
if (!required.every(isValidThemeColor)) {
return null;
}
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0)
: [];
return {
...raw,
metadata: {
...metadata,
id: id.trim(),
name: name.trim(),
description: typeof metadata.description === 'string' ? metadata.description : '',
version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0',
variant,
tags,
},
};
};
const readCustomThemesFromDisk = async () => {
try {
const entries = await fsPromises.readdir(OPENCHAMBER_USER_THEMES_DIR, { withFileTypes: true });
const themes = [];
const seen = new Set();
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.toLowerCase().endsWith('.json')) continue;
const filePath = path.join(OPENCHAMBER_USER_THEMES_DIR, entry.name);
try {
const stat = await fsPromises.stat(filePath);
if (!stat.isFile()) continue;
if (stat.size > MAX_THEME_JSON_BYTES) {
console.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`);
continue;
}
const rawText = await fsPromises.readFile(filePath, 'utf8');
const parsed = JSON.parse(rawText);
const normalized = normalizeThemeJson(parsed);
if (!normalized) {
console.warn(`[themes] Skip ${entry.name}: invalid theme JSON`);
continue;
}
const id = normalized.metadata.id;
if (seen.has(id)) {
console.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`);
continue;
}
seen.add(id);
themes.push(normalized);
} catch (error) {
console.warn(`[themes] Failed to read ${entry.name}:`, error);
}
}
return themes;
} catch (error) {
// Missing dir is fine.
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return [];
}
console.warn('[themes] Failed to list custom themes dir:', error);
return [];
}
};
const isPathWithinRoot = (resolvedPath, rootPath) => {
const resolvedRoot = path.resolve(rootPath || os.homedir());
@@ -1092,13 +1249,58 @@ const migrateSettingsFromLegacyLastDirectory = async (current) => {
return { settings: merged, changed: true };
};
const migrateSettingsFromLegacyThemePreferences = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
if (hasLight && hasDark) {
return { settings, changed: false };
}
const defaultLight = 'flexoki-light';
const defaultDark = 'flexoki-dark';
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
if (!hasLight) {
if (themeId && themeVariant === 'light') {
nextLightThemeId = themeId;
} else {
nextLightThemeId = defaultLight;
}
}
if (!hasDark) {
if (themeId && themeVariant === 'dark') {
nextDarkThemeId = themeId;
} else {
nextDarkThemeId = defaultDark;
}
}
const merged = mergePersistedSettings(settings, {
...settings,
...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
});
return { settings: merged, changed: true };
};
const readSettingsFromDiskMigrated = async () => {
const current = await readSettingsFromDisk();
const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
if (changed) {
await writeSettingsToDisk(settings);
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
if (migration1.changed || migration2.changed) {
await writeSettingsToDisk(migration2.settings);
}
return settings;
return migration2.settings;
};
const getOrCreateVapidKeys = async () => {
@@ -3338,6 +3540,16 @@ async function main(options = {}) {
}
});
app.get('/api/config/themes', async (_req, res) => {
try {
const customThemes = await readCustomThemesFromDisk();
res.json({ themes: customThemes });
} catch (error) {
console.error('Failed to load custom themes:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' });
}
});
app.put('/api/config/settings', async (req, res) => {
console.log(`[API:PUT /api/config/settings] Received request`);
console.log(`[API:PUT /api/config/settings] Request body:`, JSON.stringify(req.body, null, 2));