Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 KiB
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— shadcncn()工具。 - 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 tosrc/*. -
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:
git branch --show-current
git status --short
Expected:
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:
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页面设计图.pngand../../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:
{
"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:
pnpm add next@latest react@latest react-dom@latest clsx tailwind-merge lucide-react simple-icons
Install development dependencies:
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:
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
}
export default nextConfig
Create tsconfig.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:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// 该文件由 Next.js 类型系统使用,不应手动加入业务声明。
Create postcss.config.mjs:
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config
- Step 5: Create lint, format and test configuration
Create eslint.config.mjs:
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:
/** @type {import("prettier").Config} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
semi: false,
singleQuote: false,
trailingComma: "all",
}
export default config
Create vitest.config.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:
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:
{
"$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:
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Run:
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:
@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:
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 (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}
Create src/app/page.tsx:
export default function HomePage() {
return <main>Polaris</main>
}
- Step 8: Extend the repository ignore rules
Append under the Node/TypeScript section in repository-root .gitignore:
.next/
out/
coverage/
.env*
!.env.example
- Step 9: Run the foundation smoke checks
Run from ts/web/nextjs/polaris:
pnpm format
pnpm lint
pnpm typecheck
pnpm build
Expected: all commands exit 0; out/index.html exists.
- Step 10: Record an uncommitted checkpoint
Run:
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:
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:
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<SearchProvider, "local">
export type LocalSearchResult = {
link: NavLink
categoryTitle: string
score: number
}
Create src/types/settings.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:
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:
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:
[
{
"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:
{
"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
sortandpinnedSort
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:
GitHub -> sort 10, pinnedSort 10
Vercel -> sort 100, pinnedSort 70
Figma -> sort 150, pinnedSort 50
Add the five cross-category records:
[
{
"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:
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:
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:
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:
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:
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:
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:
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 optionalpinnedSortto theNavLinkexample and field table; statesortis category order andpinnedSortis homepage order; add nullablepinned_sort INTEGERto the futurelinkstable.Polaris 首页设计文档.md: change “按sort排序取前 8 个” to “按pinnedSort排序取前 8 个”.Polaris 分类页设计文档.md: addpinnedSort?: numberto the TypeScript type and addpinnedSortonly to GitHub, Vercel and Figma sample records.
Use this exact homepage order in prose:
GitHub → ChatGPT → Gmail → Notion → Figma → YouTube → Vercel → Bilibili
- Step 12: Record an uncommitted checkpoint
Run:
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:
<ClockGreeting />with no props. -
Consumes: React client effects.
-
Step 1: Write failing pure-function tests
Create src/features/home/lib/date-time.test.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:
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:
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:
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:
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(<ClockGreeting />)
expect(screen.getByRole("timer")).toHaveTextContent("晚上好,21:56")
expect(screen.getByText("2026 年 8 月 20 日 星期四")).toBeInTheDocument()
})
it("在下一分钟边界刷新", () => {
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("21:57")
})
})
- Step 6: Run and confirm component test failure
Run:
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:
"use client"
import { useEffect, useState } from "react"
import { getDelayUntilNextMinute } from "@/features/home/lib/date-time"
export function useClock(): Date | null {
const [now, setNow] = useState<Date | null>(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:
"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 (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer">
正在同步时间…
</p>
<p className="clock-greeting__date" aria-hidden="true">
</p>
</section>
)
}
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer" aria-live="off">
{getGreeting(now.getHours())},{formatTime(now)}
</p>
<p className="clock-greeting__date">{formatDate(now)}</p>
</section>
)
}
- Step 8: Run clock tests and checkpoint
Run:
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:
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:
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:
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:
import {
SEARCH_PROVIDERS,
type ExternalSearchProvider,
type SearchProvider,
} from "@/types/search"
const SEARCH_ENDPOINTS: Record<ExternalSearchProvider, string> = {
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:
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:
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:
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:
pnpm test -- src/features/home/lib/provider-preference.test.ts
Create src/features/home/lib/provider-preference.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:
pnpm test -- src/features/home/lib/provider-preference.test.ts
Expected: PASS.
- Step 8: Record an uncommitted checkpoint
Run:
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:
<GlassPanel as?: "div" | "section" | "aside">. -
Produces:
getIconCandidates(link): IconCandidate[]. -
Produces:
<SiteIcon link size? className? />. -
Consumes:
NavLink,simple-icons, andcn(). -
Step 1: Write failing icon candidate tests
Create src/components/shared/icon-candidates.test.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:
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:
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<Record<string, SimpleIcon>> = {
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:
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<NavLink, "title" | "url" | "icon">,
): 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:
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:
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(
<SiteIcon
link={{ title: "GitHub", url: "https://github.com", icon: "si:github" }}
/>,
)
expect(container.querySelector("svg")).toBeInTheDocument()
})
it("远程图片连续失败后显示首字符", () => {
render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
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:
"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<NavLink, "title" | "url" | "icon">
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 (
<span className={wrapperClassName} style={style} aria-hidden="true">
<svg
viewBox="0 0 24 24"
role="presentation"
style={{ color: `#${icon.hex}` }}
>
<path d={icon.path} fill="currentColor" />
</svg>
</span>
)
}
if (candidate.kind === "image") {
return (
<span className={wrapperClassName} style={style}>
<img
src={candidate.src}
alt={`${link.title} 图标`}
width={size}
height={size}
onError={() =>
setCandidateIndex((index) =>
Math.min(index + 1, candidates.length - 1),
)
}
/>
</span>
)
}
return (
<span
className={cn(wrapperClassName, "site-icon--initial")}
style={style}
aria-hidden="true"
>
{candidate.value}
</span>
)
}
Create src/components/shared/glass-panel.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:
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:
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:
pnpm test -- src/features/navigation/lib/sidebar-preference.test.ts
Create src/features/navigation/lib/sidebar-preference.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:
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(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
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(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
await waitFor(() => {
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
})
it("分类项可聚焦但保持禁用", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
expect(
screen.getByRole("button", { name: "开发工具,分类页将在下一阶段提供" }),
).toHaveAttribute("aria-disabled", "true")
expect(
screen.queryByRole("link", { name: /开发工具/ }),
).not.toBeInTheDocument()
})
})
- Step 3: Run and confirm AppShell failure
Run:
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:
import {
BookOpen,
CirclePlay,
Code2,
Coffee,
Palette,
Sparkles,
Zap,
type LucideIcon,
} from "lucide-react"
const CATEGORY_ICONS: Readonly<Record<string, LucideIcon>> = {
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 <Icon aria-hidden="true" strokeWidth={1.8} />
}
- Step 5: Implement Sidebar with temporarily disabled categories
Create src/features/navigation/components/sidebar.tsx. It must:
- use
next/imagefor/brand/logo.svgwithwidth={40}andheight={40}; - use
Homefor the active home item; - use
PanelLeftClosewhen expanded andPanelLeftOpenwhen 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-labelwhile visually hiding text in collapsed mode; - expose
onToggleandcollapsedprops; - give the toggle exact accessible labels
折叠侧边栏or展开侧边栏.
Use this prop contract:
type SidebarProps = {
categories: Category[]
siteName: string
collapsed: boolean
onToggle: () => void
}
Add this comment above disabled categories:
// 本次只验收首页;分类页完成前保持可见但禁用,避免用户进入 404。
- Step 6: Implement AppShell persistence and hydration guard
Create src/features/navigation/components/app-shell.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 (
<div
className="app-shell"
data-testid="app-shell"
data-hydrated={hydrated}
data-sidebar-collapsed={collapsed}
>
<Sidebar
categories={categories}
siteName={siteName}
collapsed={collapsed}
onToggle={toggleSidebar}
/>
<div className="app-shell__content">{children}</div>
</div>
)
}
- Step 7: Run navigation tests
Run:
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:
<SearchBox links categories defaultProvider enabledProviders onExternalNavigate? />. -
Consumes:
buildSearchUrl,searchLocalLinks, Provider preference helpers,SiteIcon, shadcn DropdownMenu. -
External navigation test seam:
onExternalNavigate?: (url: string) => void; production default callswindow.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:
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<React.ComponentProps<typeof SearchBox>> = {},
) {
const onExternalNavigate = vi.fn()
render(
<SearchBox
links={links}
categories={categories}
defaultProvider="google"
enabledProviders={["google", "bing", "github", "local"]}
onExternalNavigate={onExternalNavigate}
{...overrides}
/>,
)
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(
<SearchBox
links={links}
categories={categories}
defaultProvider="google"
enabledProviders={["google", "bing", "github", "local"]}
/>,
)
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:
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:
const PROVIDER_LABELS: Record<SearchProvider, string> = {
google: "Google",
bing: "Bing",
github: "GitHub",
local: "本地",
}
Use lucide Globe2, Search, Github, and Database; use shadcn DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, and DropdownMenuRadioItem.
Prop contract:
type SearchProviderMenuProps = {
provider: SearchProvider
enabledProviders: SearchProvider[]
onProviderChange: (provider: SearchProvider) => void
}
The trigger accessible name must be:
选择搜索方式,当前为 {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:
type LocalSearchResultsProps = {
listboxId: string
results: LocalSearchResult[]
activeIndex: number
optionRefs: React.MutableRefObject<Array<HTMLAnchorElement | null>>
onActiveIndexChange: (index: number) => void
}
Requirements:
-
panel uses
role="listbox"andid={listboxId}; -
every result is
<a role="option" target="_blank" rel="noopener noreferrer">; -
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:
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
Implementation requirements:
- Initial state uses
defaultProvider, empty query, active index0. - Mount effect:
- focuses input;
- reads
SEARCH_PROVIDER_STORAGE_KEYintry/catch; - applies
parseStoredProvideronly if the result is enabled.
- Global key listener focuses input on
(ctrlKey || metaKey) && key.toLowerCase() === "k"and callspreventDefault(). - Provider change persists inside
try/catch, resets active index, and never opens Local results for external providers. resultsis memoized fromsearchLocalLinks(query, links, categories, 8)only in Local mode.- External submit calls
buildSearchUrland thenonExternalNavigate ?? ((url) => window.location.assign(url)). - Local
ArrowDown/ArrowUpwraps around the result length. - Local
Entercalls.click()on the selected result anchor ref. Escapeclears query and resets active index.- Input uses
role="combobox", exact accessible label搜索网站、书签或关键词, placeholder搜索网站、书签或关键词..., and correctaria-expanded,aria-controls,aria-activedescendant,aria-autocomplete="list". - Platform hint uses
⌘Kwhennavigator.userAgentcontainsMac, otherwiseCtrl 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:
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:
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:
<QuickLinks links: NavLink[] />. -
Produces:
<HomeContent links categories settings />. -
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:
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(<QuickLinks links={links} />)
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(<QuickLinks links={links} />)
expect(screen.queryByText("代码托管")).not.toBeInTheDocument()
expect(screen.queryByText("AI 助手")).not.toBeInTheDocument()
})
})
- Step 2: Run and confirm QuickLinks failure
Run:
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:
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
export function QuickLinks({ links }: { links: NavLink[] }) {
return (
<section className="quick-links" aria-labelledby="quick-links-title">
<h2 id="quick-links-title" className="quick-links__title">
常用链接
</h2>
<div className="quick-links__grid">
{links.map((link) => (
<a
key={link.id}
className="quick-link-card"
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<SiteIcon link={link} size={38} />
<span>{link.title}</span>
</a>
))}
</div>
</section>
)
}
Run its test; expected PASS.
- Step 4: Implement HomeContent composition
Create src/features/home/components/home-content.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 (
<main className="home-content">
<div className="home-content__inner">
<ClockGreeting />
<SearchBox
links={links}
categories={categories}
defaultProvider={settings.search.defaultProvider}
enabledProviders={settings.search.enabledProviders}
/>
<QuickLinks links={pinnedLinks} />
</div>
</main>
)
}
- Step 5: Replace the smoke page with service-backed Server composition
Replace src/app/page.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 (
<AppShell
categories={categories}
siteName={settings.siteName}
defaultCollapsed={settings.sidebar.defaultCollapsed}
>
<HomeContent
categories={categories}
links={links}
pinnedLinks={pinnedLinks}
settings={settings}
/>
</AppShell>
)
}
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:
export const metadata: Metadata = {
title: "Polaris — 个人导航起始页",
description: "快速搜索并访问常用网站的个人导航起始页。",
}
Add:
export const viewport: Viewport = {
colorScheme: "light",
themeColor: "#dcebf5",
}
Keep <html lang="zh-CN">.
- Step 7: Run composition checks
Run:
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:
@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:
* {
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:
.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 padding16px; -
logo
40px; expanded name visible, collapsed name visually hidden; -
nav item height
48px, radius16px, gap12px; -
active home background
var(--primary-soft)and colorvar(--primary-hover); -
disabled categories retain readable
#334155, cursornot-allowed, opacity at least0.78; -
collapsed nav icons stay centered;
-
tooltip must remain inside viewport and above the main content.
-
Step 4: Style the main hierarchy
Use:
.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), height68px, centered, margin-top40px; - surface
var(--surface-search), blur22px, radius24px,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 usesvar(--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:
.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:
@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:
pnpm format
pnpm lint
pnpm typecheck
pnpm test
pnpm build
Expected: all commands exit 0.
- Step 9: Record an uncommitted checkpoint
Run:
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-homepagetarget 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:
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:
- Search input auto-focuses on load.
Ctrl+Kor⌘Kreturns focus to search.- Provider can switch among all four values and survives refresh.
- Google/Bing/GitHub submit correctly encoded URLs in the current tab; use a harmless query and return to Polaris after each check.
- Local results appear only in Local mode.
- Local
↑,↓,Enter, mouse click andEscwork. - Local no-result text is exact.
- Sidebar collapses to 72px, expands to 240px, and survives refresh.
- Classification items show the temporary tooltip and never navigate.
- Every quick link opens a new tab with the correct URL.
- No broken brand icon appears; fallback can be verified by temporarily changing one icon in browser devtools only, without committing fixture corruption.
- Focus rings are visible and reduced-motion mode removes perceptible transitions.
- Step 7: Fix verified defects with targeted regression tests
For each defect:
- Add or extend the nearest existing test so it fails for the observed behavior.
- Run that specific test and record the expected failure.
- Apply the smallest implementation or CSS correction.
- Re-run the specific test.
- Re-run
pnpm lint,pnpm typecheck,pnpm test, andpnpm buildafter 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
NavLinkexamples and schema text distinguishsortfrompinnedSort; -
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:
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:
git add .gitignore ts/web/nextjs/polaris
Review staged scope:
git diff --cached --stat
git diff --cached --check
Commit without pushing:
git commit -m "$(cat <<'EOF'
feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.