任务书 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
188 lines
8.5 KiB
TypeScript
188 lines
8.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { createElement, createRef } from "react";
|
|
import { renderToString } from "react-dom/server";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
AccountDialogOverlay,
|
|
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"];
|
|
const closeButtonRef = createRef<HTMLButtonElement | null>() as AccountOverlayModel["closeButtonRef"];
|
|
return {
|
|
title: "个人资料",
|
|
dialogClass: "settings-modal",
|
|
signingOut: false,
|
|
close() {},
|
|
navigate() {},
|
|
overlayRef,
|
|
closeButtonRef,
|
|
renderProfile() { return null; },
|
|
renderChartLibrary() { return null; },
|
|
renderBilling() { return null; },
|
|
renderGeneral() { return null; },
|
|
renderLogout() { return null; },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("a closed account overlay does not render profile or logout content", () => {
|
|
let profileRenders = 0;
|
|
let logoutRenders = 0;
|
|
const model = overlayModel({
|
|
renderProfile() {
|
|
profileRenders += 1;
|
|
return null;
|
|
},
|
|
renderLogout() {
|
|
logoutRenders += 1;
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const html = renderToString(createElement(AccountDialogOverlay, {
|
|
open: false,
|
|
dialog: null,
|
|
model,
|
|
}));
|
|
|
|
assert.equal(html, "");
|
|
assert.equal(profileRenders, 0);
|
|
assert.equal(logoutRenders, 0);
|
|
});
|
|
|
|
test("home does not write overlay epoch or chat actions during render", () => {
|
|
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
|
const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8");
|
|
assert.match(overlay, /model: AccountOverlayModel \| null/);
|
|
assert.doesNotMatch(overlay, /openEpoch/);
|
|
assert.doesNotMatch(overlay, /modelRef/);
|
|
assert.doesNotMatch(page, /accountOverlayEpochRef/);
|
|
assert.doesNotMatch(page, /sessionsRef\.current = sessions/);
|
|
assert.match(page, /queueMicrotask\(\(\) => setActiveChartId\(storedChartId\)\)/);
|
|
assert.match(page, /useEffect\(\(\) => \{\s*chatActionsRef\.current = \{/);
|
|
});
|
|
|
|
test("settings navigation lists four panes and billing can be current", () => {
|
|
const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8");
|
|
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
const html = renderToString(createElement(AccountDialogOverlay, {
|
|
open: true,
|
|
dialog: "billing",
|
|
model: overlayModel({ title: "账户与点数" }),
|
|
}));
|
|
|
|
assert.match(overlay, /dialog: "profile"/);
|
|
assert.match(overlay, /dialog: "chart-library"/);
|
|
assert.match(overlay, /dialog: "billing"/);
|
|
assert.match(overlay, /dialog: "general"/);
|
|
assert.doesNotMatch(overlay, /openRedeem/);
|
|
assert.match(html, /个人资料/);
|
|
assert.match(html, /星盘资料/);
|
|
assert.match(html, /账户与点数/);
|
|
assert.match(html, /通用设置/);
|
|
assert.match(html, /aria-current="page"/);
|
|
// Existence check only, kept from BUG-554. It cannot see whether the declaration
|
|
// survives to the browser, which is exactly how BUG-698 slipped through: the height
|
|
// was declared but written only in dvh. The real guards are the same-size contract
|
|
// below and frontend/tests/viewport-unit-fallback-contract.test.ts.
|
|
assert.match(styles, /\.settings-modal \{[^}]*width:[^}]*height:/);
|
|
assert.doesNotMatch(styles, /chart-library-modal|profile-modal/);
|
|
});
|
|
|
|
test("all four settings panes share one dialog class, so the box cannot change size", () => {
|
|
// BUG-554 root cause: each pane had its own width class. BUG-698 is the same symptom
|
|
// from a different layer, so the shared-class invariant is asserted directly rather
|
|
// than inferred from one pane's rendered output.
|
|
const panes = ["profile", "chart-library", "billing", "general"] as const;
|
|
const classes = new Set(panes.map((pane) => accountDialogClasses[pane]));
|
|
|
|
assert.deepEqual([...classes], ["settings-modal"]);
|
|
assert.notEqual(accountDialogClasses.logout, accountDialogClasses.profile);
|
|
});
|
|
|
|
test("the settings pane menu separates hover from current without an accent bar", () => {
|
|
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
const navRules = (styles.match(/[^{}\n]*\.settings-dialog-nav-item[^{}]*\{[^{}]*\}/g) ?? []);
|
|
const currentRules = navRules.filter((rule) => rule.includes('[aria-current="page"]'));
|
|
const hoverRules = navRules.filter((rule) => rule.includes(":hover"));
|
|
|
|
assert.ok(currentRules.length > 0, "expected a rule for the current settings pane");
|
|
assert.ok(hoverRules.length > 0, "expected a rule for the hovered settings pane");
|
|
|
|
// Product decision 2026-09-15: the pane menu drops the action-colour bar.
|
|
for (const rule of [...currentRules, ...hoverRules]) {
|
|
assert.doesNotMatch(rule, /box-shadow/, `settings pane menu must not draw an accent bar: ${rule}`);
|
|
assert.doesNotMatch(rule, /font-weight/, `current pane must not be expressed with weight: ${rule}`);
|
|
}
|
|
|
|
// Hover and current must not share one declaration, or they become indistinguishable
|
|
// once the bar is gone.
|
|
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:/);
|
|
});
|