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:
parent
9770f46d4a
commit
a6182e3cb2
11 changed files with 238 additions and 14 deletions
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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's own accent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
39
frontend/src/test/accent.test.ts
Normal file
39
frontend/src/test/accent.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/ipc.rs
13
src/ipc.rs
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue