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` 一种 // 原因:星盘列表分组与两个
都已删除,侧栏只剩一条平铺列表和它的一个标题。 // 「标题要压过自己的正文」这条规则本身没变,仍然断。 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, //); assert.match(sidebar, / { const sidebar = readProjectFile("src/components/ui/sidebar.tsx"); // 原值:同一棵侧栏旁边条件渲染遮罩按钮。 // 新值:手机把同一棵侧栏放进 Sheet,桌面直接渲染它。 // 原因:K1 不再有独立遮罩节点;侧栏内容仍是同一个 `sidebar` 变量。 assert.match(sidebar, /[\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, /