From 0044d4f358cf8b09c71876f6ea4dcd82b306e10a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 05:40:29 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu --- docs/BUG_HISTORY.md | 48 ++++++ frontend/src/app/globals.css | 11 +- frontend/src/app/page.tsx | 6 +- .../components/onboarding-redeem-paywall.tsx | 9 +- .../class-name-definition-contract.test.ts | 140 ++++++++++++++++++ frontend/tests/membership-page.test.ts | 26 ++++ 6 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 frontend/tests/class-name-definition-contract.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dc9c0306..2d5517d0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6638,3 +6638,51 @@ - 修复版本:待发布 +## BUG-434 | 入门付费墙的关闭按钮掉到标题下面,弹窗头部从未有过样式 + +- 状态:resolved +- 首次发现:2026-08-29 +- 最近更新:2026-08-29 +- 影响面:`/` 入门问题完成后弹出的「解锁完整咨询」弹窗,移动端最明显 +- 用户现象:标题「解锁完整咨询」独占一行,关闭「×」以灰色圆角块的形式落在标题下方靠左,和礼物图标那段挤在一起;头部与正文之间没有间距。 +- 触发条件:打开该弹窗即出现,与账号状态无关。 +- 根因:`onboarding-redeem-paywall.tsx` 写的是 `
`,而 `dialog-header` 这个类在全仓 CSS 里**没有任何定义**。同类弹窗(`account-dialog-overlay.tsx`、`membership/page.tsx`)用的都是 `
`(`display:flex; justify-content:space-between; padding-bottom`)。付费墙自 `a17ff258` 引入起就拼错,未被任何测试或样式覆盖,因此一直是无样式块级 div,标题与按钮竖排。 +- 修复:改为 `
`,与另外两处弹窗完全一致。未新增任何 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)给每条消息套了一层 `
`,而这个类在 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 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 01af899a..f93f9310 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 83807c6f..76d39f5b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3873,7 +3873,7 @@ export default function Home() { - +
))} @@ -3997,8 +3997,8 @@ export default function Home() {

删除聊天记录?

“{pendingSessionDeletion.title}”将被永久删除,无法恢复。

- - + -
+
-
- 入门问题已经准备好 -

填写兑换码,解锁后续咨询、生时校正与完整解读。

-
+

入门问题已经准备好。填写兑换码,解锁后续咨询、生时校正与完整解读。

当前可用点数 diff --git a/frontend/tests/class-name-definition-contract.test.ts b/frontend/tests/class-name-definition-contract.test.ts new file mode 100644 index 00000000..f68f9e38 --- /dev/null +++ b/frontend/tests/class-name-definition-contract.test.ts @@ -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