# Polaris Interactive Homepage Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 `feat/polaris-homepage` 分支中建立可维护的 Next.js 项目骨架,并交付符合已批准设计的完整可交互 Polaris 首页。 **Architecture:** `src/app` 只负责路由和组合,首页与导航逻辑分别聚合在 `src/features/home`、`src/features/navigation`,跨功能 UI 放在 `src/components/shared`,本地 JSON 只能通过 `src/services` 访问。页面保持 Server Component,只有侧栏、时钟、搜索和图标 fallback 使用最小 Client Component 边界。 **Tech Stack:** Next.js App Router、React、TypeScript strict、Tailwind CSS v4、shadcn/ui、lucide-react、simple-icons、Vitest、Testing Library、ESLint、Prettier、pnpm。 **Spec:** `ts/web/nextjs/polaris/docs/design/2026-08-26-Polaris-首页实现规格.md` ## Global Constraints - 当前工作分支必须是 `feat/polaris-homepage`。 - 采用 `output: "export"`,`pnpm build` 必须生成纯静态 `out/` 产物。 - 第一版只实现浅色桌面首页;不得创建正式分类页、管理页、深色模式或 Go API。 - 运行时背景必须是 CSS 低饱和青蓝渐变;设计图仅作构图参考。 - Google、Bing、GitHub 搜索在当前标签页跳转;Local 结果和常用链接在新标签页打开。 - 分类导航本阶段可见但禁用,不得进入 404。 - `sort` 管分类内顺序;可选 `pinnedSort` 管首页常用链接顺序。 - 关键业务规则、fallback、hydration 和阶段性限制使用简洁中文注释;不写复述代码的噪声注释。 - 遵循 TDD:行为任务先写失败测试,确认失败,再写最小实现。 - 用户要求整个分支目标完成后再提交,因此 Task 1–9 不创建中间 commit;仅 Task 10 在全部验证通过后提交一次。 - 不引入 Redux、Zustand、React Query、Zod、Storybook、Repository interface 或依赖注入容器。 --- ## File Map ### Project and documentation - Modify: `.gitignore` — 增加 Next.js、测试覆盖率和环境文件忽略规则。 - Create: `ts/web/nextjs/polaris/package.json` — 脚本、依赖和 Node 版本约束。 - Create: `ts/web/nextjs/polaris/pnpm-lock.yaml` — pnpm 锁文件。 - Create: `ts/web/nextjs/polaris/next.config.ts` — 静态导出配置。 - Create: `ts/web/nextjs/polaris/tsconfig.json` — strict TypeScript 与 `@/*` alias。 - Create: `ts/web/nextjs/polaris/next-env.d.ts` — Next.js 类型声明。 - Create: `ts/web/nextjs/polaris/postcss.config.mjs` — Tailwind v4 PostCSS 插件。 - Create: `ts/web/nextjs/polaris/eslint.config.mjs` — Next.js Core Web Vitals 与 TypeScript lint。 - Create: `ts/web/nextjs/polaris/prettier.config.mjs` — Prettier 与 Tailwind class 排序。 - Create: `ts/web/nextjs/polaris/vitest.config.ts` — jsdom、React 和 alias 测试配置。 - Create: `ts/web/nextjs/polaris/components.json` — shadcn/ui 配置。 - Move: 三份 Polaris 文档到 `ts/web/nextjs/polaris/docs/design/`。 - Move: `Polaris页面设计图.png` 到 `ts/web/nextjs/polaris/docs/assets/`。 - Move: `logo.svg` 到 `ts/web/nextjs/polaris/public/brand/logo.svg`。 ### Application shell - Create: `src/app/layout.tsx` — 根 HTML、metadata 与全局样式。 - Create: `src/app/page.tsx` — Server Component 首页组合入口。 - Create: `src/app/globals.css` — Tailwind、design tokens、背景与全局动效。 - Create: `src/lib/utils.ts` — shadcn `cn()` 工具。 - Create: `src/test/setup.ts` — Testing Library 全局清理与 jest-dom matcher。 - Create: `src/components/ui/button.tsx` — shadcn Button。 - Create: `src/components/ui/dropdown-menu.tsx` — shadcn DropdownMenu。 - Create: `src/components/ui/tooltip.tsx` — shadcn Tooltip。 ### Data and contracts - Create: `src/types/navigation.ts` — `Category`、`NavLink`。 - Create: `src/types/search.ts` — Provider 与 Local 结果类型。 - Create: `src/types/settings.ts` — `AppSettings`。 - Create: `src/data/categories.json` — 7 个分类。 - Create: `src/data/links.json` — 20 条链接及 8 个 `pinnedSort`。 - Create: `src/data/settings.json` — 搜索、背景和侧栏默认设置。 - Create: `src/data/data.test.ts` — JSON 结构与交叉引用测试。 - Create: `src/services/categories.ts` — 分类读取和排序。 - Create: `src/services/links.ts` — 链接读取与常用链接排序。 - Create: `src/services/settings.ts` — 设置读取。 - Create: `src/services/services.test.ts` — service 排序和不可变性测试。 ### Home domain - Create: `src/features/home/lib/date-time.ts` — 问候、时间和日期纯函数。 - Create: `src/features/home/lib/date-time.test.ts` — 时间边界测试。 - Create: `src/features/home/hooks/use-clock.ts` — 对齐分钟边界的客户端时钟。 - Create: `src/features/home/components/clock-greeting.tsx` — 时钟展示。 - Create: `src/features/home/components/clock-greeting.test.tsx` — 时钟交互测试。 - Create: `src/features/home/lib/search-providers.ts` — 外部搜索 URL 与 Provider 校验。 - Create: `src/features/home/lib/search-providers.test.ts` — URL 编码测试。 - Create: `src/features/home/lib/local-search.ts` — 本地匹配、评分和排序。 - Create: `src/features/home/lib/local-search.test.ts` — 本地搜索权重测试。 - Create: `src/features/home/lib/provider-preference.ts` — Provider 存储解析与容错。 - Create: `src/features/home/lib/provider-preference.test.ts` — 非法存储值测试。 - Create: `src/features/home/components/search-provider-menu.tsx` — Provider 菜单。 - Create: `src/features/home/components/local-search-results.tsx` — 结果 listbox。 - Create: `src/features/home/components/search-box.tsx` — 搜索状态和键盘交互。 - Create: `src/features/home/components/search-box.test.tsx` — 完整搜索交互测试。 - Create: `src/features/home/components/quick-links.tsx` — 常用链接网格。 - Create: `src/features/home/components/quick-links.test.tsx` — 顺序与安全属性测试。 - Create: `src/features/home/components/home-content.tsx` — 首页主内容组合。 ### Navigation and shared UI - Create: `src/features/navigation/lib/sidebar-preference.ts` — 折叠状态解析和存储。 - Create: `src/features/navigation/lib/sidebar-preference.test.ts` — 存储容错测试。 - Create: `src/features/navigation/components/category-icon.tsx` — 分类 icon 白名单映射。 - Create: `src/features/navigation/components/sidebar.tsx` — Logo、首页选中态和禁用分类项。 - Create: `src/features/navigation/components/app-shell.tsx` — 折叠状态与布局容器。 - Create: `src/features/navigation/components/app-shell.test.tsx` — 折叠、持久化和禁用导航测试。 - Create: `src/components/shared/glass-panel.tsx` — 共享玻璃容器。 - Create: `src/components/shared/icon-candidates.ts` — 图标候选链纯函数。 - Create: `src/components/shared/icon-candidates.test.ts` — fallback 顺序测试。 - Create: `src/components/shared/simple-icon-map.ts` — 当前 20 个品牌 icon 的显式映射。 - Create: `src/components/shared/site-icon.tsx` — SVG、favicon 和文字占位渲染。 - Create: `src/components/shared/site-icon.test.tsx` — 图片失败 fallback 测试。 --- ### Task 1: Establish the project foundation and move documentation **Files:** - Modify: `.gitignore` - Create: all project configuration files listed under “Project and documentation” - Create: `src/app/layout.tsx` - Create: `src/app/page.tsx` - Create: `src/app/globals.css` - Create: `src/lib/utils.ts` - Create: `src/test/setup.ts` - Move: existing docs and assets to approved locations **Interfaces:** - Produces: a runnable static-export Next.js application with `@/*` resolving to `src/*`. - Produces scripts: `dev`, `build`, `lint`, `typecheck`, `test`, `test:watch`, `format`, `format:check`. - Consumes: no application code. - [ ] **Step 1: Verify branch and baseline** Run from repository root: ```bash git branch --show-current git status --short ``` Expected: ```text feat/polaris-homepage ?? ts/web/nextjs/polaris/docs/ ``` The only pre-existing uncommitted file should be the approved implementation spec. - [ ] **Step 2: Move approved documentation and assets** Run: ```bash cd ts/web/nextjs/polaris git mv 'Polaris 项目说明文档.md' docs/design/ git mv 'Polaris 首页设计文档.md' docs/design/ git mv 'Polaris 分类页设计文档.md' docs/design/ mkdir -p docs/assets public/brand public/icons git mv 'Polaris页面设计图.png' docs/assets/ git mv logo.svg public/brand/logo.svg ``` Update links after the move: - In `docs/design/Polaris 项目说明文档.md`, replace the asset note with links to `../assets/Polaris页面设计图.png` and `../../public/brand/logo.svg`. - In `docs/design/Polaris 首页设计文档.md`, point the design image link to `../assets/Polaris页面设计图.png`. - Keep cross-document links as `./Polaris%20...md`, because all three documents remain siblings. - [ ] **Step 3: Create the initial package manifest** Create `package.json`: ```json { "name": "polaris", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "format": "prettier --write .", "format:check": "prettier --check ." }, "engines": { "node": ">=20.9.0" } } ``` Install runtime dependencies: ```bash pnpm add next@latest react@latest react-dom@latest clsx tailwind-merge lucide-react simple-icons ``` Install development dependencies: ```bash pnpm add -D typescript @types/node @types/react @types/react-dom tailwindcss @tailwindcss/postcss postcss eslint eslint-config-next prettier prettier-plugin-tailwindcss vitest jsdom @vitejs/plugin-react vite-tsconfig-paths @testing-library/react @testing-library/user-event @testing-library/jest-dom ``` Expected: `package.json` contains pinned resolved ranges and `pnpm-lock.yaml` exists. - [ ] **Step 4: Create TypeScript and Next.js configuration** Create `next.config.ts`: ```ts import type { NextConfig } from "next" const nextConfig: NextConfig = { output: "export", trailingSlash: true, images: { unoptimized: true, }, } export default nextConfig ``` Create `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ``` Create `next-env.d.ts`: ```ts /// /// // 该文件由 Next.js 类型系统使用,不应手动加入业务声明。 ``` Create `postcss.config.mjs`: ```js const config = { plugins: { "@tailwindcss/postcss": {}, }, } export default config ``` - [ ] **Step 5: Create lint, format and test configuration** Create `eslint.config.mjs`: ```js import { defineConfig, globalIgnores } from "eslint/config" import nextVitals from "eslint-config-next/core-web-vitals" import nextTypeScript from "eslint-config-next/typescript" export default defineConfig([ ...nextVitals, ...nextTypeScript, globalIgnores([".next/**", "out/**", "coverage/**", "next-env.d.ts"]), ]) ``` Create `prettier.config.mjs`: ```js /** @type {import("prettier").Config} */ const config = { plugins: ["prettier-plugin-tailwindcss"], semi: false, singleQuote: false, trailingComma: "all", } export default config ``` Create `vitest.config.ts`: ```ts import react from "@vitejs/plugin-react" import tsconfigPaths from "vite-tsconfig-paths" import { defineConfig } from "vitest/config" export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { environment: "jsdom", setupFiles: ["./src/test/setup.ts"], clearMocks: true, css: true, }, }) ``` Create `src/test/setup.ts`: ```ts import "@testing-library/jest-dom/vitest" import { cleanup } from "@testing-library/react" import { afterEach } from "vitest" afterEach(() => { cleanup() }) ``` - [ ] **Step 6: Configure shadcn/ui and generate only required primitives** Create `components.json`: ```json { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" } } ``` Create `src/lib/utils.ts`: ```ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` Run: ```bash pnpm dlx shadcn@latest add button dropdown-menu tooltip --yes ``` Expected: only `button.tsx`, `dropdown-menu.tsx`, `tooltip.tsx` and their required packages are added. - [ ] **Step 7: Create the minimal App Router smoke page** Create `src/app/globals.css`: ```css @import "tailwindcss"; :root { color-scheme: light; } * { box-sizing: border-box; } html, body { min-height: 100%; } body { margin: 0; color: #0f172a; background: #e9f2f8; } ``` Create `src/app/layout.tsx`: ```tsx import type { Metadata } from "next" import "./globals.css" export const metadata: Metadata = { title: "Polaris", description: "安静、高效的个人导航起始页", } export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( {children} ) } ``` Create `src/app/page.tsx`: ```tsx export default function HomePage() { return
Polaris
} ``` - [ ] **Step 8: Extend the repository ignore rules** Append under the Node/TypeScript section in repository-root `.gitignore`: ```gitignore .next/ out/ coverage/ .env* !.env.example ``` - [ ] **Step 9: Run the foundation smoke checks** Run from `ts/web/nextjs/polaris`: ```bash pnpm format pnpm lint pnpm typecheck pnpm build ``` Expected: all commands exit `0`; `out/index.html` exists. - [ ] **Step 10: Record an uncommitted checkpoint** Run: ```bash git status --short git diff --check ``` Expected: project foundation and moved docs are visible; no whitespace errors. Do not commit. --- ### Task 2: Define typed data, independent pinned ordering, and service boundaries **Files:** - Create: `src/types/navigation.ts` - Create: `src/types/search.ts` - Create: `src/types/settings.ts` - Create: `src/data/categories.json` - Create: `src/data/links.json` - Create: `src/data/settings.json` - Create: `src/data/data.test.ts` - Create: `src/services/categories.ts` - Create: `src/services/links.ts` - Create: `src/services/settings.ts` - Create: `src/services/services.test.ts` - Modify: moved project, home and category design documents to describe `pinnedSort` **Interfaces:** - Produces: `Category`, `NavLink`, `SearchProvider`, `AppSettings`. - Produces: `getCategories(): Category[]`. - Produces: `getLinks(): NavLink[]`. - Produces: `getPinnedLinks(limit?: number): NavLink[]`. - Produces: `getSettings(): AppSettings`. - Consumes: JSON module support from Task 1. - [ ] **Step 1: Create the shared contracts** Create `src/types/navigation.ts`: ```ts export type Category = { id: string title: string description: string icon: string sort: number } export type NavLink = { id: string title: string description: string url: string icon?: string categoryId: string pinned: boolean /** 首页顺序与分类内顺序职责不同,避免复用 sort 造成顺序冲突。 */ pinnedSort?: number keywords: string[] sort: number } ``` Create `src/types/search.ts`: ```ts import type { NavLink } from "@/types/navigation" export const SEARCH_PROVIDERS = ["google", "bing", "github", "local"] as const export type SearchProvider = (typeof SEARCH_PROVIDERS)[number] export type ExternalSearchProvider = Exclude export type LocalSearchResult = { link: NavLink categoryTitle: string score: number } ``` Create `src/types/settings.ts`: ```ts import type { SearchProvider } from "@/types/search" export type AppSettings = { siteName: string theme: "light-glass" search: { defaultProvider: SearchProvider enabledProviders: SearchProvider[] } background: { type: "gradient" | "image" url: string | null blur: number overlay: number } sidebar: { defaultCollapsed: boolean } } ``` - [ ] **Step 2: Write failing data validation tests before creating JSON** Create `src/data/data.test.ts` with tests that import all three missing JSON files and assert the contracts: ```ts import { describe, expect, it } from "vitest" import categories from "@/data/categories.json" import links from "@/data/links.json" import settings from "@/data/settings.json" import { SEARCH_PROVIDERS } from "@/types/search" const hasUniqueValues = (values: string[]) => new Set(values).size === values.length describe("Polaris 静态数据", () => { it("包含 7 个唯一且结构完整的分类", () => { expect(categories).toHaveLength(7) expect(hasUniqueValues(categories.map(({ id }) => id))).toBe(true) for (const category of categories) { expect(category).toEqual({ id: expect.any(String), title: expect.any(String), description: expect.any(String), icon: expect.any(String), sort: expect.any(Number), }) } }) it("包含 20 条合法链接且分类引用均存在", () => { const categoryIds = new Set(categories.map(({ id }) => id)) expect(links).toHaveLength(20) expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true) for (const link of links) { expect(() => new URL(link.url)).not.toThrow() expect(categoryIds.has(link.categoryId)).toBe(true) expect(Array.isArray(link.keywords)).toBe(true) expect(Number.isFinite(link.sort)).toBe(true) } }) it("8 条常用链接拥有唯一 pinnedSort", () => { const pinned = links.filter(({ pinned }) => pinned) const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort) expect(pinned).toHaveLength(8) expect(pinnedSorts.every(Number.isFinite)).toBe(true) expect(new Set(pinnedSorts).size).toBe(8) }) it("搜索设置只使用受支持的 Provider", () => { expect(SEARCH_PROVIDERS).toContain(settings.search.defaultProvider) expect(settings.search.enabledProviders).toEqual([...SEARCH_PROVIDERS]) }) }) ``` - [ ] **Step 3: Run the data test and verify the expected failure** Run: ```bash pnpm test -- src/data/data.test.ts ``` Expected: FAIL because `categories.json`, `links.json`, and `settings.json` do not exist. - [ ] **Step 4: Create categories and settings data** Create `src/data/categories.json` with this exact order and content: ```json [ { "id": "ai", "title": "AI 工具", "description": "精选常用 AI 工具与服务,辅助创作、开发和效率提升。", "icon": "sparkles", "sort": 10 }, { "id": "dev", "title": "开发工具", "description": "高效的开发工具和资源集合,提升你的开发体验和效率。", "icon": "code", "sort": 20 }, { "id": "design", "title": "设计资源", "description": "常用设计工具、素材与灵感资源集合。", "icon": "palette", "sort": 30 }, { "id": "learning", "title": "学习资料", "description": "整理常用学习平台、文档、课程和知识资源。", "icon": "book-open", "sort": 40 }, { "id": "efficiency", "title": "效率工具", "description": "提升日常工作流效率的工具入口。", "icon": "zap", "sort": 50 }, { "id": "media", "title": "娱乐媒体", "description": "常用影音、社区和内容平台。", "icon": "circle-play", "sort": 60 }, { "id": "life", "title": "生活服务", "description": "日常生活、服务查询和实用网站入口。", "icon": "coffee", "sort": 70 } ] ``` Create `src/data/settings.json`: ```json { "siteName": "Polaris", "theme": "light-glass", "search": { "defaultProvider": "google", "enabledProviders": ["google", "bing", "github", "local"] }, "background": { "type": "gradient", "url": null, "blur": 8, "overlay": 0.32 }, "sidebar": { "defaultCollapsed": false } } ``` - [ ] **Step 5: Create all 20 links with separate `sort` and `pinnedSort`** Create `src/data/links.json`. Use the 15 development links exactly as specified in `docs/design/Polaris 分类页设计文档.md`; retain their category `sort` values `10` through `150`. Add these `pinnedSort` values to the three pinned development links: ```text GitHub -> sort 10, pinnedSort 10 Vercel -> sort 100, pinnedSort 70 Figma -> sort 150, pinnedSort 50 ``` Add the five cross-category records: ```json [ { "id": "chatgpt", "title": "ChatGPT", "description": "智能对话与创作助手", "url": "https://chatgpt.com", "icon": "si:openai", "categoryId": "ai", "pinned": true, "pinnedSort": 20, "keywords": ["ai", "人工智能", "对话", "写作"], "sort": 10 }, { "id": "gmail", "title": "Gmail", "description": "Google 电子邮件服务", "url": "https://mail.google.com", "icon": "si:gmail", "categoryId": "efficiency", "pinned": true, "pinnedSort": 30, "keywords": ["邮件", "邮箱", "google", "mail"], "sort": 10 }, { "id": "notion", "title": "Notion", "description": "一体化知识与协作空间", "url": "https://www.notion.so", "icon": "si:notion", "categoryId": "efficiency", "pinned": true, "pinnedSort": 40, "keywords": ["笔记", "知识库", "协作", "文档"], "sort": 20 }, { "id": "youtube", "title": "YouTube", "description": "全球视频内容平台", "url": "https://www.youtube.com", "icon": "si:youtube", "categoryId": "media", "pinned": true, "pinnedSort": 60, "keywords": ["视频", "影音", "直播", "google"], "sort": 10 }, { "id": "bilibili", "title": "Bilibili", "description": "年轻人的视频社区", "url": "https://www.bilibili.com", "icon": "si:bilibili", "categoryId": "media", "pinned": true, "pinnedSort": 80, "keywords": ["哔哩哔哩", "视频", "动漫", "社区"], "sort": 20 } ] ``` The final file must contain one JSON array of 20 objects, not two concatenated arrays. - [ ] **Step 6: Run the data tests and make them pass** Run: ```bash pnpm test -- src/data/data.test.ts ``` Expected: all 4 tests PASS. - [ ] **Step 7: Write failing service behavior tests** Create `src/services/services.test.ts`: ```ts import { describe, expect, it } from "vitest" import { getCategories } from "@/services/categories" import { getLinks, getPinnedLinks } from "@/services/links" import { getSettings } from "@/services/settings" describe("静态数据 service", () => { it("按 sort 返回分类并提供新数组", () => { const first = getCategories() const second = getCategories() expect(first.map(({ id }) => id)).toEqual([ "ai", "dev", "design", "learning", "efficiency", "media", "life", ]) expect(first).not.toBe(second) }) it("按 pinnedSort 返回首页 8 个常用链接", () => { expect(getPinnedLinks().map(({ id }) => id)).toEqual([ "github", "chatgpt", "gmail", "notion", "figma", "youtube", "vercel", "bilibili", ]) }) it("限制常用链接数量且不修改原数据", () => { const allLinks = getLinks() expect(getPinnedLinks(3)).toHaveLength(3) expect(getLinks()).toEqual(allLinks) }) it("返回设置对象的独立副本", () => { expect(getSettings()).not.toBe(getSettings()) }) }) ``` - [ ] **Step 8: Run service tests and verify the expected failure** Run: ```bash pnpm test -- src/services/services.test.ts ``` Expected: FAIL because service modules do not exist. - [ ] **Step 9: Implement service functions** Create `src/services/categories.ts`: ```ts import categoriesData from "@/data/categories.json" import type { Category } from "@/types/navigation" const categories = categoriesData satisfies Category[] export function getCategories(): Category[] { return categories .map((category) => ({ ...category })) .sort((a, b) => a.sort - b.sort) } ``` Create `src/services/links.ts`: ```ts import linksData from "@/data/links.json" import type { NavLink } from "@/types/navigation" const links = linksData satisfies NavLink[] function cloneLink(link: NavLink): NavLink { return { ...link, keywords: [...link.keywords] } } export function getLinks(): NavLink[] { return links.map(cloneLink) } export function getPinnedLinks(limit = 8): NavLink[] { // 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。 return links .filter((link) => link.pinned) .map(cloneLink) .sort( (a, b) => (a.pinnedSort ?? Number.MAX_SAFE_INTEGER) - (b.pinnedSort ?? Number.MAX_SAFE_INTEGER), ) .slice(0, limit) } ``` Create `src/services/settings.ts`: ```ts import settingsData from "@/data/settings.json" import type { AppSettings } from "@/types/settings" // JSON 的联合字面量由数据校验测试兜底,此处收窄为应用设置契约。 const settings = settingsData as AppSettings export function getSettings(): AppSettings { return { ...settings, search: { ...settings.search, enabledProviders: [...settings.search.enabledProviders], }, background: { ...settings.background }, sidebar: { ...settings.sidebar }, } } ``` - [ ] **Step 10: Run service and data tests** Run: ```bash pnpm test -- src/data/data.test.ts src/services/services.test.ts pnpm typecheck ``` Expected: all tests PASS and TypeScript exits `0`. - [ ] **Step 11: Update design documents for `pinnedSort`** Update all moved documents consistently: - `Polaris 项目说明文档.md`: add optional `pinnedSort` to the `NavLink` example and field table; state `sort` is category order and `pinnedSort` is homepage order; add nullable `pinned_sort INTEGER` to the future `links` table. - `Polaris 首页设计文档.md`: change “按 `sort` 排序取前 8 个” to “按 `pinnedSort` 排序取前 8 个”. - `Polaris 分类页设计文档.md`: add `pinnedSort?: number` to the TypeScript type and add `pinnedSort` only to GitHub, Vercel and Figma sample records. Use this exact homepage order in prose: ```text GitHub → ChatGPT → Gmail → Notion → Figma → YouTube → Vercel → Bilibili ``` - [ ] **Step 12: Record an uncommitted checkpoint** Run: ```bash pnpm format pnpm test -- src/data/data.test.ts src/services/services.test.ts git diff --check ``` Expected: PASS; do not commit. --- ### Task 3: Implement deterministic greeting and minute-aligned clock **Files:** - Create: `src/features/home/lib/date-time.ts` - Create: `src/features/home/lib/date-time.test.ts` - Create: `src/features/home/hooks/use-clock.ts` - Create: `src/features/home/components/clock-greeting.tsx` - Create: `src/features/home/components/clock-greeting.test.tsx` **Interfaces:** - Produces: `getGreeting(hour: number): string`. - Produces: `formatTime(date: Date): string`. - Produces: `formatDate(date: Date): string`. - Produces: `getDelayUntilNextMinute(date: Date): number`. - Produces: `` with no props. - Consumes: React client effects. - [ ] **Step 1: Write failing pure-function tests** Create `src/features/home/lib/date-time.test.ts`: ```ts import { describe, expect, it } from "vitest" import { formatDate, formatTime, getDelayUntilNextMinute, getGreeting, } from "./date-time" describe("首页日期与问候", () => { it.each([ [0, "夜深了"], [4, "夜深了"], [5, "早上好"], [11, "早上好"], [12, "中午好"], [13, "中午好"], [14, "下午好"], [17, "下午好"], [18, "晚上好"], [23, "晚上好"], ])("%i 时显示 %s", (hour, expected) => { expect(getGreeting(hour)).toBe(expected) }) it("拒绝无效小时", () => { expect(() => getGreeting(-1)).toThrow("hour must be between 0 and 23") expect(() => getGreeting(24)).toThrow("hour must be between 0 and 23") }) it("格式化中文日期与两位时间", () => { const date = new Date(2026, 7, 20, 9, 6, 30) expect(formatTime(date)).toBe("09:06") expect(formatDate(date)).toBe("2026 年 8 月 20 日 星期四") }) it("计算到下一分钟边界的延迟", () => { expect(getDelayUntilNextMinute(new Date(2026, 7, 20, 9, 6, 30, 250))).toBe( 29_750, ) }) }) ``` - [ ] **Step 2: Run and confirm failure** Run: ```bash pnpm test -- src/features/home/lib/date-time.test.ts ``` Expected: FAIL because `date-time.ts` does not exist. - [ ] **Step 3: Implement date-time pure functions** Create `src/features/home/lib/date-time.ts`: ```ts const WEEKDAYS = [ "星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", ] as const export function getGreeting(hour: number): string { if (!Number.isInteger(hour) || hour < 0 || hour > 23) { throw new RangeError("hour must be between 0 and 23") } if (hour < 5) return "夜深了" if (hour < 12) return "早上好" if (hour < 14) return "中午好" if (hour < 18) return "下午好" return "晚上好" } export function formatTime(date: Date): string { const hours = String(date.getHours()).padStart(2, "0") const minutes = String(date.getMinutes()).padStart(2, "0") return `${hours}:${minutes}` } export function formatDate(date: Date): string { return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${WEEKDAYS[date.getDay()]}` } export function getDelayUntilNextMinute(date: Date): number { return 60_000 - (date.getSeconds() * 1_000 + date.getMilliseconds()) } ``` - [ ] **Step 4: Run pure-function tests** Run: ```bash pnpm test -- src/features/home/lib/date-time.test.ts ``` Expected: all tests PASS. - [ ] **Step 5: Write the failing ClockGreeting component test** Create `src/features/home/components/clock-greeting.test.tsx`: ```tsx import { act, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ClockGreeting } from "./clock-greeting" describe("ClockGreeting", () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 7, 20, 21, 56, 30)) }) afterEach(() => { vi.useRealTimers() }) it("挂载后显示当前问候、时间和日期", () => { render() expect(screen.getByRole("timer")).toHaveTextContent("晚上好,21:56") expect(screen.getByText("2026 年 8 月 20 日 星期四")).toBeInTheDocument() }) it("在下一分钟边界刷新", () => { render() act(() => { vi.advanceTimersByTime(30_000) }) expect(screen.getByRole("timer")).toHaveTextContent("21:57") }) }) ``` - [ ] **Step 6: Run and confirm component test failure** Run: ```bash pnpm test -- src/features/home/components/clock-greeting.test.tsx ``` Expected: FAIL because `ClockGreeting` does not exist. - [ ] **Step 7: Implement a minute-aligned clock hook and component** Create `src/features/home/hooks/use-clock.ts`: ```ts "use client" import { useEffect, useState } from "react" import { getDelayUntilNextMinute } from "@/features/home/lib/date-time" export function useClock(): Date | null { const [now, setNow] = useState(null) useEffect(() => { let intervalId: number | undefined const current = new Date() setNow(current) // 先对齐真实分钟边界,再每分钟更新,避免从挂载时刻累计漂移。 const timeoutId = window.setTimeout(() => { setNow(new Date()) intervalId = window.setInterval(() => setNow(new Date()), 60_000) }, getDelayUntilNextMinute(current)) return () => { window.clearTimeout(timeoutId) if (intervalId !== undefined) window.clearInterval(intervalId) } }, []) return now } ``` Create `src/features/home/components/clock-greeting.tsx`: ```tsx "use client" import { useClock } from "@/features/home/hooks/use-clock" import { formatDate, formatTime, getGreeting, } from "@/features/home/lib/date-time" export function ClockGreeting() { const now = useClock() if (!now) { return (

正在同步时间…

) } return (

{getGreeting(now.getHours())},{formatTime(now)}

{formatDate(now)}

) } ``` - [ ] **Step 8: Run clock tests and checkpoint** Run: ```bash pnpm test -- src/features/home/lib/date-time.test.ts src/features/home/components/clock-greeting.test.tsx pnpm typecheck git diff --check ``` Expected: PASS; do not commit. --- ### Task 4: Implement external search URLs and deterministic Local ranking **Files:** - Create: `src/features/home/lib/search-providers.ts` - Create: `src/features/home/lib/search-providers.test.ts` - Create: `src/features/home/lib/local-search.ts` - Create: `src/features/home/lib/local-search.test.ts` - Create: `src/features/home/lib/provider-preference.ts` - Create: `src/features/home/lib/provider-preference.test.ts` **Interfaces:** - Produces: `isSearchProvider(value: unknown): value is SearchProvider`. - Produces: `buildSearchUrl(provider, query): string | null`. - Produces: `searchLocalLinks(query, links, categories, limit?): LocalSearchResult[]`. - Produces: `parseStoredProvider(value, fallback): SearchProvider`. - Consumes: types and data contracts from Task 2. - [ ] **Step 1: Write failing external Provider tests** Create `src/features/home/lib/search-providers.test.ts`: ```ts import { describe, expect, it } from "vitest" import { buildSearchUrl, isSearchProvider } from "./search-providers" describe("外部搜索 Provider", () => { it.each([ ["google", "https://www.google.com/search"], ["bing", "https://www.bing.com/search"], ["github", "https://github.com/search"], ] as const)("为 %s 构建正确 URL", (provider, expectedBase) => { const result = buildSearchUrl(provider, "Next.js 中文") const url = new URL(result!) expect(`${url.origin}${url.pathname}`).toBe(expectedBase) expect(url.searchParams.get("q")).toBe("Next.js 中文") }) it("空白查询不生成 URL", () => { expect(buildSearchUrl("google", " ")).toBeNull() }) it("识别合法 Provider", () => { expect(isSearchProvider("local")).toBe(true) expect(isSearchProvider("duckduckgo")).toBe(false) expect(isSearchProvider(null)).toBe(false) }) }) ``` - [ ] **Step 2: Write failing Local search tests** Create `src/features/home/lib/local-search.test.ts` with compact fixtures and these assertions: ```ts import { describe, expect, it } from "vitest" import type { Category, NavLink } from "@/types/navigation" import { searchLocalLinks } from "./local-search" const categories: Category[] = [ { id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 }, { id: "ai", title: "AI 工具", description: "", icon: "sparkles", sort: 20 }, ] const links: NavLink[] = [ { id: "github", title: "GitHub", description: "代码托管平台", url: "https://github.com", icon: "si:github", categoryId: "dev", pinned: true, pinnedSort: 10, keywords: ["git", "仓库"], sort: 20, }, { id: "gitlab", title: "GitLab", description: "DevOps 平台", url: "https://gitlab.com", icon: "si:gitlab", categoryId: "dev", pinned: false, keywords: ["git", "ci"], sort: 10, }, { id: "chatgpt", title: "ChatGPT", description: "智能对话与写作", url: "https://chatgpt.com", icon: "si:openai", categoryId: "ai", pinned: true, pinnedSort: 20, keywords: ["人工智能", "写作"], sort: 10, }, ] describe("Local 搜索", () => { it("标题完全匹配优先于标题包含和关键词", () => { expect(searchLocalLinks("github", links, categories)[0].link.id).toBe( "github", ) }) it("匹配关键词、描述、分类标题和分类 ID", () => { expect(searchLocalLinks("人工智能", links, categories)[0].link.id).toBe( "chatgpt", ) expect(searchLocalLinks("DevOps", links, categories)[0].link.id).toBe( "gitlab", ) expect( searchLocalLinks("开发工具", links, categories).map( ({ link }) => link.id, ), ).toEqual(["gitlab", "github"]) expect(searchLocalLinks("ai", links, categories)[0].link.id).toBe("chatgpt") }) it("同分时按 sort 再按标题稳定排序", () => { expect( searchLocalLinks("git", links, categories).map(({ link }) => link.id), ).toEqual(["gitlab", "github"]) }) it("规范化大小写和首尾空白并限制结果数量", () => { expect(searchLocalLinks(" GITHUB ", links, categories, 1)).toHaveLength(1) }) it("空查询返回空数组", () => { expect(searchLocalLinks(" ", links, categories)).toEqual([]) }) }) ``` - [ ] **Step 3: Run search tests and confirm failure** Run: ```bash pnpm test -- src/features/home/lib/search-providers.test.ts src/features/home/lib/local-search.test.ts ``` Expected: FAIL because both implementation modules are missing. - [ ] **Step 4: Implement external Provider helpers** Create `src/features/home/lib/search-providers.ts`: ```ts import { SEARCH_PROVIDERS, type ExternalSearchProvider, type SearchProvider, } from "@/types/search" const SEARCH_ENDPOINTS: Record = { google: "https://www.google.com/search", bing: "https://www.bing.com/search", github: "https://github.com/search", } export function isSearchProvider(value: unknown): value is SearchProvider { return ( typeof value === "string" && SEARCH_PROVIDERS.includes(value as SearchProvider) ) } export function buildSearchUrl( provider: ExternalSearchProvider, query: string, ): string | null { const normalizedQuery = query.trim() if (!normalizedQuery) return null const url = new URL(SEARCH_ENDPOINTS[provider]) url.searchParams.set("q", normalizedQuery) return url.toString() } ``` - [ ] **Step 5: Implement weighted Local search** Create `src/features/home/lib/local-search.ts`: ```ts import type { Category, NavLink } from "@/types/navigation" import type { LocalSearchResult } from "@/types/search" function normalize(value: string): string { return value.normalize("NFKC").trim().toLocaleLowerCase("zh-CN") } function textScore( value: string, query: string, exact: number, prefix: number, contains: number, ): number { if (value === query) return exact if (value.startsWith(query)) return prefix if (value.includes(query)) return contains return 0 } function scoreLink( link: NavLink, category: Category | undefined, query: string, ): number { const title = normalize(link.title) const keywordScore = Math.max( 0, ...link.keywords.map((keyword) => textScore(normalize(keyword), query, 300, 260, 240), ), ) // 各区间不重叠,保证“标题 > 关键词 > 描述 > 分类”的规则不会被字段数量反转。 return Math.max( textScore(title, query, 500, 400, 350), keywordScore, normalize(link.description).includes(query) ? 180 : 0, category ? textScore(normalize(category.title), query, 140, 130, 120) : 0, category ? textScore(normalize(category.id), query, 100, 90, 80) : 0, ) } export function searchLocalLinks( query: string, links: NavLink[], categories: Category[], limit = 8, ): LocalSearchResult[] { const normalizedQuery = normalize(query) if (!normalizedQuery || limit <= 0) return [] const categoryById = new Map( categories.map((category) => [category.id, category]), ) return links .map((link) => { const category = categoryById.get(link.categoryId) return { link, categoryTitle: category?.title ?? "未分类", score: scoreLink(link, category, normalizedQuery), } }) .filter(({ score }) => score > 0) .sort( (a, b) => b.score - a.score || a.link.sort - b.link.sort || a.link.title.localeCompare(b.link.title, "zh-CN"), ) .slice(0, limit) } ``` - [ ] **Step 6: Run pure search tests** Run: ```bash pnpm test -- src/features/home/lib/search-providers.test.ts src/features/home/lib/local-search.test.ts ``` Expected: all tests PASS. - [ ] **Step 7: Test and implement persisted Provider parsing** Create `src/features/home/lib/provider-preference.test.ts`: ```ts import { describe, expect, it } from "vitest" import { parseStoredProvider } from "./provider-preference" describe("Provider 持久化值", () => { it("返回合法存储值", () => { expect(parseStoredProvider("local", "google")).toBe("local") }) it("非法或空值回退默认值", () => { expect(parseStoredProvider("duckduckgo", "google")).toBe("google") expect(parseStoredProvider(null, "bing")).toBe("bing") }) }) ``` Run and expect FAIL: ```bash pnpm test -- src/features/home/lib/provider-preference.test.ts ``` Create `src/features/home/lib/provider-preference.ts`: ```ts import { isSearchProvider } from "@/features/home/lib/search-providers" import type { SearchProvider } from "@/types/search" export const SEARCH_PROVIDER_STORAGE_KEY = "polaris.search.provider" export function parseStoredProvider( value: string | null, fallback: SearchProvider, ): SearchProvider { return isSearchProvider(value) ? value : fallback } ``` Run: ```bash pnpm test -- src/features/home/lib/provider-preference.test.ts ``` Expected: PASS. - [ ] **Step 8: Record an uncommitted checkpoint** Run: ```bash pnpm format pnpm test -- src/features/home/lib pnpm typecheck git diff --check ``` Expected: PASS; do not commit. --- ### Task 5: Implement the shared glass primitive and icon fallback chain **Files:** - Create: `src/components/shared/glass-panel.tsx` - Create: `src/components/shared/icon-candidates.ts` - Create: `src/components/shared/icon-candidates.test.ts` - Create: `src/components/shared/simple-icon-map.ts` - Create: `src/components/shared/site-icon.tsx` - Create: `src/components/shared/site-icon.test.tsx` **Interfaces:** - Produces: ``. - Produces: `getIconCandidates(link): IconCandidate[]`. - Produces: ``. - Consumes: `NavLink`, `simple-icons`, and `cn()`. - [ ] **Step 1: Write failing icon candidate tests** Create `src/components/shared/icon-candidates.test.ts`: ```ts import { describe, expect, it } from "vitest" import type { NavLink } from "@/types/navigation" import { getIconCandidates } from "./icon-candidates" const baseLink: NavLink = { id: "example", title: "Example", description: "Example site", url: "https://example.com/path", categoryId: "dev", pinned: false, keywords: [], sort: 10, } describe("站点图标候选链", () => { it("local 图标后依次回退 favicon 和文字", () => { expect(getIconCandidates({ ...baseLink, icon: "local:custom" })).toEqual([ { kind: "image", src: "/icons/custom.svg" }, { kind: "image", src: "https://example.com/favicon.ico" }, { kind: "initial", value: "E" }, ]) }) it("已知 simple icon 优先使用本地 SVG 数据", () => { expect( getIconCandidates({ ...baseLink, icon: "si:github" })[0], ).toMatchObject({ kind: "simple", slug: "github", }) }) it("未知 simple icon 跳过无效映射", () => { expect(getIconCandidates({ ...baseLink, icon: "si:not-real" })[0]).toEqual({ kind: "image", src: "https://example.com/favicon.ico", }) }) it("显式 URL 图标失败后仍回退 favicon 和文字", () => { expect( getIconCandidates({ ...baseLink, icon: "url:https://cdn.example.com/icon.png", }), ).toEqual([ { kind: "image", src: "https://cdn.example.com/icon.png" }, { kind: "image", src: "https://example.com/favicon.ico" }, { kind: "initial", value: "E" }, ]) }) }) ``` - [ ] **Step 2: Run and confirm failure** Run: ```bash pnpm test -- src/components/shared/icon-candidates.test.ts ``` Expected: FAIL because the module does not exist. - [ ] **Step 3: Create an explicit tree-shakeable Simple Icons map** Create `src/components/shared/simple-icon-map.ts` importing these exact named exports from `simple-icons`: ```ts import { siBilibili, siBitbucket, siDocker, siFigma, siGithub, siGitlab, siGmail, siGoland, siJetbrains, siKubernetes, siNetlify, siNotion, siNpm, siOpenai, siPnpm, siPostman, siRender, siVercel, siVisualstudiocode, siYoutube, type SimpleIcon, } from "simple-icons" export const SIMPLE_ICON_MAP: Readonly> = { bilibili: siBilibili, bitbucket: siBitbucket, docker: siDocker, figma: siFigma, github: siGithub, gitlab: siGitlab, gmail: siGmail, goland: siGoland, jetbrains: siJetbrains, kubernetes: siKubernetes, netlify: siNetlify, notion: siNotion, npm: siNpm, openai: siOpenai, pnpm: siPnpm, postman: siPostman, render: siRender, vercel: siVercel, vscode: siVisualstudiocode, youtube: siYoutube, } ``` - [ ] **Step 4: Implement the pure candidate chain** Create `src/components/shared/icon-candidates.ts`: ```ts import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map" import type { NavLink } from "@/types/navigation" export type IconCandidate = | { kind: "simple"; slug: string } | { kind: "image"; src: string } | { kind: "initial"; value: string } function getFaviconUrl(url: string): string | null { try { return new URL("/favicon.ico", url).toString() } catch { return null } } export function getIconCandidates( link: Pick, ): IconCandidate[] { const candidates: IconCandidate[] = [] const icon = link.icon?.trim() if (icon?.startsWith("local:")) { const name = icon.slice("local:".length) if (name) candidates.push({ kind: "image", src: `/icons/${name}.svg` }) } else if (icon?.startsWith("si:")) { const slug = icon.slice("si:".length).toLowerCase() if (SIMPLE_ICON_MAP[slug]) candidates.push({ kind: "simple", slug }) } else if (icon?.startsWith("url:")) { const src = icon.slice("url:".length) if (src) candidates.push({ kind: "image", src }) } const favicon = getFaviconUrl(link.url) if ( favicon && !candidates.some( (candidate) => candidate.kind === "image" && candidate.src === favicon, ) ) { candidates.push({ kind: "image", src: favicon }) } candidates.push({ kind: "initial", value: link.title.trim().charAt(0).toUpperCase() || "?", }) return candidates } ``` - [ ] **Step 5: Run candidate tests** Run: ```bash pnpm test -- src/components/shared/icon-candidates.test.ts ``` Expected: PASS. - [ ] **Step 6: Write failing SiteIcon component test** Create `src/components/shared/site-icon.test.tsx`: ```tsx import { fireEvent, render, screen } from "@testing-library/react" import { describe, expect, it } from "vitest" import { SiteIcon } from "./site-icon" describe("SiteIcon", () => { it("渲染已知 Simple Icon", () => { const { container } = render( , ) expect(container.querySelector("svg")).toBeInTheDocument() }) it("远程图片连续失败后显示首字符", () => { render( , ) fireEvent.error(screen.getByRole("img", { name: "Example 图标" })) fireEvent.error(screen.getByRole("img", { name: "Example 图标" })) expect(screen.getByText("E")).toBeInTheDocument() }) }) ``` - [ ] **Step 7: Implement SiteIcon and GlassPanel** Create `src/components/shared/site-icon.tsx`: ```tsx "use client" /* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */ import { useEffect, useMemo, useState } from "react" import { getIconCandidates } from "@/components/shared/icon-candidates" import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map" import { cn } from "@/lib/utils" import type { NavLink } from "@/types/navigation" type SiteIconProps = { link: Pick size?: number className?: string } export function SiteIcon({ link, size = 36, className }: SiteIconProps) { const candidates = useMemo( () => getIconCandidates(link), [link.icon, link.title, link.url], ) const [candidateIndex, setCandidateIndex] = useState(0) useEffect(() => setCandidateIndex(0), [candidates]) const candidate = candidates[candidateIndex] const wrapperClassName = cn("site-icon", className) const style = { width: size, height: size } if (candidate.kind === "simple") { const icon = SIMPLE_ICON_MAP[candidate.slug] return ( ) } if (candidate.kind === "image") { return ( {`${link.title} setCandidateIndex((index) => Math.min(index + 1, candidates.length - 1), ) } /> ) } return ( ) } ``` Create `src/components/shared/glass-panel.tsx`: ```tsx import { createElement, type ComponentPropsWithoutRef } from "react" import { cn } from "@/lib/utils" type GlassPanelProps = ComponentPropsWithoutRef<"div"> & { as?: "div" | "section" | "aside" } export function GlassPanel({ as = "div", className, ...props }: GlassPanelProps) { return createElement(as, { className: cn("glass-panel", className), ...props, }) } ``` - [ ] **Step 8: Run shared component checks** Run: ```bash pnpm test -- src/components/shared pnpm typecheck pnpm lint ``` Expected: PASS. - [ ] **Step 9: Record an uncommitted checkpoint** Run `git diff --check`; expected PASS. Do not commit. --- ### Task 6: Implement the persistent desktop navigation shell **Files:** - Create: `src/features/navigation/lib/sidebar-preference.ts` - Create: `src/features/navigation/lib/sidebar-preference.test.ts` - Create: `src/features/navigation/components/category-icon.tsx` - Create: `src/features/navigation/components/sidebar.tsx` - Create: `src/features/navigation/components/app-shell.tsx` - Create: `src/features/navigation/components/app-shell.test.tsx` **Interfaces:** - Produces: `parseSidebarPreference(value, fallback): boolean`. - Produces: `AppShell({ categories, siteName, defaultCollapsed, children })`. - Consumes: `Category[]`, shadcn Button/Tooltip, lucide icons, `/brand/logo.svg`. - [ ] **Step 1: Test sidebar preference parsing** Create `src/features/navigation/lib/sidebar-preference.test.ts`: ```ts import { describe, expect, it } from "vitest" import { parseSidebarPreference } from "./sidebar-preference" describe("侧栏持久化值", () => { it("解析显式布尔字符串", () => { expect(parseSidebarPreference("true", false)).toBe(true) expect(parseSidebarPreference("false", true)).toBe(false) }) it("损坏或缺失值回退设置默认值", () => { expect(parseSidebarPreference("collapsed", false)).toBe(false) expect(parseSidebarPreference(null, true)).toBe(true) }) }) ``` Run and expect FAIL: ```bash pnpm test -- src/features/navigation/lib/sidebar-preference.test.ts ``` Create `src/features/navigation/lib/sidebar-preference.ts`: ```ts export const SIDEBAR_STORAGE_KEY = "polaris.sidebar.collapsed" export function parseSidebarPreference( value: string | null, fallback: boolean, ): boolean { if (value === "true") return true if (value === "false") return false return fallback } ``` Run the test again; expected PASS. - [ ] **Step 2: Write failing AppShell interaction tests** Create `src/features/navigation/components/app-shell.test.tsx`: ```tsx import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it } from "vitest" import type { Category } from "@/types/navigation" import { AppShell } from "./app-shell" const categories: Category[] = [ { id: "dev", title: "开发工具", description: "开发", icon: "code", sort: 10 }, ] describe("AppShell", () => { beforeEach(() => window.localStorage.clear()) it("切换侧栏并持久化状态", async () => { render(
首页内容
, ) const toggle = screen.getByRole("button", { name: "折叠侧边栏" }) fireEvent.click(toggle) expect(screen.getByTestId("app-shell")).toHaveAttribute( "data-sidebar-collapsed", "true", ) expect(window.localStorage.getItem("polaris.sidebar.collapsed")).toBe( "true", ) }) it("hydration 后恢复持久化状态", async () => { window.localStorage.setItem("polaris.sidebar.collapsed", "true") render(
首页内容
, ) await waitFor(() => { expect(screen.getByTestId("app-shell")).toHaveAttribute( "data-sidebar-collapsed", "true", ) }) }) it("分类项可聚焦但保持禁用", () => { render(
首页内容
, ) expect( screen.getByRole("button", { name: "开发工具,分类页将在下一阶段提供" }), ).toHaveAttribute("aria-disabled", "true") expect( screen.queryByRole("link", { name: /开发工具/ }), ).not.toBeInTheDocument() }) }) ``` - [ ] **Step 3: Run and confirm AppShell failure** Run: ```bash pnpm test -- src/features/navigation/components/app-shell.test.tsx ``` Expected: FAIL because `AppShell` does not exist. - [ ] **Step 4: Implement the category icon whitelist** Create `src/features/navigation/components/category-icon.tsx`: ```tsx import { BookOpen, CirclePlay, Code2, Coffee, Palette, Sparkles, Zap, type LucideIcon, } from "lucide-react" const CATEGORY_ICONS: Readonly> = { sparkles: Sparkles, code: Code2, palette: Palette, "book-open": BookOpen, zap: Zap, "circle-play": CirclePlay, coffee: Coffee, } export function CategoryIcon({ name }: { name: string }) { const Icon = CATEGORY_ICONS[name] ?? Sparkles return