Files
Jyotisha/frontend/tests/sidebar-contract.test.ts
T
Jesse_ChenandClaude Opus 5 b3ea8c336e
Independent Staging Quality Gate / validate (push) Failing after 9m19s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 侧栏三段合成一条平铺列表,助手消息去掉头像
侧栏此前在第一条会话之前压着三段:星盘名字 chip 组、收藏对话 <details>、
历史对话 <details>——两个折叠箭头,底下都没有值得折叠的东西。现在是一条
平铺列表:收藏作为首个标签组(带 13px 星标),其余沿用既有的日期分组。

星盘入口收敛到一处。这里与任务书 D6 的字面写法方向相反:D6 说留侧栏删
账户菜单,我做的是删侧栏留账户菜单。理由——D6 写于侧栏只有 2 个导航项时,
现在是 4 个;且 chip 点下去只是开弹窗(page.tsx 里三个回调都是
openAccountDialog("chart-library")),一组只会开弹窗的名字没有导航价值。
连带删 charts/onSelectChart/onAddChart props、SidebarChart 类型、
sidebarCharts 派生与一批 CSS。

助手头像删除,--assistant-content-inset 由 calc(32px + gap) 归零,
追问 chip 与运行时间轴改为与正文对齐。/jyotish-logo.png 文件保留。

顺带关闭 R2 留下的隐患:入口 pill 36px 低于 44px 触摸推荐值,补
::after inset -4px 热区扩展,并在 touch-target-contract 里接替
已删除的 .chart-nav-chip。

测试 3350 不变,fail 仍 31 且与基线逐条一致;/ 仍 Static;
CSS gzip 41,044→39,825(−3.0%),三轮累计 −3.1%。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 05:46:29 +00:00

492 lines
29 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("leaves the chat document for chart, ephemeris, and reports", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
// 对话首页用 history.pushState 维护 ?c=App Router 的 router.push 无法离开。
assert.match(appSidebar, /persistLoginSessionReturn\(\);\s*window\.location\.assign\(path\)/);
assert.match(appSidebar, /leaveChat\("\/chart"\)/);
assert.match(appSidebar, /leaveChat\("\/ephemeris"\)/);
assert.match(appSidebar, /leaveChat\("\/reports"\)/);
assert.doesNotMatch(appSidebar, /router\.push\("\/chart"\)/);
assert.doesNotMatch(appSidebar, /router\.push\("\/ephemeris"\)/);
assert.doesNotMatch(appSidebar, /useRouter/);
});
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/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", () => {
const page = readProjectFile("src/app/page.tsx");
assert.match(page, /const modalOpen = activeAccountDialog !== null \|\| onboardingPaywallOpen/);
assert.match(page, /<SidebarProvider escapeBlocked=\{accountMenuOpen \|\| modalOpen\}>/);
assert.match(page, /<main className="chat-app">[\s\S]*<AppSidebar\b/);
assert.match(
page,
/<SidebarInset className=\{`chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}`\} inert=\{modalOpen\}>/,
);
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
});
test("removes page-local mobile sidebar ownership", () => {
const page = readProjectFile("src/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 page = readProjectFile("src/app/page.tsx");
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(page, /escapeBlocked=\{accountMenuOpen \|\| modalOpen\}/);
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/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/);
assert.match(appSidebar, /sessionControls\.hasMore \? <div ref=\{loadMoreRef\}/);
assert.doesNotMatch(appSidebar, /加载更多|没有更多/);
});