mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user