Skip to content
Merged
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
48 changes: 34 additions & 14 deletions docs/decisions/0008-stylesheet-import-in-site-config.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,59 @@
# Shell stylesheet must be imported in site.config file.
# Shell style manifest must be imported in site.config file.

## Summary

A project must import the stylesheet for the application shell in its site.config file.
A site must import the shell's style manifest (`@openedx/frontend-base/shell/style`) from its site.config file.

## Context

There is a particular quirk of the stylesheet loaders for webpack (style-loader and/or css-loader) where the import of stylesheets into JavaScript files must take place in a JS file in the project, not in library dependency like frontend-base. Further, the stylesheet imported into JS must _itself_ be a part of the project.
A composing site needs a single, project-owned entry point for its global CSS. Two concerns drive this:

If, for instance, we try to import a stylesheet from frontend-base (shell, header, footer, etc.) inside a React component inside the shell, webpack silently ignores the import and refuses to load the stylesheet. If we try to import a stylesheet from frontend-base directly into the site.config file in the project, that will also fail with webpack silently ignoring the stylesheet. If, however, frontend-base exports the stylesheet and it's loaded into a SCSS file in the project and _that_ is imported into site.config, everything works correctly.
1. **CSS ownership.** Paragon's base CSS declares CSS custom properties on `:root`. If a lazy-loaded app chunk ships its own copy of Paragon's base CSS, its `:root` declarations run after the site's brand overrides and clobber them globally. The composing site must therefore be the **single owner** of shell, Paragon base, and brand CSS; apps must not re-bundle these. Having the site import the shell's style manifest from its site.config makes this ownership explicit and keeps the rule easy to audit.

This slight indirection through a SCSS file in the project is necessary, and arguably desirable. It ensure as common, unified entry point for SCSS from dependencies of the project. SCSS from the project or micro-frontend itself can be imported into its own components, or can be imported into this top-level SCSS file as desired. Further, this ensures that every aspect of the style of a project or MFE can easily be customized since the stylesheet is supplied through the site.config file.
2. **Layer classification by resource path.** The build pipeline wraps each stylesheet in a CSS cascade layer based on where it was resolved from. For that to classify Paragon's base CSS into the `shell` layer rather than the layer of whichever SCSS file happened to `@use` it, each import must be its own webpack module. The shell's style manifest is a small TS file that imports Paragon's CSS and the shell's own SCSS side by side, so every entry is seen by webpack as an independent compilation unit and classified on its own merits.

3. **PostCSS-pass scoping.** Paragon exposes responsive breakpoints as `@custom-media` declarations, substituted at build time by `postcss-custom-media`. Substitution only works if the declarations are visible to the same PostCSS pass as the `@media` rule that references them. The shell manifest pulls Paragon's core CSS into the site's build, so any `@media (--pgn-size-breakpoint-*)` reference in a site-level stylesheet resolves correctly. App stylesheets are separate PostCSS passes and handle this themselves; see [the theming guide](../how_tos/theming.md#custom-media-breakpoints) for details.

An earlier version of this ADR claimed that webpack's stylesheet loaders silently ignore imports sourced from library dependencies. That is no longer accurate with the current loader configuration (see `tools/webpack/common-config/all/getStylesheetRule.ts`). Importing the manifest from site.config remains the correct pattern, but for the ownership, classification, and scoping reasons above rather than a loader quirk.

## Decision

As a best practice, a project should have a top-level SCSS file as a peer to the site.config file. This SCSS file should import the stylesheet from the frontend-base shell application. It should, in turn, be imported into the site.config file.
A project's site.config file must import the shell's style manifest (`@openedx/frontend-base/shell/style`). If the site has additional global styles, it can import its own SCSS file from site.config alongside (or in place of) a brand package.

## Implementation
The shell manifest must be imported **only once**, by the composing site. Apps that are consumed by a site must not import the manifest (or any other source of Paragon base styles) from their runtime code, because doing so causes lazy-loaded app chunks to re-declare Paragon's `:root` CSS custom properties and clobber the site's brand overrides globally. Apps may still import the manifest from `site.config.dev.tsx` (not shipped in `dist/`) so that they render correctly when run standalone. See [the theming guide](../how_tos/theming.md#css-ownership) and [the migration guide](../how_tos/migrate-frontend-app.md#separate-runtime-styles-from-the-dev-harness) for details.

The `site.scss` file should import the stylesheet from the shell:
## Cascade layers

```diff
+ @import '@openedx/frontend-base/shell/app.scss';
The build pipeline wraps each stylesheet in a CSS cascade layer based on the resolved resource path:

// other styles
```
| Layer | Sources |
| --------- | ----------------------------------------------------------------- |
| `paragon` | `@openedx/paragon` |
| `shell` | `@openedx/frontend-base` |
| `app` | any other stylesheet resolved from `node_modules` |
| `site` | stylesheets outside `node_modules` (the composing site's source) |
| `brand` | `@(open)?edx/brand*` packages |

The order declared at the top of `shell/style.scss` is `@layer paragon, shell, app, site, brand;` so the cascade resolves in that order: `brand` wins over `site`, `site` wins over `app`, `app` wins over `shell`, and `shell` wins over `paragon`.

The site.config file should then import the top-level SCSS file:
`brand` is last because, in production, brand CSS is injected at runtime via `<link>` tags that bypass webpack entirely and therefore land **unlayered**. Unlayered rules beat every layered rule regardless of declared order, so runtime brand wins over the site's own CSS. Putting build-time brand imports (e.g. a dev harness that `@use`s a brand package directly) in the last layer keeps dev harness behavior consistent with production: brand overrides apply on top of everything the site declares.

This is enforced by a PostCSS plugin (`tools/webpack/common-config/all/postcssWrapLayer.ts`) applied as the final step of the CSS pipeline. `@charset`, `@import`, `@use`, `@forward`, and existing `@layer` nodes are preserved at the root; everything else is moved into the layer block.

Apps must still follow the ownership rule above. The layering is a safety net: if an app chunk does re-ship Paragon's `:root` declarations, its `app`-layer block loses to the site's `site`-layer tokens and any brand overrides. Relying on the safety net rather than the ownership rule still wastes bytes on the wire.

## Implementation

The site.config file should import the shell's style manifest:

```diff
+ import './site.scss';
+ import '@openedx/frontend-base/shell/style';

const siteConfig = {
// config document
}

export default siteConfig;
```

If the site has its own global styles, it can add an `@use` or `@import` of a local SCSS file after the manifest import.
49 changes: 32 additions & 17 deletions docs/how_tos/migrate-frontend-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,11 @@ Define the public API for your package:

```json
"exports": {
".": "./dist/index.js",
"./app.scss": "./dist/app.scss"
".": "./dist/index.js"
},
```

The `exports` map decouples your public API from the internal `dist/` directory structure. Consumers import from clean paths (e.g., `@openedx/frontend-app-yourapp/app.scss`) and the map resolves them to the actual files in `dist/`. If your app has SCSS files that downstream site projects need to `@use`, add them as exports as shown above.
Comment thread
brian-smith-tcril marked this conversation as resolved.
The `exports` map decouples your public API from the internal `dist/` directory structure. Consumers import from clean paths and the map resolves them to the actual files in `dist/`.

files
-----
Expand Down Expand Up @@ -770,18 +769,19 @@ Observe the following file and directory structure. Not counting any extra file
```
src
(...)
├── sass
├── slots
├── widgets
├── Main.jsx
├── app.scss
├── app.ts
├── constants.ts
├── index.ts
├── messages.js
├── providers.ts
├── routes.tsx
├── setupTest.tsx
└── slots.tsx
├── slots.tsx
└── style.scss
```

A brief explanation of the new ones:
Expand All @@ -795,6 +795,8 @@ A brief explanation of the new ones:
- `providers.ts`: where global context providers are defined
- `routes.tsx`: where the app's routes are declared
- `slots.tsx`: what slots the app _uses_; this is distinct from the slots the app _offers_, which are defined in the `slots` directory
- `style.scss`: app-scoped runtime styles, imported from component code (an internal implementation detail, not exported)
- `sass`: partials used by `style.scss`, if any

Create, rename, and/or move file contents around to match. Refer to a previously converted MFE (such as [Learner Dashboard](https://github.com/openedx/frontend-app-learner-dashboard/tree/frontend-base/src)) for examples.

Expand Down Expand Up @@ -871,27 +873,38 @@ This may require a little interpretation. In spirit, the modules of your app ar
These modules should be unopinionated about the path prefix where they are mounted.


Create an app.scss file
=======================
Separate runtime styles from the dev harness
============================================

This is required for running the app in dev mode.
Frontend apps deal with two distinct sets of styles, and the distinction matters for the styling of the composing site:

Create a new `app.scss` file at the top of your application. It's responsible for:
1. **Runtime styles**: App-scoped SCSS that lives inside `src/` and is imported from the app's component code. It is an internal implementation detail of the package, never exposed through the `exports` map, and loaded automatically whenever the app's code runs (including as a lazy-loaded route in a larger site).
2. **Dev harness imports**: The shell's style manifest (`@openedx/frontend-base/shell/style`) loads Paragon's base CSS and the shell's own styles. It is imported only from `site.config.dev.tsx`, so it runs when the app is run standalone for development.

1. Using the shell's stylesheet, which includes Paragon's core stylesheet.
2. Using the stylesheets from your application, if any.
> [!IMPORTANT]
> Runtime styles must NOT import `@openedx/frontend-base/shell/style` (or any other source of Paragon base styles). Paragon's base styles set CSS custom properties at `:root`. If a lazy-loaded app chunk re-injects those declarations, they clobber any brand overrides that the composing site has already applied, breaking theming globally. See [the theming guide](./theming.md#css-ownership) for details.

For example:
Runtime styles
--------------

```
@use "@openedx/frontend-base/shell/app.scss";
@use "sass/style";
Keep app-scoped SCSS under `src/` and import it directly from the component code that needs it. The suggested layout is a single `src/style.scss` entry point, with any partials under `src/sass/`:

```ts
import './style.scss';
```

You must then import this file from your `site.config.dev.tsx` file:
Any file layout works, as long as the styles are loaded by the app's own JS/TS modules rather than exposed to consumers.

> [!IMPORTANT]
> Any SCSS entry that uses `@media (--pgn-size-breakpoint-*)` must `@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css"` at the top. That includes `src/style.scss` and every component-level `index.scss` imported directly from JS: each is its own PostCSS pass and does not inherit the declarations from siblings. Missing `@use`s fail silently (the rule never matches any viewport). See [the theming guide](./theming.md#custom-media-breakpoints) for the full rationale.
Comment thread
brian-smith-tcril marked this conversation as resolved.

Dev harness imports
-------------------

Import the shell's style manifest from `site.config.dev.tsx` (NOT from `site.config.build.tsx` or any file that ships). This loads the global styles your app needs when it runs standalone:

```diff
+ import './app.scss';
+ import '@openedx/frontend-base/shell/style';

const siteConfig: SiteConfig = {
// config document
Expand All @@ -900,6 +913,8 @@ const siteConfig: SiteConfig = {
export default siteConfig;
```

Dev harnesses should NOT import brand packages: running against unbranded Paragon defaults surfaces styling bugs that brand overrides would otherwise hide, and it keeps brand assets out of the app's dependency tree entirely.


Document module-specific configuration needs
============================================
Expand Down
84 changes: 84 additions & 0 deletions docs/how_tos/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,90 @@ and instead use a pre-compiled CSS file. In doing so, this allows making
changes to the site theme without needing to necessarily re-build and re-deploy
all consuming applications.

### CSS ownership

A composing site is the **single owner** of global styles. In practice, this
means:

- The site (and only the site) imports `@openedx/frontend-base/shell/style`
(or any other source of Paragon's base CSS) from its `site.config` file.
- The site (and only the site) loads brand overrides, either through the
runtime `theme` configuration described below or by importing brand CSS
after the shell manifest.
- Apps keep their own SCSS as an internal implementation detail (imported from
the app's component code, never exported through `package.json`). These
styles must contain only app-scoped rules: they must not re-bundle shell or
Paragon base styles, and they must not import brand packages at runtime.

This matters because Paragon's base CSS declares CSS custom properties on
`:root`. CSS custom properties live on the element they target, and `var()`
resolves at use-time against the current computed value. Without layering, a
lazy-loaded app chunk shipping its own copy of Paragon's base CSS would run
its `:root` declarations after the site's brand overrides and clobber those
overrides globally.

The build pipeline wraps every stylesheet in a CSS cascade layer based on its
source: Paragon goes in `paragon`, `frontend-base` goes in `shell`,
`node_modules` stylesheets go in `app`, the site's own source goes in
`site`, and `@(open)?edx/brand*` packages go in `brand`. The declared order
is `@layer paragon, shell, app, site, brand;`, so brand tokens and site
overrides out-rank anything an app might accidentally redeclare.

`brand` comes last to match production: in production, brand CSS is
injected at runtime via `<link>` tags that bypass webpack's layering and
land **unlayered**, which beats every layered rule regardless of declared
order. Putting build-time brand imports (e.g. a dev harness that `@use`s a
brand package) in the last layer keeps dev harness behavior consistent:
brand always wins. A site that needs to override runtime brand CSS must do
so with unlayered rules of its own (or `!important`). See [ADR 0008](../decisions/0008-stylesheet-import-in-site-config.md#cascade-layers)
for details.

Apps should still keep shell and brand out of their runtime SCSS: the layer
ordering protects token correctness, but shipping duplicate Paragon base CSS
wastes bandwidth and build time.

Apps should follow the split described in the
[migration guide](./migrate-frontend-app.md#separate-runtime-styles-from-the-dev-harness):
a runtime stylesheet with app-scoped rules, plus a separate dev harness
stylesheet that loads the shell stylesheet only when the app runs standalone.

### Custom media breakpoints

Paragon exposes responsive breakpoints as `@custom-media` declarations (e.g.,
`--pgn-size-breakpoint-min-width-xl`). Browsers do not implement
`@custom-media` natively; the build pipeline substitutes references at
compile time via `postcss-custom-media`. Substitution only works if the
declarations are visible to the **same PostCSS pass** as the `@media` rule
that references them.

Because apps no longer transitively import Paragon's core CSS through the
shell, each SCSS entry that uses `@media (--pgn-size-breakpoint-*)` must
import the declarations itself:

```scss
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css";
```

"Each SCSS entry" means every file that is its own PostCSS pass. In
practice, that is:

- The app's top-level runtime stylesheet (e.g., `src/style.scss`). Covers
every partial it `@use`s via Sass.
- Any component-level `.scss` file imported directly from a JS/TS module
(`import './index.scss'`). Each of these is a separate PostCSS pass and
needs its own `@use`.

Silent failure is the main hazard: a missing `@use` does not produce a
build error. The unresolved `@media (--pgn-size-breakpoint-*)` simply
never matches any viewport, so the rule quietly does not apply. When
debugging a style that "should apply but doesn't," the fix is usually to
add the `@use` at the top of the stylesheet containing the `@media` rule.

The composing site's `@openedx/frontend-base/shell/style` manifest already
pulls in Paragon's core CSS (which carries the declarations), so any
`@media (--pgn-size-breakpoint-*)` in a site-level stylesheet resolves
correctly without the explicit `@use`.

### Dark mode and theme variant preferences

`@openedx/frontend-base` supports both `light` (required) and `dark` (optional)
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
".": "./dist/index.js",
"./tools": "./dist/tools/index.js",
"./tools/tsconfig.json": "./dist/tools/typescript/tsconfig.json",
"./shell/app.scss": "./dist/shell/app.scss",
"./shell/style": "./dist/shell/style.js",
"./shell/site": "./dist/shell/site.js"
},
"files": [
Expand Down Expand Up @@ -53,7 +53,8 @@
},
"sideEffects": [
"*.css",
"*.scss"
"*.scss",
"**/shell/style.{js,ts}"
],
"homepage": "https://github.com/openedx/frontend-base#readme",
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion shell/site.config.dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { EnvironmentTypes, SiteConfig } from '../types';
import { devFooterApp, devHeaderApp, devHomeApp, devUserApp, slotShowcaseApp } from './dev';
import { footerApp, headerApp, shellApp } from '.';

import './app.scss';
import './style';

const siteConfig: SiteConfig = {
apps: [
Expand Down
3 changes: 1 addition & 2 deletions shell/app.scss → shell/style.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@use "@openedx/paragon/dist/core.min.css";
@use "@openedx/paragon/dist/light.min.css";
@layer paragon, shell, app, site, brand;

.flex-basis-0 {
flex-basis: 0 !important;
Expand Down
9 changes: 9 additions & 0 deletions shell/style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Shell style manifest. Each import is a separate webpack module, which
* keeps Paragon's CSS and the shell's own SCSS as independent compilation
* units: the build pipeline wraps each in the `shell` cascade layer by
* resource path. See ADR 0008.
*/
import '@openedx/paragon/dist/core.min.css';
import '@openedx/paragon/dist/light.min.css';
import './style.scss';
Loading
Loading