fix(frontend): style the paywall header and guard undefined class names
The onboarding paywall wrote `dialog-header`, a class no stylesheet defines, so its close button stacked under the title instead of sitting beside it. The session-delete confirm buttons had the same problem, and a `message-entry` wrapper added in August had quietly killed the `.message + .message` spacing rule in the main chat. Fixes all three against the patterns already in the codebase, then adds a contract test so the next undefined class fails instead of shipping: it scopes itself to our own class families so Tailwind utilities stay out, strips CSS comments before deciding what counts as defined, and carries an allowlist of the fifteen deliberate no-op modifiers that must shrink rather than grow. Also collapses the paywall's duplicate display heading into the intro sentence, leaving one heading in the dialog. BUG-434, BUG-435, BUG-436. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
// Guards the defect class behind BUG-434 / BUG-435: a className the CSS never
|
||||
// defines renders as an unstyled element and silently breaks the layout, with
|
||||
// nothing in the build or the type checker to catch it.
|
||||
//
|
||||
// Scope: only the project's own class families. A token counts as ours when its
|
||||
// first segment already names a family in our CSS (`paywall-`, `message-`,
|
||||
// `dialog-`, `danger-`, ...), which keeps Tailwind utilities out without having
|
||||
// to enumerate them.
|
||||
|
||||
const projectRoot = new URL("../", import.meta.url);
|
||||
const sourceRoot = new URL("src/", projectRoot);
|
||||
|
||||
const walk = (dir: URL, ext: readonly string[]): string[] =>
|
||||
readdirSync(dir, { recursive: true, encoding: "utf8" })
|
||||
.filter((entry) => ext.some((suffix) => entry.endsWith(suffix)))
|
||||
.map((entry) => `src/${entry.split("\\").join("/")}`);
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, projectRoot), "utf8");
|
||||
|
||||
const styleSheets = walk(sourceRoot, [".css"]).map(read);
|
||||
const componentFiles = walk(sourceRoot, [".tsx", ".ts"]);
|
||||
// Boundary pages under the root layout carry their rules in an inline <style>.
|
||||
const inlineStyles = componentFiles.flatMap((path) =>
|
||||
[...read(path).matchAll(/<style>\{`([\s\S]*?)`\}<\/style>/g)].map((hit) => hit[1]));
|
||||
|
||||
// Comments are stripped first: a class name that only survives in a `/* ... */`
|
||||
// note is not a definition, and counting it would hide exactly the bug we want.
|
||||
const styleSource = [...styleSheets, ...inlineStyles].join("\n").replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
const definedClasses = new Set(
|
||||
[...styleSource.matchAll(/\.(-?[A-Za-z_][A-Za-z0-9_-]*)/g)].map((hit) => hit[1]),
|
||||
);
|
||||
|
||||
const projectFamilies = new Set([...definedClasses].map((name) => name.split("-")[0]));
|
||||
|
||||
// Tailwind utilities whose first segment collides with one of our families.
|
||||
const tailwindCollisions = new Set([
|
||||
"inline-flex", "inline-block", "inline-grid", "inline-table",
|
||||
"select-none", "select-all", "select-text", "select-auto",
|
||||
]);
|
||||
|
||||
// Classes that carry no styling on purpose: dead modifiers on already-styled
|
||||
// elements, or hooks handed to a component that styles itself. Each one is a
|
||||
// no-op today; the point of listing them is that a NEW undefined class fails.
|
||||
//
|
||||
// The `is-*` entries are state hooks whose visual is carried by a sibling
|
||||
// element instead — the thinking step's marker icon, the board minute's
|
||||
// "当前时间" / "已更新" note. `sidebar-header` and `chart-nav` land on shadcn
|
||||
// components that style themselves. The rest are modifiers sitting beside an
|
||||
// already-styled class on the same element.
|
||||
const knownUnstyled = new Set([
|
||||
"birth-time-evidence-receipt",
|
||||
"chart-nav",
|
||||
"consultation-run-timeline__summary",
|
||||
"consultation-step-tree",
|
||||
"is-changed",
|
||||
"is-done",
|
||||
"is-live",
|
||||
"is-working",
|
||||
"membership-orders",
|
||||
"membership-segment",
|
||||
"onboarding-step-card",
|
||||
"personal-report-additional-charts",
|
||||
"rectification-pending-note",
|
||||
"rectification-terminal-actions",
|
||||
"rectification-terminal-note",
|
||||
"sidebar-header",
|
||||
"starter-workbench",
|
||||
]);
|
||||
|
||||
/** Static class tokens; `${...}` holes split the string and their ragged edges are dropped. */
|
||||
function staticClassTokens(source: string): string[] {
|
||||
const segments = [...source.matchAll(/className\s*=\s*"([^"]*)"/g)].map((hit) => hit[1]);
|
||||
for (const opener of source.matchAll(/className\s*=\s*\{/g)) {
|
||||
let depth = 1;
|
||||
let cursor = opener.index + opener[0].length;
|
||||
const start = cursor;
|
||||
while (cursor < source.length && depth > 0) {
|
||||
if (source[cursor] === "{") depth += 1;
|
||||
else if (source[cursor] === "}") depth -= 1;
|
||||
cursor += 1;
|
||||
}
|
||||
const expression = source.slice(start, cursor - 1);
|
||||
for (const literal of expression.matchAll(/`([^`]*)`|"([^"]*)"|'([^']*)'/g)) {
|
||||
segments.push(literal[1] ?? literal[2] ?? literal[3] ?? "");
|
||||
}
|
||||
}
|
||||
return segments.flatMap((segment) => (
|
||||
` ${segment} `.split(/\$\{[^}]*\}/).flatMap((piece) => {
|
||||
const tokens = piece.split(/\s+/).filter(Boolean);
|
||||
if (!/^\s/.test(piece)) tokens.shift();
|
||||
if (!/\s$/.test(piece)) tokens.pop();
|
||||
return tokens;
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
const ownershipSuspects = new Map<string, Set<string>>();
|
||||
for (const path of componentFiles) {
|
||||
for (const token of staticClassTokens(read(path))) {
|
||||
if (definedClasses.has(token)) continue;
|
||||
if (tailwindCollisions.has(token)) continue;
|
||||
if (!token.includes("-") || /[:[\]()/@!$.{}]/.test(token)) continue;
|
||||
if (!projectFamilies.has(token.split("-")[0])) continue;
|
||||
const seen = ownershipSuspects.get(token) ?? new Set<string>();
|
||||
seen.add(path);
|
||||
ownershipSuspects.set(token, seen);
|
||||
}
|
||||
}
|
||||
|
||||
test("every project class a component renders has a rule in our CSS", () => {
|
||||
const undefinedClasses = [...ownershipSuspects.entries()]
|
||||
.filter(([name]) => !knownUnstyled.has(name))
|
||||
.map(([name, files]) => `${name} (${[...files].sort().join(", ")})`)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
undefinedClasses,
|
||||
[],
|
||||
"These class names style nothing. Define them in CSS, drop them, or add them "
|
||||
+ "to knownUnstyled with a reason:\n " + undefinedClasses.join("\n "),
|
||||
);
|
||||
});
|
||||
|
||||
test("the knownUnstyled allowlist has no stale entries", () => {
|
||||
// An entry that got a rule, or stopped being rendered, must leave the list —
|
||||
// otherwise the allowlist grows into a blindfold.
|
||||
const stale = [...knownUnstyled].filter((name) => (
|
||||
definedClasses.has(name) || !ownershipSuspects.has(name)
|
||||
));
|
||||
assert.deepEqual(stale, [], `no longer undefined-and-used: ${stale.join(", ")}`);
|
||||
});
|
||||
|
||||
test("the guard actually sees the codebase", () => {
|
||||
assert.ok(componentFiles.length > 100, `only walked ${componentFiles.length} components`);
|
||||
assert.ok(definedClasses.size > 500, `only parsed ${definedClasses.size} classes`);
|
||||
assert.ok(projectFamilies.has("paywall") && projectFamilies.has("message"));
|
||||
});
|
||||
@@ -249,6 +249,32 @@ test("finished starter questions open the redeem paywall only for accounts witho
|
||||
assert.match(onboardingPaywallSource, /membershipHref\("onboarding-paywall"\)/);
|
||||
});
|
||||
|
||||
test("the onboarding paywall reuses the shared account dialog header", () => {
|
||||
// The close button only sits beside the title when the header carries the shared
|
||||
// flex class. A bespoke wrapper leaves it stacked under the heading on mobile.
|
||||
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
|
||||
assert.match(onboardingPaywallSource, /<\/header>/);
|
||||
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
|
||||
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
|
||||
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
|
||||
assert.match(onboardingPaywallSource, /className="dialog-close"/);
|
||||
});
|
||||
|
||||
test("every static class the onboarding paywall renders has a rule in globals.css", () => {
|
||||
// Guards the whole defect class: a class name with no matching selector renders
|
||||
// as an unstyled block and silently breaks the layout.
|
||||
const used = new Set(
|
||||
[...onboardingPaywallSource.matchAll(/className="([^"{}]+)"/g)]
|
||||
.flatMap((hit) => hit[1].split(/\s+/))
|
||||
.filter(Boolean),
|
||||
);
|
||||
assert.ok(used.size >= 12, `expected the paywall to carry its usual classes, saw ${used.size}`);
|
||||
const undefinedClasses = [...used].filter((name) => (
|
||||
!new RegExp(`\\.${name}(?![\\w-])`).test(globalStyles)
|
||||
));
|
||||
assert.deepEqual(undefinedClasses, []);
|
||||
});
|
||||
|
||||
test("payment and plan copy points users to the separate orders page", () => {
|
||||
assert.match(pageSource, /本页会自动检查支付状态并刷新余额。/);
|
||||
assert.match(pageSource, /「订单记录」页面/);
|
||||
|
||||
Reference in New Issue
Block a user