Files
Jyotisha/frontend/tests/sidebar-contract.test.ts
T
jesse-ux e4e73f56c0
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m33s
fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
2026-09-17 21:27:40 +08:00

540 lines
33 KiB
TypeScript
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.
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
import { homeSurface } from "./home-surface.ts";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
const globalStyles = readProjectFile("src/app/globals.css");
const cssBlock = (selector: string) => cssDeclarations(selector, globalStyles);
/** The base-layer rule only — media-query copies of a selector are indented. */
const topLevelRule = (selector: string) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hit = globalStyles.match(new RegExp(`^${escaped}\\s*\\{([^}]*)\\}`, "m"));
assert.ok(hit, `no base-layer rule for ${selector}`);
return hit[1];
};
test("sidebar nav items read left-aligned everywhere; centering is the desktop rail only", () => {
// `data-state` tracks the DESKTOP open state, so it cannot decide layout on
// the mobile drawer — that drawer is always expanded no matter what the
// desktop rail is doing. Centering as the base layer, flipped back by a
// `[data-state="expanded"]` override, left 新建对话 / 我的报告 floating in
// the middle of the drawer on phones while every other row sat flush left.
for (const selector of [".new-chat", ".report-nav-button"]) {
assert.match(topLevelRule(selector), /justify-content:\s*flex-start/);
assert.doesNotMatch(topLevelRule(selector), /justify-content:\s*center/);
}
assert.doesNotMatch(globalStyles, /\[data-state="expanded"\][^{]*\.(?:new-chat|report-nav-button)/);
// The rail lives at >=768px, beside the other collapsed-state rules.
assert.match(
globalStyles,
/@media\s*\(min-width:\s*768px\)[\s\S]*\[data-state="collapsed"\]\s+\.new-chat,\s*\[data-state="collapsed"\]\s+\.report-nav-button\s*\{[^}]*justify-content:\s*center/,
);
});
test("新建对话 carries the sidebar's only accent; 我的报告 stays neutral", () => {
// The sidebar had no accent pixel at all in the empty state: its only two
// action-colored surfaces are the 2px active-session bar and the terracotta
// profile initial, and neither renders with no sessions and an uploaded
// avatar. DESIGN.md keeps the action color scarce, so exactly one row gets
// it — as tinted text, not a filled block, so the surface stays light.
assert.match(topLevelRule(".new-chat"), /color:\s*var\(--color-action\)/);
assert.doesNotMatch(topLevelRule(".new-chat"), /background:\s*var\(--color-action\)/);
assert.match(topLevelRule(".report-nav-button"), /color:\s*var\(--sidebar-foreground\)/);
assert.doesNotMatch(topLevelRule(".report-nav-button"), /--color-action/);
// Hover deepens the same hue rather than reverting to ink.
for (const body of [...globalStyles.matchAll(/\.new-chat(?::not\(:disabled\))?:hover\s*\{([^}]*)\}/g)]) {
assert.match(body[1], /color:\s*var\(--color-action-hover\)/);
}
});
test("the mobile drawer is opaque; the glass surface is a desktop treatment", () => {
// rgba(235,233,227,.86) assumes a light backdrop. On mobile the drawer sits
// above the scrim, which showed through and pulled #EBE9E3 down to #E3E1DC.
assert.match(
globalStyles,
/@media\s*\(max-width:\s*767px\)[\s\S]*\[data-sidebar="sidebar"\]\s*\{[^}]*background:\s*var\(--sidebar-solid\)[^}]*backdrop-filter:\s*none/,
);
// Desktop keeps the glass.
assert.match(topLevelRule(".sidebar"), /background:\s*var\(--sidebar-background\)/);
assert.match(topLevelRule(".sidebar"), /backdrop-filter:\s*saturate/);
});
test("a section heading outranks its own body copy", () => {
// Headings and their empty-state copy both sat on ink-tertiary, so the nav
// read as one flat grey with no hierarchy to scan.
// 原值:选择器 `.sidebar-label, .sidebar-section-summary`(两种标题:分组标签与折叠摘要)
// 新值:`.sidebar-section-label` 一种
// 原因:星盘列表分组与两个 <details> 都已删除,侧栏只剩一条平铺列表和它的一个标题。
// 「标题要压过自己的正文」这条规则本身没变,仍然断。
const heading = cssDeclarations(".sidebar-section-label", globalStyles);
assert.match(heading, /color:\s*var\(--color-ink-secondary\)/);
assert.doesNotMatch(topLevelRule(".sidebar-empty"), /--color-ink-secondary/);
assert.match(topLevelRule(".sidebar-empty"), /color:\s*var\(--color-ink-tertiary\)/);
});
test("empty sidebar sections name the next step instead of dead-ending", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
// Neutral on purpose: 新建对话 already carries the accent a few rows above,
// and a second tinted call to action would compete with it.
// 原值:断言「收藏对话」与「历史对话」各有一句空态文案
// 新值:只剩一句——两个分组合并成一条平铺列表后,只有一个空态
// 原因:「还没有收藏」是给独立收藏分区用的。收藏现在是列表顶部的一个标签组,
// 没有收藏时它整块不渲染;再对一个不存在的分区说「还没有」是噪音。
assert.match(appSidebar, /暂无对话,点上方「新建对话」开始/);
assert.doesNotMatch(appSidebar, /还没有收藏/);
assert.doesNotMatch(topLevelRule(".sidebar-empty"), /--color-action/);
});
test("provides the generic composable sidebar primitive", () => {
assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true);
});
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("keeps sidebar behavior in the generic primitive", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /useSidebarViewport/);
assert.match(sidebar, /defaultSidebarOpen/);
assert.match(sidebar, /shouldHandleSidebarShortcut/);
assert.match(sidebar, /data-state/);
assert.match(sidebar, /data-viewport/);
assert.match(sidebar, /data-mobile-open/);
assert.match(sidebar, /addEventListener\("keydown"/);
assert.match(sidebar, /preventDefault\(\)/);
assert.match(sidebar, /viewport === "mobile" \|\| !openMobile\) return;[\s\S]*setOpenMobile\(false\)/);
assert.match(sidebar, /@base-ui\/react\/tooltip/);
assert.doesNotMatch(sidebar, /Sheet/);
assert.match(sidebar, /cn\(/);
assert.doesNotMatch(sidebar, /#[0-9a-fA-F]{3,8}|hsl\(/);
});
test("provides the mobile drawer closing surface", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /data-sidebar="scrim"/);
assert.match(sidebar, /data-slot="sidebar-scrim"/);
assert.match(sidebar, /aria-label="关闭聊天记录"/);
assert.match(sidebar, /onClick=\{\(\) => setOpenMobile\(false\)\}/);
assert.match(sidebar, /isMobile && openMobile \? <button/);
});
test("keeps the sidebar subtree stable while toggling its mobile scrim", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /return <>\s*\{sidebar\}\s*\{isMobile && openMobile \? <button/);
});
test("cancels a stale mobile drawer focus frame", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /const focusDrawer = window\.requestAnimationFrame/);
assert.match(sidebar, /sidebarSurfaceRef\.current\?\.focus\(\)/);
assert.match(sidebar, /return \(\) => window\.cancelAnimationFrame\(focusDrawer\);/);
});
test("uses the approved localized trigger actions", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /"收起侧边栏"/);
assert.match(sidebar, /"展开侧边栏"/);
assert.match(sidebar, /"打开聊天记录"/);
assert.match(sidebar, /"关闭聊天记录"/);
assert.match(sidebar, /PanelLeft/);
assert.doesNotMatch(sidebar, /PanelLeftClose|PanelLeftOpen/);
assert.match(sidebar, /<PanelLeft aria-hidden="true" \/>/);
});
test("keeps provider primitive defaults and consumer handlers composable", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /id=\{id \?\? "chat-sidebar"\}/);
assert.match(sidebar, /if \(viewport === "mobile" \|\| !openMobile\) return;/);
assert.match(sidebar, /onClick\?\.\(event\);\s*if \(!event\.defaultPrevented\) setOpen\(!open\);/);
});
test("retains viewport state within an unchanged breakpoint", () => {
const viewportHook = readProjectFile("src/hooks/use-sidebar-viewport.ts");
assert.match(viewportHook, /previous\.ready && previous\.viewport === viewport \? previous : \{ viewport, ready: true \}/);
});
test("documents the sidebar shell design contract", () => {
const design = readProjectFile("DESIGN.md");
assert.match(design, /### Sidebar shell/);
assert.match(design, /Scroll ownership/);
assert.match(design, /session-local/);
assert.match(design, /single visible collapse\/expand trigger/);
assert.match(design, /Sidebar state changes are immediate/);
});
test("composes the Jyotisha app sidebar from the generic shell", () => {
assert.equal(existsSync(projectFile("src/components/app-sidebar.tsx")), true);
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
for (const component of ["SidebarHeader", "SidebarContent", "SidebarFooter", "SidebarRail"]) {
assert.match(appSidebar, new RegExp(`<${component}\\b`));
}
});
test("keeps the sidebar brand row free of a duplicate collapse trigger", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.doesNotMatch(appSidebar, /SidebarTrigger/);
});
test("reaches chart, ephemeris and reports with links, not a document load", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
// 原值:要求 `persistLoginSessionReturn(); window.location.assign(path)`
// 以及三个 `leaveChat("/chart" | "/ephemeris" | "/reports")` 调用点。
// 新值:要求三项由 `NAV_PAGES` 渲染成 `<SidebarMenuLink href=…>``leaveChat()`
// 只保留 `persistLoginSessionReturn()` 与关抽屉,并反向禁止任何
// `window.location.assign`。
// 原因:TASK-sidebar-unify D3/T4。原写法是**有意**的整页刷新,理由写在这里的旧注释
// 里:首页用 `history.pushState` 维护 `?c=``router.push` 离不开首页。
// `<Link>` 没有这个限制——它是 App Router 的导航原语,而不是 `router.push`
// ——所以产品拍板换成客户端跳转,React 树与内存缓存因此能活过这一跳。
// `?c=` 的保存(`persistLoginSessionReturn`)一步没少,仍在跳转之前。
// `/login` 仍是硬跳转,由 chat-navigation-a11y-contract 守着。
assert.match(appSidebar, /function leaveChat\(\) \{\s*persistLoginSessionReturn\(\);/);
assert.match(appSidebar, /\{ href: "\/chart", label: "星盘"/);
assert.match(appSidebar, /\{ href: "\/ephemeris", label: "星历"/);
assert.match(appSidebar, /\{ href: "\/reports", label: "我的报告"/);
assert.match(appSidebar, /<SidebarMenuLink\n\s*className="report-nav-button"/);
assert.doesNotMatch(appSidebar, /window\.location/);
assert.doesNotMatch(appSidebar, /router\.push\("\/chart"\)/);
assert.doesNotMatch(appSidebar, /router\.push\("\/ephemeris"\)/);
assert.doesNotMatch(appSidebar, /useRouter/);
});
test("the same component renders read-only when `/` is not the one mounting it", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
// One component, two modes: `controls` present on `/`, absent everywhere else.
assert.match(appSidebar, /export type AppSidebarControls/);
assert.match(appSidebar, /controls\?: AppSidebarControls;/);
// Read-only rows keep the interactive row's markup — `.session-row` wrapping a
// `.session-main` — and drop only the menu trigger and its reserved column.
const sessionRow = readProjectFile("src/components/sidebar-session-row.tsx");
assert.match(sessionRow, /<div className="session-row" data-readonly="true">/);
assert.match(sessionRow, /<Link\n\s*className="session-main"/);
assert.match(cssBlock('.session-row[data-readonly="true"]'), /grid-template-columns:\s*minmax\(0,\s*1fr\)/);
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
assert.match(appSidebar, /<Link\n\s*className="profile-trigger"\n\s*href="\/"/);
// And nothing in read-only mode can write: the write callbacks only exist
// inside `controls`, which the secondary layout never passes.
assert.doesNotMatch(appSidebar, /supabase|fetch\(/i);
});
test("uses one collapsed history action instead of icon-only session rows", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /MessageSquareText/);
assert.match(appSidebar, /state === "collapsed" && !isMobile/);
assert.match(appSidebar, /favoriteSessions\.map/);
assert.match(appSidebar, /historyGroups\.map/);
assert.match(appSidebar, /星盘列表/);
assert.match(appSidebar, /收藏对话/);
assert.match(appSidebar, /历史对话/);
assert.doesNotMatch(appSidebar, /sessions\.map\([^)]*\)\s*=>\s*<[^>]+aria-label=/);
});
test("keeps session navigation independent of request state", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /onSelectSession\(session\.id\)/);
assert.doesNotMatch(appSidebar, /pendingSession|isLoading|cancellationPending|requestPending/);
});
test("uses portaled Base UI menus with safe collision padding", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
const sessionRow = readProjectFile("src/components/sidebar-session-row.tsx");
assert.match(appSidebar, /import \{ Menu \} from "@base-ui\/react\/menu"/);
assert.match(appSidebar, /<Menu\.Portal>/);
assert.match(appSidebar, /collisionPadding=\{12\}/);
assert.match(appSidebar, /<Menu\.Popup className="account-menu-popup"/);
assert.match(sessionRow, /import \{ Menu \} from "@base-ui\/react\/menu"/);
assert.match(sessionRow, /<Menu\.Portal>/);
assert.match(sessionRow, /<Menu\.Popup className="session-actions"/);
assert.doesNotMatch(sessionRow, /role="menu(?:item)?"/);
});
test("closes an open account menu when the sidebar viewport or state changes", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /const \{[^}]*\bviewport\b[^}]*\} = useSidebar\(\)/);
assert.match(appSidebar, /const menuPlacement = `\$\{viewport\}:\$\{state\}`/);
assert.match(appSidebar, /previousMenuPlacement/);
assert.match(appSidebar, /previousMenuPlacement\.current !== menuPlacement && accountMenuOpen/);
});
test("keeps app sidebar props as product data and callbacks", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /export type AppSidebarProps/);
assert.match(appSidebar, /onSelectSession: \(sessionId: string\) => void/);
// 原值:断言 `charts` 与 `onSelectChart` 在 props 里
// 新值:两者连同 `onAddChart`、`SidebarChart` 类型一起删除
// 原因:侧栏的「星盘列表」chip 与加号按钮打开的,和账户菜单「星盘资料」是**同一个弹窗**
// page.tsx 里三个回调都是 openAccountDialog("chart-library"))。一组点了只会开弹窗的
// 名字不提供导航价值。入口收敛到账户菜单一处,见任务书 D6。
assert.doesNotMatch(appSidebar, /onSelectChart|onAddChart|SidebarChart/);
assert.match(appSidebar, /onOpenChartLibrary: \(\) => void/);
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
});
test("removes the user-facing admin entry and routes credit control to membership", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
const page = readProjectFile("src/app/(app)/page.tsx");
assert.doesNotMatch(appSidebar, /adminUrl|account\.isAdmin|后台管理|KeyRound/);
assert.doesNotMatch(page, /admin-button|ShieldCheck|后台管理/);
assert.doesNotMatch(page, /account\.isAdmin && account\.adminUrl/);
assert.match(page, /className="chat-header-actions"/);
assert.match(page, /className="credit-button"/);
assert.match(page, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
});
test("composes the chat page with the app sidebar shell", () => {
// 原值:page.tsx 自己挂 SidebarProvider / AppSidebar / SidebarInset
// 新值:外壳在 (app)/layout.tsx;首页只注册 controls 并保留 SidebarTrigger
// 原因:T2 列表只拉一次、四页共用一份侧栏
const page = readProjectFile("src/app/(app)/page.tsx");
const layout = readProjectFile("src/app/(app)/layout.tsx");
const shell = readProjectFile("src/hooks/use-home-shell-registration.ts");
assert.match(page, /const modalOpen = activeAccountDialog !== null \|\| onboardingPaywallOpen/);
assert.match(layout, /<SidebarProvider escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}>/);
assert.match(layout, /<main className="chat-app">[\s\S]*<AppSidebar\b/);
assert.match(layout, /insetClassName/);
assert.match(shell, /insetClassName: `chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}`/);
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
assert.doesNotMatch(page, /<SidebarProvider/);
});
test("removes page-local mobile sidebar ownership", () => {
const page = readProjectFile("src/app/(app)/page.tsx");
assert.doesNotMatch(page, /mobileSidebarOpen|setMobileSidebarOpen/);
assert.doesNotMatch(page, /className="sidebar-backdrop"/);
assert.doesNotMatch(page, /<aside className="sidebar"/);
assert.doesNotMatch(page, /className="mobile-menu"/);
});
test("blocks the provider mobile Escape action behind layered account UI", () => {
const layout = readProjectFile("src/app/(app)/layout.tsx");
const shell = readProjectFile("src/hooks/use-home-shell-registration.ts");
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(shell, /escapeBlocked: accountMenuOpen \|\| modalOpen/);
assert.match(layout, /escapeBlocked=\{registration\?\.escapeBlocked \?\? false\}/);
assert.match(sidebar, /event\.key === "Escape" && isMobile && openMobile && !escapeBlocked/);
});
test("keeps the page session selection callback free of request locks", () => {
const selectSession = homeSurface.match(/function selectSession\(sessionId: string\) \{([\s\S]*?)\n \}/);
assert.ok(selectSession);
assert.match(selectSession[1], /setActiveSessionId\(sessionId\)/);
assert.match(selectSession[1], /setDraft\(""\)/);
assert.match(selectSession[1], /setComposerNotice\(""\)/);
assert.doesNotMatch(selectSession[1], /pendingSessionId|isLoading|cancellationPending|creatingSession/);
});
test("maps the sidebar semantic aliases and responsive dimensions", () => {
for (const declaration of [
"--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);",
]) {
assert.equal(globalStyles.includes(declaration), true, `missing ${declaration}`);
}
});
test("selects desktop and tablet shell widths from provider data", () => {
assert.match(globalStyles, /\.group\\\/sidebar-provider\[data-viewport\][^{]*\{[^}]*height:\s*100dvh/);
assert.match(globalStyles, /\[data-viewport="desktop"\]\[data-state="expanded"\]\s+\.chat-app\s*\{[^}]*grid-template-columns:\s*var\(--sidebar-width-desktop\)\s+minmax\(0,\s*1fr\)/);
assert.match(globalStyles, /\[data-viewport="tablet"\]\[data-state="expanded"\]\s+\.chat-app\s*\{[^}]*grid-template-columns:\s*var\(--sidebar-width-tablet\)\s+minmax\(0,\s*1fr\)/);
assert.match(globalStyles, /\[data-state="collapsed"\]\s+\.chat-app\s*\{[^}]*grid-template-columns:\s*var\(--sidebar-width-icon\)\s+minmax\(0,\s*1fr\)/);
assert.doesNotMatch(globalStyles, /transition:[^;}]*\b(?:width|grid-template-columns)\b/);
});
test("changes sidebar state without transition frames", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
const design = readProjectFile("DESIGN.md");
assert.doesNotMatch(sidebar, /document\.startViewTransition/);
assert.doesNotMatch(sidebar, /skipMotion/);
assert.match(sidebar, /const setOpen = useCallback\(\(nextOpen: boolean\) => \{\s*commitOpen\(nextOpen\);/);
assert.match(sidebar, /else commitOpen\(!open\);/);
assert.doesNotMatch(globalStyles, /view-transition-name/);
assert.doesNotMatch(globalStyles, /::view-transition-/);
assert.match(globalStyles, /\[data-sidebar="trigger"\]\s+svg\s*\{[^}]*width:\s*18px[^}]*height:\s*18px/);
assert.doesNotMatch(cssBlock('[data-sidebar="trigger"]'), /transition:/);
assert.match(globalStyles, /\[data-sidebar="sidebar"\]\[data-mobile-open="false"\][^{]*\{[^}]*visibility:\s*hidden[^}]*transform:\s*translateX\(-100%\)/);
assert.match(design, /Sidebar state changes are immediate/);
});
test("keeps the chat title in the flexible left-aligned header column", () => {
const page = readProjectFile("src/app/(app)/page.tsx");
const header = page.slice(page.indexOf('<header className="chat-header">'), page.indexOf("</header>", page.indexOf('<header className="chat-header">')));
assert.match(cssBlock(".chat-header"), /grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/);
assert.match(cssBlock(".chat-header"), /text-align:\s*left/);
assert.doesNotMatch(cssBlock(".chat-header"), /justify-content:\s*space-between/);
assert.match(header, /<strong>\{activeSession\?\.title \|\| "新对话"\}<\/strong>/);
assert.doesNotMatch(header, /status-loading|基于星盘证据回答|回答一般占星知识|正在校正出生时间/);
});
test("anchors the account footer to the bottom edge without trailing sidebar padding", () => {
assert.match(globalStyles, /\.sidebar \{ position:[^}]*padding:\s*var\(--space-5\)\s+var\(--space-3\)\s+0/);
assert.match(cssBlock(".sidebar-footer"), /margin-top:\s*0/);
});
test("makes SidebarContent the only sidebar scroll owner", () => {
assert.match(cssBlock('[data-sidebar="header"]'), /flex:\s*0\s+0\s+auto/);
assert.match(cssBlock('[data-sidebar="content"]'), /min-height:\s*0/);
assert.match(cssBlock('[data-sidebar="content"]'), /overflow-y:\s*auto/);
assert.match(cssBlock('[data-sidebar="footer"]'), /flex:\s*0\s+0\s+auto/);
assert.doesNotMatch(cssBlock(".session-list"), /overflow(?:-y)?:\s*auto/);
assert.match(readProjectFile("src/components/sidebar-session-row.tsx"), /className="session-title"[\s\S]*className="truncate"/);
assert.match(globalStyles, /\[data-active="true"\][^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
});
test("styles the session history scrollbar as a quiet overlay", () => {
const content = cssBlock('[data-sidebar="content"]');
assert.match(content, /scrollbar-width:\s*thin/);
assert.match(content, /scrollbar-color:\s*transparent\s+transparent/);
assert.doesNotMatch(content, /scrollbar-gutter/);
assert.match(cssBlock('[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within'), /scrollbar-color:\s*color-mix\(in srgb,\s*var\(--color-ink\)\s+26%,\s*transparent\)\s+transparent/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar'), /width:\s*10px/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar'), /background:\s*transparent/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar-button'), /display:\s*none/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner'), /background:\s*transparent/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar-thumb'), /background-color:\s*transparent/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar-thumb'), /border:\s*3px\s+solid\s+transparent/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar-thumb'), /background-clip:\s*content-box/);
assert.match(cssBlock('[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb'), /background-color:\s*color-mix\(in srgb,\s*var\(--color-ink\)\s+26%,\s*transparent\)/);
assert.match(cssBlock('[data-sidebar="content"]::-webkit-scrollbar-thumb:hover'), /background-color:\s*color-mix\(in srgb,\s*var\(--color-ink\)\s+40%,\s*transparent\)/);
assert.match(globalStyles, /@media\s*\(prefers-contrast:\s*more\)[\s\S]*\[data-sidebar="content"\][^{]*\{[^}]*scrollbar-color:\s*var\(--color-ink-secondary\)\s+transparent/);
assert.match(globalStyles, /@media\s*\(forced-colors:\s*active\)[\s\S]*\[data-sidebar="content"\][^{]*\{[^}]*scrollbar-color:\s*auto/);
assert.match(readProjectFile("DESIGN.md"), /quiet overlay scrollbar/);
assert.match(readProjectFile("DESIGN.md"), /ordinary session/);
});
test("nests sidebar lists under one heading scale without an archive toggle", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.doesNotMatch(appSidebar, /归档 \$\{/);
assert.doesNotMatch(appSidebar, /onToggleArchivedView/);
assert.match(appSidebar, /className="sidebar-nested"/);
// 原值:列表缩进 = 左边距 + 标题图标 18px + 间隙,用来让列表对齐折叠标题的文字
// 新值:`padding: 0 var(--space-3) var(--space-1)`,与侧栏自身的边距一致
// 原因:列表不再嵌在可折叠标题之下,没有需要对齐的标题图标了。
assert.match(cssBlock(".sidebar-nested"), /padding:\s*0 var\(--space-3\) var\(--space-1\)/);
assert.match(cssBlock(".session-title"), /font-size:\s*var\(--type-caption\)/);
assert.match(cssBlock(".session-title"), /font-weight:\s*500/);
assert.match(cssBlock(".session-row"), /color:\s*var\(--sidebar-foreground\)/);
// Was ink-tertiary, identical to .sidebar-empty below it, which left the
// headings and their own body copy indistinguishable. The shared scale is
// what this test guards; the rank between the two levels is asserted in
// "a section heading outranks its own body copy".
// 原值:`.sidebar-label` 与 `.sidebar-section-summary` 两套标题各断一次
// 新值:合并为 `.sidebar-section-label` 一套
// 原因:同上,两种标题形态已合一。字号与色阶要求不变。
assert.match(cssBlock(".sidebar-section-label"), /color:\s*var\(--color-ink-secondary\)/);
assert.match(cssBlock(".sidebar-section-label"), /font-size:\s*var\(--type-caption\)/);
assert.match(cssBlock(".new-chat"), /font-size:\s*var\(--type-body-sm\)/);
assert.match(cssBlock(".report-nav-button"), /font-size:\s*var\(--type-body-sm\)/);
assert.match(cssBlock(".new-chat svg"), /width:\s*18px/);
assert.match(cssBlock(".report-nav-button svg"), /width:\s*18px/);
// 原值:两种标题的图标各 18px / 新值:标题不再带 18px 图标
// 原因:唯一剩下的标题是「最近」两个字,不带图标;收藏分组的星标是 13px 的
// 行内标记,属于 `.sidebar-group-label`,不是标题图标。
assert.match(cssBlock(".sidebar-group-label > svg"), /color:\s*var\(--color-action\)/);
assert.match(cssBlock(".session-title > svg"), /color:\s*currentColor/);
assert.doesNotMatch(appSidebar, /session-nav-header-inline/);
});
test("renders each session title and menu as one unified row surface", () => {
assert.match(cssBlock(".session-row"), /grid-template-columns:\s*minmax\(0,\s*1fr\)\s+44px/);
assert.match(globalStyles, /\.session-row:has\(\.session-main\[data-active="true"\]\)[^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
assert.match(cssBlock('.session-main[data-active="true"]'), /background:\s*transparent/);
assert.match(cssBlock(".session-menu-trigger"), /border-radius:\s*0/);
assert.doesNotMatch(cssBlock(".session-menu-trigger"), /border-radius:\s*50%/);
});
test("styles the non-mobile collapsed rail without repeated session rows", () => {
assert.match(globalStyles, /@media\s*\(min-width:\s*768px\)[\s\S]*\[data-state="collapsed"\][^{]*\.brand-row/);
assert.match(globalStyles, /\[data-state="collapsed"\][^{]*\[data-sidebar="menu-button"\][^{]*\{[^}]*width:\s*44px/);
assert.match(globalStyles, /\[data-state="collapsed"\][^{]*\.profile-trigger[^{]*\{[^}]*width:\s*44px/);
assert.match(globalStyles, /\[data-sidebar="rail"\][^{]*\{[^}]*position:\s*absolute[^}]*width:\s*var\(--space-2\)/);
assert.match(globalStyles, /\[data-sidebar="rail"\]:focus-visible[^{]*\{[^}]*outline/);
});
test("uses provider attributes for the mobile drawer and scrim", () => {
assert.match(globalStyles, /@media\s*\(max-width:\s*767px\)[\s\S]*\.chat-app\s*\{[^}]*grid-template-columns:\s*1fr/);
assert.match(globalStyles, /\[data-sidebar="sidebar"\][^{]*\{[^}]*position:\s*fixed[^}]*width:\s*var\(--sidebar-width-mobile\)/);
assert.match(globalStyles, /\[data-sidebar="sidebar"\]\[data-mobile-open="false"\][^{]*\{[^}]*transform:\s*translateX\(-100%\)/);
assert.match(globalStyles, /\[data-sidebar="sidebar"\]\[data-mobile-open="true"\][^{]*\{[^}]*visibility:\s*visible[^}]*transform:\s*translateX\(0\)/);
assert.match(globalStyles, /\.sidebar-scrim\s*\{[^}]*position:\s*fixed[^}]*background:\s*var\(--color-scrim\)/);
assert.match(globalStyles, /\[data-sidebar="sidebar"\]:focus\s*\{[^}]*outline:\s*none/);
assert.match(globalStyles, /\[data-sidebar="rail"\]\s*\{[^}]*display:\s*none/);
});
test("styles the portaled account popup and collapsed tooltips", () => {
assert.match(globalStyles, /:has\(>\s*\.account-menu-popup\)[^{]*\{[^}]*z-index:\s*30/);
assert.match(globalStyles, /\.account-menu-popup\s*\{[^}]*width:\s*min\(280px,\s*calc\(100vw\s*-\s*var\(--space-6\)\)\)[^}]*transform-origin:\s*var\(--transform-origin\)/);
assert.match(globalStyles, /\.account-menu-popup\[data-starting-style\][^{]*\.account-menu-popup\[data-ending-style\][^{]*\{[^}]*opacity:\s*0[^}]*translateY\(var\(--space-1\)\)/);
assert.match(globalStyles, /\[role="tooltip"\]\s*\{[^}]*pointer-events:\s*none[^}]*font-size:\s*var\(--type-caption\)/);
assert.match(globalStyles, /\[role="tooltip"\]\[data-starting-style\][^{]*\{[^}]*opacity:\s*0[^}]*translateX\(-?var\(--space-1\)\)/);
});
test("extends sidebar accessibility preference styles", () => {
assert.match(globalStyles, /@media\s*\(prefers-reduced-motion:\s*reduce\)[\s\S]*\.account-menu-popup\[data-starting-style\][^{]*\{[^}]*transform:\s*none/);
assert.match(globalStyles, /@media\s*\(prefers-reduced-transparency:\s*reduce\)[\s\S]*\.sidebar\s*\{[^}]*background:\s*var\(--sidebar-solid\)[^}]*backdrop-filter:\s*none/);
assert.match(globalStyles, /@media\s*\(prefers-contrast:\s*more\)[\s\S]*\[data-active="true"\]::before\s*\{[^}]*width:\s*var\(--space-1\)/);
});
test("removes class-owned drawer state and obsolete sidebar anchoring", () => {
assert.doesNotMatch(globalStyles, /\.(?:sidebar-backdrop|sidebar-close|mobile-menu|sidebar-open)\b/);
assert.doesNotMatch(globalStyles, /\.account-menu\s*\{/);
assert.doesNotMatch(globalStyles, /\.session-list\s*\{[^}]*overflow(?:-y)?:\s*auto/);
});
test("history renders recency group labels and a silent load-more sentinel", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /groupSessionsByRecency/);
assert.match(appSidebar, /sidebar-group-label/);
assert.match(appSidebar, /session-list-sentinel/);
// 原值:`sessionControls.hasMore ? <div ref={loadMoreRef}`
// 新值:`hasMoreSessions ? <div ref={loadMoreRef}`
// 原因:`sessionControls` 在只读模式下不存在,改从可选的 `controls` 里解构;
// `hasMoreSessions` 是它派生出的稳定布尔值,effect 的依赖数组也用它,
// 否则 exhaustive-deps 会要求整个对象、让观察器每次渲染重建。哨兵本身
// (有下一页才渲染、渲染出来是静默的 sentinel)没变。
assert.match(appSidebar, /hasMoreSessions \? <div ref=\{loadMoreRef\}/);
assert.doesNotMatch(appSidebar, /加载更多|没有更多/);
});