diff --git a/src/assets/js/theme.js b/src/assets/js/theme.js new file mode 100644 index 0000000..74d2ef1 --- /dev/null +++ b/src/assets/js/theme.js @@ -0,0 +1,51 @@ +// Theme Management Utility + +const THEME_KEY = 'color-theme'; +const DARK_CLASS = 'dark'; + +function getPreferredTheme() { + // Check local storage first + const storedTheme = localStorage.getItem(THEME_KEY); + if (storedTheme) return storedTheme; + + // Then check system preference + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; +} + +function applyTheme(theme) { + const isDark = theme === 'dark'; + + // Toggle dark class on document element + document.documentElement.classList.toggle(DARK_CLASS, isDark); + + // Store preference + localStorage.setItem(THEME_KEY, theme); +} + +function toggleTheme() { + const currentTheme = getPreferredTheme(); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + applyTheme(newTheme); +} + +// Initialize theme on page load +document.addEventListener('DOMContentLoaded', () => { + const preferredTheme = getPreferredTheme(); + applyTheme(preferredTheme); + + // Optional: Add theme toggle button event listener + const themeToggle = document.getElementById('theme-toggle'); + if (themeToggle) { + themeToggle.addEventListener('click', toggleTheme); + } +}); + +// Listen for system theme changes +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + const newTheme = e.matches ? 'dark' : 'light'; + applyTheme(newTheme); +}); + +export { applyTheme, toggleTheme, getPreferredTheme }; \ No newline at end of file diff --git a/src/tailwind.css b/src/tailwind.css index 72f6497..13bbcb7 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -2,206 +2,31 @@ @tailwind components; @tailwind utilities; -@layer base { - a { - @apply text-twilight-800; - } - a:hover { - @apply underline underline-offset-4; - } - p { - @apply mb-4; - } - h1, - h2, - h3 { - @apply text-pink-800; - } - h1 { - @apply text-3xl mb-8 font-bold; - } - h2 { - @apply text-2xl font-semibold; - } - - h3 { - @apply text-xl font-medium; - } +:root { + color-scheme: light dark; + scroll-behavior: smooth; } -@layer components { - /* BUTTONS */ - .btn { - @apply py-2 px-5 rounded block no-underline mb-2 hover:no-underline w-full text-center; - } - .btn.secondary { - @apply border border-pink-800 hover:bg-pink-700 text-pink-800 hover:text-white; - } - .btn.primary { - @apply border border-pink-800 bg-pink-800 hover:bg-pink-700 hover:border-pink-700 text-white; - } - - /*** HEADER LINKS ***/ - .nav-link, - .account-link { - @apply hover:no-underline text-xl sm:text-base md:text-lg lg:text-xl block rounded-md py-2 text-pink-800; - } - .nav-link.active, - .account-link.active { - @apply decoration-twilight-700 underline decoration-4 underline-offset-6 hover:underline; - } - - /*** MOBILE MENU ***/ - .mobile-menu { - @apply pt-20 block absolute top-full w-full h-screen bg-white left-0; - } - .mobile-menu li { - @apply text-center; - } - - /* COMPLETED LESSONS/HOMEWORK */ - .done { - @apply opacity-25; - } - - /* AUTH FORMS */ - .auth-form { - @apply mt-5 max-w-sm w-full mx-auto gap-2 bg-white; - } - .auth-form label { - @apply w-full block text-base uppercase; - } - .auth-form input { - @apply w-full mb-3 border-stone-300; - } - .auth-form button { - @apply mt-2; - } - - /* ADD/EDIT FORMS (ADMIN ONLY) */ - .add-form { - @apply mt-5 max-w-5xl mx-auto border grid p-10 gap-2 items-center grid-cols-[repeat(4,_1fr)]; - } - .add-form label { - @apply text-right; - } - #timestamps, - #hw-items, - #pw-items { - @apply mt-3 w-full border px-5 py-3; - } - .timestamp, - .hw-item, - .pw-item { - @apply grid grid-cols-4 gap-2 items-center py-3 border-t border-dashed border-gray-700; - grid-template-columns: repeat(4, 1fr); - } - .timestamp:first-of-type, - .hw-item:first-of-type, - .pw-item:first-of-type { - @apply border-0; - } - - /* FLASH MESSAGES */ - .flash { - @apply w-full py-2 px-4 mb-4 border text-center font-bold relative; - } - .flash-success { - @apply bg-green-100 text-green-700 border-green-700; - } - .flash-error { - @apply bg-red-100 text-red-700 border-red-700; - } - .flash-info { - @apply bg-blue-100 text-blue-700 border-blue-700; - } - .flash-close { - @apply absolute right-3 cursor-pointer text-3xl; - top: 0; - } - - /*** FAQ ACCORDION ***/ - dl { - @apply bg-white py-2 xs:py-4 px-2 xs:px-5 border border-stone-200; - } - dt { - @apply bg-twilight-50 cursor-pointer pt-4 pb-3 px-6 w-full outline-0 transition border border-twilight-300 flex items-center; - } - dt h3 { - @apply text-twilight-900 mr-auto; - } - dt:last-of-type { - @apply mb-0; - } - - dt.acc-opened, - dt:hover { - @apply bg-twilight-100; - } - dt:after { - @apply text-2xl font-bold font-awesome text-pink-800; - content: "\2b"; /* plus sign */ - } - - dt.acc-opened:after { - content: "\f068"; /* minus sign */ - } - - dd { - @apply px-6 bg-stone-50 max-h-0 overflow-hidden opacity-0 mb-2 border border-stone-200; - transition: 0.4s ease-in-out; - } - dd > *:last-child { - @apply mb-0; - } - - dd.acc-opened { - @apply opacity-100 py-6 mt-1; - max-height: 2500px; - } - - dd ul { - @apply list-disc ml-6; - } - - dd ol { - @apply mt-3 ml-8 list-decimal; - } - - /*** RESOURCE PAGE CARDS ***/ - - #card-container { - @apply flex flex-wrap gap-3 md:gap-5 justify-center; - } - .card { - @apply border border-twilight-200 bg-white w-full max-w-[400px] xs:w-[300px] md:w-[350px] flex flex-col drop-shadow-[0_0_7px_rgba(34,101,129,0.1)] hover:drop-shadow-[0_0_7px_rgba(34,101,129,0.25)] overflow-hidden; - } - .card:hover { - } - .card a { - @apply block h-full no-underline text-center; - } - .card h2 { - @apply xs:text-xl md:text-[1.75rem] px-8 pt-10; - } - .card span { - @apply text-twilight-900 px-8 pb-10 block; - } - .card img { - @apply shadow-[0_2px_10px_0_rgba(0,0,0,0.1)]; - } +/* Dark mode base styles */ +.dark { + color-scheme: dark; + background-color: theme('colors.dark.background'); + color: theme('colors.dark.text.primary'); +} - /********* temporary ************/ - #video { - max-width: 960px; - } - #player { - @apply w-full aspect-video; - } +/* Smooth theme transition */ +body { + transition-property: background-color, color; + transition-duration: 300ms; + transition-timing-function: ease-in-out; } -@layer utilities { - .stripe-rows { - @apply [&>:nth-of-type(even)]:bg-stone-200; - } +/* Base link styles for dark mode */ +.dark a { + color: theme('colors.dark.accent.DEFAULT'); + transition: color 300ms ease-in-out; } + +.dark a:hover { + color: theme('colors.dark.accent.variant'); +} \ No newline at end of file diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 255708f..cc3ffd5 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,47 +1,97 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./src/views/**/*.pug", "./src/assets/js/*.js"], - theme: { - screens: { - xs: "640px", - sm: "780px", - md: "926px", - lg: "1024px", - xl: "1280px", - "2xl": "1536px", - }, - extend: { - fontFamily: { - logo: ["Orienta", "serif"], - main: ["Poppins", "sans-serif"], - awesome: ['"Font Awesome 6 Free"'], - }, - colors: { - twilight: { - 50: "#f1f8fa", - 100: "#E8F3F7", - 200: "#bfe0ee", - 300: "#9ed0e5", - 400: "#7ec0dd", - 500: "#5eb1d4", - 600: "#3ea1cc", - 700: "#2e86ab", - 800: "#226581", - 900: "#153f51", - }, - }, - textUnderlineOffset: { - 6: "6px", - }, - textDecorationThickness: { - 3: "3px", - }, - maxWidth: { - "1/3": "33%", - 80: "20rem", - }, - }, - }, - plugins: [require("@tailwindcss/forms")], - safelist: ["flash-success", "flash-error", "flash-info"], -}; + content: ["./src/views/**/*.pug", "./src/assets/js/*.js"], + darkMode: 'class', // Enable dark mode via a class + theme: { + screens: { + xs: "640px", + sm: "780px", + md: "926px", + lg: "1024px", + xl: "1280px", + "2xl": "1536px", + }, + extend: { + fontFamily: { + logo: ["Orienta", "serif"], + main: ["Poppins", "sans-serif"], + awesome: ['"Font Awesome 6 Free"'], + }, + colors: { + // Extend existing twilight palette with dark mode variants + twilight: { + 50: "#f1f8fa", + 100: "#E8F3F7", + 200: "#bfe0ee", + 300: "#9ed0e5", + 400: "#7ec0dd", + 500: "#5eb1d4", + 600: "#3ea1cc", + 700: "#2e86ab", + 800: "#226581", + 900: "#153f51", + }, + // Dark mode semantic color tokens + dark: { + // Background colors + background: { + DEFAULT: "#121212", + surface: "#1E1E1E", + elevated: "#2C2C2C", + }, + // Text colors + text: { + primary: "#E0E0E0", + secondary: "#A0A0A0", + disabled: "#6E6E6E", + }, + // Accent and interaction colors + accent: { + DEFAULT: "#BB86FC", + variant: "#3700B3", + }, + // Status colors + error: { + DEFAULT: "#CF6679", + light: "#FFB4AB", + }, + success: { + DEFAULT: "#4CAF50", + light: "#81C784", + }, + warning: { + DEFAULT: "#FFC107", + light: "#FFD54F", + }, + }, + }, + textUnderlineOffset: { + 6: "6px", + }, + textDecorationThickness: { + 3: "3px", + }, + maxWidth: { + "1/3": "33%", + 80: "20rem", + }, + // Add transitions for smooth theme switching + transitionProperty: { + 'theme': 'background-color, color, border-color, text-decoration-color, fill, stroke', + }, + transitionDuration: { + 'theme': '300ms', + }, + }, + }, + plugins: [require("@tailwindcss/forms")], + safelist: [ + "flash-success", + "flash-error", + "flash-info", + // Add dark mode related classes to safelist + "dark:bg-dark-background", + "dark:text-dark-text-primary", + "dark:bg-dark-background-surface" + ], +}; \ No newline at end of file diff --git a/tests/theme.test.js b/tests/theme.test.js new file mode 100644 index 0000000..3f72df6 --- /dev/null +++ b/tests/theme.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { applyTheme, toggleTheme, getPreferredTheme } from '../src/assets/js/theme.js'; + +describe('Theme Management', () => { + // Mocking localStorage + const localStorageMock = (() => { + let store = {}; + return { + getItem: (key) => store[key] || null, + setItem: (key, value) => store[key] = value.toString(), + clear: () => store = {} + }; + })(); + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock + }); + + beforeEach(() => { + localStorageMock.clear(); + document.documentElement.classList.remove('dark'); + }); + + afterEach(() => { + document.documentElement.classList.remove('dark'); + }); + + it('should get default theme based on system preference', () => { + const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = getPreferredTheme(); + expect(theme).toBe(defaultDark ? 'dark' : 'light'); + }); + + it('should apply dark theme correctly', () => { + applyTheme('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(localStorage.getItem('color-theme')).toBe('dark'); + }); + + it('should apply light theme correctly', () => { + applyTheme('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(localStorage.getItem('color-theme')).toBe('light'); + }); + + it('should toggle theme between light and dark', () => { + const initialTheme = getPreferredTheme(); + toggleTheme(); + const newTheme = getPreferredTheme(); + expect(newTheme).toBe(initialTheme === 'dark' ? 'light' : 'dark'); + }); + + it('should remember theme preference in localStorage', () => { + applyTheme('dark'); + expect(localStorage.getItem('color-theme')).toBe('dark'); + + applyTheme('light'); + expect(localStorage.getItem('color-theme')).toBe('light'); + }); +}); \ No newline at end of file