feat(settings): 表单分区收窄到 440px,套餐卡收敛成两种互斥状态
任务书 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:
co-authored by
Claude Opus 5
parent
50ce02c837
commit
34628ecb67
@@ -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:/);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
// 新值: 分区标题由弹窗壳提供「账户与点数」,摘要行写余额与会员状态
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user