feat(settings): 表单分区收窄到 440px,套餐卡收敛成两种互斥状态
Independent Staging Quality Gate / validate (push) Canceled after 6m18s
Independent Staging Quality Gate / publish (push) Canceled after 0s

任务书 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 06:24:11 +00:00
co-authored by Claude Opus 5
parent 50ce02c837
commit 34628ecb67
8 changed files with 342 additions and 15 deletions
@@ -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> = {}): AccountOverlayModel {
const overlayRef = createRef<HTMLElement | null>() 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:/);
});
+58 -2
View File
@@ -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 套餐与会员", () => {
// 原值: <h1 className="membership-title">套餐与会员</h1>
// 新值: 分区标题由弹窗壳提供「账户与点数」,摘要行写余额与会员状态
+24 -6
View File
@@ -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-recommendedtoken 与改前一致。
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", () => {