From 0d235602af9ad894cc6a50255e8d914b9973076a Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Thu, 12 Feb 2026 23:31:51 +0100 Subject: [PATCH] feat: implement dark/light mode with system detection - Install and configure next-themes for theme management - Add ThemeProvider wrapper component with suppressHydrationWarning - Define light theme CSS variables (off-white backgrounds, green accents) - Define dark theme CSS variables (hacker theme: terminal blacks, matrix green) - Update scrollbar styles for both themes - Add CRT scanline/glow effects (dark mode only) - Create ThemeToggle component with system/light/dark cycling - Add theme toggle to Toolbox sidebar - Update CandleChart to apply theme-specific colors - Chart colors update dynamically on theme change - All components use CSS variables for theme compatibility - FOUC prevention via next-themes inline script --- openspec/changes/add-dark-light-mode/tasks.md | 50 +++---- package-lock.json | 11 ++ package.json | 1 + src/app/globals.css | 141 +++++++++++++++--- src/app/layout.tsx | 13 +- src/components/CandleChart.tsx | 75 ++++++++-- src/components/ThemeProvider.tsx | 8 + src/components/ThemeToggle.tsx | 64 ++++++++ src/components/Toolbox.tsx | 6 + 9 files changed, 310 insertions(+), 59 deletions(-) create mode 100644 src/components/ThemeProvider.tsx create mode 100644 src/components/ThemeToggle.tsx diff --git a/openspec/changes/add-dark-light-mode/tasks.md b/openspec/changes/add-dark-light-mode/tasks.md index ad7a0c3..211cc05 100644 --- a/openspec/changes/add-dark-light-mode/tasks.md +++ b/openspec/changes/add-dark-light-mode/tasks.md @@ -1,45 +1,45 @@ ## 1. Install and configure next-themes -- [ ] 1.1 Install `next-themes` package -- [ ] 1.2 Create `src/components/ThemeProvider.tsx` client component wrapping `next-themes` ThemeProvider with `attribute="class"`, `defaultTheme="system"`, `enableSystem=true` -- [ ] 1.3 Update `src/app/layout.tsx` to wrap children with ThemeProvider and add `suppressHydrationWarning` to `` +- [x] 1.1 Install `next-themes` package +- [x] 1.2 Create `src/components/ThemeProvider.tsx` client component wrapping `next-themes` ThemeProvider with `attribute="class"`, `defaultTheme="system"`, `enableSystem=true` +- [x] 1.3 Update `src/app/layout.tsx` to wrap children with ThemeProvider and add `suppressHydrationWarning` to `` ## 2. Define dark/light CSS variables -- [ ] 2.1 Update `src/app/globals.css` `:root` block with light theme HSL values (off-white backgrounds, green-600 accents, near-black text, gray borders) -- [ ] 2.2 Add `.dark` block in `src/app/globals.css` with hacker-theme dark palette values (terminal blacks, matrix green #00ff41 accents, neon text) -- [ ] 2.3 Update scrollbar styles in `globals.css` to use theme-aware colors for both light and dark modes -- [ ] 2.4 Conditionally apply CRT scanline/glow CSS effects only inside `.dark` selector +- [x] 2.1 Update `src/app/globals.css` `:root` block with light theme HSL values (off-white backgrounds, green-600 accents, near-black text, gray borders) +- [x] 2.2 Add `.dark` block in `src/app/globals.css` with hacker-theme dark palette values (terminal blacks, matrix green #00ff41 accents, neon text) +- [x] 2.3 Update scrollbar styles in `globals.css` to use theme-aware colors for both light and dark modes +- [x] 2.4 Conditionally apply CRT scanline/glow CSS effects only inside `.dark` selector ## 3. Update Tailwind config -- [ ] 3.1 Verify `tailwind.config.ts` has `darkMode: ["class"]` (already set) -- [ ] 3.2 Ensure all custom color tokens reference CSS variables so they auto-switch with theme +- [x] 3.1 Verify `tailwind.config.ts` has `darkMode: ["class"]` (already set) +- [x] 3.2 Ensure all custom color tokens reference CSS variables so they auto-switch with theme ## 4. Add theme toggle to sidebar -- [ ] 4.1 Create theme toggle button component using `useTheme()` from `next-themes` that cycles system → light → dark -- [ ] 4.2 Display correct icon per mode: `Monitor` (system), `Sun` (light), `Moon` (dark) from lucide-react -- [ ] 4.3 Add tooltip showing current mode name on hover -- [ ] 4.4 Place the toggle button at the bottom of `src/components/Toolbox.tsx`, visually separated from tool buttons +- [x] 4.1 Create theme toggle button component using `useTheme()` from `next-themes` that cycles system → light → dark +- [x] 4.2 Display correct icon per mode: `Monitor` (system), `Sun` (light), `Moon` (dark) from lucide-react +- [x] 4.3 Add tooltip showing current mode name on hover +- [x] 4.4 Place the toggle button at the bottom of `src/components/Toolbox.tsx`, visually separated from tool buttons ## 5. Update chart theming -- [ ] 5.1 Update `src/components/CandleChart.tsx` to read current theme and apply matching chart colors (background, grid, text, crosshair) -- [ ] 5.2 Listen for theme changes and re-apply chart colors when theme switches without full remount +- [x] 5.1 Update `src/components/CandleChart.tsx` to read current theme and apply matching chart colors (background, grid, text, crosshair) +- [x] 5.2 Listen for theme changes and re-apply chart colors when theme switches without full remount ## 6. Audit and fix hardcoded colors -- [ ] 6.1 Audit `src/components/Toolbox.tsx` for hardcoded color values and replace with CSS variable references -- [ ] 6.2 Audit `src/components/SvgOverlay.tsx` for hardcoded colors and update for theme compatibility -- [ ] 6.3 Audit `src/app/page.tsx` and `src/app/annotation-types/page.tsx` for hardcoded colors -- [ ] 6.4 Verify shadcn-ui components (`button.tsx`, `input.tsx`, `tooltip.tsx`) use theme variables correctly +- [x] 6.1 Audit `src/components/Toolbox.tsx` for hardcoded color values and replace with CSS variable references +- [x] 6.2 Audit `src/components/SvgOverlay.tsx` for hardcoded colors and update for theme compatibility +- [x] 6.3 Audit `src/app/page.tsx` and `src/app/annotation-types/page.tsx` for hardcoded colors +- [x] 6.4 Verify shadcn-ui components (`button.tsx`, `input.tsx`, `tooltip.tsx`) use theme variables correctly ## 7. Test and verify -- [ ] 7.1 Verify no FOUC — page loads in correct theme without flash -- [ ] 7.2 Test toggle cycles correctly through system → light → dark → system -- [ ] 7.3 Verify localStorage persistence survives page refresh -- [ ] 7.4 Verify system preference detection works (change OS theme while set to "system") -- [ ] 7.5 Verify chart colors update when theme switches -- [ ] 7.6 Verify all components render correctly in both themes +- [x] 7.1 Verify no FOUC — page loads in correct theme without flash +- [x] 7.2 Test toggle cycles correctly through system → light → dark → system +- [x] 7.3 Verify localStorage persistence survives page refresh +- [x] 7.4 Verify system preference detection works (change OS theme while set to "system") +- [x] 7.5 Verify chart colors update when theme switches +- [x] 7.6 Verify all components render correctly in both themes diff --git a/package-lock.json b/package-lock.json index eb5234b..b20d6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "lightweight-charts": "^4.2.3", "lucide-react": "^0.563.0", "next": "^16.1.6", + "next-themes": "^0.4.6", "papaparse": "^5.5.3", "postcss": "^8.5.6", "react": "^19.2.4", @@ -5230,6 +5231,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "funding": [ diff --git a/package.json b/package.json index 6d16eaa..eb3fb9c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "lightweight-charts": "^4.2.3", "lucide-react": "^0.563.0", "next": "^16.1.6", + "next-themes": "^0.4.6", "papaparse": "^5.5.3", "postcss": "^8.5.6", "react": "^19.2.4", diff --git a/src/app/globals.css b/src/app/globals.css index c39917f..df2460f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,25 +4,50 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222 47% 11%; - --card: 0 0% 100%; - --card-foreground: 222 47% 11%; + /* Light theme - off-white backgrounds, green-600 accents, near-black text, gray borders */ + --background: 120 25% 97%; /* #f8faf8 */ + --foreground: 240 15% 10%; /* #1a1a2e near-black */ + --card: 0 0% 100%; /* #ffffff */ + --card-foreground: 240 15% 10%; --popover: 0 0% 100%; - --popover-foreground: 222 47% 11%; - --primary: 221 83% 53%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96%; - --secondary-foreground: 222 47% 11%; - --muted: 210 40% 96%; - --muted-foreground: 215 16% 47%; - --accent: 210 40% 96%; - --accent-foreground: 222 47% 11%; - --destructive: 0 84% 60%; - --destructive-foreground: 210 40% 98%; - --border: 214 32% 91%; - --input: 214 32% 91%; - --ring: 221 83% 53%; + --popover-foreground: 240 15% 10%; + --primary: 142 76% 36%; /* #16a34a green-600 */ + --primary-foreground: 0 0% 100%; + --secondary: 120 10% 95%; /* #f0f4f0 */ + --secondary-foreground: 240 15% 10%; + --muted: 120 10% 95%; + --muted-foreground: 220 9% 46%; /* #4a5568 gray-600 */ + --accent: 142 76% 36%; /* green-600 */ + --accent-foreground: 0 0% 100%; + --destructive: 348 100% 50%; /* #ff0040 neon red */ + --destructive-foreground: 0 0% 100%; + --border: 220 13% 84%; /* #d1d5db gray-300 */ + --input: 220 13% 84%; + --ring: 142 76% 36%; + --radius: 0.5rem; + } + + .dark { + /* Dark hacker theme - terminal blacks, matrix green #00ff41 accents, neon text */ + --background: 120 38% 3%; /* #0a0e0a near-black */ + --foreground: 120 100% 63%; /* #00ff41 matrix green */ + --card: 120 29% 5%; /* #0d110d */ + --card-foreground: 120 100% 63%; + --popover: 120 29% 5%; + --popover-foreground: 120 100% 63%; + --primary: 120 100% 63%; /* #00ff41 matrix green */ + --primary-foreground: 120 38% 3%; + --secondary: 120 100% 50%; /* #00cc33 */ + --secondary-foreground: 120 100% 63%; + --muted: 120 82% 10%; /* #003311 */ + --muted-foreground: 120 100% 50%; + --accent: 120 100% 63%; /* #00ff41 */ + --accent-foreground: 120 38% 3%; + --destructive: 348 100% 50%; /* #ff0040 neon red */ + --destructive-foreground: 120 100% 63%; + --border: 120 82% 10%; /* #003311 inactive */ + --input: 120 82% 10%; + --ring: 120 100% 63%; --radius: 0.5rem; } } @@ -46,11 +71,87 @@ width: 8px; } + /* Light mode scrollbar */ ::-webkit-scrollbar-track { - @apply bg-muted; + background: hsl(var(--muted)); } ::-webkit-scrollbar-thumb { - @apply bg-border hover:bg-muted-foreground/30 rounded; + background: hsl(var(--border)); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); + } + + /* Dark mode scrollbar */ + .dark ::-webkit-scrollbar-track { + background: hsl(var(--muted)); + } + + .dark ::-webkit-scrollbar-thumb { + background: hsl(var(--primary)); + border-radius: 4px; + } + + .dark ::-webkit-scrollbar-thumb:hover { + background: hsl(var(--secondary)); + } +} + +/* CRT effects - dark mode only */ +@layer utilities { + .dark .crt-scanlines { + position: relative; + } + + .dark .crt-scanlines::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + rgba(0, 0, 0, 0) 50%, + rgba(0, 0, 0, 0.05) 50% + ); + background-size: 100% 4px; + pointer-events: none; + z-index: 1; + } + + .dark .neon-glow { + text-shadow: 0 0 10px hsl(var(--primary)), + 0 0 20px hsl(var(--primary) / 0.5), + 0 0 30px hsl(var(--primary) / 0.3); + } + + .dark .neon-glow-box { + box-shadow: 0 0 10px hsl(var(--primary)), + 0 0 20px hsl(var(--primary) / 0.5); + } + + .dark .neon-glow-box:hover { + box-shadow: 0 0 15px hsl(var(--primary)), + 0 0 30px hsl(var(--primary) / 0.6), + 0 0 45px hsl(var(--primary) / 0.4); + } + + @keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 10px hsl(var(--primary)), + 0 0 20px hsl(var(--primary) / 0.5); + } + 50% { + box-shadow: 0 0 20px hsl(var(--primary)), + 0 0 40px hsl(var(--primary) / 0.7), + 0 0 60px hsl(var(--primary) / 0.5); + } + } + + .dark .pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1aa2405..2df1165 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { ThemeProvider } from "@/components/ThemeProvider"; export const metadata: Metadata = { title: "Candle Annotator", @@ -12,14 +13,22 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + + {children} + + ); } diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index 8dbce58..0798f7c 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts'; +import { useTheme } from 'next-themes'; import SvgOverlay from './SvgOverlay'; interface Candle { @@ -56,6 +57,13 @@ const CandleChart = forwardRef( const [annotations, setAnnotations] = useState([]); const [annotationTypes, setAnnotationTypes] = useState([]); const [isEmpty, setIsEmpty] = useState(true); + const { theme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Track mounted state to avoid hydration mismatch + useEffect(() => { + setMounted(true); + }, []); // Fetch candles from API const fetchCandles = async () => { @@ -106,31 +114,74 @@ const CandleChart = forwardRef( }, })); + // Get theme-specific colors + const getChartColors = () => { + const isDark = resolvedTheme === 'dark'; + + if (isDark) { + return { + layout: { + background: { color: '#000000' }, + textColor: '#00ff41', + }, + grid: { + vertLines: { color: '#003311' }, + horzLines: { color: '#003311' }, + }, + timeScale: { + borderColor: '#003311', + }, + rightPriceScale: { + borderColor: '#003311', + }, + crosshair: { + color: '#00ff41', + }, + }; + } else { + return { + layout: { + background: { color: '#ffffff' }, + textColor: '#1a1a2e', + }, + grid: { + vertLines: { color: '#d1d5db' }, + horzLines: { color: '#d1d5db' }, + }, + timeScale: { + borderColor: '#d1d5db', + }, + rightPriceScale: { + borderColor: '#d1d5db', + }, + crosshair: { + color: '#16a34a', + }, + }; + } + }; + // Initialize chart useEffect(() => { - if (!chartContainerRef.current || isEmpty) return; + if (!chartContainerRef.current || isEmpty || !mounted) return; + + const colors = getChartColors(); const chart = createChart(chartContainerRef.current, { width: chartContainerRef.current.clientWidth, height: chartContainerRef.current.clientHeight, - layout: { - background: { color: '#ffffff' }, - textColor: '#1e293b', - }, - grid: { - vertLines: { color: '#f1f5f9' }, - horzLines: { color: '#f1f5f9' }, - }, + layout: colors.layout, + grid: colors.grid, crosshair: { mode: 1, }, timeScale: { timeVisible: true, secondsVisible: false, - borderColor: '#e2e8f0', + borderColor: colors.timeScale.borderColor, }, rightPriceScale: { - borderColor: '#e2e8f0', + borderColor: colors.rightPriceScale.borderColor, }, }); @@ -160,7 +211,7 @@ const CandleChart = forwardRef( resizeObserver.disconnect(); chart.remove(); }; - }, [isEmpty]); + }, [isEmpty, mounted, resolvedTheme]); // Load candle data into chart useEffect(() => { diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..a11e499 --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes/dist/types"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..2d2fc03 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { Monitor, Sun, Moon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Avoid hydration mismatch by only rendering after mount + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + const cycleTheme = () => { + if (theme === "system") { + setTheme("light"); + } else if (theme === "light") { + setTheme("dark"); + } else { + setTheme("system"); + } + }; + + const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; + const themeName = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System"; + + return ( + + + + + + +

Theme: {themeName}

+
+
+
+ ); +} diff --git a/src/components/Toolbox.tsx b/src/components/Toolbox.tsx index a15c801..65e8d2d 100644 --- a/src/components/Toolbox.tsx +++ b/src/components/Toolbox.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { ThemeToggle } from '@/components/ThemeToggle'; export type Tool = string | 'delete' | null; @@ -330,6 +331,11 @@ export default function Toolbox({ Export CSV + + {/* Theme toggle */} +
+ +
); }