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
+3 -1
View File
@@ -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
+16 -4
View File
@@ -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; }
}
@@ -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<AccountSettingsDialog> = ["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({
</button>
))}
</nav>
<div className="settings-dialog-content">{renderSettingsContent()}</div>
<div
className={`settings-dialog-content${isFormPane ? " settings-dialog-content--form" : ""}`}
>
{renderSettingsContent()}
</div>
</div>
) : model.renderLogout()}
</section>
+6 -1
View File
@@ -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 (
<article
key={product.id}
ref={highlighted ? highlightedCardRef : undefined}
tabIndex={highlighted ? -1 : undefined}
aria-current={highlighted ? "true" : undefined}
className={`membership-plan-card${recommended ? " membership-plan-card--recommended" : ""}${isCurrent ? " membership-plan-card--current" : ""}${highlighted ? " membership-plan-card--highlighted" : ""}`}
data-plan-alias={alias ?? undefined}
className={`membership-plan-card${stateClass}`}
>
<header className="membership-card-header">
<div>
@@ -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", () => {