mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Improve CSS usage dynamic prefix audit
This commit is contained in:
parent
8edb115e08
commit
1a42558d68
9 changed files with 194 additions and 20 deletions
|
|
@ -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:");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
|
||||
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ function ComposerMetaChoicePopover({
|
|||
"--codex-panel-composer-meta-popover-left": `${String(Math.round(left))}px`,
|
||||
};
|
||||
return (
|
||||
<div className={`codex-panel__composer-meta-popover codex-panel__composer-meta-popover--${kind}`} style={style}>
|
||||
<div className="codex-panel__composer-meta-popover" data-codex-panel-composer-meta-kind={kind} style={style}>
|
||||
{choices.map((choice) => (
|
||||
<ComposerMetaChoice key={choice.label} choice={choice} onClose={onClose} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={`codex-panel__toolbar-panel codex-panel__toolbar-panel--${model.openPanel}`}>
|
||||
<div
|
||||
className={["codex-panel__toolbar-panel", model.openPanel === "status" ? "codex-panel__toolbar-panel--status" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-codex-panel-toolbar-panel={model.openPanel}
|
||||
>
|
||||
{model.openPanel === "history" ? <ThreadList threads={model.threads} actions={actions} /> : null}
|
||||
{model.openPanel === "chat-actions" ? <ChatActionsPanel model={model} actions={actions} /> : null}
|
||||
{model.openPanel === "status" ? <StatusPanel model={model} actions={actions} /> : 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 (
|
||||
<div className={`codex-panel__limit-panel codex-panel__limit-panel--${rateLimit.level}`}>
|
||||
<div className="codex-panel__limit-panel">
|
||||
<div className="codex-panel__limit-panel-title">Usage limit</div>
|
||||
<div className="codex-panel__limit-panel-list">
|
||||
{rateLimit.rows.map((row) => (
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
|
||||
expect(parent.querySelector<HTMLInputElement>(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe(
|
||||
"Active",
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ describe("ComposerShell decisions", () => {
|
|||
|
||||
parent.querySelector<HTMLElement>(".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<HTMLElement>(".codex-panel__composer-meta-option"));
|
||||
expect(metaOptions.map((option) => option.getAttribute("role"))).toEqual([null, null]);
|
||||
|
|
@ -227,8 +227,8 @@ describe("ComposerShell decisions", () => {
|
|||
|
||||
parent.querySelector<HTMLElement>(".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<HTMLElement>(".codex-panel__composer-meta-option")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
|
|
@ -239,7 +239,7 @@ describe("ComposerShell decisions", () => {
|
|||
|
||||
parent.querySelector<HTMLElement>(".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<HTMLElement>(".codex-panel__composer-meta-option")[1]
|
||||
|
|
@ -251,7 +251,7 @@ describe("ComposerShell decisions", () => {
|
|||
|
||||
parent.querySelector<HTMLElement>(".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(() => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue