Skip to content

feat!: enforce CSS ownership with cascade layers#234

Merged
arbrandes merged 1 commit into
openedx:mainfrom
arbrandes:fix-branding
Apr 22, 2026
Merged

feat!: enforce CSS ownership with cascade layers#234
arbrandes merged 1 commit into
openedx:mainfrom
arbrandes:fix-branding

Conversation

@arbrandes
Copy link
Copy Markdown
Contributor

@arbrandes arbrandes commented Apr 21, 2026

Description

Without cascade layers, CSS from lazy-loaded apps is injected after the site's stylesheet and wins over brand and site overrides at equal specificity. The practical consequence is that apps cannot be reliably styled by a brand package or by the composing site: tokens get clobbered and overrides silently lose.

This PR wraps every stylesheet in one of five cascade layers based on its resolved path: paragon for @openedx/paragon, shell for @openedx/frontend-base, app for anything else from node_modules, site for the composing site's own source, and brand for @(open)?edx/brand* packages. The declared order @layer paragon, shell, app, site, brand; ships at the top of shell/style.scss, so site and brand always out-rank app styles regardless of load order. brand is placed last so build-time brand imports match the precedence of runtime brand CSS, which is injected unlayered via <link> tags and therefore beats every layered rule. A small PostCSS plugin (with unit tests) handles the wrapping at build time.

The shell's styles are now exposed as a JS manifest at @openedx/frontend-base/shell/style rather than a single SCSS file. Each import in the manifest (Paragon core, Paragon light, and the shell's own SCSS) is its own webpack module, so each is classified into its own layer by resolved path. This also removes the need for sites to maintain a top-level site.scss: the site.config file can import the manifest directly.

The stylesheet-import ADR, theming guide, and migration guide are updated to describe the new layering. The test-site reference is updated to demonstrate the correct cascade.

Breaking changes

Apps no longer export their SCSS through package.json. App stylesheets are an internal implementation detail, imported from the app's own component code and loaded by the app at runtime. Sites that imported app stylesheets directly (e.g., @use '@some-org/frontend-app-foo/src/style.scss') must stop doing so.

SCSS entries using @media (--pgn-size-breakpoint-*) must now @use '@openedx/paragon/styles/css/core/custom-media-breakpoints.css' themselves. Each PostCSS pass is its own scope, so a missing import fails silently: the unresolved @media rule never matches any viewport. This applies to every top-level app stylesheet and every component-level .scss imported directly from JS/TS.

The shell's styles are now exposed as a JS manifest at @openedx/frontend-base/shell/style instead of a SCSS file. Sites must replace @use '@openedx/frontend-base/shell/style.scss' with import '@openedx/frontend-base/shell/style' from their site.config. A separate top-level site.scss is no longer required.

Refs #232.

LLM usage notice

Built with assistance from Claude.

Comment thread test-site/package.json
@arbrandes arbrandes force-pushed the fix-branding branch 7 times, most recently from 95d3dd2 to d44b17a Compare April 21, 2026 20:30
@arbrandes arbrandes linked an issue Apr 21, 2026 that may be closed by this pull request
@arbrandes arbrandes changed the title docs: clarify CSS ownership for composing sites vs apps docs: clarify CSS ownership and custom-media scoping for sites vs apps Apr 21, 2026
@arbrandes arbrandes changed the title docs: clarify CSS ownership and custom-media scoping for sites vs apps feat!: enforce CSS ownership with cascade layers Apr 22, 2026
@arbrandes arbrandes force-pushed the fix-branding branch 3 times, most recently from 1171ddf to dd97151 Compare April 22, 2026 12:36
Apps must not re-bundle shell or brand styles in their runtime SCSS. Updates
the migration guide, theming guide, and stylesheet-import ADR, and revises
the test-site reference to demonstrate the correct cascade.

Adds a cascade-layer mechanism so sites and brand packages can reliably style
apps: without layers, app CSS loaded later (via lazy loading) wins over
brand and site overrides at equal specificity, leaving apps effectively
un-brandable. The webpack pipeline now wraps every stylesheet in
paragon/shell/app/site/brand layers by resolved path, so site and brand
always out-rank app styles. Brand is placed last so build-time brand
imports match the precedence of runtime brand CSS, which is injected
unlayered via <link> tags and thus beats every layered rule.

BREAKING CHANGE: Apps no longer export their SCSS through package.json.
Sites that imported app stylesheets directly must stop doing so; app styles
are loaded by the app itself at runtime.

BREAKING CHANGE: SCSS entries using @media (--pgn-size-breakpoint-*) must now
@use Paragon's custom-media-breakpoints.css themselves, since each PostCSS
pass is its own scope and a missing import fails silently.

BREAKING CHANGE: The shell's styles are now exposed as a JS manifest at
@openedx/frontend-base/shell/style instead of a SCSS file. Sites must
replace '@use \"@openedx/frontend-base/shell/style.scss\"' with
'import \"@openedx/frontend-base/shell/style\"' from their site.config.
The manifest keeps Paragon's CSS and the shell's own SCSS as independent
webpack modules so each is classified into its own cascade layer.

Refs openedx#232

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@brian-smith-tcril brian-smith-tcril left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this makes a lot of sense. I'm a big fan of the ADR, it definitely makes the "where do styles live and where are they loaded from?" story a lot clearer.

I left a couple comments, a few little questions and a nit.

Comment thread docs/how_tos/migrate-frontend-app.md
Comment thread docs/how_tos/migrate-frontend-app.md
Comment thread docs/how_tos/migrate-frontend-app.md Outdated
Comment thread tools/webpack/common-config/all/getStylesheetRule.ts Outdated
Comment thread test-site/package.json
@arbrandes
Copy link
Copy Markdown
Contributor Author

Brian, I just finished testing it via openedx/frontend-template-site#14 (and frontend-base npm run dev, and test-site run dev). It's really nice seeing the layers in action in the Firefox inspector. :)

Copy link
Copy Markdown
Contributor

@brian-smith-tcril brian-smith-tcril left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great. Looking at the frontend-template-site PR and realizing that the site.scss

@use '@openedx/frontend-base/shell/app.scss' as shell;
@use '@openedx/brand-openedx/core.min.css';
@use '@openedx/brand-openedx/light.min.css';

could be shuffled completely and it would all still work is awesome.

The separation of concerns is huge:

  • site.scss: @use the things you need to have in the bundle, order doesn't matter
  • postcssWrapLayer: figure out all the order stuff

@arbrandes arbrandes merged commit 5be3c5e into openedx:main Apr 22, 2026
5 checks passed
@openedx-semantic-release-bot
Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0-alpha.33 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix frontend-base branding

3 participants