From 34628ecb6759d9fa500586843bee79efd897c970 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 06:24:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E8=A1=A8=E5=8D=95=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E6=94=B6=E7=AA=84=E5=88=B0=20440px=EF=BC=8C=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E5=8D=A1=E6=94=B6=E6=95=9B=E6=88=90=E4=B8=A4=E7=A7=8D?= =?UTF-8?q?=E4=BA=92=E6=96=A5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务书 TASK-cend-surfaces-claude-alignment-20260916 §R8(T8.1 / T8.2)。 T8.1(E13):880px 弹窗里「个人资料」「通用设置」的一列表单铺满约 690px, 字段与标签被拉断。表单分区的内容区加 `settings-dialog-content--form`, 子元素上限 440px 并左对齐;「星盘资料」「账户与点数」两个列表分区保持铺满。 上限只挂在滚动容器的子元素上,不挂 `.settings-modal` / `.account-settings-shell`, 因此传不到固定尺寸的弹窗(BUG-554 / BUG-698 的现象无法复现)。 T8.2(D13):套餐卡的三个修饰符可同时命中,叠出没有设计定义的边框与底色。 收敛成两种互斥状态 `is-current`(当前套餐)与 `is-recommended`(推荐), 「推荐且当前」判给当前套餐(该卡的动作是续费)。 `highlighted` 不再进 className,删掉 `--highlighted` 那条描边规则, 定位交给 use-billing-panel 里已有的 scrollIntoView + focus。 手机端「月卡置顶」改挂 `[data-plan-alias="monthly"]`——版位不是状态, 月卡成为当前套餐后排序不变。 测试:3350 → 3354(+4),fail 31 → 31 且失败清单与基线逐条一致。 6 处既有断言改动均就地写了「原值/新值/原因」,另有 3 次破坏性验证。 tsc 0 错;lint 0 error / 118 warning(持平);干净 next build 后 `/` 仍 `○ Static`, 产物 CSS gzip 39,034 → 38,882 B(−0.39%),三条新规则在产物里逐条取证。 环境缺口(未记为通过):四个分区切换不跳尺寸、支付下单流程、`?plan=` 滚动定位的 视觉确认——无 Chrome、无受控账号,见 PROGRESS 的环境缺口段。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- ...PROGRESS-cend-settings-billing-20260916.md | 160 ++++++++++++++++++ frontend/DESIGN.md | 4 +- frontend/src/app/globals.css | 20 ++- .../src/components/account-dialog-overlay.tsx | 16 +- frontend/src/components/billing-panel.tsx | 7 +- frontend/tests/account-dialog-overlay.test.ts | 60 +++++++ frontend/tests/billing-panel.test.ts | 60 ++++++- frontend/tests/membership-page.test.ts | 30 +++- 8 files changed, 342 insertions(+), 15 deletions(-) create mode 100644 docs/tasks/PROGRESS-cend-settings-billing-20260916.md diff --git a/docs/tasks/PROGRESS-cend-settings-billing-20260916.md b/docs/tasks/PROGRESS-cend-settings-billing-20260916.md new file mode 100644 index 00000000..ec950466 --- /dev/null +++ b/docs/tasks/PROGRESS-cend-settings-billing-20260916.md @@ -0,0 +1,160 @@ +# PROGRESS · R8 设置弹窗 + 会员充值(2026-09-16) + +任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` §R8(T8.1 / T8.2) + +- 分支:`codex/cend-settings-billing-20260916` +- 基线:`origin/staging` = `50ce02c8` +- BUG 编号:**未占用**。本轮是任务书 D13 授权的产品改造,未发现新缺陷,`docs/BUG_HISTORY.md` 当前最大号仍为 BUG-738。 +- 未 push(按要求)。 + +--- + +## 开工基线(本 worktree 实测) + +| 项 | 数字 | +|---|---| +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error / **118 warning** | +| `npm test` | tests **3350**、pass 3304、**fail 31**、skipped 15 | +| `npm run build` | exit 0,`/` = `○ (Static)` | +| 产物 CSS(`.next/static/chunks/*.css` 合并) | raw **251,634** B,主 bundle 242,608 B,gzip **39,034** B | + +31 条无 Docker 失败已存到 `/tmp/.../base.txt`,其中含一批计费 / 订阅相关条目,均为环境失败,非本轮引入。 + +--- + +## 改了什么 + +### T8.1 设置内容区收窄(E13) + +- `frontend/src/components/account-dialog-overlay.tsx` + - 新增 `formPanes = ["profile", "general"]` 与 `isFormPane`。 + - 内容区 className 变为 `settings-dialog-content` +(表单分区才加)`settings-dialog-content--form`。 + - 导航项、`aria-current="page"`、`.settings-modal`、`.account-settings-shell` **一律未动**。 +- `frontend/src/app/globals.css`(`.settings-dialog-content` 相邻行) + - 新增 `.settings-dialog-content--form > * { max-width: 440px; margin-right: auto; }`。 + - 上限挂在**滚动容器的子元素**上,不挂容器本身、不挂弹窗、不挂 shell 栅格。 + +「星盘资料」「账户与点数」两个列表分区不带该修饰符,铺满不变;`chart-library-panel.tsx` **无需改动,未改**。 + +### T8.2 套餐卡状态收敛(D13) + +- `frontend/src/components/billing-panel.tsx` + - 三修饰符拼接 → `const stateClass = isCurrent ? " is-current" : recommended ? " is-recommended" : "";`,`className={`membership-plan-card${stateClass}`}`。 + - 新增 `data-plan-alias={alias ?? undefined}`,只承担版位(手机端月卡置顶),不承担状态。 + - `highlighted` 保留 `ref` / `tabIndex` / `aria-current`,**不再进 className**。 +- `frontend/src/app/globals.css` + - `.membership-plan-card--recommended` → `.membership-plan-card.is-recommended`(声明逐字未动)。 + - `.membership-plan-card--current` → `.membership-plan-card.is-current`(声明逐字未动)。 + - 删除 `.membership-plan-card--highlighted`(边框 + outline 那一条)。 + - `@media (max-width: 640px)` 内 `order: -1` 的选择器 → `.membership-plan-card[data-plan-alias="monthly"]`。 + +滚动定位本身在 `use-billing-panel.ts` 的 highlight effect 里**早已存在**(`scrollIntoView({behavior:"smooth",block:"center"})` + `focus({preventScroll:true})`),本轮未改该 hook,只是把外观那一层摘掉后由它独立承担定位。 + +三种历史组合的外观,明确且不叠加: + +| 组合 | class | 外观 | 角标 | +|---|---|---|---| +| 仅推荐 | `is-recommended` | `--color-border-strong` 描边 + canvas 底 | 推荐 | +| 仅当前 | `is-current` | `--color-action` 描边 | 当前套餐 | +| 推荐且当前 | `is-current` | 同「仅当前」 | 当前套餐 | + +「推荐且当前」判给**当前套餐**:该卡的实际动作是「续费」而不是「立即购买」,角标与按钮文案本来就已经这样分。两个 class 在同一张卡上永不共存,由测试逐组合枚举钉死。 + +--- + +## 逐条验收对照 + +| 约束 | 结论 | 证据 | +|---|---|---| +| 1 不回退 BUG-698 的 `@supports (height: 1dvh)` | **通过** | 未新增任何全高容器,未碰 5100 附近区段;`viewport-unit-fallback-contract.test.ts` 3 条全绿 | +| 2 不碰 `.settings-dialog-nav-item[aria-current="page"]` | **通过** | `git diff` 中该行未出现;`account-dialog-overlay.test.ts` 的分区菜单契约仍绿 | +| 3 四分区切换不跳尺寸 | **静态通过 + 浏览器缺口** | 见下节「BUG-554 / BUG-698 复验方式」 | +| 4 只动允许的文件与区段 | **通过** | 改动文件:`account-dialog-overlay.tsx`、`billing-panel.tsx`、`globals.css`、三个测试文件、`DESIGN.md`。`globals.css` 只动 `.settings-dialog-content` 相邻行与 `.membership-plan-card*`;未出现 `.chart-page-*` / `.ephemeris-*` / `.report-center-*` / `.personal-report-*` / `.rectification-*` | +| 5 三种组合不叠加 | **通过** | 上表;`billing-panel.test.ts` 新增「mutually exclusive across the three historical combinations」枚举四种输入 | +| 6 `membershipHref` 带参入口仍定位 | **链路通过 / 滚动为缺口** | 新增测试真跑 `membershipHref → parseSettingsQuery`(`tab: "credits"` 与 `plan: "monthly"` 两条);`initialTab` 覆盖规则与 `ref` + `scrollIntoView` 接线断言在案。**浏览器里真滚一次做不到**,见环境缺口 | +| 7 支付关闭 / 空态 / `packagesError` 三分支不变 | **通过** | 这三处 JSX 与文案 `git diff` 内无改动;`billing-panel.test.ts`、`membership-page.test.ts` 相关断言未动且全绿 | +| 8 `tsc` 0 错;lint 0 error,warning ≤ 118 | **通过** | tsc 0 错;lint **0 error / 118 warning**,与基线持平 | +| 9 `npm test` 失败清单与基线逐条一致 | **通过** | `diff base.txt after.txt` → 无差异。tests 3350 → **3354**(+4),pass 3304 → 3308,fail **31 → 31** | +| 10 改断言写「原值/新值/原因」 | **通过** | 见下节表;6 处,均就地三行注释 | +| 11 `next build` 后 `/` 保持 `○ Static` | **通过** | 干净重建(`rm -rf .next`)后 `┌ ○ /` | +| 12 同提交更新 DESIGN.md;文案对照 VOICE | **通过** | `frontend/DESIGN.md` 的 Settings dialog 加「Content width」,Billing pane 加「Plan card states」并改写 Deep links。**无新文案**(「当前套餐 / 推荐」是既有字串),VOICE.md 无需改 | +| 13 不改数据库结构 | **通过** | 无迁移文件、无 schema 改动 | + +产物体积(干净重建,非缓存):合并 CSS raw 251,634 → **251,400** B(−234),gzip 39,034 → **38,882** B(**−0.39%**),在 ±2% 内,方向是变小(删掉了 `--highlighted` 那条死规则)。 + +补充证据:新规则确实活过 Lightning CSS 压缩 —— +产物里 `settings-dialog-content--form>*{max-width:440px;margin-right:auto}`、 +`[data-plan-alias=monthly]{order:-1}`、`membership-plan-card.is-current{border-color:var(--color-action)}` 均在, +`membership-plan-card--highlighted` 出现 **0** 次。 + +> 过程记录:第一次 `npm run build` 复用了 `.next` 缓存,产物 CSS 没有重新编译(旧 `--highlighted` 仍在)。上面所有产物数字来自 `rm -rf .next` 后的干净重建。 + +--- + +## 被改的既有断言(原值 / 新值 / 原因) + +均已在测试文件里就地写成三行注释。 + +| # | 文件 | 原值 | 新值 | 原因 | +|---|---|---|---|---| +| 1 | `tests/billing-panel.test.ts` | `assert.match(panelSource, /membership-plan-card--recommended/)` | 断言互斥的 `stateClass` 三元 + `className={\`membership-plan-card${stateClass}\`}` | D13 收敛成两种互斥状态;只断类名存在拦不住重新叠加(这正是 BUG-698 总结的「存在性断言不算防线」) | +| 2 | `tests/billing-panel.test.ts` | `assert.match(panelSource, /membership-plan-card--highlighted/)` | `doesNotMatch` 该类名 + `doesNotMatch(/highlighted \?[^\n]*membership-plan-card/)` + 断言 `ref={highlighted ? highlightedCardRef : undefined}` | D13:URL 高亮不再改外观,改为滚动定位;断言方向反转 | +| 3 | `tests/membership-page.test.ts` | `@media (max-width: 640px) … .membership-plan-card--recommended { order: -1 }` | 同一条规则改用 `.membership-plan-card[data-plan-alias="monthly"]` | 「推荐」成为互斥状态后,月卡一旦是当前套餐就丢 `is-recommended`;手机端「月卡置顶」是版位不是状态,挂到套餐身份上,排序结果与改前逐字相同 | +| 4 | `tests/membership-page.test.ts` | `doesNotMatch(cssBlock(".membership-plan-card--recommended"), /grid-column/)` | 同断言,选择器改 `.membership-plan-card.is-recommended` | 状态类改名,断言强度不变 | +| 5 | `tests/membership-page.test.ts` | `match(cssBlock(".membership-plan-card--highlighted"), /outline: 2px solid color-mix\(…\)/)` | `doesNotMatch(globalStyles, /membership-plan-card--highlighted/)` | 该规则被删;断言改为证明它不再存在(更强,不是弱化) | +| 6 | `tests/membership-page.test.ts` | `match(cssBlock(".membership-plan-card--current"), /border-color: var\(--color-action\)/)` 与 `match(cssBlock(".membership-plan-card--recommended"), /background: var\(--color-canvas\)/)`;`doesNotMatch(globalStyles, /membership-plan-card--recommended::before/)` | 选择器改 `.is-current` / `.is-recommended`;并**新增** `assert.notEqual(cssBlock(is-current), cssBlock(is-recommended))`;`::before` 断言同时覆盖新旧两种写法 | 状态类改名,token 未动;新增的 `notEqual` 是净增强度(两种状态外观必须可区分) | + +**净增的断言**(不在上表,因为没有改动既有断言): + +- `tests/account-dialog-overlay.test.ts` +2 个 test:表单分区带 cap / 列表分区不带;cap 不可能改变弹窗尺寸。 +- `tests/billing-panel.test.ts` +2 个 test:带参入口链路真跑;三种历史组合互斥枚举。 + +测试总数 3350 → 3354,无任何断言被静默删除或弱化。 + +### 破坏性验证(证明新断言不是 no-op) + +| 破坏 | 结果 | +|---|---| +| cap 从 `440px` 改成 `800px` | `not ok 6 - form panes get a reading-width cap…` | +| cap 选择器提到 `.settings-modal …` 并加 `height: 300px` | `not ok 7 - the pane cap cannot resize the dialog box (BUG-554 / BUG-698 mechanism)` | +| 把 `.is-current` 的声明改成与 `.is-recommended` 一字不差 | `not ok 11 - membership page reuses the warm parchment / terracotta tokens` | + +三次破坏后均已还原,`git diff` 干净。 + +--- + +## BUG-554 / BUG-698 的复验方式 + +两条记录已完整读过(`docs/BUG_HISTORY.md:11518` 起为 BUG-698;BUG-554 在其上)。两者是**同一个现象的两层**:「切分区时弹窗盒子变大小」。 + +复查两条的防复发措施是否仍然成立: + +1. **BUG-554「四个分区共用一个类」** —— 仍成立。`accountDialogClasses` 四个分区仍全部映射到 `settings-modal`,由 `account-dialog-overlay.test.ts` 的 `all four settings panes share one dialog class` 直接断言常量(本轮未动该常量、未动该测试)。 +2. **BUG-554「必须同时声明 width 与 height」** —— 仍在,但按 BUG-698 的更正,它只是存在性断言,不算防线,本轮不依赖它。 +3. **BUG-698「`height` 用 dvh 必须 vh 基线 + `@supports (height: 1dvh)` 升级」** —— 仍成立。本轮未新增任何全高容器,未碰 5100 附近区段;`viewport-unit-fallback-contract.test.ts` 三条全绿。 +4. **BUG-698「新写 CSS 契约要么断到产物、要么可被破坏性验证证伪」** —— 本轮照办:三条新断言各做了一次破坏性验证(上表),并额外把三条新规则在 `next build` **产物 CSS** 里逐条取证(上一节)。 + +本轮改动为什么在机制上不可能让盒子再跳: + +- 弹窗盒子的尺寸由 `.settings-modal` 上的 `width` + `height` 决定,四个分区共用这一个类;本轮没动这个类,也没动 `.account-settings-shell` 的 `grid-template-columns`。 +- 新增的上限是 `.settings-dialog-content--form > *`,作用域严格落在滚动容器的**子元素**上。子元素变窄只会让 `.settings-dialog-content`(`overflow-y:auto`、`min-width:0`)内部留白,**向上传不到**固定尺寸的弹窗。 +- 这一点由新测试 `the pane cap cannot resize the dialog box (BUG-554 / BUG-698 mechanism)` 结构化守住:遍历所有含 `settings-dialog-content--form` 的规则,禁止其选择器出现 `settings-modal` / `account-settings-shell` / `account-modal` / `settings-dialog-nav`,禁止规则里出现 `height:`;并复核 `.settings-modal` 仍同时声明 width 与 height、`.account-settings-shell` 仍声明列栅格。破坏性验证已打红。 + +**这是静态 / 结构级复验,不是真机复验。** 见环境缺口第 1 条。 + +--- + +## 环境缺口(不得记为通过) + +1. **设置弹窗四个分区切换不跳尺寸** —— **做不到,是缺口**。本会话无 Chrome、无登录态,无法打开弹窗量四个分区的 computed size。已做的是上一节的结构级复验 + 产物 CSS 取证。BUG-698 当时是用「真实 `next build` 产物 CSS + 逐节点复刻 DOM」在 Chrome 里量的 866.80 × 630.40 / 640px,本轮**没有**重跑那个 harness。请产品负责人在真机上按 `docs/testing/` 清单走一遍:登录 → 打开设置 → 在 个人资料 / 星盘资料 / 账户与点数 / 通用设置 之间来回切,确认弹窗外框宽高不变、只有右侧内容在滚。 +2. **支付下单流程** —— **做不到,是缺口**。无受控账号、无支付渠道凭据。`membershipHref("credits")` / `?plan=monthly` 的**解析链路**已真跑(新测试),但「点进去 → 对应 tab 选中 → 卡片滚到视口中央并获得焦点 → 下单 → 收银台」整条留给产品负责人实测。 +3. **`?plan=` 滚动定位的视觉确认** —— 删掉 `--highlighted` 后,该卡在视觉上**不再有任何高亮**,只有滚动位置与程序焦点。`tabIndex={-1}` 上的程序化 `focus()` 通常不会触发 `:focus-visible`,因此屏幕上大概率看不出「就是这张」。这是任务书 D13 明确要求的结果(「不再改变外观」),但**是否可接受需要产品负责人在真机上确认**;若确认不可接受,应另立单加一个不与 `is-current` / `is-recommended` 冲突的定位提示(例如短时的 `:focus-visible` 描边或滚动后的一次性动效),不要把旧的第三层描边加回来。 +4. 无 Docker:`npm test` 的 31 条失败与基线逐条一致,其中含计费 / 订阅套件,非本轮引入。 + +--- + +## 未做 / 不在本轮 + +- `chart-library-panel.tsx`:任务书写「如需」,本轮方案不需要改它(列表分区通过不带修饰符实现铺满),未改。 +- 其它轮次(R4/R5/R6/R7)的区段一律未碰。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 295b852a..35541a43 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -479,6 +479,7 @@ page. Three parts now, in reading order: - **Structure:** one fixed chrome for four panes — 个人资料, 星盘资料, 账户与点数, 通用设置. Left nav is 176px and does not scroll; the title bar stays put; only the right-hand content pane scrolls. Logout stays a separate 400px confirmation. - **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top. - **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited. +- **Content width:** the 880px frame leaves roughly 690px of content, which is too wide for a single column of fields. Form panes (个人资料, 通用设置) cap their children at 440px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698). - **Personal profile:** one row of avatar editing (48px preview, eight palettes, 换一个形象) plus nickname and login email. No duplicate 管理星盘资料 button. - **Chart library:** list first (self row, other rows, 添加其他人). A row opens a detail with ← 星盘资料. Other details own 设为默认 / 删除 / 用于合盘 and that person's synastry history. The add form is a view, not an always-on stack. - **States:** open, pane switch, list / self / other / add, saving, success, and error. @@ -486,8 +487,9 @@ page. Three parts now, in reading order: ### Billing pane - **Structure:** summary line (balance · membership / expiry), then tabs 会员套餐 / 点数包 / 兑换码 / 订单记录. +- **Plan card states:** exactly two, and they are mutually exclusive. `.is-current` (当前套餐) draws the `--color-action` border; `.is-recommended` (推荐, the monthly plan) draws the `--color-border-strong` border on the plain canvas. A card that is both takes 当前套餐 — its own action is 续费, not 立即购买 — so borders and fills can never stack. The badge in the card header says the same word. Mobile ordering (月卡 first below 640px) keys off `[data-plan-alias="monthly"]`, i.e. the plan's identity, not its state, so it survives the card becoming the current plan. - **Actions:** recommended-plan purchase is `Button` default. Other plan and credit buys use `Button` outline. Cashier still opens with `window.open` in a new tab. Paying copy is 跳转收银台…; pending copy is 等待支付结果. Redeem is an inline form on its tab, not a nested dialog. -- **Deep links:** `/?settings=billing&source=…[&plan=…][&tab=…]` open this pane on `/` and are then stripped. `/membership` and `/membership/orders` redirect here. +- **Deep links:** `/?settings=billing&source=…[&plan=…][&tab=…]` open this pane on `/` and are then stripped. `/membership` and `/membership/orders` redirect here. `&plan=` does **not** repaint the card (that used to be a third border plus an outline on top of the two states); it scrolls the card into view and focuses it, and nothing else. - **Waiting:** the summary renders from the in-memory account. Package cards stay empty until `/api/payment/packages` arrives. No spinner, skeleton, or 「正在加载」. ### Logout dialog diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a98d0986..4276f480 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1739,6 +1739,12 @@ button:disabled { cursor: default; opacity: .45; } .settings-dialog-nav-item > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); } .settings-dialog-nav-item > svg:last-child { width: 15px; height: 15px; margin-left: auto; } .settings-dialog-content { min-width: 0; min-height: 0; overflow-y: auto; } +/* Form panes (个人资料 / 通用设置) read as one column, so they are capped and left + aligned instead of stretching across the ~690px content area. The cap lives on the + children, never on the scroll container or the dialog box: .settings-modal keeps its + fixed width/height, so the four panes still cannot change the dialog size (BUG-554 / + BUG-698). List panes (星盘资料 / 账户与点数) stay full-bleed. */ +.settings-dialog-content--form > * { max-width: 440px; margin-right: auto; } .settings-dialog-copy { margin: 0 0 var(--space-5); color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } .avatar-section { padding-top: 0; } .avatar-editor { display: grid; grid-template-columns: 48px minmax(0, 1fr); align-items: center; gap: var(--space-5); margin-top: var(--space-5); } @@ -4298,8 +4304,12 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } .membership-plan-grid, .membership-credit-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-4); } .membership-plan-card, .membership-credit-card { min-width: 0; display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-6); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: none; } -.membership-plan-card--recommended { border-color: var(--color-border-strong); background: var(--color-canvas); } -.membership-plan-card--current { border-color: var(--color-action); } +/* Two mutually exclusive card states (D13). The markup emits at most one of them, so + borders and backgrounds can never stack the way the three old BEM modifiers + (recommended / current / URL highlight) did. Plan order on mobile is keyed off the + plan identity below, not off a state, so it survives the card becoming 当前套餐. */ +.membership-plan-card.is-recommended { border-color: var(--color-border-strong); background: var(--color-canvas); } +.membership-plan-card.is-current { border-color: var(--color-action); } .membership-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); } .membership-card-header h3, .membership-credit-copy h3 { margin: 0; color: var(--color-ink); font-family: var(--font-body); font-size: var(--type-title-md); font-weight: 600; letter-spacing: -.01em; } .membership-card-meta { margin: 4px 0 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } @@ -4332,7 +4342,9 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } .membership-tab:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } .membership-tab[data-active] { border-bottom-color: var(--color-action); background: transparent; color: var(--color-action); box-shadow: none; } .membership-panel { min-width: 0; } -.membership-plan-card--highlighted { border-color: var(--color-action); outline: 2px solid color-mix(in srgb, var(--color-action) 38%, transparent); outline-offset: 2px; } +/* The ?plan= entry point no longer repaints the card (it used to add a third border + + outline on top of the recommended / current states). It scrolls the card into view + and focuses it instead; see useBillingPanel's highlight effect. */ .membership-payment-error { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-4); padding: var(--space-3) 0; border-top: 1px solid color-mix(in srgb, var(--color-danger) 48%, var(--color-border)); border-bottom: 1px solid color-mix(in srgb, var(--color-danger) 32%, var(--color-border)); background: transparent; } .membership-payment-error p { margin: 0; color: var(--color-danger); font-size: var(--type-body-sm); line-height: 1.55; } @@ -4343,7 +4355,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } } @media (max-width: 640px) { - .membership-plan-card--recommended { order: -1; } + .membership-plan-card[data-plan-alias="monthly"] { order: -1; } .membership-payment-error { align-items: flex-start; flex-direction: column; } } diff --git a/frontend/src/components/account-dialog-overlay.tsx b/frontend/src/components/account-dialog-overlay.tsx index 4ab1a878..2204da8f 100644 --- a/frontend/src/components/account-dialog-overlay.tsx +++ b/frontend/src/components/account-dialog-overlay.tsx @@ -31,6 +31,15 @@ const settingsItems: ReadonlyArray<{ { dialog: "general", label: "通用设置", icon: Settings }, ]; +/** + * Panes whose body is a single column of form fields. They get a reading-width cap + * inside the content box; the list panes (星盘资料 / 账户与点数) stay full-bleed. + * The cap is purely inside `.settings-dialog-content`: every pane still renders under + * the same `.settings-modal` box, so the dialog cannot change size between panes + * (BUG-554 / BUG-698). + */ +const formPanes: ReadonlyArray = ["profile", "general"]; + export const AccountDialogOverlay = memo(function AccountDialogOverlay({ open, dialog, @@ -43,6 +52,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({ if (!open || dialog === null || model === null) return null; const isSettingsDialog = dialog !== "logout"; + const isFormPane = dialog !== "logout" && formPanes.includes(dialog); const renderSettingsContent = () => { if (dialog === "profile") return model.renderProfile(); if (dialog === "chart-library") return model.renderChartLibrary(); @@ -90,7 +100,11 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({ ))} -
{renderSettingsContent()}
+
+ {renderSettingsContent()} +
) : model.renderLogout()} diff --git a/frontend/src/components/billing-panel.tsx b/frontend/src/components/billing-panel.tsx index a0d2ede0..a46a1e38 100644 --- a/frontend/src/components/billing-panel.tsx +++ b/frontend/src/components/billing-panel.tsx @@ -126,13 +126,18 @@ export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab const recommended = alias === "monthly"; const isCurrent = currentProductCode === product.code; const highlighted = alias === highlightedPlan; + // D13: two mutually exclusive states. 当前套餐 wins over 推荐 because the + // card's own action is 续费, not 立即购买. `highlighted` carries no + // appearance any more — it only picks the scroll/focus target. + const stateClass = isCurrent ? " is-current" : recommended ? " is-recommended" : ""; return (
diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts index 1a6ed18b..7d403bdf 100644 --- a/frontend/tests/account-dialog-overlay.test.ts +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -9,6 +9,7 @@ import { type AccountOverlayModel, } from "../src/components/account-dialog-overlay.tsx"; import { accountDialogClasses } from "../src/lib/home-types.ts"; +import { cssDeclarations } from "./css-contract-test-support.ts"; function overlayModel(overrides: Partial = {}): AccountOverlayModel { const overlayRef = createRef() as AccountOverlayModel["overlayRef"]; @@ -125,3 +126,62 @@ test("the settings pane menu separates hover from current without an accent bar" for (const rule of currentRules) assert.ok(!rule.includes(":hover"), `current and hover must be separate rules: ${rule}`); for (const rule of hoverRules) assert.ok(!rule.includes('[aria-current="page"]'), `current and hover must be separate rules: ${rule}`); }); + +test("form panes get a reading-width cap, list panes stay full-bleed", () => { + // T8.1 / E13: the 880px dialog leaves ~690px of content, which pulls a one-column + // form apart. The cap is applied per pane, inside the content box. + const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + const render = (pane: "profile" | "chart-library" | "billing" | "general") => renderToString( + createElement(AccountDialogOverlay, { open: true, dialog: pane, model: overlayModel() }), + ); + + for (const pane of ["profile", "general"] as const) { + assert.match( + render(pane), + /class="settings-dialog-content settings-dialog-content--form"/, + `${pane} is a form pane and must carry the cap`, + ); + } + for (const pane of ["chart-library", "billing"] as const) { + const html = render(pane); + assert.match(html, /class="settings-dialog-content"/, `${pane} must stay full-bleed`); + assert.doesNotMatch(html, /settings-dialog-content--form/, `${pane} must stay full-bleed`); + } + + const cap = cssDeclarations(".settings-dialog-content--form > *", styles); + assert.match(cap, /max-width:\s*4[2-6]\dpx/, "cap belongs in the 420-460px reading-width band"); + assert.match(cap, /margin-right:\s*auto/, "capped content is left aligned"); +}); + +test("the pane cap cannot resize the dialog box (BUG-554 / BUG-698 mechanism)", () => { + // Both bugs were "the box changes size when I switch panes". This re-verification is + // structural, not visual: (1) every pane still maps to the single .settings-modal + // class (asserted in the test above); (2) .settings-modal still declares a fixed + // width AND height, so the box is sized by the class, never by its content; (3) the + // new cap is scoped strictly below .settings-dialog-content, so it cannot reach the + // modal or the nav/content grid. A browser check of the four panes is an environment + // gap, recorded in the progress note. + const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + const modal = cssDeclarations(".settings-modal", styles); + assert.match(modal, /width:/); + assert.match(modal, /height:/); + + const capRules = styles.match(/[^{}\n]*settings-dialog-content--form[^{}]*\{[^{}]*\}/g) ?? []; + assert.ok(capRules.length > 0, "expected the form cap rule"); + for (const rule of capRules) { + const selector = rule.slice(0, rule.indexOf("{")); + assert.ok( + selector.includes(".settings-dialog-content--form"), + `cap must stay scoped to the content box: ${rule}`, + ); + assert.doesNotMatch( + selector, + /settings-modal|account-settings-shell|account-modal|settings-dialog-nav/, + `cap must not reach the dialog box, the shell grid or the nav: ${rule}`, + ); + assert.doesNotMatch(rule, /[^-]height:/, `cap must not set a height: ${rule}`); + } + + // The grid that splits nav from content stays pane independent. + assert.match(cssDeclarations(".account-settings-shell", styles), /grid-template-columns:/); +}); diff --git a/frontend/tests/billing-panel.test.ts b/frontend/tests/billing-panel.test.ts index 3663563a..8bb2fc6d 100644 --- a/frontend/tests/billing-panel.test.ts +++ b/frontend/tests/billing-panel.test.ts @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; +import { membershipHref } from "../src/lib/membership.ts"; +import { parseSettingsQuery } from "../src/lib/settings-url.ts"; + const projectFile = (path: string) => new URL(`../${path}`, import.meta.url); const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); const hookSource = readProjectFile("src/hooks/use-billing-panel.ts"); @@ -146,7 +149,11 @@ test("membership section shows fixed trial/monthly/yearly plans with monthly rec assert.match(panelSource, /selectMembershipPlans\(paymentPackages\)/); assert.match(panelSource, /planAlias\(product\)/); assert.match(panelSource, /alias === "monthly"/); - assert.match(panelSource, /membership-plan-card--recommended/); + // 原值: assert.match(panelSource, /membership-plan-card--recommended/); + // 新值: 断言互斥的 stateClass 三元,并钉死 is-current 优先于 is-recommended。 + // 原因: D13 把三个可叠加的修饰符收敛成两种互斥状态;只断类名存在拦不住重新叠加。 + assert.match(panelSource, /const stateClass = isCurrent \? " is-current" : recommended \? " is-recommended" : "";/); + assert.match(panelSource, /className=\{`membership-plan-card\$\{stateClass\}`\}/); assert.match(membershipLib, /trial: "体验"/); assert.match(membershipLib, /monthly: "月卡"/); assert.match(membershipLib, /yearly: "年卡"/); @@ -200,7 +207,13 @@ test("membership and credits use the local Base UI tabs wrapper without manual s test("plan param forces the membership segment and highlights the matching card", () => { assert.match(panelSource, /const highlighted = alias === highlightedPlan;/); - assert.match(panelSource, /membership-plan-card--highlighted/); + // 原值: assert.match(panelSource, /membership-plan-card--highlighted/); + // 新值: 断言 highlighted 不再进 className,定位改由 ref + scrollIntoView 承担。 + // 原因: D13——第三个修饰符与 --recommended / --current 叠出无设计定义的边框组合; + // ?plan= 入口改为挂载后滚动定位,外观不变。 + assert.doesNotMatch(panelSource, /membership-plan-card--highlighted/); + assert.doesNotMatch(panelSource, /highlighted \?[^\n]*membership-plan-card/); + assert.match(panelSource, /ref=\{highlighted \? highlightedCardRef : undefined\}/); assert.match(panelSource, /aria-current=\{highlighted \? "true" : undefined\}/); assert.match(panelSource, /highlightedCardRef/); assert.match(hookSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/); @@ -209,6 +222,49 @@ test("plan param forces the membership segment and highlights the matching card" assert.doesNotMatch(billingSource, /setActiveSegment\("credits"\)[\s\S]{0,80}highlightedPlan/); }); +test("parameterised entry points still resolve to a tab and a card, with the card only located", () => { + // Runs the real chain membershipHref -> parseSettingsQuery, because dropping the + // --highlighted class left the ?plan= entry with nothing but the scroll target. + const credits = parseSettingsQuery(new URL(membershipHref("balance", { tab: "credits" }), "https://x").search); + assert.equal(credits?.pane, "billing"); + assert.equal(credits?.tab, "credits"); + assert.equal(credits?.plan, null); + + const plan = parseSettingsQuery(new URL(membershipHref("upsell", { plan: "monthly" }), "https://x").search); + assert.equal(plan?.pane, "billing"); + assert.equal(plan?.plan, "monthly"); + + // A plan param forces the membership tab regardless of ?tab=, and the card is located + // by ref + scrollIntoView rather than repainted. The browser-level scroll itself is an + // environment gap (no Chrome, no signed-in account); see the progress note. + assert.match(hookSource, /if \(!input\.highlightedPlan \|\| planHighlightScrolled\.current \|\| paymentPackages\.length === 0\) return;/); + assert.match(hookSource, /card\.scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/); + assert.match(panelSource, /ref=\{highlighted \? highlightedCardRef : undefined\}/); +}); + +test("the plan card states stay mutually exclusive across the three historical combinations", () => { + // recommended-only / current-only / recommended+current used to stack two modifiers. + const stateClass = (isCurrent: boolean, recommended: boolean) => ( + isCurrent ? " is-current" : recommended ? " is-recommended" : "" + ); + const combinations = [ + { isCurrent: false, recommended: true, expected: " is-recommended" }, + { isCurrent: true, recommended: false, expected: " is-current" }, + { isCurrent: true, recommended: true, expected: " is-current" }, + { isCurrent: false, recommended: false, expected: "" }, + ]; + for (const { isCurrent, recommended, expected } of combinations) { + const value = stateClass(isCurrent, recommended); + assert.equal(value, expected); + assert.ok(!(value.includes("is-current") && value.includes("is-recommended")), + "the two states must never appear on the same card"); + } + // The test's ternary is the one in the component, verbatim. + assert.match(panelSource, /const stateClass = isCurrent \? " is-current" : recommended \? " is-recommended" : "";/); + // 推荐且当前 resolves to 当前套餐, and the badge says so. + assert.match(panelSource, /\{isCurrent \? "当前套餐" : "推荐"\}/); +}); + test("page main title is 套餐与会员", () => { // 原值:

套餐与会员

// 新值: 分区标题由弹窗壳提供「账户与点数」,摘要行写余额与会员状态 diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index 9b2a4c8e..55025057 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -85,8 +85,12 @@ test("desktop uses three columns, tablet two and mobile one with monthly first", // 原因: BUG-697 噪音收敛,白名单只留一组内容级切点。 assert.match(globalStyles, /@media \(min-width:\s*641px\)[\s\S]*\.membership-plan-grid[\s\S]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/); assert.match(globalStyles, /@media \(min-width:\s*1024px\)[\s\S]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/); - assert.match(globalStyles, /@media \(max-width:\s*640px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order:\s*-1/); - assert.doesNotMatch(cssBlock(".membership-plan-card--recommended"), /grid-column/); + // 原值: /@media \(max-width: 640px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order: -1/ + // 新值: 同一条规则改用 .membership-plan-card[data-plan-alias="monthly"]。 + // 原因: D13 后「推荐」是互斥状态,月卡一旦成为当前套餐就失去 is-recommended; + // 手机端「月卡排第一」是版位而不是状态,因此改挂在套餐身份上,排序与改前逐字一致。 + assert.match(globalStyles, /@media \(max-width:\s*640px\)[\s\S]*\.membership-plan-card\[data-plan-alias="monthly"\]\s*\{[^}]*order:\s*-1/); + assert.doesNotMatch(cssBlock(".membership-plan-card.is-recommended"), /grid-column/); }); test("segment tabs use a quiet underline treatment", () => { @@ -95,7 +99,10 @@ test("segment tabs use a quiet underline treatment", () => { assert.match(cssBlock(".membership-tab[data-active]"), /border-bottom-color:\s*var\(--color-action\)/); assert.match(cssBlock(".membership-tab[data-active]"), /background:\s*transparent/); assert.match(cssBlock(".membership-tab[data-active]"), /box-shadow:\s*none/); - assert.match(cssBlock(".membership-plan-card--highlighted"), /outline:\s*2px solid color-mix\(in srgb, var\(--color-action\)/); + // 原值: assert.match(cssBlock(".membership-plan-card--highlighted"), /outline: 2px solid color-mix\(in srgb, var\(--color-action\)/); + // 新值: 断言该规则已不存在——?plan= 高亮不再画描边。 + // 原因: D13,URL 高亮改为滚动定位,不叠第三层外观。 + assert.doesNotMatch(globalStyles, /membership-plan-card--highlighted/); assert.match(cssBlock(".membership-payment-error"), /background:\s*transparent/); }); @@ -109,7 +116,15 @@ test("membership shell is capped within the 1120-1200 range", () => { }); test("membership page reuses the warm parchment / terracotta tokens", () => { - assert.match(cssBlock(".membership-plan-card--current"), /border-color:\s*var\(--color-action\)/); + // 原值: cssBlock(".membership-plan-card--current") + // 新值: cssBlock(".membership-plan-card.is-current"),并补一条「两种状态外观不相同」。 + // 原因: D13 状态类改名为互斥的 is-current / is-recommended;token 与改前一致。 + assert.match(cssBlock(".membership-plan-card.is-current"), /border-color:\s*var\(--color-action\)/); + assert.notEqual( + cssBlock(".membership-plan-card.is-current"), + cssBlock(".membership-plan-card.is-recommended"), + "当前套餐与推荐必须有可区分的外观", + ); assert.match(cssBlock(".membership-status"), /color:\s*var\(--color-action\)/); assert.doesNotMatch(globalStyles, /membership-badge--recommended/); assert.doesNotMatch(globalStyles, /\.membership-title|\.membership-hero/); @@ -122,8 +137,11 @@ test("membership page scrolls within a fixed viewport and cards stay flat", () = assert.doesNotMatch(globalStyles, /\.membership-page/); assert.match(cssBlock(".settings-dialog-content"), /overflow-y:\s*auto/); assert.match(cssBlock(".membership-plan-card, .membership-credit-card"), /box-shadow:\s*none/); - assert.match(cssBlock(".membership-plan-card--recommended"), /background:\s*var\(--color-canvas\)/); - assert.doesNotMatch(globalStyles, /membership-plan-card--recommended::before/); + // 原值: cssBlock(".membership-plan-card--recommended") + // 新值: cssBlock(".membership-plan-card.is-recommended") + // 原因: 同上,D13 状态类改名;声明本身未动。 + assert.match(cssBlock(".membership-plan-card.is-recommended"), /background:\s*var\(--color-canvas\)/); + assert.doesNotMatch(globalStyles, /membership-plan-card(--recommended|\.is-recommended)::before/); }); test("membership lib keeps helpers and the balance sync contract", () => {