# 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 (
setCandidateIndex((index) =>
Math.min(index + 1, candidates.length - 1),
)
}
/>
)
}
return (
{candidate.value}
)
}
```
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
}
```
- [ ] **Step 5: Implement Sidebar with temporarily disabled categories**
Create `src/features/navigation/components/sidebar.tsx`. It must:
- use `next/image` for `/brand/logo.svg` with `width={40}` and `height={40}`;
- use `Home` for the active home item;
- use `PanelLeftClose` when expanded and `PanelLeftOpen` when collapsed;
- render category items as `button type="button" aria-disabled="true"`, never as links;
- wrap each category in shadcn Tooltip with the exact content `分类页将在下一阶段提供`;
- preserve each category title in `aria-label` while visually hiding text in collapsed mode;
- expose `onToggle` and `collapsed` props;
- give the toggle exact accessible labels `折叠侧边栏` or `展开侧边栏`.
Use this prop contract:
```ts
type SidebarProps = {
categories: Category[]
siteName: string
collapsed: boolean
onToggle: () => void
}
```
Add this comment above disabled categories:
```ts
// 本次只验收首页;分类页完成前保持可见但禁用,避免用户进入 404。
```
- [ ] **Step 6: Implement AppShell persistence and hydration guard**
Create `src/features/navigation/components/app-shell.tsx`:
```tsx
"use client"
import { useEffect, useState } from "react"
import { Sidebar } from "@/features/navigation/components/sidebar"
import {
parseSidebarPreference,
SIDEBAR_STORAGE_KEY,
} from "@/features/navigation/lib/sidebar-preference"
import type { Category } from "@/types/navigation"
type AppShellProps = {
categories: Category[]
siteName: string
defaultCollapsed: boolean
children: React.ReactNode
}
export function AppShell({
categories,
siteName,
defaultCollapsed,
children,
}: AppShellProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
// Server 首屏使用配置默认值;挂载后再读取浏览器偏好,避免 hydration mismatch。
try {
setCollapsed(
parseSidebarPreference(
window.localStorage.getItem(SIDEBAR_STORAGE_KEY),
defaultCollapsed,
),
)
} catch {
setCollapsed(defaultCollapsed)
} finally {
setHydrated(true)
}
}, [defaultCollapsed])
const toggleSidebar = () => {
setCollapsed((current) => {
const next = !current
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next))
} catch {
// 存储不可用时仍保留当前会话内交互,不让隐私模式破坏页面。
}
return next
})
}
return (
)
}
```
- [ ] **Step 7: Run navigation tests**
Run:
```bash
pnpm test -- src/features/navigation
pnpm typecheck
pnpm lint
```
Expected: PASS. If Radix Tooltip schedules animation frames in tests, wrap expectations in `waitFor`; do not remove Tooltip semantics.
- [ ] **Step 8: Record an uncommitted checkpoint**
Run `git diff --check`; expected PASS. Do not commit.
---
### Task 7: Implement the complete SearchBox interaction
**Files:**
- Create: `src/features/home/components/search-provider-menu.tsx`
- Create: `src/features/home/components/local-search-results.tsx`
- Create: `src/features/home/components/search-box.tsx`
- Create: `src/features/home/components/search-box.test.tsx`
**Interfaces:**
- Produces: ` `.
- Consumes: `buildSearchUrl`, `searchLocalLinks`, Provider preference helpers, `SiteIcon`, shadcn DropdownMenu.
- External navigation test seam: `onExternalNavigate?: (url: string) => void`; production default calls `window.location.assign(url)`.
- [ ] **Step 1: Write comprehensive failing SearchBox tests**
Create `src/features/home/components/search-box.test.tsx`. Use fixtures from Task 4 and include these tests:
```tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { SearchBox } from "./search-box"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 10,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 20,
},
]
function renderSearchBox(
overrides: Partial> = {},
) {
const onExternalNavigate = vi.fn()
render(
,
)
return { onExternalNavigate }
}
describe("SearchBox", () => {
beforeEach(() => window.localStorage.clear())
it("挂载后自动聚焦,并响应 Ctrl+K 与 Meta+K", async () => {
renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await waitFor(() => expect(input).toHaveFocus())
input.blur()
fireEvent.keyDown(window, { key: "k", ctrlKey: true })
expect(input).toHaveFocus()
input.blur()
fireEvent.keyDown(window, { key: "k", metaKey: true })
expect(input).toHaveFocus()
})
it("Google 模式回车只执行当前页外部导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await user.type(input, "Next.js 中文{Enter}")
const url = new URL(onExternalNavigate.mock.calls[0][0])
expect(url.hostname).toBe("www.google.com")
expect(url.searchParams.get("q")).toBe("Next.js 中文")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("空白查询不导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
await user.type(screen.getByRole("combobox"), " {Enter}")
expect(onExternalNavigate).not.toHaveBeenCalled()
})
it("切换 Local 后实时显示结果并持久化 Provider", async () => {
const user = userEvent.setup()
renderSearchBox()
await user.click(
screen.getByRole("button", { name: "选择搜索方式,当前为 Google" }),
)
await user.click(screen.getByRole("menuitem", { name: "本地" }))
await user.type(screen.getByRole("combobox"), "git")
expect(screen.getByRole("listbox")).toBeInTheDocument()
expect(screen.getAllByRole("option")).toHaveLength(2)
expect(window.localStorage.getItem("polaris.search.provider")).toBe("local")
})
it("Local 模式支持上下选择、Enter 与 Esc", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
const input = screen.getByRole("combobox")
await user.type(input, "git")
expect(screen.getAllByRole("option")[0]).toHaveAttribute(
"aria-selected",
"true",
)
await user.keyboard("{ArrowDown}")
expect(screen.getAllByRole("option")[1]).toHaveAttribute(
"aria-selected",
"true",
)
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {})
await user.keyboard("{Enter}")
expect(clickSpy).toHaveBeenCalledOnce()
clickSpy.mockRestore()
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("Local 无结果显示空状态", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
await user.type(screen.getByRole("combobox"), "不存在的网站")
expect(screen.getByText("没有匹配的网站")).toBeInTheDocument()
})
it("恢复合法 Provider,非法值回退默认 Provider", async () => {
window.localStorage.setItem("polaris.search.provider", "local")
const { unmount } = render(
,
)
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 本地/ }),
).toBeInTheDocument(),
)
unmount()
window.localStorage.setItem("polaris.search.provider", "invalid")
renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
})
})
```
- [ ] **Step 2: Run and confirm SearchBox failure**
Run:
```bash
pnpm test -- src/features/home/components/search-box.test.tsx
```
Expected: FAIL because `SearchBox` does not exist.
- [ ] **Step 3: Implement Provider metadata and menu**
In `search-provider-menu.tsx`, define exact labels and icons:
```ts
const PROVIDER_LABELS: Record = {
google: "Google",
bing: "Bing",
github: "GitHub",
local: "本地",
}
```
Use lucide `Globe2`, `Search`, `Github`, and `Database`; use shadcn `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuRadioGroup`, and `DropdownMenuRadioItem`.
Prop contract:
```ts
type SearchProviderMenuProps = {
provider: SearchProvider
enabledProviders: SearchProvider[]
onProviderChange: (provider: SearchProvider) => void
}
```
The trigger accessible name must be:
```text
选择搜索方式,当前为 {Provider label}
```
Each radio item visible label must be the exact value in `PROVIDER_LABELS`.
- [ ] **Step 4: Implement LocalSearchResults as a semantic listbox**
Prop contract:
```ts
type LocalSearchResultsProps = {
listboxId: string
results: LocalSearchResult[]
activeIndex: number
optionRefs: React.MutableRefObject>
onActiveIndexChange: (index: number) => void
}
```
Requirements:
- panel uses `role="listbox"` and `id={listboxId}`;
- every result is ``;
- ID is `${listboxId}-option-${index}`;
- `aria-selected={index === activeIndex}`;
- mouse move updates active index without navigation;
- render `SiteIcon`, title and category title;
- empty array renders a panel containing `没有匹配的网站`.
- [ ] **Step 5: Implement SearchBox state machine**
Use this prop contract:
```ts
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
```
Implementation requirements:
1. Initial state uses `defaultProvider`, empty query, active index `0`.
2. Mount effect:
- focuses input;
- reads `SEARCH_PROVIDER_STORAGE_KEY` in `try/catch`;
- applies `parseStoredProvider` only if the result is enabled.
3. Global key listener focuses input on `(ctrlKey || metaKey) && key.toLowerCase() === "k"` and calls `preventDefault()`.
4. Provider change persists inside `try/catch`, resets active index, and never opens Local results for external providers.
5. `results` is memoized from `searchLocalLinks(query, links, categories, 8)` only in Local mode.
6. External submit calls `buildSearchUrl` and then `onExternalNavigate ?? ((url) => window.location.assign(url))`.
7. Local `ArrowDown`/`ArrowUp` wraps around the result length.
8. Local `Enter` calls `.click()` on the selected result anchor ref.
9. `Escape` clears query and resets active index.
10. Input uses `role="combobox"`, exact accessible label `搜索网站、书签或关键词`, placeholder `搜索网站、书签或关键词...`, and correct `aria-expanded`, `aria-controls`, `aria-activedescendant`, `aria-autocomplete="list"`.
11. Platform hint uses `⌘K` when `navigator.userAgent` contains `Mac`, otherwise `Ctrl K`; detect after mount so SSR and hydration remain stable.
Add concise Chinese comments only around strict Provider separation, Local keyboard delegation to the real anchor, and hydration-safe preference restoration.
- [ ] **Step 6: Run SearchBox tests and fix only contract mismatches**
Run:
```bash
pnpm test -- src/features/home/components/search-box.test.tsx
```
Expected: all tests PASS. Do not weaken accessible names or replace real Local anchors with click-only `div` elements.
- [ ] **Step 7: Run the full home-domain suite**
Run:
```bash
pnpm test -- src/features/home
pnpm typecheck
pnpm lint
```
Expected: PASS.
- [ ] **Step 8: Record an uncommitted checkpoint**
Run `git diff --check`; expected PASS. Do not commit.
---
### Task 8: Compose quick links and the Server Component homepage
**Files:**
- 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`
- Modify: `src/app/page.tsx`
- Modify: `src/app/layout.tsx`
**Interfaces:**
- Produces: ` `.
- Produces: ` `.
- Consumes: services from Task 2, AppShell from Task 6, and interactive components from Tasks 3 and 7.
- [ ] **Step 1: Write failing QuickLinks tests**
Create `src/features/home/components/quick-links.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { QuickLinks } from "./quick-links"
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: [],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "AI 助手",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: [],
sort: 10,
},
]
describe("QuickLinks", () => {
it("按输入顺序渲染安全的新标签页链接", () => {
render( )
const renderedLinks = screen.getAllByRole("link")
expect(renderedLinks.map((link) => link.textContent)).toEqual([
"GitHub",
"ChatGPT",
])
for (const link of renderedLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
}
})
it("只展示图标和名称,不展示描述", () => {
render( )
expect(screen.queryByText("代码托管")).not.toBeInTheDocument()
expect(screen.queryByText("AI 助手")).not.toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run and confirm QuickLinks failure**
Run:
```bash
pnpm test -- src/features/home/components/quick-links.test.tsx
```
Expected: FAIL because `QuickLinks` does not exist.
- [ ] **Step 3: Implement QuickLinks**
Create `src/features/home/components/quick-links.tsx`:
```tsx
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
export function QuickLinks({ links }: { links: NavLink[] }) {
return (
)
}
```
Run its test; expected PASS.
- [ ] **Step 4: Implement HomeContent composition**
Create `src/features/home/components/home-content.tsx`:
```tsx
import { ClockGreeting } from "@/features/home/components/clock-greeting"
import { QuickLinks } from "@/features/home/components/quick-links"
import { SearchBox } from "@/features/home/components/search-box"
import type { Category, NavLink } from "@/types/navigation"
import type { AppSettings } from "@/types/settings"
type HomeContentProps = {
categories: Category[]
links: NavLink[]
pinnedLinks: NavLink[]
settings: AppSettings
}
export function HomeContent({
categories,
links,
pinnedLinks,
settings,
}: HomeContentProps) {
return (
)
}
```
- [ ] **Step 5: Replace the smoke page with service-backed Server composition**
Replace `src/app/page.tsx`:
```tsx
import { HomeContent } from "@/features/home/components/home-content"
import { AppShell } from "@/features/navigation/components/app-shell"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getSettings } from "@/services/settings"
export default function HomePage() {
const categories = getCategories()
const links = getLinks()
const pinnedLinks = getPinnedLinks()
const settings = getSettings()
return (
)
}
```
This file must not contain `"use client"` and must not import JSON directly.
- [ ] **Step 6: Finalize root metadata and viewport**
In `src/app/layout.tsx`, extend the type import to `import type { Metadata, Viewport } from "next"`, then set:
```ts
export const metadata: Metadata = {
title: "Polaris — 个人导航起始页",
description: "快速搜索并访问常用网站的个人导航起始页。",
}
```
Add:
```ts
export const viewport: Viewport = {
colorScheme: "light",
themeColor: "#dcebf5",
}
```
Keep ``.
- [ ] **Step 7: Run composition checks**
Run:
```bash
pnpm test -- src/features/home/components/quick-links.test.tsx
pnpm typecheck
pnpm lint
pnpm build
```
Expected: PASS and `out/index.html` exists. Do not commit.
---
### Task 9: Apply the Polaris visual system, responsive desktop layout, and accessibility polish
**Files:**
- Modify: `src/app/globals.css`
- Modify: navigation, search, clock, quick-link and shared components only where class names or semantics need alignment
- Test: existing component suites
**Interfaces:**
- Consumes: all rendered class names from Tasks 3, 5, 6, 7 and 8.
- Produces: the final CSS-gradient glass homepage at 1024×768, 1440×900 and 1920×1080.
- [ ] **Step 1: Replace global CSS with semantic Tailwind v4 tokens**
At the top of `src/app/globals.css`, use:
```css
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--text-primary);
--color-primary: var(--primary);
--color-primary-foreground: #ffffff;
--color-muted-foreground: var(--text-secondary);
--color-border: var(--border-subtle);
--radius-lg: var(--radius-card);
--radius-xl: var(--radius-panel);
}
:root {
color-scheme: light;
--background: #dcebf5;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-soft: rgba(59, 130, 246, 0.1);
--surface-sidebar: rgba(255, 255, 255, 0.68);
--surface-search: rgba(255, 255, 255, 0.72);
--surface-card: rgba(255, 255, 255, 0.62);
--surface-card-hover: rgba(255, 255, 255, 0.78);
--border-glass: rgba(255, 255, 255, 0.58);
--border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.1);
--shadow-medium: 0 20px 60px rgba(15, 23, 42, 0.12);
--shadow-panel: 0 24px 80px rgba(15, 23, 42, 0.1);
--radius-card: 20px;
--radius-panel: 24px;
--sidebar-expanded: 240px;
--sidebar-collapsed: 72px;
--layout-gap: 20px;
}
```
- [ ] **Step 2: Implement the CSS-only atmospheric background**
Use exact global foundations:
```css
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
background:
radial-gradient(
circle at 12% 15%,
rgba(255, 245, 239, 0.92) 0,
transparent 34%
),
radial-gradient(
circle at 82% 14%,
rgba(194, 222, 246, 0.92) 0,
transparent 38%
),
radial-gradient(
circle at 48% 82%,
rgba(226, 239, 247, 0.72) 0,
transparent 44%
),
linear-gradient(135deg, #eef5fa 0%, #dceaf4 48%, #cbdff0 100%);
font-family:
Inter,
"SF Pro Display",
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
pointer-events: none;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.08)
),
repeating-linear-gradient(
165deg,
transparent 0 84px,
rgba(255, 255, 255, 0.08) 85px 86px
);
backdrop-filter: blur(8px);
}
button,
input,
a {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.34);
outline-offset: 3px;
}
```
Do not reference `docs/assets/Polaris页面设计图.png` from CSS.
- [ ] **Step 3: Style the shell and sidebar**
Implement these behavior-defining rules; values may only vary by at most 2px during visual tuning:
```css
.app-shell {
--sidebar-width: var(--sidebar-expanded);
min-height: 100svh;
}
.app-shell[data-sidebar-collapsed="true"] {
--sidebar-width: var(--sidebar-collapsed);
}
.app-shell:not([data-hydrated="true"]) *,
.app-shell:not([data-hydrated="true"]) *::before,
.app-shell:not([data-hydrated="true"]) *::after {
transition: none !important;
}
.sidebar {
position: fixed;
inset: 20px auto 20px 20px;
z-index: 20;
width: var(--sidebar-width);
overflow: visible;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(24px);
transition: width 180ms ease;
}
.app-shell__content {
min-height: 100svh;
margin-left: calc(var(--sidebar-width) + 40px);
transition: margin-left 180ms ease;
}
```
Sidebar internal requirements:
- header height `72px`, horizontal padding `16px`;
- logo `40px`; expanded name visible, collapsed name visually hidden;
- nav item height `48px`, radius `16px`, gap `12px`;
- active home background `var(--primary-soft)` and color `var(--primary-hover)`;
- disabled categories retain readable `#334155`, cursor `not-allowed`, opacity at least `0.78`;
- collapsed nav icons stay centered;
- tooltip must remain inside viewport and above the main content.
- [ ] **Step 4: Style the main hierarchy**
Use:
```css
.home-content {
display: grid;
min-height: 100svh;
place-items: start center;
padding: clamp(108px, 14vh, 156px) 40px 64px;
}
.home-content__inner {
width: min(100%, 880px);
}
.clock-greeting {
text-align: center;
}
.clock-greeting__time {
min-height: 1.2em;
margin: 0;
color: var(--text-primary);
font-size: clamp(44px, 4.2vw, 56px);
font-weight: 700;
letter-spacing: -0.035em;
}
.clock-greeting__date {
margin: 12px 0 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.6;
}
```
- [ ] **Step 5: Style SearchBox and Local results**
Required geometry:
- search wrapper width `min(100%, 760px)`, height `68px`, centered, margin-top `40px`;
- surface `var(--surface-search)`, blur `22px`, radius `24px`, `var(--shadow-medium)`;
- Provider trigger min width sufficient for current label; no layout jump between labels;
- input takes remaining width and has no native border/background;
- hint is a muted bordered capsule;
- Local panel top gap `8px`, same width, max height enough for 8 rows with internal scroll;
- row height at least `58px`; selected row uses `var(--primary-soft)`;
- empty state minimum height `96px`;
- Local panel z-index above quick links.
Use `transition` only on background, color, transform, border-color and box-shadow; do not use `transition: all`.
- [ ] **Step 6: Style QuickLinks and SiteIcon**
Required geometry:
```css
.quick-links {
width: min(100%, 880px);
margin-top: 44px;
}
.quick-links__title {
margin: 0 0 18px;
color: var(--text-primary);
font-size: 16px;
font-weight: 650;
}
.quick-links__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px 18px;
}
.quick-link-card {
display: flex;
min-width: 0;
height: 76px;
align-items: center;
gap: 14px;
padding: 0 20px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-glass);
border-radius: var(--radius-card);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(20px);
transition:
transform 180ms ease,
background 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
.quick-link-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.76);
background: var(--surface-card-hover);
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
}
```
`site-icon` must center SVG/image, prevent shrinking, round image corners, and render initials on a light translucent square.
- [ ] **Step 7: Add desktop narrowing and reduced-motion rules**
Use:
```css
@media (max-width: 1199px) {
:root {
--sidebar-expanded: 220px;
}
.home-content {
padding-inline: 28px;
}
.quick-links__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
```
At 1024px, all text and controls must remain visible with no horizontal scroll. Do not add mobile drawer behavior.
- [ ] **Step 8: Run all automated checks after visual changes**
Run:
```bash
pnpm format
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Expected: all commands exit `0`.
- [ ] **Step 9: Record an uncommitted checkpoint**
Run:
```bash
git diff --check
git status --short
```
Expected: all homepage files remain uncommitted; no category route exists.
---
### Task 10: Run real-browser acceptance, reconcile docs, and create the single final commit
**Files:**
- Modify only files needed to fix verified acceptance defects.
- Verify: all files created in Tasks 1–9.
- Commit: entire `feat/polaris-homepage` target after every gate passes.
**Interfaces:**
- Consumes: the complete homepage.
- Produces: verified static build and one local Git commit; no push.
- [ ] **Step 1: Run the final clean automated gate**
Run from `ts/web/nextjs/polaris`:
```bash
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Expected: five commands exit `0`; record test counts and build route summary for the final report.
- [ ] **Step 2: Launch the real application using the project run workflow**
Invoke the `run` skill, then launch `pnpm dev` on an available localhost port. Do not substitute static file inspection for a real rendered browser.
Verify the console contains no hydration warning, uncaught exception, missing asset error, or React key warning.
- [ ] **Step 3: Inspect 1440×900**
Verify:
- sidebar width is 240px and remains 20px from viewport edges;
- logo, title, selected home item and all seven categories are aligned;
- greeting is the first visual focus, search second, quick links third;
- search is centered and approximately 760×68px;
- quick links are 4 columns × 2 rows in the approved order;
- cards and search glass surfaces remain readable over the gradient;
- Local panel aligns with SearchBox and does not push content layout;
- no horizontal or vertical clipping.
Capture a screenshot if the run environment supports it.
- [ ] **Step 4: Inspect 1920×1080**
Verify:
- main content does not stretch beyond 880px;
- background gradients fill the viewport without bitmap scaling artifacts;
- vertical placement stays in the upper-middle region rather than dead center;
- shadows remain subtle and the large empty space feels intentional;
- sidebar height and fixed positioning remain correct.
Capture a screenshot if supported.
- [ ] **Step 5: Inspect 1024×768**
Verify:
- sidebar uses the 220px narrowed desktop width;
- main area has no horizontal overflow;
- search remains fully usable;
- quick links switch to 2 columns;
- all seven categories remain reachable within the viewport;
- no mobile drawer or unintended mobile layout appears.
Capture a screenshot if supported.
- [ ] **Step 6: Exercise every interactive acceptance path**
Verify manually in the real app:
1. Search input auto-focuses on load.
2. `Ctrl+K` or `⌘K` returns focus to search.
3. Provider can switch among all four values and survives refresh.
4. Google/Bing/GitHub submit correctly encoded URLs in the current tab; use a harmless query and return to Polaris after each check.
5. Local results appear only in Local mode.
6. Local `↑`, `↓`, `Enter`, mouse click and `Esc` work.
7. Local no-result text is exact.
8. Sidebar collapses to 72px, expands to 240px, and survives refresh.
9. Classification items show the temporary tooltip and never navigate.
10. Every quick link opens a new tab with the correct URL.
11. No broken brand icon appears; fallback can be verified by temporarily changing one icon in browser devtools only, without committing fixture corruption.
12. Focus rings are visible and reduced-motion mode removes perceptible transitions.
- [ ] **Step 7: Fix verified defects with targeted regression tests**
For each defect:
1. Add or extend the nearest existing test so it fails for the observed behavior.
2. Run that specific test and record the expected failure.
3. Apply the smallest implementation or CSS correction.
4. Re-run the specific test.
5. Re-run `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` after all corrections.
Do not add unrelated features during this step.
- [ ] **Step 8: Verify documentation and scope consistency**
Confirm:
- all three moved design documents link to one another;
- homepage document references `../assets/Polaris页面设计图.png`;
- project document references `../../public/brand/logo.svg`;
- all `NavLink` examples and schema text distinguish `sort` from `pinnedSort`;
- no root-level duplicate docs or assets remain;
- no `src/app/categories/` directory exists;
- implementation spec status remains “用户已批准,可进入实施”.
- [ ] **Step 9: Run repository-level hygiene checks**
Run from repository root:
```bash
git diff --check
git status --short
git diff --stat
```
Inspect all unexpected files before staging. Ensure `.next/`, `out/`, `coverage/`, and `node_modules/` are absent from status.
- [ ] **Step 10: Create the one authorized feature commit**
Stage only the repository ignore update and Polaris project:
```bash
git add .gitignore ts/web/nextjs/polaris
```
Review staged scope:
```bash
git diff --cached --stat
git diff --cached --check
```
Commit without pushing:
```bash
git commit -m "$(cat <<'EOF'
feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context)
EOF
)"
```
Expected: one new commit on `feat/polaris-homepage`; working tree clean.
- [ ] **Step 11: Report evidence and stop at the homepage boundary**
Final report must include:
- commit hash and message;
- dependency/tool versions actually resolved in `package.json`;
- exact results for format, lint, typecheck, test and build;
- visual results at all three viewport sizes;
- interaction checks performed;
- screenshots or their saved locations when available;
- any remaining visual deviation or browser limitation;
- explicit statement that classification pages were not implemented and further first-stage work awaits user approval.