From a6182e3cb227a36d1fd5800f64303118760890fd Mon Sep 17 00:00:00 2001 From: Avi Date: Mon, 28 Sep 2026 10:17:03 -0500 Subject: [PATCH] feat(theme): iOS blue accent for Workshop Dark + custom accent color setting Workshop Dark accent is now #007AFF (hover #4da3ff, soft #0f2a44, white labels), including the ambient wash tint. New Settings -> Appearance -> Accent color: a color picker plus hex field that overrides primary/focus for ANY theme, with a 'Use theme default' reset. Persisted in settings.json (accent_color), validated server-side (#rrggbb, empty clears), painted over the theme via inline CSS custom properties with automatic hover/soft mixes and luminance-based label contrast. Verified live: .btn-primary renders rgb(0,122,255)/white under workshop-dark. 220 Rust unit + 6 e2e, 139 frontend tests (4 new), clippy 0, all gates green, release rebuilt. --- frontend/src/App.tsx | 2 +- frontend/src/lib/api.ts | 4 +- frontend/src/lib/types.ts | 2 + frontend/src/screens/SettingsScreen.tsx | 53 ++++++++++++++++++++++ frontend/src/state/AppProvider.tsx | 54 ++++++++++++++++++++--- frontend/src/styles.css | 14 +++--- frontend/src/test/SettingsScreen.test.tsx | 18 ++++++++ frontend/src/test/accent.test.ts | 39 ++++++++++++++++ frontend/src/test/fakeBackend.ts | 12 +++++ src/ipc.rs | 13 ++++++ src/settings.rs | 41 +++++++++++++++++ 11 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 frontend/src/test/accent.test.ts 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> { + let raw = raw.trim(); + if raw.is_empty() { + return Some(None); + } + let digits = raw.strip_prefix('#').unwrap_or(raw); + if digits.len() != 6 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + Some(Some(format!("#{}", digits.to_ascii_lowercase()))) } fn default_true() -> bool { @@ -80,6 +99,28 @@ impl Default for Settings { confirm_before_publish: true, shorten_npub: true, relays: crate::relays::default_relays(), + accent_color: None, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accent_color_normalizes_and_clears() { + assert_eq!( + normalize_accent_color("#007AFF"), + Some(Some("#007aff".to_string())) + ); + assert_eq!( + normalize_accent_color("ff8800"), + Some(Some("#ff8800".to_string())) + ); + assert_eq!(normalize_accent_color(" "), Some(None)); + assert_eq!(normalize_accent_color("#12345"), None); + assert_eq!(normalize_accent_color("#gggggg"), None); + assert_eq!(normalize_accent_color("red"), None); + } +}