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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Observability:
account.toggle_updated records field="theme_preference" and the new
value.
- 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
Browser scenarios
- 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.
- Same visitor activates the pill: theme switches to the explicit opposite and survives
reload even after switching the OS to light.
- 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.
- Member sets Theme to System in account settings with OS dark: next page load is dark;
setting OS to light and reloading renders light.
- 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.
- All of the above at mobile width; nothing overflows or overlaps in the masthead.
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 onbody.dark-mode; light/darkpairs are the only sanctioned colour source for design 5a pages.
localStorage['darkMode']('true'/'false'), read bythe 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).CustomUser.dark_modeboolean (accounts/models.py:67, defaultFalse), published asDARK_MODEbycourse_management/context_processors.py:5-12andrendered 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)."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 theboolean, returns
{"dark_mode": bool}; consumed by the design 5a masthead script and bythe 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 allowlistedfield (only
dark_modetoday); consumed by the settings page's immediate-toggle row(
course_platform_templates/accounts/account_settings.html:394-401viacourses/static/settings_toggles.js).checkbox (
accounts/forms.py:94,105,124).prefers-color-schemeappears nowhere in repository CSS/JS/templates (verified); theeffective default is light for everyone who never touched the toggle.
under
body.dark-mode(templates/core/_design_system.html) and the legacycourses/static/courses.cssoverrides (137body.darkselectors) withcourses/static/dark_mode.js. Both read the same storage key, body classes, dataattributes, and flip endpoint.
Product decision (settled in grooming)
System/Light/Dark, defaultSystemfor new accounts and anonymous visitors.dark_mode=Truerows becomeDark; existingdark_mode=Falserows becomeLight. A storedFalsecan be either "never chose" or "mayhave deliberately switched off", and mapping it to
Lightis the only choice that preservesevery current user's rendered outcome at deploy time; nobody's theme flips because this
shipped.
LightorDark(fromSystem, the pill writes the opposite of the currently effective theme).Returning to
Systemhappens in account settings only — no three-state cycling on asingle button.
Systemis resolved inthe browser by the pre-paint bootstrap, which extends from anonymous-only to any visitor
whose resolved preference is
System.Scope
CustomUser.dark_modewiththeme_preference = CharField(choices=[system, light, dark], default="system"); datamigration maps
True → dark,False → light; migration-drift check green. One source oftruth — no dual columns.
course_management/context_processors.pypublishesTHEME_PREFERENCE(raw)and keeps publishing
DARK_MODEas the server-effective boolean (systemresolves tolight server-side), so legacy templates and pages keep rendering exactly as today.
data-theme-preference="{{ THEME_PREFERENCE }}";server-rendered
dark dark-modeclasses remain forlight/dark. The head bootstrap(
_site_shell_head.html) resolvesSystemviamatchMedia('(prefers-color-scheme: dark)')pre-paint for anonymous and authenticatedalike, and keeps honouring the stored explicit choice first.
themePreference(system|light|dark); the legacydarkModekey is read as an explicitLight/Darkchoice when the new key is absent andis never written again. Existing visitors and the Playwright suites that seed
darkModekeep working unchanged.
update_account_toggleallowlist gainstheme_preference, accepting exactlysystem|light|dark(400 otherwise); response includestheme_preferenceand theeffective
dark_modeboolean.toggle_dark_moderemains for legacy un-ported pages: it flipstheme_preferencebetween
lightanddarkand keeps returning{"dark_mode": <effective bool>}socourses/static/dark_mode.jsneeds no change.mirroring the preference into
localStoragefor authenticated users.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.
aria-pressedon the masthead pill continues to reflect the effectivedark state; declare
color-scheme: light/color-scheme: darkalongside the existingbody.dark-modetoken table so native controls and scrollbars follow the theme.account.toggle_updatedrecordsfield="theme_preference"and the newvalue.
context processor, form) and Playwright core coverage for the new behaviour.
Non-goals
prefers-color-schemechanges mid-session:Systemresolves atpage load; reacting to an OS switch without a reload is future work.
courses.cssdark overrides,dark_mode.js, or legacy pagetemplates 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.
browser_timezonecookie pattern isnot adopted here).
Dependencies
main(Rebuild the public homepage on the design 5a mockup #179 work landed).Acceptance criteria
dark_mode=True → theme_preference=darkandFalse → light; new users default tosystem; migration-drift check passes.light/darkusers get server-rendered body classes with noscript-dependent flash; authenticated
systemusers and anonymous visitors resolveSystemviaprefers-color-schemein the pre-paint bootstrap (no light flash when theOS is dark).
themePreferenceand wins over the OS; a legacydarkModevalue still applies whenthemePreferenceis absent; nothing writesdarkModeanymore.light/darkfor both anonymous andauthenticated visitors, updates
aria-pressedand its label, and persists acrossreloads.
immediately via
update_account_toggle, applies without a full-page reload, and aninvalid 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_MODEremains server-effective for legacy templates; a legacy course page rendersand toggles exactly as before.
color-schemeis declared for both themes in the design system.overrides OS; authenticated
systemfollows OS; reload shows no light flash; existingsuites that seed
darkModestay green.content page, and the settings page showing the three-state control, in both themes.
Browser scenarios
flash; the masthead pill shows the state; reload keeps dark.
reload even after switching the OS to light.
no flash; the legacy course platform page they can still reach renders identically to
today.
setting OS to light and reloading renders light.
saved state announced; the masthead pill's pressed state matches the visible theme.