fix(frontend): style the paywall header and guard undefined class names
Independent Staging Quality Gate / validate (push) Successful in 10m8s
Independent Staging Quality Gate / publish (push) Successful in 9m39s

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:
Jesse_Chen
2026-08-29 05:40:29 +00:00
parent c960691ee5
commit 0044d4f358
6 changed files with 227 additions and 13 deletions
+48
View File
@@ -6638,3 +6638,51 @@
- 修复版本:待发布
## BUG-434 | 入门付费墙的关闭按钮掉到标题下面,弹窗头部从未有过样式
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 影响面:`/` 入门问题完成后弹出的「解锁完整咨询」弹窗,移动端最明显
- 用户现象:标题「解锁完整咨询」独占一行,关闭「×」以灰色圆角块的形式落在标题下方靠左,和礼物图标那段挤在一起;头部与正文之间没有间距。
- 触发条件:打开该弹窗即出现,与账号状态无关。
- 根因:`onboarding-redeem-paywall.tsx` 写的是 `<div className="dialog-header">`,而 `dialog-header` 这个类在全仓 CSS 里**没有任何定义**。同类弹窗(`account-dialog-overlay.tsx``membership/page.tsx`)用的都是 `<header className="account-modal-header">``display:flex; justify-content:space-between; padding-bottom`)。付费墙自 `a17ff258` 引入起就拼错,未被任何测试或样式覆盖,因此一直是无样式块级 div,标题与按钮竖排。
- 修复:改为 `<header className="account-modal-header">`,与另外两处弹窗完全一致。未新增任何 CSS,未改文案、`aria-label="关闭"``aria-modal`、焦点陷阱与 44px 触达。
- 验证:`frontend/tests/membership-page.test.ts` 新增两条 —— 一条锁付费墙必须复用 `account-modal-header` 且不得再出现 `dialog-header`;一条**通用守卫**,抽出该组件所有静态 `className` 逐个断言在 `globals.css` 里有对应选择器。反向验证:把 `dialog-header` 放回去,两条同时变红;还原后 38 pass / 0 fail。
- 防复发:弹窗头部一律用 `account-modal-header`,不得为单个弹窗另造头部类名。新增组件若引入新类名,必须同时在 `globals.css` 落规则 —— 上面那条通用守卫会挡住这类拼写漂移。
- 相关记录:BUG-433
- 复发自:无
- 修复版本:待发布
## BUG-435 | 删除会话确认弹窗的两个按钮没有任何样式,"确认删除"也不是红色
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 影响面:`/` 侧边栏删除会话时弹出的「删除聊天记录?」确认框
- 用户现象:「取消」和「确认删除」渲染成浏览器原生灰色按钮 —— 没有圆角、没有 44px 高度、没有内边距,破坏性操作也没有任何红色提示。
- 触发条件:打开该确认框即出现。
- 根因:`page.tsx:4000` 的「取消」完全没写 class`page.tsx:4001` 的「确认删除」写的是 `danger-button`,而这个类在全仓 CSS 里没有定义。全局只给 `button` 设了 `font: inherit; color: inherit`,没有任何兜底规则,所以两个按钮都是裸原生按钮。同一文件 60 行之前的退出登录确认框用的是正确写法 `button-secondary` + `button-primary danger-primary`
- 修复:两个按钮改成与退出登录框完全一致的 `button-secondary` / `button-primary danger-primary`。未新增 CSS,未改文案与 `role="alertdialog"`。星盘库「删除」上同样无定义的 `danger-button` 修饰符一并去掉(它本就没生效,渲染不变)。
- 验证:`./node_modules/.bin/next build` 后用类名扫描脚本核对,`danger-button` 已从"无规则类名"清单消失;`tsc --noEmit` exit 0、`eslint` 0 error、全量测试无新增失败。
- 防复发:破坏性确认框一律复用 `button-secondary` + `button-primary danger-primary`,不得临时造修饰符。新类名必须同时在 `globals.css` 落规则 —— 参见 BUG-434 在 `membership-page.test.ts` 里加的通用守卫。
- 相关记录:BUG-434
- 复发自:无
- 修复版本:待发布
## BUG-436 | 主对话消息间距规则从 8-19 起失效,消息比设计稿挤
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 影响面:`/` 主对话(非空、非生时校正会话)里相邻消息之间的竖向间距
- 用户现象:消息之间只剩气泡自身的内边距,比设计稿定的 `--space-8`(移动端 `--space-6`)窄。
- 触发条件:任何有历史消息的普通咨询会话。
- 根因:`globals.css``.conversation:not(.is-empty):not(.is-rectification) .message + .message { margin-top: var(--space-8) }` 写于 `200c39d2`2026-07-22),当时消息是 `.message-list` 的直接兄弟。`7d667fec`(2026-08-19)给每条消息套了一层 `<div className="message-entry">`,而这个类在 CSS 里没有任何定义,两条 `.message` 从此不再相邻,选择器永久不匹配。同一规则组里 `.message` 又被覆盖成 `padding: 0`,于是间距归零。`3198fb6b` 把同一层包装搬进 `ChatTranscript`,未改变这一点。
- 修复:选择器改为 `.message-entry + .message-entry`,桌面 `--space-8`、移动 `--space-6` 数值不变。`.message-list` 里除 `.message-entry` 外只有可选的 `.error-message`,不会误伤。引导语消息在 `.welcome` 内且只在 `.is-empty` 时渲染,本就被选择器排除。
- 验证:`frontend/tests/class-name-definition-contract.test.ts`(本次新增的通用守卫)反向验证 —— 把选择器改回 `.message + .message``message-entry` 立即被判为无规则类名,测试变红;改回则绿。
- 防复发:给消息列表加包装层时必须同步迁移依赖相邻兄弟的选择器。新类名漏定义由上述守卫兜底;该守卫会剥掉 CSS 注释再判定,避免"只在注释里出现过"被当成已定义。
- 相关记录:BUG-434、BUG-435
- 复发自:无
- 修复版本:待发布
+7 -4
View File
@@ -1411,8 +1411,8 @@ button:disabled { cursor: default; opacity: .45; }
.paywall-intro { display: flex; align-items: flex-start; gap: var(--space-4); padding-bottom: var(--space-5); }
.paywall-mark { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border-radius: 50%; background: var(--color-action-soft); color: var(--color-action); }
.paywall-mark svg { width: 20px; height: 20px; }
.paywall-intro b { display: block; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.paywall-intro p { margin: var(--space-1) 0 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; text-wrap: pretty; }
.paywall-intro b { color: var(--color-ink); font-weight: 600; }
.paywall-intro p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; text-wrap: pretty; }
.paywall-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-6); padding-top: var(--space-5); border-top: 1px solid var(--color-border); }
.paywall-footer span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.paywall-footer > a, .paywall-footer > button { flex: 0 0 auto; text-decoration: none; }
@@ -2086,7 +2086,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
animation: none;
}
.conversation:not(.is-empty):not(.is-rectification) .message + .message {
/* Each message renders inside its own .message-entry wrapper (row + actions +
follow-ups), so two .message elements are never adjacent siblings. Space the
wrappers instead. */
.conversation:not(.is-empty):not(.is-rectification) .message-entry + .message-entry {
margin-top: var(--space-8);
}
@@ -2177,7 +2180,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
padding-bottom: var(--space-16);
}
.conversation:not(.is-empty):not(.is-rectification) .message + .message {
.conversation:not(.is-empty):not(.is-rectification) .message-entry + .message-entry {
margin-top: var(--space-6);
}
+3 -3
View File
@@ -3873,7 +3873,7 @@ export default function Home() {
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}></button>
<button className="button-secondary" type="button" onClick={() => deleteOtherChart(record.id)}></button>
</div>
</article>
))}
@@ -3997,8 +3997,8 @@ export default function Home() {
<h2></h2>
<p>{pendingSessionDeletion.title}</p>
<div className="dialog-actions">
<button type="button" onClick={() => setPendingSessionDeletion(null)}></button>
<button className="danger-button" type="button" onClick={() => {
<button className="button-secondary" type="button" onClick={() => setPendingSessionDeletion(null)}></button>
<button className="button-primary danger-primary" type="button" onClick={() => {
const session = pendingSessionDeletion;
setPendingSessionDeletion(null);
void deleteSession(session);
@@ -84,18 +84,15 @@ export function OnboardingRedeemPaywall({
else keepFocusWithin(event.nativeEvent, event.currentTarget);
}}
>
<div className="dialog-header">
<header className="account-modal-header">
<h2 id="onboarding-paywall-title"></h2>
<button className="dialog-close" aria-label="关闭" type="button" onClick={close} disabled={pending}>
<X aria-hidden="true" />
</button>
</div>
</header>
<div className="paywall-intro">
<span className="paywall-mark" aria-hidden="true"><Gift /></span>
<div>
<b></b>
<p></p>
</div>
<p><b></b></p>
</div>
<div className="redeem-balance">
<span></span>
@@ -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"));
});
+26
View File
@@ -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, /「订单记录」页面/);