Files
lab/ts/web/nextjs/polaris/docs/superpowers/plans/2026-08-26-polaris-homepage.md
T
2026-09-02 23:53:28 +08:00

88 KiB
Raw Blame History

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:

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页面设计图.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:

{
  "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 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:

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 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:

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">
          &nbsp;
        </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, and cn().

  • 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/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:

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 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:

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" and id={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:

  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:

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.


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 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:

.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:

.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-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:

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:

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.