feat(theme): Implement Aspirant theme switcher with DesignSwitch component

This commit is contained in:
fullsizemalt 2026-01-08 00:46:48 -08:00
parent 3023155fde
commit 63d0e4ee2d
6 changed files with 181 additions and 14 deletions

View file

@ -1,7 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Merriweather:wght@300;400;700;900&display=swap"
rel="stylesheet">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#10b981" />

View file

@ -0,0 +1,21 @@
import { useTheme } from '../context/ThemeContext';
import { Palette } from 'lucide-react';
export default function DesignSwitch() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'default' ? 'aspirant' : 'default')}
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors duration-fast text-sm font-medium
${theme === 'aspirant'
? 'text-[var(--color-primary)] bg-[var(--color-primary-soft)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]'
}`}
title="Switch Design Theme"
>
<Palette size={16} />
<span className="hidden sm:inline text-xs">{theme === 'aspirant' ? 'Aspirant' : 'Classic'}</span>
</button>
);
}

View file

@ -14,6 +14,7 @@ import { Breadcrumbs } from './ui/Breadcrumbs';
import { pageVariants } from '../lib/animations';
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import DesignSwitch from './DesignSwitch';
import { UserMenu } from './layout/UserMenu';
import { NotificationBell } from './notifications/NotificationBell';
@ -91,6 +92,7 @@ export default function Layout() {
</div>
</div>
<DesignSwitch />
<ThemeToggle />
<NotificationBell />
<button className="lg:hidden p-2 text-[var(--color-text-tertiary)]" onClick={() => setMobileSheetOpen(true)}>

View file

@ -0,0 +1,35 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'default' | 'aspirant';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('veridian-theme') as Theme) || 'default';
});
useEffect(() => {
localStorage.setItem('veridian-theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View file

@ -11,7 +11,8 @@
/* ============================================
CSS Custom Properties (Design Tokens)
============================================ */
:root {
:root,
[data-theme="default"] {
/* Font families */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -778,3 +779,105 @@
}
}
}
/* ===== THEME: Aspirant (Agave & Bone) ===== */
[data-theme="aspirant"] {
/* Backgrounds - Bone scale */
--color-bg-primary: #FDFCF8;
--color-bg-secondary: #F5F2EB; /* slightly darker stone */
--color-bg-tertiary: #E6E0D4;
--color-bg-elevated: #F5F2EB;
/* Text - Charcoal */
--color-text-primary: #2C2A26;
--color-text-secondary: #5C554B;
--color-text-tertiary: #847C6F;
--color-text-quaternary: #A8A195;
--color-text-inverse: #FDFCF8;
/* Borders */
--color-border-default: #E6E0D4;
--color-border-subtle: #F5F2EB;
--color-border-strong: #A8A195;
/* Primary (Agave) */
--color-primary: #4B7F79;
--color-primary-hover: #3D6A64;
--color-primary-soft: rgba(75, 127, 121, 0.15);
/* Accent (Gold) */
--color-accent: #E0A96D;
--color-accent-hover: #D49B5C;
--color-accent-soft: rgba(224, 169, 109, 0.15);
/* Status Colors (Earthy) */
--color-success: #6A994E;
--color-warning: #E9C46A;
--color-error: #C78D75;
--color-info: #4B7F79;
/* Chart colors */
--color-chart-green: #4B7F79;
--color-chart-orange: #E0A96D;
--color-chart-blue: #5F8D87;
--color-chart-purple: #8B735B;
--color-chart-gridline: #E6E0D4;
/* Layout override */
--card-radius: 12px;
/* Fonts */
--font-sans: 'Lato', sans-serif;
--font-display: 'Merriweather', serif;
/* shadcn override */
--background: 48 20% 98%;
--foreground: 33 8% 16%;
--card: 48 20% 98%;
--card-foreground: 33 8% 16%;
--primary: 172 26% 40%;
--primary-foreground: 48 20% 98%;
--secondary: 48 20% 94%;
--secondary-foreground: 33 8% 16%;
--muted: 48 20% 94%;
--muted-foreground: 33 8% 40%;
--accent: 33 65% 65%;
--accent-foreground: 48 20% 98%;
--destructive: 18 45% 62%;
--destructive-foreground: 48 20% 98%;
--border: 48 20% 90%;
--input: 48 20% 90%;
--ring: 172 26% 40%;
--radius: 0.75rem;
}
/* Aspirant Dark Mode Override */
[data-theme="aspirant"].dark,
[data-theme="aspirant"] .dark {
/* Backgrounds - Obsidian */
--color-bg-primary: #1A1916;
--color-bg-secondary: #2C2A26;
--color-bg-tertiary: #3D3A35;
--color-bg-elevated: #2C2A26;
/* Text - Bone */
--color-text-primary: #FDFCF8;
--color-text-secondary: #A8A195;
--color-text-tertiary: #847C6F;
--color-text-quaternary: #5C554B;
--color-text-inverse: #1A1916;
/* Borders */
--color-border-default: #3D3A35;
--color-border-subtle: #2C2A26;
--color-border-strong: #4B4842;
/* Primary (Agave preserved) */
--color-primary: #4B7F79;
--color-primary-hover: #5F8D87;
/* shadcn override */
--background: 33 8% 10%;
--foreground: 48 20% 98%;
--card: 33 8% 16%;
--card-foreground: 48 20% 98%;
}

View file

@ -2,12 +2,15 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { ThemeProvider } from './context/ThemeContext'
// Initialize i18n
import './lib/i18n'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>,
)