Move CSS lint policy to Biome

This commit is contained in:
murashit 2026-06-24 21:53:19 +09:00
parent 6afe931d01
commit f125a4cf17
14 changed files with 189 additions and 1378 deletions

View file

@ -25,7 +25,10 @@
"./scripts/lint/grit/no-non-reexport-index.grit",
"./scripts/lint/grit/no-self-referential-initializer-callback.grit",
"./scripts/lint/grit/no-unsafe-iterator-value.grit",
"./scripts/lint/grit/no-uncontrolled-preact-form-state.grit"
"./scripts/lint/grit/no-uncontrolled-preact-form-state.grit",
// CSS source policy.
"./scripts/lint/grit/no-restricted-css-policy.grit"
],
"files": {
"includes": [
@ -72,17 +75,18 @@
"noFloatingPromises": "error",
"noMisusedPromises": "error",
"noUnsafePlusOperands": "error",
"useExhaustiveSwitchCases": "error"
"useExhaustiveSwitchCases": "error",
"noExcessiveSelectorClasses": {
"level": "error",
"options": {
"maxClasses": 4
}
}
},
"suspicious": {
"noArrayIndexKey": "error",
"noImportCycles": "error"
}
}
},
"css": {
"linter": {
"enabled": false
}
}
}

View file

@ -14,7 +14,7 @@ Use this as the normal edit loop. `npm run format` applies Biome formatting and
Use focused scripts for tight loops: `npm run typecheck`, `npm run test`, `npm run lint`, or `npm run build`. The `*:ci` variants match CI cache behavior.
Biome owns formatting, import organization, general JavaScript/TypeScript/JSON linting, GritQL source-shape plugins, and GritQL import-boundary restrictions. Biome warnings fail `npm run lint` and `npm run check`; info diagnostics stay advisory, including accessibility while Obsidian-specific UI semantics are reviewed rule by rule. CSS lint policy stays in Stylelint and the CSS usage script. ESLint remains for typed unsafe-any checks, Obsidian plugin policy, and Codex Panel responsibility-boundary rules that need TypeScript type information.
Biome owns formatting, import organization, general JavaScript/TypeScript/JSON/CSS linting, GritQL source-shape plugins, GritQL import-boundary restrictions, and GritQL CSS source policy. Biome warnings fail `npm run lint` and `npm run check`; info diagnostics stay advisory, including accessibility while Obsidian-specific UI semantics are reviewed rule by rule. The CSS usage script still checks dead authored classes. ESLint remains for typed unsafe-any checks, Obsidian plugin policy, and Codex Panel responsibility-boundary rules that need TypeScript type information.
## Generated and Loaded Files

1181
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -42,8 +42,6 @@
"jsdom": "^29.1.1",
"knip": "^6.16.1",
"obsidian": "~1.12.3",
"stylelint": "^17.13.0",
"stylelint-config-standard": "^40.0.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.61.0",
"vitest": "^4.1.8"

View file

@ -39,7 +39,6 @@ function nonBiomeLintCommands({ ciMode, namePrefix = "" }) {
name: `${namePrefix}eslint`,
command: `eslint src --max-warnings=0${eslintCacheArgs}`,
},
{ name: `${namePrefix}css`, command: 'stylelint "src/**/*.css" --max-warnings=0' },
{ name: `${namePrefix}css-usage`, command: "node scripts/lint/check-css-usage.mjs" },
{ name: `${namePrefix}unused`, command: "knip --no-progress" },
];

View file

@ -0,0 +1,57 @@
engine biome(1.0)
language css
or {
// Keep colors on Obsidian or Codex Panel design tokens.
CssDeclarationWithSemicolon() as $decl where {
$decl <: r"\s*(?:background(?:-color)?|border(?:-(?:block|inline|top|right|bottom|left))?(?:-color)?|box-shadow|caret-color|color|fill|outline(?:-color)?|stroke|text-shadow): .*?(?:#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(|\b(?:black|white)\b).*",
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel design tokens instead of hardcoded colors.")
},
CssDeclarationWithSemicolon() as $decl where {
$decl <: r".*--codex-panel-.*(?:accent|background|border|color|danger|error|faint|muted|ring|success|surface|text|warning).*: .*?(?:#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(|\b(?:black|white)\b).*",
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel design tokens instead of hardcoded colors.")
},
CssDeclarationWithSemicolon() as $decl where {
$decl <: r".*\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(.*",
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel design tokens instead of color functions.")
},
// Keep typography and layout dimensions on shared variables.
CssDeclarationWithSemicolon() as $decl where {
$decl <: r"\s*font-size: .*?\b\d*\.?\d+(?:px|rem|em|%)\b.*",
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel typography tokens instead of hardcoded font sizes.")
},
CssDeclarationWithSemicolon() as $decl where {
$decl <: r"\s*font-weight:\s*(?:[1-9]00|bold|bolder|lighter|normal)\s*;.*",
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel typography tokens instead of hardcoded font weights.")
},
CssDeclarationWithSemicolon() as $decl where {
$decl <: r"\s*line-height: .*?(?:\b\d*\.?\d+\b|\b\d*\.?\d+(?:px|rem|em|%)\b).*",
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel typography tokens instead of hardcoded line heights.")
},
CssDeclarationWithSemicolon() as $decl where {
$decl <: r"\s*(?:border(?:-(?:block|inline|top|right|bottom|left))?-width|border-radius|bottom|column-gap|gap|height|inset|inset-(?:block|inline)|left|margin|margin-(?:block|inline|top|right|bottom|left)|max-height|max-width|min-height|min-width|padding|padding-(?:block|inline|top|right|bottom|left)|right|row-gap|top|width): .*?\b\d*\.?\d+(?:px|rem|em)\b.*",
register_diagnostic(span=$decl, message="Prefer Obsidian or Codex Panel spacing and size tokens for layout dimensions.")
},
// Keep selectors local and predictable.
CssPseudoClassSelector() as $selector where {
$selector <: r".*:has.*",
register_diagnostic(span=$selector, message="Avoid :has() because it can cause broad selector invalidation.")
},
CssPseudoClassSelector() as $selector where {
$selector <: r".*:where[(].*(?:[.]|#|\[).*",
register_diagnostic(span=$selector, message="Do not hide class, id, or attribute selectors inside :where().")
},
CssIdSelector() as $selector where { register_diagnostic(span=$selector, message="Avoid ID selectors in Codex Panel CSS.") },
CssUniversalSelector() as $selector where {
register_diagnostic(span=$selector, message="Avoid universal selectors in Codex Panel CSS.")
},
CssTypeSelector() as $selector where {
not $selector <: r"(?:dd|dt|pre|span|summary|svg|ul)",
register_diagnostic(span=$selector, message="Avoid unnecessary type selectors in Codex Panel CSS.")
},
CssAtRule() as $rule where {
$rule <: r"@keyframes ([a-zA-Z0-9_-]+)[\s\S]*"($name),
not $name <: r"codex-panel-[a-z0-9-]+",
register_diagnostic(span=$rule, message="Prefix keyframes with codex-panel-.")
}
}

View file

@ -1,67 +0,0 @@
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 noSpecificityWhereRule(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;
}
noSpecificityWhereRule.ruleName = ruleName;
noSpecificityWhereRule.messages = messages;
export default stylelint.createPlugin(ruleName, noSpecificityWhereRule);

View file

@ -54,6 +54,14 @@
color: var(--icon-color);
}
.codex-panel-ui__icon-button svg,
.codex-panel-ui__toolbar-action svg,
.codex-panel-ui__nav-row-action svg {
width: var(--icon-size);
height: var(--icon-size);
stroke-width: var(--icon-stroke);
}
.codex-panel-ui__toolbar-action.is-active,
.codex-panel-ui__toolbar-action.is-active:hover,
.codex-panel-ui__toolbar-action.is-active:focus-visible,
@ -216,14 +224,6 @@
outline: none;
}
.codex-panel-ui__icon-button svg,
.codex-panel-ui__toolbar-action svg,
.codex-panel-ui__nav-row-action svg {
width: var(--icon-size);
height: var(--icon-size);
stroke-width: var(--icon-stroke);
}
.codex-panel-ui__text-input {
width: 100%;
overflow: hidden;

View file

@ -129,6 +129,16 @@
font-weight: var(--codex-panel-secondary-label-weight);
}
.codex-panel__message-content {
min-width: 0;
max-width: 100%;
font-family: var(--codex-panel-content-font);
font-size: var(--codex-panel-content-font-size);
overflow-wrap: anywhere;
cursor: text;
user-select: text;
}
.codex-panel__message--system .codex-panel__message-content {
color: var(--text-muted);
font-family: var(--font-interface);
@ -220,6 +230,20 @@
color: var(--codex-panel-color-accent);
}
.codex-panel__reasoning {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--codex-panel-section-gap);
padding-left: var(--codex-panel-message-indent);
border-left: var(--codex-panel-rail);
color: var(--text-muted);
font-size: var(--font-ui-small);
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: text;
user-select: text;
}
.codex-panel__activity-group {
min-width: 0;
max-width: 100%;
@ -252,16 +276,6 @@
margin-bottom: var(--codex-panel-section-gap);
}
.codex-panel__message-content {
min-width: 0;
max-width: 100%;
font-family: var(--codex-panel-content-font);
font-size: var(--codex-panel-content-font-size);
overflow-wrap: anywhere;
cursor: text;
user-select: text;
}
.codex-panel__message-content.markdown-rendered {
white-space: normal;
}
@ -396,20 +410,6 @@
color: var(--text-faint);
}
.codex-panel__reasoning {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--codex-panel-section-gap);
padding-left: var(--codex-panel-message-indent);
border-left: var(--codex-panel-rail);
color: var(--text-muted);
font-size: var(--font-ui-small);
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: text;
user-select: text;
}
.codex-panel__reasoning-role {
margin-bottom: var(--codex-panel-panel-gap);
color: var(--codex-panel-role-label-color);

View file

@ -71,6 +71,11 @@
margin-bottom: 0;
}
.codex-panel__output,
.codex-panel-diff-file {
margin-top: var(--codex-panel-section-gap);
}
.codex-panel__detail > .codex-panel__output,
.codex-panel__detail > .codex-panel-diff-file,
.codex-panel__detail > .codex-panel__meta-grid,
@ -157,11 +162,6 @@
color: var(--text-faint);
}
.codex-panel__output,
.codex-panel-diff-file {
margin-top: var(--codex-panel-section-gap);
}
.codex-panel__output-title {
line-height: var(--line-height-tight);
}

View file

@ -64,9 +64,20 @@
content: "";
}
.codex-panel-threads__row-main:hover .codex-panel-threads__row-title,
.codex-panel-threads__row-main:focus .codex-panel-threads__row-title {
color: var(--nav-item-color-hover, var(--text-normal));
.codex-panel-threads__row-main,
.codex-panel-threads__row-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-panel-threads__row-title {
display: block;
color: var(--codex-panel-threads-row-title-color);
font-size: var(--font-ui-small);
font-weight: var(--nav-item-weight, var(--font-normal));
line-height: var(--line-height-tight);
}
.codex-panel-threads__row--selected {
@ -78,6 +89,11 @@
font-weight: var(--nav-item-weight-active, var(--font-medium));
}
.codex-panel-threads__row-main:hover .codex-panel-threads__row-title,
.codex-panel-threads__row-main:focus .codex-panel-threads__row-title {
color: var(--nav-item-color-hover, var(--text-normal));
}
.codex-panel-threads__row--pending {
--codex-panel-threads-gutter-color: color-mix(in srgb, var(--codex-panel-color-warning) 72%, transparent);
}
@ -104,22 +120,6 @@
padding: var(--codex-panel-panel-gap);
}
.codex-panel-threads__row-main,
.codex-panel-threads__row-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-panel-threads__row-title {
display: block;
color: var(--codex-panel-threads-row-title-color);
font-size: var(--font-ui-small);
font-weight: var(--nav-item-weight, var(--font-normal));
line-height: var(--line-height-tight);
}
.codex-panel-threads__actions {
display: flex;
align-items: center;

View file

@ -1,62 +0,0 @@
import noSpecificityWhereRule from "./scripts/lint/stylelint-no-specificity-where.mjs";
export default {
extends: ["stylelint-config-standard"],
plugins: [noSpecificityWhereRule],
rules: {
"codex-panel/no-specificity-where": true,
"color-named": "never",
"color-no-hex": true,
"custom-property-empty-line-before": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-empty-line-before": null,
"declaration-no-important": true,
"declaration-property-value-disallowed-list": [
{
"/^(?:background(?:-color)?|border(?:-(?:block|inline|top|right|bottom|left))?(?:-color)?|box-shadow|caret-color|color|fill|outline(?:-color)?|stroke|text-shadow)$/":
["/(?:#[0-9a-fA-F]{3,8}\\b|\\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\\()/", "/\\b(?:black|white)\\b/"],
"/^--codex-panel-.*(?:accent|background|border|color|danger|error|faint|muted|ring|success|surface|text|warning).*$/": [
"/(?:#[0-9a-fA-F]{3,8}\\b|\\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\\()/",
"/\\b(?:black|white)\\b/",
],
"font-size": ["/\\b\\d*\\.?\\d+(?:px|rem|em|%)\\b/"],
"font-weight": ["/^(?:[1-9]00|bold|bolder|lighter|normal)$/"],
"line-height": ["/^(?:\\d*\\.?\\d+|\\d*\\.?\\d+(?:px|rem|em|%))$/"],
},
{
message: "Use Obsidian or Codex Panel design tokens instead of hardcoded colors or typography.",
},
],
"declaration-property-unit-disallowed-list": [
{
"/^(?:border(?:-(?:block|inline|top|right|bottom|left))?-width|border-radius|bottom|column-gap|gap|height|inset|inset-(?:block|inline)|left|margin|margin-(?:block|inline|top|right|bottom|left)|max-height|max-width|min-height|min-width|padding|padding-(?:block|inline|top|right|bottom|left)|right|row-gap|top|width)$/":
["em", "px", "rem"],
},
{
message: "Prefer Obsidian or Codex Panel spacing/size tokens for layout dimensions.",
},
],
"function-disallowed-list": ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch"],
"keyframes-name-pattern": "^codex-panel-[a-z0-9-]+$",
"max-nesting-depth": 2,
"no-descending-specificity": null,
"selector-class-pattern": null,
"selector-max-compound-selectors": 4,
"selector-max-specificity": "0,4,0",
"selector-max-type": [
0,
{
ignoreTypes: ["dd", "dt", "pre", "span", "summary", "svg", "ul"],
},
],
"selector-max-id": 0,
"selector-disallowed-list": [
["/:has\\(/"],
{
message: "Avoid :has() because it can cause broad selector invalidation.",
},
],
"selector-max-universal": 0,
"selector-not-notation": null,
},
};

View file

@ -15,7 +15,6 @@ describe("check command plan", () => {
command:
"eslint src --max-warnings=0 --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache",
},
{ name: "lint:css", command: 'stylelint "src/**/*.css" --max-warnings=0' },
{ name: "lint:css-usage", command: "node scripts/lint/check-css-usage.mjs" },
{ name: "lint:unused", command: "knip --no-progress" },
],

View file

@ -556,6 +556,68 @@ runner = new Runner(() => runner.stop());
]);
expect(pluginDiagnostics(biomeLint(["src/plugin-runtime-declared.ts"], cwd, { expectErrors: false }))).toEqual([]);
});
it("keeps CSS on design tokens and scoped selectors", async () => {
const cwd = await tempBiomeWorkspace(["no-restricted-css-policy.grit"]);
await writeFile(
path.join(cwd, "src/styles/bad.css"),
`
.codex-panel:has(.codex-panel__item) {
color: black;
margin-top: 8px;
}
:where(.codex-panel__item) {
font-size: 12px;
}
#codex-panel-id {
font-weight: bold;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/styles/good.css"),
`
.codex-panel__item:where(:hover, :focus-visible) {
color: var(--text-normal);
margin-top: var(--codex-panel-item-gap);
font-size: var(--font-ui-small);
font-weight: var(--font-normal);
}
@keyframes codex-panel-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`.trimStart(),
);
expect(pluginMessages(biomeLint(["src/styles/bad.css"], cwd))).toEqual([
"Avoid :has() because it can cause broad selector invalidation.",
"Use Obsidian or Codex Panel design tokens instead of hardcoded colors.",
"Prefer Obsidian or Codex Panel spacing and size tokens for layout dimensions.",
"Do not hide class, id, or attribute selectors inside :where().",
"Use Obsidian or Codex Panel typography tokens instead of hardcoded font sizes.",
"Avoid ID selectors in Codex Panel CSS.",
"Use Obsidian or Codex Panel typography tokens instead of hardcoded font weights.",
"Prefix keyframes with codex-panel-.",
]);
expect(pluginDiagnostics(biomeLint(["src/styles/good.css"], cwd, { expectErrors: false }))).toEqual([]);
});
});
async function tempBiomeWorkspace(plugins) {
@ -577,6 +639,7 @@ async function tempBiomeWorkspace(plugins) {
await mkdir(path.join(cwd, "src/app-server/services"), { recursive: true });
await mkdir(path.join(cwd, "src/shared"), { recursive: true });
await mkdir(path.join(cwd, "src/shared/ui"), { recursive: true });
await mkdir(path.join(cwd, "src/styles"), { recursive: true });
await mkdir(path.join(cwd, "tests/app-server"), { recursive: true });
await writeFile(
path.join(cwd, "biome.jsonc"),
@ -584,6 +647,7 @@ async function tempBiomeWorkspace(plugins) {
$schema: "https://biomejs.dev/schemas/2.5.1/schema.json",
vcs: { enabled: false },
plugins: plugins.map((plugin) => path.join(repoRoot, "scripts", "lint", "grit", plugin)),
css: { linter: { enabled: true } },
}),
);
return cwd;