diff --git a/docs/superpowers/plans/2026-07-23-p0-webui-foundation.md b/docs/superpowers/plans/2026-07-23-p0-webui-foundation.md new file mode 100644 index 0000000..9a8c1a2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-p0-webui-foundation.md @@ -0,0 +1,616 @@ +# 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` + `` 引入 `/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) -> 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 + + +
+ {#if running} + {turnLabel} · STREAMING + {#if rate != null}▲ {rate} tok/s{/if} + {#if ctx}ctx {ctx}{/if} + {:else if chat.turn} + IDLE + 最近 {turnLabel} + {:else} + READY + {/if} + + {chat.connected ? "已连接" : "重连中"} + {#if version}{version}{/if} + +
+ + +``` + +(`.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 页面尚未实现,先渲染占位 `
即将上线
`(P1/P2 补齐);logs/memory/tasks/settings 复用现有页面组件。 +- 结构:`