桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。
自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。
Co-Authored-By: Grok (agent loop)
646 lines
41 KiB
TypeScript
646 lines
41 KiB
TypeScript
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.
|
||
// 原值:`.new-chat` 与 `.report-nav-button` 各自左对齐,收起轨才居中。
|
||
// 新值:两条都是 `.sidebar-nav-link`,新建对话再用 `[data-nav="new"]`。
|
||
// 原因:K1 侧栏导航改成同一套菜单行;左对齐和只在桌面收起轨居中没有变。
|
||
for (const selector of [".sidebar-nav-link", ".sidebar-nav-link[data-nav=\"new\"]"]) {
|
||
assert.match(topLevelRule(selector), /justify-content:\s*flex-start/);
|
||
assert.doesNotMatch(topLevelRule(selector), /justify-content:\s*center/);
|
||
}
|
||
assert.doesNotMatch(globalStyles, /\[data-state="expanded"\][^{]*\.sidebar-nav-link/);
|
||
assert.match(
|
||
globalStyles,
|
||
/@media\s*\(min-width:\s*768px\)[\s\S]*\[data-state="collapsed"\]\s+\.sidebar-nav-link\[data-nav="new"\],\s*\[data-state="collapsed"\]\s+\.sidebar-nav-link\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.
|
||
// 原值:`.new-chat` 用行动色,`.report-nav-button` 用侧栏字色。
|
||
// 新值:`.sidebar-nav-link[data-nav="new"]` 与 `.sidebar-nav-link`。
|
||
// 原因:K1 合并导航类名;新建对话仍是唯一行动色,其余导航仍是中性字色。
|
||
assert.match(topLevelRule(".sidebar-nav-link[data-nav=\"new\"]"), /color:\s*var\(--color-action\)/);
|
||
assert.doesNotMatch(topLevelRule(".sidebar-nav-link[data-nav=\"new\"]"), /background:\s*var\(--color-action\)/);
|
||
assert.match(topLevelRule(".sidebar-nav-link"), /color:\s*var\(--sidebar-foreground\)/);
|
||
assert.doesNotMatch(topLevelRule(".sidebar-nav-link"), /--color-action/);
|
||
for (const body of [...globalStyles.matchAll(/\.sidebar-nav-link\[data-nav="new"\](?::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/);
|
||
// 原值:侧栏原语不得出现 Sheet。
|
||
// 新值:手机侧栏使用 Sheet,从左边滑出。
|
||
// 原因:K1 用组件库 Sheet 替换手写抽屉,桌面仍是同一块侧栏。
|
||
assert.match(sidebar, /SheetContent side="left"/);
|
||
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");
|
||
// 原值:手写遮罩按钮 `data-slot="sidebar-scrim"`,点一下 `setOpenMobile(false)`。
|
||
// 新值:`Sheet` 包住同一块侧栏,`side="left"`,关掉时走 `onOpenChange={setOpenMobile}`。
|
||
// 原因:K1 手机侧栏从左边滑出,关闭面交给组件库 Sheet,不再手写遮罩。
|
||
assert.match(sidebar, /<Sheet open=\{openMobile\} onOpenChange=\{setOpenMobile\}>/);
|
||
assert.match(sidebar, /<SheetContent side="left"/);
|
||
assert.doesNotMatch(sidebar, /data-slot="sidebar-scrim"/);
|
||
assert.match(sidebar, /aria-label="对话导航"|aria-label=\{label\}/);
|
||
});
|
||
|
||
test("keeps the sidebar subtree stable while toggling its mobile scrim", () => {
|
||
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
|
||
// 原值:同一棵侧栏旁边条件渲染遮罩按钮。
|
||
// 新值:手机把同一棵侧栏放进 Sheet,桌面直接渲染它。
|
||
// 原因:K1 不再有独立遮罩节点;侧栏内容仍是同一个 `sidebar` 变量。
|
||
assert.match(sidebar, /<Sheet open=\{openMobile\} onOpenChange=\{setOpenMobile\}>[\s\S]*\{sidebar\}/);
|
||
// 原值:`if (isMobile) return (` 里包住同一棵侧栏
|
||
// 新值:`if (!isMobile) return sidebar`,手机再放进上面的 Sheet
|
||
// 原因:K1 桌面直接用这棵侧栏,手机用 Sheet。内容仍是同一个变量
|
||
assert.match(sidebar, /if \(!isMobile\) return sidebar;/);
|
||
});
|
||
|
||
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\(\);/)。
|
||
// 新值: leaveChat 只关抽屉,侧栏源码里不再出现 persistLoginSessionReturn。
|
||
// 原因: BUG-1052 D3——离开首页不再存 `?c=`,回裸 `/` 落空白首页;回到某条对话走它的 `?c=` 链接。
|
||
assert.match(appSidebar, /function leaveChat\(\) \{\s*closeDrawer\(\);\s*\}/);
|
||
assert.doesNotMatch(appSidebar, /persistLoginSessionReturn/);
|
||
assert.match(appSidebar, /\{ href: "\/chart", label: "星盘"/);
|
||
assert.match(appSidebar, /\{ href: "\/ephemeris", label: "星历"/);
|
||
assert.match(appSidebar, /\{ href: "\/reports", label: "我的报告"/);
|
||
// 原值:`<SidebarMenuLink className="report-nav-button">`
|
||
// 新值:`className="sidebar-nav-link"`
|
||
// 原因:K1 导航行共用一个类,当前页高亮交给 layoutId。
|
||
assert.match(appSidebar, /<SidebarMenuLink\n\s*className="sidebar-nav-link"/);
|
||
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");
|
||
// 原值:`<div className="session-row" data-readonly="true">`
|
||
// 新值:`sidebar-session`
|
||
// 原因:K1 会话行改名,只读行仍不留菜单列。
|
||
assert.match(sessionRow, /<div className="sidebar-session" data-readonly="true">/);
|
||
// 原值:只读行是 `<Link className="session-main">`,页脚是 `<Link className="profile-trigger" href="/">`
|
||
// 新值:两者都改成 `AppLink`,属性与 href 一字未改
|
||
// 原因:BUG-967。跨部署旧标签页上 Next 的客户端导航会静默失败;AppLink 在版本不一致时
|
||
// 走 `window.location.assign`,一致时仍是客户端路由。断言主语(只读行仍是
|
||
// `.session-main` 链接、页脚仍去 `/`、没有菜单按钮)没变。
|
||
assert.match(sessionRow, /<AppLink\n\s*className="session-main"/);
|
||
assert.match(cssBlock('.sidebar-session[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/);
|
||
|
||
// Only the new-chat action carries intent.
|
||
// 原值:新建对话链接的 `onClick={closeDrawer}`
|
||
// 新值:`onClick={handleNewChatLink}`,该函数仍只做 closeDrawer,外加一个本地 performance mark
|
||
// 原因:BUG-1104 给「新建对话」到首页可用的耗时打点;意图仍只在 href 的 `/?new=1` 上,下一行断言不变
|
||
// 原值:`className="new-chat"`
|
||
// 新值:`className="sidebar-nav-link" data-nav="new"`,href 与 onClick 不变。
|
||
// 原因:K1 导航类名合并;新建对话的地址和点击仍只关抽屉。
|
||
assert.match(appSidebar, /<SidebarMenuLink className="sidebar-nav-link" data-nav="new"[^>]*href=\{newChatHref\(\)\}[^>]*onClick=\{handleNewChatLink\}/);
|
||
assert.match(appSidebar, /function handleNewChatLink\(\) \{\s*markNewChatClick\(\);\s*closeDrawer\(\);\s*\}/);
|
||
assert.doesNotMatch(appSidebar, /<SidebarMenuLink className="sidebar-nav-link" data-nav="new"[^>]*href="\/"/);
|
||
|
||
// 原值: /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/——只读页脚是回 `/` 的链接,无 chevron、无菜单
|
||
// 新值: 页脚在两种模式都是同一个 `Menu.Root`(`.profile-trigger` 按钮),不再有回 `/` 的页脚链接;只读模式的弹窗项是 `/?account=<dialog>` 链接
|
||
// 原因: TASK-self-edit-avatar-menu-20260928 S2——产品要求各页点头像都弹账户菜单(推翻侧栏统一单「次级页头像 = 回首页链接」)
|
||
// 原值:一个 `Menu.Root`,类名 `profile-trigger`。
|
||
// 新值:一个 `DropdownMenu`,触发器类名 `sidebar-account-trigger`。
|
||
// 原因:K1 账户菜单改用组件库;各页仍是同一个菜单,只读页的项仍链到 `/?account=`。
|
||
assert.doesNotMatch(appSidebar, /<AppLink\n\s*className="sidebar-account-trigger"/);
|
||
assert.equal(appSidebar.match(/<DropdownMenu /g)?.length, 1, "one account menu");
|
||
assert.match(appSidebar, /\{account \? \(\s*(?:\/\*[\s\S]*?\*\/\s*)?<DropdownMenu open=\{accountMenuOpen\}/);
|
||
assert.match(appSidebar, /render=\{<AppLink href=\{accountDialogHref\(dialog\)\}/);
|
||
|
||
// 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/);
|
||
// 原值:`favoriteSessions.map` 直接画收藏行
|
||
// 新值:`renderSortableGroup(favoriteSessions`
|
||
// 原因:K1 收藏行可以拖动排序。展开时仍是一组行,收起时仍是上面的一个「聊天记录」按钮
|
||
assert.match(appSidebar, /renderSortableGroup\(favoriteSessions/);
|
||
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");
|
||
// 原值:直接使用 Base UI `Menu`,并手写 `collisionPadding={12}`。
|
||
// 新值:`DropdownMenu` / `DropdownMenuContent`,类名仍是 `account-menu-popup` 与 `sidebar-session-menu`。
|
||
// 原因:K1 改用组件库菜单。组件库的定位器没有把 collisionPadding 露出来,不另叉一份原语。
|
||
assert.match(appSidebar, /DropdownMenuContent/);
|
||
assert.match(appSidebar, /className="account-menu-popup w-auto min-w-56"/);
|
||
assert.match(sessionRow, /DropdownMenuContent className="sidebar-session-menu/);
|
||
assert.doesNotMatch(sessionRow, /role="menu(?:item)?"/);
|
||
assert.doesNotMatch(appSidebar, /from "@base-ui\/react\/menu"/);
|
||
});
|
||
|
||
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/);
|
||
// 原值: onOpenChartLibrary opened the settings chart pane.
|
||
// 新值: the account menu no longer has that callback; /people is a nav page.
|
||
// 原因: D8 puts the archive beside 星盘 / 星历 / 我的报告.
|
||
assert.doesNotMatch(appSidebar, /onOpenChartLibrary/);
|
||
assert.match(appSidebar, /href: "\/people", label: "星盘档案"/);
|
||
// 原值:导航到「星盘档案」为止,侧栏不接收合盘开关。
|
||
// 新值:「婚恋合盘」接在「我的报告」之后,由 compatibilityEnabled 决定是否渲染,默认不显示。
|
||
// 原因:S7 要求侧栏只吃 props,开关由外壳传入。
|
||
assert.match(appSidebar, /href: "\/synastry", label: "婚恋合盘"/);
|
||
assert.match(appSidebar, /compatibilityEnabled\?: boolean/);
|
||
assert.match(appSidebar, /compatibilityEnabled = false/);
|
||
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
|
||
const appShell = readProjectFile("src/app/(app)/layout.tsx");
|
||
assert.doesNotMatch(appShell, /\/api\/product-access/);
|
||
assert.match(appShell, /list\.account\?\.compatibility === true/);
|
||
});
|
||
|
||
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"/);
|
||
// 原值:page.tsx 里 `className="credit-button"`。
|
||
// 新值:page.tsx 渲染 `CreditBalanceButton`,类名 `credit-balance` 在该组件上。
|
||
// 原因:K1 顶栏点数是正常大小的 outline 按钮,数字变化时滚动。
|
||
assert.match(page, /<CreditBalanceButton/);
|
||
assert.match(readProjectFile("src/components/credit-balance-button.tsx"), /className="credit-balance"/);
|
||
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/);
|
||
// 原值:`.chat-app` 用网格列分出 288 / 240 / 64。
|
||
// 新值:`.chat-app` 是纵向 flex,`.shell-frame` 是横向 flex;宽度由可拖面板记住,收起是 64。
|
||
// 原因:K1 桌面侧栏放进可拖宽面板,不再用网格列写死三档宽度。
|
||
assert.match(globalStyles, /\.chat-app\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
|
||
assert.match(globalStyles, /\.shell-frame\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*row/);
|
||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /minSize=\{SIDEBAR_WIDTH_MIN\}/);
|
||
assert.match(readProjectFile("src/components/shell-frame.tsx"), /maxSize=\{SIDEBAR_WIDTH_MAX\}/);
|
||
assert.match(readProjectFile("src/lib/sidebar-state.ts"), /SIDEBAR_WIDTH_COLLAPSED = 64/);
|
||
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:/);
|
||
// 原值:手机抽屉用 translateX 藏起来。
|
||
// 新值:样式里不再有这条位移;开关交给 Sheet。
|
||
// 原因:K1 手机侧栏从左边滑出,不再手写位移。
|
||
assert.doesNotMatch(globalStyles, /translateX\(-100%\)/);
|
||
assert.match(readProjectFile("src/components/ui/sidebar.tsx"), /SheetContent side="left"/);
|
||
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("session row menu is rename, pin, share and delete — not archive", () => {
|
||
const row = readProjectFile("src/components/sidebar-session-row.tsx");
|
||
assert.match(row, /session\.pinned \? "取消收藏" : "收藏"/);
|
||
assert.deepEqual(
|
||
[...row.matchAll(/<span>([^<{][^<]*)<\/span>/g)].map((match) => match[1]),
|
||
["重命名", "转发", "删除"],
|
||
);
|
||
assert.doesNotMatch(row, /归档|恢复|ArchiveRestore|onToggleArchived/);
|
||
});
|
||
|
||
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(".sidebar-session"), /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\)/);
|
||
// 原值:`.new-chat` 与 `.report-nav-button` 都是 body-sm,图标 18px。
|
||
// 新值:`.sidebar-nav-link` 与 `[data-nav="new"]`。
|
||
// 原因:K1 合并导航类名,字号和图标尺寸不变。
|
||
assert.match(cssBlock(".sidebar-nav-link"), /font-size:\s*var\(--type-body-sm\)/);
|
||
assert.match(cssBlock(".sidebar-nav-link[data-nav=\"new\"]"), /font-size:\s*var\(--type-body-sm\)/);
|
||
assert.match(cssBlock(".sidebar-nav-link svg"), /width:\s*18px/);
|
||
assert.match(cssBlock(".sidebar-nav-link[data-nav=\"new\"] 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", () => {
|
||
// 原值:`.session-row` 两列。
|
||
// 新值:`.sidebar-session` 仍是标题加 44px 菜单;可排序时前面再加 44px 手柄。
|
||
// 原因:K1 会话行改名,并给拖动排序留出手柄,菜单列还在。
|
||
assert.match(cssBlock(".sidebar-session"), /grid-template-columns:\s*minmax\(0,\s*1fr\)\s+44px/);
|
||
assert.match(cssBlock(".sidebar-session"), /overflow:\s*hidden/);
|
||
assert.match(cssBlock('.sidebar-session[data-sortable="true"]'), /grid-template-columns:\s*44px\s+minmax\(0,\s*1fr\)\s+44px/);
|
||
assert.match(globalStyles, /\.sidebar-session:has\(\.session-main\[data-active="true"\]\)[^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
|
||
assert.match(cssBlock(".session-main"), /border-radius:\s*0/);
|
||
assert.match(cssBlock('.session-main[data-active="true"]'), /background:\s*transparent/);
|
||
assert.match(cssBlock(".session-menu-trigger"), /border-radius:\s*0/);
|
||
assert.match(cssBlock(".session-menu-trigger"), /align-self:\s*stretch/);
|
||
assert.match(cssBlock(".session-menu-trigger"), /min-height:\s*44px/);
|
||
assert.doesNotMatch(cssBlock(".session-menu-trigger"), /border-radius:\s*50%/);
|
||
assert.doesNotMatch(cssBlock(".session-menu-trigger"), /(?<!min-)height:\s*44px/);
|
||
const triggerHover = cssBlock(".session-menu-trigger:hover, .session-menu-trigger[aria-expanded=\"true\"]");
|
||
assert.match(triggerHover, /background:\s*transparent/);
|
||
assert.doesNotMatch(triggerHover, /--color-canvas/);
|
||
});
|
||
|
||
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"\][^{]*\.sidebar-account-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", () => {
|
||
// 原值:手机用固定定位抽屉、位移和 `.sidebar-scrim`。
|
||
// 新值:手机侧栏铺满 Sheet,实底、无毛玻璃;不再有位移和手写遮罩。
|
||
// 原因:K1 手机侧栏从左边滑出,遮罩由 Sheet 提供。
|
||
assert.match(globalStyles, /@media\s*\(max-width:\s*767px\)[\s\S]*\[data-sidebar="sidebar"\]\s*\{[^}]*background:\s*var\(--sidebar-solid\)[^}]*backdrop-filter:\s*none/);
|
||
assert.doesNotMatch(globalStyles, /\.sidebar-scrim\s*\{/);
|
||
assert.doesNotMatch(globalStyles, /translateX\(-100%\)/);
|
||
assert.match(globalStyles, /\[data-sidebar="sidebar"\]:focus\s*\{[^}]*outline:\s*none/);
|
||
assert.match(globalStyles, /\[data-sidebar="rail"\]\s*\{[^}]*display:\s*none/);
|
||
assert.match(readProjectFile("src/components/ui/sidebar.tsx"), /w-\[min\(86vw,320px\)\]/);
|
||
});
|
||
|
||
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, /加载更多|没有更多/);
|
||
});
|