diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8aa3c87..176e18d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -25,7 +25,7 @@ function Shell() {
const [importOpen, setImportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
- useThemeSync(state?.settings.theme);
+ useThemeSync(state?.settings.theme, state?.settings.accent_color);
if (loading) {
return (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 3f1d0db..59b53c1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -93,7 +93,9 @@ export const api = {
updateCheck: () => call('update_check'),
updateApply: () => call('update_apply'),
settingsUpdate: (
- patch: Partial>,
+ patch: Partial<
+ Pick
+ >,
) => call('settings_update', patch),
backupNow: () => call<{ backup_path: string }>('backup_now'),
setVaultPassword: (currentPassword: string | null, newPassword: string) =>
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 376ca84..1b2db24 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -134,6 +134,8 @@ export interface Settings {
confirm_before_publish: boolean;
shorten_npub: boolean;
relays: RelayConfig[];
+ /** Custom accent (`#rrggbb`) painted over the theme; null/undefined = theme default. */
+ accent_color?: string | null;
}
export interface RelayFailure {
diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx
index e2bc2bb..fdfd396 100644
--- a/frontend/src/screens/SettingsScreen.tsx
+++ b/frontend/src/screens/SettingsScreen.tsx
@@ -10,6 +10,21 @@ import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultP
import type { Theme, UpdateCheckReport } from '../lib/types';
import { useApp } from '../state/AppProvider';
+/**
+ * The color input always needs a valid #rrggbb. With no override saved,
+ * show the theme's own accent (read from the live stylesheet).
+ */
+function themeAccentHex(accent: string | null | undefined): string {
+ if (accent && /^#[0-9a-f]{6}$/i.test(accent)) {
+ return accent;
+ }
+ const computed = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
+ if (/^#[0-9a-f]{6}$/i.test(computed)) {
+ return computed;
+ }
+ return '#007aff';
+}
+
export function SettingsScreen() {
const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
@@ -33,6 +48,10 @@ export function SettingsScreen() {
await updateSettings({ theme });
};
+ const onAccent = async (accent_color: string) => {
+ await updateSettings({ accent_color });
+ };
+
const onConfirmToggle = async (confirm_before_publish: boolean) => {
await updateSettings({ confirm_before_publish });
};
@@ -210,6 +229,40 @@ export function SettingsScreen() {
"Impeccable Dark", and "Cosmic" are aesthetic themes with distinctive color
palettes.
+
+
+
+ void onAccent(event.target.value)}
+ />
+ {
+ const raw = event.target.value.trim();
+ if (/^#?[0-9a-f]{6}$/i.test(raw)) {
+ void onAccent(raw);
+ }
+ }}
+ />
+ {settings.accent_color ? (
+
+ ) : null}
+
+
+ Overrides the accent (buttons, links, focus) for any theme. Leave it empty to use
+ the theme's own accent.
+
+
diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx
index 9aa5483..89fce9e 100644
--- a/frontend/src/state/AppProvider.tsx
+++ b/frontend/src/state/AppProvider.tsx
@@ -61,7 +61,9 @@ interface AppContextValue {
updateCheck: () => Promise;
updateApply: () => Promise;
updateSettings: (
- patch: Partial>,
+ patch: Partial<
+ Pick
+ >,
) => Promise;
backupNow: () => Promise<{ backup_path: string }>;
setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise;
@@ -472,7 +474,46 @@ export function useApp(): AppContextValue {
return context;
}
-/** Apply the requested theme. */
+/**
+ * Paint a user-chosen accent over the active theme's palette: the accent
+ * fills primary/focus, a luminance-mixed pair covers hover/soft, and the
+ * label color flips to dark-on-light / white-on-dark for contrast.
+ */
+export function applyAccentOverride(accent: string | null | undefined): void {
+ const root = document.documentElement;
+ const clear = () => {
+ for (const token of [
+ '--primary',
+ '--primary-hover',
+ '--primary-soft',
+ '--on-primary',
+ '--focus',
+ ]) {
+ root.style.removeProperty(token);
+ }
+ };
+ const match = accent?.match(/^#?([0-9a-f]{6})$/i);
+ if (!match) {
+ clear();
+ return;
+ }
+ const value = match[1];
+ const r = parseInt(value.slice(0, 2), 16);
+ const g = parseInt(value.slice(2, 4), 16);
+ const b = parseInt(value.slice(4, 6), 16);
+ // Relative luminance (sRGB approximation) decides label contrast.
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+ const mix = (channel: number, target: number, amount: number) =>
+ Math.round(channel + (target - channel) * amount);
+ const lighten = (amount: number) =>
+ `#${[r, g, b].map((c) => mix(c, 255, amount).toString(16).padStart(2, '0')).join('')}`;
+ root.style.setProperty('--primary', `#${value}`);
+ root.style.setProperty('--primary-hover', lighten(0.25));
+ root.style.setProperty('--primary-soft', lighten(0.85));
+ root.style.setProperty('--on-primary', luminance > 0.6 ? '#12110f' : '#ffffff');
+ root.style.setProperty('--focus', `#${value}`);
+}
+
export function applyTheme(theme: Theme): void {
if (theme === 'glass' || theme === 'neon') {
document.documentElement.dataset.theme = theme;
@@ -482,15 +523,18 @@ export function applyTheme(theme: Theme): void {
}
/** Keep the document theme in sync with settings, watching system changes. */
-export function useThemeSync(theme: Theme | undefined): void {
+export function useThemeSync(theme: Theme | undefined, accent?: string | null): void {
useEffect(() => {
if (!theme) {
return;
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
- const sync = () => applyTheme(theme);
+ const sync = () => {
+ applyTheme(theme);
+ applyAccentOverride(accent);
+ };
sync();
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
- }, [theme]);
+ }, [theme, accent]);
}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 31e669f..44509b6 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -643,10 +643,10 @@ html[data-theme='workshop'] h2 {
--border-strong: #4d463c;
--text: #ebe6dc;
--text-muted: #a39b8f;
- --primary: #7eb89a;
- --primary-hover: #96d0b2;
- --primary-soft: #1c322a;
- --on-primary: #12110f;
+ --primary: #007aff;
+ --primary-hover: #4da3ff;
+ --primary-soft: #0f2a44;
+ --on-primary: #ffffff;
--danger: #d98a7d;
--danger-soft: #35211d;
--warning: #d4a574;
@@ -655,14 +655,14 @@ html[data-theme='workshop'] h2 {
--success-soft: #1c322a;
--info: #92b4cc;
--info-soft: #1d2831;
- --focus: #7eb89a;
+ --focus: #007aff;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35);
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6);
--radius: 14px;
--radius-sm: 9px;
- /* Moi dark atmosphere (site.css dark block): pale grid + pine/clay washes */
+ /* Moi dark atmosphere (site.css dark block), tinted to the blue accent */
--wk-grid: rgba(235, 230, 220, 0.035);
- --wk-wash-mint: rgba(126, 184, 154, 0.08);
+ --wk-wash-mint: rgba(0, 122, 255, 0.09);
--wk-wash-clay: rgba(212, 165, 116, 0.07);
--wk-copper: #d4a574;
}
diff --git a/frontend/src/test/SettingsScreen.test.tsx b/frontend/src/test/SettingsScreen.test.tsx
index f3954fa..e821e54 100644
--- a/frontend/src/test/SettingsScreen.test.tsx
+++ b/frontend/src/test/SettingsScreen.test.tsx
@@ -29,6 +29,24 @@ describe('SettingsScreen', () => {
expect(document.documentElement.dataset.theme).toBe('dark');
});
+ it('sets, normalizes, and clears the custom accent color', async () => {
+ const backend = createFakeBackend();
+ installFakeBackend(backend);
+ renderWithApp();
+
+ const hexInput = await screen.findByLabelText('Accent color hex value');
+ fireEvent.change(hexInput, { target: { value: '#007AFF' } });
+ await waitFor(() => {
+ expect(backend.state.settings.accent_color).toBe('#007aff');
+ });
+
+ const reset = await screen.findByRole('button', { name: /Use theme default/i });
+ fireEvent.click(reset);
+ await waitFor(() => {
+ expect(backend.state.settings.accent_color).toBeNull();
+ });
+ });
+
it('applies the neon theme to the document', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
diff --git a/frontend/src/test/accent.test.ts b/frontend/src/test/accent.test.ts
new file mode 100644
index 0000000..b697904
--- /dev/null
+++ b/frontend/src/test/accent.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { applyAccentOverride } from '../state/AppProvider';
+
+const TOKENS = ['--primary', '--primary-hover', '--primary-soft', '--on-primary', '--focus'];
+
+describe('applyAccentOverride', () => {
+ afterEach(() => {
+ applyAccentOverride(null);
+ });
+
+ it('paints the accent across the primary token family', () => {
+ applyAccentOverride('#007aff');
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--primary')).toBe('#007aff');
+ expect(style.getPropertyValue('--focus')).toBe('#007aff');
+ // Hover and soft are lighter mixes of the same hue.
+ expect(style.getPropertyValue('--primary-hover')).toMatch(/^#[0-9a-f]{6}$/);
+ expect(style.getPropertyValue('--primary-soft')).toMatch(/^#[0-9a-f]{6}$/);
+ // A mid-dark blue gets white labels.
+ expect(style.getPropertyValue('--on-primary')).toBe('#ffffff');
+ });
+
+ it('flips labels to dark ink on bright accents', () => {
+ applyAccentOverride('#ffee00');
+ expect(document.documentElement.style.getPropertyValue('--on-primary')).toBe('#12110f');
+ });
+
+ it('clears every override token when unset or invalid', () => {
+ applyAccentOverride('#007aff');
+ applyAccentOverride(null);
+ for (const token of TOKENS) {
+ expect(document.documentElement.style.getPropertyValue(token)).toBe('');
+ }
+ applyAccentOverride('#007aff');
+ applyAccentOverride('not-a-color');
+ expect(document.documentElement.style.getPropertyValue('--primary')).toBe('');
+ });
+});
diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts
index 40b147c..11b7752 100644
--- a/frontend/src/test/fakeBackend.ts
+++ b/frontend/src/test/fakeBackend.ts
@@ -482,6 +482,18 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'settings_update': {
const nextSettings: Settings = { ...state.settings, ...params };
+ // Mirror the backend's accent normalization: '#rrggbb' lowercased,
+ // empty string clears, anything else rejected.
+ if ('accent_color' in params) {
+ const raw = String(params.accent_color ?? '').trim();
+ if (raw === '') {
+ nextSettings.accent_color = null;
+ } else if (/^#?[0-9a-f]{6}$/i.test(raw)) {
+ nextSettings.accent_color = `#${raw.replace('#', '').toLowerCase()}`;
+ } else {
+ throw new Error('Accent color must be a #rrggbb hex value.');
+ }
+ }
backend.setState({ ...state, settings: nextSettings });
return nextSettings;
}
diff --git a/src/ipc.rs b/src/ipc.rs
index 8674757..dcb07a1 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -108,6 +108,8 @@ pub enum Request {
theme: Option,
confirm_before_publish: Option,
shorten_npub: Option,
+ /// `#rrggbb` to override the accent; empty string to clear.
+ accent_color: Option,
},
/// Create a timestamped backup of the vault file.
BackupNow,
@@ -1003,6 +1005,7 @@ async fn run_with_app(app: &mut App, request: Request) -> Result {
if let Some(theme) = theme {
app.settings.theme = theme;
@@ -1013,6 +1016,16 @@ async fn run_with_app(app: &mut App, request: Request) -> Result,
+ /// Custom accent color (`#rrggbb`) painted over the selected theme's
+ /// palette. `None` means "use the theme's own accent".
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub accent_color: Option,
+}
+
+/// Validate and normalize a user-supplied accent color to `#rrggbb`
+/// lowercase. Returns `None` for anything that is not a plain 6-digit hex
+/// color; an empty string means "clear the override".
+pub fn normalize_accent_color(raw: &str) -> Option