diff --git a/src/components/ResizablePanel.jsx b/src/components/ResizablePanel.jsx
index f3a8769..6b5f3d3 100644
--- a/src/components/ResizablePanel.jsx
+++ b/src/components/ResizablePanel.jsx
@@ -51,7 +51,7 @@ export function ResizablePanel({ children, title, initialHeight = 440, minHeight
📊 {title}
diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx
new file mode 100644
index 0000000..c7cbc8c
--- /dev/null
+++ b/src/components/ThemeToggle.jsx
@@ -0,0 +1,55 @@
+import React, { useState, useEffect } from 'react';
+import { Moon, Sun, Monitor } from 'lucide-react';
+
+const themes = ['system', 'light', 'dark'];
+
+function applyTheme(theme) {
+ const root = document.documentElement;
+ const prefersDark = window.matchMedia
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
+ : false;
+ if (theme === 'dark' || (theme === 'system' && prefersDark)) {
+ root.classList.add('dark');
+ } else {
+ root.classList.remove('dark');
+ }
+}
+
+export function ThemeToggle({ className = '' }) {
+ const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'system');
+
+ useEffect(() => {
+ applyTheme(theme);
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+
+ useEffect(() => {
+ const media = window.matchMedia
+ ? window.matchMedia('(prefers-color-scheme: dark)')
+ : null;
+ const listener = () => theme === 'system' && applyTheme('system');
+ media?.addEventListener('change', listener);
+ return () => media?.removeEventListener('change', listener);
+ }, [theme]);
+
+ const cycleTheme = () => {
+ const next = themes[(themes.indexOf(theme) + 1) % themes.length];
+ setTheme(next);
+ };
+
+ const icons = {
+ system:
,
+ light:
,
+ dark:
,
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/__tests__/ThemeToggle.test.jsx b/src/components/__tests__/ThemeToggle.test.jsx
new file mode 100644
index 0000000..9a430c5
--- /dev/null
+++ b/src/components/__tests__/ThemeToggle.test.jsx
@@ -0,0 +1,35 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ThemeToggle } from '../ThemeToggle';
+
+describe('ThemeToggle', () => {
+ beforeEach(() => {
+ window.matchMedia = vi.fn().mockImplementation(() => ({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ localStorage.clear();
+ document.documentElement.classList.remove('dark');
+ });
+
+ it('cycles through system, light, and dark themes', () => {
+ render(
);
+ const button = screen.getByRole('button', { name: '切换主题' });
+
+ // system mode should follow matchMedia (dark)
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+
+ // light mode
+ fireEvent.click(button);
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+
+ // dark mode
+ fireEvent.click(button);
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+
+ // back to system (still dark)
+ fireEvent.click(button);
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+});
diff --git a/src/index.css b/src/index.css
index 3d38cda..8a0cb92 100644
--- a/src/index.css
+++ b/src/index.css
@@ -4,6 +4,9 @@
/* Accessibility improvements */
@layer base {
+ body {
+ @apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
+ }
/* Screen reader only content */
.sr-only {
position: absolute;
@@ -62,11 +65,42 @@
}
}
-/* Custom styles */
-.chart-container {
- position: relative;
- height: 440px;
- width: 100%;
+@layer components {
+ .card {
+ @apply bg-white dark:bg-gray-800 rounded-lg shadow-md p-3;
+ }
+
+ .card-title {
+ @apply text-base font-semibold text-gray-800 dark:text-gray-100;
+ }
+
+ .input-field {
+ @apply w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none dark:bg-gray-700 dark:text-gray-100;
+ }
+
+ .checkbox {
+ @apply rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
+ }
+
+ .radio {
+ @apply text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
+ }
+
+ .chart-panel {
+ @apply relative bg-white dark:bg-gray-800 rounded-lg shadow-md;
+ }
+
+ .drag-area {
+ @apply border-2 border-dashed border-gray-300 dark:border-gray-600;
+ }
+
+ .drag-area.dragover {
+ @apply border-blue-500 bg-blue-50 dark:bg-blue-900;
+ }
+
+ .resizable-chart-container {
+ @apply relative min-h-[200px] max-h-[600px] resize-y overflow-hidden border border-gray-200 dark:border-gray-700 rounded-lg;
+ }
}
/* Custom styles */
@@ -76,23 +110,6 @@
width: 100%;
}
-.chart-panel {
- position: relative;
- background: white;
- border-radius: 8px;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
-}
-
-.resizable-chart-container {
- position: relative;
- min-height: 200px;
- max-height: 600px;
- resize: vertical;
- overflow: hidden;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
-}
-
.resize-handle {
position: absolute;
bottom: 0;
@@ -114,22 +131,6 @@
outline-offset: 2px;
}
-.chart-panel {
- position: relative;
- background: white;
- border-radius: 8px;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
-}
-
-.drag-area {
- border: 2px dashed #e2e8f0;
-}
-
-.drag-area.dragover {
- border-color: #3b82f6;
- background-color: #dbeafe;
-}
-
/* Enhanced focus indicators for interactive elements */
button:focus,
input:focus,
diff --git a/src/main.jsx b/src/main.jsx
index b9a1a6d..9c8b975 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,10 +1,18 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.jsx'
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import App from './App.jsx';
+
+const theme = localStorage.getItem('theme') || 'system';
+const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+if (theme === 'dark' || (theme === 'system' && prefersDark)) {
+ document.documentElement.classList.add('dark');
+} else {
+ document.documentElement.classList.remove('dark');
+}
createRoot(document.getElementById('root')).render(
,
-)
+);
diff --git a/tailwind.config.js b/tailwind.config.js
index dca8ba0..6fa4d26 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
+ darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
diff --git a/vite.config.js b/vite.config.js
index a4038f7..98e9017 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -15,6 +15,7 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
+ setupFiles: ['./vitest.setup.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
diff --git a/vitest.setup.js b/vitest.setup.js
new file mode 100644
index 0000000..2d004af
--- /dev/null
+++ b/vitest.setup.js
@@ -0,0 +1,17 @@
+import { vi } from 'vitest';
+
+if (!window.matchMedia) {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+}