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 [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme);
useThemeSync(state?.settings.theme, state?.settings.accent_color);
if (loading) {
return (

View file

@ -93,7 +93,9 @@ export const api = {
updateCheck: () => call<UpdateCheckReport>('update_check'),
updateApply: () => call<UpdateApplyReport>('update_apply'),
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),
backupNow: () => call<{ backup_path: string }>('backup_now'),
setVaultPassword: (currentPassword: string | null, newPassword: string) =>

View file

@ -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 {

View file

@ -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.
</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>
</section>

View file

@ -61,7 +61,9 @@ interface AppContextValue {
updateCheck: () => Promise<UpdateCheckReport>;
updateApply: () => Promise<UpdateApplyReport>;
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>;
backupNow: () => Promise<{ backup_path: string }>;
setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise<AppState>;
@ -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]);
}

View file

@ -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;
}

View file

@ -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(<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 () => {
const backend = createFakeBackend();
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': {
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;
}