DEV: Enforce :where() for .uc-* upcoming-change classes via stylelint (#41917)

Upcoming changes with `body_class: true` add a `uc-*` class to `<body>`.
This class is temporary, so we do not want core/theme/plugin CSS to
become dependent on it. Therefore we must not allow it to contribute
specificity to selectors.

This commit adds a `discourse/uc-classes-in-where` which reports any
misuse of `.uc-*` classes, fixes up some existing cases, and documents
the pattern in the upcoming-changes skill.
This commit is contained in:
David Taylor
2026-07-22 15:26:59 +01:00
committed by GitHub
parent 8373fdf80a
commit ce04edc1ae
11 changed files with 63 additions and 8 deletions
@@ -222,6 +222,7 @@ enable_your_feature_name:
#### Key Behaviors
- **Opt-in only**: Omitting `body_class` (or setting it `false`) means no body class — the default. Add it only when you actually have CSS keyed on `uc-{name}`.
- **Always wrap in `:where()`**: Style the class as `:where(.uc-{name})`, never a bare `.uc-{name}`, so the transitional class adds zero specificity and stays safe to unwrap and delete later — the `discourse/uc-classes-in-where` stylelint rule enforces this.
- **Enabled-for-user gated**: The class only appears for users the change is enabled for (via `currentUserUpcomingChanges`), not globally. Anonymous/ineligible users won't get it.
- **Integrity-checked**: `body_class` is in the integrity spec's `allowed_keys` and must be a boolean — see [Mocking Metadata](#mocking-metadata) for how to set it in tests.
+1 -1
View File
@@ -83,7 +83,7 @@ upcoming_change:
| `[specific_groups]` | No one, Specific group(s) |
| `[staff, specific_groups]` | No one, Staff, Specific group(s) |
**Optional:** Add `include_css: true` if you need to scope CSS to this change. When enabled for a user, a `uc-<dasherized-setting-name>` class is added to `<body>` so stylesheets can gate visuals on the change (e.g. `enable_your_feature_name``body.uc-enable-your-feature-name`). Omit it (the default) when the change has no CSS keyed on the body class — body classes are opt-in, not emitted for every change.
**Optional:** Add `include_css: true` if you need to scope CSS to this change. When enabled for a user, a `uc-<dasherized-setting-name>` class is added to `<body>` so stylesheets can gate visuals on the change (e.g. `enable_your_feature_name``body.uc-enable-your-feature-name`). Omit it (the default) when the change has no CSS keyed on the body class — body classes are opt-in, not emitted for every change. Always scope this CSS as `:where(.uc-<dasherized-setting-name>)`, never a bare `.uc-<name>`, so the transitional class adds zero specificity and stays safe to remove later — enforced by the `discourse/uc-classes-in-where` stylelint rule.
```yaml
upcoming_change:
@@ -1250,7 +1250,7 @@ body.has-sidebar-page {
}
}
.uc-enable-composer-redesign {
:where(.uc-enable-composer-redesign) {
// flat to match the rest of the bar, aligned to the content gutter
#reply-control .reply-to .composer-actions-trigger {
padding-inline-start: 0;
@@ -1,6 +1,6 @@
@use "lib/viewport";
.uc-floating-dismiss-topics-on-mobile {
:where(.uc-floating-dismiss-topics-on-mobile) {
@include viewport.until(sm) {
.dismiss-container-top {
position: fixed;
@@ -1,4 +1,4 @@
.uc-enable-new-checkbox-style {
:where(.uc-enable-new-checkbox-style) {
.form-kit {
&__control-checkbox {
/* hide native checkbox */
+2 -2
View File
@@ -322,7 +322,7 @@ html {
}
// Modernize Foundation Theme specific WCAG overrides
html.discourse-no-touch .uc-modernize-foundation-theme {
html.discourse-no-touch :where(.uc-modernize-foundation-theme) {
.btn-default {
.d-icon {
color: var(--d-button-default-icon-color);
@@ -347,7 +347,7 @@ html.discourse-no-touch .uc-modernize-foundation-theme {
}
}
html .uc-modernize-foundation-theme {
html :where(.uc-modernize-foundation-theme) {
// Header icons with modernize theme colors
.d-header-icons .d-icon,
.header-sidebar-toggle button .d-icon {
+1
View File
@@ -19,6 +19,7 @@
"lint-to-the-future": "^3.2.0",
"lint-to-the-future-eslint": "^4.0.0",
"playwright": "1.59.1",
"postcss-selector-parser": "^7.1.4",
"prettier": "3.8.1",
"stylelint": "17.5.0",
"typescript": "^5.9.3"
+3
View File
@@ -90,6 +90,9 @@ importers:
playwright:
specifier: 1.59.1
version: 1.59.1
postcss-selector-parser:
specifier: ^7.1.4
version: 7.1.4
prettier:
specifier: 3.8.1
version: 3.8.1
+48
View File
@@ -0,0 +1,48 @@
import stylelint from "stylelint";
import parser from "postcss-selector-parser";
const ruleName = "discourse/uc-classes-in-where";
// `.uc-*` classes are the `uc-<dasherized-setting-name>` body classes emitted
// for upcoming changes (feature flags with `include_css: true`). They gate
// transitional CSS for a change and are removed once it becomes permanent, so
// they must never contribute specificity: every use has to sit inside a
// `:where()` clause. This keeps the styling safe to unwrap and delete later
// without leaving behind rules that silently relied on the class's specificity.
//
// A `.uc-*` class is allowed only when one of its ancestor nodes is a
// `:where()` pseudo-class.
function isInsideWhere(node) {
for (let parent = node.parent; parent; parent = parent.parent) {
if (parent.type === "pseudo" && parent.value.toLowerCase() === ":where") {
return true;
}
}
return false;
}
export default stylelint.createPlugin(ruleName, (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkRules((rule) => {
parser((selectors) => {
selectors.walkClasses((classNode) => {
if (!classNode.value.startsWith("uc-") || isInsideWhere(classNode)) {
return;
}
stylelint.utils.report({
message: `Wrap the upcoming-change class ".${classNode.value}" in a :where() clause so it does not contribute specificity`,
node: rule,
result,
ruleName,
word: "." + classNode.value,
});
});
}).processSync(rule.selector);
});
};
});
+3 -1
View File
@@ -1,11 +1,13 @@
import noCoreVariables from "./stylelint-rules/no-core-variables.mjs";
import requireDesignTokens from "./stylelint-rules/require-design-tokens.mjs";
import ucClassesInWhere from "./stylelint-rules/uc-classes-in-where.mjs";
export default {
extends: ["@discourse/lint-configs/stylelint"],
plugins: [noCoreVariables, requireDesignTokens],
plugins: [noCoreVariables, requireDesignTokens, ucClassesInWhere],
rules: {
"media-feature-range-notation": "context",
"discourse/uc-classes-in-where": true,
},
overrides: [
{
+1 -1
View File
@@ -103,6 +103,6 @@ html {
--discourse_id-border: var(--discourse_id-background) !important;
}
.uc-modernize-foundation-theme {
:where(.uc-modernize-foundation-theme) {
--d-sidebar-active-background: var(--d-selected) !important;
}