Files
Jyotisha/docs/superpowers/plans/2026-07-17-composable-sidebar.md
T
2026-07-17 17:05:52 +08:00

930 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Composable Collapsible Sidebar 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 page-local navigation shell with a shadcn-style composable sidebar that expands to 288px on desktop, collapses to a useful 64px icon rail, defaults to a compact rail on tablet, and remains an off-canvas drawer on mobile.
**Architecture:** Put deterministic breakpoint and initial-state decisions in a pure sidebar-state module; put provider state, responsive mechanics, shortcuts, tooltips, and semantic primitives in `components/ui/sidebar.tsx`; put Jyotisha-specific brand, new-chat, history, and account composition in `components/app-sidebar.tsx`. Keep sessions, request locks, account mutations, dialogs, and API calls in `app/page.tsx`, which supplies typed data and callbacks to the product component.
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript 5, Base UI 1.6 Popover/Tooltip, Tailwind CSS 4 infrastructure with existing global token CSS, Lucide icons, Node test runner, ESLint.
## Global Constraints
- Follow `docs/superpowers/specs/2026-07-17-composable-sidebar-design.md` and `frontend/DESIGN.md`; when they disagree, the dated sidebar spec governs this feature.
- Treat the user's pasted Base Nova sidebar documentation as the composition reference. Do not import the stock shadcn visual palette.
- Do not run `shadcn add sidebar` against the working tree: it may overwrite the existing Base UI `Button`. Task 3 inspects the registry source read-only, then hand-adapts only the retained primitives.
- Add no dependency unless an imported Base UI primitive is demonstrably absent from the existing `@base-ui/react` package.
- Use only Jyotisha color, spacing, radius, shadow, typography, and motion tokens; no raw colors or new shadow recipes.
- Preserve all session selection, request lock, onboarding, model selection, cancellation, credit, profile, redemption, administrator, and logout behavior.
- Existing sessions remain clickable while another session is answering. Only new requests and new-chat creation retain their current request locks.
- Keep the account task dialogs in `page.tsx`. The extracted account menu may only route to callbacks.
- Do not persist sidebar state in cookies or local storage. Reload defaults are desktop expanded, tablet collapsed, and mobile closed.
- Do not animate grid-column width or another layout property. Animate only opacity, transform, and colors; respect reduced-motion.
- Keep the current unrelated working-tree changes untouched. Do not push during implementation.
- Run commands that reference `node_modules/`, `src/`, `tests/`, `npm`, `npx`, or `tsc` from `frontend/`. Run `git` commands from the repository root so the paths shown below resolve exactly.
---
## Target File Map
| File | Responsibility |
|---|---|
| `frontend/src/lib/sidebar-state.ts` | Pure viewport classification and default-state rules. |
| `frontend/src/hooks/use-sidebar-viewport.ts` | One browser subscription for mobile/tablet/desktop viewport state and hydration readiness. |
| `frontend/src/components/ui/sidebar.tsx` | Provider, context, generic semantic primitives, trigger, rail, tooltip, mobile focus/keyboard mechanics. |
| `frontend/src/components/app-sidebar.tsx` | Jyotisha brand, new-chat action, session history, collapsed history control, account menu composition. |
| `frontend/src/app/page.tsx` | Business state and callbacks; provider/app-sidebar integration; existing task dialogs. |
| `frontend/src/app/globals.css` | Token aliases, shell widths, responsive drawer/rail states, account popover, tooltip, accessibility preferences. |
| `frontend/tests/sidebar-state.test.ts` | Pure state unit tests. |
| `frontend/tests/sidebar-contract.test.ts` | Source-level architecture and token contract tests. |
| `frontend/tests/starter-questions.test.ts` | Update existing account/session assertions to follow extracted markup. |
| `frontend/DESIGN.md` | Shipped component contract for the new shell. |
---
### Task 1: Lock the architecture and design contract
**Files:**
- Create: `frontend/tests/sidebar-contract.test.ts`
- Modify: `frontend/DESIGN.md`
**Interfaces:**
- Consumes: the approved design spec and existing source-contract testing style.
- Produces: an initially red architecture test that prevents a partial migration or stock-theme regression.
- [ ] **Step 1: Read the local Next.js 16 guidance before touching client boundaries**
Run:
```bash
sed -n '1,220p' node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md
sed -n '1,180p' node_modules/next/dist/docs/01-app/03-api-reference/01-directives/use-client.md
sed -n '1,180p' node_modules/next/dist/docs/01-app/02-guides/preventing-flash-before-hydration.md
```
Expected: confirm that the sidebar provider and product sidebar are client components, props crossing boundaries remain serializable only where a Server Component boundary exists, and initial responsive markup must not depend on reading `window` during render.
- [ ] **Step 2: Add the failing structural contract test**
Create `frontend/tests/sidebar-contract.test.ts`:
```ts
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
test("uses a composable sidebar instead of page-local navigation state", () => {
assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true);
assert.equal(existsSync(projectFile("src/components/app-sidebar.tsx")), true);
const page = readProjectFile("src/app/page.tsx");
assert.match(page, /<SidebarProvider>/);
assert.match(page, /<AppSidebar/);
assert.match(page, /<SidebarInset/);
assert.match(page, /<SidebarTrigger/);
assert.doesNotMatch(page, /mobileSidebarOpen|setMobileSidebarOpen/);
assert.doesNotMatch(page, /className="sidebar-backdrop"/);
assert.doesNotMatch(page, /<aside className="sidebar"/);
});
test("exports only the retained sidebar composition surface", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
for (const name of [
"SidebarProvider", "Sidebar", "SidebarHeader", "SidebarContent",
"SidebarGroup", "SidebarGroupLabel", "SidebarGroupContent",
"SidebarMenu", "SidebarMenuItem", "SidebarMenuButton",
"SidebarFooter", "SidebarInset", "SidebarTrigger", "SidebarRail", "useSidebar",
]) {
assert.match(sidebar, new RegExp(`export (?:function|const) ${name}\\b`));
}
assert.doesNotMatch(sidebar, /SidebarMenuBadge|SidebarMenuSkeleton|SidebarMenuSub|side\?:|variant\?:/);
});
test("maps sidebar semantics to Jyotisha tokens", () => {
const styles = readProjectFile("src/app/globals.css");
assert.match(styles, /--sidebar-background:\s*var\(--color-sidebar\)/);
assert.match(styles, /--sidebar-solid:\s*var\(--color-sidebar-solid\)/);
assert.match(styles, /--sidebar-foreground:\s*var\(--color-ink\)/);
assert.match(styles, /--sidebar-accent:\s*var\(--color-selected\)/);
assert.match(styles, /--sidebar-border:\s*var\(--color-border\)/);
assert.match(styles, /--sidebar-ring:\s*var\(--color-focus\)/);
});
```
- [ ] **Step 3: Run the new test and confirm the intended red state**
Run: `node --test tests/sidebar-contract.test.ts`
Expected: failures report missing `ui/sidebar.tsx`, missing `app-sidebar.tsx`, and the old page-local state. A syntax/import failure is not the expected red state and must be corrected first.
- [ ] **Step 4: Add the shipped Sidebar primitive to `frontend/DESIGN.md`**
Insert after `### Navigation item`:
```md
### Sidebar shell
- **Composition:** provider, fixed header, one scroll-owning content region, fixed footer, trigger, rail, and flexible chat inset.
- **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail.
- **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded.
- **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim, trigger, or Escape.
- **Collapsed content:** logo, new-chat action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons.
- **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner.
- **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support.
- **State:** session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence.
```
- [ ] **Step 5: Check the contract artifacts without committing a broken test state**
Run:
```bash
git diff --check -- frontend/DESIGN.md frontend/tests/sidebar-contract.test.ts
```
Expected: whitespace check passes. Leave both files uncommitted until Task 3 makes the structural contract green; never create a commit that knowingly breaks the frontend test suite.
---
### Task 2: Implement deterministic responsive state
**Files:**
- Create: `frontend/src/lib/sidebar-state.ts`
- Create: `frontend/src/hooks/use-sidebar-viewport.ts`
- Create: `frontend/tests/sidebar-state.test.ts`
**Interfaces:**
```ts
export type SidebarViewport = "mobile" | "tablet" | "desktop";
export function sidebarViewportForWidth(width: number): SidebarViewport;
export function defaultSidebarOpen(viewport: SidebarViewport): boolean;
export function shouldHandleSidebarShortcut(event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey" | "target">): boolean;
export function useSidebarViewport(): { viewport: SidebarViewport; ready: boolean };
```
- [ ] **Step 1: Write pure-state tests first**
Create `frontend/tests/sidebar-state.test.ts`:
```ts
import assert from "node:assert/strict";
import test from "node:test";
import {
defaultSidebarOpen,
shouldHandleSidebarShortcut,
sidebarViewportForWidth,
} from "../src/lib/sidebar-state.ts";
test("classifies the exact sidebar breakpoints", () => {
assert.equal(sidebarViewportForWidth(0), "mobile");
assert.equal(sidebarViewportForWidth(767), "mobile");
assert.equal(sidebarViewportForWidth(768), "tablet");
assert.equal(sidebarViewportForWidth(1023), "tablet");
assert.equal(sidebarViewportForWidth(1024), "desktop");
});
test("uses the approved reload defaults", () => {
assert.equal(defaultSidebarOpen("mobile"), false);
assert.equal(defaultSidebarOpen("tablet"), false);
assert.equal(defaultSidebarOpen("desktop"), true);
});
test("accepts Command/Control+B only outside editable controls", () => {
const shortcut = { key: "b", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false };
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: null }), true);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { tagName: "TEXTAREA" } }), false);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { isContentEditable: true } }), false);
assert.equal(shouldHandleSidebarShortcut({ ...shortcut, key: "k", target: null }), false);
});
```
- [ ] **Step 2: Run the state test and confirm red**
Run: `node --test tests/sidebar-state.test.ts`
Expected: module-not-found for `src/lib/sidebar-state.ts`.
- [ ] **Step 3: Implement the pure rules**
Create `frontend/src/lib/sidebar-state.ts`:
```ts
export type SidebarViewport = "mobile" | "tablet" | "desktop";
export function sidebarViewportForWidth(width: number): SidebarViewport {
if (width < 768) return "mobile";
if (width < 1024) return "tablet";
return "desktop";
}
export function defaultSidebarOpen(viewport: SidebarViewport) {
return viewport === "desktop";
}
export function shouldHandleSidebarShortcut(
event: {
key: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
target: { tagName?: string; isContentEditable?: boolean } | null;
},
) {
const target = event.target;
const editable = Boolean(target
&& (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName ?? "")));
return !editable
&& event.key.toLowerCase() === "b"
&& (event.metaKey || event.ctrlKey)
&& !event.altKey
&& !event.shiftKey;
}
```
Keep `HTMLElement` usage inside the browser-called function; the current unit tests import but do not invoke that branch in Node.
- [ ] **Step 4: Add one viewport subscription hook**
Create `frontend/src/hooks/use-sidebar-viewport.ts` as a client module. Initialize `{ viewport: "desktop", ready: false }` so server and first-client markup match. In one effect:
1. classify `window.innerWidth`;
2. set `{ viewport, ready: true }`;
3. subscribe to `window.resize` with the same classifier;
4. remove the listener on cleanup.
Do not read `window` in a state initializer and do not create three independent `matchMedia` subscriptions.
- [ ] **Step 5: Run state tests and static checks**
Run:
```bash
node --test tests/sidebar-state.test.ts
npx tsc --noEmit
git diff --check -- frontend/src/lib/sidebar-state.ts frontend/src/hooks/use-sidebar-viewport.ts frontend/tests/sidebar-state.test.ts
```
Expected: both state tests pass; TypeScript and whitespace checks exit 0.
- [ ] **Step 6: Commit the responsive state unit**
Run:
```bash
git add frontend/src/lib/sidebar-state.ts frontend/src/hooks/use-sidebar-viewport.ts frontend/tests/sidebar-state.test.ts
git commit -m "feat: add responsive sidebar state model"
```
---
### Task 3: Build the retained generic sidebar primitives
**Files:**
- Create: `frontend/src/components/ui/sidebar.tsx`
- Modify: `frontend/tests/sidebar-contract.test.ts`
**Interfaces:**
```ts
export type SidebarState = "expanded" | "collapsed";
export type SidebarContextValue = {
state: SidebarState;
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
viewport: SidebarViewport;
ready: boolean;
isMobile: boolean;
toggleSidebar: () => void;
};
export type SidebarProviderProps = React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onMobileOpenChange?: (open: boolean) => void;
escapeBlocked?: boolean;
};
export type SidebarTriggerProps = React.ComponentProps<"button"> & {
placement?: "inset" | "sidebar";
};
```
- [ ] **Step 1: Inspect the current Base Nova source without mutating files**
Run: `npx shadcn@latest view sidebar`
Expected: registry source/API is printed. Compare its provider, data attributes, trigger, rail, and menu semantics with the approved spec. Do not run `add`, do not accept generated `Button`, `Sheet`, skeleton, badge, submenu, right-side, floating, or inset-variant code.
- [ ] **Step 2: Extend the contract test for behavioral invariants**
Append assertions that the primitive source:
- imports `useSidebarViewport`, `defaultSidebarOpen`, and `shouldHandleSidebarShortcut`;
- uses `data-state`, `data-viewport`, and `data-mobile-open`;
- uses one `keydown` listener and calls `preventDefault()` for the accepted shortcut;
- closes `openMobile` when leaving mobile mode;
- imports Base UI Tooltip and never imports a Sheet;
- uses `cn` and never includes raw hex/HSL colors.
Run: `node --test tests/sidebar-contract.test.ts`
Expected: the original missing-file failure changes to specific missing-export/behavior failures after the file is created in the next step.
- [ ] **Step 3: Implement `SidebarProvider` and `useSidebar`**
In `frontend/src/components/ui/sidebar.tsx`:
1. add `"use client"`;
2. create a context initialized to `null` and a `useSidebar` hook that throws outside the provider;
3. support controlled and uncontrolled desktop `open` state;
4. keep mobile `openMobile` independent;
5. use a `userChangedDesktopState` ref so the first ready viewport applies its breakpoint default, while later resizes do not erase a user's explicit desktop/tablet choice;
6. close mobile state whenever `viewport !== "mobile"`;
7. route `toggleSidebar` to `openMobile` on mobile and desktop `open` otherwise;
8. install Command/Control+B through `shouldHandleSidebarShortcut`;
9. render a provider wrapper with `data-state`, `data-viewport`, `data-ready`, and `data-mobile-open`.
The provider wrapper must not calculate viewport-dependent inline markup before `ready`; CSS will use `data-ready="false"` plus media queries for the initial visual width.
- [ ] **Step 4: Implement the semantic primitives**
Each primitive forwards native element props, merges classes with `cn`, and adds stable `data-slot`/`data-sidebar` attributes:
```ts
Sidebar // aside, id="chat-sidebar", aria-label supplied by caller
SidebarHeader // div
SidebarContent // div; sole scroll owner
SidebarGroup // section
SidebarGroupLabel // h2 by default, supports tabIndex for empty-history focus
SidebarGroupContent // div
SidebarMenu // ul
SidebarMenuItem // li
SidebarMenuButton // button; accepts isActive and optional collapsed tooltip
SidebarFooter // div
SidebarInset // section; combines caller inert with isMobile && openMobile
SidebarTrigger // button; contextual aria-label and aria-expanded
SidebarRail // button; desktop/tablet only, aria-label reflects action
```
`SidebarMenuButton` must accept:
```ts
type SidebarMenuButtonProps = React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string;
};
```
When `tooltip` is present, wrap only the trigger with Base UI `Tooltip.Root`, `Tooltip.Portal`, `Tooltip.Positioner side="right" sideOffset={8}`, and `Tooltip.Popup`. Mount the tooltip only when `state === "collapsed" && !isMobile`; expanded and mobile states render no tooltip popup.
- [ ] **Step 5: Implement mobile focus and Escape behavior in the provider layer**
Keep refs for the chat-header trigger and mobile sidebar close trigger inside the provider. `SidebarTrigger placement="inset"` registers the return target; `SidebarTrigger placement="sidebar"` registers the drawer target. When the mobile drawer opens, focus the sidebar trigger on the next animation frame. When it closes, return focus to the inset trigger.
The mobile Escape handler must:
1. do nothing while `escapeBlocked` is true;
2. close only the mobile drawer;
3. never collapse desktop/tablet state.
The page supplies `escapeBlocked={accountMenuOpen || activeAccountDialog !== null}`. This makes the Escape priority explicit and independent of effect registration order or DOM class queries.
- [ ] **Step 6: Run the primitive contract and type checks**
Run:
```bash
node --test tests/sidebar-contract.test.ts
npx tsc --noEmit
npm run lint -- src/components/ui/sidebar.tsx src/hooks/use-sidebar-viewport.ts src/lib/sidebar-state.ts
```
Expected: retained-export and provider-behavior assertions pass. The page-integration assertions remain red until Task 5. TypeScript and ESLint exit 0.
- [ ] **Step 7: Commit the primitive foundation**
Run:
```bash
git add frontend/src/components/ui/sidebar.tsx frontend/tests/sidebar-contract.test.ts frontend/DESIGN.md
git commit -m "feat: add composable sidebar primitives"
```
---
### Task 4: Compose the Jyotisha product sidebar and portal account menu
**Files:**
- Create: `frontend/src/components/app-sidebar.tsx`
- Modify: `frontend/tests/sidebar-contract.test.ts`
- Modify: `frontend/tests/starter-questions.test.ts`
**Interfaces:**
```ts
export type SidebarSession = {
id: string;
title: string;
messageCount: number;
};
export type SidebarAccount = {
name: string;
email: string;
credits: number;
isAdmin: boolean;
initial: string;
};
export type AppSidebarProps = {
sessions: readonly SidebarSession[];
activeSessionId: string | null;
account: SidebarAccount;
accountMenuOpen: boolean;
accountTriggerRef: React.Ref<HTMLButtonElement>;
newChatDisabled: boolean;
creatingSession: boolean;
onAccountMenuOpenChange: (open: boolean) => void;
onNewChat: () => void;
onSelectSession: (sessionId: string) => void;
onOpenProfile: () => void;
onOpenRedeem: () => void;
onOpenLogout: () => void;
};
```
- [ ] **Step 1: Add product-composition tests before implementation**
Extend `sidebar-contract.test.ts` to require:
- `AppSidebar` composes `SidebarHeader`, `SidebarContent`, `SidebarFooter`, and `SidebarRail`;
- collapsed history uses one `MessageSquareText` action and not a mapped list of icon-only sessions;
- selecting a session invokes `onSelectSession(session.id)` even while request state exists outside this component;
- account menu imports `Popover` from `@base-ui/react/popover`, uses `Popover.Portal`, and sets `collisionPadding={12}`;
- `AppSidebarProps` contains callbacks/data only and no Supabase, `fetch`, or API import.
Update the account assertions in `starter-questions.test.ts` to read `app-sidebar.tsx` for account popover/admin-link markup while keeping dialog routing assertions against `page.tsx`.
Run: `node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts`
Expected: product composition tests fail because `app-sidebar.tsx` does not exist.
- [ ] **Step 2: Implement brand and new-chat regions**
Create the header with the existing `brand-mark` plus `Jyotisha` wordmark. Create the existing new-chat button through `SidebarMenuButton`:
- expanded label: `正在创建` or `新对话`;
- collapsed visual: centered Plus icon;
- collapsed tooltip: `新对话`;
- disabled value comes exclusively from `newChatDisabled`;
- mobile activation closes the drawer only after invoking `onNewChat`.
- [ ] **Step 3: Implement history expansion and session rows**
Use `useSidebar()` and refs for the first session and history heading. In collapsed desktop/tablet state render one history control. Its handler must:
```ts
setOpen(true);
window.requestAnimationFrame(() => {
(firstSessionRef.current ?? historyHeadingRef.current)?.focus();
});
```
Expanded/mobile content renders:
- focusable `聊天记录` heading;
- `暂无对话` for an empty array;
- one row per session with title, optional `${messageCount} 条消息`, `aria-current="page"`, and one-line truncation;
- `onSelectSession(session.id)` without a pending-request disabled prop.
After selection, close the drawer only when `isMobile`; leave desktop/tablet expansion unchanged.
- [ ] **Step 4: Implement the responsive account trigger and menu with Base UI Popover**
Use a controlled `Popover.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange}`. The trigger shows avatar/name expanded and avatar only collapsed. Configure the positioner from sidebar context:
```tsx
<Popover.Positioner
side={isMobile || state === "expanded" ? "top" : "right"}
align={isMobile || state === "expanded" ? "end" : "center"}
sideOffset={8}
collisionPadding={12}
>
```
Keep the current identity header, profile action, redeem action/balance, administrator-only `/admin/codes` link, separator, and logout action. Each task action first closes the popover and then invokes its callback. Do not render task forms here.
When state or viewport changes while the popover is open, close it through `onAccountMenuOpenChange(false)` so it cannot remain detached from its trigger.
- [ ] **Step 5: Run product tests and checks**
Run:
```bash
node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts
npx tsc --noEmit
npm run lint -- src/components/app-sidebar.tsx
```
Expected: product/account structural tests pass; page-integration portions remain red. TypeScript and ESLint exit 0.
- [ ] **Step 6: Commit the product composition**
Run:
```bash
git add frontend/src/components/app-sidebar.tsx frontend/tests/sidebar-contract.test.ts frontend/tests/starter-questions.test.ts
git commit -m "feat: compose Jyotisha app sidebar"
```
---
### Task 5: Replace page-local sidebar state and markup
**Files:**
- Modify: `frontend/src/app/page.tsx`
- Modify: `frontend/tests/sidebar-contract.test.ts`
**Interfaces:**
- Consumes: existing `sessions`, `activeSession`, `profile`, `account`, `creatingSession`, request locks, account dialog handlers, and refs.
- Produces: typed props/callbacks for `AppSidebar`; no sidebar responsive state in the page.
- [ ] **Step 1: Import the new composition and remove obsolete imports/state/refs**
Add imports for `AppSidebar`, `SidebarProvider`, `SidebarInset`, and `SidebarTrigger`. Remove sidebar-only Lucide icons from `page.tsx` once no longer used there.
Delete:
```ts
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const accountMenu = useRef<HTMLDivElement>(null);
const mobileMenuTrigger = useRef<HTMLButtonElement>(null);
const sidebar = useRef<HTMLElement>(null);
const sidebarCloseButton = useRef<HTMLButtonElement>(null);
```
Delete the page-local mobile-drawer focus effect and page-local account outside-click/Escape effect; Base UI Popover and `SidebarProvider` now own those interactions. Keep `accountTrigger`, because account dialogs return focus to it.
- [ ] **Step 2: Isolate page-owned session callbacks**
Change `startNewChat` only by removing `setMobileSidebarOpen(false)`.
Add:
```ts
function selectSession(sessionId: string) {
setActiveSessionId(sessionId);
setDraft("");
setComposerNotice("");
}
```
Do not add request-state guards to `selectSession`; reading another session remains available during an answer.
- [ ] **Step 3: Build serializable view data without moving domain types**
Immediately before the successful return, derive:
```ts
const sidebarAccount = {
name: profile.name.trim() || account.user.email || "账户",
email: account.user.email || "尚未读取邮箱",
credits: account.credits,
isAdmin: account.isAdmin,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
|| "你",
};
const sidebarSessions = sessions.map((session) => ({
id: session.id,
title: session.title,
messageCount: session.messages.length,
}));
```
These are client-to-client props; no server serialization boundary is introduced.
- [ ] **Step 4: Replace the shell markup**
Use this hierarchy:
```tsx
<SidebarProvider escapeBlocked={accountMenuOpen || activeAccountDialog !== null}>
<main className="chat-app">
<AppSidebar
sessions={sidebarSessions}
activeSessionId={activeSession?.id ?? null}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenRedeem={() => openAccountDialog("redeem")}
onOpenLogout={() => openAccountDialog("logout")}
/>
<SidebarInset className="chat-panel" inert={activeAccountDialog !== null}>
{/* existing chat panel contents */}
</SidebarInset>
{/* existing account task-dialog overlays remain siblings inside main */}
</main>
</SidebarProvider>
```
Pass `escapeBlocked={accountMenuOpen || activeAccountDialog !== null}` to `SidebarProvider`, and render `SidebarTrigger placement="inset"` in the chat header. `AppSidebar` renders `SidebarTrigger placement="sidebar"` as the mobile close control in its brand row; CSS hides it outside the open mobile drawer.
Remove the old backdrop, inline `<aside>`, footer/menu markup, and mobile-only trigger. Put `SidebarTrigger` immediately before the existing chat title block.
`SidebarInset` must combine the caller's modal inertness with its own `isMobile && openMobile`; the page must never calculate drawer inertness itself.
- [ ] **Step 5: Preserve Escape ordering explicitly**
Confirm the existing account-dialog keydown listener remains registered only while a dialog is open. Base UI Popover consumes Escape while the account menu is open. Assert in `sidebar-contract.test.ts` that the page passes `escapeBlocked={accountMenuOpen || activeAccountDialog !== null}` and that the provider's mobile Escape branch returns while `escapeBlocked` is true.
- [ ] **Step 6: Run the page integration contract**
Run:
```bash
node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts
npx tsc --noEmit
npm run lint -- src/app/page.tsx src/components/app-sidebar.tsx src/components/ui/sidebar.tsx
```
Expected: all sidebar contract assertions pass; existing account/session tests pass; TypeScript and ESLint exit 0.
- [ ] **Step 7: Commit the page migration**
Run:
```bash
git add frontend/src/app/page.tsx frontend/tests/sidebar-contract.test.ts
git commit -m "refactor: migrate chat shell to app sidebar"
```
---
### Task 6: Implement the Jyotisha sidebar visual states
**Files:**
- Modify: `frontend/src/app/globals.css`
- Modify: `frontend/tests/sidebar-contract.test.ts`
**Interfaces:**
- Consumes: provider data attributes and stable primitive class/data-slot names.
- Produces: desktop/tablet grid widths, mobile drawer, collapsed rail, portal popup, tooltips, focus, and accessibility preference styles.
- [ ] **Step 1: Add semantic aliases and shell dimensions**
Add aliases beside the existing root tokens:
```css
--sidebar-background: var(--color-sidebar);
--sidebar-solid: var(--color-sidebar-solid);
--sidebar-foreground: var(--color-ink);
--sidebar-muted-foreground: var(--color-ink-secondary);
--sidebar-accent: var(--color-selected);
--sidebar-accent-foreground: var(--color-ink);
--sidebar-border: var(--color-border);
--sidebar-ring: var(--color-focus);
--sidebar-primary: var(--color-surface-dark);
--sidebar-primary-foreground: var(--color-on-dark);
--sidebar-width-desktop: 288px;
--sidebar-width-tablet: 240px;
--sidebar-width-icon: 64px;
--sidebar-width-mobile: min(86vw, 320px);
```
Use these aliases everywhere in the new sidebar selectors.
- [ ] **Step 2: Replace the desktop/tablet shell rules**
The provider wrapper fills `100dvh`. `.chat-app` remains a two-column grid whose first column is selected by provider data:
- desktop expanded: `288px`;
- tablet expanded: `240px`;
- desktop/tablet collapsed: `64px`;
- mobile: `1fr`.
Do not add `transition: width` or `transition: grid-template-columns`. Collapsed labels may use opacity/transform but must also leave the accessibility tree only when their control has a replacement accessible label.
- [ ] **Step 3: Make `SidebarContent` the only sidebar scroll owner**
Set the sidebar shell to a fixed-height flex column. Header/footer use `flex: 0 0 auto`; content uses `min-height: 0; overflow-y: auto`. Remove `overflow-y: auto` from `.session-list` so large histories do not create a nested scrollbar.
Keep one-line session truncation and the existing active marker.
- [ ] **Step 4: Style collapsed rail content**
At `data-state="collapsed"` and non-mobile viewport:
- center the 44px logo/new-chat/history/avatar controls in 64px;
- hide wordmark, session list, metadata, and account text visually;
- keep full accessible names through `aria-label`/tooltip trigger text;
- show `SidebarRail` as a narrow edge target with a visible focus ring;
- do not render or style repeated session icons.
Use 120ms opacity/transform/color transitions only. Disabled new-chat remains visibly disabled.
- [ ] **Step 5: Port the existing mobile drawer to provider data attributes**
Below 768px:
- make grid one column;
- position sidebar fixed at the left with `width: var(--sidebar-width-mobile)`;
- translate it fully off-canvas when `data-mobile-open="false"`;
- show a fixed scrim only when open;
- use the existing 180ms transform/opacity/visibility choreography;
- add safe-area padding;
- hide `SidebarRail`;
- render full expanded labels regardless of desktop `open` state.
Remove all `.sidebar-open` selectors because the class no longer exists.
- [ ] **Step 6: Style portal account menu and collapsed tooltips**
Replace `.account-menu { position: absolute; right: 0; bottom: ... }` with Base UI positioner/popup rules:
- positioner owns z-index and viewport constraints;
- popup width remains `min(280px, calc(100vw - var(--space-6)))`;
- background, border, radius, and shadow stay token-based;
- starting/ending styles use opacity and 4px transform;
- transform origin follows Base UI's origin custom property if exposed.
Tooltip popup uses the sidebar solid/canvas surface, border, caption typography, and a 120ms opacity/translate entry. It must never intercept pointer events.
- [ ] **Step 7: Preserve accessibility preferences**
Extend existing media rules:
```css
@media (prefers-reduced-motion: reduce) { /* remove sidebar/popup/tooltip transforms */ }
@media (prefers-reduced-transparency: reduce) { /* use --sidebar-solid; remove blur */ }
@media (prefers-contrast: more) { /* strengthen border/current marker/focus outline */ }
```
Do not replace the existing rules for other components; merge sidebar selectors into them.
- [ ] **Step 8: Remove obsolete selectors and run contract checks**
Delete old `.sidebar-backdrop`, `.sidebar-close`, `.mobile-menu`, `.sidebar-open`, absolute account-menu anchoring, and redundant session-list scroll rules after their replacements exist.
Run:
```bash
node --test tests/sidebar-contract.test.ts tests/starter-questions.test.ts
npm run lint
npx tsc --noEmit
git diff --check -- frontend/src/app/globals.css frontend/tests/sidebar-contract.test.ts
```
Expected: all tests, lint, TypeScript, and whitespace checks pass; no contract references the old class-based drawer state.
- [ ] **Step 9: Commit the visual implementation**
Run:
```bash
git add frontend/src/app/globals.css frontend/tests/sidebar-contract.test.ts
git commit -m "style: add responsive sidebar rail and drawer"
```
---
### Task 7: Verify behavior, accessibility, and production output
**Files:**
- Modify only if a verified issue is found: sidebar files from Tasks 26 and their tests.
- [ ] **Step 1: Run the complete automated suite**
Run:
```bash
npm test
npm run lint
npx tsc --noEmit
npm run build
```
Expected: all commands exit 0. The production build must not report hydration warnings, missing CSS imports, or client/server boundary errors.
- [ ] **Step 2: Start the local preview without real API mutations**
Run: `npm run dev`
Open `http://localhost:3000/?preview=conversation`. Use only preview modes for visual checks unless the user explicitly asks to exercise cloud-backed data.
- [ ] **Step 3: Verify the three required viewport widths**
At 1280px:
- initial width is 288px;
- trigger and rail collapse to 64px and expand again;
- chat resizes beside the rail rather than being covered;
- logo, new-chat, one history action, and account avatar remain useful;
- tooltips appear for keyboard focus and pointer hover only while collapsed.
At 768px:
- initial state is the 64px rail;
- expansion is 240px;
- no overlay or scrim appears;
- Chinese session titles truncate without horizontal overflow.
At 375px:
- initial drawer is closed and no rail remains;
- header trigger opens `min(86vw, 320px)` drawer;
- focus moves into the drawer;
- scrim and Escape close it and return focus;
- chat becomes inert only while the drawer is open.
- [ ] **Step 4: Verify state and content edge cases**
Check empty, one-session, many-session, and very-long-title states. Confirm:
- collapsed history action expands and focuses the first session without selecting it;
- empty history focuses the `聊天记录` heading and shows `暂无对话` expanded;
- long history scrolls only `SidebarContent`; header/footer remain fixed;
- selecting a session while another session answers remains possible;
- selecting on mobile closes the drawer; selecting on desktop/tablet does not collapse it;
- new-chat loading and disabled states match current business locks.
- [ ] **Step 5: Verify account surfaces from every sidebar state**
From desktop expanded, desktop collapsed, tablet collapsed, and mobile drawer:
- account popover is fully visible and collision-safe;
- expanded/mobile placement is above the footer;
- collapsed placement is to the avatar's right;
- profile, redeem, logout confirmation, and administrator navigation route correctly;
- dialogs remain above the drawer/popover and return focus to the account trigger;
- Escape closes one topmost surface at a time.
- [ ] **Step 6: Verify keyboard and preference behavior**
Confirm:
- Command+B on macOS and Control+B elsewhere toggles the sidebar;
- the shortcut does nothing while focus is in input, textarea, select, or contenteditable;
- all controls have a visible focus ring and at least a 44px target;
- reduced-motion removes translations without breaking state changes;
- reduced-transparency uses an opaque warm background and no blur;
- increased contrast preserves borders, focus, and the active-session marker.
- [ ] **Step 7: Inspect runtime evidence**
Confirm the browser console has no React, hydration, accessibility, or Base UI warnings. Confirm there is no invisible scrim, stale `inert`, double scrollbar, clipped popup, or horizontal overflow after repeated resize/open/close cycles.
- [ ] **Step 8: Run the final diff audit**
Run:
```bash
git status --short
git diff --check HEAD
git diff --stat HEAD~5..HEAD
git diff HEAD~5..HEAD -- frontend/src frontend/tests frontend/DESIGN.md
```
Expected: only planned sidebar/design/test files are included; unrelated research manifests, assets, and `frontend/plans/` remain uncommitted and untouched.
- [ ] **Step 9: Commit only verified follow-up fixes, if any**
If QA required changes, stage only the affected planned files and commit:
```bash
git commit -m "fix: polish sidebar interaction states"
```
If QA required no changes, do not create an empty commit. Do not push; hand the verified local branch back to the user for local acceptance.
---
## Completion Gate
Implementation is complete only when all of the following are true:
- [ ] All 13 acceptance criteria in the approved design spec are satisfied.
- [ ] `npm test`, `npm run lint`, `npx tsc --noEmit`, and `npm run build` pass.
- [ ] Manual checks at 375px, 768px, and 1280px pass.
- [ ] Account popover/dialog behavior is unchanged except for responsive anchoring.
- [ ] Session reading remains available during an active answer.
- [ ] No raw colors, stock shadcn palette, nested scroll owner, invisible overlay, stale inert state, or hydration warning remains.
- [ ] The final diff contains no unrelated user changes.
- [ ] No push has been performed.