diff --git a/DARK_MODE.md b/DARK_MODE.md new file mode 100644 index 0000000..d36f003 --- /dev/null +++ b/DARK_MODE.md @@ -0,0 +1,211 @@ +# Dark Mode Implementation Guide + +This document describes the dark mode implementation in the Competition Groups application and provides guidance for developers adding dark mode support to new components. + +## Overview + +The application uses Tailwind CSS's class-based dark mode strategy. The theme preference is managed via a React context provider and persisted in localStorage. + +## Architecture + +### Theme Management + +- **Provider**: `UserSettingsProvider` manages the theme state +- **Context**: `UserSettingsContext` provides access to theme settings +- **Hook**: `useUserSettings()` hook for accessing theme in components +- **Storage**: Theme preference is stored in localStorage with the key `competition-groups.{CLIENT_ID}.theme` + +### Theme Options + +1. **Light**: Always use light theme +2. **Dark**: Always use dark theme +3. **System**: Follow the system's color scheme preference + +### Implementation Details + +The `UserSettingsProvider`: + +- Monitors system theme preference via `window.matchMedia('(prefers-color-scheme: dark)')` +- Applies the `dark` class to `document.documentElement` when dark mode is active +- Persists the user's theme choice to localStorage +- Restores the theme on page load + +## Adding Dark Mode to Components + +### Basic Pattern + +Use Tailwind's `dark:` variant to add dark mode styles: + +```tsx +
+

Title

+

Description

+
+``` + +### Common Patterns + +#### Background Colors + +```tsx +// Main backgrounds +className = 'bg-white dark:bg-gray-900'; + +// Card/panel backgrounds +className = 'bg-white dark:bg-gray-800'; + +// Nested/elevated backgrounds +className = 'bg-gray-50 dark:bg-gray-700'; +``` + +#### Text Colors + +```tsx +// Primary text +className = 'text-gray-900 dark:text-white'; + +// Secondary text +className = 'text-gray-600 dark:text-gray-400'; + +// Muted/tertiary text +className = 'text-gray-500 dark:text-gray-500'; +``` + +#### Borders + +```tsx +// Default borders +className = 'border-gray-200 dark:border-gray-700'; + +// Emphasized borders +className = 'border-gray-300 dark:border-gray-600'; +``` + +#### Links + +```tsx +// Primary links +className = 'text-blue-700 dark:text-blue-400'; + +// With underline +className = 'text-blue-700 dark:text-blue-400 underline'; +``` + +#### Interactive States + +```tsx +// Hover +className = 'hover:bg-gray-100 dark:hover:bg-gray-700'; + +// Focus +className = 'focus:ring-blue-500 dark:focus:ring-blue-400'; + +// Active/Selected +className = 'bg-blue-100 dark:bg-blue-900'; +``` + +#### Shadows + +```tsx +// Subtle shadow +className = 'shadow-md dark:shadow-gray-800'; + +// No shadow in dark mode (optional) +className = 'shadow-md dark:shadow-none'; +``` + +## Examples from the Codebase + +### Header Component + +```tsx +
+ + + +
+``` + +### List Item Component + +```tsx +
  • +

    {title}

    +

    {subtitle}

    +
  • +``` + +### Button Component + +```tsx + +``` + +## Testing Dark Mode + +### Manual Testing + +1. Navigate to `/settings` +2. Select "Dark" theme +3. Verify all visible components display correctly +4. Check for: + - Proper contrast between text and backgrounds + - Visible borders and dividers + - Readable link colors + - Appropriate hover states + +### Automated Testing + +When adding dark mode classes to components with snapshot tests, update the snapshots: + +```bash +yarn test -u +``` + +## Color Palette Reference + +### Light Mode + +- Background: `white`, `gray-50` +- Text: `gray-900`, `gray-600`, `gray-500` +- Borders: `gray-200`, `gray-300` +- Links: `blue-700` + +### Dark Mode + +- Background: `gray-900`, `gray-800`, `gray-700` +- Text: `white`, `gray-400`, `gray-500` +- Borders: `gray-700`, `gray-600` +- Links: `blue-400` + +## Best Practices + +1. **Always pair background and text colors**: When you change a background to dark, update text colors for contrast +2. **Test with both themes**: Always check that your component looks good in both light and dark mode +3. **Use semantic color scales**: Stick to the established gray and blue scales for consistency +4. **Consider interactive states**: Don't forget to add dark mode variants for hover, focus, and active states +5. **Update tests**: Remember to update snapshot tests when adding dark mode classes + +## Accessibility + +- Maintain WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text) +- Test with high contrast mode enabled +- Ensure focus indicators are visible in both themes + +## Future Enhancements + +Potential improvements to the dark mode system: + +- Add more theme options (e.g., high contrast, custom themes) +- Implement per-page theme overrides +- Add transition animations when switching themes +- Create a theme preview component +- Add keyboard shortcuts for theme switching diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 7a0a0a8..8890b03 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -81,7 +81,7 @@ define(['./workbox-5357ef54'], function (workbox) { [ { url: 'index.html', - revision: '0.3rdphr8mv9', + revision: '0.4hfq3gsql8', }, ], {}, diff --git a/src/App.tsx b/src/App.tsx index a289226..882b13e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,12 +28,14 @@ import CompetitionScramblerSchedule from './pages/Competition/ScramblerSchedule' import CompetitionStats from './pages/Competition/Stats'; import CompetitionStreamSchedule from './pages/Competition/StreamSchedule'; import Home from './pages/Home'; +import Settings from './pages/Settings'; import Support from './pages/Support'; import Test from './pages/Test'; import UserLogin from './pages/UserLogin'; import { AppProvider } from './providers/AppProvider'; import { AuthProvider, useAuth } from './providers/AuthProvider'; import { QueryProvider } from './providers/QueryProvider/QueryProvider'; +import { UserSettingsProvider } from './providers/UserSettingsProvider'; import { useWCIF } from './providers/WCIFProvider'; const PersonalSchedule = () => { @@ -119,6 +121,7 @@ const Navigation = () => { } /> } /> + } /> } /> } /> @@ -129,15 +132,17 @@ const Navigation = () => { const App = () => ( - - - - - - - - - + + + + + + + + + + + ); diff --git a/src/components/ActivityRow/ActivityRow.tsx b/src/components/ActivityRow/ActivityRow.tsx index aec2ac4..b1c536f 100644 --- a/src/components/ActivityRow/ActivityRow.tsx +++ b/src/components/ActivityRow/ActivityRow.tsx @@ -6,7 +6,7 @@ import { Stage } from '@/extensions/org.cubingusa.natshelper.v1/types'; import { useNow } from '@/hooks/useNow'; import { activityCodeToName } from '@/lib/activityCodes'; import { formatTimeRange } from '@/lib/time'; -import { Pill } from '../Pill'; +import { RoomPill } from '../Pill'; interface ActivityRowProps { activity: Activity; @@ -31,22 +31,22 @@ export function ActivityRow({ activity, stage, timeZone, showRoom = true }: Acti {activityName} - + {showRoom && stage && ( - {stage.name} - + )} {formatTimeRange(activity.startTime, activity.endTime, 5, timeZone)} diff --git a/src/components/AssignmentLabel/AssignmentLabel.tsx b/src/components/AssignmentLabel/AssignmentLabel.tsx index 9fdad74..d569c21 100644 --- a/src/components/AssignmentLabel/AssignmentLabel.tsx +++ b/src/components/AssignmentLabel/AssignmentLabel.tsx @@ -1,6 +1,6 @@ import { AssignmentCode } from '@wca/helpers'; import { useTranslation } from 'react-i18next'; -import { Pill } from '../Pill'; +import { BaseAssignmentPill } from '../Pill'; interface AssignmentLabelProps { assignmentCode: AssignmentCode; @@ -11,11 +11,11 @@ export function AssignmentLabel({ assignmentCode }: AssignmentLabelProps) { if (assignmentCode.match(/judge/i)) { return ( - + {t('common.assignments.staff-judge.noun', { defaultValue: assignmentCode.replace('staff-', ''), })} - + ); } @@ -25,24 +25,46 @@ export function AssignmentLabel({ assignmentCode }: AssignmentLabelProps) { switch (assignmentCode) { case 'competitor': - return {name}; + return ( + {name} + ); case 'staff-scrambler': - return {name}; + return ( + {name} + ); case 'staff-runner': - return {name}; + return ( + {name} + ); case 'staff-dataentry': - return {name}; + return ( + {name} + ); case 'staff-announcer': - return {name}; + return ( + {name} + ); case 'staff-delegate': - return {name}; + return ( + {name} + ); case 'staff-stagelead': - return {name}; + return ( + + {name} + + ); case 'staff-stream': - return {name}; + return ( + {name} + ); case 'staff-photo': - return {name}; + return ( + {name} + ); default: - return {name}; + return ( + {name} + ); } } diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index 397694d..8040863 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { Fragment } from 'react'; import { Link } from 'react-router-dom'; -import { Pill, PillProps } from '../Pill'; +import { BreadcrumbPill, PillProps } from '../Pill'; export type Breadcrumb = | { @@ -25,14 +25,14 @@ export const Breadcrumbs = ({ breadcrumbs }: BreadcrumbsProps) => { {index > 0 && ·} {'href' in breadcrumb ? ( - {label} - + ) : ( {label} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 4ca5e2c..977f0d2 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -3,8 +3,27 @@ import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; export interface ButtonProps extends PropsWithChildren> { className?: string; + variant?: 'blue' | 'green' | 'gray' | 'light'; } -export const Button = ({ className, ...props }: ButtonProps) => { - return