Skip to content

Conversation

@JasonWarrenUK
Copy link
Contributor

Overview

Implements complete dark mode support for Rhea with user-selectable themes (Light/Dark/System). All workflows maintain their unique colour identities while adapting seamlessly to both light and dark themes.

Task: Rhea 1a2b - Add Dark Mode to UI


Features

✅ User-selectable themes - Light, dark, or system preference
✅ System preference detection - Automatically respects OS dark mode setting
✅ Persistent preference - localStorage saves user choice across sessions
✅ Workflow-specific palettes - Each workflow maintains identity in both themes
✅ WCAG AA compliant - All colour combinations meet 4.5:1 contrast requirements
✅ Zero breaking changes - Fully backward compatible
✅ Professional UI - Text-only theme selector


Implementation

Architecture

Dual-dimension palette system:

  • Workflow dimension: metis, themis, tethys, theia, rhea
  • Theme dimension: light, dark

Key Components

  1. Dark Palettes - 5 new files with dark variants for all workflows
  2. Theme Store - User preference + system detection + persistence
  3. Theme Selector - Three-state toggle UI component
  4. CSS Variables - Semantic variables that automatically adapt

Changes

  • New files: 10 (palettes, theme system, component, docs)
  • Modified files: 24 (palette system, layout, workflows, components)
  • Lines added: ~1,200

Testing

✅ Build successful - No errors
✅ All workflows tested - Metis, Themis, Tethys, Theia, Rhea
✅ Theme switching - Instant transitions
✅ System preference - Detects OS setting
✅ Persistence - Survives page reload
✅ Contrast verified - WCAG AA compliant


Documentation

  • Implementation guide: docs/dev/features/dark-mode.md
  • Palette reference: Updated docs/dev/ref/palettes/README.md

- Darken all dark palette backgrounds by 30-40% for proper contrast
- Add dark theme overrides for workflow-specific CSS variables
- Fix all heading elements (h1, h2, h3) to use --palette-foreground
- Replace hardcoded 'background: white' with --palette-bg-subtle
- Fix workflow navigation step visibility with borders and opacity
- Update all button backgrounds to use CSS variables
- Fix Themis components: ArcSection, CourseOverview, ModuleCard, etc.
- Fix Theia components: ExportButton, ExportConfigModal
- Ensure all text colors use semantic CSS variables for theme adaptation

All workflows (Metis, Themis, Tethys, Theia, Rhea) now have excellent
contrast in both light and dark modes with no hardcoded colors.

Resolves remaining dark mode contrast issues.
@claude
Copy link

claude bot commented Oct 29, 2025

Summary

Excellent dark mode implementation with a well-architected dual-dimension palette system. The code is clean, type-safe, and accessibility-focused with WCAG AA compliance. The architecture is solid with proper SSR handling and localStorage persistence. Minor suggestions include adding tests, reducing CSS variable duplication, and enhancing error handling for edge cases.

Key Strengths: Comprehensive documentation, zero breaking changes, proper accessibility attributes, SSR-safe implementation
Key Opportunities: Test coverage, CSS generation robustness, initial theme flash prevention

🎯 Code Quality & Best Practices

Strengths

1. Type Safety ✅

  • Excellent TypeScript usage throughout (ThemePreference, ThemeMode, WorkflowName)
  • Type-safe palette loader with proper generic constraints in src/lib/utils/palette/paletteLoader.ts:67-69
  • No any types detected

2. Architecture ✅

  • Clean separation of concerns: stores, utilities, components
  • Dual-dimension palette system (workflow × theme) is elegant and scalable
  • Single source of truth pattern for palettes
  • Proper use of Svelte 5's $effect runes in src/routes/+layout.svelte:19-32

3. SSR Compatibility ✅

  • Proper typeof window === 'undefined' guards in src/lib/stores/themeStore.ts:49 and src/lib/utils/state/persistenceUtils.ts:24
  • Safe localStorage access via persistedStore utility

4. Documentation ✅

  • Exceptional documentation in docs/dev/features/dark-mode.md (584 lines)
  • Clear JSDoc comments throughout
  • Architecture diagrams and usage examples

5. Accessibility ✅

  • Proper ARIA attributes in ThemeSelector.svelte:42-51:
    • role="radiogroup" and role="radio"
    • aria-checked for state
    • aria-label for screen readers
  • Keyboard navigation support (handleKeydown for Enter/Space)
  • WCAG AA contrast compliance documented

Suggestions

1. CSS Variable Duplication

The generated CSS in src/lib/styles/palettes.generated.css has duplicate selectors:

[data-palette="metis"],
[data-palette="metis"][data-theme="light"] { ... }

Consider simplifying to just use [data-palette="metis"] for light theme since it's the default. This would reduce file size.

2. Component Prop Syntax

The ThemeSelector component uses Svelte 4 export syntax:

<!-- Current: no props needed, which is fine -->

Since you're using Svelte 5 runes ($effect, $derived) elsewhere, consider consistency. However, this component has no props, so this is a minor point.

3. Color Naming Consistency

In src/lib/config/palettes/metisPalette.dark.ts:10, the dark color is named "void" while in the light palette it's "depths". Consider consistent naming conventions across light/dark variants for easier maintenance.

4. Error Handling in CSS Generation

The scripts/generatePaletteCss.js script uses regex parsing which could be fragile. Consider:

  • Adding validation to ensure all required colors are found
  • Throwing descriptive errors if palette structure is invalid
  • Adding a dry-run mode for testing
🐛 Potential Issues

Critical Issues

None found

Medium Priority

1. Initial Theme Flash

The theme is applied in +layout.svelte:28-31 via $effect, which runs after hydration. Users might see a brief flash of the wrong theme. Consider:

<!-- Add inline script in app.html to set theme before content loads -->
<script>
  const theme = localStorage.getItem('theme-preference') || 'system';
  const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  const effective = theme === 'system' ? systemTheme : theme;
  document.body.setAttribute('data-theme', effective);
</script>

2. Tab Index Pattern in ThemeSelector

src/lib/components/ui/ThemeSelector.svelte:51 uses tabindex={ === option ? 0 : -1}

This creates a tab trap pattern. Consider using roving tabindex with arrow key navigation (standard for radio groups):

  • Only first item has tabindex="0"
  • Arrow keys move focus between options
  • Current pattern makes non-selected items unreachable via keyboard

3. Legacy Browser Support

src/lib/stores/themeStore.ts:64-70 includes fallback for deprecated addListener/removeListener methods. While thorough, these were deprecated in 2018. Consider dropping for code simplicity unless you need IE11 support.

Low Priority

1. Missing Cleanup in Layout

src/routes/+layout.svelte:20 calls initSystemThemeDetection() but doesn't store/call the cleanup function:

(() => {
  const cleanup = initSystemThemeDetection();
  return cleanup; // Svelte 5 effect cleanup
});

While not critical (event listener persists for app lifetime), it's best practice.

2. Palette Generation Script Truncation

The diff shows scripts/generatePaletteCss.js was truncated in the GitHub diff. Verify the file is complete:

const tethysPaletteDark = parsePaletteFile(join(palette
// ^^^ Appears incomplete
⚡ Performance Considerations

Strengths

1. CSS Custom Properties ✅

  • Instant theme switching without React/Svelte re-renders
  • Browser-native CSS cascading
  • No JavaScript required for color updates

2. Efficient State Management ✅

  • Single matchMedia listener for entire app
  • localStorage read only on initialization
  • Derived stores prevent unnecessary recalculation

3. Tree-Shakable Palettes ✅

  • Each palette is a separate module
  • Build tools can eliminate unused palettes

Suggestions

1. CSS File Size

Generated CSS is ~121 lines added for dark themes. For 5 workflows × 2 themes × ~10 variables, this is reasonable but consider:

  • CSS minification in production
  • Critical CSS extraction (inline light theme, async load dark)

2. localStorage Subscription

src/lib/utils/state/persistenceUtils.ts:126-133 subscribes immediately, which triggers a localStorage write on store creation:

store.subscribe(value => {
  saveToLocalStorage(key, value, serialize);
});

This writes the default value even if nothing changed. Consider:

let initialized = false;
store.subscribe(value => {
  if (!initialized) {
    initialized = true;
    return;
  }
  saveToLocalStorage(key, value, serialize);
});

3. Event Listener Optimization

The matchMedia listener in src/lib/stores/themeStore.ts:59-61 updates the store on every system theme change. Consider debouncing if you observe performance issues during rapid OS theme toggling (unlikely but possible).

🔒 Security Concerns

Overall Assessment

No significant security issues found

Observations

1. localStorage Usage ✅

  • No sensitive data stored (only theme preference)
  • Proper error handling prevents exceptions
  • XSS-safe (no innerHTML or dangerous operations)

2. CSS Injection ✅

  • All colors are hardcoded, no user input
  • CSS variables are safe from injection
  • No style attribute manipulation with user data

3. Type Safety ✅

  • Strict TypeScript prevents invalid theme values
  • Zod-like validation not needed for simple string literals

Best Practices

1. Consider CSP Compatibility

If your app uses Content Security Policy, ensure inline styles are allowed or move to external CSS. Current implementation uses CSS variables in external files, so this should be fine.

2. localStorage Quota

While theme preference is tiny (~20 bytes), consider handling QuotaExceededError:

try {
  localStorage.setItem(key, serialized);
} catch (e) {
  if (e.name === 'QuotaExceededError') {
    // Handle gracefully - theme still works, just not persisted
    console.warn('localStorage full, theme preference not saved');
  }
}

Already handled generically in persistenceUtils.ts:63

✅ Test Coverage

Current State

No tests found for new code

Only existing test: src/lib/schemas/courseValidator.test.ts

Recommended Tests

1. Theme Store Tests

// src/lib/stores/themeStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import { themePreference, effectiveTheme, toggleTheme, cycleThemePreference } from './themeStore';

describe('themeStore', () => {
  beforeEach(() => {
    themePreference.set('system');
    localStorage.clear();
  });

  it('defaults to system preference', () => {
    expect(get(themePreference)).toBe('system');
  });

  it('toggleTheme switches between light and dark', () => {
    themePreference.set('light');
    toggleTheme();
    expect(get(themePreference)).toBe('dark');
    toggleTheme();
    expect(get(themePreference)).toBe('light');
  });

  it('cycleThemePreference rotates through all options', () => {
    themePreference.set('light');
    cycleThemePreference();
    expect(get(themePreference)).toBe('dark');
    cycleThemePreference();
    expect(get(themePreference)).toBe('system');
    cycleThemePreference();
    expect(get(themePreference)).toBe('light');
  });

  it('effectiveTheme resolves system to actual theme', () => {
    // Mock matchMedia
    window.matchMedia = () => ({ matches: true } as MediaQueryList);
    themePreference.set('system');
    expect(get(effectiveTheme)).toBe('dark');
  });
});

2. Palette Loader Tests

// src/lib/utils/palette/paletteLoader.test.ts
import { describe, it, expect } from 'vitest';
import { getWorkflowPalette, generateCSSVariables } from './paletteLoader';

describe('paletteLoader', () => {
  it('loads light palette by default', () => {
    const palette = getWorkflowPalette('metis');
    expect(palette.colours.background.primary.main).toBeDefined();
  });

  it('loads dark palette when specified', () => {
    const lightPalette = getWorkflowPalette('metis', 'light');
    const darkPalette = getWorkflowPalette('metis', 'dark');
    expect(lightPalette).not.toEqual(darkPalette);
  });

  it('generates valid CSS variables', () => {
    const vars = generateCSSVariables('metis', 'dark');
    expect(vars['--palette-primary']).toMatch(/^#[0-9a-fA-F]{8}$/);
    expect(vars['--palette-foreground']).toBeDefined();
  });
});

3. ThemeSelector Component Tests

// src/lib/components/ui/ThemeSelector.test.ts
import { describe, it, expect } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import ThemeSelector from './ThemeSelector.svelte';

describe('ThemeSelector', () => {
  it('renders all theme options', () => {
    const { getByText } = render(ThemeSelector);
    expect(getByText('Light')).toBeInTheDocument();
    expect(getByText('Dark')).toBeInTheDocument();
    expect(getByText('System')).toBeInTheDocument();
  });

  it('applies active class to selected theme', () => {
    const { getByText } = render(ThemeSelector);
    const lightButton = getByText('Light');
    expect(lightButton).toHaveClass('active');
  });

  it('changes theme on click', async () => {
    const { getByText } = render(ThemeSelector);
    const darkButton = getByText('Dark');
    await fireEvent.click(darkButton);
    expect(darkButton).toHaveClass('active');
  });

  it('handles keyboard navigation', async () => {
    const { getByText } = render(ThemeSelector);
    const darkButton = getByText('Dark');
    await fireEvent.keyDown(darkButton, { key: 'Enter' });
    expect(darkButton).toHaveClass('active');
  });
});

4. Visual Regression Tests

Consider adding visual regression tests with Playwright or Chromatic to ensure:

  • All workflows render correctly in dark mode
  • Contrast ratios meet WCAG AA
  • No color leakage between workflows

Testing Checklist

  • Unit tests for theme store
  • Unit tests for palette loader
  • Component tests for ThemeSelector
  • Integration test: theme persistence across page reloads
  • Integration test: system preference detection
  • E2E test: theme switching in all workflows
  • Visual regression tests for color accuracy
  • Accessibility tests (axe-core)

Conclusion

This is a high-quality PR that introduces dark mode in a maintainable, accessible, and performant way. The architecture is well-designed for future extensibility (custom themes, per-workflow themes, etc.).

Recommended Actions Before Merge:

  1. ✅ Add unit tests for theme store and palette loader (highest priority)
  2. ✅ Fix keyboard navigation in ThemeSelector (accessibility concern)
  3. ✅ Add inline script to prevent initial theme flash
  4. ✅ Verify generatePaletteCss.js is complete (appears truncated in diff)
  5. ⚠️ Consider adding E2E test for theme switching

Ready to merge after addressing the keyboard navigation issue (#2 in Potential Issues).

Great work on the comprehensive documentation and thorough implementation! 🎉

@JasonWarrenUK JasonWarrenUK merged commit 125797d into main Oct 29, 2025
1 check passed
@JasonWarrenUK JasonWarrenUK deleted the ui/dark-mode branch October 29, 2025 01:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants