Skip to content

Commit 6c6dd44

Browse files
committed
feat: custom color theme
1 parent f9f2015 commit 6c6dd44

File tree

11 files changed

+384
-12
lines changed

11 files changed

+384
-12
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Copyright (c) Freelens Authors. All rights reserved.
3+
* Copyright (c) OpenLens Authors. All rights reserved.
4+
* Licensed under MIT License. See LICENSE in root directory for more information.
5+
*/
6+
7+
@use "../../../../../../renderer/components/vars" as *;
8+
9+
$accent-teal: #00a7a0;
10+
$accent-green: #4caf50;
11+
$accent-blue: #2196f3;
12+
$accent-orange: #ff9800;
13+
14+
.selectRow {
15+
display: flex;
16+
gap: $unit;
17+
}
18+
19+
.themeSelect {
20+
flex: 9;
21+
}
22+
23+
.accentSelect {
24+
flex: 1;
25+
}
26+
27+
.colorPreview {
28+
display: flex;
29+
align-items: center;
30+
gap: $unit;
31+
margin-top: $unit;
32+
}
33+
34+
.resetButton {
35+
padding: 6px 12px;
36+
font-size: 12px;
37+
border: 1px solid var(--borderColor);
38+
border-radius: $radius;
39+
background-color: var(--secondaryBackground);
40+
color: var(--textColorPrimary);
41+
cursor: pointer;
42+
transition: all 0.2s;
43+
44+
&:hover {
45+
background-color: var(--layoutTabsBackground);
46+
border-color: var(--primary);
47+
color: var(--textColorPrimary);
48+
}
49+
50+
&:active {
51+
transform: translateY(1px);
52+
}
53+
}
54+

packages/core/src/features/preferences/renderer/preference-items/application/theme/theme.tsx

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { lensThemeDeclarationInjectionToken } from "../../../../../../renderer/t
1313
import defaultLensThemeInjectable from "../../../../../../renderer/themes/default-theme.injectable";
1414
import userPreferencesStateInjectable from "../../../../../user-preferences/common/state.injectable";
1515

16+
import styles from "./theme.module.scss";
17+
1618
import type { LensTheme } from "../../../../../../renderer/themes/lens-theme";
1719
import type { UserPreferencesState } from "../../../../../user-preferences/common/state.injectable";
1820

@@ -34,16 +36,61 @@ const NonInjectedTheme = observer(({ state, themes, defaultTheme }: Dependencies
3436
})),
3537
];
3638

39+
const accentColorOptions = [
40+
{ value: "#00a7a0", label: "Teal" },
41+
{ value: "#4caf50", label: "Green" },
42+
{ value: "#2196f3", label: "Blue" },
43+
{ value: "#ff9800", label: "Orange" },
44+
];
45+
46+
const currentColor = state.customAccentColor || "#00a7a0";
47+
48+
const ColorSwatch = ({ color }: { color: string }) => (
49+
<div style={{ backgroundColor: color, width: '20px', height: '20px', borderRadius: '2px' }} />
50+
);
51+
52+
const ColorOption = ({ option }: { option: { value: string; label: string } }) => (
53+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
54+
<ColorSwatch color={option.value} />
55+
<span>{option.label}</span>
56+
</div>
57+
);
58+
3759
return (
3860
<section id="appearance">
3961
<SubTitle title="Theme" />
40-
<Select
41-
id="theme-input"
42-
options={themeOptions}
43-
value={state.colorTheme}
44-
onChange={(value) => (state.colorTheme = value?.value ?? defaultTheme.name)}
45-
themeName="lens"
46-
/>
62+
<div className={styles.selectRow}>
63+
<Select
64+
className={styles.themeSelect}
65+
id="theme-input"
66+
options={themeOptions}
67+
value={state.colorTheme}
68+
onChange={(value) => (state.colorTheme = value?.value ?? defaultTheme.name)}
69+
themeName="lens"
70+
/>
71+
72+
<Select
73+
className={styles.accentSelect}
74+
id="accent-color-select"
75+
options={accentColorOptions}
76+
value={currentColor}
77+
onChange={(value) => (state.customAccentColor = value?.value)}
78+
formatOptionLabel={(option) => <ColorOption option={option} />}
79+
themeName="lens"
80+
/>
81+
</div>
82+
83+
<div className={styles.colorPreview}>
84+
{currentColor !== "#00a7a0" && (
85+
<button
86+
onClick={() => (state.customAccentColor = undefined)}
87+
className={styles.resetButton}
88+
title="Reset to default color"
89+
>
90+
Reset to Default
91+
</button>
92+
)}
93+
</div>
4794
</section>
4895
);
4996
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Copyright (c) Freelens Authors. All rights reserved.
3+
* Copyright (c) OpenLens Authors. All rights reserved.
4+
* Licensed under MIT License. See LICENSE in root directory for more information.
5+
*/
6+
7+
import type { MessageChannel } from "@freelensapp/messaging";
8+
import type { LensTheme } from "../../../../renderer/themes/lens-theme";
9+
10+
export const activeThemeUpdateChannel: MessageChannel<LensTheme> = {
11+
id: "active-theme-update",
12+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Copyright (c) Freelens Authors. All rights reserved.
3+
* Copyright (c) OpenLens Authors. All rights reserved.
4+
* Licensed under MIT License. See LICENSE in root directory for more information.
5+
*/
6+
7+
import { getMessageChannelListenerInjectable } from "@freelensapp/messaging";
8+
import applyLensThemeInjectable from "../../../../renderer/themes/apply-lens-theme.injectable";
9+
import { activeThemeUpdateChannel } from "../common/channel";
10+
11+
const activeThemeUpdateListenerInjectable = getMessageChannelListenerInjectable({
12+
channel: activeThemeUpdateChannel,
13+
id: "renderer",
14+
getHandler: (di) => {
15+
const applyLensTheme = di.inject(applyLensThemeInjectable);
16+
17+
return (theme) => {
18+
// Store theme globally in cluster frames for persistence
19+
if (!process.isMainFrame) {
20+
(window as any).__lastReceivedTheme = theme;
21+
}
22+
23+
applyLensTheme(theme);
24+
};
25+
},
26+
});
27+
28+
export default activeThemeUpdateListenerInjectable;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) Freelens Authors. All rights reserved.
3+
* Copyright (c) OpenLens Authors. All rights reserved.
4+
* Licensed under MIT License. See LICENSE in root directory for more information.
5+
*/
6+
7+
import { getInjectable } from "@ogre-tools/injectable";
8+
import { computed } from "mobx";
9+
import userPreferencesStateInjectable from "./state.injectable";
10+
11+
const customAccentColorInjectable = getInjectable({
12+
id: "custom-accent-color",
13+
instantiate: (di) => {
14+
const state = di.inject(userPreferencesStateInjectable);
15+
16+
return computed(() => state.customAccentColor);
17+
},
18+
});
19+
20+
export default customAccentColorInjectable;

packages/core/src/features/user-preferences/common/preference-descriptors.injectable.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ const userPreferenceDescriptorsInjectable = getInjectable({
5050
fromStore: (val) => val || defaultThemeId,
5151
toStore: (val) => (!val || val === defaultThemeId ? undefined : val),
5252
}),
53+
customAccentColor: getPreferenceDescriptor<string | undefined>({
54+
fromStore: (val) => val,
55+
toStore: (val) => val || undefined,
56+
}),
5357
terminalTheme: getPreferenceDescriptor<string>({
5458
fromStore: (val) => val || "",
5559
toStore: (val) => val || undefined,

packages/core/src/features/user-preferences/common/storage.injectable.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const userPreferencesPersistentStorageInjectable = getInjectable({
3939
state.allowErrorReporting = descriptors.allowErrorReporting.fromStore(preferences.allowErrorReporting);
4040
state.allowUntrustedCAs = descriptors.allowUntrustedCAs.fromStore(preferences.allowUntrustedCAs);
4141
state.colorTheme = descriptors.colorTheme.fromStore(preferences.colorTheme);
42+
state.customAccentColor = descriptors.customAccentColor.fromStore(preferences.customAccentColor);
4243
state.downloadBinariesPath = descriptors.downloadBinariesPath.fromStore(preferences.downloadBinariesPath);
4344
state.downloadKubectlBinaries = descriptors.downloadKubectlBinaries.fromStore(
4445
preferences.downloadKubectlBinaries,
@@ -64,6 +65,7 @@ const userPreferencesPersistentStorageInjectable = getInjectable({
6465
allowErrorReporting: descriptors.allowErrorReporting.toStore(state.allowErrorReporting),
6566
allowUntrustedCAs: descriptors.allowUntrustedCAs.toStore(state.allowUntrustedCAs),
6667
colorTheme: descriptors.colorTheme.toStore(state.colorTheme),
68+
customAccentColor: descriptors.customAccentColor.toStore(state.customAccentColor),
6769
downloadBinariesPath: descriptors.downloadBinariesPath.toStore(state.downloadBinariesPath),
6870
downloadKubectlBinaries: descriptors.downloadKubectlBinaries.toStore(state.downloadKubectlBinaries),
6971
downloadMirror: descriptors.downloadMirror.toStore(state.downloadMirror),
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Copyright (c) Freelens Authors. All rights reserved.
3+
* Copyright (c) OpenLens Authors. All rights reserved.
4+
* Licensed under MIT License. See LICENSE in root directory for more information.
5+
*/
6+
7+
import { getInjectable } from "@ogre-tools/injectable";
8+
import { beforeClusterFrameStartsFirstInjectionToken } from "../../before-frame-starts/tokens";
9+
import applyLensThemeInjectable from "../../themes/apply-lens-theme.injectable";
10+
11+
/**
12+
* Ensures cluster frames preserve theme CSS variables by:
13+
* 1. Storing the last received theme in a global variable
14+
* 2. Reapplying it when the document is ready
15+
* 3. Watching for potential CSS variable resets with delayed reapplication
16+
*/
17+
const ensureThemeReadyInjectable = getInjectable({
18+
id: "ensure-theme-ready-in-cluster-frame",
19+
instantiate: (di) => ({
20+
run: () => {
21+
const applyLensTheme = di.inject(applyLensThemeInjectable);
22+
23+
// Store the last received theme globally in the iframe
24+
// This will be set by the update listener
25+
(window as any).__lastReceivedTheme = null;
26+
27+
// Function to apply stored theme
28+
const applyStoredTheme = () => {
29+
const theme = (window as any).__lastReceivedTheme;
30+
if (theme) {
31+
applyLensTheme(theme);
32+
}
33+
};
34+
35+
// Apply theme when document is fully ready
36+
if (document.readyState === 'loading') {
37+
document.addEventListener('DOMContentLoaded', applyStoredTheme);
38+
}
39+
40+
// Watch for potential CSS variable resets
41+
// Some frameworks might clear styles, so we reapply after delays
42+
setTimeout(() => {
43+
const primaryVar = getComputedStyle(document.documentElement)
44+
.getPropertyValue('--primary')
45+
.trim();
46+
47+
if (!primaryVar && (window as any).__lastReceivedTheme) {
48+
applyStoredTheme();
49+
}
50+
}, 200);
51+
52+
setTimeout(() => {
53+
const primaryVar = getComputedStyle(document.documentElement)
54+
.getPropertyValue('--primary')
55+
.trim();
56+
57+
if (!primaryVar && (window as any).__lastReceivedTheme) {
58+
applyStoredTheme();
59+
}
60+
}, 500);
61+
},
62+
}),
63+
injectionToken: beforeClusterFrameStartsFirstInjectionToken,
64+
});
65+
66+
export default ensureThemeReadyInjectable;

packages/core/src/renderer/themes/active.injectable.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { getInjectable } from "@ogre-tools/injectable";
88
import assert from "assert";
99
import { computed } from "mobx";
1010
import lensColorThemePreferenceInjectable from "../../features/user-preferences/common/lens-color-theme.injectable";
11+
import customAccentColorInjectable from "../../features/user-preferences/common/custom-accent-color.injectable";
1112
import { lensThemeDeclarationInjectionToken } from "./declaration";
1213
import defaultLensThemeInjectable from "./default-theme.injectable";
1314
import systemThemeConfigurationInjectable from "./system-theme.injectable";
1415
import lensThemesInjectable from "./themes.injectable";
1516

17+
import type { LensTheme } from "./lens-theme";
18+
1619
const activeThemeInjectable = getInjectable({
1720
id: "active-theme",
1821
instantiate: (di) => {
@@ -21,20 +24,46 @@ const activeThemeInjectable = getInjectable({
2124
const lensColorThemePreference = di.inject(lensColorThemePreferenceInjectable);
2225
const systemThemeConfiguration = di.inject(systemThemeConfigurationInjectable);
2326
const defaultLensTheme = di.inject(defaultLensThemeInjectable);
27+
const customAccentColor = di.inject(customAccentColorInjectable);
2428

2529
return computed(() => {
2630
const pref = lensColorThemePreference.get();
31+
let baseTheme: LensTheme;
2732

2833
if (pref.useSystemTheme) {
2934
const systemThemeType = systemThemeConfiguration.get();
3035
const matchingTheme = themeDecls.find((theme) => theme.type === systemThemeType);
3136

3237
assert(matchingTheme, `Missing theme declaration for system theme "${systemThemeType}"`);
3338

34-
return matchingTheme;
39+
baseTheme = matchingTheme;
40+
} else {
41+
baseTheme = lensThemes.get(pref.lensThemeId) ?? defaultLensTheme;
42+
}
43+
44+
const accentColor = customAccentColor.get();
45+
46+
if (!accentColor) {
47+
return baseTheme;
3548
}
3649

37-
return lensThemes.get(pref.lensThemeId) ?? defaultLensTheme;
50+
// Override all colors that use the primary accent color
51+
// This ensures both root and iframe get the same theme with accent color applied
52+
return {
53+
...baseTheme,
54+
colors: {
55+
...baseTheme.colors,
56+
blue: accentColor,
57+
primary: accentColor,
58+
buttonPrimaryBackground: accentColor,
59+
menuActiveBackground: accentColor,
60+
helmStableRepo: accentColor,
61+
colorInfo: accentColor,
62+
sidebarSubmenuActiveColor: accentColor,
63+
// Keep sidebarActiveColor white for better contrast
64+
sidebarActiveColor: "#ffffff",
65+
},
66+
};
3867
});
3968
},
4069
});

packages/core/src/renderer/themes/apply-lens-theme.injectable.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const applyLensThemeInjectable = getInjectable({
2323
try {
2424
const colors = object.entries(theme.colors);
2525

26+
// Set each CSS variable on document.documentElement
2627
for (const [name, value] of colors) {
2728
document.documentElement.style.setProperty(`--${name}`, value);
2829
}

0 commit comments

Comments
 (0)