diff --git a/docs/how_tos/theming.md b/docs/how_tos/theming.md
index 52646a7d6..c7ff08084 100644
--- a/docs/how_tos/theming.md
+++ b/docs/how_tos/theming.md
@@ -1,15 +1,47 @@
-# Theming support with Paragon
-
-This document serves as a guide to using `@edx/frontend-platform` to support MFE theming with Paragon using theme CSS loaded externally (e.g., from a CDN). By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications. We would also get a meaningful gain in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles as users navigate across different MFEs.
+# Theming support with `@edx/paragon` and `@edx/brand`
## Overview
+This document serves as a guide to using `@edx/frontend-platform` to support MFE theming with Paragon using theme CSS loaded externally (e.g., from a CDN).
+
+To do this, configured URLs pointing to relevant CSS files from `@edx/paragon` and (optionally) `@edx/brand` are loaded and injected to the HTML document at runtime. This differs than the consuming application importing the styles from `@edx/paragon` and `@edx/brand` directly, which includes these styles in the application's production assets.
+
+By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications.
+
+### Dark mode and theme variant preferences
+
+`@edx/frontend-platform` supports both `light` (required) and `dark` (optional) theme variants. The choice of which theme variant should be applied on page load is based on the following preference cascade:
+
+1. **Get theme preference from localStorage.** Supports persisting and loading the user's preference for their selected theme variant, until cleared.
+1. **Detect user system settings.** Rely on the `prefers-color-scheme` media query to detect if the user's system indicates a preference for dark mode. If so, use the default dark theme variant, if one is configured.
+1. **Use default theme variant as configured (see below).** Otherwise, load the default theme variant as configured by the `defaults` option described below.
+
+Whenever the current theme variant changes, an attrivbute `data-paragon-theme-variant="*"` is updated on the `` element. This attribute enables applications' both JS and CSS to have knowledge of the currently applied theme variant.
+
+### Supporting custom theme variants beyond `light` and `dark`
+
+If your use case necessitates additional variants beyond the default supported `light` and `dark` theme variants, you may pass any number of custom theme variants. Custom theme variants will work the user's persisted localStorage setting (i.e., if a user switches to a custom theme variant, the MFE will continue to load the custom theme variant by default). By supporting custom theme variants, it also supports having multiple or alternative `light` and/or `dark` theme variants.
+
+### Performance implications
+
+There is also a meaningful improvement in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles included in each individual MFE as users navigate across the platform.
+
+However, as the styles from `@edx/paragon` and `@edx/brand` get loaded at runtime by `@edx/frontend-platform`, the associated CSS files do not get processed through the consuming application's Webpack build process (e.g., if the MFE used PurgeCSS or any custom PostCSS plugins specifically for Paragon).
+
+### Falling back to styles installed in consuming application
+
+If any of the configured external `PARAGON_THEME_URLS` fail to load for whatever reason (e.g., CDN is down, URL is incorrectly configured), `@edx/paragon` will attempt to fallback to the relevant files installed in `node_modules` from the consuming application.
+
+## Technical architecture
+
![overview of paragon theme loader](./assets/paragon-theme-loader.png "Paragon theme loader")
-## Basic theme URL configuration
+## Development
+
+### Basic theme URL configuration
Paragon supports 2 mechanisms for configuring the Paragon theme urls:
-* JavaScript-based configuration via `env.config.js`.
+* JavaScript-based configuration via `env.config.js`
* MFE runtime configuration API via `edx-platform`
Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setting must be created to point to the externally hosted Paragon theme CSS files, e.g.:
@@ -19,16 +51,45 @@ Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setti
"core": {
"url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css"
},
+ "defaults": {
+ "light": "light",
+ },
"variants": {
"light": {
"url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css",
- "default": true,
- "dark": false,
}
}
}
```
+### Configuration options
+
+The `PARAGON_THEME_URLS` configuration object supports using only the default styles from `@edx/paragon` or, optionally, extended/overridden styles via `@edx/brand`. To utilize `@edx/brand` overrides, see the `core.urls` and `variants.*.urls` options below.
+
+The `dark` theme variant options are optional.
+
+| Property | Data Type | Description |
+| -------- | ----------- | ----------- |
+| `core` | Object | Metadata about the core styles from `@edx/paragon` and `@edx/brand`. |
+| `core.url` | String | URL for the `core.css` file from `@edx/paragon`. |
+| `core.urls` | Object | URL(s) for the `core.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
+| `core.urls.default` | String | URL for the `core.css` file from `@edx/paragon`. |
+| `core.urls.brandOverride` | Object | URL for the `core.css` file from `@edx/brand`. |
+| `defaults` | Object | Mapping of theme variants to Paragon's default supported light and dark theme variants. |
+| `defaults.light` | String | Default `light` theme variant from the theme variants in the `variants` object. |
+| `defaults.dark` | String | Default `dark` theme variant from the theme variants in the `variants` object. |
+| `variants` | Object | Metadata about each supported theme variant. |
+| `variants.light` | Object | Metadata about the light theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. |
+| `variants.light.url` | String | URL for the `light.css` file from `@edx/paragon`. |
+| `variants.light.urls` | Object | URL(s) for the `light.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
+| `variants.light.urls.default` | String | URL for the `light.css` file from `@edx/paragon`. |
+| `variants.light.urls.brandOverride` | String | URL for the `light.css` file from `@edx/brand`. |
+| `variants.dark` | Object | Metadata about the dark theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. |
+| `variants.dark.url` | String | URL for the `dark.css` file from `@edx/paragon`. |
+| `variants.dark.urls` | Object | URL(s) for the `dark.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
+| `variants.dark.urls.default` | String | URL for the `dark.css` file from `@edx/paragon`. |
+| `variants.dark.urls.brandOverride` | String | URL for the `dark.css` file from `@edx/brand`. |
+
### JavaScript-based configuration
One approach to configuring the `PARAGON_THEME_URLS` is to create a `env.config.js` file in the root of the repository. The configuration is defined as a JavaScript file, which affords consumers to use more complex data types, amongst other benefits.
@@ -41,11 +102,12 @@ const config = {
core: {
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
},
+ defaults: {
+ light: 'light',
+ },
variants: {
light: {
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
- default: true,
- dark: false,
},
},
},
@@ -70,11 +132,12 @@ MFE_CONFIG_OVERRIDES = {
'core': {
'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
},
+ 'defaults': {
+ 'light': 'light',
+ },
'variants': {
'light': {
'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
- 'default': True,
- 'dark': False,
},
},
},
@@ -112,14 +175,15 @@ const config = {
brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@#brandVersion/dist/core.min.css',
},
},
+ defaults: {
+ light: 'light',
+ },
variants: {
light: {
urls: {
default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@$brandVersion/dist/light.min.css',
},
- default: true,
- dark: false,
},
},
},
diff --git a/src/react/AppProvider.jsx b/src/react/AppProvider.jsx
index 3d975e2cf..646d661c4 100644
--- a/src/react/AppProvider.jsx
+++ b/src/react/AppProvider.jsx
@@ -22,6 +22,7 @@ import {
LOCALE_CHANGED,
} from '../i18n';
import { basename } from '../initialize';
+import { SELECTED_THEME_VARIANT_KEY } from './constants';
/**
* A wrapper component for React-based micro-frontends to initialize a number of common data/
@@ -66,6 +67,7 @@ export default function AppProvider({ store, children, wrapWithRouter }) {
setLocale(getLocale());
});
+ useTrackColorSchemeChoice();
const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(config);
const appContextValue = useMemo(() => ({
@@ -76,6 +78,9 @@ export default function AppProvider({ store, children, wrapWithRouter }) {
state: paragonThemeState,
setThemeVariant: (themeVariant) => {
paragonThemeDispatch(paragonThemeActions.setParagonThemeVariant(themeVariant));
+
+ // Persist selected theme variant to localStorage.
+ window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant);
},
},
}), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]);
diff --git a/src/react/AppProvider.test.jsx b/src/react/AppProvider.test.jsx
index af0a7625e..71f076392 100644
--- a/src/react/AppProvider.test.jsx
+++ b/src/react/AppProvider.test.jsx
@@ -1,30 +1,65 @@
import React from 'react';
import { createStore } from 'redux';
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom/extend-expect';
+
import AppProvider from './AppProvider';
import { initialize } from '../initialize';
+import { useAppEvent, useTrackColorSchemeChoice, useParagonTheme } from './hooks';
+import { AUTHENTICATED_USER_CHANGED, getAuthenticatedUser } from '../auth';
+import { CONFIG_CHANGED } from '../constants';
+import { getConfig } from '../config';
+import { getLocale, LOCALE_CHANGED } from '../i18n';
+import AppContext from './AppContext';
+import { SELECTED_THEME_VARIANT_KEY, SET_THEME_VARIANT } from './constants';
jest.mock('../auth', () => ({
- configure: () => {},
- getAuthenticatedUser: () => null,
- fetchAuthenticatedUser: () => null,
- getAuthenticatedHttpClient: () => ({}),
+ ...jest.requireActual('../auth'),
+ getAuthenticatedUser: jest.fn(),
+ fetchAuthenticatedUser: jest.fn(),
+ getAuthenticatedHttpClient: jest.fn().mockReturnValue({}),
AUTHENTICATED_USER_CHANGED: 'user_changed',
}));
+jest.mock('../config', () => ({
+ ...jest.requireActual('../config'),
+ getConfig: jest.fn().mockReturnValue({
+ BASE_URL: 'localhost:8080',
+ LMS_BASE_URL: 'localhost:18000',
+ LOGIN_URL: 'localhost:18000/login',
+ LOGOUT_URL: 'localhost:18000/logout',
+ REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token',
+ ACCESS_TOKEN_COOKIE_NAME: 'access_token',
+ CSRF_TOKEN_API_PATH: 'localhost:18000/csrf',
+ }),
+}));
+
+jest.mock('../i18n', () => ({
+ ...jest.requireActual('../i18n'),
+ getLocale: jest.fn().mockReturnValue('en'),
+}));
+
jest.mock('../analytics', () => ({
- configure: () => {},
+ configure: () => { },
identifyAnonymousUser: jest.fn(),
identifyAuthenticatedUser: jest.fn(),
}));
jest.mock('./hooks', () => ({
...jest.requireActual('./hooks'),
+ useAppEvent: jest.fn(),
useTrackColorSchemeChoice: jest.fn(),
+ useParagonTheme: jest.fn().mockImplementation(() => [
+ { isThemeLoaded: true, themeVariant: 'light' },
+ jest.fn(),
+ ]),
}));
describe('AppProvider', () => {
beforeEach(async () => {
+ jest.clearAllMocks();
+
await initialize({
loggingService: jest.fn(() => ({
logError: jest.fn(),
@@ -104,4 +139,147 @@ describe('AppProvider', () => {
const reduxProvider = wrapper.queryByTestId('redux-provider');
expect(reduxProvider).not.toBeInTheDocument();
});
+
+ describe('paragon theme and brand', () => {
+ it('calls trackColorSchemeChoice', () => {
+ const Component = (
+
+