diff --git a/scripts/lint/check-css-usage.mjs b/scripts/lint/check-css-usage.mjs index 99900456..0ab99288 100644 --- a/scripts/lint/check-css-usage.mjs +++ b/scripts/lint/check-css-usage.mjs @@ -3,11 +3,64 @@ import path from "node:path"; const root = process.cwd(); const stylesDir = path.join(root, "src", "styles"); -if (process.argv.length > 2) { - console.error("Usage: node scripts/lint/check-css-usage.mjs"); +const reportDynamicPrefixes = process.argv[2] === "--report-dynamic-prefixes"; +if (process.argv.length > (reportDynamicPrefixes ? 3 : 2)) { + console.error("Usage: node scripts/lint/check-css-usage.mjs [--report-dynamic-prefixes]"); process.exit(1); } +const dynamicCssClassPatterns = [ + { + prefix: "codex-panel-diff__line--", + classes: [ + "codex-panel-diff__line--added", + "codex-panel-diff__line--context", + "codex-panel-diff__line--file", + "codex-panel-diff__line--hunk", + "codex-panel-diff__line--removed", + ], + }, + { + prefix: "codex-panel-diff__word--", + classes: ["codex-panel-diff__word--added", "codex-panel-diff__word--removed"], + }, + { + prefix: "codex-panel-threads__row--", + classes: ["codex-panel-threads__row--open", "codex-panel-threads__row--pending", "codex-panel-threads__row--running"], + }, + { + prefix: "codex-panel__connection-diagnostics-row--", + classes: ["codex-panel__connection-diagnostics-row--error", "codex-panel__connection-diagnostics-row--warning"], + }, + { + prefix: "codex-panel__execution--", + classes: ["codex-panel__execution--completed", "codex-panel__execution--failed", "codex-panel__execution--running"], + }, + { + prefix: "codex-panel__goal--", + classes: [ + "codex-panel__goal--active", + "codex-panel__goal--blocked", + "codex-panel__goal--budgetLimited", + "codex-panel__goal--complete", + "codex-panel__goal--paused", + "codex-panel__goal--usageLimited", + ], + }, + { + prefix: "codex-panel__limit-panel-meter--", + classes: ["codex-panel__limit-panel-meter--5", "codex-panel__limit-panel-meter--7"], + }, + { + prefix: "codex-panel__limit-panel-row--", + classes: ["codex-panel__limit-panel-row--danger", "codex-panel__limit-panel-row--warn"], + }, + { + prefix: "codex-panel__task-step--", + classes: ["codex-panel__task-step--completed", "codex-panel__task-step--inProgress"], + }, +]; + const cssFiles = await orderedCssFiles(); const sourceFiles = await filesInTree(path.join(root, "src"), new Set([".ts", ".tsx"]), { excludedPrefixes: [path.join(root, "src", "generated")], @@ -19,16 +72,23 @@ const sourceTexts = await readTexts(sourceFiles); const testTexts = await readTexts(testFiles); const sourceText = sourceTexts.map((item) => item.text).join("\n"); const dynamicPrefixes = collectDynamicClassPrefixes(sourceText); +const dynamicCssClasses = dynamicCssClassMap(dynamicCssClassPatterns); const testOnlyCandidates = []; const candidates = []; +const dynamicPrefixMatches = new Map(); for (const [className, locations] of cssClasses) { const sourceMatches = locationsInTexts(sourceTexts, className); if (sourceMatches.length > 0) continue; - const matchingPrefix = dynamicPrefixes.find((prefix) => className.startsWith(prefix)); - if (matchingPrefix) continue; + const dynamicClass = dynamicCssClasses.get(className); + if (dynamicClass && dynamicPrefixes.includes(dynamicClass.prefix)) { + const matches = dynamicPrefixMatches.get(dynamicClass.prefix) ?? []; + matches.push({ className, locations }); + dynamicPrefixMatches.set(dynamicClass.prefix, matches); + continue; + } const testMatches = locationsInTexts(testTexts, className); if (testMatches.length > 0) { @@ -39,8 +99,18 @@ for (const [className, locations] of cssClasses) { candidates.push({ className, locations }); } -if (candidates.length + testOnlyCandidates.length > 0) { - printCandidates({ testOnlyCandidates, candidates }); +const dynamicConfigurationErrors = dynamicCssConfigurationErrors({ + cssClasses, + dynamicCssClassPatterns, + dynamicPrefixes, +}); + +if (reportDynamicPrefixes) { + printDynamicPrefixReport({ dynamicPrefixes, dynamicPrefixMatches }); +} + +if (candidates.length + testOnlyCandidates.length + dynamicConfigurationErrors.length > 0) { + printCandidates({ testOnlyCandidates, candidates, dynamicConfigurationErrors }); process.exit(1); } @@ -82,6 +152,33 @@ function collectDynamicClassPrefixes(text) { return [...prefixes].sort((left, right) => left.localeCompare(right)); } +function dynamicCssClassMap(patterns) { + const result = new Map(); + for (const pattern of patterns) { + for (const className of pattern.classes) { + if (!className.startsWith(pattern.prefix)) { + throw new Error(`Dynamic CSS class ${className} does not match prefix ${pattern.prefix}.`); + } + result.set(className, { prefix: pattern.prefix }); + } + } + return result; +} + +function dynamicCssConfigurationErrors({ cssClasses, dynamicCssClassPatterns, dynamicPrefixes }) { + const errors = []; + for (const pattern of dynamicCssClassPatterns) { + const existingClasses = pattern.classes.filter((className) => cssClasses.has(className)); + if (existingClasses.length > 0 && !dynamicPrefixes.includes(pattern.prefix)) { + errors.push({ + title: "Dynamic CSS class configuration references prefixes that are not present in source templates:", + details: [` ${pattern.prefix}`, ...existingClasses.map((className) => ` ${className}`)], + }); + } + } + return errors; +} + async function readTexts(files) { const result = []; for (const file of files) { @@ -122,8 +219,40 @@ async function collectFiles(directory, extensions, excludedPrefixes, result) { } } -function printCandidates({ testOnlyCandidates, candidates }) { +function printDynamicPrefixReport({ dynamicPrefixes, dynamicPrefixMatches }) { + console.log("Dynamic CSS class prefix exemptions:"); + if (dynamicPrefixMatches.size === 0) { + console.log(" none"); + } else { + for (const [prefix, matches] of [...dynamicPrefixMatches.entries()].sort(([left], [right]) => left.localeCompare(right))) { + console.log(` ${prefix}`); + for (const match of matches) { + console.log(` ${match.className}`); + console.log(` css: ${match.locations.join(", ")}`); + } + } + } + + const prefixesWithoutExemptions = dynamicPrefixes.filter((prefix) => !dynamicPrefixMatches.has(prefix)); + if (prefixesWithoutExemptions.length === 0) return; + + console.log(""); + console.log("Detected dynamic prefixes with no CSS exemptions:"); + for (const prefix of prefixesWithoutExemptions) { + console.log(` ${prefix}`); + } +} + +function printCandidates({ testOnlyCandidates, candidates, dynamicConfigurationErrors }) { console.error("CSS usage check failed."); + for (const error of dynamicConfigurationErrors) { + console.error(""); + console.error(error.title); + for (const detail of error.details) { + console.error(detail); + } + } + if (testOnlyCandidates.length > 0) { console.error(""); console.error("Test-only unused CSS class candidates:"); diff --git a/src/features/chat/presentation/message-stream/detail-view.ts b/src/features/chat/presentation/message-stream/detail-view.ts index 24614c06..e0d155e8 100644 --- a/src/features/chat/presentation/message-stream/detail-view.ts +++ b/src/features/chat/presentation/message-stream/detail-view.ts @@ -158,7 +158,7 @@ function agentDetailView(item: AgentMessageStreamItem): DetailView { function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): DetailView { return detailViewBase( item, - `codex-panel__detail-item codex-panel__detail-item--${item.kind}`, + detailItemClassName(item.kind), item.toolName ?? item.kind, messageDetailKey(item.id, "details"), [...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)], @@ -187,7 +187,7 @@ function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView { function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView { return detailViewBase( item, - `codex-panel__detail-item codex-panel__detail-item--${item.kind}`, + detailItemClassName(item.kind), detailLabel(item), messageDetailKey(item.id, "details"), genericDetailSections(item, workspaceRoot), @@ -199,6 +199,10 @@ function messageDetailKey(itemId: string, suffix: string): string { return `${itemId}:${suffix}`; } +function detailItemClassName(kind: MessageStreamItem["kind"]): string { + return kind === "hook" ? "codex-panel__detail-item codex-panel__detail-item--hook" : "codex-panel__detail-item"; +} + function resultDetailView( item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem, label: string, diff --git a/src/features/chat/presentation/message-stream/text-view.ts b/src/features/chat/presentation/message-stream/text-view.ts index db15b3b4..d60722c0 100644 --- a/src/features/chat/presentation/message-stream/text-view.ts +++ b/src/features/chat/presentation/message-stream/text-view.ts @@ -166,13 +166,20 @@ function executionClassName(state: ExecutionState): string { } function textItemClass(item: MessageStreamItem): string { - const classes = ["codex-panel__message", `codex-panel__message--${item.role}`]; + const classes = ["codex-panel__message", messageRoleClassName(item.role)]; if (item.kind === "approvalResult") classes.push("codex-panel__message--approval-result"); if (item.kind === "userInputResult") classes.push("codex-panel__message--user-input-result"); if (item.kind === "reviewResult") classes.push("codex-panel__message--review-result"); return classes.join(" "); } +function messageRoleClassName(role: MessageStreamItem["role"]): string { + if (role === "assistant") return "codex-panel__message--assistant"; + if (role === "system") return "codex-panel__message--system"; + if (role === "tool") return "codex-panel__message--tool"; + return "codex-panel__message--user"; +} + function definedProp(key: Key, value: Value | undefined): Partial> { return value === undefined ? {} : ({ [key]: value } as Partial>); } diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 9cd37cb5..44bf049e 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -417,7 +417,7 @@ function ComposerMetaChoicePopover({ "--codex-panel-composer-meta-popover-left": `${String(Math.round(left))}px`, }; return ( -
+
{choices.map((choice) => ( ))} diff --git a/src/features/chat/ui/message-stream/status.tsx b/src/features/chat/ui/message-stream/status.tsx index a6533c1d..b35f54fe 100644 --- a/src/features/chat/ui/message-stream/status.tsx +++ b/src/features/chat/ui/message-stream/status.tsx @@ -20,7 +20,7 @@ export function createStatusMessageClassName(className: string, tone?: "warning" "codex-panel__message--tool", "codex-panel__status-message", className, - tone ? `codex-panel__status-message--${tone}` : "", + tone === "warning" ? "codex-panel__status-message--warning" : "", ] .filter(Boolean) .join(" "); diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index 1eb154f2..d1b99894 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -128,7 +128,12 @@ function StatusButton({ model, actions }: { model: ToolbarViewModel; actions: To function ToolbarPanel({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode { if (!model.openPanel) return null; return ( -
+
{model.openPanel === "history" ? : null} {model.openPanel === "chat-actions" ? : null} {model.openPanel === "status" ? : null} @@ -182,7 +187,7 @@ function StatusPanel({ model, actions }: { model: ToolbarViewModel; actions: Too function RateLimitPanel({ rateLimit }: { rateLimit: RateLimitSummary | null }): UiNode { if (!rateLimit) return null; return ( -
+
Usage limit
{rateLimit.rows.map((row) => ( diff --git a/tests/features/chat/panel/surface/projection.test.ts b/tests/features/chat/panel/surface/projection.test.ts index baae78d5..0e736600 100644 --- a/tests/features/chat/panel/surface/projection.test.ts +++ b/tests/features/chat/panel/surface/projection.test.ts @@ -46,7 +46,7 @@ describe("chat panel surface projections", () => { h(ChatPanelToolbar, { surface: toolbarSurfaceFixture({ archiveExportEnabled: true }), actions: toolbarActionsFixture() }), ); - expect(parent.querySelector(".codex-panel__toolbar-panel--history")).not.toBeNull(); + expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull(); expect(parent.querySelector(".codex-panel__new-chat")?.disabled).toBe(true); expect(parent.querySelector(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe( "Active", diff --git a/tests/features/chat/ui/renderers/composer.test.ts b/tests/features/chat/ui/renderers/composer.test.ts index f2cb6a57..8f179d70 100644 --- a/tests/features/chat/ui/renderers/composer.test.ts +++ b/tests/features/chat/ui/renderers/composer.test.ts @@ -215,7 +215,7 @@ describe("ComposerShell decisions", () => { parent.querySelector(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); await waitForAsyncWork(() => { - expect(parent.querySelector(".codex-panel__composer-meta-popover--model")?.textContent).toBe("gpt-5.5gpt-5.4"); + expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')?.textContent).toBe("gpt-5.5gpt-5.4"); }); const metaOptions = Array.from(parent.querySelectorAll(".codex-panel__composer-meta-option")); expect(metaOptions.map((option) => option.getAttribute("role"))).toEqual([null, null]); @@ -227,8 +227,8 @@ describe("ComposerShell decisions", () => { parent.querySelector(".codex-panel__composer-meta-effort")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); await waitForAsyncWork(() => { - expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).toBeNull(); - expect(parent.querySelector(".codex-panel__composer-meta-popover--effort")?.textContent).toBe("mediumhigh"); + expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).toBeNull(); + expect(parent.querySelector('[data-codex-panel-composer-meta-kind="effort"]')?.textContent).toBe("mediumhigh"); }); parent.querySelector(".codex-panel__composer-meta-option")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); @@ -239,7 +239,7 @@ describe("ComposerShell decisions", () => { parent.querySelector(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); await waitForAsyncWork(() => { - expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).not.toBeNull(); + expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).not.toBeNull(); }); parent .querySelectorAll(".codex-panel__composer-meta-option")[1] @@ -251,7 +251,7 @@ describe("ComposerShell decisions", () => { parent.querySelector(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); await waitForAsyncWork(() => { - expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).not.toBeNull(); + expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).not.toBeNull(); }); document.dispatchEvent(new Event("mousedown", { bubbles: true })); await waitForAsyncWork(() => { diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index bf374adf..b6786312 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -149,6 +149,35 @@ describe("development scripts", () => { expect(result.stderr).toContain("codex-panel__unused"); }); + it("reports dynamic CSS prefix exemptions on demand", async () => { + const cwd = await cssUsageFixture({ + "src/styles/10-component.css": ".codex-panel__task-step--completed { display: block; }\n", + "src/component.ts": "export const className = `codex-panel__task-step--${status}`;\n", + }); + + const result = runNodeScript("scripts/lint/check-css-usage.mjs", ["--report-dynamic-prefixes"], cwd); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Dynamic CSS class prefix exemptions:"); + expect(result.stdout).toContain("codex-panel__task-step--"); + expect(result.stdout).toContain("codex-panel__task-step--completed"); + }); + + it("does not let dynamic CSS prefixes hide unconfigured class candidates", async () => { + const cwd = await cssUsageFixture({ + "src/styles/10-component.css": ".codex-panel__task-step--stale { display: block; }\n", + "src/component.ts": "export const className = `codex-panel__task-step--${status}`;\n", + }); + + const result = runNodeScript("scripts/lint/check-css-usage.mjs", [], cwd); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("CSS usage check failed."); + expect(result.stderr).toContain("codex-panel__task-step--stale"); + }); + it("fails release prepare before changing version files when release notes already exist", async () => { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, ".github", "release-notes"), { recursive: true });