feat: theme system — 6 color themes + dot picker

Themes:
- Gruvbox (default), Midnight Blue, Nord, Solarized Dark,
  Rosé Pine, Catppuccin Mocha
- CSS custom properties overridden via data-theme attribute
- Persisted in Zustand store, hydrated on boot
- Dot picker in the footer bar (next to UI scale toggle)
- All themes are dark; light scaffold ready for community PRs

Roadmap: removed code signing (skipped), cleaned up shipped section
This commit is contained in:
debpalash
2026-04-28 11:47:06 +05:30
parent 809943b881
commit 9e971b517e
8 changed files with 275 additions and 17 deletions
+5 -17
View File
@@ -291,32 +291,20 @@ chmod +x OmniVoice.Studio_*.AppImage
### 🔜 Next — by priority
**🚀 Ship-blockers**
- [ ] macOS code signing + notarization — eliminates the `xattr -cr` workaround
- [ ] Windows code signing — eliminates SmartScreen warning
- [x] ~~Onboarding sample clip — pre-loaded demo project for first-run experience~~
- [x] ~~Docker DX polish — health endpoint, CPU/GPU profiles, proper compose~~
- [x] ~~Auto-updater — check + download + install from GitHub Releases~~
**🚀 Shipped** ✅
- ~~Onboarding sample clip~~ · ~~Docker DX~~ · ~~Auto-updater~~ · ~~Deferred disk writes~~
- ~~MCP server~~ · ~~Voice personalities~~ · ~~Audio effects chain~~ · ~~i18n framework~~
- ~~Global hotkey dictation~~ · ~~Real-time dub preview~~ · ~~Speaker casting view~~
**⚡ Performance**
- [ ] Batched TTS (816 segments per forward pass) — 35× throughput
- [x] ~~Eliminate per-segment disk round-trips in `dub_generate.py`~~ (deferred batch-write)
- [ ] Cold start ≤ 1.5s (currently ~4s on Apple Silicon)
- [ ] Crash-sandbox GPU engines (subprocess isolation)
**✨ Differentiators**
- [x] ~~Global hotkey dictation — system-wide record → transcribe → paste (⌘+⇧+Space)~~
- [x] ~~MCP server — expose OmniVoice as an AI agent tool (Claude, Cursor, etc.)~~
- [x] ~~Voice personalities — named presets (narrator, casual, formal) with saved TTS params~~
- [x] ~~Audio effects chain — post-processing pipeline (reverb, EQ, compression)~~
- [x] ~~Real-time dub preview — single-segment TTS preview without full re-render~~
- [x] ~~Project-level casting view — assign voices to speakers~~
**🎨 Polish & Quality**
- [x] ~~i18n framework — UI translation support (community-driven)~~
- [ ] Waveform timeline v2 — WaveSurfer continuous regions overlay
- [ ] Accessibility audit — WCAG AA, ARIA live regions, full keyboard nav
- [ ] Theme system — multiple themes, system preference sync
- [x] ~~Theme system — 6 themes (Gruvbox, Midnight, Nord, Solarized, Rosé Pine, Catppuccin)~~
**📦 Ecosystem**
- [ ] Plugin SDK for third-party TTS engines (ElevenLabs, XTTS, Bark, Fish)
+8
View File
@@ -175,6 +175,14 @@ function App() {
// via the store's `partialize`; active project / voice ids stay transient.
const uiScale = useAppStore(s => s.uiScale);
const setUiScale = useAppStore(s => s.setUiScale);
const theme = useAppStore(s => s.theme);
// Hydrate the theme on mount so that persisted preference takes effect.
useEffect(() => {
if (theme && theme !== 'gruvbox') {
document.documentElement.setAttribute('data-theme', theme);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const mode = useAppStore(s => s.mode);
const setMode = useAppStore(s => s.setMode);
const [navRailSide, setNavRailSide] = useState(() => {
+26
View File
@@ -83,6 +83,32 @@
.logs-footer__scale {
margin-right: 2px;
}
/* Theme color dots */
.logs-footer__themes {
display: flex;
align-items: center;
gap: 4px;
}
.logs-footer__theme-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--dot-color, #888);
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
padding: 0;
}
.logs-footer__theme-dot:hover {
transform: scale(1.25);
box-shadow: 0 0 6px color-mix(in srgb, var(--dot-color, #888) 50%, transparent);
}
.logs-footer__theme-dot.is-active {
border-color: var(--dot-color, #888);
box-shadow: 0 0 8px color-mix(in srgb, var(--dot-color, #888) 40%, transparent);
transform: scale(1.15);
}
.logs-footer__toggle {
background: none;
border: none;
+33
View File
@@ -87,6 +87,37 @@ function UiScaleToggle() {
);
}
const THEMES = [
{ id: 'gruvbox', label: 'Gruvbox', dot: '#d3869b' },
{ id: 'midnight', label: 'Midnight', dot: '#8b5cf6' },
{ id: 'nord', label: 'Nord', dot: '#88c0d0' },
{ id: 'solarized', label: 'Solarized', dot: '#268bd2' },
{ id: 'rose-pine', label: 'Rosé Pine', dot: '#ebbcba' },
{ id: 'catppuccin', label: 'Catppuccin', dot: '#cba6f7' },
];
function ThemePicker() {
const theme = useAppStore(s => s.theme);
const setTheme = useAppStore(s => s.setTheme);
return (
<div className="logs-footer__themes" role="radiogroup" aria-label="Color theme">
{THEMES.map(t => (
<button
key={t.id}
type="button"
className={`logs-footer__theme-dot ${theme === t.id ? 'is-active' : ''}`}
style={{ '--dot-color': t.dot }}
onClick={() => setTheme(t.id)}
title={t.label}
aria-label={`${t.label} theme`}
aria-checked={theme === t.id}
role="radio"
/>
))}
</div>
);
}
function SourcePill({ source, counts, active, onClick }) {
const hasErrors = counts.error > 0;
const hasWarns = counts.warn > 0;
@@ -322,6 +353,8 @@ export default function LogsFooter() {
<div className="logs-footer__left">
<UiScaleToggle />
<span className="logs-footer__divider" />
<ThemePicker />
<span className="logs-footer__divider" />
<button
type="button"
className="logs-footer__toggle"
+1
View File
@@ -64,6 +64,7 @@ export const useAppStore = create<AppStore>()(
isSidebarProjectsCollapsed: s.isSidebarProjectsCollapsed,
sidebarTab: s.sidebarTab,
uiScale: s.uiScale,
theme: s.theme,
// Generate-tab prefs — users expect their synthesis knobs to stick.
language: s.language,
speed: s.speed,
+15
View File
@@ -9,6 +9,7 @@
import type { StateCreator } from 'zustand';
export type TranslateQuality = 'fast' | 'cinematic';
export type ThemeId = 'gruvbox' | 'midnight' | 'nord' | 'solarized' | 'rose-pine' | 'catppuccin';
export interface PrefsSlice {
translateQuality: TranslateQuality;
@@ -27,6 +28,9 @@ export interface PrefsSlice {
setBurnSubs: (on: boolean) => void;
setGlossaryVisible: (on: boolean) => void;
setReviewMode: (mode: 'on' | 'off') => void;
theme: ThemeId;
setTheme: (id: ThemeId) => void;
}
export const createPrefsSlice: StateCreator<PrefsSlice, [], [], PrefsSlice> = (set) => ({
@@ -41,4 +45,15 @@ export const createPrefsSlice: StateCreator<PrefsSlice, [], [], PrefsSlice> = (s
setBurnSubs: (on) => set({ burnSubs: on }),
setGlossaryVisible: (on) => set({ glossaryVisible: on }),
setReviewMode: (mode) => set({ reviewMode: mode }),
theme: 'gruvbox',
setTheme: (id) => {
set({ theme: id });
// Apply to DOM — gruvbox is default (no attribute)
if (id === 'gruvbox') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', id);
}
},
});
+1
View File
@@ -9,6 +9,7 @@
// ─────────────────────────────────────────────────────────────────
import './tokens.css';
import './themes.css';
export { default as Button } from './Button.jsx';
export { default as Panel } from './Panel.jsx';
+186
View File
@@ -0,0 +1,186 @@
/* ────────────────────────────────────────────────────────────────────
OmniVoice theme definitions.
Each theme overrides the semantic tokens defined in tokens.css.
The active theme is set via a `data-theme` attribute on <html>.
Default (no attribute) = Gruvbox Dark.
Usage:
document.documentElement.setAttribute('data-theme', 'midnight');
──────────────────────────────────────────────────────────────────── */
/* ── Midnight Blue ─────────────────────────────────────────────────── */
[data-theme="midnight"] {
--color-fg: #e2e8f0;
--color-fg-muted: #94a3b8;
--color-fg-subtle: #64748b;
--color-fg-inverse: #0f172a;
--color-bg: #0f172a;
--color-bg-elev-1: rgba(30, 41, 59, 0.85);
--color-bg-elev-2: rgba(15, 23, 42, 0.40);
--color-bg-elev-3: rgba(30, 41, 59, 0.30);
--color-border: rgba(148, 163, 184, 0.10);
--color-border-strong: rgba(148, 163, 184, 0.20);
--color-border-warm: rgba(139, 92, 246, 0.12);
--color-brand: #8b5cf6;
--color-brand-hover: #7c3aed;
--color-brand-glow: rgba(139, 92, 246, 0.35);
--color-accent: #f59e0b;
--color-success: #10b981;
--color-warn: #f97316;
--color-danger: #ef4444;
--color-info: #3b82f6;
/* Chrome overrides for components using legacy vars */
--chrome-bg: #1e293b;
--chrome-fg: #e2e8f0;
--chrome-fg-muted: #94a3b8;
--chrome-fg-dim: #475569;
--chrome-border: #334155;
}
/* ── Nord ──────────────────────────────────────────────────────────── */
[data-theme="nord"] {
--color-fg: #eceff4;
--color-fg-muted: #d8dee9;
--color-fg-subtle: #4c566a;
--color-fg-inverse: #2e3440;
--color-bg: #2e3440;
--color-bg-elev-1: rgba(59, 66, 82, 0.85);
--color-bg-elev-2: rgba(46, 52, 64, 0.40);
--color-bg-elev-3: rgba(59, 66, 82, 0.25);
--color-border: rgba(216, 222, 233, 0.08);
--color-border-strong: rgba(216, 222, 233, 0.15);
--color-border-warm: rgba(136, 192, 208, 0.10);
--color-brand: #88c0d0;
--color-brand-hover: #81a1c1;
--color-brand-glow: rgba(136, 192, 208, 0.3);
--color-accent: #ebcb8b;
--color-success: #a3be8c;
--color-warn: #d08770;
--color-danger: #bf616a;
--color-info: #5e81ac;
--chrome-bg: #3b4252;
--chrome-fg: #eceff4;
--chrome-fg-muted: #d8dee9;
--chrome-fg-dim: #4c566a;
--chrome-border: #434c5e;
}
/* ── Solarized Dark ────────────────────────────────────────────────── */
[data-theme="solarized"] {
--color-fg: #839496;
--color-fg-muted: #657b83;
--color-fg-subtle: #586e75;
--color-fg-inverse: #fdf6e3;
--color-bg: #002b36;
--color-bg-elev-1: rgba(7, 54, 66, 0.90);
--color-bg-elev-2: rgba(0, 43, 54, 0.40);
--color-bg-elev-3: rgba(7, 54, 66, 0.25);
--color-border: rgba(131, 148, 150, 0.10);
--color-border-strong: rgba(131, 148, 150, 0.18);
--color-border-warm: rgba(181, 137, 0, 0.10);
--color-brand: #268bd2;
--color-brand-hover: #2aa198;
--color-brand-glow: rgba(38, 139, 210, 0.3);
--color-accent: #b58900;
--color-success: #859900;
--color-warn: #cb4b16;
--color-danger: #dc322f;
--color-info: #6c71c4;
--chrome-bg: #073642;
--chrome-fg: #93a1a1;
--chrome-fg-muted: #657b83;
--chrome-fg-dim: #586e75;
--chrome-border: #073642;
}
/* ── Rose Pine ─────────────────────────────────────────────────────── */
[data-theme="rose-pine"] {
--color-fg: #e0def4;
--color-fg-muted: #908caa;
--color-fg-subtle: #6e6a86;
--color-fg-inverse: #191724;
--color-bg: #191724;
--color-bg-elev-1: rgba(30, 27, 42, 0.90);
--color-bg-elev-2: rgba(25, 23, 36, 0.40);
--color-bg-elev-3: rgba(38, 35, 58, 0.30);
--color-border: rgba(144, 140, 170, 0.10);
--color-border-strong: rgba(144, 140, 170, 0.18);
--color-border-warm: rgba(235, 188, 186, 0.10);
--color-brand: #ebbcba;
--color-brand-hover: #eb6f92;
--color-brand-glow: rgba(235, 188, 186, 0.3);
--color-accent: #f6c177;
--color-success: #9ccfd8;
--color-warn: #f6c177;
--color-danger: #eb6f92;
--color-info: #c4a7e7;
--chrome-bg: #1f1d2e;
--chrome-fg: #e0def4;
--chrome-fg-muted: #908caa;
--chrome-fg-dim: #6e6a86;
--chrome-border: #26233a;
}
/* ── Catppuccin Mocha ──────────────────────────────────────────────── */
[data-theme="catppuccin"] {
--color-fg: #cdd6f4;
--color-fg-muted: #a6adc8;
--color-fg-subtle: #6c7086;
--color-fg-inverse: #1e1e2e;
--color-bg: #1e1e2e;
--color-bg-elev-1: rgba(49, 50, 68, 0.85);
--color-bg-elev-2: rgba(30, 30, 46, 0.40);
--color-bg-elev-3: rgba(49, 50, 68, 0.25);
--color-border: rgba(166, 173, 200, 0.08);
--color-border-strong: rgba(166, 173, 200, 0.16);
--color-border-warm: rgba(245, 194, 231, 0.10);
--color-brand: #cba6f7;
--color-brand-hover: #b4befe;
--color-brand-glow: rgba(203, 166, 247, 0.3);
--color-accent: #f9e2af;
--color-success: #a6e3a1;
--color-warn: #fab387;
--color-danger: #f38ba8;
--color-info: #89b4fa;
--chrome-bg: #313244;
--chrome-fg: #cdd6f4;
--chrome-fg-muted: #a6adc8;
--chrome-fg-dim: #585b70;
--chrome-border: #45475a;
}
/* ── System-preference sync ────────────────────────────────────────── */
/* When theme is "auto", the light theme activates on light-mode OS.
Currently we don't ship a light theme; this is scaffolding for
community contributions. */
@media (prefers-color-scheme: light) {
[data-theme="auto"] {
/* Future light theme tokens go here */
}
}