Harden CSS specificity lint

This commit is contained in:
murashit 2026-05-29 10:59:58 +09:00
parent cf7b717517
commit 7e1f86bd6e
4 changed files with 95 additions and 18 deletions

View file

@ -0,0 +1,67 @@
import stylelint from "stylelint";
const ruleName = "codex-panel/no-specificity-where";
const messages = stylelint.utils.ruleMessages(ruleName, {
rejected: (selector) => `Do not hide class, id, or attribute selectors inside :where(): "${selector}"`,
});
function rule(primary) {
return (root, result) => {
if (primary === false) return;
root.walkRules((ruleNode) => {
const selector = ruleNode.selector;
if (!selector.includes(":where(")) return;
for (const range of whereRanges(selector)) {
const body = selector.slice(range.bodyStart, range.bodyEnd);
if (!/[.#[]/.test(body)) continue;
stylelint.utils.report({
message: messages.rejected,
messageArgs: [selector.slice(range.start, range.end)],
node: ruleNode,
result,
ruleName,
index: range.start,
endIndex: range.end,
});
}
});
};
}
function whereRanges(selector) {
const ranges = [];
let searchFrom = 0;
while (searchFrom < selector.length) {
const start = selector.indexOf(":where(", searchFrom);
if (start === -1) break;
const bodyStart = start + ":where(".length;
let depth = 1;
let index = bodyStart;
for (; index < selector.length && depth > 0; index += 1) {
const char = selector[index];
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
}
if (depth === 0) {
ranges.push({ start, bodyStart, bodyEnd: index - 1, end: index });
searchFrom = index;
} else {
searchFrom = bodyStart;
}
}
return ranges;
}
rule.ruleName = ruleName;
rule.messages = messages;
export default stylelint.createPlugin(ruleName, rule);

View file

@ -1,6 +1,15 @@
import noSpecificityWhere from "./scripts/stylelint-no-specificity-where.mjs";
export default {
extends: ["stylelint-config-standard"],
plugins: [noSpecificityWhere],
rules: {
"codex-panel/no-specificity-where": [
true,
{
severity: "warning",
},
],
"color-named": [
"never",
{

View file

@ -373,7 +373,7 @@
stroke: currentcolor;
}
.codex-panel-ui__toolbar-control:not(.is-active):focus:not(:hover):not(:focus-visible) {
.codex-panel-ui__toolbar-control:where(:focus:not(:hover):not(:focus-visible)) {
background: transparent;
box-shadow: none;
color: var(--icon-color);
@ -619,24 +619,20 @@
font-size: var(--codex-panel-toolbar-meta-size);
}
.codex-panel__toolbar-panel-item:hover:not(:where(:disabled, .is-disabled)) {
.codex-panel__toolbar-panel-item:hover:not(:disabled):not(.is-disabled) {
color: var(--nav-item-color-hover, var(--text-normal));
background: var(--background-modifier-hover);
box-shadow: none;
transform: none;
}
.codex-panel__toolbar-panel-item:hover:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-label,
.codex-panel__toolbar-panel-item:focus:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-label,
.codex-panel__toolbar-panel-item:focus-visible:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-label,
.codex-panel__toolbar-panel-item:active:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-label {
.codex-panel__toolbar-panel-item:where(:hover, :focus, :focus-visible, :active):not(:disabled):not(.is-disabled)
.codex-panel__toolbar-panel-label {
color: var(--nav-item-color-hover, var(--text-normal));
}
.codex-panel__toolbar-panel-item:hover:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-meta,
.codex-panel__toolbar-panel-item:focus:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-meta,
.codex-panel__toolbar-panel-item:focus-visible:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-meta,
.codex-panel__toolbar-panel-item:active:not(:where(:disabled, .is-disabled)) .codex-panel__toolbar-panel-meta {
.codex-panel__toolbar-panel-item:where(:hover, :focus, :focus-visible, :active):not(:disabled):not(.is-disabled)
.codex-panel__toolbar-panel-meta {
color: var(--nav-item-color-hover, var(--text-normal));
}
@ -1242,7 +1238,7 @@
grid-template-columns: minmax(0, 1fr) auto auto;
}
.codex-panel__thread:hover:not(:where(:disabled, .is-disabled)),
.codex-panel__thread:hover:not(:disabled):not(.is-disabled),
.codex-panel__thread.is-checked {
background: transparent;
}
@ -1653,11 +1649,11 @@
animation: codex-panel-reasoning-dot 1.2s infinite ease-in-out;
}
.codex-panel__reasoning:where(.is-active) .codex-panel__reasoning-dots span:nth-child(2) {
.codex-panel__reasoning.is-active .codex-panel__reasoning-dots span:where(:nth-child(2)) {
animation-delay: 0.18s;
}
.codex-panel__reasoning:where(.is-active) .codex-panel__reasoning-dots span:nth-child(3) {
.codex-panel__reasoning.is-active .codex-panel__reasoning-dots span:where(:nth-child(3)) {
animation-delay: 0.36s;
}
@ -2093,11 +2089,11 @@
animation: codex-panel-reasoning-dot 1.2s infinite ease-in-out;
}
.codex-panel-selection-rewrite__status:where(.is-active) .codex-panel-selection-rewrite__status-dots span:nth-child(2) {
.codex-panel-selection-rewrite__status.is-active .codex-panel-selection-rewrite__status-dots span:where(:nth-child(2)) {
animation-delay: 0.18s;
}
.codex-panel-selection-rewrite__status:where(.is-active) .codex-panel-selection-rewrite__status-dots span:nth-child(3) {
.codex-panel-selection-rewrite__status.is-active .codex-panel-selection-rewrite__status-dots span:where(:nth-child(3)) {
animation-delay: 0.36s;
}

View file

@ -17,14 +17,19 @@ describe("panel CSS token scope", () => {
});
describe("chat toolbar CSS", () => {
it("does not let mouse-focus reset override active toolbar controls", () => {
it("keeps mouse-focus reset less specific than active toolbar controls", () => {
const toolbarMouseFocus =
/\.codex-panel-ui__toolbar-control:not\(\.is-active\):focus:not\(:hover\):not\(:focus-visible\) \{(?<body>[^}]+)\}/.exec(styles)
?.groups?.["body"] ?? "";
/\.codex-panel-ui__toolbar-control:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
"body"
] ?? "";
expect(toolbarMouseFocus).toContain("background: transparent");
expect(toolbarMouseFocus).toContain("color: var(--icon-color)");
});
it("keeps class selectors out of zero-specificity :where selectors", () => {
expect(styles).not.toMatch(/:where\([^)]*[.#[]/);
});
});
describe("threads view CSS", () => {