diff --git a/package.json b/package.json index 4d5a6b3e..da813fe4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-css-usage.mjs b/scripts/check-css-usage.mjs new file mode 100644 index 00000000..d6269d4b --- /dev/null +++ b/scripts/check-css-usage.mjs @@ -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); +} diff --git a/scripts/check.mjs b/scripts/check.mjs index eb634466..6ef7a240 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -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]); diff --git a/src/styles/10-shared-ui.css b/src/styles/10-shared-ui.css index 8a9a9d85..dade14e3 100644 --- a/src/styles/10-shared-ui.css +++ b/src/styles/10-shared-ui.css @@ -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; diff --git a/src/styles/23-chat-goal.css b/src/styles/23-chat-goal.css index 5ec17224..02b9f799 100644 --- a/src/styles/23-chat-goal.css +++ b/src/styles/23-chat-goal.css @@ -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; } diff --git a/tests/styles.test.ts b/tests/styles.test.ts index b1fada19..f201e3fe 100644 --- a/tests/styles.test.ts +++ b/tests/styles.test.ts @@ -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\)\) \{(?[^}]+)\}/.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\)\) \{(?[^}]+)\}/.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 \{(?[^}]+)\}/.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 \{(?[^}]+)\}/.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");