feat(ui): Implement Dynamic Color system and M3 Forms (Stage 2)

- Integrated @material/material-color-utilities for HCT-based color generation
- Implemented M3 Filled Text Field with floating labels and state layers
- Added Seed Color picker and palette preview to Settings
- Updated Login page with new M3 components
- Enhanced ThemeProvider with system preference listeners

Refs #3
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-11 01:16:36 +03:00
parent db0b93b007
commit b71076450c
11 changed files with 493 additions and 82 deletions

6
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "bonch-md-client",
"version": "0.0.0",
"dependencies": {
"@material/material-color-utilities": "^0.4.0",
"axios": "^1.15.0",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
@@ -900,6 +901,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@material/material-color-utilities": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz",
"integrity": "sha512-dlq6VExJReb8dhjj3a/yTigr3ncNwoFmL5Iy2ENtbDX03EmNeOEdZ+vsaGrj7RTuO+mB7L58II4LCsl4NpM8uw=="
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",

View File

@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@material/material-color-utilities": "^0.4.0",
"axios": "^1.15.0",
"lucide-react": "^1.8.0",
"react": "^19.2.4",

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import {
Calendar,
GraduationCap,
@@ -8,7 +8,8 @@ import {
User,
ChevronDown,
ChevronRight,
Menu
Menu,
LogOut
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { useDisplay } from '../hooks/useDisplay';
@@ -60,9 +61,10 @@ const navItems: NavItem[] = [
export const Navigation: React.FC = () => {
const { navMode } = useDisplay();
const { isRailExpanded, toggleRail, badges } = useStore();
const { isRailExpanded, toggleRail, badges, profile, reset } = useStore();
const scrollDir = useScrollDirection();
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const navigate = useNavigate();
const toggleSubmenu = (label: string) => {
setExpandedItems(prev =>
@@ -70,11 +72,20 @@ export const Navigation: React.FC = () => {
);
};
const handleLogout = () => {
reset();
navigate('/login');
};
const renderBadge = (count?: number) => {
if (!count) return null;
return <span className="m3-badge">{count > 99 ? '99+' : count}</span>;
};
const getInitials = (name: string) => {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
};
if (navMode === 'bar') {
return (
<nav className={`navigation-bar ${scrollDir === 'down' ? 'hidden' : ''}`}>
@@ -106,6 +117,18 @@ export const Navigation: React.FC = () => {
{isExpanded && <span className="rail-title">Bonch</span>}
</div>
{isExpanded && profile && (
<div className="user-info">
<div className="user-avatar">
{getInitials(profile.fullName)}
</div>
<div className="user-details">
<div className="user-name">{profile.fullName}</div>
<div className="user-group">{profile.group || 'БЕЗ ГРУППЫ'}</div>
</div>
</div>
)}
<div className="rail-items">
{navItems.map((item) => {
const hasChildren = item.children && item.children.length > 0;
@@ -151,6 +174,15 @@ export const Navigation: React.FC = () => {
);
})}
</div>
{isExpanded && (
<div style={{ marginTop: 'auto', padding: '12px' }}>
<button onClick={handleLogout} className="m3-button" style={{ width: '100%', justifyContent: 'flex-start' }}>
<LogOut size={20} style={{ marginRight: '12px' }} />
Выход
</button>
</div>
)}
</nav>
);
};

View File

@@ -0,0 +1,56 @@
import React, { useState } from 'react';
interface TextFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
supportingText?: string;
}
export const TextField: React.FC<TextFieldProps> = ({
label,
error,
supportingText,
value,
onFocus,
onBlur,
required,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const isFilled = value !== undefined && value !== '';
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
onFocus?.(e);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
onBlur?.(e);
};
return (
<div className={`m3-text-field-container ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${isFilled ? 'filled' : ''}`}>
<div className="m3-text-field-field">
<input
{...props}
value={value}
onFocus={handleFocus}
onBlur={handleBlur}
className="m3-text-field-input"
placeholder=" " // Keep empty for :placeholder-shown trick
/>
<label className="m3-text-field-label">
{label}{required && ' *'}
</label>
<div className="m3-text-field-active-indicator" />
<div className="m3-text-field-state-layer" />
</div>
{(error || supportingText) && (
<div className="m3-text-field-supporting-text">
{error ? `Ошибка: ${error}` : supportingText}
</div>
)}
</div>
);
};

View File

@@ -1,27 +1,48 @@
import React, { useEffect } from 'react';
import { useStore } from '../store/useStore';
import { generateM3Scheme, applyM3CSSVars } from '../theme/dynamicTheme';
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { theme, seedColor } = useStore();
useEffect(() => {
const root = document.documentElement;
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
// Set Theme Class
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
const scheme = generateM3Scheme(seedColor, isDark);
applyM3CSSVars(scheme);
const root = document.documentElement;
if (isDark) {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.remove('dark');
root.style.colorScheme = 'light';
}
}, [theme, seedColor]);
// Set Primary Color (Seed)
root.style.setProperty('--md-sys-color-primary-base', seedColor);
// Simplification: In a real app we'd use material-color-utilities here.
// For now we just use the seed color as primary.
root.style.setProperty('--md-sys-color-primary', seedColor);
// Handle system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const isDark = mediaQuery.matches;
const scheme = generateM3Scheme(seedColor, isDark);
applyM3CSSVars(scheme);
const root = document.documentElement;
if (isDark) {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.remove('dark');
root.style.colorScheme = 'light';
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, seedColor]);
return <>{children}</>;

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useStore } from '../store/useStore';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { TextField } from '../components/TextField';
export const Login: React.FC = () => {
const { apiDomain, apiKey, isMockMode, setApiDomain, setApiKey, setMockMode } = useStore();
@@ -15,75 +16,73 @@ export const Login: React.FC = () => {
setError('');
try {
// Basic validation
if (!isMockMode && (!apiDomain || !apiKey)) {
throw new Error('Please fill all fields');
throw new Error('Заполните все поля');
}
if (isMockMode && !apiKey) {
setApiKey('mock-session');
}
// Check health
await api.checkHealth();
// If health check passes, try to get profile
await api.getProfile();
navigate('/profile');
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Connection failed');
setError(err.response?.data?.message || err.message || 'Ошибка подключения');
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: '400px', margin: '100px auto', padding: '20px' }}>
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>Bonch Client</h1>
<div style={{ maxWidth: '400px', margin: '100px auto', padding: '24px' }}>
<h1 style={{ marginBottom: '32px', textAlign: 'center', fontWeight: 400 }}>Bonch Client</h1>
<form onSubmit={handleLogin}>
<input
type="text"
className="m3-text-field"
placeholder="API Domain (e.g. https://api.example.com)"
<TextField
label="API Домен"
value={apiDomain}
onChange={(e) => setApiDomain(e.target.value)}
disabled={isMockMode}
required={!isMockMode}
supportingText="Например: http://localhost:3000"
/>
<input
<TextField
type="password"
className="m3-text-field"
placeholder="API Key"
label="API Ключ"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isMockMode}
required={!isMockMode}
/>
<div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center' }}>
<div style={{ marginBottom: '24px', display: 'flex', alignItems: 'center', gap: '12px', padding: '0 4px' }}>
<input
type="checkbox"
id="mockMode"
checked={isMockMode}
onChange={(e) => setMockMode(e.target.checked)}
style={{ marginRight: '8px' }}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<label htmlFor="mockMode" style={{ fontSize: '14px', cursor: 'pointer' }}>
Enable Mock Mode (UI Only)
<label htmlFor="mockMode" style={{ fontSize: '14px', cursor: 'pointer', userSelect: 'none' }}>
Режим отладки (UI Only)
</label>
</div>
{error && (
<p style={{ color: 'var(--md-sys-color-error)', marginBottom: '16px' }}>
<p style={{ color: 'var(--md-sys-color-error)', marginBottom: '16px', fontSize: '14px' }}>
{error}
</p>
)}
<button
type="submit"
className="m3-button m3-button-filled"
style={{ width: '100%' }}
style={{ width: '100%', height: '48px' }}
disabled={loading}
>
{loading ? 'Connecting...' : 'Login'}
{loading ? 'Подключение...' : 'Войти'}
</button>
</form>
</div>

View File

@@ -3,11 +3,13 @@ import { api } from '../api/client';
import type { Profile as ProfileType } from '../types/api';
import { useNavigate, Link } from 'react-router-dom';
import { User, Mail, GraduationCap, Users, Calendar, GraduationCap as GradesIcon, MessageSquare, Settings } from 'lucide-react';
import { useStore } from '../store/useStore';
export const Profile: React.FC = () => {
const [profile, setProfile] = useState<ProfileType | null>(null);
const [profileData, setProfileData] = useState<ProfileType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { setProfile } = useStore();
const navigate = useNavigate();
useEffect(() => {
@@ -15,6 +17,7 @@ export const Profile: React.FC = () => {
try {
const data = await api.getProfile();
setProfile(data);
setProfileData(data);
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Failed to load profile');
if (err.response?.status === 401) {
@@ -26,7 +29,7 @@ export const Profile: React.FC = () => {
};
fetchProfile();
}, [navigate]);
}, [navigate, setProfile]);
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading profile...</div>;
if (error) return (
@@ -45,42 +48,42 @@ export const Profile: React.FC = () => {
<h1>Profile</h1>
</div>
{profile && (
{profileData && (
<div className="m3-card">
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<User size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
<div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>Full Name</div>
<div style={{ fontSize: '18px', fontWeight: 500 }}>{profile.fullName}</div>
<div style={{ fontSize: '18px', fontWeight: 500 }}>{profileData.fullName}</div>
</div>
</div>
{profile.email && (
{profileData.email && (
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<Mail size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
<div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>Email</div>
<div>{profile.email}</div>
<div>{profileData.email}</div>
</div>
</div>
)}
{profile.group && (
{profileData.group && (
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<Users size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
<div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>Group</div>
<div>{profile.group}</div>
<div>{profileData.group}</div>
</div>
</div>
)}
{profile.faculty && (
{profileData.faculty && (
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<GraduationCap size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
<div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>Faculty</div>
<div>{profile.faculty}</div>
<div>{profileData.faculty}</div>
</div>
</div>
)}

View File

@@ -1,9 +1,10 @@
import React from 'react';
import React, { useState } from 'react';
import { useStore } from '../store/useStore';
import { Palette, Moon, Sun, Monitor, LogOut } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { TextField } from '../components/TextField';
const colors = [
const presetColors = [
{ name: 'Blue', value: '#0061a4' },
{ name: 'Red', value: '#ba1a1a' },
{ name: 'Green', value: '#006d3a' },
@@ -13,6 +14,7 @@ const colors = [
export const Settings: React.FC = () => {
const { theme, setTheme, seedColor, setSeedColor, reset } = useStore();
const [customColor, setCustomColor] = useState(seedColor);
const navigate = useNavigate();
const handleLogout = () => {
@@ -20,76 +22,134 @@ export const Settings: React.FC = () => {
navigate('/login');
};
const handleColorChange = (color: string) => {
setCustomColor(color);
if (/^#[0-9A-F]{6}$/i.test(color)) {
setSeedColor(color);
}
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1 style={{ marginBottom: '24px' }}>Settings</h1>
<h1 style={{ marginBottom: '32px' }}>Настройки</h1>
<section style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center' }}>
<Palette size={20} style={{ marginRight: '12px' }} />
Theme
<section style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center', fontWeight: 500 }}>
<Monitor size={20} style={{ marginRight: '12px' }} />
Тема оформления
</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
<button
onClick={() => setTheme('light')}
className={`m3-button m3-button-tonal ${theme === 'light' ? 'active' : ''}`}
style={{ border: theme === 'light' ? '2px solid var(--md-sys-color-primary)' : 'none' }}
style={{
border: theme === 'light' ? '2px solid var(--md-sys-color-primary)' : 'none',
height: '56px'
}}
>
<Sun size={18} style={{ marginRight: '8px' }} />
Light
Светлая
</button>
<button
onClick={() => setTheme('dark')}
className={`m3-button m3-button-tonal ${theme === 'dark' ? 'active' : ''}`}
style={{ border: theme === 'dark' ? '2px solid var(--md-sys-color-primary)' : 'none' }}
style={{
border: theme === 'dark' ? '2px solid var(--md-sys-color-primary)' : 'none',
height: '56px'
}}
>
<Moon size={18} style={{ marginRight: '8px' }} />
Dark
Тёмная
</button>
<button
onClick={() => setTheme('system')}
className={`m3-button m3-button-tonal ${theme === 'system' ? 'active' : ''}`}
style={{ border: theme === 'system' ? '2px solid var(--md-sys-color-primary)' : 'none' }}
style={{
border: theme === 'system' ? '2px solid var(--md-sys-color-primary)' : 'none',
height: '56px'
}}
>
<Monitor size={18} style={{ marginRight: '8px' }} />
System
Системная
</button>
</div>
</section>
<section>
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center' }}>
<section style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center', fontWeight: 500 }}>
<Palette size={20} style={{ marginRight: '12px' }} />
Accent Color
Акцентный цвет (Dynamic Color)
</h2>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
{colors.map((c) => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginBottom: '24px' }}>
{presetColors.map((c) => (
<button
key={c.value}
onClick={() => setSeedColor(c.value)}
onClick={() => handleColorChange(c.value)}
style={{
width: '48px',
height: '48px',
borderRadius: '24px',
backgroundColor: c.value,
border: seedColor === c.value ? '4px solid var(--md-sys-color-on-surface)' : 'none',
border: seedColor.toLowerCase() === c.value.toLowerCase() ? '4px solid var(--md-sys-color-on-surface)' : 'none',
cursor: 'pointer',
transition: 'transform 0.2s',
}}
title={c.name}
/>
))}
<div style={{ position: 'relative', width: '48px', height: '48px' }}>
<input
type="color"
value={seedColor}
onChange={(e) => handleColorChange(e.target.value)}
style={{
width: '100%',
height: '100%',
border: 'none',
padding: 0,
background: 'transparent',
cursor: 'pointer',
}}
/>
</div>
</div>
<TextField
label="HEX код цвета"
value={customColor}
onChange={(e) => handleColorChange(e.target.value)}
placeholder="#0061A4"
/>
<div style={{ marginTop: '16px', padding: '16px', borderRadius: '12px', backgroundColor: 'var(--md-sys-color-surface-variant)' }}>
<div style={{ fontSize: '14px', marginBottom: '12px', fontWeight: 500 }}>Превью палитры:</div>
<div style={{ display: 'flex', gap: '8px' }}>
{['primary', 'primary-container', 'secondary', 'secondary-container', 'tertiary', 'tertiary-container'].map(role => (
<div
key={role}
style={{
flex: 1,
height: '40px',
borderRadius: '8px',
backgroundColor: `var(--md-sys-color-${role})`,
border: '1px solid var(--md-sys-color-outline-variant)'
}}
title={role}
/>
))}
</div>
</div>
</section>
<section style={{ marginTop: '48px', borderTop: '1px solid var(--md-sys-color-outline)', paddingTop: '24px' }}>
<section style={{ marginTop: '48px', borderTop: '1px solid var(--md-sys-color-outline-variant)', paddingTop: '24px' }}>
<button
onClick={handleLogout}
className="m3-button m3-button-tonal"
style={{ color: 'var(--md-sys-color-error)', width: '100%' }}
style={{ color: 'var(--md-sys-color-error)', width: '100%', height: '48px' }}
>
<LogOut size={18} style={{ marginRight: '8px' }} />
Logout from Session
<LogOut size={18} style={{ marginRight: '12px' }} />
Выйти из системы
</button>
</section>
</div>

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Profile } from '../types/api';
interface AppState {
apiDomain: string;
@@ -8,6 +9,7 @@ interface AppState {
theme: 'system' | 'light' | 'dark';
seedColor: string;
isRailExpanded: boolean;
profile: Profile | null;
badges: {
messages: number;
};
@@ -16,6 +18,7 @@ interface AppState {
setMockMode: (isMock: boolean) => void;
setTheme: (theme: 'system' | 'light' | 'dark') => void;
setSeedColor: (color: string) => void;
setProfile: (profile: Profile | null) => void;
toggleRail: () => void;
setRailExpanded: (expanded: boolean) => void;
setBadge: (key: keyof AppState['badges'], count: number) => void;
@@ -31,20 +34,22 @@ export const useStore = create<AppState>()(
theme: 'system',
seedColor: '#0061a4',
isRailExpanded: true,
profile: null,
badges: {
messages: 3, // Mock value
messages: 3,
},
setApiDomain: (apiDomain) => set({ apiDomain }),
setApiKey: (apiKey) => set({ apiKey }),
setMockMode: (isMockMode) => set({ isMockMode }),
setTheme: (theme) => set({ theme }),
setSeedColor: (seedColor) => set({ seedColor }),
setProfile: (profile) => set({ profile }),
toggleRail: () => set((state) => ({ isRailExpanded: !state.isRailExpanded })),
setRailExpanded: (isRailExpanded) => set({ isRailExpanded }),
setBadge: (key, count) => set((state) => ({
badges: { ...state.badges, [key]: count }
})),
reset: () => set({ apiKey: '' }),
reset: () => set({ apiKey: '', profile: null }),
}),
{
name: 'bonch-md-storage',

View File

@@ -12,7 +12,11 @@
--md-sys-color-surface-variant: #dfe2eb;
--md-sys-color-on-surface-variant: #43474e;
--md-sys-color-outline: #73777f;
--md-sys-color-outline-variant: #c3c7cf;
--md-sys-color-error: #ba1a1a;
--md-sys-color-on-error: #ffffff;
--md-sys-color-background: #fdfcff;
--md-sys-color-on-background: #1a1c1e;
--nav-rail-width: 80px;
--nav-rail-expanded-width: 280px;
@@ -33,7 +37,11 @@
--md-sys-color-surface-variant: #43474e;
--md-sys-color-on-surface-variant: #c3c7cf;
--md-sys-color-outline: #8d9199;
--md-sys-color-outline-variant: #43474e;
--md-sys-color-error: #ffb4ab;
--md-sys-color-on-error: #690005;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e2e2e6;
}
* {
@@ -44,8 +52,8 @@
body {
font-family: 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif;
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
line-height: 1.5;
transition: background-color 0.3s, color 0.3s;
}
@@ -57,7 +65,7 @@ body {
top: -4px;
right: -4px;
background-color: var(--md-sys-color-error);
color: white;
color: var(--md-sys-color-on-error);
font-size: 10px;
font-weight: 500;
min-width: 16px;
@@ -80,10 +88,13 @@ body {
border: none;
color: var(--md-sys-color-on-surface-variant);
transition: background-color 0.2s;
cursor: pointer;
position: relative;
overflow: hidden;
}
.icon-button:hover {
background-color: var(--md-sys-color-surface-variant);
background-color: rgba(var(--md-sys-color-on-surface-variant-rgb, 67, 71, 78), 0.08);
}
.m3-button {
@@ -97,10 +108,11 @@ body {
font-weight: 500;
font-size: 14px;
letter-spacing: 0.1px;
transition: box-shadow 0.2s, background-color 0.2s;
transition: all 0.2s;
background-color: transparent;
color: var(--md-sys-color-primary);
text-decoration: none;
cursor: pointer;
}
.m3-button-filled {
@@ -108,11 +120,20 @@ body {
color: var(--md-sys-color-on-primary);
}
.m3-button-filled:hover {
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
}
.m3-button-tonal {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.m3-button-outlined {
border: 1px solid var(--md-sys-color-outline);
color: var(--md-sys-color-primary);
}
.m3-card {
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
@@ -121,16 +142,98 @@ body {
margin-bottom: 16px;
}
.m3-text-field {
/* M3 Filled Text Field */
.m3-text-field-container {
display: flex;
flex-direction: column;
margin-bottom: 16px;
width: 100%;
padding: 12px 16px;
border-radius: 4px 4px 0 0;
border: none;
border-bottom: 1px solid var(--md-sys-color-outline);
}
.m3-text-field-field {
position: relative;
height: 56px;
background-color: var(--md-sys-color-surface-variant);
border-radius: 4px 4px 0 0;
overflow: hidden;
}
.m3-text-field-input {
width: 100%;
height: 100%;
padding: 24px 16px 8px;
border: none;
background: transparent;
color: var(--md-sys-color-on-surface);
font-size: 16px;
outline: none;
z-index: 1;
}
.m3-text-field-label {
position: absolute;
left: 16px;
top: 16px;
color: var(--md-sys-color-on-surface-variant);
font-size: 16px;
margin-bottom: 16px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 2;
}
.m3-text-field-container.focused .m3-text-field-label,
.m3-text-field-container.filled .m3-text-field-label {
top: 8px;
font-size: 12px;
color: var(--md-sys-color-primary);
}
.m3-text-field-active-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--md-sys-color-on-surface-variant);
transition: height 0.2s, background-color 0.2s;
}
.m3-text-field-container.focused .m3-text-field-active-indicator {
height: 2px;
background-color: var(--md-sys-color-primary);
}
.m3-text-field-state-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--md-sys-color-on-surface);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.m3-text-field-field:hover .m3-text-field-state-layer {
opacity: 0.08;
}
.m3-text-field-container.error .m3-text-field-label,
.m3-text-field-container.error .m3-text-field-active-indicator {
color: var(--md-sys-color-error);
background-color: var(--md-sys-color-error);
}
.m3-text-field-supporting-text {
font-size: 12px;
padding: 4px 16px 0;
color: var(--md-sys-color-on-surface-variant);
}
.m3-text-field-container.error .m3-text-field-supporting-text {
color: var(--md-sys-color-error);
}
/* Layout */
@@ -191,7 +294,7 @@ body {
display: flex;
flex-direction: column;
padding: 16px 0;
border-right: 1px solid var(--md-sys-color-outline);
border-right: 1px solid var(--md-sys-color-outline-variant);
z-index: 100;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
@@ -216,6 +319,46 @@ body {
white-space: nowrap;
}
.user-info {
display: flex;
align-items: center;
padding: 16px;
gap: 12px;
margin-bottom: 8px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
overflow: hidden;
}
.user-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-group {
font-size: 12px;
opacity: 0.7;
}
.rail-items {
display: flex;
flex-direction: column;

85
src/theme/dynamicTheme.ts Normal file
View File

@@ -0,0 +1,85 @@
import {
argbFromHex,
hexFromArgb,
SchemeTonalSpot,
Hct,
MaterialDynamicColors,
DynamicScheme,
} from '@material/material-color-utilities';
export interface M3ColorScheme {
primary: string;
onPrimary: string;
primaryContainer: string;
onPrimaryContainer: string;
secondary: string;
onSecondary: string;
secondaryContainer: string;
onSecondaryContainer: string;
tertiary: string;
onTertiary: string;
tertiaryContainer: string;
onTertiaryContainer: string;
surface: string;
onSurface: string;
surfaceVariant: string;
onSurfaceVariant: string;
background: string;
onBackground: string;
error: string;
onError: string;
errorContainer: string;
onErrorContainer: string;
outline: string;
outlineVariant: string;
}
export function generateM3Scheme(
seedHex: string,
isDark: boolean,
contrastLevel = 0.0
): M3ColorScheme {
const sourceColorArgb = argbFromHex(seedHex);
const sourceHct = Hct.fromInt(sourceColorArgb);
// SchemeTonalSpot is the standard M3 scheme
const scheme: DynamicScheme = new SchemeTonalSpot(sourceHct, isDark, contrastLevel);
const resolve = (role: any) => hexFromArgb(role.getArgb(scheme));
return {
primary: resolve(MaterialDynamicColors.primary),
onPrimary: resolve(MaterialDynamicColors.onPrimary),
primaryContainer: resolve(MaterialDynamicColors.primaryContainer),
onPrimaryContainer: resolve(MaterialDynamicColors.onPrimaryContainer),
secondary: resolve(MaterialDynamicColors.secondary),
onSecondary: resolve(MaterialDynamicColors.onSecondary),
secondaryContainer: resolve(MaterialDynamicColors.secondaryContainer),
onSecondaryContainer: resolve(MaterialDynamicColors.onSecondaryContainer),
tertiary: resolve(MaterialDynamicColors.tertiary),
onTertiary: resolve(MaterialDynamicColors.onTertiary),
tertiaryContainer: resolve(MaterialDynamicColors.tertiaryContainer),
onTertiaryContainer: resolve(MaterialDynamicColors.onTertiaryContainer),
surface: resolve(MaterialDynamicColors.surface),
onSurface: resolve(MaterialDynamicColors.onSurface),
surfaceVariant: resolve(MaterialDynamicColors.surfaceVariant),
onSurfaceVariant: resolve(MaterialDynamicColors.onSurfaceVariant),
background: resolve(MaterialDynamicColors.background),
onBackground: resolve(MaterialDynamicColors.onBackground),
error: resolve(MaterialDynamicColors.error),
onError: resolve(MaterialDynamicColors.onError),
errorContainer: resolve(MaterialDynamicColors.errorContainer),
onErrorContainer: resolve(MaterialDynamicColors.onErrorContainer),
outline: resolve(MaterialDynamicColors.outline),
outlineVariant: resolve(MaterialDynamicColors.outlineVariant),
};
}
export function applyM3CSSVars(scheme: M3ColorScheme): void {
const root = document.documentElement;
Object.entries(scheme).forEach(([key, value]) => {
// camelCase → kebab-case
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
root.style.setProperty(`--md-sys-color-${cssKey}`, value);
});
}