# Phase 1 · Foundation 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:** 在 macOS 开发机上搭起 Pawcast 工程骨架，能 `bun dev` 启动 Electron 窗口，5 Tab 切换无错，DB 可读写，挂件 server 可访问。

**Architecture:** Monorepo via `bun workspaces` · `apps/desktop` (Electron + React + Vite) + `packages/{core,db,widget-server,ipc-contract,ui-kit}`。本 phase 不实现具体业务逻辑（vote engine 等），只搭骨架 + IPC 通路 + DB schema + 设置中心 placeholder（仅 AI Key 可写）。

**Tech Stack:** Bun 1.2 + Electron 30 + React 19 + Vite 6 + Tailwind 4 + Zustand + TanStack Query + bun:sqlite + Hono + Zod + Vitest + Biome

**Project root:** `/Users/luzhipeng/projects/pawcast`

---

## File Structure（本 phase 创建）

```
pawcast/
├── apps/
│   ├── desktop/
│   │   ├── electron/
│   │   │   ├── main.ts              ★ Electron 主入口
│   │   │   ├── preload.ts           ★ contextBridge 安全桥
│   │   │   └── window.ts            ★ BrowserWindow 创建
│   │   ├── src/
│   │   │   ├── main.tsx             ★ React 入口
│   │   │   ├── App.tsx              ★ 5 Tab 主壳
│   │   │   ├── pages/
│   │   │   │   ├── Dashboard/index.tsx
│   │   │   │   ├── Vote/index.tsx
│   │   │   │   ├── Explore/index.tsx
│   │   │   │   ├── DM/index.tsx
│   │   │   │   ├── Monitor/index.tsx
│   │   │   │   └── Settings/index.tsx
│   │   │   ├── components/
│   │   │   │   ├── Topnav.tsx       ★ 顶部 5 Tab 导航
│   │   │   │   └── PageShell.tsx    ★ 占位"开发中"组件
│   │   │   ├── lib/
│   │   │   │   └── ipc-client.ts    ★ 类型化 IPC 客户端
│   │   │   ├── stores/
│   │   │   │   └── settingsStore.ts ★ AI Key 等设置 zustand store
│   │   │   └── styles/
│   │   │       └── globals.css      ★ Tailwind import
│   │   ├── index.html
│   │   ├── vite.config.ts
│   │   ├── tailwind.config.ts
│   │   ├── tsconfig.json
│   │   └── package.json
│   │
│   └── widgets/
│       ├── src/
│       │   └── hello/index.html     ★ Phase 1 placeholder 挂件
│       ├── vite.config.ts
│       └── package.json
│
├── packages/
│   ├── core/
│   │   ├── src/
│   │   │   ├── logger.ts            ★ 统一 logger
│   │   │   └── index.ts
│   │   ├── tsconfig.json
│   │   └── package.json
│   │
│   ├── db/
│   │   ├── src/
│   │   │   ├── client.ts            ★ bun:sqlite 单例
│   │   │   ├── migrations/
│   │   │   │   ├── 001-initial.sql  ★ 10 张表 schema
│   │   │   │   └── runner.ts        ★ migration 执行器
│   │   │   ├── repositories/
│   │   │   │   └── settings.ts      ★ Phase 1 唯一 repository
│   │   │   ├── types.ts
│   │   │   └── index.ts
│   │   └── package.json
│   │
│   ├── widget-server/
│   │   ├── src/
│   │   │   ├── server.ts            ★ Hono on Bun 起 17777
│   │   │   ├── routes/
│   │   │   │   └── widget.ts        ★ GET /widget/:type
│   │   │   ├── pubsub.ts            ★ in-memory event bus
│   │   │   └── index.ts
│   │   └── package.json
│   │
│   ├── ipc-contract/
│   │   ├── src/
│   │   │   ├── methods.ts           ★ 所有 RPC method 定义
│   │   │   ├── events.ts            ★ 主→渲染 push event 定义
│   │   │   └── index.ts
│   │   └── package.json
│   │
│   └── ui-kit/
│       ├── src/
│       │   ├── tokens.css           ★ CSS variables
│       │   ├── components/
│       │   │   ├── Pill.tsx
│       │   │   ├── Toggle.tsx
│       │   │   └── Button.tsx
│       │   └── index.ts
│       └── package.json
│
├── tests/
│   └── fixtures/
│       └── recorded-events/         (空，phase 2 才用)
│
├── scripts/
│   ├── dev.ts                       ★ `bun dev` 入口
│   └── seed-db.ts                   ★ 灌测试数据
│
├── .gitignore
├── .biome.json
├── tsconfig.base.json
├── package.json                     ★ 根 workspace
└── README.md
```

★ 标记的是本 phase 必须创建的文件。

---

## Task 1: 初始化 monorepo + bun workspaces

**Files:**
- Create: `package.json`
- Create: `tsconfig.base.json`
- Create: `.gitignore`
- Create: `.biome.json`
- Create: `README.md`

- [ ] **Step 1: 创建项目根 `package.json`**

```json
{
  "name": "pawcast",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "dev": "bun scripts/dev.ts",
    "test": "bun test",
    "test:watch": "bun test --watch",
    "typecheck": "tsc --noEmit -p tsconfig.base.json",
    "lint": "biome check .",
    "fmt": "biome format --write .",
    "seed": "bun scripts/seed-db.ts"
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.4",
    "@types/bun": "latest",
    "concurrently": "^9.1.0",
    "typescript": "^5.7.0"
  }
}
```

- [ ] **Step 2: 创建根 `tsconfig.base.json`（所有 package 继承）**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": false,
    "jsx": "preserve",
    "types": ["bun-types"],
    "paths": {
      "@pawcast/core": ["./packages/core/src"],
      "@pawcast/core/*": ["./packages/core/src/*"],
      "@pawcast/db": ["./packages/db/src"],
      "@pawcast/db/*": ["./packages/db/src/*"],
      "@pawcast/widget-server": ["./packages/widget-server/src"],
      "@pawcast/ipc-contract": ["./packages/ipc-contract/src"],
      "@pawcast/ui-kit": ["./packages/ui-kit/src"]
    }
  },
  "exclude": ["node_modules", "dist", "out", "release"]
}
```

- [ ] **Step 3: 创建 `.gitignore`**

```
node_modules
dist
out
release
*.log
.DS_Store
.env
.env.local
~/.pawcast/
*.sqlite
*.sqlite-journal
.idea
.vscode/settings.json
```

- [ ] **Step 4: 创建 `.biome.json`**

```json
{
  "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
  "organizeImports": { "enabled": true },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "style": {
        "useImportType": "warn",
        "noNonNullAssertion": "off"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "files": {
    "ignore": ["dist", "out", "release", "node_modules"]
  }
}
```

- [ ] **Step 5: 创建 `README.md`**

```markdown
# Pawcast

TikTok 团播桌面工作台。Electron + Bun + React。

## Setup
\`\`\`bash
bun install
bunx playwright install chromium
\`\`\`

## Develop
\`\`\`bash
bun dev      # start everything
bun test     # run tests
bun typecheck
bun lint
\`\`\`

## Architecture
See `docs/superpowers/plans/2026-05-02-pawcast-architecture.md`
```

- [ ] **Step 6: 安装根依赖 + 验证**

```bash
cd /Users/luzhipeng/projects/pawcast && bun install
```

Expected: `bun install` completes without errors, `node_modules` directory created.

- [ ] **Step 7: Commit**

```bash
git init -b main
git add package.json tsconfig.base.json .gitignore .biome.json README.md
git commit -m "chore: init monorepo workspace"
```

---

## Task 2: 创建 packages/ipc-contract

**Files:**
- Create: `packages/ipc-contract/package.json`
- Create: `packages/ipc-contract/tsconfig.json`
- Create: `packages/ipc-contract/src/methods.ts`
- Create: `packages/ipc-contract/src/events.ts`
- Create: `packages/ipc-contract/src/index.ts`
- Test: `packages/ipc-contract/src/methods.test.ts`

- [ ] **Step 1: 创建 `packages/ipc-contract/package.json`**

```json
{
  "name": "@pawcast/ipc-contract",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": {
    "zod": "^3.23.8"
  }
}
```

- [ ] **Step 2: 创建 `packages/ipc-contract/tsconfig.json`**

```json
{
  "extends": "../../tsconfig.base.json",
  "include": ["src/**/*"]
}
```

- [ ] **Step 3: 写 `src/methods.ts` - 定义 SettingsContract（Phase 1 唯一）**

```typescript
import { z } from 'zod';

const Method = <I extends z.ZodTypeAny, O extends z.ZodTypeAny>(input: I, output: O) =>
  ({ input, output }) as { input: I; output: O };

export const SettingsContract = {
  'settings.get': Method(
    z.object({ key: z.string() }),
    z.union([z.string(), z.null()]),
  ),
  'settings.set': Method(
    z.object({ key: z.string(), value: z.string() }),
    z.object({ ok: z.literal(true) }),
  ),
  'settings.listAiKeys': Method(
    z.void(),
    z.object({
      anthropic: z.string().nullable(),
      openai: z.string().nullable(),
      qwen: z.string().nullable(),
    }),
  ),
} as const;

export type ContractType = typeof SettingsContract;
export type MethodName = keyof ContractType;
export type MethodInput<K extends MethodName> = z.infer<ContractType[K]['input']>;
export type MethodOutput<K extends MethodName> = z.infer<ContractType[K]['output']>;
```

- [ ] **Step 4: 写 `src/events.ts`**

```typescript
import { z } from 'zod';

// 主 → 渲染推送事件（不需 ack）
export const EventSchemas = {
  'settings.changed': z.object({ key: z.string(), value: z.string() }),
} as const;

export type EventName = keyof typeof EventSchemas;
export type EventPayload<K extends EventName> = z.infer<(typeof EventSchemas)[K]>;
```

- [ ] **Step 5: 写 `src/index.ts`**

```typescript
export * from './methods';
export * from './events';
```

- [ ] **Step 6: 写测试 `src/methods.test.ts`**

```typescript
import { test, expect } from 'bun:test';
import { SettingsContract } from './methods';

test('SettingsContract.settings.get input schema accepts {key: string}', () => {
  const ok = SettingsContract['settings.get'].input.safeParse({ key: 'theme' });
  expect(ok.success).toBe(true);
});

test('SettingsContract.settings.get rejects missing key', () => {
  const fail = SettingsContract['settings.get'].input.safeParse({});
  expect(fail.success).toBe(false);
});

test('SettingsContract.settings.set output is {ok: true}', () => {
  const result = SettingsContract['settings.set'].output.safeParse({ ok: true });
  expect(result.success).toBe(true);
});
```

- [ ] **Step 7: 跑测试**

Run: `cd packages/ipc-contract && bun test`
Expected: 3 passed

- [ ] **Step 8: Commit**

```bash
git add packages/ipc-contract/
git commit -m "feat(ipc-contract): add SettingsContract with Zod schemas"
```

---

## Task 3: 创建 packages/core 与 logger

**Files:**
- Create: `packages/core/package.json`
- Create: `packages/core/tsconfig.json`
- Create: `packages/core/src/logger.ts`
- Create: `packages/core/src/index.ts`
- Test: `packages/core/src/logger.test.ts`

- [ ] **Step 1: 创建 `packages/core/package.json`**

```json
{
  "name": "@pawcast/core",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": {
    "zod": "^3.23.8"
  }
}
```

- [ ] **Step 2: 创建 `tsconfig.json`**

```json
{
  "extends": "../../tsconfig.base.json",
  "include": ["src/**/*"]
}
```

- [ ] **Step 3: 写 `src/logger.ts`**

```typescript
type Level = 'info' | 'warn' | 'error' | 'debug';

const colors: Record<Level, string> = {
  info: '\x1b[36m',  // cyan
  warn: '\x1b[33m',  // yellow
  error: '\x1b[31m', // red
  debug: '\x1b[90m', // gray
};
const reset = '\x1b[0m';

function format(level: Level, scope: string, msg: string, data?: unknown): string {
  const ts = new Date().toISOString();
  const tag = `${colors[level]}[${level.toUpperCase()}]${reset}`;
  const dataStr = data !== undefined ? ' ' + JSON.stringify(data) : '';
  return `${ts} ${tag} ${scope} · ${msg}${dataStr}`;
}

export const logger = {
  info: (scope: string, msg: string, data?: unknown) => console.log(format('info', scope, msg, data)),
  warn: (scope: string, msg: string, data?: unknown) => console.warn(format('warn', scope, msg, data)),
  error: (scope: string, msg: string, data?: unknown) => console.error(format('error', scope, msg, data)),
  debug: (scope: string, msg: string, data?: unknown) => {
    if (process.env.DEBUG) console.debug(format('debug', scope, msg, data));
  },
};
```

- [ ] **Step 4: 写 `src/index.ts`**

```typescript
export { logger } from './logger';
```

- [ ] **Step 5: 写测试 `src/logger.test.ts`**

```typescript
import { test, expect, mock } from 'bun:test';
import { logger } from './logger';

test('logger.info logs to console.log', () => {
  const log = mock();
  const orig = console.log;
  console.log = log;
  logger.info('test', 'hello');
  console.log = orig;
  expect(log).toHaveBeenCalledTimes(1);
  expect(log.mock.calls[0]?.[0]).toContain('hello');
  expect(log.mock.calls[0]?.[0]).toContain('test');
});

test('logger.error logs to console.error', () => {
  const err = mock();
  const orig = console.error;
  console.error = err;
  logger.error('test', 'boom');
  console.error = orig;
  expect(err).toHaveBeenCalledTimes(1);
});
```

- [ ] **Step 6: 跑测试**

Run: `cd packages/core && bun test`
Expected: 2 passed

- [ ] **Step 7: Commit**

```bash
git add packages/core/
git commit -m "feat(core): add logger"
```

---

## Task 4: 创建 packages/db 与 SQLite 单例

**Files:**
- Create: `packages/db/package.json`
- Create: `packages/db/tsconfig.json`
- Create: `packages/db/src/client.ts`
- Create: `packages/db/src/types.ts`
- Create: `packages/db/src/index.ts`
- Test: `packages/db/src/client.test.ts`

- [ ] **Step 1: 创建 `packages/db/package.json`**

```json
{
  "name": "@pawcast/db",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": {
    "@pawcast/core": "workspace:*"
  }
}
```

- [ ] **Step 2: `tsconfig.json`** —— 同 core，extends base.

- [ ] **Step 3: 写 `src/client.ts`**

```typescript
import { Database } from 'bun:sqlite';
import { logger } from '@pawcast/core';
import { homedir } from 'node:os';
import { mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';

let _db: Database | null = null;

export function getDbPath(): string {
  const home = homedir();
  const dir = join(home, '.pawcast');
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  return join(dir, 'pawcast.sqlite');
}

export function openDb(path?: string): Database {
  if (_db) return _db;
  const p = path ?? getDbPath();
  _db = new Database(p);
  _db.exec('PRAGMA journal_mode = WAL;');
  _db.exec('PRAGMA foreign_keys = ON;');
  _db.exec('PRAGMA busy_timeout = 5000;');
  logger.info('db', 'opened', { path: p });
  return _db;
}

export function closeDb(): void {
  if (_db) {
    _db.close();
    _db = null;
  }
}

/** For tests: open fresh in-memory DB */
export function openMemoryDb(): Database {
  const db = new Database(':memory:');
  db.exec('PRAGMA foreign_keys = ON;');
  return db;
}
```

- [ ] **Step 4: 写 `src/types.ts`**（Phase 1 暂留空但导出）

```typescript
// 表 row 类型，phase 推进时填充
export interface SettingsRow {
  key: string;
  value: string;
  updated_at: number;
}
```

- [ ] **Step 5: 写 `src/index.ts`**

```typescript
export { openDb, closeDb, openMemoryDb, getDbPath } from './client';
export type { SettingsRow } from './types';
```

- [ ] **Step 6: 写测试 `src/client.test.ts`**

```typescript
import { test, expect } from 'bun:test';
import { openMemoryDb } from './client';

test('openMemoryDb returns a Database with foreign_keys ON', () => {
  const db = openMemoryDb();
  const result = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
  expect(result.foreign_keys).toBe(1);
  db.close();
});

test('openMemoryDb supports CREATE TABLE + INSERT + SELECT', () => {
  const db = openMemoryDb();
  db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
  db.prepare('INSERT INTO t (v) VALUES (?)').run('hello');
  const row = db.prepare('SELECT v FROM t WHERE id = 1').get() as { v: string };
  expect(row.v).toBe('hello');
  db.close();
});
```

- [ ] **Step 7: 跑测试**

Run: `cd packages/db && bun test`
Expected: 2 passed

- [ ] **Step 8: Commit**

```bash
git add packages/db/
git commit -m "feat(db): add bun:sqlite client with WAL + foreign_keys"
```

---

## Task 5: DB Migrations 执行器

**Files:**
- Create: `packages/db/src/migrations/runner.ts`
- Create: `packages/db/src/migrations/001-initial.sql`
- Test: `packages/db/src/migrations/runner.test.ts`

- [ ] **Step 1: 写 `src/migrations/001-initial.sql`** —— 10 张表全部 schema

```sql
-- Migration 001: Initial schema for Pawcast v0.1
CREATE TABLE IF NOT EXISTS broadcasters (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  tt_user_id  TEXT NOT NULL UNIQUE,
  username    TEXT,
  display     TEXT,
  avatar_url  TEXT,
  status      TEXT NOT NULL DEFAULT 'discovered',  -- discovered/contacted/signed/declined
  scores_json TEXT,
  tags_json   TEXT,
  notes       TEXT,
  created_at  INTEGER NOT NULL,
  updated_at  INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_broadcasters_status ON broadcasters(status);

CREATE TABLE IF NOT EXISTS presets (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  slot        INTEGER NOT NULL,    -- 1-12
  room_id     TEXT,                -- 绑定到哪个直播间，可空（全局预设）
  name        TEXT NOT NULL,
  mode        TEXT NOT NULL,       -- sticker_dance/duel_dance/multi_pk/solo_stage/freedom
  config_json TEXT NOT NULL,       -- 玩法专属配置
  cast_json   TEXT NOT NULL,       -- 主播槽 + 礼物绑定
  is_active   INTEGER NOT NULL DEFAULT 0,
  updated_at  INTEGER NOT NULL,
  UNIQUE(slot, room_id)
);

CREATE TABLE IF NOT EXISTS live_sessions (
  id             INTEGER PRIMARY KEY AUTOINCREMENT,
  broadcaster_id INTEGER REFERENCES broadcasters(id),
  tt_room_id     TEXT NOT NULL,
  preset_id      INTEGER REFERENCES presets(id),
  start_at       INTEGER NOT NULL,
  end_at         INTEGER,
  total_gifts    INTEGER DEFAULT 0,
  total_coins    INTEGER DEFAULT 0,
  peak_viewers   INTEGER DEFAULT 0,
  meta_json      TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_room ON live_sessions(tt_room_id);
CREATE INDEX IF NOT EXISTS idx_sessions_start ON live_sessions(start_at);

CREATE TABLE IF NOT EXISTS gift_events (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id   INTEGER NOT NULL REFERENCES live_sessions(id),
  user_id      TEXT NOT NULL,
  user_name    TEXT,
  gift_id      TEXT NOT NULL,
  gift_name    TEXT,
  count        INTEGER NOT NULL,
  coins        INTEGER NOT NULL,
  bound_to     INTEGER,                 -- 绑定到哪位主播 (broadcasters.id)
  binding_type TEXT,                    -- single_gift/multi_gift/manual/unallocated
  ts           INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_gift_session ON gift_events(session_id);
CREATE INDEX IF NOT EXISTS idx_gift_user ON gift_events(user_id);
CREATE INDEX IF NOT EXISTS idx_gift_ts ON gift_events(ts);

CREATE TABLE IF NOT EXISTS chat_events (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id INTEGER NOT NULL REFERENCES live_sessions(id),
  user_id    TEXT NOT NULL,
  user_name  TEXT,
  text       TEXT NOT NULL,
  vote_for   INTEGER,                   -- 解析后归到哪位主播
  ts         INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_session ON chat_events(session_id);

CREATE TABLE IF NOT EXISTS like_events (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id INTEGER NOT NULL REFERENCES live_sessions(id),
  user_id    TEXT NOT NULL,
  count      INTEGER NOT NULL,
  ts         INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_like_session ON like_events(session_id);

CREATE TABLE IF NOT EXISTS pk_battles (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  battle_id       TEXT,
  my_session_id   INTEGER REFERENCES live_sessions(id),
  other_room      TEXT NOT NULL,
  start_at        INTEGER NOT NULL,
  end_at          INTEGER,
  my_score        INTEGER DEFAULT 0,
  other_score     INTEGER DEFAULT 0,
  winner          TEXT,
  punish_type     TEXT,
  meta_json       TEXT
);

CREATE TABLE IF NOT EXISTS dms (
  id             INTEGER PRIMARY KEY AUTOINCREMENT,
  to_broadcaster INTEGER REFERENCES broadcasters(id),
  template       TEXT,
  draft          TEXT,
  final          TEXT,
  timeline_json  TEXT,
  outcome        TEXT,                  -- pending/sent/read/replied/signed/declined
  created_at     INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dms_outcome ON dms(outcome);
CREATE INDEX IF NOT EXISTS idx_dms_to ON dms(to_broadcaster);

CREATE TABLE IF NOT EXISTS dm_templates (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  name         TEXT NOT NULL,
  category     TEXT NOT NULL,
  content      TEXT NOT NULL,
  variants_json TEXT,
  usage_count  INTEGER DEFAULT 0,
  reply_count  INTEGER DEFAULT 0,
  is_active    INTEGER DEFAULT 1,
  updated_at   INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS tt_users (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  tt_user_id   TEXT NOT NULL UNIQUE,
  display      TEXT,
  avatar_url   TEXT,
  total_coins  INTEGER DEFAULT 0,
  total_gifts  INTEGER DEFAULT 0,
  last_seen    INTEGER
);

CREATE TABLE IF NOT EXISTS settings (
  key        TEXT PRIMARY KEY,
  value      TEXT NOT NULL,
  updated_at INTEGER NOT NULL
);

-- Migration tracking
CREATE TABLE IF NOT EXISTS _migrations (
  version    INTEGER PRIMARY KEY,
  applied_at INTEGER NOT NULL
);
```

- [ ] **Step 2: 写 `src/migrations/runner.ts`**

```typescript
import type { Database } from 'bun:sqlite';
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { logger } from '@pawcast/core';

const __dirname = dirname(fileURLToPath(import.meta.url));

interface MigrationFile {
  version: number;
  name: string;
  sql: string;
}

function loadMigrations(): MigrationFile[] {
  const files = readdirSync(__dirname)
    .filter((f) => f.endsWith('.sql'))
    .sort();
  return files.map((file) => {
    const match = file.match(/^(\d+)-(.+)\.sql$/);
    if (!match) throw new Error(`Bad migration filename: ${file}`);
    return {
      version: Number(match[1]),
      name: match[2]!,
      sql: readFileSync(join(__dirname, file), 'utf8'),
    };
  });
}

export function runMigrations(db: Database): { applied: number[]; skipped: number[] } {
  // Ensure _migrations table exists
  db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
    version INTEGER PRIMARY KEY,
    applied_at INTEGER NOT NULL
  )`);

  const applied = (db.prepare('SELECT version FROM _migrations').all() as { version: number }[])
    .map((r) => r.version);

  const all = loadMigrations();
  const result = { applied: [] as number[], skipped: [] as number[] };

  for (const m of all) {
    if (applied.includes(m.version)) {
      result.skipped.push(m.version);
      continue;
    }
    logger.info('db.migrations', `applying ${m.version}-${m.name}`);
    db.exec('BEGIN');
    try {
      db.exec(m.sql);
      db.prepare('INSERT INTO _migrations (version, applied_at) VALUES (?, ?)').run(
        m.version,
        Date.now(),
      );
      db.exec('COMMIT');
      result.applied.push(m.version);
    } catch (err) {
      db.exec('ROLLBACK');
      throw err;
    }
  }
  return result;
}
```

- [ ] **Step 3: 更新 `src/index.ts` 导出 runMigrations**

```typescript
export { openDb, closeDb, openMemoryDb, getDbPath } from './client';
export { runMigrations } from './migrations/runner';
export type { SettingsRow } from './types';
```

- [ ] **Step 4: 写测试 `src/migrations/runner.test.ts`**

```typescript
import { test, expect } from 'bun:test';
import { openMemoryDb } from '../client';
import { runMigrations } from './runner';

test('runMigrations applies 001-initial creating all expected tables', () => {
  const db = openMemoryDb();
  const result = runMigrations(db);
  expect(result.applied).toContain(1);

  const tables = (db.prepare(
    "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
  ).all() as { name: string }[]).map((r) => r.name);

  expect(tables).toContain('broadcasters');
  expect(tables).toContain('presets');
  expect(tables).toContain('live_sessions');
  expect(tables).toContain('gift_events');
  expect(tables).toContain('chat_events');
  expect(tables).toContain('like_events');
  expect(tables).toContain('pk_battles');
  expect(tables).toContain('dms');
  expect(tables).toContain('dm_templates');
  expect(tables).toContain('tt_users');
  expect(tables).toContain('settings');
  db.close();
});

test('runMigrations is idempotent', () => {
  const db = openMemoryDb();
  const r1 = runMigrations(db);
  const r2 = runMigrations(db);
  expect(r1.applied.length).toBeGreaterThan(0);
  expect(r2.applied.length).toBe(0);
  expect(r2.skipped).toContain(1);
  db.close();
});
```

- [ ] **Step 5: 跑测试**

Run: `cd packages/db && bun test`
Expected: 4 passed (含之前的 client.test.ts)

- [ ] **Step 6: Commit**

```bash
git add packages/db/src/migrations/ packages/db/src/index.ts
git commit -m "feat(db): add migration runner + initial schema (10 tables)"
```

---

## Task 6: Settings Repository

**Files:**
- Create: `packages/db/src/repositories/settings.ts`
- Test: `packages/db/src/repositories/settings.test.ts`

- [ ] **Step 1: 写 `src/repositories/settings.ts`**

```typescript
import type { Database } from 'bun:sqlite';
import type { SettingsRow } from '../types';

export class SettingsRepo {
  constructor(private db: Database) {}

  get(key: string): string | null {
    const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
      | { value: string }
      | undefined;
    return row?.value ?? null;
  }

  set(key: string, value: string): void {
    this.db
      .prepare(
        `INSERT INTO settings (key, value, updated_at)
         VALUES (?, ?, ?)
         ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
      )
      .run(key, value, Date.now());
  }

  delete(key: string): void {
    this.db.prepare('DELETE FROM settings WHERE key = ?').run(key);
  }

  all(): SettingsRow[] {
    return this.db.prepare('SELECT key, value, updated_at FROM settings').all() as SettingsRow[];
  }
}
```

- [ ] **Step 2: 更新 `src/index.ts`**

```typescript
export { openDb, closeDb, openMemoryDb, getDbPath } from './client';
export { runMigrations } from './migrations/runner';
export { SettingsRepo } from './repositories/settings';
export type { SettingsRow } from './types';
```

- [ ] **Step 3: 写测试 `src/repositories/settings.test.ts`**

```typescript
import { test, expect, beforeEach } from 'bun:test';
import { openMemoryDb } from '../client';
import { runMigrations } from '../migrations/runner';
import { SettingsRepo } from './settings';
import type { Database } from 'bun:sqlite';

let db: Database;
let repo: SettingsRepo;

beforeEach(() => {
  db = openMemoryDb();
  runMigrations(db);
  repo = new SettingsRepo(db);
});

test('get returns null for missing key', () => {
  expect(repo.get('nope')).toBeNull();
});

test('set then get round-trips', () => {
  repo.set('theme', 'dark');
  expect(repo.get('theme')).toBe('dark');
});

test('set on existing key updates value', () => {
  repo.set('theme', 'dark');
  repo.set('theme', 'light');
  expect(repo.get('theme')).toBe('light');
});

test('delete removes the key', () => {
  repo.set('theme', 'dark');
  repo.delete('theme');
  expect(repo.get('theme')).toBeNull();
});

test('all returns all rows', () => {
  repo.set('a', '1');
  repo.set('b', '2');
  const rows = repo.all();
  expect(rows.length).toBe(2);
  expect(rows.some((r) => r.key === 'a' && r.value === '1')).toBe(true);
});
```

- [ ] **Step 4: 跑测试**

Run: `cd packages/db && bun test`
Expected: 9 passed

- [ ] **Step 5: Commit**

```bash
git add packages/db/src/repositories/ packages/db/src/index.ts
git commit -m "feat(db): add SettingsRepo with key-value upsert"
```

---

## Task 7: 创建 packages/widget-server

**Files:**
- Create: `packages/widget-server/package.json`
- Create: `packages/widget-server/tsconfig.json`
- Create: `packages/widget-server/src/server.ts`
- Create: `packages/widget-server/src/routes/widget.ts`
- Create: `packages/widget-server/src/pubsub.ts`
- Create: `packages/widget-server/src/index.ts`
- Test: `packages/widget-server/src/server.test.ts`
- Test: `packages/widget-server/src/pubsub.test.ts`

- [ ] **Step 1: `package.json`**

```json
{
  "name": "@pawcast/widget-server",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": {
    "@pawcast/core": "workspace:*",
    "hono": "^4.6.0"
  }
}
```

- [ ] **Step 2: `tsconfig.json`** —— extends base.

- [ ] **Step 3: 写 `src/pubsub.ts`** —— in-memory event bus

```typescript
type Listener = (data: unknown) => void;

class PubSub {
  private channels = new Map<string, Set<Listener>>();

  subscribe(channel: string, listener: Listener): () => void {
    if (!this.channels.has(channel)) this.channels.set(channel, new Set());
    this.channels.get(channel)!.add(listener);
    return () => this.channels.get(channel)?.delete(listener);
  }

  publish(channel: string, data: unknown): void {
    this.channels.get(channel)?.forEach((l) => l(data));
  }

  channelCount(): number {
    return this.channels.size;
  }
}

export const pubsub = new PubSub();
```

- [ ] **Step 4: 写 `src/routes/widget.ts`** —— GET /widget/:type

```typescript
import { Hono } from 'hono';

const placeholderHtml = (type: string) => `<!doctype html>
<html><head><meta charset="utf-8"/>
<title>Pawcast Widget · ${type}</title>
<style>
body { margin:0; background:transparent; color:#fff; font-family:system-ui;
       display:flex; align-items:center; justify-content:center; min-height:100vh; }
.box { padding:24px 32px; background:rgba(20,20,28,0.92);
       border:1px solid rgba(139,92,246,0.4); border-radius:12px;
       box-shadow:0 0 24px rgba(139,92,246,0.3); backdrop-filter:blur(8px); }
.t { font-size:18px; font-weight:700; }
.s { font-size:12px; opacity:0.6; margin-top:4px; }
</style></head>
<body><div class="box">
<div class="t">🐾 Pawcast Widget · ${type}</div>
<div class="s">Phase 1 placeholder · 等 Phase 3 实现具体玩法</div>
</div></body></html>`;

export const widgetRoutes = new Hono();

widgetRoutes.get('/widget/:type', (c) => {
  const type = c.req.param('type');
  return c.html(placeholderHtml(type));
});

widgetRoutes.get('/health', (c) => c.json({ ok: true, service: 'widget-server' }));
```

- [ ] **Step 5: 写 `src/server.ts`**

```typescript
import { Hono } from 'hono';
import { logger } from '@pawcast/core';
import { widgetRoutes } from './routes/widget';

export interface WidgetServer {
  port: number;
  stop: () => void;
}

export function startWidgetServer(port = 17777): WidgetServer {
  const app = new Hono();
  app.route('/', widgetRoutes);

  const server = Bun.serve({ port, hostname: '127.0.0.1', fetch: app.fetch });
  logger.info('widget-server', 'listening', { port });
  return {
    port: server.port,
    stop: () => {
      server.stop();
      logger.info('widget-server', 'stopped');
    },
  };
}
```

- [ ] **Step 6: 写 `src/index.ts`**

```typescript
export { startWidgetServer } from './server';
export type { WidgetServer } from './server';
export { pubsub } from './pubsub';
```

- [ ] **Step 7: 写 `src/pubsub.test.ts`**

```typescript
import { test, expect, mock } from 'bun:test';
import { pubsub } from './pubsub';

test('subscribe + publish delivers data', () => {
  const fn = mock();
  const unsub = pubsub.subscribe('test', fn);
  pubsub.publish('test', { v: 1 });
  expect(fn).toHaveBeenCalledWith({ v: 1 });
  unsub();
});

test('unsubscribe stops delivery', () => {
  const fn = mock();
  const unsub = pubsub.subscribe('t2', fn);
  pubsub.publish('t2', 'a');
  unsub();
  pubsub.publish('t2', 'b');
  expect(fn).toHaveBeenCalledTimes(1);
});
```

- [ ] **Step 8: 写 `src/server.test.ts`**

```typescript
import { test, expect } from 'bun:test';
import { startWidgetServer } from './server';

test('startWidgetServer responds to /health', async () => {
  const server = startWidgetServer(17778);
  const res = await fetch('http://127.0.0.1:17778/health');
  expect(res.status).toBe(200);
  const json = await res.json();
  expect(json).toEqual({ ok: true, service: 'widget-server' });
  server.stop();
});

test('startWidgetServer serves /widget/:type with placeholder html', async () => {
  const server = startWidgetServer(17779);
  const res = await fetch('http://127.0.0.1:17779/widget/sticker_dance');
  expect(res.status).toBe(200);
  const html = await res.text();
  expect(html).toContain('Pawcast Widget');
  expect(html).toContain('sticker_dance');
  server.stop();
});
```

- [ ] **Step 9: 跑测试**

Run: `cd packages/widget-server && bun install && bun test`
Expected: 4 passed

- [ ] **Step 10: Commit**

```bash
git add packages/widget-server/
git commit -m "feat(widget-server): add Hono server with /health + /widget/:type placeholder"
```

---

## Task 8: 创建 packages/ui-kit

**Files:**
- Create: `packages/ui-kit/package.json`
- Create: `packages/ui-kit/tsconfig.json`
- Create: `packages/ui-kit/src/tokens.css`
- Create: `packages/ui-kit/src/components/Pill.tsx`
- Create: `packages/ui-kit/src/components/Toggle.tsx`
- Create: `packages/ui-kit/src/components/Button.tsx`
- Create: `packages/ui-kit/src/index.ts`

- [ ] **Step 1: `package.json`**

```json
{
  "name": "@pawcast/ui-kit",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "react": "^19.0.0"
  }
}
```

- [ ] **Step 2: `tsconfig.json`**（含 jsx: "react-jsx"）

```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "jsx": "react-jsx" },
  "include": ["src/**/*"]
}
```

- [ ] **Step 3: 写 `src/tokens.css`** —— 复制设计稿的 token

```css
:root {
  --pc-primary: #8B5CF6;
  --pc-primary-soft: #A78BFA;
  --pc-accent: #F97316;
  --pc-accent-soft: #FB923C;
  --pc-bg-deep: #06060A;
  --pc-surface-1: #13131B;
  --pc-surface-2: #1C1C26;
  --pc-surface-3: #25252F;
  --pc-border-subtle: #27272F;
  --pc-border-active: #3F3F4B;
  --pc-text-primary: #FAFAFA;
  --pc-text-secondary: #A1A1AA;
  --pc-text-muted: #71717A;
  --pc-success: #10B981;
  --pc-warning: #F59E0B;
  --pc-danger: #EF4444;
  --pc-live: #FF2E5C;
  --pc-grad-paw: linear-gradient(135deg, #8B5CF6 0%, #F97316 100%);
}
```

- [ ] **Step 4: 写 `src/components/Pill.tsx`**

```tsx
import type { ReactNode } from 'react';

type PillVariant = 'live' | 'success' | 'warn' | 'danger' | 'primary' | 'muted';

const variantClass: Record<PillVariant, string> = {
  live: 'bg-[#FF2E5C26] text-[#FF2E5C]',
  success: 'bg-[#10B98120] text-[#10B981]',
  warn: 'bg-[#F59E0B20] text-[#F59E0B]',
  danger: 'bg-[#EF444420] text-[#EF4444]',
  primary: 'bg-[#8B5CF626] text-[#A78BFA] border border-[#8B5CF640]',
  muted: 'bg-white/5 text-[#71717A]',
};

export function Pill({
  variant = 'muted',
  children,
}: {
  variant?: PillVariant;
  children: ReactNode;
}) {
  return (
    <span
      className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${variantClass[variant]}`}
    >
      {variant === 'live' && (
        <span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" />
      )}
      {children}
    </span>
  );
}
```

- [ ] **Step 5: 写 `src/components/Toggle.tsx`**

```tsx
export function Toggle({
  on,
  onChange,
}: {
  on: boolean;
  onChange?: (on: boolean) => void;
}) {
  return (
    <button
      type="button"
      onClick={() => onChange?.(!on)}
      className={`relative w-9 h-5 rounded-full transition-colors ${
        on ? 'bg-gradient-to-r from-[#8B5CF6] to-[#F97316]' : 'bg-[#25252F]'
      }`}
      aria-pressed={on}
    >
      <span
        className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
          on ? 'left-[18px]' : 'left-0.5 bg-[#71717A]'
        }`}
      />
    </button>
  );
}
```

- [ ] **Step 6: 写 `src/components/Button.tsx`**

```tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';

type Variant = 'primary' | 'ghost' | 'danger' | 'success';

const variantClass: Record<Variant, string> = {
  primary:
    'bg-gradient-to-br from-[#8B5CF6] to-[#F97316] text-white shadow-[0_8px_20px_-8px_rgba(139,92,246,0.6)] hover:shadow-[0_10px_24px_-6px_rgba(139,92,246,0.7)]',
  ghost:
    'bg-transparent text-[#A1A1AA] border border-[#27272F] hover:bg-[#25252F] hover:text-white',
  danger:
    'bg-[#EF444419] text-[#EF4444] border border-[#EF444433]',
  success:
    'bg-[#10B98120] text-[#10B981] border border-[#10B98140]',
};

export function Button({
  variant = 'ghost',
  className = '',
  children,
  ...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; children: ReactNode }) {
  return (
    <button
      type="button"
      className={`inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-[13px] font-medium transition-all ${variantClass[variant]} ${className}`}
      {...rest}
    >
      {children}
    </button>
  );
}
```

- [ ] **Step 7: 写 `src/index.ts`**

```typescript
import './tokens.css';
export { Pill } from './components/Pill';
export { Toggle } from './components/Toggle';
export { Button } from './components/Button';
```

- [ ] **Step 8: Commit**（这步无 test，组件等 desktop app 集成时再测）

```bash
git add packages/ui-kit/
git commit -m "feat(ui-kit): add tokens.css + Pill/Toggle/Button base components"
```

---

## Task 9: 创建 apps/desktop 工程骨架

**Files:**
- Create: `apps/desktop/package.json`
- Create: `apps/desktop/tsconfig.json`
- Create: `apps/desktop/vite.config.ts`
- Create: `apps/desktop/tailwind.config.ts`
- Create: `apps/desktop/index.html`
- Create: `apps/desktop/src/main.tsx`
- Create: `apps/desktop/src/styles/globals.css`

- [ ] **Step 1: `apps/desktop/package.json`**

```json
{
  "name": "@pawcast/desktop",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "electron/main.ts",
  "scripts": {
    "dev:renderer": "vite",
    "dev:electron": "electron electron/main.ts",
    "build": "vite build && tsc -p tsconfig.electron.json",
    "test": "bun test"
  },
  "dependencies": {
    "@pawcast/core": "workspace:*",
    "@pawcast/db": "workspace:*",
    "@pawcast/ipc-contract": "workspace:*",
    "@pawcast/ui-kit": "workspace:*",
    "@pawcast/widget-server": "workspace:*",
    "@tanstack/react-query": "^5.59.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "zod": "^3.23.8",
    "zustand": "^5.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.3.4",
    "autoprefixer": "^10.4.20",
    "electron": "^33.0.0",
    "postcss": "^8.4.49",
    "tailwindcss": "^4.0.0",
    "vite": "^6.0.0"
  }
}
```

- [ ] **Step 2: `tsconfig.json`**（renderer，jsx-react）

```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler"
  },
  "include": ["src/**/*"]
}
```

- [ ] **Step 3: `tsconfig.electron.json`**（electron main）

```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node",
    "outDir": "./dist-electron"
  },
  "include": ["electron/**/*"]
}
```

- [ ] **Step 4: `vite.config.ts`**

```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@pawcast/core': resolve(__dirname, '../../packages/core/src'),
      '@pawcast/db': resolve(__dirname, '../../packages/db/src'),
      '@pawcast/ipc-contract': resolve(__dirname, '../../packages/ipc-contract/src'),
      '@pawcast/ui-kit': resolve(__dirname, '../../packages/ui-kit/src'),
    },
  },
  server: { port: 5173 },
  build: { outDir: 'dist' },
});
```

- [ ] **Step 5: `tailwind.config.ts`**

```typescript
import type { Config } from 'tailwindcss';

export default {
  content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/ui-kit/src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      fontFamily: {
        display: ['Manrope', 'system-ui', 'sans-serif'],
        sans: ['Inter', 'system-ui', 'sans-serif'],
        mono: ['JetBrains Mono', 'monospace'],
      },
      colors: {
        primary: '#8B5CF6',
        accent: '#F97316',
        live: '#FF2E5C',
      },
    },
  },
} satisfies Config;
```

- [ ] **Step 6: `index.html`**

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Pawcast</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@600;700;800&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet" />
  </head>
  <body class="bg-[#06060A] text-[#FAFAFA] antialiased">
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

- [ ] **Step 7: `src/main.tsx`**

```typescript
import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './styles/globals.css';
import { App } from './App';

const queryClient = new QueryClient();

const root = createRoot(document.getElementById('root')!);
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>,
);
```

- [ ] **Step 8: `src/styles/globals.css`**

```css
@import "@pawcast/ui-kit/tokens.css";
@import "tailwindcss";

body {
  background:
    radial-gradient(ellipse at 20% 0%, rgba(139,92,246,0.06), transparent 60%),
    radial-gradient(ellipse at 80% 100%, rgba(249,115,22,0.04), transparent 60%),
    var(--pc-bg-deep);
  min-height: 100vh;
}
```

- [ ] **Step 9: 安装并测试 vite 启动**

```bash
cd apps/desktop && bun install
bunx vite build
```

Expected: build succeeds (虽然 App.tsx 还没写，下个 task 加)

- [ ] **Step 10: Commit**

```bash
git add apps/desktop/
git commit -m "feat(desktop): scaffold vite + react + tailwind + tanstack-query"
```

---

## Task 10: 5 Tab 主壳（App.tsx + Topnav + 6 占位页）

**Files:**
- Create: `apps/desktop/src/App.tsx`
- Create: `apps/desktop/src/components/Topnav.tsx`
- Create: `apps/desktop/src/components/PageShell.tsx`
- Create: `apps/desktop/src/pages/Dashboard/index.tsx`
- Create: `apps/desktop/src/pages/Vote/index.tsx`
- Create: `apps/desktop/src/pages/Explore/index.tsx`
- Create: `apps/desktop/src/pages/DM/index.tsx`
- Create: `apps/desktop/src/pages/Monitor/index.tsx`
- Create: `apps/desktop/src/pages/Settings/index.tsx`

- [ ] **Step 1: 写 `src/components/PageShell.tsx`** —— 占位

```tsx
import type { ReactNode } from 'react';

export function PageShell({
  title,
  desc,
  children,
}: {
  title: string;
  desc?: string;
  children?: ReactNode;
}) {
  return (
    <main className="p-8 max-w-7xl mx-auto">
      <header className="mb-8">
        <h1 className="font-display text-4xl font-extrabold tracking-tight mb-2">{title}</h1>
        {desc && <p className="text-[#A1A1AA] text-base max-w-2xl leading-relaxed">{desc}</p>}
      </header>
      {children ?? (
        <div className="rounded-xl border border-[#27272F] bg-[#13131B] p-12 text-center">
          <div className="text-5xl mb-3">🚧</div>
          <div className="font-display text-xl font-bold mb-1">开发中</div>
          <div className="text-sm text-[#71717A]">
            Phase 1 仅搭骨架，本模块将在后续 Phase 落地
          </div>
        </div>
      )}
    </main>
  );
}
```

- [ ] **Step 2: 写 `src/components/Topnav.tsx`**

```tsx
import { Pill } from '@pawcast/ui-kit';

export type PageId = 'dashboard' | 'vote' | 'explore' | 'dm' | 'monitor' | 'settings';

const TABS: Array<{ id: PageId; label: string; icon: string }> = [
  { id: 'dashboard', label: 'Dashboard', icon: '🏠' },
  { id: 'vote', label: '计票器', icon: '🗳️' },
  { id: 'explore', label: '探索主播', icon: '🔍' },
  { id: 'dm', label: '私信中心', icon: '💬' },
  { id: 'monitor', label: '直播监控', icon: '📺' },
];

export function Topnav({
  active,
  onChange,
}: {
  active: PageId;
  onChange: (id: PageId) => void;
}) {
  return (
    <nav className="sticky top-0 z-50 flex items-center gap-6 px-6 py-3 bg-[#06060Aee] backdrop-blur-xl border-b border-[#27272F]">
      <button
        type="button"
        onClick={() => onChange('dashboard')}
        className="flex items-center gap-2 font-display text-lg font-extrabold"
      >
        🐾 Pawcast
      </button>
      <div className="flex gap-1 flex-1 ml-2 pl-4 border-l border-[#27272F]">
        {TABS.map((t) => (
          <button
            key={t.id}
            type="button"
            onClick={() => onChange(t.id)}
            className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
              t.id === active
                ? 'bg-gradient-to-br from-[#8B5CF6] to-[#F97316] text-white shadow-[0_4px_14px_-4px_rgba(139,92,246,0.6)]'
                : 'text-[#A1A1AA] hover:bg-[#1C1C26] hover:text-white'
            }`}
          >
            {t.label}
          </button>
        ))}
      </div>
      <Pill variant="live">LIVE · 0</Pill>
      <button
        type="button"
        onClick={() => onChange('settings')}
        aria-label="settings"
        className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${
          active === 'settings' ? 'bg-[#1C1C26] text-white' : 'text-[#A1A1AA] hover:bg-[#1C1C26]'
        }`}
      >
        ⚙
      </button>
    </nav>
  );
}
```

- [ ] **Step 3: 写 `src/pages/Dashboard/index.tsx`**

```tsx
import { PageShell } from '../../components/PageShell';
export function Dashboard() {
  return (
    <PageShell
      title="Dashboard"
      desc="一眼看完今天的团播状态：在线直播间、计票战况、私信进度、礼物总流水。"
    />
  );
}
```

- [ ] **Step 4: 写其他 5 个占位页（Vote / Explore / DM / Monitor / Settings）**

每个文件结构相同。例：

`src/pages/Vote/index.tsx`:
```tsx
import { PageShell } from '../../components/PageShell';
export function Vote() {
  return (
    <PageShell
      title="计票器"
      desc="对标 MCAStar 的核心模块。5 种玩法（贴纸舞 / 攻守擂 / 多人 PK / Solo Stage / Freedom）。"
    />
  );
}
```

`src/pages/Explore/index.tsx`:
```tsx
import { PageShell } from '../../components/PageShell';
export function Explore() {
  return <PageShell title="探索主播" desc="从 TikTok 公开页面挖掘潜力主播 + AI 7 维度评分。" />;
}
```

`src/pages/DM/index.tsx`:
```tsx
import { PageShell } from '../../components/PageShell';
export function DM() {
  return (
    <PageShell title="私信中心" desc="AI 起草 + 人工审核 + 速率限制 + 模板库 + 漏斗追踪。" />
  );
}
```

`src/pages/Monitor/index.tsx`:
```tsx
import { PageShell } from '../../components/PageShell';
export function Monitor() {
  return <PageShell title="直播监控" desc="同时盯 8+ 团员直播间 + PK 战役自动追踪。" />;
}
```

`src/pages/Settings/index.tsx`（先简化，下个 task 接 IPC）:
```tsx
import { PageShell } from '../../components/PageShell';
export function Settings() {
  return <PageShell title="设置中心" desc="授权 / 设备 / AI Key / 语言 / 主题 / 关于。" />;
}
```

- [ ] **Step 5: 写 `src/App.tsx`**

```tsx
import { useState } from 'react';
import { Topnav, type PageId } from './components/Topnav';
import { Dashboard } from './pages/Dashboard';
import { Vote } from './pages/Vote';
import { Explore } from './pages/Explore';
import { DM } from './pages/DM';
import { Monitor } from './pages/Monitor';
import { Settings } from './pages/Settings';

export function App() {
  const [page, setPage] = useState<PageId>('dashboard');
  return (
    <>
      <Topnav active={page} onChange={setPage} />
      {page === 'dashboard' && <Dashboard />}
      {page === 'vote' && <Vote />}
      {page === 'explore' && <Explore />}
      {page === 'dm' && <DM />}
      {page === 'monitor' && <Monitor />}
      {page === 'settings' && <Settings />}
    </>
  );
}
```

- [ ] **Step 6: 启动 vite 验证**

```bash
cd apps/desktop && bunx vite
```

打开 http://localhost:5173 - 应能看到 Pawcast 顶栏 + Dashboard 占位 + 切换 5 Tab 无错。

- [ ] **Step 7: Commit**

```bash
git add apps/desktop/src/
git commit -m "feat(desktop): add 5 Tab shell with PageShell placeholder"
```

---

## Task 11: Electron 主进程 + preload bridge

**Files:**
- Create: `apps/desktop/electron/main.ts`
- Create: `apps/desktop/electron/preload.ts`
- Create: `apps/desktop/electron/window.ts`
- Create: `apps/desktop/electron/ipc-router.ts`

- [ ] **Step 1: 写 `electron/window.ts`**

```typescript
import { BrowserWindow } from 'electron';
import { join } from 'node:path';

export function createMainWindow(devUrl?: string): BrowserWindow {
  const win = new BrowserWindow({
    width: 1400,
    height: 900,
    minWidth: 1100,
    minHeight: 720,
    backgroundColor: '#06060A',
    titleBarStyle: 'hiddenInset',
    webPreferences: {
      preload: join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  if (devUrl) {
    win.loadURL(devUrl);
  } else {
    win.loadFile(join(__dirname, '../dist/index.html'));
  }
  return win;
}
```

- [ ] **Step 2: 写 `electron/preload.ts`**

```typescript
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('pawcast', {
  invoke: (method: string, payload: unknown) => ipcRenderer.invoke(method, payload),
  on: (event: string, listener: (data: unknown) => void) => {
    const wrapped = (_e: unknown, data: unknown) => listener(data);
    ipcRenderer.on(event, wrapped);
    return () => ipcRenderer.off(event, wrapped);
  },
});

declare global {
  interface Window {
    pawcast: {
      invoke: (method: string, payload: unknown) => Promise<unknown>;
      on: (event: string, listener: (data: unknown) => void) => () => void;
    };
  }
}
```

- [ ] **Step 3: 写 `electron/ipc-router.ts`**

```typescript
import { ipcMain } from 'electron';
import { SettingsContract, type MethodName } from '@pawcast/ipc-contract';
import { logger } from '@pawcast/core';
import { openDb, runMigrations, SettingsRepo } from '@pawcast/db';

export function registerIpcRouter() {
  const db = openDb();
  runMigrations(db);
  const settings = new SettingsRepo(db);

  // settings.get
  ipcMain.handle('settings.get', async (_e, raw) => {
    const input = SettingsContract['settings.get'].input.parse(raw);
    const value = settings.get(input.key);
    return SettingsContract['settings.get'].output.parse(value);
  });

  // settings.set
  ipcMain.handle('settings.set', async (_e, raw) => {
    const input = SettingsContract['settings.set'].input.parse(raw);
    settings.set(input.key, input.value);
    return SettingsContract['settings.set'].output.parse({ ok: true });
  });

  // settings.listAiKeys
  ipcMain.handle('settings.listAiKeys', async () => {
    const result = {
      anthropic: settings.get('ai.anthropic.key'),
      openai: settings.get('ai.openai.key'),
      qwen: settings.get('ai.qwen.key'),
    };
    return SettingsContract['settings.listAiKeys'].output.parse(result);
  });

  logger.info('ipc-router', 'registered');
}
```

- [ ] **Step 4: 写 `electron/main.ts`**

```typescript
import { app } from 'electron';
import { logger } from '@pawcast/core';
import { startWidgetServer, type WidgetServer } from '@pawcast/widget-server';
import { createMainWindow } from './window';
import { registerIpcRouter } from './ipc-router';

let widgetServer: WidgetServer | null = null;

app.whenReady().then(() => {
  registerIpcRouter();
  widgetServer = startWidgetServer(17777);
  const devUrl = process.env.PAWCAST_DEV_URL;
  createMainWindow(devUrl);
  logger.info('main', 'pawcast started', { devUrl });
});

app.on('window-all-closed', () => {
  widgetServer?.stop();
  if (process.platform !== 'darwin') app.quit();
});

app.on('before-quit', () => {
  widgetServer?.stop();
});
```

- [ ] **Step 5: 添加 desktop package.json electron build script**

更新 `apps/desktop/package.json` scripts：
```json
"scripts": {
  "dev:renderer": "vite",
  "dev:electron": "tsc -p tsconfig.electron.json && PAWCAST_DEV_URL=http://localhost:5173 electron dist-electron/main.js",
  "build": "vite build && tsc -p tsconfig.electron.json"
}
```

- [ ] **Step 6: 测试 electron 能起**

```bash
cd apps/desktop
bunx vite &       # 后台跑 renderer
sleep 2
bun run dev:electron
```

应能看到 Electron 窗口打开，5 Tab 切换无错。

按 Cmd+Q 关闭。

- [ ] **Step 7: Commit**

```bash
git add apps/desktop/electron/ apps/desktop/package.json
git commit -m "feat(desktop): wire electron main + preload + IPC router for settings"
```

---

## Task 12: IPC 客户端 + Settings 完整闭环（AI Key 配置可写入）

**Files:**
- Create: `apps/desktop/src/lib/ipc-client.ts`
- Create: `apps/desktop/src/stores/settingsStore.ts`
- Modify: `apps/desktop/src/pages/Settings/index.tsx`

- [ ] **Step 1: 写 `src/lib/ipc-client.ts`**

```typescript
import { z } from 'zod';
import {
  SettingsContract,
  type MethodName,
  type MethodInput,
  type MethodOutput,
} from '@pawcast/ipc-contract';

declare global {
  interface Window {
    pawcast: {
      invoke: (method: string, payload: unknown) => Promise<unknown>;
      on: (event: string, listener: (data: unknown) => void) => () => void;
    };
  }
}

const allMethods = { ...SettingsContract };

export async function invoke<K extends MethodName>(
  method: K,
  input: MethodInput<K>,
): Promise<MethodOutput<K>> {
  const contract = allMethods[method];
  const validInput = contract.input.parse(input);
  const raw = await window.pawcast.invoke(method, validInput);
  return contract.output.parse(raw) as MethodOutput<K>;
}
```

- [ ] **Step 2: 写 `src/stores/settingsStore.ts`** —— 简单 zustand store

```typescript
import { create } from 'zustand';
import { invoke } from '../lib/ipc-client';

interface SettingsState {
  aiKeys: {
    anthropic: string | null;
    openai: string | null;
    qwen: string | null;
  };
  loaded: boolean;
  load: () => Promise<void>;
  setAiKey: (provider: 'anthropic' | 'openai' | 'qwen', value: string) => Promise<void>;
}

export const useSettingsStore = create<SettingsState>((set, get) => ({
  aiKeys: { anthropic: null, openai: null, qwen: null },
  loaded: false,
  load: async () => {
    const aiKeys = await invoke('settings.listAiKeys', undefined);
    set({ aiKeys, loaded: true });
  },
  setAiKey: async (provider, value) => {
    await invoke('settings.set', { key: `ai.${provider}.key`, value });
    set({ aiKeys: { ...get().aiKeys, [provider]: value } });
  },
}));
```

- [ ] **Step 3: 重写 `src/pages/Settings/index.tsx`**

```tsx
import { useEffect, useState } from 'react';
import { Button, Pill } from '@pawcast/ui-kit';
import { PageShell } from '../../components/PageShell';
import { useSettingsStore } from '../../stores/settingsStore';

export function Settings() {
  const { aiKeys, loaded, load, setAiKey } = useSettingsStore();
  const [drafts, setDrafts] = useState({ anthropic: '', openai: '', qwen: '' });

  useEffect(() => {
    if (!loaded) load();
  }, [loaded, load]);

  return (
    <PageShell title="设置中心" desc="授权 / 设备 / AI Key / 语言 / 主题 / 关于">
      <div className="rounded-xl border border-[#27272F] bg-[#13131B] p-6">
        <h2 className="font-display text-xl font-bold mb-1">AI 模型 Key</h2>
        <p className="text-sm text-[#71717A] mb-6">
          配置至少 1 个 Provider 才能使用 AI 起草 / 视频分析。Key 加密存储到本地，不上传服务器。
        </p>
        {(['anthropic', 'openai', 'qwen'] as const).map((p) => (
          <div key={p} className="flex items-center gap-3 py-3 border-b border-[#27272F] last:border-0">
            <span className="font-medium w-32 capitalize">{p}</span>
            <input
              className="flex-1 bg-[#1C1C26] border border-[#27272F] rounded-md px-3 py-1.5 text-sm font-mono outline-none focus:border-[#8B5CF6]"
              value={drafts[p] || aiKeys[p] || ''}
              placeholder={aiKeys[p] ? '已设置（重新输入覆盖）' : `${p} API Key`}
              onChange={(e) => setDrafts((d) => ({ ...d, [p]: e.target.value }))}
            />
            {aiKeys[p] ? <Pill variant="success">已设置</Pill> : <Pill variant="muted">未配置</Pill>}
            <Button
              variant="primary"
              onClick={async () => {
                if (drafts[p]) {
                  await setAiKey(p, drafts[p]);
                  setDrafts((d) => ({ ...d, [p]: '' }));
                }
              }}
            >
              保存
            </Button>
          </div>
        ))}
      </div>
    </PageShell>
  );
}
```

- [ ] **Step 4: 跑 dev mode 验证**

```bash
cd apps/desktop && bunx vite & sleep 2 && bun run dev:electron
```

- 在 Settings 页面输入一个测试 Key
- 点保存
- 重启 app
- Key 仍然存在（"已设置" pill 显示）

- [ ] **Step 5: Commit**

```bash
git add apps/desktop/src/lib/ apps/desktop/src/stores/ apps/desktop/src/pages/Settings/
git commit -m "feat(desktop): wire Settings page to IPC + persistence (AI Key roundtrip works)"
```

---

## Task 13: scripts/dev.ts 一键启动

**Files:**
- Create: `scripts/dev.ts`
- Modify: 根 `package.json` 已有 `bun dev` 指向 `scripts/dev.ts`

- [ ] **Step 1: 写 `scripts/dev.ts`**

```typescript
#!/usr/bin/env bun
// 一键启动: vite renderer + electron main，依赖 vite up 后再启 electron

import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';

console.log('🐾 Pawcast dev mode starting...');

// 1. start vite
const vite = spawn('bun', ['x', 'vite'], {
  cwd: 'apps/desktop',
  stdio: 'inherit',
});

// 2. wait for vite to listen
console.log('Waiting for vite to listen on :5173...');
let viteReady = false;
for (let i = 0; i < 30; i++) {
  await sleep(500);
  try {
    const res = await fetch('http://localhost:5173');
    if (res.ok) {
      viteReady = true;
      break;
    }
  } catch {}
}
if (!viteReady) {
  console.error('Vite did not start in 15s; aborting.');
  vite.kill();
  process.exit(1);
}

// 3. compile electron main + start
const tsc = spawn(
  'bun',
  ['x', 'tsc', '-p', 'apps/desktop/tsconfig.electron.json'],
  { stdio: 'inherit' },
);
await new Promise<void>((res) => tsc.on('exit', () => res()));

const electron = spawn('bun', ['x', 'electron', 'apps/desktop/dist-electron/main.js'], {
  stdio: 'inherit',
  env: { ...process.env, PAWCAST_DEV_URL: 'http://localhost:5173' },
});

// 4. cleanup
process.on('SIGINT', () => {
  vite.kill();
  electron.kill();
  process.exit(0);
});
electron.on('exit', () => {
  vite.kill();
  process.exit(0);
});
```

- [ ] **Step 2: 测试 `bun dev`**

```bash
cd /Users/luzhipeng/projects/pawcast
bun dev
```

应能：
- 终端打印 vite 启动 + electron 启动
- Mac Dock 出现 Pawcast 应用
- 窗口显示 5 Tab 主壳
- Cmd+Q 退出，所有进程清理

- [ ] **Step 3: Commit**

```bash
git add scripts/dev.ts
git commit -m "feat(scripts): add one-shot dev launcher for vite + electron"
```

---

## Task 14: scripts/seed-db.ts + 集成测试

**Files:**
- Create: `scripts/seed-db.ts`
- Create: `tests/integration/db-seed.test.ts`

- [ ] **Step 1: 写 `scripts/seed-db.ts`**

```typescript
#!/usr/bin/env bun
// 灌一些测试数据到本地 DB，方便开发时看 UI 效果

import { openDb, runMigrations, SettingsRepo } from '@pawcast/db';
import { logger } from '@pawcast/core';

const db = openDb();
runMigrations(db);
const settings = new SettingsRepo(db);

settings.set('device.alias', 'Yokohama-Studio-01');
settings.set('app.first_run_at', String(Date.now()));
settings.set('ui.theme', 'dark');

logger.info('seed', 'done');
db.close();
```

- [ ] **Step 2: 写 `tests/integration/db-seed.test.ts`**

```typescript
import { test, expect } from 'bun:test';
import { openMemoryDb, runMigrations, SettingsRepo } from '@pawcast/db';

test('integration: full DB roundtrip via memory DB', () => {
  const db = openMemoryDb();
  runMigrations(db);
  const repo = new SettingsRepo(db);
  repo.set('device.alias', 'Yokohama-Studio-01');
  repo.set('ui.theme', 'dark');
  expect(repo.get('device.alias')).toBe('Yokohama-Studio-01');
  expect(repo.get('ui.theme')).toBe('dark');
  expect(repo.all().length).toBe(2);
  db.close();
});
```

- [ ] **Step 3: 跑测试**

```bash
bun test tests/integration/
```

Expected: 1 passed.

- [ ] **Step 4: 手动跑 seed**

```bash
bun seed
```

- [ ] **Step 5: Commit**

```bash
git add scripts/seed-db.ts tests/
git commit -m "feat(scripts): add seed-db + integration roundtrip test"
```

---

## Task 15: README + 准出验证

**Files:**
- Modify: `README.md`

- [ ] **Step 1: 完善 `README.md`**

```markdown
# Pawcast

TikTok 团播桌面工作台。Electron + Bun + React 19。仅支持 macOS dev mode（v0.1）。

## Setup

\`\`\`bash
bun install
bunx playwright install chromium  # Phase 2 才用，现在不必装
\`\`\`

## Develop

\`\`\`bash
bun dev          # 启动 vite + electron + widget server
bun test         # 跑全部测试
bun typecheck    # TypeScript 检查
bun lint         # Biome lint
bun fmt          # Biome format
bun seed         # 灌测试数据
\`\`\`

## 项目结构

- \`apps/desktop\` - Electron 桌面应用
- \`apps/widgets\` - 挂件 HTML
- \`packages/core\` - 核心业务逻辑
- \`packages/db\` - SQLite 持久化
- \`packages/widget-server\` - 挂件 HTTP server
- \`packages/ipc-contract\` - IPC RPC schema
- \`packages/ui-kit\` - 共享 UI 组件

详见 \`docs/superpowers/plans/2026-05-02-pawcast-architecture.md\`。

## 数据存储

- DB 路径：\`~/.pawcast/pawcast.sqlite\`
- 录制文件：\`~/.pawcast/recordings/\`（Phase 2 开始用）
- 日志：\`~/.pawcast/logs/\`（Phase 2 开始用）

## 验证 Phase 1 准出

| 项 | 操作 | 期望 |
|---|---|---|
| dev 启动 | \`bun dev\` | 终端无错 + Electron 窗口打开 + 5 Tab 切换不报错 |
| 单元测试 | \`bun test\` | ≥10 个测试通过 |
| 挂件 server | 浏览器打开 \`http://127.0.0.1:17777/widget/hello\` | 看到 placeholder html |
| Settings 闭环 | Settings 页面输入 AI Key 保存 → 重启 → Key 还在 | ✅ |
\`\`\`

- [ ] **Step 2: 跑全部 sanity check**

```bash
bun typecheck
bun test
bun lint
```

Expected: all pass.

- [ ] **Step 3: 跑 dev 一键启动验证**

```bash
bun dev
```

5 步验证：
1. ✅ 终端不报错
2. ✅ Electron 窗口打开
3. ✅ 切换 5 Tab + Settings 不报错
4. ✅ Settings 页面能保存 AI Key 并重启后保留
5. ✅ 浏览器打开 `http://127.0.0.1:17777/widget/hello` 看到 placeholder html

- [ ] **Step 4: Commit**

```bash
git add README.md
git commit -m "docs: complete Phase 1 README + acceptance checklist"
```

- [ ] **Step 5: 创建 Phase 1 完成 tag**

```bash
git tag phase-1-foundation-complete
```

---

## Phase 1 准出清单

完成所有 task 后，验证以下条目，逐项打勾：

- [ ] `bun install` 成功
- [ ] `bun typecheck` 0 错误
- [ ] `bun test` 全部通过（≥10 个测试）
- [ ] `bun lint` 0 警告
- [ ] `bun dev` 启动成功
  - [ ] Electron 窗口打开
  - [ ] 顶栏 5 Tab 可切换
  - [ ] Dashboard / Vote / Explore / DM / Monitor 显示"开发中"占位
  - [ ] Settings 页面显示 3 个 AI Key 输入框
- [ ] Settings 闭环
  - [ ] 输入 Anthropic Key → 点保存 → 出现"已设置"
  - [ ] Cmd+Q 关闭 → 重启 `bun dev` → Key 仍存在
- [ ] DB 文件存在 `~/.pawcast/pawcast.sqlite`
  - [ ] `sqlite3 ~/.pawcast/pawcast.sqlite ".tables"` 列出 11 张表（10 业务 + _migrations）
- [ ] Widget Server
  - [ ] `curl http://127.0.0.1:17777/health` 返回 `{"ok":true}`
  - [ ] 浏览器打开 `http://127.0.0.1:17777/widget/hello` 看到紫色框 placeholder
- [ ] Git 状态
  - [ ] 所有改动已 commit
  - [ ] 含 tag `phase-1-foundation-complete`

---

## Self-Review 已完成

- ✅ Spec coverage: 本 plan 实现了 architecture 中 §1-7 的所有基础架构内容
- ✅ Placeholder scan: 所有 task 步骤都有完整代码，无 TBD/TODO
- ✅ Type consistency: SettingsContract → SettingsRepo → useSettingsStore 命名一致
- ✅ DRY/YAGNI: 仅做 Phase 1 必需，不为后续 phase 做超前抽象

下一步：开始 Phase 2 详细 plan 之前，本 phase 完成后应做一次合伙人 review。
