Bug 2068024 - Stylelint: create the no-background-without-text-color rule r=hjones,desktop-theme-reviewers

The rule ships `meta.fixable: false`; bug 2068773 adds the fix, holding it off on the annotated sites with the disable comment stylelint already honours for fixes.

Differential Revision: https://phabricator.services.mozilla.com/D322597
This commit is contained in:
Dão Gottwald
2026-09-10 03:55:11 +00:00
committed by dgottwald@mozilla.com
parent ac0311aaf6
commit 12eef3d296
6 changed files with 563 additions and 1 deletions
+1
View File
@@ -279,6 +279,7 @@ module.exports = {
// unrelated errors in the same query.
"media-query-no-invalid": null,
"stylelint-plugin-mozilla/media-query-no-invalid": true,
"stylelint-plugin-mozilla/no-background-without-text-color": true,
"stylelint-plugin-mozilla/no-base-design-tokens": true,
"stylelint-plugin-mozilla/no-has-selector": true,
"stylelint-plugin-mozilla/use-design-tokens": true,
@@ -0,0 +1,160 @@
# no-background-without-text-color (stylelint)
This rule requires a text color from any declaration block that paints its
background with a design token that has a paired text color token.
A block that paints a background claims a surface, and it owes that surface a
text color. Without one, the text takes whatever color an unrelated ancestor
happens to supply, and nothing guarantees the two have sufficient contrast in
every theme, in dark mode, under `prefers-contrast`, or under `forced-colors`
high contrast mode, which applies to chrome windows on Windows and to `about:`
pages on every platform.
The counterpart of the background token is the text color the surface was
designed for, so the rule's message names it;
[use-paired-color-tokens](use-paired-color-tokens.md) is what checks a
combination once both halves are declared.
## Rule Scope
The rule reports a block whose winning `background` or `background-color`
declaration reads a background token that has a paired text color token, and
which sets no `color`. A background token without a counterpart is meant to
combine with whatever the surface inherits, so it makes no claim to report.
Blocks selected by a state — `:hover`, `:focus`, `[open]`, `[disabled]` and
their kin — are exempt, along with blocks nested inside one. A state variant
usually restyles an element its base rule has already given a text color, and
that base rule is generally a flat sibling the rule cannot reach. A negated
state such as `:not(:hover)` names the base state itself, so a block selected
that way owes the surface a text color and is reported.
Read that as a gap in the rule, not as permission. Declare both halves in a
state variant too: `--button-text-color-hover` and `--button-text-color` are
separate tokens that can resolve to entirely different values under
`forced-colors`, so a state that repaints the background and inherits the base
rule's text color is not safe there.
Declarations directly inside an at-rule are checked as their own block, since a
`@media` query can paint a background the rule around it does not. The element
is the same one either way, so a `color` on the rule covers what the at-rule
paints:
```css
.card {
color: var(--panel-text-color);
@media -moz-pref("browser.nova.enabled") {
/* Fine: the color above applies to this element in every query. */
background-color: var(--panel-background-color);
}
}
```
A nested *rule* matches a different element and stands on its own.
A block can hand the surface a text color through a custom property instead of a
`color` declaration, which is how a component that renders the text in its own
shadow tree takes one. Defining a paired text token, or a property that reads
one, satisfies the rule:
```css
.new-badge {
background-color: var(--badge-background-color-filled);
--badge-text-color: var(--badge-text-color-filled);
}
```
The rule does not require the reverse: a block that sets only a text color is
usually a descendant of the element painting the background, which the rule
cannot see.
## Examples of incorrect usage for this rule
```css
#header {
background-color: var(--sidebar-background-color);
}
```
## Examples of correct usage for this rule
```css
#header {
background-color: var(--sidebar-background-color);
color: var(--sidebar-text-color);
}
```
```css
.toolbar-button:hover {
background-color: var(--button-background-color-hover);
color: var(--button-text-color-hover);
}
```
## Autofix functionality
None yet;
[bug 2068773](https://bugzilla.mozilla.org/show_bug.cgi?id=2068773) adds it. The
counterpart is fixed by the design system rather than chosen by the author,
which is why the message can name it, and the rule only reports where that
counterpart exists, so the declaration to insert is determined. What the block
alone does not say is whether the surface takes its text color from elsewhere on
purpose, where inserting one is a silent rendering change. Such a block carries
a disable comment, which stylelint honours for fixes as well as reports — so the
fix goes in ungated and the disable comment described below is what holds it
off.
## Disabling the rule
**Prefer declaring the color.** The tempting disable is the one that reasons
about what the block contains — this element holds no text, its icon takes the
color from a `fill` below, a child element colors itself. Those are all easy to
falsify: markup changes, `forced-colors` turns a translucent wash into an opaque
`ButtonFace`, and the next person to put a text node in the element inherits
whatever happens to be there. Declaring the counterpart costs one line and is
usually value-neutral in the default theme.
A `::part()` is not an exception to that. A `::part()` rule in the outer tree
overrides the component's own declaration for that part, so a block repainting a
part's background sets the `color` in the same block:
```css
.section-context-menu.context-menu-open > moz-button::part(button) {
background-color: var(--button-background-color-ghost-hover);
color: var(--button-text-color-ghost-hover);
}
```
`moz-select.css` is the in-tree example to follow: its
`panel-item[selected]::part(button)` sets `background-color` and `color` together
on the element `panel-item.css` gives `background-color: transparent` and
`color: inherit`.
Where a component remaps a background token into its own palette, remap the text
counterpart beside it. Pairing the remapped background with the palette's own
text token instead is what
[use-paired-color-tokens](use-paired-color-tokens.md) rejects:
```css
panel-list {
--button-background-color-hover: var(--smartwindow-panel-item-background-color-hover);
--button-text-color-hover: var(--panel-list-text-color);
}
```
That leaves the disable worth writing, for a block where declaring the color
would do damage or would not resolve at all:
```css
/* The tracker count below takes the box text color; a color here would recolor
the shield. */
/* stylelint-disable-next-line stylelint-plugin-mozilla/no-background-without-text-color */
background-color: var(--urlbar-box-background-color);
```
The comment must name what setting a color here would break, or where the color
comes from instead, in terms a reviewer can check. If the surface should have a
text color of its own but the token does not exist, file a bug for the missing
token and reference it from a `TODO`.
@@ -15,7 +15,10 @@ in the theme the author did not try.
The rule only reports a declaration block that sets both a background color and
a text color, because a block that sets one of them takes the other from
somewhere the rule cannot see: an ancestor, a sibling rule, or another
pseudo-element.
pseudo-element. Whether a block that paints a surface owes it a text color at
all is
[no-background-without-text-color](no-background-without-text-color.md)'s
question.
Within such a block it reports two things:
@@ -5,6 +5,7 @@
*/
import mediaQueryNoInvalid from "./media-query-no-invalid.mjs";
import noBackgroundWithoutTextColor from "./no-background-without-text-color.mjs";
import noBaseDesignTokens from "./no-base-design-tokens.mjs";
import noBrowserRefsInToolkit from "./no-browser-refs-in-toolkit.mjs";
import noHasSelector from "./no-has-selector.mjs";
@@ -13,6 +14,7 @@ import usePairedColorTokens from "./use-paired-color-tokens.mjs";
export default {
"media-query-no-invalid": mediaQueryNoInvalid,
"no-background-without-text-color": noBackgroundWithoutTextColor,
"no-base-design-tokens": noBaseDesignTokens,
"no-browser-refs-in-toolkit": noBrowserRefsInToolkit,
"no-has-selector": noHasSelector,
@@ -0,0 +1,185 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import stylelint from "stylelint";
import {
backgroundToText,
customPropertiesRead,
findColorDeclarations,
isCustomPropertyDefinition,
namespace,
textToBackground,
} from "../helpers.mjs";
const {
utils: { report, ruleMessages, validateOptions },
} = stylelint;
let ruleName = namespace("no-background-without-text-color");
let messages = ruleMessages(ruleName, {
noTextColor: (background, text) =>
`"${background}" should be used with a text color; add "color: var(${text})", or disable the rule with a comment saying where the text color comes from.`,
});
let meta = {
url: "https://firefox-source-docs.mozilla.org/code-quality/lint/linters/stylelint-plugin-mozilla/rules/no-background-without-text-color.html",
fixable: false,
};
// A state variant usually restyles an element its base rule has already given a
// text color, so state selectors are exempt. That base rule is generally a flat
// sibling (`.foo:hover {}` beside `.foo {}`) rather than a nesting parent, which
// is why this keys on the selector rather than walking up.
const STATE_PSEUDO_CLASS =
/:(?:active|checked|current|default|disabled|enabled|focus|focus-visible|focus-within|hover|in-range|indeterminate|invalid|open|out-of-range|past|paused|placeholder-shown|playing|popover-open|read-only|read-write|target|user-invalid|user-valid|valid|visited|-moz-broken|-moz-drag-over|-moz-focusring|-moz-window-inactive)\b/;
// Chrome markup carries the states a pseudo-class cannot express as attributes
// instead, so `menulist[disabled]` is a state variant of `menulist`.
const STATE_ATTRIBUTE =
/\[\s*(?:active|aria-checked|aria-current|aria-disabled|aria-expanded|aria-pressed|aria-selected|busy|checked|disabled|focused|open|pressed|selected)\s*[\]~^|$*=]/;
/**
* Drops the argument of every `:not()`, innermost first. A negated state names
* the base state rather than a variant of it, so `&:not(:hover)` is the rule
* that owes the surface a text color, not one that inherits it from elsewhere.
*
* @param {string} selector
* @returns {string}
*/
let stripNegations = selector => {
for (let previous; selector != previous; ) {
previous = selector;
selector = selector.replace(/:not\([^()]*\)/g, "");
}
return selector;
};
/**
* Whether a selector says the text color is not this block's to declare.
*
* @param {string} selector
* @returns {boolean}
*/
let isExemptSelector = selector => {
let states = stripNegations(selector);
return STATE_PSEUDO_CLASS.test(states) || STATE_ATTRIBUTE.test(states);
};
/**
* Whether the selector of a block, or of a block it is nested in, exempts it.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @returns {boolean}
*/
let isExempt = block => {
for (let node = block; node; node = node.parent) {
if (
node.type == "rule" &&
isExemptSelector(node.raws.selector?.raw ?? node.selector)
) {
return true;
}
}
return false;
};
/**
* Whether the element a block styles is given a text color. An at-rule nested
* in a rule styles that rule's element, so a `color` the rule sets applies to
* the at-rule's surface too; a nested rule matches a different element.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @returns {boolean}
*/
let hasTextColor = block => {
for (let node = block; node; node = node.parent) {
if (findColorDeclarations(node).text) {
return true;
}
if (node.type != "atrule") {
return false;
}
}
return false;
};
/**
* Whether a block gives the surface a text color through a custom property,
* which is how a component rendering the text in its own shadow tree takes
* one.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @returns {boolean}
*/
let definesTextColor = block =>
block.nodes.some(
node =>
node.type == "decl" &&
isCustomPropertyDefinition(node) &&
(textToBackground.has(node.prop) ||
customPropertiesRead(node.value).some(token =>
textToBackground.has(token)
))
);
/**
* Reports a declaration block that paints a paired background token and sets
* no text color, leaving the surface to inherit one no theme guarantees the
* contrast of. Only the declarations that win the cascade within the block are
* considered.
*
* @param {object} block - A PostCSS Rule or AtRule.
* @param {object} result - The PostCSS result to report to.
*/
let checkBlock = (block, result) => {
let { background } = findColorDeclarations(block);
if (
!background ||
hasTextColor(block) ||
definesTextColor(block) ||
isExempt(block)
) {
return;
}
let [paired] = customPropertiesRead(background.value).filter(token =>
backgroundToText.has(token)
);
if (!paired) {
return;
}
report({
message: messages.noTextColor(paired, backgroundToText.get(paired)),
node: background,
result,
ruleName,
});
};
let ruleFunction = primaryOption => {
return (root, result) => {
let validOptions = validateOptions(result, ruleName, {
actual: primaryOption,
possible: [true],
});
if (!validOptions) {
return;
}
// Declarations nest directly inside an at-rule as well as inside a rule,
// which is the shape of every generated token sheet. A statement at-rule
// such as @namespace has no block and so no nodes at all.
root.walk(node => {
if ((node.type == "rule" || node.type == "atrule") && node.nodes) {
checkBlock(node, result);
}
});
};
};
ruleFunction.ruleName = ruleName;
ruleFunction.messages = messages;
ruleFunction.meta = meta;
export default ruleFunction;
@@ -0,0 +1,211 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Bug 1948378: remove this exception when the eslint import plugin fully
// supports exports in package.json files
// eslint-disable-next-line import/no-unresolved
import { testRule } from "stylelint-test-rule-node";
import stylelint from "stylelint";
import noBackgroundWithoutTextColor from "../rules/no-background-without-text-color.mjs";
let plugin = stylelint.createPlugin(
noBackgroundWithoutTextColor.ruleName,
noBackgroundWithoutTextColor
);
let {
ruleName,
rule: { messages },
} = plugin;
testRule({
plugins: [plugin],
ruleName,
config: [true],
fix: false,
accept: [
{
code: ".a { background-color: var(--button-background-color); color: var(--button-text-color); }",
description: "A background token with a text color.",
},
{
code: ".a { color: var(--button-text-color); }",
description: "A block that paints no background claims no surface.",
},
{
code: ".a { background-color: var(--color-accent-primary); }",
description: "A base color is not a background token.",
},
{
code: ".a { background-color: var(--text-color-deemphasized); }",
description:
"A token with no background counterpart guarantees nothing to pair with.",
},
{
code: ".a { background-color: transparent; }",
description: "A background that paints nothing claims no surface.",
},
{
code: ".a { background-color: var(--my-local-background); }",
description: "A local custom property is not a token.",
},
{
code: ".a { --box-background: var(--button-background-color); }",
description: "Defining a custom property is not painting a surface.",
},
{
code: ".a:hover { background-color: var(--button-background-color-hover); }",
description:
"A state variant takes the text color from the rule it varies.",
},
{
code: ".a { &:hover { background-color: var(--button-background-color-hover); } }",
description: "A nested state variant is one as well.",
},
{
code: ".a:hover { .b { background-color: var(--button-background-color-hover); } }",
description: "A block nested inside a state variant is covered too.",
},
{
code: "menulist[disabled] { background-color: var(--button-background-color); }",
description: "A state carried as an attribute is a state variant.",
},
{
code: '.a[aria-expanded="true"] { background-color: var(--button-background-color); }',
description: "An ARIA state attribute is one as well.",
},
{
code: ".a:not(.b):is(:hover, [open]) { background-color: var(--button-background-color); }",
description: "A state beside a negation still exempts the block.",
},
{
code: ".a { background-color: var(--button-background-color); color: inherit; }",
description: "Any text color satisfies the rule, token or not.",
},
{
code: ".a { color: CanvasText; @media (prefers-contrast) { background-color: var(--button-background-color); } }",
description:
"An at-rule paints the surface of the rule around it, which has a text color.",
},
{
code: ".a { color: var(--panel-text-color); background-color: var(--panel-background-color); }",
description: "Declaration order does not matter.",
},
{
code: "@namespace url(http://www.w3.org/1998/Math/MathML);",
description: "A statement at-rule has no block to check.",
},
{
code: ".a[type='text'] { background-color: var(--button-background-color); color: var(--button-text-color); }",
description: "An attribute that is not a state is not a carve-out.",
},
{
code: ".a { background-color: var(--badge-background-color-filled); --badge-text-color: var(--badge-text-color-filled); }",
description:
"A paired text token handed to a component through a custom property.",
},
{
code: ".a { background-color: var(--button-background-color-ghost); --button-text-color-ghost: currentColor; }",
description: "Defining the paired text token itself counts as well.",
},
],
reject: [
{
code: ".a { background-color: var(--sidebar-background-color); }",
message: messages.noTextColor(
"--sidebar-background-color",
"--sidebar-text-color"
),
description: "A block that paints a surface and names no text color.",
line: 1,
column: 6,
},
{
code: ".a { background: var(--panel-background-color) no-repeat; }",
message: messages.noTextColor(
"--panel-background-color",
"--panel-text-color"
),
description: "The background shorthand paints a surface too.",
line: 1,
column: 6,
},
{
code: ".a { background-color: light-dark(var(--button-background-color-hover), var(--button-background-color-hover)); }",
message: messages.noTextColor(
"--button-background-color-hover",
"--button-text-color-hover"
),
description: "A token nested in light-dark() paints a surface.",
line: 1,
column: 6,
},
{
code: ".a { @media (prefers-contrast) { background-color: var(--sidebar-background-color); } }",
message: messages.noTextColor(
"--sidebar-background-color",
"--sidebar-text-color"
),
description:
"Declarations nested directly inside an at-rule are checked.",
line: 1,
column: 34,
},
{
code: ".a { background-color: var(--sidebar-background-color); color: var(--sidebar-text-color); &::after { background-color: var(--panel-background-color); } }",
message: messages.noTextColor(
"--panel-background-color",
"--panel-text-color"
),
description:
"A pseudo-element paints its own surface and inherits nothing from its host block.",
line: 1,
column: 102,
},
{
code: ".a { --box-radius: 4px; background-color: var(--sidebar-background-color); }",
message: messages.noTextColor(
"--sidebar-background-color",
"--sidebar-text-color"
),
description:
"A custom property that carries no text color does not excuse the block.",
line: 1,
column: 25,
},
{
code: ".a { background-color: var(--button-background-color); @media (prefers-contrast) { color: CanvasText; } }",
message: messages.noTextColor(
"--button-background-color",
"--button-text-color"
),
description:
"A text color set only inside a nested at-rule leaves the surface without one outside it.",
line: 1,
column: 6,
},
{
code: ".a { &:not(:hover) { background-color: var(--button-background-color); } }",
message: messages.noTextColor(
"--button-background-color",
"--button-text-color"
),
description:
"A negated state names the base state, which owes the surface a text color.",
line: 1,
column: 22,
},
{
code: ".a:not([open]) { background-color: var(--button-background-color); }",
message: messages.noTextColor(
"--button-background-color",
"--button-text-color"
),
description: "A negated state attribute is no exemption either.",
line: 1,
column: 18,
},
],
});