# P0 地基 Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 建立 Signal Deck 设计系统（双主题 tokens + 内嵌字体）、应用外壳（扁平导航 + 全局聊天 WS + 活动脊 + 主题/鉴权），并按新设计重构聊天页，产出可工作的聊天优先控制台地基。

**Architecture:** 用 CSS 自定义属性表达 Signal Deck tokens（`:root` 暗色 / `:root[data-theme="light"]` 亮色），整体重写 `styles.css`。将聊天 WebSocket 连接从 ChatPage 提升为模块级单例 `lib/chat.svelte.js`，由 App 外壳统一持有，使活动脊在所有页面可用；ChatPage 订阅帧并保留全部现有逻辑（会话/消息/turn 快照/计划/上传/斜杠命令）。两个拉丁字体经 vite `publicDir` 以固定名输出，`http.rs` 用 `include_bytes!` 内嵌并提供同源路由，维持单二进制与现有 CSP。

**Tech Stack:** Svelte 5（runes）、Bits UI、Vite、CSS custom properties；Rust/Axum（字体路由）、build.rs + vite（嵌入管线）。

**验证约定（重要）：** 本仓库前端**没有单元测试框架**。前端任务以 `npm run check`（svelte-check）+ `npm run build` + 浏览器目检为验证手段（见 AGENTS.md）；涉及 Rust 的任务以 `cargo build` + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` 验证。不要虚构前端测试。

**参考文档：** 设计规格 `docs/superpowers/specs/2026-07-23-webui-refactor-design.md`（§4 设计系统、§5 信息架构、§6.1 聊天页、§8 前端架构）。配色/组件 mockup 见 `.superpowers/brainstorm/111044-1784795642/`（design-system.html、page-chat.html）。

---

## File Structure

**Create:**
- `webui/public/fonts/space-grotesk-500.woff2`、`space-grotesk-700.woff2`、`jetbrains-mono-400.woff2`、`jetbrains-mono-700.woff2` — 内嵌拉丁字体（vite publicDir 原样复制到产物根）
- `webui/public/theme-init.js` — 首屏防闪烁主题初始化脚本（CSP 安全，经 `/theme-init.js` 路由提供）
- `webui/src/lib/theme.js` — 主题检测/应用/持久化
- `webui/src/lib/chat.svelte.js` — 全局聊天 WS 单例（连接/重连/订阅/发送/最新 turn 快照）
- `webui/src/lib/components/ActivitySpine.svelte` — 全局活动脊

**Modify:**
- `webui/src/styles.css` — 全面重写为 Signal Deck tokens + @font-face + 组件样式
- `webui/src/App.svelte` — 外壳：扁平导航、全局 WS、活动脊、主题切换、鉴权
- `webui/src/pages/ChatPage.svelte` — 改用全局 chat client + Signal Deck 三栏布局（保留全部逻辑）
- `webui/src/pages/PairingPage.svelte` — 套用新 tokens（结构不变）
- `webui/index.html` — theme-color 更新为 `#0b1017` + `<head>` 引入 `/theme-init.js`
- `webui/src/lib/ToolCallCard.svelte`、`TurnView.svelte`、`Markdown.svelte`、`Toast.svelte` — 套用新 tokens/类名（StatusBadge 无独立样式，随 styles.css 的 `.badge.*` 更新）
- `src/gateway/http.rs` — 字体路由（include_bytes! + font/woff2）+ `/theme-init.js` handler（include_str!）
- `src/gateway/mod.rs` — 公开静态路由组追加 `/fonts/{name}` 与 `/theme-init.js`
- `build.rs` — `rerun-if-changed` 增加 `webui/public`

**不动：** 后端聊天/配置/记忆等现有端点（P0 纯前端 + 字体路由）。

---

## Chunk 1: 设计 tokens 与字体内嵌管线

### Task 1.1: 内嵌字体（publicDir + http.rs 路由）

**Files:**
- Create: `webui/public/fonts/{space-grotesk-500,space-grotesk-700,jetbrains-mono-400,jetbrains-mono-700}.woff2`
- Modify: `src/gateway/http.rs`（新增字体 handler 与路由）
- Modify: `src/gateway/mod.rs`（注册 `/fonts/{name}` 路由，公开静态资源层）
- Modify: `build.rs`（`rerun-if-changed=webui/public`）

- [ ] **Step 1: 获取并提交字体文件**

从 @fontsource 取 latin 子集 woff2（版本锁定、可复现）：

```bash
cd webui
npm i -D @fontsource/space-grotesk @fontsource/jetbrains-mono
mkdir -p public/fonts
cp node_modules/@fontsource/space-grotesk/files/space-grotesk-latin-500-normal.woff2 public/fonts/space-grotesk-500.woff2
cp node_modules/@fontsource/space-grotesk/files/space-grotesk-latin-700-normal.woff2 public/fonts/space-grotesk-700.woff2
cp node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2 public/fonts/jetbrains-mono-400.woff2
cp node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2 public/fonts/jetbrains-mono-700.woff2
```

若 @fontsource 文件路径/命名随版本不同，用 `ls node_modules/@fontsource/*/files/ | grep latin` 找到对应 latin 500/700/400 的 normal woff2。确认 4 个文件均为非空 woff2。@fontsource 仅为取字体的 devDependency，运行时不依赖。

- [ ] **Step 2: build.rs 监听 public 目录**

在 `build.rs` 的 `build_webui` 的监听列表（约 64-73 行）追加：

```rust
        "webui/public",
```

- [ ] **Step 3: http.rs 增加字体 handler**

在 `src/gateway/http.rs`（`webui_styles` 之后）新增：

```rust
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
    (
        "space-grotesk-500.woff2",
        include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-500.woff2")),
    ),
    (
        "space-grotesk-700.woff2",
        include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-700.woff2")),
    ),
    (
        "jetbrains-mono-400.woff2",
        include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-400.woff2")),
    ),
    (
        "jetbrains-mono-700.woff2",
        include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-700.woff2")),
    ),
];

pub async fn webui_font(Path(name): Path<String>) -> Response {
    let bytes = EMBEDDED_FONTS
        .iter()
        .find(|(font_name, _)| *font_name == name)
        .map(|(_, bytes)| *bytes);
    let Some(bytes) = bytes else {
        return StatusCode::NOT_FOUND.into_response();
    };
    Response::builder()
        .header(header::CONTENT_TYPE, "font/woff2")
        .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
        .header("X-Content-Type-Options", "nosniff")
        .body(Body::from(bytes))
        .expect("valid font response")
}
```

（`Path` 已在文件顶部 `axum::extract` 导入。）

- [ ] **Step 4: mod.rs 注册字体路由（公开层，随静态资源）**

在 `src/gateway/mod.rs` 的公开静态路由组（约 592-596 行，`/`、`/app.js`、`/styles.css` 处）追加：

```rust
        .route("/fonts/{name}", routing::get(http::webui_font))
```

字体属静态资源层，不进设备鉴权（与 app.js/styles.css 同级；CSP `default-src 'self'` 已允许同源 font）。

- [ ] **Step 5: 构建验证**

Run: `cargo build`（会自动触发 vite 构建，public/fonts 复制到 OUT_DIR/webui/fonts）
Expected: 编译成功，无 clippy 级错误。

Run: `cargo clippy --all-targets --all-features -- -D warnings`
Expected: 无警告。

- [ ] **Step 6: Commit**

```bash
git add webui/public/fonts build.rs src/gateway/http.rs src/gateway/mod.rs webui/package.json webui/package-lock.json
git commit -m "feat(webui): embed latin fonts and serve via /fonts route"
```

### Task 1.2: 重写 styles.css 为 Signal Deck tokens

**Files:**
- Modify: `webui/src/styles.css`（整体重写）

- [ ] **Step 1: 写入 @font-face 与 tokens**

将 `styles.css` 顶部的 `:root` / `:root[data-theme="light"]` 块整体替换为（保留文件其余组件类，随后在 Step 2 调整）：

```css
@font-face {
  font-family: "Space Grotesk";
  src: url("/fonts/space-grotesk-500.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Space Grotesk";
  src: url("/fonts/space-grotesk-700.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/fonts/jetbrains-mono-400.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/fonts/jetbrains-mono-700.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}

:root {
  --font-ui: "Space Grotesk", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
  color-scheme: dark;
  font-family: var(--font-ui);
  color: #e7ecf3;
  background: #0b1017;
  --bg: #0b1017;
  --panel: #0e1520;
  --panel-2: #131c29;
  --sidebar: #0d131c;
  --header: rgb(11 16 23 / 84%);
  --line: #1d2733;
  --line-strong: #2c3a4c;
  --muted: #8fa3b8;
  --faint: #5b6b7e;
  --text: #e7ecf3;
  --text-soft: #b8c4d4;
  --accent: #ffb454;          /* amber = 活动 */
  --accent-hover: #ffc370;
  --accent-contrast: #1a1206;
  --accent-soft: rgb(255 180 84 / 12%);
  --accent-border: rgb(255 180 84 / 35%);
  --signal: #2dd4bf;          /* teal = 健康 */
  --signal-soft: rgb(45 212 191 / 12%);
  --signal-border: rgb(45 212 191 / 35%);
  --info: #6aa6ff;
  --info-soft: rgb(106 166 255 / 12%);
  --danger: #ff7b86;
  --danger-soft: rgb(255 123 134 / 12%);
  --danger-border: rgb(255 123 134 / 35%);
  --warning: #ffb454;
  --warning-soft: rgb(255 180 84 / 10%);
  --success-soft: rgb(45 212 191 / 12%);
  --overlay: #101826;
  --code-bg: #080c12;
  --user-bubble: #221d38;
  --spine-bg: #0e1520;        /* 活动脊：亮色下也保持深色 */
  --shadow: 0 16px 45px rgb(0 0 0 / 35%);
  --radius: 11px;
}

:root[data-theme="light"] {
  color-scheme: light;
  color: #1a2230;
  background: #eef1f5;
  --bg: #eef1f5;
  --panel: #ffffff;
  --panel-2: #f4f6f9;
  --sidebar: #f7f9fc;
  --header: rgb(238 241 245 / 86%);
  --line: #d8dee8;
  --line-strong: #c2ccd9;
  --muted: #5b6b7e;
  --faint: #8494a8;
  --text: #1a2230;
  --text-soft: #3d4b5e;
  --accent: #c47400;
  --accent-hover: #a86300;
  --accent-contrast: #ffffff;
  --accent-soft: rgb(196 116 0 / 10%);
  --accent-border: rgb(196 116 0 / 35%);
  --signal: #0d9488;
  --signal-soft: rgb(13 148 136 / 10%);
  --signal-border: rgb(13 148 136 / 35%);
  --info: #2f6fd0;
  --info-soft: rgb(47 111 208 / 10%);
  --danger: #d94354;
  --danger-soft: rgb(217 67 84 / 10%);
  --danger-border: rgb(217 67 84 / 35%);
  --warning: #c47400;
  --warning-soft: rgb(196 116 0 / 8%);
  --success-soft: rgb(13 148 136 / 10%);
  --overlay: #ffffff;
  --code-bg: #f7f9fc;
  --user-bubble: #ece7fb;
  --spine-bg: #0e1520;        /* 亮色下活动脊仍是深色 LED 条 */
  --shadow: 0 16px 45px rgb(31 41 55 / 12%);
}
```

- [ ] **Step 2: 调整组件类以适配新 tokens**

逐个检查并更新其余组件类（原文件 60 行起）：
- 所有 `font-family` 硬编码处改用 `var(--font-ui)`；数据/日志/时间戳/`code`/`.mono` 类用 `var(--font-mono)`。
- 原紫色相关（`--accent` 旧值、`--user-bubble`）已由 tokens 替换，确认无残留硬编码 hex。
- `.primary` 按钮：`background: var(--accent); color: var(--accent-contrast);`（暗色下琥珀底深字，亮色下深琥珀底白字）。
- 状态点/在线指示：健康用 `var(--signal)`，活动/进行中用 `var(--accent)`，错误用 `var(--danger)`。
- **两处硬编码绿色必须手动改为 `var(--signal)`**（否则不随 tokens 更新）：`.gateway-status i.online { color: #48b985 }`（约 93 行）与 `.badge.ok { color: #38a877 }`（约 267 行，StatusBadge 的颜色实际来自这里）。
- 新增工具类（供组件使用）：

```css
.mono { font-family: var(--font-mono); }
.label-caps { font-family: var(--font-mono); font-size: 9px; letter-spacing: .16em; color: var(--faint); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
.cap { display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; font-weight: 600; border-radius: 6px; padding: 2.5px 8px; }
.cap.signal { color: var(--signal); background: var(--signal-soft); border: 1px solid var(--signal-border); }
.cap.accent { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-border); }
.cap.danger { color: var(--danger); background: var(--danger-soft); border: 1px solid var(--danger-border); }
.cap.info { color: var(--info); background: var(--info-soft); border: 1px solid var(--line); }
@keyframes spine-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; animation: spine-pulse 1.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
```

- [ ] **Step 3: 验证**

Run: `cd webui && npm run check && npm run build`
Expected: svelte-check 无错误；构建成功。

- [ ] **Step 4: Commit**

```bash
git add webui/src/styles.css
git commit -m "feat(webui): Signal Deck design tokens and base styles"
```

---

## Chunk 2: 核心 lib 与应用外壳

### Task 2.1: theme.js 主题管理

**Files:**
- Create: `webui/src/lib/theme.js`

- [ ] **Step 1: 实现**

```js
const STORAGE_KEY = "picobot-theme";

export function preferredTheme() {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved === "light" || saved === "dark") return saved;
  return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

export function applyTheme(theme) {
  document.documentElement.dataset.theme = theme;
  document.documentElement.style.colorScheme = theme;
  document
    .querySelector('meta[name="theme-color"]')
    ?.setAttribute("content", theme === "dark" ? "#0b1017" : "#eef1f5");
  localStorage.setItem(STORAGE_KEY, theme);
}
```

- [ ] **Step 2: 验证** — `cd webui && npm run check`（无错误）
- [ ] **Step 3: Commit** — `git add webui/src/lib/theme.js && git commit -m "feat(webui): theme helpers"`

### Task 2.2: chat.svelte.js 全局聊天客户端

**Files:**
- Create: `webui/src/lib/chat.svelte.js`

将 ChatPage 的连接/重连生命周期提取为模块级单例。帧分发保留给订阅者（ChatPage 搬入其 `handleFrame` 逻辑）；客户端额外暴露最新 turn 快照供活动脊使用。

- [ ] **Step 1: 实现**

```js
import { clientId } from "./api.js";

class ChatClient {
  connected = $state(false);
  turn = $state(null); // 最新 turn 快照（任意 session），供活动脊
  #socket = null;
  #handlers = new Set();
  #reconnectTimer = null;
  #stopped = false;

  connect() {
    if (this.#socket) return;
    this.#stopped = false;
    const scheme = location.protocol === "https:" ? "wss" : "ws";
    const ws = new WebSocket(`${scheme}://${location.host}/ws?client_id=${encodeURIComponent(clientId())}`);
    this.#socket = ws;
    ws.onopen = () => {
      this.connected = true;
      this.#dispatch({ type: "_open" });
    };
    ws.onerror = () => ws.close();
    ws.onclose = () => {
      this.connected = false;
      this.#socket = null;
      this.#dispatch({ type: "_close" });
      if (!this.#stopped) this.#reconnectTimer = setTimeout(() => this.connect(), 1800);
    };
    ws.onmessage = (event) => {
      const frame = JSON.parse(event.data);
      if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot;
      this.#dispatch(frame);
    };
  }

  disconnect() {
    this.#stopped = true;
    clearTimeout(this.#reconnectTimer);
    this.#socket?.close();
    this.#socket = null;
  }

  send(frame) {
    if (this.#socket?.readyState === WebSocket.OPEN) {
      this.#socket.send(JSON.stringify(frame));
      return true;
    }
    return false;
  }

  subscribe(handler) {
    this.#handlers.add(handler);
    return () => this.#handlers.delete(handler);
  }

  #dispatch(frame) {
    for (const handler of this.#handlers) handler(frame);
  }
}

export const chat = new ChatClient();
```

- [ ] **Step 2: 验证** — `cd webui && npm run check`
- [ ] **Step 3: Commit** — `git add webui/src/lib/chat.svelte.js && git commit -m "feat(webui): global chat websocket client"`

### Task 2.3: ActivitySpine.svelte 活动脊

**Files:**
- Create: `webui/src/lib/components/ActivitySpine.svelte`

- [ ] **Step 1: 实现**

活动脊显示：Turn 实时状态（来自 `chat.turn` 快照）+ 吞吐（前端对相邻帧 `usage.completion_tokens` 差值求导）+ 连接状态。gen/uptime/metrics 等字段在 P1 由 `/api/status` 补充，P0 先显示版本与连接态。

```svelte
<script>
  import { chat } from "../chat.svelte.js";

  let { version = "" } = $props();
  let lastTokens = $state(null); // { at, completion }
  let rate = $state(null);

  $effect(() => {
    const turn = chat.turn;
    if (!turn || turn.status !== "running") { rate = null; return; }
    const completion = turn.usage?.completion_tokens;
    const now = Date.now();
    if (completion != null && lastTokens && now > lastTokens.at) {
      const delta = completion - lastTokens.completion;
      const secs = (now - lastTokens.at) / 1000;
      if (delta >= 0 && secs > 0) rate = Math.round(delta / secs);
    }
    if (completion != null) lastTokens = { at: now, completion };
  });

  const running = $derived(chat.turn?.status === "running");
  const turnLabel = $derived(chat.turn ? `TURN ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : "");
  const ctx = $derived(chat.turn?.usage?.prompt_tokens != null
    ? `${(chat.turn.usage.prompt_tokens / 1000).toFixed(1)}k` : null);
</script>

<div class="spine mono">
  {#if running}
    <span class="spine-turn active"><i class="pulse-dot" style="background:var(--accent);box-shadow:0 0 10px var(--accent)"></i>{turnLabel} · STREAMING</span>
    {#if rate != null}<span class="spine-rate">▲ {rate} tok/s</span>{/if}
    {#if ctx}<span>ctx {ctx}</span>{/if}
  {:else if chat.turn}
    <span class="spine-turn idle"><i class="pulse-dot" style="background:var(--signal);animation:none"></i>IDLE</span>
    <span>最近 {turnLabel}</span>
  {:else}
    <span class="spine-turn idle"><i class="pulse-dot" style="background:var(--signal);animation:none"></i>READY</span>
  {/if}
  <span class="spine-right">
    <span class:spine-ok={chat.connected} class:spine-down={!chat.connected}>{chat.connected ? "已连接" : "重连中"}</span>
    {#if version}<span>{version}</span>{/if}
  </span>
</div>

<style>
  .spine { display: flex; align-items: center; gap: 14px; font-size: 10.5px; color: var(--muted);
    background: var(--spine-bg); border-bottom: 1px solid var(--line); padding: 8px 16px; }
  .spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; }
  .spine-turn.active { color: var(--accent); }
  .spine-turn.idle { color: var(--signal); }
  .spine-rate { color: var(--signal); }
  .spine-right { margin-left: auto; display: inline-flex; gap: 14px; color: var(--faint); }
  .spine-ok { color: var(--signal); }
  .spine-down { color: var(--warning); }
</style>
```

（`.mono`、`.pulse-dot` 来自 styles.css 工具类。）

- [ ] **Step 2: 验证** — `cd webui && npm run check`
- [ ] **Step 3: Commit** — `git add webui/src/lib/components/ActivitySpine.svelte && git commit -m "feat(webui): global activity spine"`

### Task 2.4: App.svelte 外壳重构

**Files:**
- Modify: `webui/src/App.svelte`

- [ ] **Step 1: 重构**

要点（保留现有鉴权/配对/health 逻辑，替换导航与布局）：
- `onMount` 中：`applyTheme(preferredTheme())`；health 轮询保留（取 version 传给 ActivitySpine）。
- **WS 生命周期跟随"已鉴权外壳"而非根 onMount**：用 `$effect` 监听 `authenticated`——`authenticated` 为真时 `chat.connect()`，为假（如凭据被撤销、外壳卸载回配对页）时 `chat.disconnect()`。避免鉴权失效后客户端仍在后台每 1.8s 静默重连。
  ```js
  $effect(() => {
    if (authenticated) { chat.connect(); } else { chat.disconnect(); }
  });
  ```
- 页面数组改为扁平导航（图标 + 名称）：`["chat","◫","聊天"]`、`["overview","◉","概览"]`、`["tools","🧰","工具&Skills"]`、`["logs","≋","日志"]`、`["memory","◇","记忆"]`、`["tasks","⌁","任务"]`、`["settings","⚙","配置"]`。P0 中 overview/tools 页面尚未实现，先渲染占位 `<div class="empty-card">即将上线</div>`（P1/P2 补齐）；logs/memory/tasks/settings 复用现有页面组件。
- 结构：`<aside class="sidebar">`（品牌 + 扁平 nav + 底部网关状态/主题切换）+ `<main>` 内 `<ActivitySpine {version} />` 置顶 + 页面区。
- 主题切换按钮调用 `applyTheme(theme === "dark" ? "light" : "dark")` 并更新 `theme` 状态。
- 需要的新 import：`import { chat } from "./lib/chat.svelte.js"`、`import { applyTheme, preferredTheme } from "./lib/theme.js"`、`import ActivitySpine from "./lib/components/ActivitySpine.svelte"`。
- WS 断开由上面的 `$effect` 负责（`authenticated=false` 时 disconnect）；如需双保险，`onMount` 清理函数 `return () => chat.disconnect()` 亦可，两者不冲突。

- [ ] **Step 2: index.html 防主题闪烁（CSP 安全方案）**

现有 CSP 为 `script-src 'self'`（无 `'unsafe-inline'`），**不能**写内联 `<script>`。改为独立同源脚本文件：

1. Create `webui/public/theme-init.js`（vite publicDir 会原样复制到 `OUT_DIR/webui/theme-init.js`）：

```js
try {
  var t = localStorage.getItem("picobot-theme");
  if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  document.documentElement.dataset.theme = t;
} catch (e) {}
```

2. `src/gateway/http.rs` 新增 handler（与 `webui_script` 同构）：

```rust
pub async fn webui_theme_init() -> Response {
    static_response(
        "text/javascript; charset=utf-8",
        include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
    )
}
```

3. `src/gateway/mod.rs` 公开静态路由组追加 `.route("/theme-init.js", routing::get(http::webui_theme_init))`。
4. `webui/index.html`：`<meta name="theme-color">` 的 `#0d1117` 改为 `#0b1017`；`<head>` 内加解析阻塞引用 `<script src="/theme-init.js"></script>`（同源，被 `script-src 'self'` 允许）。
5. File Structure 与 Task 1.1 的 build.rs 监听已含 `webui/public`（theme-init.js 随之复制）。

- [ ] **Step 3: 验证** — `cd webui && npm run check && npm run build`
- [ ] **Step 4: 目检** — `cargo run -- gateway` 后打开 http://127.0.0.1:19876/，确认：暗/亮主题切换生效且持久化、无首屏闪烁；活动脊显示"已连接/READY"；导航 7 项齐全；未实现页面显示占位。
- [ ] **Step 5: Commit** — `git add webui/public/theme-init.js src/gateway/http.rs src/gateway/mod.rs webui/src/App.svelte webui/index.html && git commit -m "feat(webui): app shell with flat nav, activity spine, and theme init"`

### Task 2.5: PairingPage 套用新 tokens

**Files:**
- Modify: `webui/src/pages/PairingPage.svelte`

- [ ] **Step 1: 将硬编码颜色替换为新 tokens**（结构与逻辑不变，仅样式对齐 Signal Deck）。
- [ ] **Step 2: 验证** — `npm run check`；未配对状态下目检配对页。
- [ ] **Step 3: Commit** — `git add webui/src/pages/PairingPage.svelte && git commit -m "style(webui): pairing page Signal Deck tokens"`

---

## Chunk 3: 聊天页重构

### Task 3.1: ChatPage 接入全局客户端 + 三栏布局

**Files:**
- Modify: `webui/src/pages/ChatPage.svelte`

这是 P0 最大的改动。**原则：全部现有业务逻辑（handleFrame 各分支、上传、斜杠补全、计划侧栏、历史校准）原样保留**，只做两件事：(a) 连接生命周期改用 `chat` 单例；(b) 套用 Signal Deck 类名/三栏布局。

- [ ] **Step 1: 连接改造**

- 删除组件内 `connect()`/`socket`/`reconnectTimer`/`stopped` 与 `onMount` 中的连接代码。
- `onMount` 中改为（**注意：重连时必须重置计划相关状态，与重构前 `connect()` 的 `onopen` 行为完全一致**）：
  ```js
  const unsubscribe = chat.subscribe(handleFrame);
  const onOpen = (frame) => {
    if (frame.type !== "_open") return;
    // 与重构前一致：每次（重）连接都重置计划状态再拉取
    plansBySession = {};
    unseenPlanSessions = {};
    todoOpen = false;
    chat.send({ type: "list_sessions", include_archived: false });
    chat.send({ type: "get_slash_commands" });
  };
  const unsubOpen = chat.subscribe(onOpen);
  if (chat.connected) onOpen({ type: "_open" }); // 已连接时首次挂载也走同一逻辑
  return () => { unsubscribe(); unsubOpen(); clearPendingUploads(); };
  ```
- 所有 `send(...)` 调用改为 `chat.send(...)`；`connected` 改读 `chat.connected`。
- `handleFrame` 中原 `session_established`/`session_list`/... 分支逻辑**不变**。

- [ ] **Step 2: 布局与样式改造**

- 顶层 `<section class="page chat-layout">` 三栏：`sessions-panel`（左）｜ `chat-panel`（中）｜ `todo-panel`（右，`{#if todoOpen && currentPlan}`）。
- 消息气泡：用户用 `var(--user-bubble)` + 右下小圆角；助手无气泡底色、正文 `var(--text-soft)`。
- reasoning `<details>` 用 `.cap.info` 风格摘要；工具调用沿用 ToolCallCard（Task 3.2 重制）。
- 流式 turn（TurnView）下方显示 `▲ tok/s`（可复用 ActivitySpine 的速率逻辑，或简单显示 `status`）。
- 输入区（composer）：容器 `var(--panel)` + `var(--line-strong)` 边框；发送按钮 `.primary`（琥珀）；连接状态点用 `--signal`/`--warning`。
- 会话项激活态：左边框 `var(--accent)` + `var(--panel-2)` 底。
- 头部操作、Todo 侧栏沿用现有结构，仅换 tokens。

- [ ] **Step 3: 验证** — `cd webui && npm run check && npm run build`
- [ ] **Step 4: 端到端目检** — `cargo run -- gateway` + 浏览器：新建对话、发消息、收到流式回复（活动脊出现 STREAMING）、工具卡片折叠展开、/ 命令补全、附件上传、Todo 侧栏随 plan_updated 弹出、切换亮/暗主题聊天页正常。
- [ ] **Step 5: Commit** — `git add webui/src/pages/ChatPage.svelte && git commit -m "feat(webui): refactor chat page onto global client and Signal Deck"`

### Task 3.2: 重制共享组件（ToolCallCard / TurnView / Toast / Markdown）

**Files:**
- Modify: `webui/src/lib/ToolCallCard.svelte`、`TurnView.svelte`、`Toast.svelte`、`Markdown.svelte`

（`StatusBadge.svelte` 本身无 `<style>`，其颜色来自 `styles.css` 的 `.badge.*`，已在 Task 1.2 处理，不在此列。）

- [ ] **Step 1: ToolCallCard** — 默认折叠卡片：左边框运行中=`var(--accent)`（脉冲点）、完成=`var(--signal)`、失败=`var(--danger)`；名称/耗时用 `.mono`；展开显示参数与结果（`<details>`）。
- [ ] **Step 2: TurnView** — 流式渲染 reasoning（折叠）+ 正文 + 工具卡片 + 光标（`.pulse-dot` 或方块闪烁）+ `▲ tok/s`。
- [ ] **Step 3: Toast / Markdown** — 套用 tokens：Toast 用 `var(--overlay)` + 对应语义色边框；Markdown 的 code/pre 用 `var(--code-bg)` + `var(--font-mono)`，链接用 `var(--info)`。
- [ ] **Step 4: 验证** — `npm run check && npm run build`；目检聊天流中的卡片/Toast/代码块。
- [ ] **Step 5: Commit** — `git add webui/src/lib && git commit -m "style(webui): shared components Signal Deck"`

### Task 3.3: P0 收尾验证

- [ ] **Step 1: 全量构建与测试**

Run: `cd webui && npm run check && npm run build`
Run: `cargo build`
Run: `cargo test --lib`
Run: `cargo clippy --all-targets --all-features -- -D warnings`
Expected: 全部通过。

- [ ] **Step 2: 回归目检清单** — 配对流程、主题切换持久化、活动脊实时性、聊天全链路（含附件/命令/计划）、既有 logs/memory/tasks/settings 页面在新 tokens 下无样式崩坏。
- [ ] **Step 3: 版本号** — 按 AGENTS.md「功能变化后更新版本号」，在 `Cargo.toml` 与 `webui/package.json` bump minor（如 1.3.0 → 1.4.0），并同步 README 中对 WebUI 的描述（如有）。
- [ ] **Step 4: Commit** — `git add -A && git commit -m "chore(release): P0 webui foundation"`

---

## P0 完成标志

- 单二进制 `cargo build` 成功，字体经 `/fonts/*` 同源提供，无 CDN。
- 亮/暗双主题覆盖外壳与聊天页，活动脊全局可见且随 turn 实时变化。
- 聊天页功能与重构前完全一致（会话/消息/流式/工具/附件/命令/计划），仅视觉与连接归属变化。
- `npm run check`、`npm run build`、`cargo build`、`cargo test --lib`、`cargo clippy -- -D warnings` 全绿。

后续 P1（观测：Metrics + /api/status + 概览页 + 工具&Skills 页）、P2（日志流式 + 记忆可写 + 任务页）、P3（配置编辑器）将各自编写独立计划。
