Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions DARK_MODE.md
Original file line number Diff line number Diff line change
@@ -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
<div className="bg-white dark:bg-gray-800">
<h1 className="text-gray-900 dark:text-white">Title</h1>
<p className="text-gray-600 dark:text-gray-400">Description</p>
</div>
```

### 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
<header className="bg-white dark:bg-gray-800 shadow-md">
<Link to="/" className="text-blue-500 dark:text-blue-400">
<i className="fa fa-home" />
</Link>
</header>
```

### List Item Component

```tsx
<li
className="border bg-white dark:bg-gray-800 dark:border-gray-700
hover:bg-slate-100 dark:hover:bg-gray-700">
<p className="text-gray-900 dark:text-white">{title}</p>
<p className="text-gray-600 dark:text-gray-400">{subtitle}</p>
</li>
```

### Button Component

```tsx
<button
className="bg-white dark:bg-gray-800
text-gray-900 dark:text-white
border-gray-200 dark:border-gray-700
hover:bg-gray-100 dark:hover:bg-gray-700">
Click me
</button>
```

## 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
2 changes: 1 addition & 1 deletion dev-dist/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ define(['./workbox-5357ef54'], function (workbox) {
[
{
url: 'index.html',
revision: '0.3rdphr8mv9',
revision: '0.4hfq3gsql8',
},
],
{},
Expand Down
23 changes: 14 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -119,6 +121,7 @@ const Navigation = () => {
</Route>
<Route path="/users/:userId" element={<UserLogin />} />
<Route path="about" element={<About />} />
<Route path="settings" element={<Settings />} />
<Route path="support" element={<Support />} />
</Route>
<Route path="test" element={<Test />} />
Expand All @@ -129,15 +132,17 @@ const Navigation = () => {

const App = () => (
<AppProvider>
<QueryProvider>
<ApolloProvider client={client}>
<BrowserRouter>
<AuthProvider>
<Navigation />
</AuthProvider>
</BrowserRouter>
</ApolloProvider>
</QueryProvider>
<UserSettingsProvider>
<QueryProvider>
<ApolloProvider client={client}>
<BrowserRouter>
<AuthProvider>
<Navigation />
</AuthProvider>
</BrowserRouter>
</ApolloProvider>
</QueryProvider>
</UserSettingsProvider>
</AppProvider>
);

Expand Down
12 changes: 6 additions & 6 deletions src/components/ActivityRow/ActivityRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,22 +31,22 @@ export function ActivityRow({ activity, stage, timeZone, showRoom = true }: Acti
<Link
key={activity.id}
className={classNames(
'flex flex-col w-full p-2 even:bg-slate-50 hover:bg-slate-100 even:hover:bg-slate-200',
'flex flex-col w-full p-2 text-gray-900 dark:text-white even:bg-slate-50 even:dark:bg-gray-800 hover:bg-slate-100 dark:hover:bg-gray-700 even:hover:bg-slate-200 even:dark:hover:bg-gray-600',
{
'opacity-50': isOver,
},
)}
to={`/competitions/${competitionId}/activities/${activity.id}`}>
<span>{activityName}</span>
<span className="text-xs md:text-sm font-light flex justify-between">
<span className="flex justify-between text-xs font-light md:text-sm">
{showRoom && stage && (
<Pill
className="mr-2 px-1 rounded"
<RoomPill
className="px-1 mr-2 rounded"
style={{
backgroundColor: `${stage.color}70`,
}}>
{stage.name}
</Pill>
</RoomPill>
)}
<span>{formatTimeRange(activity.startTime, activity.endTime, 5, timeZone)}</span>
</span>
Expand Down
48 changes: 35 additions & 13 deletions src/components/AssignmentLabel/AssignmentLabel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,11 +11,11 @@ export function AssignmentLabel({ assignmentCode }: AssignmentLabelProps) {

if (assignmentCode.match(/judge/i)) {
return (
<Pill className="bg-blue-200">
<BaseAssignmentPill className="bg-blue-200 dark:bg-blue-900">
{t('common.assignments.staff-judge.noun', {
defaultValue: assignmentCode.replace('staff-', ''),
})}
</Pill>
</BaseAssignmentPill>
);
}

Expand All @@ -25,24 +25,46 @@ export function AssignmentLabel({ assignmentCode }: AssignmentLabelProps) {

switch (assignmentCode) {
case 'competitor':
return <Pill className="bg-green-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-green-200 dark:bg-green-900">{name}</BaseAssignmentPill>
);
case 'staff-scrambler':
return <Pill className="bg-yellow-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-yellow-200 dark:bg-yellow-900">{name}</BaseAssignmentPill>
);
case 'staff-runner':
return <Pill className="bg-orange-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-orange-200 dark:bg-orange-900">{name}</BaseAssignmentPill>
);
case 'staff-dataentry':
return <Pill className="bg-cyan-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-cyan-200 dark:bg-cyan-900">{name}</BaseAssignmentPill>
);
case 'staff-announcer':
return <Pill className="bg-violet-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-violet-200 dark:bg-violet-900">{name}</BaseAssignmentPill>
);
case 'staff-delegate':
return <Pill className="bg-purple-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-purple-200 dark:bg-purple-900">{name}</BaseAssignmentPill>
);
case 'staff-stagelead':
return <Pill className="bg-fuchsia-200">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-fuchsia-200 dark:bg-fuchsia-900">
{name}
</BaseAssignmentPill>
);
case 'staff-stream':
return <Pill className="bg-pink-300">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-pink-300 dark:bg-pink-900">{name}</BaseAssignmentPill>
);
case 'staff-photo':
return <Pill className="bg-amber-500">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-amber-500 dark:bg-amber-900">{name}</BaseAssignmentPill>
);
default:
return <Pill className="bg-blue-100">{name}</Pill>;
return (
<BaseAssignmentPill className="bg-blue-100 dark:bg-blue-900">{name}</BaseAssignmentPill>
);
}
}
Loading