mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add CSS usage audit
This commit is contained in:
parent
6fade82895
commit
4f723d4784
6 changed files with 195 additions and 80 deletions
|
|
@ -25,6 +25,8 @@
|
|||
"generate:app-server-types": "node scripts/generate-app-server-types.mjs",
|
||||
"lint": "node scripts/run-parallel.mjs lint:ts lint:css",
|
||||
"lint:css": "stylelint \"src/**/*.css\" --max-warnings=0",
|
||||
"lint:css:usage": "node scripts/check-css-usage.mjs",
|
||||
"lint:css:usage:check": "node scripts/check-css-usage.mjs --fail-on-candidates",
|
||||
"lint:ts": "eslint src tests scripts \"*.config.ts\" \"*.config.mjs\" --max-warnings=0 --cache --cache-location node_modules/.cache/eslint/ --cache-strategy content",
|
||||
"lint:ts:ci": "eslint src tests scripts \"*.config.ts\" \"*.config.mjs\" --max-warnings=0",
|
||||
"release:check": "node scripts/release/check.mjs",
|
||||
|
|
|
|||
180
scripts/check-css-usage.mjs
Normal file
180
scripts/check-css-usage.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const stylesDir = path.join(root, "src", "styles");
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const failOnCandidates = args.has("--fail-on-candidates");
|
||||
const validArgs = new Set(["--fail-on-candidates"]);
|
||||
|
||||
for (const arg of args) {
|
||||
if (!validArgs.has(arg)) {
|
||||
console.error("Usage: node scripts/check-css-usage.mjs [--fail-on-candidates]");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const cssFiles = await orderedCssFiles();
|
||||
const sourceFiles = await filesInTree(path.join(root, "src"), new Set([".ts", ".tsx"]), {
|
||||
excludedPrefixes: [path.join(root, "src", "generated")],
|
||||
});
|
||||
const testFiles = await filesInTree(path.join(root, "tests"), new Set([".ts", ".tsx"]));
|
||||
|
||||
const cssClasses = await collectCssClasses(cssFiles);
|
||||
const sourceTexts = await readTexts(sourceFiles);
|
||||
const testTexts = await readTexts(testFiles);
|
||||
const sourceText = sourceTexts.map((item) => item.text).join("\n");
|
||||
const dynamicPrefixes = collectDynamicClassPrefixes(sourceText);
|
||||
|
||||
const exactSourceUsed = [];
|
||||
const dynamicSourceUsed = [];
|
||||
const testOnlyCandidates = [];
|
||||
const candidates = [];
|
||||
|
||||
for (const [className, locations] of cssClasses) {
|
||||
const sourceMatches = locationsInTexts(sourceTexts, className);
|
||||
if (sourceMatches.length > 0) {
|
||||
exactSourceUsed.push({ className, locations, sourceMatches });
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingPrefix = dynamicPrefixes.find((prefix) => className.startsWith(prefix));
|
||||
if (matchingPrefix) {
|
||||
dynamicSourceUsed.push({ className, locations, matchingPrefix });
|
||||
continue;
|
||||
}
|
||||
|
||||
const testMatches = locationsInTexts(testTexts, className);
|
||||
if (testMatches.length > 0) {
|
||||
testOnlyCandidates.push({ className, locations, testMatches });
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({ className, locations });
|
||||
}
|
||||
|
||||
printReport({
|
||||
total: cssClasses.size,
|
||||
exactSourceUsed,
|
||||
dynamicSourceUsed,
|
||||
testOnlyCandidates,
|
||||
candidates,
|
||||
dynamicPrefixes,
|
||||
});
|
||||
|
||||
if (failOnCandidates && candidates.length + testOnlyCandidates.length > 0) process.exit(1);
|
||||
|
||||
async function orderedCssFiles() {
|
||||
const orderPath = path.join(stylesDir, "order.json");
|
||||
const order = JSON.parse(await readFile(orderPath, "utf8"));
|
||||
if (!Array.isArray(order) || !order.every((item) => typeof item === "string")) {
|
||||
throw new Error(`${relative(orderPath)} must be a JSON array of CSS file names.`);
|
||||
}
|
||||
return order.map((file) => path.join(stylesDir, file));
|
||||
}
|
||||
|
||||
async function collectCssClasses(files) {
|
||||
const result = new Map();
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, "utf8");
|
||||
const lines = text.split("\n");
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const withoutComment = line.replace(/\/\*.*?\*\//g, "");
|
||||
const classPattern = /(^|[^\\])\.([_a-zA-Z][\w-]*)/g;
|
||||
for (const match of withoutComment.matchAll(classPattern)) {
|
||||
const className = match[2];
|
||||
if (!className.startsWith("codex-panel")) continue;
|
||||
const locations = result.get(className) ?? [];
|
||||
locations.push(`${relative(file)}:${index + 1}`);
|
||||
result.set(className, locations);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function collectDynamicClassPrefixes(text) {
|
||||
const prefixes = new Set();
|
||||
const templatePrefixPattern = /codex-panel(?:-[a-z]+)?__[a-z0-9-]+--\$\{/g;
|
||||
for (const match of text.matchAll(templatePrefixPattern)) {
|
||||
prefixes.add(match[0].slice(0, -"${".length));
|
||||
}
|
||||
return [...prefixes].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function readTexts(files) {
|
||||
const result = [];
|
||||
for (const file of files) {
|
||||
result.push({ file, text: await readFile(file, "utf8") });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function locationsInTexts(texts, needle) {
|
||||
const locations = [];
|
||||
for (const { file, text } of texts) {
|
||||
let offset = text.indexOf(needle);
|
||||
if (offset === -1) continue;
|
||||
const line = text.slice(0, offset).split("\n").length;
|
||||
locations.push(`${relative(file)}:${line}`);
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
async function filesInTree(directory, extensions, options = {}) {
|
||||
const excludedPrefixes = options.excludedPrefixes ?? [];
|
||||
const result = [];
|
||||
await collectFiles(directory, extensions, excludedPrefixes, result);
|
||||
return result.sort((left, right) => relative(left).localeCompare(relative(right)));
|
||||
}
|
||||
|
||||
async function collectFiles(directory, extensions, excludedPrefixes, result) {
|
||||
if (excludedPrefixes.some((prefix) => directory === prefix || directory.startsWith(`${prefix}${path.sep}`))) return;
|
||||
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectFiles(file, extensions, excludedPrefixes, result);
|
||||
} else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
||||
result.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printReport({ total, exactSourceUsed, dynamicSourceUsed, testOnlyCandidates, candidates, dynamicPrefixes }) {
|
||||
const totalCandidates = candidates.length + testOnlyCandidates.length;
|
||||
|
||||
console.log("CSS usage audit");
|
||||
console.log(` CSS classes: ${total}`);
|
||||
console.log(` Source literals: ${exactSourceUsed.length}`);
|
||||
console.log(` Source dynamic modifiers: ${dynamicSourceUsed.length}`);
|
||||
console.log(` Test-only candidates: ${testOnlyCandidates.length}`);
|
||||
console.log(` Unused candidates: ${totalCandidates}`);
|
||||
|
||||
if (dynamicPrefixes.length > 0) {
|
||||
console.log("\nDynamic modifier prefixes:");
|
||||
for (const prefix of dynamicPrefixes) console.log(` ${prefix}*`);
|
||||
}
|
||||
|
||||
if (testOnlyCandidates.length > 0) {
|
||||
console.log("\nTest-only unused CSS class candidates:");
|
||||
for (const item of testOnlyCandidates) {
|
||||
console.log(` ${item.className}`);
|
||||
console.log(` css: ${item.locations.join(", ")}`);
|
||||
console.log(` tests: ${item.testMatches.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
console.log("\nUnused CSS class candidates:");
|
||||
for (const item of candidates) {
|
||||
console.log(` ${item.className}`);
|
||||
console.log(` css: ${item.locations.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function relative(file) {
|
||||
return path.relative(root, file);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ const checks = [
|
|||
{ local: "test", ci: "test:ci", localPhase: "parallel" },
|
||||
{ local: "lint:ts", ci: "lint:ts:ci", localPhase: "parallel" },
|
||||
{ local: "lint:css", ci: "lint:css", localPhase: "parallel" },
|
||||
{ local: "lint:css:usage:check", ci: null, localPhase: "parallel" },
|
||||
{ local: "format:check", ci: "format:check:ci", localPhase: "parallel" },
|
||||
{ local: "build:styles:check", ci: "build:styles:check", localPhase: "parallel" },
|
||||
{ local: "unused", ci: "unused", localPhase: "parallel" },
|
||||
|
|
@ -21,7 +22,10 @@ for (const arg of args) {
|
|||
}
|
||||
|
||||
if (args.has("--ci")) {
|
||||
for (const check of checks) run(npmCommand, ["run", check.ci]);
|
||||
for (const check of checks) {
|
||||
if (check.ci === null) continue;
|
||||
run(npmCommand, ["run", check.ci]);
|
||||
}
|
||||
} else {
|
||||
const parallelScripts = checks.filter((check) => check.localPhase === "parallel").map((check) => check.local);
|
||||
run("node", ["scripts/run-parallel.mjs", ...parallelScripts]);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@
|
|||
transform: translateX(-10000px);
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control,
|
||||
.codex-panel-ui__icon-button {
|
||||
--icon-size: var(--codex-panel-control-icon-size);
|
||||
--icon-stroke: var(--icon-m-stroke-width, 1.75px);
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
|
|
@ -42,60 +43,6 @@
|
|||
line-height: var(--line-height-tight);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control:disabled,
|
||||
.codex-panel-ui__icon-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control {
|
||||
--icon-size: var(--codex-panel-control-icon-size);
|
||||
--icon-stroke: var(--icon-m-stroke-width, 1.75px);
|
||||
min-width: var(--codex-panel-icon-button-inline-size);
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: var(--codex-panel-control-gap) var(--codex-panel-item-gap);
|
||||
border-radius: var(--codex-panel-control-radius);
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control:hover,
|
||||
.codex-panel-ui__toolbar-control:focus-visible,
|
||||
.codex-panel-ui__toolbar-control:active {
|
||||
background: var(--background-modifier-hover);
|
||||
box-shadow: none;
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control:active svg {
|
||||
color: var(--icon-color);
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control.is-active,
|
||||
.codex-panel-ui__toolbar-control.is-active:hover,
|
||||
.codex-panel-ui__toolbar-control.is-active:focus-visible,
|
||||
.codex-panel-ui__toolbar-control.is-active:active {
|
||||
background: var(--background-modifier-active-hover);
|
||||
box-shadow: none;
|
||||
color: var(--icon-color-active);
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control.is-active svg {
|
||||
color: var(--icon-color-active);
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control:where(:focus:not(:hover):not(:focus-visible)) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.codex-panel-ui__icon-button {
|
||||
--icon-size: var(--codex-panel-control-icon-size);
|
||||
--icon-stroke: var(--icon-m-stroke-width, 1.75px);
|
||||
min-width: var(--codex-panel-composer-control-size);
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
|
@ -103,6 +50,11 @@
|
|||
border-radius: var(--codex-panel-control-radius);
|
||||
}
|
||||
|
||||
.codex-panel-ui__icon-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-action {
|
||||
--icon-size: var(--codex-panel-control-icon-size);
|
||||
--icon-stroke: var(--icon-m-stroke-width, 1.75px);
|
||||
|
|
@ -277,7 +229,6 @@
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.codex-panel-ui__toolbar-control svg,
|
||||
.codex-panel-ui__icon-button svg,
|
||||
.codex-panel-ui__toolbar-action svg,
|
||||
.codex-panel-ui__nav-row-action svg {
|
||||
|
|
@ -294,15 +245,6 @@
|
|||
resize: none;
|
||||
}
|
||||
|
||||
.codex-panel-ui__action-stack {
|
||||
height: 100%;
|
||||
align-self: end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: var(--codex-panel-control-gap);
|
||||
}
|
||||
|
||||
.workspace-leaf-content[data-type="codex-panel-view"] .view-content.codex-panel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@
|
|||
}
|
||||
|
||||
.codex-panel__goal:hover .codex-panel__goal-action,
|
||||
.codex-panel__goal-role--confirm-open .codex-panel__goal-action,
|
||||
.codex-panel__goal-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,34 +56,22 @@ describe("chat toolbar CSS", () => {
|
|||
expect(styles).not.toContain(".codex-panel__runtime-strip");
|
||||
});
|
||||
|
||||
it("keeps mouse-focus reset less specific than active toolbar controls", () => {
|
||||
const toolbarMouseFocus =
|
||||
/\.codex-panel-ui__toolbar-control:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
|
||||
"body"
|
||||
] ?? "";
|
||||
it("keeps mouse-focus reset less specific than active toolbar actions", () => {
|
||||
const toolbarActionMouseFocus =
|
||||
/\.codex-panel-ui__toolbar-action:where\(:focus:not\(:hover\):not\(:focus-visible\)\) \{(?<body>[^}]+)\}/.exec(styles)?.groups?.[
|
||||
"body"
|
||||
] ?? "";
|
||||
|
||||
expect(toolbarMouseFocus).toContain("background: transparent");
|
||||
expect(toolbarMouseFocus).toContain("color: var(--icon-color)");
|
||||
expect(toolbarActionMouseFocus).toContain("background: transparent");
|
||||
expect(toolbarActionMouseFocus).toContain("color: var(--icon-color)");
|
||||
});
|
||||
|
||||
it("uses the shared active state for toolbar actions", () => {
|
||||
const toolbarControlActive =
|
||||
/\.codex-panel-ui__toolbar-control\.is-active,\n\.codex-panel-ui__toolbar-control\.is-active:hover,\n\.codex-panel-ui__toolbar-control\.is-active:focus-visible,\n\.codex-panel-ui__toolbar-control\.is-active:active \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
const toolbarActionActive =
|
||||
/\.codex-panel-ui__toolbar-action\.is-active,\n\.codex-panel-ui__toolbar-action\.is-active:hover,\n\.codex-panel-ui__toolbar-action\.is-active:focus-visible,\n\.codex-panel-ui__toolbar-action\.is-active:active \{(?<body>[^}]+)\}/.exec(
|
||||
styles,
|
||||
)?.groups?.["body"] ?? "";
|
||||
|
||||
expect(toolbarControlActive).toContain("background: var(--background-modifier-active-hover)");
|
||||
expect(toolbarControlActive).toContain("color: var(--icon-color-active)");
|
||||
expect(toolbarActionActive).toContain("background: var(--background-modifier-active-hover)");
|
||||
expect(toolbarActionActive).toContain("color: var(--icon-color-active)");
|
||||
expect(styles).not.toContain(".codex-panel__runtime-model.is-active");
|
||||
|
|
|
|||
Loading…
Reference in a new issue