feat: 更新嵌入式文件系统逻辑,支持路径清理和静态资源请求处理,增强前端主题切换能力

This commit is contained in:
ryan
2026-03-11 13:18:32 +08:00
parent 6eea676f8f
commit ad090c9c04
26 changed files with 465 additions and 81 deletions
+14 -5
View File
@@ -6,18 +6,27 @@
}
html {
background: var(--surface-canvas);
}
html[data-theme='light'] {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 30%),
linear-gradient(180deg, rgba(8, 17, 31, 0.98), rgba(8, 17, 31, 1));
background: var(--body-background);
color: var(--foreground-primary);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
transition:
background 0.2s ease,
color 0.2s ease;
}
a {
@@ -30,6 +39,6 @@ button {
}
::selection {
background: rgba(56, 189, 248, 0.32);
color: #f8fafc;
background: var(--selection-background);
color: var(--selection-foreground);
}
+5
View File
@@ -1,7 +1,9 @@
import type { Metadata } from 'next';
import Script from 'next/script';
import type { ReactNode } from 'react';
import { AppProviders } from '@/components/providers/app-providers';
import { getThemeInitScript } from '@/lib/theme/theme';
import './globals.css';
@@ -22,6 +24,9 @@ export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning>
<body>
<Script id='theme-init' strategy='beforeInteractive'>
{getThemeInitScript()}
</Script>
<AppProviders>{children}</AppProviders>
</body>
</html>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function NotFound() {
<AppCard title='页面不存在' description='当前请求的页面尚未接入新版管理端路由。'>
<div className='space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
<Link href='/' className='inline-flex rounded-full bg-sky-400 px-4 py-2 font-medium text-slate-950 transition hover:bg-sky-300'>
<Link href='/' className='inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90'>
返回总览
</Link>
</div>
@@ -5,8 +5,8 @@ interface EmptyStateProps {
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className='rounded-2xl border border-dashed border-white/10 bg-white/5 px-5 py-6 text-sm'>
<p className='text-base font-semibold text-white'>{title}</p>
<div className='rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm'>
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{title}</p>
<p className='mt-2 leading-6 text-[var(--foreground-secondary)]'>{description}</p>
</div>
);
@@ -5,9 +5,9 @@ interface ErrorStateProps {
export function ErrorState({ title, description }: ErrorStateProps) {
return (
<div className='rounded-2xl border border-rose-400/20 bg-rose-500/10 px-5 py-6 text-sm'>
<p className='text-base font-semibold text-rose-100'>{title}</p>
<p className='mt-2 leading-6 text-rose-100/80'>{description}</p>
<div className='rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm'>
<p className='text-base font-semibold text-[var(--status-danger-foreground)]'>{title}</p>
<p className='mt-2 leading-6 text-[var(--status-danger-foreground)]/80'>{description}</p>
</div>
);
}
@@ -21,7 +21,7 @@ export function FeaturePlaceholder({
<ul className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
{milestones.map((item) => (
<li key={item} className='flex gap-3'>
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-sky-400' />
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]' />
<span>{item}</span>
</li>
))}
@@ -7,9 +7,9 @@ interface LoadingStateProps {
export function LoadingState({ className }: LoadingStateProps) {
return (
<div className={cn('space-y-3', className)}>
<div className='h-4 w-32 animate-pulse rounded bg-white/10' />
<div className='h-24 animate-pulse rounded-2xl bg-white/5' />
<div className='h-24 animate-pulse rounded-2xl bg-white/5' />
<div className='h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]' />
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' />
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' />
</div>
);
}
@@ -21,13 +21,13 @@ export function DashboardSidebar() {
)}
>
<div className='flex h-full flex-col gap-6'>
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-white/5 px-3 py-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-400/15 text-lg font-semibold text-sky-200'>
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-lg font-semibold text-[var(--brand-primary)]'>
AF
</div>
{!isSidebarCollapsed ? (
<div>
<p className='text-sm font-semibold text-white'>ATSFlare</p>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p>
<p className='text-xs text-[var(--foreground-secondary)]'>控制面新版工程</p>
</div>
) : null}
@@ -44,11 +44,11 @@ export function DashboardSidebar() {
className={cn(
'flex items-start gap-3 rounded-2xl border px-3 py-3 transition-colors',
active
? 'border-sky-300/30 bg-sky-400/10 text-white'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-white/5 hover:text-white',
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
)}
>
<span className='mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/5 text-xs font-semibold'>
<span className='mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-xs font-semibold'>
{item.shortLabel.slice(0, 1)}
</span>
{!isSidebarCollapsed ? (
@@ -65,7 +65,7 @@ export function DashboardSidebar() {
</nav>
{!isSidebarCollapsed ? (
<div className='rounded-2xl border border-emerald-400/15 bg-emerald-500/10 px-4 py-4 text-xs leading-6 text-emerald-100/90'>
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4 text-xs leading-6 text-[var(--status-success-foreground)]'>
阶段 1 已完成目录骨架、静态导出与基础布局初始化。
</div>
) : null}
@@ -1,5 +1,6 @@
'use client';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
@@ -18,20 +19,21 @@ export function DashboardTopbar() {
<button
type='button'
onClick={toggleSidebar}
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-white/5 text-lg text-white transition hover:bg-white/10'
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
aria-label='切换侧边栏'
>
≡
</button>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-[var(--foreground-secondary)]'>当前模块</p>
<h2 className='text-lg font-semibold text-white'>
<h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>
{currentItem?.label ?? 'ATSFlare 控制台'}
</h2>
</div>
</div>
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
<ThemeToggle />
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
静态导出模式
</span>
@@ -6,9 +6,9 @@ interface PageHeaderProps {
export function PageHeader({ title, description }: PageHeaderProps) {
return (
<div className='space-y-3'>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-sky-300'>ATSFlare</p>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
<div className='space-y-2'>
<h1 className='text-3xl font-semibold tracking-tight text-white'>{title}</h1>
<h1 className='text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]'>{title}</h1>
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
{description}
</p>
@@ -1,6 +1,7 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { publicEnv } from '@/lib/env/public-env';
interface PublicShellProps {
@@ -13,13 +14,14 @@ export function PublicShell({ children }: PublicShellProps) {
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'>
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'>
<div>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-sky-300'>ATSFlare</p>
<h1 className='mt-2 text-3xl font-semibold text-white'>管理端改造进行中</h1>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
<h1 className='mt-2 text-3xl font-semibold text-[var(--foreground-primary)]'>管理端改造进行中</h1>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
认证流程将在阶段 2 接入。当前页面用于承载入口骨架与路由占位。
</p>
</div>
<div className='text-sm text-[var(--foreground-secondary)]'>
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
<ThemeToggle />
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
{publicEnv.appVersion}
</span>
@@ -29,7 +31,7 @@ export function PublicShell({ children }: PublicShellProps) {
<div className='space-y-6'>{children}</div>
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
<Link href='/' className='text-sky-300 transition hover:text-sky-200'>
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
返回新版总览
</Link>
</div>
@@ -3,6 +3,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type ReactNode, useState } from 'react';
import { ThemeProvider } from '@/components/providers/theme-provider';
interface AppProvidersProps {
children: ReactNode;
}
@@ -24,5 +26,9 @@ export function AppProviders({ children }: AppProvidersProps) {
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
);
}
@@ -0,0 +1,107 @@
'use client';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
isThemeMode,
resolveTheme,
THEME_MEDIA_QUERY,
THEME_STORAGE_KEY,
type ResolvedTheme,
type ThemeMode,
} from '@/lib/theme/theme';
interface ThemeContextValue {
themeMode: ThemeMode;
resolvedTheme: ResolvedTheme;
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
function getSystemPreference() {
return window.matchMedia(THEME_MEDIA_QUERY).matches;
}
function applyTheme(mode: ThemeMode) {
const resolvedTheme = resolveTheme(mode, getSystemPreference());
const root = document.documentElement;
root.dataset.themeMode = mode;
root.dataset.theme = resolvedTheme;
root.style.colorScheme = resolvedTheme;
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
return resolvedTheme;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark');
useEffect(() => {
const root = document.documentElement;
const domThemeMode = root.dataset.themeMode;
const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system';
const nextResolvedTheme = applyTheme(nextThemeMode);
setThemeModeState(nextThemeMode);
setResolvedTheme(nextResolvedTheme);
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY);
const handleChange = () => {
if (themeMode !== 'system') {
return;
}
setResolvedTheme(applyTheme('system'));
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [themeMode]);
const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode);
setResolvedTheme(applyTheme(mode));
}, []);
const value = useMemo(
() => ({
themeMode,
resolvedTheme,
setThemeMode,
}),
[resolvedTheme, setThemeMode, themeMode],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function AppCard({
{(title || description || action) && (
<header className='flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between'>
<div className='space-y-1'>
{title ? <h2 className='text-lg font-semibold text-white'>{title}</h2> : null}
{title ? <h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>{title}</h2> : null}
{description ? (
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
) : null}
@@ -1,10 +1,14 @@
import { cn } from '@/lib/utils/cn';
const variantClasses = {
success: 'border-emerald-400/25 bg-emerald-500/15 text-emerald-200',
warning: 'border-amber-400/25 bg-amber-500/15 text-amber-200',
danger: 'border-rose-400/25 bg-rose-500/15 text-rose-200',
info: 'border-sky-400/25 bg-sky-500/15 text-sky-200',
success:
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
warning:
'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]',
danger:
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
info:
'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
} as const;
interface StatusBadgeProps {
@@ -0,0 +1,55 @@
'use client';
import { cn } from '@/lib/utils/cn';
import type { ThemeMode } from '@/lib/theme/theme';
import { useTheme } from '@/components/providers/theme-provider';
const themeOptions: Array<{ value: ThemeMode; label: string }> = [
{ value: 'light', label: '浅色' },
{ value: 'system', label: '跟随系统' },
{ value: 'dark', label: '深色' },
];
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border border-[var(--border-default)] bg-[var(--control-background)] p-1 text-xs text-[var(--foreground-secondary)]',
className,
)}
aria-label='主题切换'
>
{themeOptions.map((option) => {
const active = themeMode === option.value;
return (
<button
key={option.value}
type='button'
onClick={() => setThemeMode(option.value)}
className={cn(
'rounded-full px-3 py-1.5 transition-colors',
active
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'hover:bg-[var(--control-background-hover)] hover:text-[var(--foreground-primary)]',
)}
aria-pressed={active}
title={
option.value === 'system'
? `当前跟随系统(${resolvedTheme === 'dark' ? '深色' : '浅色'})`
: `切换到${option.label}`
}
>
{option.label}
</button>
);
})}
</div>
);
}
@@ -31,9 +31,9 @@ export function DashboardOverview() {
{readinessItems.map((item) => (
<div
key={item.title}
className='rounded-2xl border border-[var(--border-default)] bg-white/5 p-4'
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'
>
<p className='text-base font-semibold text-white'>{item.title}</p>
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{item.title}</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
{item.description}
</p>
@@ -49,9 +49,9 @@ export function DashboardOverview() {
<Link
key={item.href}
href={item.href}
className='rounded-2xl border border-[var(--border-default)] bg-white/5 p-4 transition hover:border-sky-300/30 hover:bg-sky-400/10'
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--accent-soft)]'
>
<p className='text-sm font-semibold text-white'>{item.label}</p>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{item.label}</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
{item.description}
</p>
+40
View File
@@ -0,0 +1,40 @@
export const THEME_STORAGE_KEY = 'atsflare-theme-mode';
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
export const themeModes = ['light', 'dark', 'system'] as const;
export type ThemeMode = (typeof themeModes)[number];
export type ResolvedTheme = Exclude<ThemeMode, 'system'>;
export function isThemeMode(value: string | null | undefined): value is ThemeMode {
return themeModes.includes(value as ThemeMode);
}
export function resolveTheme(
mode: ThemeMode,
systemPrefersDark: boolean,
): ResolvedTheme {
if (mode === 'system') {
return systemPrefersDark ? 'dark' : 'light';
}
return mode;
}
export function getThemeInitScript() {
return `(() => {
const storageKey = '${THEME_STORAGE_KEY}';
const mediaQuery = '${THEME_MEDIA_QUERY}';
const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system';
const storedValue = window.localStorage.getItem(storageKey);
const themeMode = isThemeMode(storedValue) ? storedValue : 'system';
const resolvedTheme = themeMode === 'system'
? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light')
: themeMode;
const root = document.documentElement;
root.dataset.themeMode = themeMode;
root.dataset.theme = resolvedTheme;
root.style.colorScheme = resolvedTheme;
})();`;
}
+68 -7
View File
@@ -1,19 +1,80 @@
:root {
--surface-canvas: #eff6ff;
--surface-panel: #ffffff;
--surface-card: rgba(255, 255, 255, 0.92);
--surface-muted: rgba(226, 232, 240, 0.65);
--surface-elevated: rgba(255, 255, 255, 0.78);
--control-background: rgba(15, 23, 42, 0.04);
--control-background-hover: rgba(15, 23, 42, 0.08);
--accent-soft: rgba(14, 165, 233, 0.12);
--border-default: rgba(148, 163, 184, 0.22);
--border-strong: rgba(14, 165, 233, 0.26);
--foreground-primary: #0f172a;
--foreground-secondary: #475569;
--foreground-muted: #64748b;
--foreground-inverse: #f8fafc;
--brand-primary: #0284c7;
--brand-secondary: #6366f1;
--brand-primary-soft: rgba(14, 165, 233, 0.12);
--status-success: #22c55e;
--status-warning: #f59e0b;
--status-danger: #ef4444;
--status-info: #38bdf8;
--status-success-soft: rgba(34, 197, 94, 0.12);
--status-success-border: rgba(34, 197, 94, 0.2);
--status-success-foreground: #166534;
--status-warning-soft: rgba(245, 158, 11, 0.14);
--status-warning-border: rgba(245, 158, 11, 0.22);
--status-warning-foreground: #92400e;
--status-danger-soft: rgba(239, 68, 68, 0.12);
--status-danger-border: rgba(239, 68, 68, 0.2);
--status-danger-foreground: #b91c1c;
--status-info-soft: rgba(56, 189, 248, 0.12);
--status-info-border: rgba(56, 189, 248, 0.22);
--status-info-foreground: #0369a1;
--body-background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.14), transparent 30%),
linear-gradient(180deg, #f8fbff, #eff6ff);
--selection-background: rgba(14, 165, 233, 0.24);
--selection-foreground: #082f49;
--shadow-soft: 0 16px 40px rgba(15, 23, 42, 0.08);
--radius-xl: 1.25rem;
}
:root[data-theme='dark'] {
--surface-canvas: #08111f;
--surface-panel: #0f172a;
--surface-card: #111c33;
--surface-muted: #16243d;
--surface-card: rgba(17, 28, 51, 0.9);
--surface-muted: rgba(255, 255, 255, 0.05);
--surface-elevated: rgba(15, 23, 42, 0.95);
--control-background: rgba(255, 255, 255, 0.05);
--control-background-hover: rgba(255, 255, 255, 0.1);
--accent-soft: rgba(56, 189, 248, 0.12);
--border-default: rgba(148, 163, 184, 0.18);
--border-strong: rgba(125, 211, 252, 0.28);
--foreground-primary: #e2e8f0;
--foreground-secondary: #94a3b8;
--foreground-muted: #64748b;
--foreground-inverse: #082f49;
--brand-primary: #38bdf8;
--brand-secondary: #818cf8;
--status-success: #22c55e;
--status-warning: #f59e0b;
--status-danger: #ef4444;
--status-info: #38bdf8;
--brand-primary-soft: rgba(56, 189, 248, 0.15);
--status-success-soft: rgba(34, 197, 94, 0.15);
--status-success-border: rgba(52, 211, 153, 0.26);
--status-success-foreground: #d1fae5;
--status-warning-soft: rgba(245, 158, 11, 0.15);
--status-warning-border: rgba(251, 191, 36, 0.28);
--status-warning-foreground: #fde68a;
--status-danger-soft: rgba(239, 68, 68, 0.14);
--status-danger-border: rgba(251, 113, 133, 0.24);
--status-danger-foreground: #ffe4e6;
--status-info-soft: rgba(14, 165, 233, 0.14);
--status-info-border: rgba(56, 189, 248, 0.24);
--status-info-foreground: #bae6fd;
--body-background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 30%),
linear-gradient(180deg, rgba(8, 17, 31, 0.98), rgba(8, 17, 31, 1));
--selection-background: rgba(56, 189, 248, 0.32);
--selection-foreground: #f8fafc;
--shadow-soft: 0 16px 40px rgba(8, 15, 31, 0.35);
--radius-xl: 1.25rem;
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isThemeMode, resolveTheme } from '@/lib/theme/theme';
describe('theme utils', () => {
it('validates theme mode values', () => {
expect(isThemeMode('light')).toBe(true);
expect(isThemeMode('dark')).toBe(true);
expect(isThemeMode('system')).toBe(true);
expect(isThemeMode('custom')).toBe(false);
});
it('resolves system mode using system preference', () => {
expect(resolveTheme('system', true)).toBe('dark');
expect(resolveTheme('system', false)).toBe('light');
expect(resolveTheme('dark', false)).toBe('dark');
expect(resolveTheme('light', true)).toBe('light');
});
});