From 0f301049aae749cf37d18f075b17c6a91972bff2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 17 Jul 2026 16:30:18 +0800 Subject: [PATCH] feat: replace account sheet with task dialogs --- .../plans/2026-07-17-account-menu-dialogs.md | 416 ++++++++++++++++++ frontend/DESIGN.md | 27 +- frontend/src/app/globals.css | 77 ++-- frontend/src/app/page.tsx | 238 ++++++---- frontend/tests/starter-questions.test.ts | 27 ++ 5 files changed, 663 insertions(+), 122 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-17-account-menu-dialogs.md diff --git a/docs/superpowers/plans/2026-07-17-account-menu-dialogs.md b/docs/superpowers/plans/2026-07-17-account-menu-dialogs.md new file mode 100644 index 00000000..4cb180c4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-account-menu-dialogs.md @@ -0,0 +1,416 @@ +# Account Menu and Task Dialogs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the right-side account sheet with an anchored account popover plus focused profile, redeem, and logout-confirmation dialogs. + +**Architecture:** Keep account/profile data and API mutations in `frontend/src/app/page.tsx`, but split presentation state into `accountMenuOpen` and `activeAccountDialog`. The sidebar trigger owns the non-modal menu; the credit button and system fallbacks route directly to the redeem dialog; each modal renders only its own task and shares one focus-trapped overlay contract. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript 5, vanilla CSS design tokens, Node test runner, existing Lucide icons. + +## Global Constraints + +- Preserve the warm editorial design tokens in `frontend/DESIGN.md`; add no raw colors, arbitrary shadow recipes, or new dependencies. +- The sidebar account trigger opens a popover; the upper-right credit trigger opens the redeem dialog directly. +- The administrator-only `管理兑换码` action navigates to `/admin/codes` in the same tab. +- `退出登录` must open a confirmation dialog; only `确认退出` signs out. +- Missing-profile and insufficient-credit flows open their task dialogs directly. +- Popover rows and dialog controls retain a minimum 44px target. +- Support outside click where appropriate, Escape, focus trapping, focus return, keyboard navigation, and reduced motion. +- Remove the former right-side account sheet markup, CSS, and overloaded state. +- Do not commit or push unrelated working-tree changes. + +--- + +### Task 1: Lock the interaction contract and design-system primitives + +**Files:** +- Modify: `frontend/tests/starter-questions.test.ts` +- Modify: `frontend/DESIGN.md` + +**Interfaces:** +- Consumes: existing source-contract test helpers `pageSource`, `globalStyles`, and `sourceBetween`. +- Produces: failing assertions for the account popover, direct redeem route, three dialogs, and removal of the sheet; documented `Account popover`, `Profile dialog`, `Redeem dialog`, and `Logout dialog` primitives. + +- [ ] **Step 1: Add failing structural tests** + +Append tests that inspect behavior-bearing names and relationships rather than prose copy: + +```ts +test("routes account actions through a popover and focused dialogs", () => { + assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/); + assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState\(null\)/); + assert.match(pageSource, /className="account-menu"/); + assert.match(pageSource, /onClick=\{\(\) => openAccountDialog\("profile"\)\}/); + assert.match(pageSource, /onClick=\{\(\) => openAccountDialog\("redeem"/); + assert.match(pageSource, /onClick=\{\(\) => openAccountDialog\("logout"\)\}/); +}); + +test("removes the monolithic account sheet", () => { + assert.doesNotMatch(pageSource, /profile-overlay|profile-dialog|openAccount\(/); + assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/); +}); + +test("keeps admin navigation separate from task dialogs", () => { + const menu = sourceBetween(pageSource, 'className="account-menu"', '\n '); + assert.match(menu, /href="\/admin\/codes"/); + assert.match(menu, /account\?\.isAdmin/); +}); +``` + +- [ ] **Step 2: Run the targeted tests and confirm the expected red state** + +Run: `npm test -- --test-name-pattern="account|sheet|admin navigation"` + +Expected: the new tests fail because `accountMenuOpen`, `activeAccountDialog`, `.account-menu`, and focused-dialog routes do not exist yet. + +- [ ] **Step 3: Update the design contract before styling** + +Replace `### Account sheet` in `frontend/DESIGN.md` with four documented primitives: + +```md +### Account popover + +- **Structure:** identity header, profile action, redeem action with balance, administrator-only code-management link, divider, and logout action. +- **Surface:** 280px elevated canvas popover anchored above the sidebar account trigger; warm hairline, existing elevated shadow, no nested cards. +- **States:** closed, open, hover, focus-visible, administrator, signing-out transition. +- **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, focus return. + +### Profile dialog + +- **Structure:** profile title, existing profile fields, inline result, save action. +- **Width:** 560px desktop maximum with viewport-safe spacing and internal scrolling. +- **States:** open, invalid, saving, success, error. + +### Redeem dialog + +- **Structure:** current balance, redemption-code form, inline result. +- **Width:** 420px desktop maximum. +- **States:** open, submitting, success, error. + +### Logout dialog + +- **Structure:** confirmation title and explanation, cancel action, destructive confirm action. +- **States:** open, signing out, error. +``` + +- [ ] **Step 4: Verify the documentation diff** + +Run: `git diff --check -- frontend/DESIGN.md frontend/tests/starter-questions.test.ts` + +Expected: exit 0 with no whitespace errors. + +--- + +### Task 2: Split surface state and route every account entry correctly + +**Files:** +- Modify: `frontend/src/app/page.tsx` + +**Interfaces:** +- Consumes: existing `Profile`, `Account`, `redeem`, `saveProfile`, `signOut`, `keepFocusWithin`, `accountTrigger`, `redeemInput`, and `closeButton` behavior. +- Produces: `type AccountDialog = "profile" | "redeem" | "logout" | null`, `accountMenuOpen`, `activeAccountDialog`, `toggleAccountMenu`, `openAccountDialog`, and `closeAccountDialog`. + +- [ ] **Step 1: Replace the overloaded state and refs** + +Add the dialog type beside other UI-state types: + +```ts +type AccountDialog = "profile" | "redeem" | "logout" | null; +``` + +Replace `profileOpen` and `redeemOpen` with: + +```ts +const [accountMenuOpen, setAccountMenuOpen] = useState(false); +const [activeAccountDialog, setActiveAccountDialog] = useState(null); +``` + +Replace the sheet ref with explicit surface refs: + +```ts +const accountMenu = useRef(null); +const accountDialog = useRef(null); +const creditTrigger = useRef(null); +const dialogReturnTarget = useRef(null); +``` + +- [ ] **Step 2: Add explicit open and close handlers** + +Replace `openAccount` and `closeAccount` with handlers shaped as follows: + +```ts +function toggleAccountMenu() { + setActiveAccountDialog(null); + setAccountMenuOpen((current) => !current); +} + +function openAccountDialog(dialog: Exclude, returnTarget = accountTrigger.current) { + dialogReturnTarget.current = returnTarget; + setAccountMenuOpen(false); + setAccountError(""); + if (dialog === "profile") { + if (profileComplete) setProfileDraft(profile); + setProfileNotice(""); + } + if (dialog === "redeem") { + setRedeemError(""); + setRedeemMessage(""); + } + setActiveAccountDialog(dialog); +} + +function closeAccountDialog() { + if (signingOut) return; + setActiveAccountDialog(null); + const returnTarget = dialogReturnTarget.current; + window.requestAnimationFrame(() => returnTarget?.focus()); +} +``` + +- [ ] **Step 3: Replace the sheet focus effect with menu dismissal and dialog focus trapping** + +Use one document listener for the non-modal popover: + +```ts +useEffect(() => { + if (!accountMenuOpen) return; + const dismissMenu = (event: MouseEvent) => { + if (!accountMenu.current?.contains(event.target as Node)) setAccountMenuOpen(false); + }; + const closeOnEscape = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape") return; + setAccountMenuOpen(false); + window.requestAnimationFrame(() => accountTrigger.current?.focus()); + }; + document.addEventListener("mousedown", dismissMenu); + window.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", dismissMenu); + window.removeEventListener("keydown", closeOnEscape); + }; +}, [accountMenuOpen]); +``` + +Use `activeAccountDialog` for modal initial focus, Escape, and `keepFocusWithin`. Focus `redeemInput` for redeem and `closeButton` for profile/logout. + +- [ ] **Step 4: Route system fallbacks directly** + +Replace every `openAccount(true)` call with: + +```ts +openAccountDialog("redeem", creditTrigger.current); +``` + +Replace the incomplete-profile branch with: + +```ts +setProfileDraft(profile); +setProfileNotice("请先补充出生资料,才能进行星盘计算。"); +openAccountDialog("profile"); +``` + +Use `activeAccountDialog === null` in the onboarding focus effect and `activeAccountDialog !== null` for modal background inertness. + +- [ ] **Step 5: Run targeted tests** + +Run: `npm test -- --test-name-pattern="account|sheet|admin navigation"` + +Expected: the state/routing assertions pass; markup or CSS assertions may remain red until Tasks 3 and 4. + +--- + +### Task 3: Replace the sheet markup with the account popover and three dialogs + +**Files:** +- Modify: `frontend/src/app/page.tsx` + +**Interfaces:** +- Consumes: Task 2 state, handlers, refs, existing profile and redeem forms, and `/admin/codes` route. +- Produces: `.account-menu`, `.account-modal-overlay`, `.account-modal`, `.profile-modal`, `.redeem-modal`, and `.logout-modal` markup. + +- [ ] **Step 1: Add the anchored popover beside the sidebar account trigger** + +Wrap the trigger and menu in the existing `.sidebar-footer`. Add `aria-expanded`, `aria-controls`, and a rotating chevron state. Render the menu only while open: + +```tsx + +{accountMenuOpen && ( + + -
- + {accountMenuOpen && ( + -
+
{activeSession?.title || "新对话"} {isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}
- @@ -1693,59 +1765,53 @@ export default function Home() {
-
-
event.stopPropagation()}> -
-

账户与出生资料

- -
+ {activeAccountDialog !== null && ( +
+
event.stopPropagation()}> +
+

{accountDialogTitles[activeAccountDialog]}

+ +
-
-
邮箱{account?.user.email || "尚未读取"}
-
剩余点数{account?.credits ?? "—"}
-
- {accountError &&

{accountError}

} + {activeAccountDialog === "profile" && ( + <> + {accountError &&

{accountError}

} + {profileNotice &&

{profileNotice}

} +
+ + + + + )} -
- - {redeemOpen && ( -
- -
- { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" /> - + {activeAccountDialog === "redeem" && ( + <> +
当前余额{account.credits} 点
+ + +
+ { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" /> + +
+ {redeemError &&

{redeemError}

} + {redeemMessage &&

{redeemMessage}

} + + + )} + + {activeAccountDialog === "logout" && ( + <> +

退出后,需要重新登录才能继续查看对话。

+ {accountError &&

{accountError}

} +
+ +
- {redeemError &&

{redeemError}

} - {redeemMessage &&

{redeemMessage}

} - + )}
- -
-
出生资料加密传输并保存到云端,用于此账号的所有对话
-
-
- 当前默认星盘 - {profileDraft.name.trim() || "未命名"} - 角色:本人 -
- -
- {profileNotice &&

{profileNotice}

} -
- - - -
- -
- {account?.isAdmin && 管理兑换码} - -
-
-
+
+ )} ); } diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index 12018cbf..fdb09a23 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -72,3 +72,30 @@ test("centers the credit value with its icon", () => { assert.match(creditValueStyles, /align-items:\s*center/); assert.match(creditValueStyles, /line-height:\s*1/); }); + +test("routes account actions through a popover and focused dialogs", () => { + // Given: the account surface state and entry-point handlers. + // When: the page source is inspected for independent menu and dialog routes. + // Then: each account task has a focused destination instead of one combined sheet. + assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/); + assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState\(null\)/); + assert.match(pageSource, /className="account-menu"/); + assert.match(pageSource, /openAccountDialog\("profile"/); + assert.match(pageSource, /openAccountDialog\("redeem"/); + assert.match(pageSource, /openAccountDialog\("logout"/); +}); + +test("removes the monolithic account sheet", () => { + // Given: the former sheet implementation names. + // When: the page and global styles are inspected. + // Then: no right-side account sheet remains. + assert.doesNotMatch(pageSource, /profile-overlay|profile-dialog|openAccount\(/); + assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/); +}); + +test("keeps admin navigation separate from account task dialogs", () => { + // Given: the administrator-only route and the new account menu. + // When: their source relationship is inspected. + // Then: code management stays a guarded navigation action rather than a modal. + assert.match(pageSource, /account\?\.isAdmin\s*&&\s*]+href="\/admin\/codes"[^>]+role="menuitem"/); +});