Skip to content

Make dark mode a user setting with a System option #183

Description

@alexeygrigorev

User outcome

A member chooses once, in their account settings, whether the site follows their operating
system's appearance (System) or always uses Light or Dark. A visitor with no account gets the
same System-by-default behaviour locally. Nobody who already chose a theme sees it change when
this ships.

This is a bounded P2 slice: one preference, one control, no preferences framework.

Normative links and current contract (verified at main, 9603342)

  • _docs/design/design-5a.md — the token table is mirrored on body.dark-mode; light/dark
    pairs are the only sanctioned colour source for design 5a pages.
  • Storage today: anonymous choice in localStorage['darkMode'] ('true'/'false'), read by
    the pre-paint bootstrap in templates/core/_site_shell_head.html:32-47 (anonymous only,
    key read at line 39) and by the toggle script in
    templates/core/_site_shell_foot.html. The Playwright suites seed this key directly
    (e.g. playwright_tests/test_legal_footer_125.py:95,127).
  • Server state today: CustomUser.dark_mode boolean (accounts/models.py:67, default
    False), published as DARK_MODE by course_management/context_processors.py:5-12 and
    rendered into the body element (class="dark dark-mode", data-dark-mode,
    data-authenticated, data-toggle-url) by every design 5a page template (e.g.
    templates/public/text_page.html:153-165).
  • Endpoints today (both JSON POST; there is no cookie for dark mode — the foot include's
    "cookie endpoint" wording is loose, and no cookie is introduced by this issue):
    • POST /accounts/toggle-dark-mode/ (accounts/views/account_toggles.py:13-29): flips the
      boolean, returns {"dark_mode": bool}; consumed by the design 5a masthead script and by
      the legacy courses/static/dark_mode.js.
    • POST /accounts/settings/toggle/ (update_account_toggle,
      accounts/views/account_toggles.py:32-64): sets an explicit value for an allowlisted
      field (only dark_mode today); consumed by the settings page's immediate-toggle row
      (course_platform_templates/accounts/account_settings.html:394-401 via
      courses/static/settings_toggles.js).
  • Settings UI today: the account settings page (design 5a already) exposes a "Use dark mode"
    checkbox (accounts/forms.py:94,105,124).
  • prefers-color-scheme appears nowhere in repository CSS/JS/templates (verified); the
    effective default is light for everyone who never touched the toggle.
  • Two theme implementations are live during the design 5a porting window: the token table
    under body.dark-mode (templates/core/_design_system.html) and the legacy
    courses/static/courses.css overrides (137 body.dark selectors) with
    courses/static/dark_mode.js. Both read the same storage key, body classes, data
    attributes, and flip endpoint.

Product decision (settled in grooming)

  • Preference model: one three-state preference, System / Light / Dark, default
    System for new accounts and anonymous visitors.
  • Migration semantics: existing dark_mode=True rows become Dark; existing
    dark_mode=False rows become Light. A stored False can be either "never chose" or "may
    have deliberately switched off", and mapping it to Light is the only choice that preserves
    every current user's rendered outcome at deploy time; nobody's theme flips because this
    shipped.
  • Masthead pill: stays a two-state quick override that writes an explicit Light or
    Dark (from System, the pill writes the opposite of the currently effective theme).
    Returning to System happens in account settings only — no three-state cycling on a
    single button.
  • Server vs browser: the server cannot see the OS preference, so System is resolved in
    the browser by the pre-paint bootstrap, which extends from anonymous-only to any visitor
    whose resolved preference is System.

Scope

  1. Model/migration: replace CustomUser.dark_mode with
    theme_preference = CharField(choices=[system, light, dark], default="system"); data
    migration maps True → dark, False → light; migration-drift check green. One source of
    truth — no dual columns.
  2. Context: course_management/context_processors.py publishes THEME_PREFERENCE (raw)
    and keeps publishing DARK_MODE as the server-effective boolean (system resolves to
    light server-side), so legacy templates and pages keep rendering exactly as today.
  3. Templates: body element gains data-theme-preference="{{ THEME_PREFERENCE }}";
    server-rendered dark dark-mode classes remain for light/dark. The head bootstrap
    (_site_shell_head.html) resolves System via
    matchMedia('(prefers-color-scheme: dark)') pre-paint for anonymous and authenticated
    alike, and keeps honouring the stored explicit choice first.
  4. Anonymous storage: new key themePreference (system|light|dark); the legacy
    darkMode key is read as an explicit Light/Dark choice when the new key is absent and
    is never written again. Existing visitors and the Playwright suites that seed darkMode
    keep working unchanged.
  5. Endpoints:
    • update_account_toggle allowlist gains theme_preference, accepting exactly
      system|light|dark (400 otherwise); response includes theme_preference and the
      effective dark_mode boolean.
    • toggle_dark_mode remains for legacy un-ported pages: it flips theme_preference
      between light and dark and keeps returning {"dark_mode": <effective bool>} so
      courses/static/dark_mode.js needs no change.
    • The design 5a masthead script switches to the value-setting endpoint and stops
      mirroring the preference into localStorage for authenticated users.
  6. Settings control: replace the "Use dark mode" checkbox with a labelled three-option
    radio group (fieldset/legend "Theme"; options System — "Follows your device's appearance",
    Light, Dark), keeping the immediate-save row behavior and the accessible help/error
    relationships the page already uses.
  7. Accessibility: aria-pressed on the masthead pill continues to reflect the effective
    dark state; declare color-scheme: light / color-scheme: dark alongside the existing
    body.dark-mode token table so native controls and scrollbars follow the theme.
  8. Observability: account.toggle_updated records field="theme_preference" and the new
    value.
  9. Focused Django tests (endpoints incl. validation and 405/302 boundaries, migration mapping,
    context processor, form) and Playwright core coverage for the new behaviour.

Non-goals

  • No preferences framework, generic preference store, or per-device preference sync.
  • No theming for the Django admin, emails, or rendered course artifacts.
  • No live re-evaluation of prefers-color-scheme changes mid-session: System resolves at
    page load; reacting to an OS switch without a reload is future work.
  • No changes to the legacy courses.css dark overrides, dark_mode.js, or legacy page
    templates beyond keeping their contracts working; porting those pages is the design 5a
    port's work, not this issue's. Legacy pages therefore keep server-effective rendering and
    do not follow the OS until they are ported.
  • No cookie is introduced for dark mode (the timezone browser_timezone cookie pattern is
    not adopted here).
  • No redesign of the masthead pill beyond its label/pressed-state semantics.

Dependencies

Acceptance criteria

  • Migration maps existing rows dark_mode=True → theme_preference=dark and
    False → light; new users default to system; migration-drift check passes.
  • Authenticated light/dark users get server-rendered body classes with no
    script-dependent flash; authenticated system users and anonymous visitors resolve
    System via prefers-color-scheme in the pre-paint bootstrap (no light flash when the
    OS is dark).
  • Anonymous explicit choice persists in themePreference and wins over the OS; a legacy
    darkMode value still applies when themePreference is absent; nothing writes
    darkMode anymore.
  • The masthead pill writes an explicit light/dark for both anonymous and
    authenticated visitors, updates aria-pressed and its label, and persists across
    reloads.
  • Account settings shows the three-option Theme radio group; changing it saves
    immediately via update_account_toggle, applies without a full-page reload, and an
    invalid value is rejected with 400.
  • POST /accounts/toggle-dark-mode/ still flips and returns {"dark_mode": bool};
    unauthenticated POST still redirects to login (302) and GET still returns 405.
  • DARK_MODE remains server-effective for legacy templates; a legacy course page renders
    and toggles exactly as before.
  • color-scheme is declared for both themes in the design system.
  • Playwright core covers: OS-dark anonymous first visit renders dark; explicit choice
    overrides OS; authenticated system follows OS; reload shows no light flash; existing
    suites that seed darkMode stay green.
  • Tester screenshots at desktop and mobile: light and dark variants of the homepage or a
    content page, and the settings page showing the three-state control, in both themes.

Browser scenarios

  1. Fresh anonymous visitor, OS set to dark: first design 5a page renders dark with no light
    flash; the masthead pill shows the state; reload keeps dark.
  2. Same visitor activates the pill: theme switches to the explicit opposite and survives
    reload even after switching the OS to light.
  3. Member with preference Dark (migrated) opens any design 5a page: dark, server-rendered,
    no flash; the legacy course platform page they can still reach renders identically to
    today.
  4. Member sets Theme to System in account settings with OS dark: next page load is dark;
    setting OS to light and reloading renders light.
  5. Keyboard-only user tabs to the Theme group, changes options with arrows, and hears the
    saved state announced; the masthead pill's pressed state matches the visible theme.
  6. All of the above at mobile width; nothing overflows or overlaps in the masthead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Nice to have or lateraccessibilityArea: accessibilityauthArea: authenhancementNew feature or requestfrontendArea: frontend

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions