fix(frontend): refine settings dialog layout
Independent Staging Quality Gate / validate (push) Failing after 8m47s
Independent Staging Quality Gate / publish (push) Skipped

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-19 11:32:27 +08:00
co-authored by Claude Code
parent c2ecbc74cd
commit eb8f5147f4
12 changed files with 135 additions and 48 deletions
+6 -6
View File
@@ -533,12 +533,12 @@ page. Three parts now, in reading order:
### Settings dialog
- **Placement:** the overlay and the onboarding paywall portal to `document.body`. `SidebarInset` stays `inert` while a dialog is open (the BUG-744~746 focus contract); the dialog itself must not sit inside that subtree (BUG-968). Closing still uses the existing overlay click, the header button, and the window-level Escape listener.
- **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.
- **Structure:** one fixed chrome for four panes — 个人资料, 星盘资料, 账户与点数, 通用设置. Left nav is 200px, has a hairline boundary, and does not scroll; the title bar stays put; only the right-hand content pane scrolls. The pane menu uses icon + label rows without a trailing chevron because these controls switch content inside the same dialog. 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)`. The right content pane uses 32px horizontal and bottom inset on desktop. 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 and 16px content inset.
- **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, no trailing navigation chevron, 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 now gives the content pane a deliberate inset instead of leaving controls against the divider. Form panes (个人资料, 通用设置) cap their children at 560px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed within the inset 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 (56px preview, eight palettes, 换一个形象) plus nickname and login email. The header uses the product-level “设置” eyebrow above the current pane title; there is no duplicate 管理星盘资料 button.
- **Chart library:** list first (self row, other rows, 添加其他人). 添加其他人 sits in the 其他人 group heading action area instead of below the list, so the entry remains discoverable when the list grows. 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.
### Billing pane
+25 -19
View File
@@ -1831,23 +1831,21 @@ button:disabled { cursor: default; opacity: .45; }
.account-modal-overlay { position: fixed; z-index: 40; inset: 0; display: grid; place-items: center; padding: var(--space-4); background: var(--color-scrim); animation: account-overlay-enter 180ms ease-out both; }
.account-modal { width: min(100%, 560px); max-height: min(84vh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; }
.settings-modal { width: min(100vw - 32px, 880px); height: min(84vh, 640px); max-height: min(84vh, 640px); overflow: hidden; display: grid; grid-template-rows: auto 1fr; }
.account-settings-shell { display: grid; grid-template-columns: 176px minmax(0, 1fr); min-height: 0; overflow: hidden; align-items: stretch; }
.settings-dialog-nav { display: grid; align-content: start; gap: var(--space-1); overflow: hidden; }
.settings-dialog-nav-item { min-height: 44px; display: grid; grid-template-columns: 18px minmax(0, 1fr) 16px; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; }
.account-settings-shell { display: grid; grid-template-columns: 200px minmax(0, 1fr); min-height: 0; overflow: hidden; align-items: stretch; }
.settings-dialog-nav { display: grid; align-content: start; gap: var(--space-1); overflow: hidden; padding-right: var(--space-4); border-right: 1px solid var(--color-border); }
.settings-dialog-nav-item { min-height: 44px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; }
.settings-dialog-nav-item:hover { background: color-mix(in srgb, var(--color-canvas-muted) 55%, transparent); color: var(--color-ink-secondary); }
.settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); }
.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; }
.settings-dialog-content { min-width: 0; min-height: 0; overflow-y: auto; padding: 0 var(--space-8) var(--space-8); }
/* Form panes (个人资料 / 通用设置) use a comfortable reading width instead of
leaving the content stranded in a narrow 440px column. The cap stays on pane
children, never on the scroll container or dialog box, so pane switches keep the
same frame (BUG-554 / BUG-698). List panes remain full-bleed within this inset. */
.settings-dialog-content--form > * { max-width: 560px; margin-right: auto; }
.settings-dialog-copy { max-width: 760px; 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); }
.avatar-editor { display: grid; grid-template-columns: 56px minmax(0, 1fr); align-items: center; gap: var(--space-5); margin-top: var(--space-5); }
.avatar-editor-controls { min-width: 0; display: grid; gap: var(--space-4); }
.avatar-palette-list { display: grid; grid-template-columns: repeat(4, minmax(48px, 1fr)); gap: var(--space-2); }
.avatar-palette { min-width: 0; height: 34px; overflow: hidden; display: flex; padding: 3px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, box-shadow 120ms ease-out, transform 120ms ease-out; }
@@ -1864,6 +1862,8 @@ button:disabled { cursor: default; opacity: .45; }
.logout-modal { width: min(100%, 400px); }
.account-modal h2, .auth-panel h1 { font-family: var(--font-display); font-weight: 500; letter-spacing: -.5px; text-wrap: balance; }
.account-modal h2 { margin: 0; font-size: var(--type-display-sm); letter-spacing: -.025em; }
.account-modal-heading { min-width: 0; display: grid; gap: var(--space-1); }
.account-modal-eyebrow { color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
.account-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding-bottom: var(--space-5); }
.dialog-close { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.redeem-balance { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); }
@@ -1890,6 +1890,8 @@ button:disabled { cursor: default; opacity: .45; }
.chart-library-panel { display: grid; gap: var(--space-5); margin-top: var(--space-5); }
.chart-library-group { display: grid; gap: var(--space-3); }
.chart-library-group-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.chart-library-group-heading > div:first-child { min-width: 0; }
.chart-library-group-heading-actions { display: flex; align-items: center; flex: 0 0 auto; gap: var(--space-2); }
.chart-library-group-heading b, .chart-library-group-heading small { display: block; }
.chart-library-group-heading b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
.chart-library-group-heading small { margin-top: 3px; color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; }
@@ -1899,6 +1901,7 @@ button.chart-library-item { cursor: pointer; text-align: left; }
.chart-library-item-chevron { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); }
.chart-library-back { min-height: 44px; display: inline-flex; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-4); padding: 0; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; font-size: var(--type-body-sm); }
.chart-library-add { margin-top: var(--space-4); }
.chart-library-add--heading { margin-top: 0; white-space: nowrap; }
.chart-library-item-main { min-width: 0; }
.chart-library-item strong, .chart-library-item small { display: block; }
.chart-library-item strong { color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; }
@@ -2025,13 +2028,16 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.account-modal { width: 100%; max-height: 100vh; min-height: 100vh; border-radius: 0; padding: var(--space-6); }
.settings-modal { width: 100%; height: 100vh; max-height: 100vh; }
.account-settings-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr; gap: var(--space-4); }
.settings-dialog-nav { position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-1); padding-bottom: var(--space-3); border-bottom: 1px solid var(--color-border); }
.settings-dialog-nav-item { min-height: 40px; grid-template-columns: 1fr; justify-items: center; gap: 2px; padding: var(--space-2); text-align: center; font-size: var(--type-caption); }
.settings-dialog-nav-item > svg:last-child { display: none; }
.settings-dialog-content { min-height: 0; overflow-y: auto; }
.avatar-editor { grid-template-columns: 48px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 48px !important; height: 48px !important; }
.settings-dialog-nav { position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-1); padding-bottom: var(--space-3); border-right: 0; border-bottom: 1px solid var(--color-border); }
.settings-dialog-nav-item { min-height: 44px; grid-template-columns: 1fr; justify-items: center; gap: 2px; padding: var(--space-2); text-align: center; font-size: var(--type-caption); }
.settings-dialog-content { min-height: 0; overflow-y: auto; padding: 0 var(--space-4) var(--space-6); }
.settings-dialog-content--form > * { max-width: none; }
.avatar-editor { grid-template-columns: 56px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 56px !important; height: 56px !important; }
.avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); }
.chart-library-group-heading { align-items: flex-start; }
.chart-library-group-heading-actions { align-items: flex-end; flex-direction: column; gap: var(--space-1); }
.chart-library-add--heading { min-height: 40px; padding-inline: var(--space-3); }
.auth-page { height: 100vh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; }
.auth-shell { min-height: 100%; overflow: visible; align-content: start; grid-template-columns: 1fr; grid-template-rows: auto auto; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 0; justify-content: flex-start; gap: var(--space-3); padding: var(--space-6) var(--space-6) var(--space-5); }
@@ -1,6 +1,6 @@
"use client";
import { ChevronRight, Settings, UserRound, Users, WalletCards, X } from "lucide-react";
import { Settings, UserRound, Users, WalletCards, X } from "lucide-react";
import { memo, type MutableRefObject, type ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -72,7 +72,10 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
onMouseDown={(event) => event.stopPropagation()}
>
<header className="account-modal-header">
<h2 id="account-dialog-title">{model.title}</h2>
<div className="account-modal-heading">
{isSettingsDialog ? <span className="account-modal-eyebrow"></span> : null}
<h2 id="account-dialog-title">{model.title}</h2>
</div>
<button
className="dialog-close"
ref={model.closeButtonRef}
@@ -97,7 +100,6 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
>
<Icon aria-hidden="true" />
<span>{label}</span>
<ChevronRight aria-hidden="true" />
</button>
))}
</nav>
+17 -15
View File
@@ -323,24 +323,26 @@ export function ChartLibraryPanel({
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b></b><small></small></div>
<span className="chart-library-count">{otherCharts.length}</span>
<div className="chart-library-group-heading-actions">
<span className="chart-library-count">{otherCharts.length}</span>
<button
className="button-secondary chart-library-add chart-library-add--heading"
type="button"
onClick={() => {
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice("");
setView({ kind: "add" });
}}
>
</button>
</div>
</div>
{otherCharts.length === 0 && <p className="empty-library-copy"></p>}
{otherCharts.map(listRow)}
<button
className="button-secondary chart-library-add"
type="button"
onClick={() => {
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice("");
setView({ kind: "add" });
}}
>
</button>
</div>
</div>
);
+3 -3
View File
@@ -27,11 +27,11 @@ export function ProfilePanel({
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title"></b>
<small>Beam </small>
<b id="avatar-section-title"></b>
<small>Beam </small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={48} label="当前头像预览" />
<UserAvatar avatar={account.avatar} size={56} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
+11 -1
View File
@@ -127,6 +127,15 @@ test("the settings pane menu separates hover from current without an accent bar"
for (const rule of hoverRules) assert.ok(!rule.includes('[aria-current="page"]'), `current and hover must be separate rules: ${rule}`);
});
test("settings navigation uses two columns without a misleading chevron", () => {
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");
assert.doesNotMatch(overlay, /<ChevronRight/);
assert.match(cssDeclarations(".settings-dialog-nav-item", styles), /grid-template-columns:\s*18px\s+minmax\(0,\s*1fr\)/);
assert.match(cssDeclarations(".settings-dialog-nav", styles), /border-right:\s*1px\s+solid\s+var\(--color-border\)/);
assert.match(cssDeclarations(".settings-dialog-content", styles), /padding:\s*0\s+var\(--space-8\)\s+var\(--space-8\)/);
});
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.
@@ -149,7 +158,8 @@ test("form panes get a reading-width cap, list panes 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");
// 原值:420460px;新值:560px;原因:右侧内容区增加内边距后,资料表单仍需使用舒适的阅读宽度,避免右侧留下过大的空白。
assert.match(cap, /max-width:\s*560px/, "cap uses the wider 560px reading width");
assert.match(cap, /margin-right:\s*auto/, "capped content is left aligned");
});
@@ -110,6 +110,8 @@ test("the chart library starts as a list and only renders a form after a view ch
assert.match(charts, /useState<ChartLibraryView>\(CHART_LIBRARY_LIST_VIEW\)/);
assert.doesNotMatch(list, /ChartProfileForm|<form/);
assert.match(list, /添加其他人/);
// 原值:仅断言列表中存在“添加其他人”;新值:同步锁定标题操作区;原因:入口从列表末尾移到“其他人”分组标题,长列表中位置仍稳定。
assert.match(charts, /chart-library-group-heading-actions[\s\S]{0,900}添加其他人/);
assert.match(charts, /setView\(\{ kind: "add" \}\)/);
assert.match(charts, /setView\(CHART_LIBRARY_LIST_VIEW\)/);
});
+2 -1
View File
@@ -8,7 +8,8 @@ test("the profile pane keeps avatar palettes and drops the duplicate chart-libra
assert.doesNotMatch(panel, /管理星盘资料/);
assert.match(panel, /role="radiogroup"/);
assert.match(panel, /换一个形象/);
assert.match(panel, /size=\{48\}/);
// 原值:48px;新值:56px;原因:设置面板的头像预览需要更清晰的视觉锚点,并与账户身份区留出稳定间距。
assert.match(panel, /size=\{56\}/);
assert.match(panel, /账户信息/);
assert.match(panel, /登录邮箱/);
});