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.
This commit is contained in:
Avi 2026-09-28 10:17:03 -05:00
commit a6182e3cb2
11 changed files with 238 additions and 14 deletions

View file

@ -25,7 +25,7 @@ function Shell() {
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false); const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme); useThemeSync(state?.settings.theme, state?.settings.accent_color);
if (loading) { if (loading) {
return ( return (

View file

@ -93,7 +93,9 @@ export const api = {
updateCheck: () => call<UpdateCheckReport>('update_check'), updateCheck: () => call<UpdateCheckReport>('update_check'),
updateApply: () => call<UpdateApplyReport>('update_apply'), updateApply: () => call<UpdateApplyReport>('update_apply'),
settingsUpdate: ( settingsUpdate: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>, patch: Partial<
Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub' | 'accent_color'>
>,
) => call<Settings>('settings_update', patch), ) => call<Settings>('settings_update', patch),
backupNow: () => call<{ backup_path: string }>('backup_now'), backupNow: () => call<{ backup_path: string }>('backup_now'),
setVaultPassword: (currentPassword: string | null, newPassword: string) => setVaultPassword: (currentPassword: string | null, newPassword: string) =>

View file

@ -134,6 +134,8 @@ export interface Settings {
confirm_before_publish: boolean; confirm_before_publish: boolean;
shorten_npub: boolean; shorten_npub: boolean;
relays: RelayConfig[]; relays: RelayConfig[];
/** Custom accent (`#rrggbb`) painted over the theme; null/undefined = theme default. */
accent_color?: string | null;
} }
export interface RelayFailure { export interface RelayFailure {

View file

@ -10,6 +10,21 @@ import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultP
import type { Theme, UpdateCheckReport } from '../lib/types'; import type { Theme, UpdateCheckReport } from '../lib/types';
import { useApp } from '../state/AppProvider'; 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() { export function SettingsScreen() {
const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp(); const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null); const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
@ -33,6 +48,10 @@ export function SettingsScreen() {
await updateSettings({ theme }); await updateSettings({ theme });
}; };
const onAccent = async (accent_color: string) => {
await updateSettings({ accent_color });
};
const onConfirmToggle = async (confirm_before_publish: boolean) => { const onConfirmToggle = async (confirm_before_publish: boolean) => {
await updateSettings({ confirm_before_publish }); await updateSettings({ confirm_before_publish });
}; };
@ -210,6 +229,40 @@ export function SettingsScreen() {
"Impeccable Dark", and "Cosmic" are aesthetic themes with distinctive color "Impeccable Dark", and "Cosmic" are aesthetic themes with distinctive color
palettes. palettes.
</p> </p>
<div className="field" style={{ marginTop: 12 }}>
<label htmlFor="accent-color">Accent color</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="accent-color"
type="color"
value={themeAccentHex(settings.accent_color)}
onChange={(event) => void onAccent(event.target.value)}
/>
<input
type="text"
aria-label="Accent color hex value"
value={settings.accent_color ?? ''}
placeholder="theme default"
spellCheck={false}
style={{ width: 110 }}
onChange={(event) => {
const raw = event.target.value.trim();
if (/^#?[0-9a-f]{6}$/i.test(raw)) {
void onAccent(raw);
}
}}
/>
{settings.accent_color ? (
<button type="button" onClick={() => void onAccent('')}>
Use theme default
</button>
) : null}
</div>
<p className="hint">
Overrides the accent (buttons, links, focus) for any theme. Leave it empty to use
the theme&apos;s own accent.
</p>
</div>
</div> </div>
</div> </div>
</section> </section>

View file

@ -61,7 +61,9 @@ interface AppContextValue {
updateCheck: () => Promise<UpdateCheckReport>; updateCheck: () => Promise<UpdateCheckReport>;
updateApply: () => Promise<UpdateApplyReport>; updateApply: () => Promise<UpdateApplyReport>;
updateSettings: ( updateSettings: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>, patch: Partial<
Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub' | 'accent_color'>
>,
) => Promise<Settings>; ) => Promise<Settings>;
backupNow: () => Promise<{ backup_path: string }>; backupNow: () => Promise<{ backup_path: string }>;
setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise<AppState>; setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise<AppState>;
@ -472,7 +474,46 @@ export function useApp(): AppContextValue {
return context; 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 { export function applyTheme(theme: Theme): void {
if (theme === 'glass' || theme === 'neon') { if (theme === 'glass' || theme === 'neon') {
document.documentElement.dataset.theme = theme; 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. */ /** 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(() => { useEffect(() => {
if (!theme) { if (!theme) {
return; return;
} }
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => applyTheme(theme); const sync = () => {
applyTheme(theme);
applyAccentOverride(accent);
};
sync(); sync();
media.addEventListener('change', sync); media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync); return () => media.removeEventListener('change', sync);
}, [theme]); }, [theme, accent]);
} }

View file

@ -643,10 +643,10 @@ html[data-theme='workshop'] h2 {
--border-strong: #4d463c; --border-strong: #4d463c;
--text: #ebe6dc; --text: #ebe6dc;
--text-muted: #a39b8f; --text-muted: #a39b8f;
--primary: #7eb89a; --primary: #007aff;
--primary-hover: #96d0b2; --primary-hover: #4da3ff;
--primary-soft: #1c322a; --primary-soft: #0f2a44;
--on-primary: #12110f; --on-primary: #ffffff;
--danger: #d98a7d; --danger: #d98a7d;
--danger-soft: #35211d; --danger-soft: #35211d;
--warning: #d4a574; --warning: #d4a574;
@ -655,14 +655,14 @@ html[data-theme='workshop'] h2 {
--success-soft: #1c322a; --success-soft: #1c322a;
--info: #92b4cc; --info: #92b4cc;
--info-soft: #1d2831; --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: 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); --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6);
--radius: 14px; --radius: 14px;
--radius-sm: 9px; --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-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-wash-clay: rgba(212, 165, 116, 0.07);
--wk-copper: #d4a574; --wk-copper: #d4a574;
} }

View file

@ -29,6 +29,24 @@ describe('SettingsScreen', () => {
expect(document.documentElement.dataset.theme).toBe('dark'); expect(document.documentElement.dataset.theme).toBe('dark');
}); });
it('sets, normalizes, and clears the custom accent color', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
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 () => { it('applies the neon theme to the document', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
installFakeBackend(backend); installFakeBackend(backend);

View file

@ -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('');
});
});

View file

@ -482,6 +482,18 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'settings_update': { case 'settings_update': {
const nextSettings: Settings = { ...state.settings, ...params }; 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 }); backend.setState({ ...state, settings: nextSettings });
return nextSettings; return nextSettings;
} }

View file

@ -108,6 +108,8 @@ pub enum Request {
theme: Option<Theme>, theme: Option<Theme>,
confirm_before_publish: Option<bool>, confirm_before_publish: Option<bool>,
shorten_npub: Option<bool>, shorten_npub: Option<bool>,
/// `#rrggbb` to override the accent; empty string to clear.
accent_color: Option<String>,
}, },
/// Create a timestamped backup of the vault file. /// Create a timestamped backup of the vault file.
BackupNow, BackupNow,
@ -1003,6 +1005,7 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
theme, theme,
confirm_before_publish, confirm_before_publish,
shorten_npub, shorten_npub,
accent_color,
} => { } => {
if let Some(theme) = theme { if let Some(theme) = theme {
app.settings.theme = theme; app.settings.theme = theme;
@ -1013,6 +1016,16 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
if let Some(shorten) = shorten_npub { if let Some(shorten) = shorten_npub {
app.settings.shorten_npub = shorten; app.settings.shorten_npub = shorten;
} }
if let Some(raw) = accent_color {
let normalized =
crate::settings::normalize_accent_color(&raw).ok_or_else(|| {
AppError::simple(
crate::errors::ErrorKind::Config,
"Accent color must be a #rrggbb hex value.",
)
})?;
app.settings.accent_color = normalized;
}
app.save_settings()?; app.save_settings()?;
Ok(json!(app.settings)) Ok(json!(app.settings))
} }

View file

@ -67,6 +67,25 @@ pub struct Settings {
pub shorten_npub: bool, pub shorten_npub: bool,
#[serde(default)] #[serde(default)]
pub relays: Vec<RelayConfig>, pub relays: Vec<RelayConfig>,
/// 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<String>,
}
/// 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<Option<String>> {
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 { fn default_true() -> bool {
@ -80,6 +99,28 @@ impl Default for Settings {
confirm_before_publish: true, confirm_before_publish: true,
shorten_npub: true, shorten_npub: true,
relays: crate::relays::default_relays(), 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);
}
}