diff --git a/scripts/stylelint-no-specificity-where.mjs b/scripts/stylelint-no-specificity-where.mjs new file mode 100644 index 00000000..2e2a8492 --- /dev/null +++ b/scripts/stylelint-no-specificity-where.mjs @@ -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); diff --git a/stylelint.config.mjs b/stylelint.config.mjs index 6403dcce..3cd8b4d4 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -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", { diff --git a/styles.css b/styles.css index 76a0c2ba..fd6d9f57 100644 --- a/styles.css +++ b/styles.css @@ -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; } diff --git a/tests/styles.test.ts b/tests/styles.test.ts index 2107483f..318972c8 100644 --- a/tests/styles.test.ts +++ b/tests/styles.test.ts @@ -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\) \{(?[^}]+)\}/.exec(styles) - ?.groups?.["body"] ?? ""; + /\.codex-panel-ui__toolbar-control:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?[^}]+)\}/.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", () => {