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"; 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. const heading = cssDeclarations(".sidebar-label, .sidebar-section-summary", 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.match(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 \?