Files
2026Technology-Competition/docs/superpowers/plans/2026-08-30-frontend-beautify.md
T
lhl b14c2213bc docs(superpowers): 补录前端美化与交互改造的设计规范与实施计划
- docs/superpowers/specs/2026-08-30-frontend-beautify-design.md
  前端美化与交互改造设计(深色指挥中心 + 文档工作台混合风格,
  单一青绿 #5EEAD4 强调色,chat.html 拆为 chat.html + chat.css + chat.js,
  8 项交互改善,用户决策 10 项)
- docs/superpowers/plans/2026-08-30-frontend-beautify.md
  前端美化与交互改造 Implementation Plan(三步走:机械拆分 → 视觉重塑 →
  交互改造,Task 独立 commit,浏览器手测验证)

按项目文档规则存入 docs/superpowers/ 目录。
2026-08-31 22:33:46 +08:00

83 KiB
Raw Blame History

前端美化与交互改造 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:src/genesis/server/static/chat.html(单文件 1215 行内联 CSS+JS)拆分为 chat.html + chat.css + chat.js,套用「深色指挥中心 + 文档工作台」混合风格(单一青绿 #5EEAD4 强调色 + 系统字体栈),并实施 8 项交互改善(分组 / 拖拽 / 气泡操作 / 多行输入 / 顶栏徽章 / 快捷键 Esc+Cmd+K / 骨架屏 / 空状态快捷指令 / 错误分级)。

Architecture: 机械拆分 → 视觉重塑 → 交互改造三步走。chat_state.js / chat_ws.js 完全不动(保持 window.GenesisState / window.GenesisWS 协议不变),chat.js 通过 fallback 默认值兜底。所有 DOM id 保留,所有 API 端点不变,所有 localStorage 键不变。无后端变更,无测试框架(pytest)可覆盖的逻辑,故 Task 验证手段为"浏览器手动 + 浏览器自动化烟雾测试"。

Tech Stack: 纯前端(HTML / CSS / JavaScript ES2017+)。无新增依赖。

Global Constraints

  • 中文优先:所有 UI 文案、注释、提交信息使用中文(专有名词 / 标识符 / 代码关键字除外)。
  • 不新增依赖:不引入 Google Fonts / JetBrains Mono;纯系统字体栈。
  • 单主题:本轮不做 dark/light 切换。
  • 兼容性保证:所有 DOM id 必须保留(见 spec §三-1);所有 localStorage / sessionStorage 键必须保留(见 spec §三-3);所有 /api/... 调用方式不变。
  • 移动端:< 768px 断点的改造不在本轮范围(spec §零-8)。
  • 不实现"重新生成"按钮(spec §零-9)。
  • 提交粒度:每个 Task 独立 commit,便于回滚。
  • 颜色十六进制值必须与 spec §一完全一致(禁止调整)。
  • 后端 Python 代码不在本 plan 修改范围;仅前端三个文件(chat.html / chat.css / chat.js+ docs。

文件结构(本 plan 涉及)

src/genesis/server/static/
├── chat.html          # Modify:删 <style>/<script> 内联,引用外部资源
├── chat.css           # Create:约 350-450 行,含 :root 令牌 + 六大区域
├── chat.js            # Create:约 800-900 行,含 8 项交互 + 原业务逻辑
├── chat_state.js      # 不动
└── chat_ws.js         # 不动

docs/
├── design.md          # Modify:追加"前端 UI 设计规范"一节
├── superpowers/specs/2026-08-30-frontend-beautify-design.md  # 已有,本文参照
└── _AI_USAGE_LOG.md   # Modify:追加 3 条记录

tests/frontend/        # Create:浏览器自动化烟雾测试(可选;本 plan 标记为"可省略",浏览器手测为主)

Task 1: 拆分 chat.html + 创建 chat.css 骨架 + chat.js 骨架

Files:

  • Modify: src/genesis/server/static/chat.html:1-1215
  • Create: src/genesis/server/static/chat.css
  • Create: src/genesis/server/static/chat.js

Interfaces:

  • Consumes: 原 chat.html 全部 DOM 节点
  • Produces:
    • chat.html 仅含 DOM 结构 + <link href="/chat.css"> + <script src="/chat.js" defer>
    • chat.css:root 全部 CSS 变量(spec §一)
    • chat.js 含原内联 <script> 段全部业务逻辑(机械迁移,零行为变更)

: 本 Task 只做"机械拆分"——CSS 暂写一份"最小还原"(保留原 Material/Google 风视觉),保证拆分后页面行为与拆分前完全一致;视觉重塑留到 Task 2。

  • Step 1: 创建 chat.css(最小还原版,仅令牌 + 原样样式)
/* src/genesis/server/static/chat.css
   v0 拆分骨架 - 视觉与原 chat.html 内联样式完全一致
   v1 视觉重塑见 Task 2
*/
:root {
  /* === 颜色 (v0 还原; v1 在 Task 2 替换为深色令牌) === */
  --blue: #1a73e8;
  --blue-dark: #1967d2;
  --blue-soft: #d2e3fc;
  --red: #ea4335;
  --red-soft: #fce8e6;
  --yellow: #fbbc05;
  --green: #34a853;
  --gray-50: #f8f9fa;
  --gray-100: #f1f3f4;
  --gray-200: #e8eaed;
  --gray-300: #dadce0;
  --gray-500: #5f6368;
  --gray-700: #3c4043;
  --gray-900: #202124;
  --blue-50: #e8f0fe;
  --blue-100: #d2e3fc;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --shadow-1: 0 1px 2px rgba(60,64,67,.30), 0 1px 3px 1px rgba(60,64,67,.15);
  --shadow-2: 0 1px 2px rgba(60,64,67,.30), 0 2px 6px 2px rgba(60,64,67,.15);
  --shadow-3: 0 1px 3px rgba(60,64,67,.30), 0 4px 8px 3px rgba(60,64,67,.15);
  --glow-blue: 0 0 0 1px rgba(26,115,232,.12), 0 8px 24px rgba(26,115,232,.18);
  --ease: all .2s cubic-bezier(.2,0,0,1);

  /* === 字体 (v0 保留 Inter via Google Fonts; v1 替换为系统字体栈) === */
  --font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
/* 完整还原原 chat.html:36-391 的全部样式(原样复制,仅变量化) */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  font-family: var(--font-ui);
  margin: 0; height: 100vh; display: flex; flex-direction: row;
  background: var(--gray-50); color: var(--gray-900);
  -webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--gray-300); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--gray-500); }
::-webkit-scrollbar-track { background: transparent; }

#sidebar { width: 264px; background: #fff; color: var(--gray-900); display: flex; flex-direction: column; flex-shrink: 0; border-right: 1px solid var(--gray-200); transition: width .25s ease; position: relative; }
#sidebar.collapsed { width: 56px; }
#sidebar.collapsed #sidebar-toggle { top: auto; bottom: 14px; right: auto; left: 14px; }
#sidebar.collapsed #session-list { padding-bottom: 52px; }
.brand { display: flex; align-items: center; gap: 12px; padding: 16px 12px 12px 16px; margin: 0 8px 8px; border-bottom: 1px solid var(--gray-100); min-height: 64px; }
#sidebar.collapsed .brand { padding: 16px 8px; justify-content: center; gap: 0; }
.brand-logo { width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 17px; font-weight: 900; background: linear-gradient(135deg, #1a73e8, #4285f4); box-shadow: var(--glow-blue); transition: var(--ease); }
.brand:hover .brand-logo { transform: scale(1.1); }
.brand-name { font-size: 17px; font-weight: 900; letter-spacing: -.02em; line-height: 1.1; }
#sidebar.collapsed .brand-name { display: none; }
.brand-sub { display: block; font-size: 11px; font-weight: 600; color: var(--gray-500); margin-top: 2px; letter-spacing: 0; }
#sidebar-toggle { position: absolute; top: 18px; right: -14px; z-index: 5; width: 28px; height: 28px; border-radius: 50%; background: #fff; border: 1px solid var(--gray-200); box-shadow: var(--shadow-1); color: var(--gray-700); cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: var(--ease); }
#sidebar-toggle:hover { color: var(--blue); border-color: var(--blue-100); }
#sidebar.collapsed #sidebar-toggle { transform: rotate(180deg); }
#new-chat { margin: 6px 16px 10px; background: var(--blue); color: #fff; border: none; padding: 10px 14px; border-radius: var(--radius-lg); cursor: pointer; font-size: 14px; font-weight: 600; letter-spacing: .01em; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); display: flex; align-items: center; justify-content: center; gap: 6px; }
#new-chat:hover { background: var(--blue-dark); box-shadow: 0 2px 6px rgba(26,115,232,.45); transform: translateY(-1px); }
#new-chat:active { transform: translateY(0); }
#sidebar.collapsed #new-chat { margin: 6px 10px 10px; padding: 10px 0; }
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
#sidebar.collapsed #new-chat .nc-text { display: none; }
#sidebar h2 { font-size: 9px; font-weight: 900; letter-spacing: .2em; text-transform: uppercase; color: var(--gray-500); margin: 10px 16px 6px; }
#sidebar.collapsed h2 { display: none; }
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.sess { padding: 9px 12px; border-radius: var(--radius-lg); margin: 2px 0; cursor: pointer; position: relative; border: 1px solid transparent; transition: var(--ease); display: flex; align-items: center; gap: 6px; }
.sess:hover { background: var(--gray-50); }
.sess.active { background: var(--blue-50); border-color: var(--blue-100); }
.sess.active::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--blue); }
.sess .info { flex: 1; min-width: 0; overflow: hidden; }
.sess .name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess.active .name { font-weight: 700; }
.sess .meta { font-size: 11px; color: var(--gray-500); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess .del { flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent; border: none; color: var(--gray-500); cursor: pointer; width: 24px; height: 24px; border-radius: 6px; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.sess:hover .del { opacity: .7; }
.sess .del:hover { opacity: 1 !important; background: var(--red-soft); color: var(--red); }
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--gray-100); color: var(--gray-700); font-size: 12px; font-weight: 700; }
.sess .avatar { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; }
#sidebar.collapsed .sess.active .avatar { background: var(--blue); color: #fff; }
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--gray-50); }
header { height: 56px; position: sticky; top: 0; z-index: 10; padding: 0 24px; display: flex; align-items: center; gap: 14px; background: rgba(255,255,255,.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--gray-200); }
header h1 { font-size: 16px; font-weight: 900; letter-spacing: -.02em; margin: 0; color: var(--gray-900); }
.proj-switcher { position: relative; }
.ps-current { display: flex; align-items: center; gap: 8px; background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--radius-lg); padding: 7px 12px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--gray-700); transition: var(--ease); }
.ps-current:hover { background: #fff; border-color: var(--blue-100); color: var(--blue-dark); }
.ps-current .ps-label { font-size: 11px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; color: var(--gray-500); }
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-current .ps-caret { font-size: 10px; color: var(--gray-500); }
.ps-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px; background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); padding: 6px; z-index: 20; display: none; }
.ps-menu.open { display: block; animation: zoomIn .2s ease; }
.ps-item { padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--gray-700); display: flex; align-items: center; gap: 8px; transition: background .15s ease; }
.ps-item:hover { background: var(--blue-50); color: var(--blue-dark); }
.ps-item.active { background: var(--blue-50); color: var(--blue-dark); font-weight: 700; }
.ps-item .ps-check { margin-left: auto; color: var(--blue); font-weight: 900; opacity: 0; }
.ps-item.active .ps-check { opacity: 1; }
.ps-sep { height: 1px; background: var(--gray-100); margin: 4px 6px; }
.ps-manage { color: var(--blue); font-weight: 600; }
#sid-badge { margin-left: auto; background: var(--blue-50); color: var(--blue-dark); border: 1px solid var(--blue-100); border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 600; max-width: 46%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#rag-bar { display: flex; align-items: center; gap: 8px; background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: 999px; padding: 3px 10px; font-size: 12px; color: var(--gray-700); }
#rag-bar label.rag-switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
#rag-bar input[type=checkbox] { width: 14px; height: 14px; accent-color: var(--blue); margin: 0; }
#rag-bar input[type=checkbox]:disabled + span { color: var(--gray-500); cursor: not-allowed; }
#rag-bar .rag-stats { font-variant-numeric: tabular-nums; color: var(--gray-500); }
#rag-bar .rag-stats.ready { color: var(--blue-dark); font-weight: 600; }
#rag-bar .rag-stats.zero { color: var(--gray-500); }
#rag-bar button { background: var(--blue); color: #fff; border: none; border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; cursor: pointer; }
#rag-bar button:disabled { background: var(--gray-200); color: var(--gray-500); cursor: not-allowed; }
#rag-bar button:hover:not(:disabled) { background: var(--blue-dark); }
#chat { flex: 1; overflow-y: auto; padding: 24px 24px 32px; max-width: 880px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: 12px 0; animation: zoomIn .25s ease; }
.msg .bubble { max-width: 74%; padding: 11px 16px; border-radius: var(--radius-xl); white-space: pre-wrap; line-height: 1.6; font-size: 14px; letter-spacing: .01em; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble { background: linear-gradient(135deg, #1a73e8, #4285f4); color: #fff; border-bottom-right-radius: 6px; box-shadow: var(--shadow-2); }
.msg.assistant { align-items: flex-start; }
.msg.assistant .bubble { background: #fff; border: 1px solid var(--gray-200); border-bottom-left-radius: 6px; box-shadow: var(--shadow-1); }
.msg.assistant::before { content: ""; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin: 16px 8px 0 0; background: var(--blue); box-shadow: 0 0 0 0 rgba(26,115,232,.4); animation: auraPulse 2.2s ease-in-out infinite; }
.msg.progress .bubble { background: var(--blue-50); border: 1px dashed var(--blue-soft); font-size: 13px; color: var(--gray-700); }
.msg.error .bubble { background: var(--red-soft); border: 1px solid #f5c6c0; color: #b31412; }
.progress-item { display: block; }
.progress-item.ok::before { content: "✔ "; color: var(--green); font-weight: 700; }
.progress-item.warn::before { content: "⚠ "; color: #b06000; font-weight: 700; }
.actions { margin-top: 8px; }
.actions a { display: inline-block; margin: 4px 6px 0 0; background: var(--blue); color: #fff; padding: 6px 14px; border-radius: 999px; text-decoration: none; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); }
.actions a:hover { background: var(--blue-dark); transform: translateY(-1px); }
.typing { color: var(--gray-500); font-size: 13px; font-style: italic; animation: auraPulse 1.4s ease-in-out infinite; }
#upload-bar { background: #fff; border-top: 1px solid var(--gray-200); padding: 10px 24px; font-size: 13px; }
#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#upload-bar select { font-size: 13px; padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-lg); background: #fff; color: var(--gray-900); cursor: pointer; outline: none; transition: var(--ease); }
#upload-bar select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); }
.file-pick { display: inline-block; padding: 8px 14px; border: 1px solid var(--gray-300); border-radius: var(--radius-lg); background: var(--gray-50); color: var(--gray-700); font-size: 13px; font-weight: 600; cursor: pointer; transition: var(--ease); }
.file-pick:hover { background: var(--blue-50); color: var(--blue-dark); border-color: var(--blue-100); }
#upload-btn { background: var(--gray-100); border: 1px solid var(--gray-200); color: var(--gray-700); border-radius: var(--radius-lg); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); }
#upload-btn:hover { background: var(--blue); border-color: var(--blue); color: #fff; }
#upload-status { color: var(--gray-500); font-size: 12px; }
#proj-hint { color: #b06000; font-size: 12px; background: #fef7e0; border: 1px solid #fde293; border-radius: 8px; padding: 4px 10px; }
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
#composer { border-top: 1px solid var(--gray-200); background: #fff; padding: 14px 24px 18px; }
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; }
#input { flex: 1; border: 1px solid var(--gray-300); border-radius: 999px; padding: 11px 18px; font-size: 14px; outline: none; background: var(--gray-50); transition: var(--ease); color: var(--gray-900); }
#input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-50); }
#input::placeholder { color: var(--gray-500); }
#send { background: var(--blue); color: #fff; border: none; border-radius: 999px; padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); }
#send:hover:not(:disabled) { background: var(--blue-dark); box-shadow: var(--shadow-2); transform: translateY(-1px); }
#send:active:not(:disabled) { transform: translateY(0); }
#send:disabled { opacity: .45; cursor: not-allowed; }
#proj-drawer { position: fixed; top: 0; right: 0; height: 100vh; width: 460px; background: #fff; border-left: 1px solid var(--gray-200); box-shadow: -8px 0 32px rgba(60,64,67,.12); transform: translateX(100%); transition: transform .3s cubic-bezier(.2,0,0,1); z-index: 100; display: flex; flex-direction: column; }
#proj-drawer.open { transform: translateX(0); }
.drawer-head { height: 56px; padding: 0 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--gray-200); background: rgba(255,255,255,.95); backdrop-filter: blur(8px); flex-shrink: 0; }
.drawer-head h2 { font-size: 15px; font-weight: 900; letter-spacing: -.01em; margin: 0; color: var(--gray-900); flex: 1; }
.drawer-close { background: var(--gray-100); border: none; color: var(--gray-700); width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: var(--ease); }
.drawer-close:hover { background: var(--red-soft); color: var(--red); }
.drawer-body { flex: 1; display: flex; min-height: 0; }
.drawer-list { width: 38%; border-right: 1px solid var(--gray-100); overflow-y: auto; padding: 10px 8px; background: var(--gray-50); display: flex; flex-direction: column; gap: 2px; }
.drawer-item { padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 13px; color: var(--gray-700); border: 1px solid transparent; transition: var(--ease); display: flex; align-items: center; gap: 8px; }
.drawer-item:hover { background: #fff; border-color: var(--gray-200); }
.drawer-item.active { background: #fff; border-color: var(--blue-100); color: var(--blue-dark); font-weight: 700; box-shadow: var(--shadow-1); }
.drawer-item .di-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drawer-item .di-new { color: var(--blue); }
.drawer-item.di-create { border: 1px dashed var(--blue-100); background: var(--blue-50); color: var(--blue-dark); font-weight: 700; justify-content: center; gap: 4px; }
.drawer-item.di-create:hover { background: var(--blue-100); border-color: var(--blue); }
.drawer-form { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
.drawer-form label { font-size: 11px; font-weight: 700; color: var(--gray-500); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 2px; display: block; }
.drawer-form input { width: 100%; padding: 8px 11px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 13px; color: var(--gray-900); background: #fff; transition: var(--ease); }
.drawer-form input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); }
.drawer-form input:read-only { background: var(--gray-50); color: var(--gray-500); }
.drawer-form .field { display: flex; flex-direction: column; }
.drawer-form .drawer-hint { margin: 0 0 6px; }
.drawer-form details.advanced { border: 1px solid var(--gray-200); border-radius: 8px; padding: 8px 12px; background: var(--gray-50); }
.drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 700; color: var(--gray-700); letter-spacing: .02em; }
.drawer-form details.advanced[open] { padding-bottom: 10px; }
.drawer-form details.advanced .field { margin-top: 8px; }
.drawer-form input.invalid { border-color: var(--red) !important; box-shadow: 0 0 0 2px var(--red-soft) !important; }
.drawer-actions { display: flex; gap: 8px; margin-top: 8px; }
.drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); }
.btn-save { background: var(--blue); color: #fff; }
.btn-save:hover { background: var(--blue-dark); }
.btn-delete { background: var(--red-soft); color: var(--red); border: 1px solid #f5c6c0 !important; }
.btn-delete:hover { background: var(--red); color: #fff; }
.drawer-hint { font-size: 11px; color: var(--gray-500); line-height: 1.5; padding: 8px 0; }
@keyframes auraPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes zoomIn { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } }
@media (max-width: 860px) {
  #sidebar { width: 220px; }
  #sidebar.collapsed { width: 48px; }
  #sid-badge { display: none; }
  #proj-drawer { width: 100vw; }
}
  • Step 2: 创建 chat.js(机械迁移原内联 <script> 段)

chat.html:509-1213 复制整段 <script>...</script>(含 let sid = null;})();),粘贴到 chat.js 顶部,不加任何 IIFE 包裹、不加任何修改。原内联段本身是顶层作用域代码(不依赖 DOMContentLoaded,因为 <script> 放在 <body> 末尾、DOM 已就绪)。

// src/genesis/server/static/chat.js
// v0 拆分骨架 - 从 chat.html 内联 <script> 段机械迁移;行为零变更
// v1 视觉/交互改造见后续 Task

// 整段从 chat.html:509-1213 复制(约 700 行):
let sid = null;
let progressWs = null;
// ... 原 chat.html 509-1213 行所有代码完整复制 ...
// 最后是 (async () => { ... })();

(实施时直接用 read 工具读原 chat.html 第 509-1213 行,写到 chat.js。)

  • Step 3: 修改 chat.html(删除内联 <style> 与 <script>,引用外部资源)

chat.html:1-15 改为:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Genesis — 概要设计书自动生成(对话)</title>
<link rel="stylesheet" href="/chat.css">
</head>
<body>

chat.html:507-508<script src="/chat_state.js"> / chat_ws.js)保持不变,将 chat.html:509-1213(整段内联 <script>)删除,并在 chat.html:506</div> 之前)的合适位置追加:

<script src="/chat_state.js"></script>
<script src="/chat_ws.js"></script>
<script src="/chat.js" defer></script>
</body>
</html>

注意chat.js 使用 defer,确保 DOM 解析完成后执行(虽然原内联段放在 body 末尾已等价,但 defer 保险)。<script src="/chat_state.js"> 不加 defer(保持原顺序执行模式,向 window.GenesisState 注入)。

  • Step 4: 启动服务器并浏览器访问主页验证拆分无回归
# 在项目根目录
python -m genesis.server.app
# 或查看 README.md 中实际的启动命令

预期:访问 http://localhost:8000/(或 README 中端口),看到与拆分前视觉与交互完全一致的页面(Material/Google 风、Inter 字体、蓝色 #1a73e8)。

  • Step 5: 手动验证清单

依次跑通:

  1. 页面加载,浏览器控制台无 JS 错误(无 404 chat.css / chat.js
  2. 点击「折叠侧边栏」按钮 → 侧边栏变窄,会话名隐藏
  3. 点击「项目」下拉 → 弹出菜单,能选项目
  4. 输入「你好」→ 发送 → 用户气泡(蓝渐变)出现
  5. 刷新页面 → 上次会话恢复(localStorage 生效)
  • Step 6: 提交
git add src/genesis/server/static/chat.html \
        src/genesis/server/static/chat.css \
        src/genesis/server/static/chat.js
git commit -m "refactor(frontend): chat.html 拆分为 html + css + js(机械拆分,行为零变更)"

Task 2: 视觉重塑 - chat.css 应用深色设计令牌 + 六大区域样式

Files:

  • Modify: src/genesis/server/static/chat.css(替换 :root 令牌 + 全部样式)

Interfaces:

  • Consumes: spec §一 颜色/字体/间距令牌、spec §二 六大区域视觉规约
  • Produces: 重写后的 chat.css(深色主题),与原 chat.html 同样的 DOM 结构匹配

: 本 Task 是一次性大改 chat.css。chat.html 与 chat.js 不动(DOM id 全部保持,业务逻辑不变)。

  • Step 1: 替换 :root 令牌为 spec §一 深色版本
/* src/genesis/server/static/chat.css - v1 深色重塑 */
:root {
  /* === 颜色 (spec §一) === */
  --bg-base: #0B0F17;
  --bg-surface: #141A24;
  --bg-elevated: #1B2330;
  --bg-input: #0F141C;
  --border-subtle: rgba(255, 255, 255, 0.08);
  --border-strong: rgba(255, 255, 255, 0.14);
  --text-primary: #E5EAF0;
  --text-secondary: #8B95A7;
  --text-muted: #5C6677;
  --accent: #5EEAD4;
  --accent-soft: rgba(94, 234, 212, 0.15);
  --success: #34D399;
  --warning: #FBBF24;
  --danger: #F87171;
  --info: #60A5FA;

  /* === 字体 (系统字体栈,spec §一 Q2 决策) === */
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
             "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
               "Liberation Mono", monospace;

  /* === 间距 (spec §一) === */
  --sp-1: 4px;  --sp-2: 8px;  --sp-3: 12px;
  --sp-4: 16px; --sp-5: 20px; --sp-6: 24px;

  /* === 圆角 (spec §一) === */
  --r-sm: 6px;  --r-md: 10px; --r-lg: 14px;

  /* === 动效 (spec §一) === */
  --ease: cubic-bezier(.2, 0, 0, 1);
  --dur: .18s;
}
  • Step 2: 替换全局 + 滚动条样式
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  font-family: var(--font-ui);
  margin: 0; height: 100vh; display: flex; flex-direction: row;
  background: var(--bg-base); color: var(--text-primary);
  -webkit-font-smoothing: antialiased;
  font-size: 14px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
::-webkit-scrollbar-track { background: transparent; }
  • Step 3: 重写侧边栏样式(spec §2.1)
/* 侧边栏 */
#sidebar {
  width: 264px; background: var(--bg-surface); color: var(--text-primary);
  display: flex; flex-direction: column; flex-shrink: 0;
  border-right: 1px solid var(--border-subtle);
  transition: width .25s var(--ease);
  position: relative;
}
#sidebar.collapsed { width: 56px; }
#sidebar.collapsed #sidebar-toggle { top: auto; bottom: 14px; right: auto; left: 14px; }
#sidebar.collapsed #session-list { padding-bottom: 52px; }
.brand {
  display: flex; align-items: center; gap: 12px;
  padding: 16px 12px 12px 16px; margin: 0 8px 8px;
  border-bottom: 1px solid var(--border-subtle);
  min-height: 64px;
}
#sidebar.collapsed .brand { padding: 16px 8px; justify-content: center; gap: 0; }
.brand-logo {
  width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--bg-base); font-size: 17px; font-weight: 900;
  background: var(--accent);
  transition: transform var(--dur) var(--ease);
}
.brand:hover .brand-logo { transform: scale(1.1); box-shadow: 0 0 24px var(--accent-soft); }
.brand-name { font-size: 17px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; color: var(--text-primary); }
.brand-sub { display: block; font-size: 11px; font-weight: 500; color: var(--text-muted); margin-top: 2px; letter-spacing: 0; }
#sidebar.collapsed .brand-name { display: none; }
#sidebar-toggle {
  position: absolute; top: 18px; right: -14px; z-index: 5;
  width: 28px; height: 28px; border-radius: 50%;
  background: var(--bg-elevated); border: 1px solid var(--border-strong);
  color: var(--text-secondary); cursor: pointer; font-size: 14px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  transition: all var(--dur) var(--ease);
}
#sidebar-toggle:hover { color: var(--accent); border-color: var(--accent); }
#new-chat {
  margin: var(--sp-2) var(--sp-4) var(--sp-3); background: var(--accent); color: var(--bg-base);
  border: none; padding: 10px 14px; border-radius: var(--r-md);
  cursor: pointer; font-size: 14px; font-weight: 700; letter-spacing: .01em;
  transition: all var(--dur) var(--ease);
  display: flex; align-items: center; justify-content: center; gap: 6px;
}
#new-chat:hover { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
#new-chat:active { transform: translateY(0); }
#sidebar.collapsed #new-chat { margin: var(--sp-2) 10px var(--sp-3); padding: 10px 0; }
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
#sidebar.collapsed #new-chat .nc-text { display: none; }
#sidebar h2 {
  font-family: var(--font-mono);
  font-size: 11px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted); margin: var(--sp-3) var(--sp-4) var(--sp-2);
}
#sidebar.collapsed h2 { display: none; }
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.sess {
  padding: 9px 12px; border-radius: var(--r-md); margin: 2px 0;
  cursor: pointer; position: relative; border: 1px solid transparent;
  transition: all var(--dur) var(--ease);
  display: flex; align-items: center; gap: 6px;
}
.sess:hover { background: var(--bg-elevated); }
.sess:hover::before {
  content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px;
  background: var(--accent); border-radius: 0 2px 2px 0;
}
.sess.active { background: var(--bg-elevated); }
.sess.active::before {
  content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px;
  background: var(--accent); border-radius: 0 2px 2px 0;
}
.sess .info { flex: 1; min-width: 0; overflow: hidden; }
.sess .name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); }
.sess.active .name { font-weight: 700; }
.sess .meta { font-size: 11px; color: var(--text-muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); }
.sess .del {
  flex-shrink: 0; opacity: 0; transition: opacity .15s ease;
  background: transparent; border: none; color: var(--text-muted); cursor: pointer;
  width: 24px; height: 24px; border-radius: 6px; font-size: 16px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
}
.sess:hover .del { opacity: .7; }
.sess .del:hover { opacity: 1 !important; background: var(--danger); color: var(--bg-base); }
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
#sidebar.collapsed .sess .avatar {
  display: flex; align-items: center; justify-content: center;
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--text-muted);
}
.sess .avatar { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; }
#sidebar.collapsed .sess.active .avatar { background: var(--accent); box-shadow: 0 0 8px var(--accent-soft); }

注意: 把 .sess .avatar 默认宽高从 28px 改为 8px(小圆点),折叠态使用此圆点;展开态 .sess .avatar { display: none; } 保持隐藏。颜色 hash 逻辑在 chat.js 中已存在(palette 数组),无需改 JS。

  • Step 4: 重写主区 + 顶栏样式(spec §2.2)
/* 主区 */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-base); }
header {
  height: 56px; position: sticky; top: 0; z-index: 10;
  padding: 0 var(--sp-6); display: flex; align-items: center; gap: 14px;
  background: rgba(20, 26, 36, 0.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--border-subtle);
}
header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0; color: var(--text-primary); }
/* 项目切换器 */
.proj-switcher { position: relative; }
.ps-current {
  display: flex; align-items: center; gap: 8px;
  background: transparent; border: 1px solid var(--border-strong);
  border-radius: var(--r-md); padding: 7px 12px; cursor: pointer;
  font-size: 13px; font-weight: 500; color: var(--text-primary);
  transition: all var(--dur) var(--ease);
}
.ps-current:hover { border-color: var(--accent); }
.ps-current .ps-label { font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted); }
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-current .ps-caret { font-size: 10px; color: var(--text-muted); }
.ps-menu {
  position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px;
  background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-md);
  box-shadow: 0 8px 32px rgba(0,0,0,.4); padding: 6px; z-index: 20;
  display: none;
}
.ps-menu.open { display: block; animation: zoomIn .2s var(--ease); }
.ps-item {
  padding: 8px 12px; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; color: var(--text-secondary);
  display: flex; align-items: center; gap: 8px; transition: background .15s var(--ease);
}
.ps-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.ps-item.active { background: var(--bg-elevated); color: var(--accent); font-weight: 700; }
.ps-item .ps-check { margin-left: auto; color: var(--accent); font-weight: 900; opacity: 0; }
.ps-item.active .ps-check { opacity: 1; }
.ps-sep { height: 1px; background: var(--border-subtle); margin: 4px 6px; }
.ps-manage { color: var(--accent); font-weight: 600; }
#sid-badge {
  margin-left: auto;
  background: var(--bg-elevated); color: var(--text-primary);
  border: 1px solid var(--border-subtle);
  border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 500;
  max-width: 32%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  font-family: var(--font-mono);
}
  • Step 5: 重写 RAG 条样式(spec §2.2 RAG 胶囊)
/* RAG 控制条 */
#rag-bar {
  display: flex; align-items: center; gap: 8px;
  background: transparent; border: 1px solid var(--border-subtle);
  border-radius: 999px; padding: 3px 10px;
  font-size: 12px; color: var(--text-secondary);
  font-family: var(--font-mono);
}
#rag-bar label.rag-switch {
  display: inline-flex; align-items: center; gap: 6px;
  cursor: pointer; user-select: none;
}
#rag-bar input[type=checkbox] {
  width: 14px; height: 14px; accent-color: var(--accent);
  margin: 0;
}
#rag-bar input[type=checkbox]:disabled + span { color: var(--text-muted); cursor: not-allowed; }
#rag-bar .rag-stats { color: var(--text-muted); }
#rag-bar .rag-stats.ready { color: var(--accent); font-weight: 600; }
#rag-bar .rag-stats.zero { color: var(--text-muted); }
#rag-bar button {
  background: var(--accent); color: var(--bg-base); border: none;
  border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700;
  cursor: pointer; transition: all var(--dur) var(--ease);
}
#rag-bar button:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; }
#rag-bar button:hover:not(:disabled) { box-shadow: 0 0 16px var(--accent-soft); }
  • Step 6: 重写聊天区 + 气泡(spec §2.3)
/* 聊天区 */
#chat { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-6) var(--sp-6); max-width: 880px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: var(--sp-3) 0; animation: zoomIn .25s var(--ease); position: relative; }
.msg .bubble {
  max-width: 74%; padding: 11px 16px; border-radius: var(--r-lg);
  white-space: pre-wrap; line-height: 1.65; font-size: 14px; letter-spacing: .01em;
}
.msg.user { justify-content: flex-end; }
.msg.user .bubble {
  background: var(--bg-surface);
  color: var(--text-primary);
  border-bottom-right-radius: 6px;
  border-left: 2px solid var(--accent);
  padding-left: 18px; /* 抵消左边框占用的空间,避免文字偏移 */
}
.msg.assistant { align-items: flex-start; }
.msg.assistant .bubble {
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-bottom-left-radius: 6px;
  color: var(--text-primary);
}
.msg.assistant::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
  margin: 18px 10px 0 0; background: var(--accent);
  box-shadow: 0 0 8px var(--accent-soft);
}
.msg.progress .bubble {
  background: var(--bg-input); border: 1px dashed var(--accent-soft);
  font-size: 13px; color: var(--text-secondary);
  font-family: var(--font-mono);
}
.msg.error .bubble {
  background: rgba(248, 113, 113, 0.12); border: 1px solid var(--danger); color: var(--danger);
}
.progress-item { display: block; }
.progress-item.ok::before { content: "✔ "; color: var(--success); font-weight: 700; }
.progress-item.warn::before { content: "⚠ "; color: var(--warning); font-weight: 700; }
.actions { margin-top: 8px; }
.actions a {
  display: inline-block; margin: 4px 6px 0 0; background: var(--accent); color: var(--bg-base);
  padding: 6px 14px; border-radius: 999px; text-decoration: none; font-size: 13px; font-weight: 700;
  transition: all var(--dur) var(--ease);
}
.actions a:hover { box-shadow: 0 0 16px var(--accent-soft); transform: translateY(-1px); }
.typing { color: var(--text-muted); font-size: 13px; animation: auraPulse 1.4s ease-in-out infinite; font-family: var(--font-mono); }
  • Step 7: 重写上传条样式(spec §2.4)
/* 上传条 */
#upload-bar { background: var(--bg-surface); border-top: 1px solid var(--border-subtle); padding: var(--sp-3) var(--sp-6); font-size: 13px; }
#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#upload-bar select {
  font-size: 13px; padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
  background: var(--bg-input); color: var(--text-primary); cursor: pointer; outline: none;
  transition: all var(--dur) var(--ease);
  font-family: var(--font-ui);
}
#upload-bar select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.file-pick {
  display: inline-block; padding: 8px 14px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
  background: var(--bg-input); color: var(--text-secondary); font-size: 13px; font-weight: 500;
  cursor: pointer; transition: all var(--dur) var(--ease);
}
.file-pick:hover { border-color: var(--accent); color: var(--accent); }
#upload-btn {
  background: transparent; border: 1px solid var(--border-strong); color: var(--text-primary);
  border-radius: var(--r-md); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600;
  transition: all var(--dur) var(--ease);
}
#upload-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--bg-base); }
#upload-status { color: var(--text-muted); font-size: 12px; font-family: var(--font-mono); }
#proj-hint {
  color: var(--warning); font-size: 12px; background: rgba(251,191,36,.1); border: 1px solid var(--warning);
  border-left-width: 4px; border-radius: var(--r-sm); padding: 4px 10px;
}
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
  • Step 8: 重写输入区样式(spec §2.5)
/* 输入区 */
#composer { border-top: 1px solid var(--border-subtle); background: var(--bg-surface); padding: var(--sp-4) var(--sp-6) var(--sp-5); }
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
#input {
  flex: 1; border: 1px solid var(--border-strong); border-radius: var(--r-md);
  padding: 11px 14px; font-size: 14px; outline: none; background: var(--bg-input);
  transition: all var(--dur) var(--ease); color: var(--text-primary);
  font-family: var(--font-ui);
  resize: none; min-height: 42px; max-height: 160px; line-height: 1.5;
}
#input:focus { border-color: var(--accent); background: var(--bg-elevated); box-shadow: 0 0 0 2px var(--accent-soft); }
#input::placeholder { color: var(--text-muted); }
#send {
  background: var(--accent); color: var(--bg-base); border: none; border-radius: var(--r-md);
  padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700;
  transition: all var(--dur) var(--ease);
  min-height: 42px;
}
#send:hover:not(:disabled) { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
#send:active:not(:disabled) { transform: translateY(0); }
#send:disabled { opacity: .3; cursor: not-allowed; }
  • Step 9: 重写项目抽屉样式(spec §2.6)
/* 项目抽屉 */
#proj-drawer {
  position: fixed; top: 0; right: 0; height: 100vh; width: 460px;
  background: var(--bg-surface); border-left: 1px solid var(--border-subtle);
  box-shadow: -8px 0 32px rgba(0,0,0,.5);
  transform: translateX(100%); transition: transform .3s var(--ease);
  z-index: 100; display: flex; flex-direction: column;
}
#proj-drawer.open { transform: translateX(0); }
.drawer-head {
  height: 56px; padding: 0 20px; display: flex; align-items: center; gap: 12px;
  border-bottom: 1px solid var(--border-subtle); background: var(--bg-surface);
  flex-shrink: 0;
}
.drawer-head h2 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; margin: 0; color: var(--text-primary); flex: 1; }
.drawer-close {
  background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary);
  width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
  font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center;
  transition: all var(--dur) var(--ease);
}
.drawer-close:hover { background: var(--danger); border-color: var(--danger); color: var(--bg-base); }
.drawer-body { flex: 1; display: flex; min-height: 0; }
.drawer-list {
  width: 38%; border-right: 1px solid var(--border-subtle); overflow-y: auto;
  padding: 10px 8px; background: var(--bg-base);
  display: flex; flex-direction: column; gap: 2px;
}
.drawer-item {
  padding: 10px 12px; border-radius: var(--r-sm); cursor: pointer; font-size: 13px;
  color: var(--text-secondary); border: 1px solid transparent;
  transition: all var(--dur) var(--ease);
  display: flex; align-items: center; gap: 8px; position: relative;
}
.drawer-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.drawer-item.active {
  background: var(--bg-elevated); color: var(--text-primary); font-weight: 700;
}
.drawer-item.active::before {
  content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px;
  background: var(--accent); border-radius: 0 2px 2px 0;
}
.drawer-item .di-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drawer-item .di-new { color: var(--accent); }
.drawer-item.di-create {
  border: 1px dashed var(--border-strong); background: transparent;
  color: var(--accent); font-weight: 700; justify-content: center; gap: 4px;
}
.drawer-item.di-create:hover { background: var(--accent-soft); border-color: var(--accent); }
.drawer-form { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); display: flex; flex-direction: column; gap: 10px; }
.drawer-form label {
  font-family: var(--font-mono);
  font-size: 11px; font-weight: 500; color: var(--text-muted);
  text-transform: uppercase; letter-spacing: .1em; margin-bottom: 2px; display: block;
}
.drawer-form input {
  width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  font-size: 13px; color: var(--text-primary); background: var(--bg-input);
  transition: all var(--dur) var(--ease);
  font-family: var(--font-ui);
}
.drawer-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.drawer-form input:read-only { background: var(--bg-base); color: var(--text-muted); }
.drawer-form .field { display: flex; flex-direction: column; }
.drawer-form .drawer-hint { margin: 0 0 6px; }
.drawer-form details.advanced { border: 1px solid var(--border-subtle); border-radius: var(--r-sm); padding: 8px 12px; }
.drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--text-secondary); letter-spacing: .02em; }
.drawer-form details.advanced[open] { padding-bottom: 10px; }
.drawer-form details.advanced .field { margin-top: 8px; }
.drawer-form input.invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 2px rgba(248,113,113,.25) !important; }
.drawer-actions { display: flex; gap: 8px; margin-top: 8px; }
.drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; font-weight: 600; transition: all var(--dur) var(--ease); }
.btn-save { background: var(--accent); color: var(--bg-base); }
.btn-save:hover { box-shadow: 0 0 16px var(--accent-soft); }
.btn-delete { background: transparent; color: var(--danger); border: 1px solid var(--danger) !important; }
.btn-delete:hover { background: var(--danger); color: var(--bg-base); }
.drawer-hint { font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 8px 0; }
  • Step 10: 重写动效 + 响应式断点(spec §1.4)
@keyframes auraPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes zoomIn { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } }
@media (max-width: 860px) {
  #sidebar { width: 220px; }
  #sidebar.collapsed { width: 48px; }
  #sid-badge { max-width: 24%; }
  #proj-drawer { width: 100vw; }
}
  • Step 11: 浏览器验证视觉重塑

启动服务器,访问主页,验证:

  1. 整体配色变深(深空灰底 + 白色文字)
  2. 强调色全部变青绿 #5EEAD4
  3. 字体回退到系统字体(中文用 PingFang/Microsoft YaHei
  4. 六大区域(侧边栏/顶栏/聊天/上传/输入/抽屉)全部按新样式渲染
  5. 业务功能不破:侧边栏折叠、项目切换、会话创建、消息发送、上传
  • Step 12: 提交
git add src/genesis/server/static/chat.css
git commit -m "feat(frontend): 视觉重塑 - 深色指挥中心 + 文档工作台混合风格"

Task 3: 交互改造 - 会话列表按时间分组(spec §4.1)

Files:

  • Modify: src/genesis/server/static/chat.js(在 refreshSessions 内分组)

  • Modify: src/genesis/server/static/chat.css(加 .sess-group 样式)

  • Step 1: chat.css 新增 .sess-group 样式

.sess .avatar 块之后追加:

.sess-group {
  font-family: var(--font-mono);
  font-size: 11px; font-weight: 500; letter-spacing: .12em;
  color: var(--text-muted);
  padding: var(--sp-3) 12px var(--sp-1);
  text-transform: uppercase;
  cursor: default;
  user-select: none;
}
#sidebar.collapsed .sess-group { display: none; }
  • Step 2: chat.js 改造 refreshSessions 增加分组逻辑

chat.js 中找到 refreshSessions 函数(约原 chat.html:561 行),把 list.sort(...) 之后、for (const s of list) 之前替换为以下分组版本(保留原有 .sess DOM 创建逻辑,仅在循环外加分组标题插入):

async function refreshSessions() {
  const box = document.getElementById('session-list');
  if (draftProject === null) { box.innerHTML = ''; return; }
  const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject));
  box.innerHTML = '';
  list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));

  // === 新增: 按 updated_at 字符串前缀(YYYY-MM-DD)分组 ===
  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
  const weekAgo = new Date(today); weekAgo.setDate(today.getDate() - 7);
  const monthAgo = new Date(today); monthAgo.setDate(today.getDate() - 30);

  function bucketOf(s) {
    const t = s.updated_at ? new Date(s.updated_at) : null;
    if (!t || isNaN(t.getTime())) return 'older';
    if (t >= today) return 'today';
    if (t >= yesterday) return 'yesterday';
    if (t >= weekAgo) return 'thisWeek';
    if (t >= monthAgo) return 'thisMonth';
    return 'older';
  }
  const BUCKET_LABEL = { today: '今天', yesterday: '昨天', thisWeek: '本周', thisMonth: '本月', older: '更早' };
  const BUCKET_ORDER = ['today', 'yesterday', 'thisWeek', 'thisMonth', 'older'];

  let lastBucket = null;
  for (const s of list) {
    const bucket = bucketOf(s);
    if (bucket !== lastBucket) {
      const grp = document.createElement('div');
      grp.className = 'sess-group';
      grp.textContent = BUCKET_LABEL[bucket] || bucket;
      box.appendChild(grp);
      lastBucket = bucket;
    }
    // === 原有 .sess 创建逻辑(从原 chat.html:567-589 完整复制)===
    const el = document.createElement('div');
    el.className = 'sess' + (s.session_id === sid ? ' active' : '');
    const proj = s.project ? ' · ' + esc(s.project) : '';
    const rawName = (s.name || '新').trim();
    const avatar = rawName ? Array.from(rawName)[0] : '新';
    const palette = ['#5EEAD4', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#8B95A7'];
    let h = 0;
    for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0;
    const color = palette[Math.abs(h) % palette.length];
    el.innerHTML =
      '<div class="avatar" style="background:' + color + ';"></div>' +
      '<div class="info">' +
        '<div class="name">' + esc(s.name || '新会话') + '</div>' +
        '<div class="meta">' + esc(s.status) + proj + '</div>' +
      '</div>' +
      '<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
    el.querySelector('.info').onclick = () => loadSession(s.session_id);
    const delBtn = el.querySelector('.del');
    delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
    box.appendChild(el);
  }
}
  • Step 3: 验证

启动服务器,浏览器访问:

  1. 选中一个项目(确保有 ≥1 个会话)→ 侧边栏会话列表出现
  2. 若历史会话的 updated_at 跨多天 → 应出现分组标题「今天 / 昨天 / 本周 / 本月 / 更早」
  3. 折叠侧边栏 → 分组标题隐藏
  4. updated_at 的会话会落入「更早」组,不报错
  • Step 4: 提交
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 会话列表按时间分组(今天/昨天/本周/本月/更早)"

Task 4: 交互改造 - 拖拽上传(spec §4.2)

Files:

  • Modify: src/genesis/server/static/chat.js(新增 bindUploadDrag 函数 + 调用)

  • Modify: src/genesis/server/static/chat.css(加 #upload-bar.drag-active

  • Step 1: chat.css 新增拖拽激活样式

#upload-bar 块之后追加:

#upload-bar.drag-active {
  background: var(--accent-soft);
  border-top: 1px dashed var(--accent);
  border-bottom: 1px dashed var(--accent);
}
#upload-bar.drag-active #upload-bar-inner { color: var(--accent); }
  • Step 2: chat.js 新增 bindUploadDrag 函数并调用

chat.js 启动 IIFE(async () => { 之前)追加:

function bindUploadDrag() {
  const bar = document.getElementById('upload-bar');
  if (!bar || !('draggable' in document.createElement('div'))) return;
  let depth = 0; // 避免子元素 dragenter/dragleave 闪烁
  bar.addEventListener('dragenter', (e) => {
    e.preventDefault(); depth++;
    bar.classList.add('drag-active');
  });
  bar.addEventListener('dragover', (e) => { e.preventDefault(); });
  bar.addEventListener('dragleave', (e) => {
    depth = Math.max(0, depth - 1);
    if (depth === 0) bar.classList.remove('drag-active');
  });
  bar.addEventListener('drop', (e) => {
    e.preventDefault();
    depth = 0;
    bar.classList.remove('drag-active');
    const items = e.dataTransfer.items;
    if (items && items.length && items[0].webkitGetAsEntry) {
      const entry = items[0].webkitGetAsEntry();
      if (entry && entry.isDirectory) {
        addWarn('请拖入单个文件,不要拖入文件夹');
        return;
      }
    }
    const file = e.dataTransfer.files && e.dataTransfer.files[0];
    if (!file) { addWarn('未识别到文件'); return; }
    // 按后缀自动选 file-type
    const ext = (file.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
    const sel = document.getElementById('file-type');
    if (ext === '.xlsx' && sel.querySelector('option[value="requirements"]')) sel.value = 'requirements';
    else if (ext === '.docx' && sel.querySelector('option[value="template"]')) sel.value = 'template';
    else if (ext === '.zip' && sel.querySelector('option[value="existing_system"]')) sel.value = 'existing_system';
    // 写入 #file-input.files
    const input = document.getElementById('file-input');
    try {
      const dt = new DataTransfer();
      dt.items.add(file);
      input.files = dt.files;
    } catch (err) {
      addWarn('该浏览器不支持拖拽设置文件,请使用「选择文件」按钮');
    }
  });
}

在启动 IIFE 内部最前面(loadProjects() 之前)调用 bindUploadDrag();

  • Step 3: 验证

启动服务器,浏览器:

  1. 拖一个 .xlsx 文件到上传条 → 出现 accent 虚线高亮
  2. 拖入完成 → file-type 自动选 requirements;文件名出现在 file-input
  3. 点击「上传」按钮 → 走原有上传逻辑成功
  4. 拖一个 .docx → 自动选 template
  5. 拖一个 .zip → 自动选 existing_system
  6. 拖入文件夹 → 出现「请拖入单个文件」warn 提示
  • Step 4: 提交
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 拖拽上传 + 按后缀自动选 file-type"

Task 5: 交互改造 - 消息气泡复制/引用(spec §4.3)

Files:

  • Modify: src/genesis/server/static/chat.js(扩展 addMsg + 新增 attachBubbleOps
  • Modify: src/genesis/server/static/chat.css(加 .msg .ops

: 不实现"重新生成"按钮spec §零-9 决策)。

  • Step 1: chat.css 新增气泡操作按钮样式
.msg .ops {
  position: absolute; top: -10px; right: 8px;
  display: flex; gap: 4px; opacity: 0;
  transition: opacity .15s var(--ease);
  z-index: 2;
}
.msg:hover .ops { opacity: 1; }
.msg .ops button {
  background: var(--bg-elevated);
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: 12px; padding: 2px 8px;
  border-radius: var(--r-sm); cursor: pointer;
  font-family: var(--font-mono);
  transition: all var(--dur) var(--ease);
}
.msg .ops button:hover { color: var(--accent); border-color: var(--accent); }
  • Step 2: chat.js 扩展 addMsg 接收纯文本 + 在 addMsg 末尾追加操作按钮

找到 addMsg 函数(原 chat.html:540 行附近):

function addMsg(role, html) {
  const m = document.createElement('div');
  m.className = 'msg ' + role;
  m.innerHTML = '<div class="bubble">' + html + '</div>';
  chatEl.appendChild(m);
  chatEl.scrollTop = chatEl.scrollHeight;
  return m;
}

替换为:

function addMsg(role, html, text) {
  // text: 纯文本(用于复制/引用)。若不传,则尝试从 html 反推纯文本
  const m = document.createElement('div');
  m.className = 'msg ' + role;
  m.innerHTML = '<div class="bubble">' + html + '</div>';
  // 操作按钮
  if (role === 'assistant' || role === 'user') {
    const ops = document.createElement('div');
    ops.className = 'ops';
    const cleanText = String(text != null ? text : m.querySelector('.bubble').innerText);
    if (role === 'assistant') {
      const copyBtn = document.createElement('button');
      copyBtn.type = 'button';
      copyBtn.textContent = '⧉ 复制';
      copyBtn.title = '复制消息内容';
      copyBtn.onclick = () => copyToClipboard(cleanText).then(
        () => { copyBtn.textContent = '✓ 已复制'; setTimeout(() => copyBtn.textContent = '⧉ 复制', 1500); },
        () => { addWarn('复制失败,请手动选中复制'); }
      );
      ops.appendChild(copyBtn);
    } else if (role === 'user') {
      const quoteBtn = document.createElement('button');
      quoteBtn.type = 'button';
      quoteBtn.textContent = '↺ 引用';
      quoteBtn.title = '把这条消息填到输入框';
      quoteBtn.onclick = () => {
        const inputEl = document.getElementById('input');
        inputEl.value = cleanText;
        inputEl.focus();
        autoResizeTextarea();
      };
      ops.appendChild(quoteBtn);
    }
    m.appendChild(ops);
  }
  chatEl.appendChild(m);
  chatEl.scrollTop = chatEl.scrollHeight;
  return m;
}

async function copyToClipboard(text) {
  if (navigator.clipboard && window.isSecureContext) {
    return navigator.clipboard.writeText(text);
  }
  // fallback: 临时 textarea + execCommand
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed'; ta.style.opacity = '0';
  document.body.appendChild(ta);
  ta.select();
  try {
    const ok = document.execCommand('copy');
    document.body.removeChild(ta);
    if (!ok) throw new Error('execCommand returned false');
  } catch (e) {
    document.body.removeChild(ta);
    throw e;
  }
}
  • Step 3: 改造所有 addMsg 调用点传纯文本

chat.js 中找出所有 addMsg(role, ...) 调用,把第二个 html 参数后再传第三个 text 参数(纯文本):

调用位置 改造
addMsg('assistant', GenesisState.buildWelcome(draftProject)); addMsg('assistant', esc(GenesisState.buildWelcome(draftProject)), GenesisState.buildWelcome(draftProject));
addMsg(role, text)applyProgressEvent 回调中 已经是 text 纯文本,直接加第三参数 text
addMsg('assistant', '项目「' + ... + '」已保存。'); addMsg('assistant', esc('项目「' + ... + '」已保存。'), '项目「' + ... + '」已保存。');
addMsg('assistant', '项目「' + deleted + '」已删除。'); 同上模式
addMsg('error', '...') 不加 text(error 不显示复制/引用按钮,行为不变)
addMsg('progress', ...) 不加 text
addMsg('user', esc(text))send() addMsg('user', esc(text), text)
addMsg('user', '[上传] ...') addMsg('user', esc('[上传] ...'), '[上传] ...')
addMsg(role, esc(m.content))loadSession 循环中 addMsg(role, esc(m.content), m.content)

注意: 调用点涉及多处,本 Step 用 grep 工具列出所有 addMsg( 调用,逐一修改。

  • Step 4: 验证

启动服务器,浏览器:

  1. 发送一条消息 → 鼠标 hover 用户气泡 → 右上角浮出 ↺ 引用 按钮
  2. 点击 ↺ 引用 → 输入框出现该消息文本 + 焦点在输入框
  3. 收到助手回复 → hover → 浮出 ⧉ 复制 按钮
  4. 点击 ⧉ 复制 → 按钮短暂变 ✓ 已复制,粘贴验证文本正确
  5. 进度/错误消息无操作按钮(hover 也不显示)
  • Step 5: 提交
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 消息气泡支持复制/引用(hover 浮出按钮)"

Task 6: 交互改造 - 输入区多行 + 自动高度(spec §4.4)

Files:

  • Modify: src/genesis/server/static/chat.html<input id="input"><textarea>

  • Modify: src/genesis/server/static/chat.js(新增 autoResizeTextarea + 改造 keydown

  • Modify: src/genesis/server/static/chat.css(已完成 Task 2 Step 8#input 已是 textarea 样式,含 min-height/max-height/resize:none

  • Step 1: 修改 chat.html

<input id="input" placeholder="..." autocomplete="off" maxlength="2000"> 改为:

<textarea id="input" rows="1" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000"></textarea>
  • Step 2: chat.js 新增 autoResizeTextarea + 改造 keydown 监听

chat.js 顶部 IIFE 内(bindUploadDrag 之后)新增:

function autoResizeTextarea() {
  const el = document.getElementById('input');
  if (!el || el.tagName !== 'TEXTAREA') return;
  el.style.height = 'auto';
  el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}

function bindComposer() {
  const el = document.getElementById('input');
  if (!el) return;
  if (el.tagName === 'TEXTAREA') {
    el.addEventListener('input', autoResizeTextarea);
    // Enter 发送(Shift+Enter 换行;IME 组合中不发送)
    el.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
        e.preventDefault();
        send();
      }
    });
    autoResizeTextarea();
  }
  // 保留原有 send 按钮点击监听(chat.js 末尾已有的 document.getElementById('send').addEventListener('click', send); 不变)
}

在启动 IIFE 内 bindUploadDrag(); 之后调用 bindComposer();

: 原 chat.html:1011 已有 inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });,如果 input 现在是 textarea,这段代码仍会触发(textarea 也支持 keydown),但是会抢在 Shift+Enter 时也发送。需要把这段替换为上面 bindComposer 里的逻辑;或者保留并在 bindComposer 内移除旧监听

简单方案:在 bindComposerel.removeEventListener('keydown', oldHandler) 不容易(旧 handler 是匿名)。改为在 chat.js 中找到原 inputEl.addEventListener('keydown', ...);(约 chat.html:1011),整行删除,让 bindComposer 内的 keydown 监听接管。

  • Step 3: 验证

启动服务器,浏览器:

  1. 点击输入框 → 输入多行(按 Shift+Enter 换行)→ 文本框自动撑高(最大 160px)
  2. 按 Enter(无 Shift)→ 消息发送,文本框重置为 1 行
  3. 中文输入法组合中按 Enter → 不发送,走组合确认逻辑
  4. maxlength=2000 仍生效
  • Step 4: 提交
git add src/genesis/server/static/chat.html src/genesis/server/static/chat.js
git commit -m "feat(chat): 输入区多行 textarea + 自动高度(Enter 发送 / Shift+Enter 换行)"

Task 7: 交互改造 - 顶栏项目不匹配徽章化(spec §4.5)

Files:

  • Modify: src/genesis/server/static/chat.html(在 #rag-bar 前插入 #proj-mismatch-badge

  • Modify: src/genesis/server/static/chat.js(改造 renderProjectMismatchHint → 调用新徽章渲染)

  • Modify: src/genesis/server/static/chat.css(加 .mismatch-badge 样式 + popover

  • Step 1: chat.html 插入徽章 DOM

找到 <div id="rag-bar" hidden> 这一行,之前插入:

<span id="proj-mismatch-badge" class="mismatch-badge" hidden><span id="mismatch-text"></span></span>
  • Step 2: chat.css 新增徽章 + popover 样式
.mismatch-badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 10px;
  border: 1px solid var(--warning);
  border-radius: var(--r-md);
  color: var(--warning);
  font-size: 12px; font-weight: 600;
  cursor: pointer;
  transition: all var(--dur) var(--ease);
  position: relative;
}
.mismatch-badge:hover { background: rgba(251,191,36,.1); }
.mismatch-popover {
  position: absolute; top: calc(100% + 6px); right: 0;
  min-width: 240px; max-width: 320px;
  background: var(--bg-surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  box-shadow: 0 8px 32px rgba(0,0,0,.5);
  padding: 10px 12px;
  z-index: 30;
  display: none;
  font-size: 12px;
  color: var(--text-secondary);
  line-height: 1.5;
}
.mismatch-popover.open { display: block; animation: zoomIn .2s var(--ease); }
.mismatch-popover .mp-actions { display: flex; gap: 6px; margin-top: 8px; }
.mismatch-popover .mp-actions button {
  flex: 1; padding: 6px 10px;
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  border-radius: var(--r-sm);
  font-size: 12px; font-weight: 600; cursor: pointer;
  transition: all var(--dur) var(--ease);
}
.mismatch-popover .mp-actions button:hover { border-color: var(--accent); color: var(--accent); }
.mismatch-popover .mp-actions button.primary { background: var(--accent); color: var(--bg-base); border-color: var(--accent); }
.mismatch-popover .mp-actions button.primary:hover { box-shadow: 0 0 16px var(--accent-soft); }
  • Step 3: chat.js 改造 renderProjectMismatchHint 为徽章渲染

找到原 renderProjectMismatchHint 函数(约原 chat.html:912 行),整段替换为:

function renderProjectMismatchHint() {
  const badge = document.getElementById('proj-mismatch-badge');
  const text = document.getElementById('mismatch-text');
  if (!badge || !text) return;
  if (!sid || !activeProject || activeProject === draftProject) {
    badge.hidden = true;
    badge.replaceChildren(); // 清空 popover
    return;
  }
  let skipped = {};
  try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
  if (skipped[sid] === activeProject) {
    badge.hidden = true;
    return;
  }
  // 显示徽章
  text.textContent = '项目不匹配:' + activeProject + ' ≠ ' + (draftProject || '(未选择)');
  badge.hidden = false;
  badge.onclick = (e) => {
    e.stopPropagation();
    // 构建/切换 popover
    let pop = badge.querySelector('.mismatch-popover');
    if (!pop) {
      pop = document.createElement('div');
      pop.className = 'mismatch-popover';
      pop.innerHTML =
        '<div>当前会话属于 <strong style="color:var(--accent)">' + esc(activeProject) + '</strong>' +
        '顶栏项目是 <strong style="color:var(--warning)">' + esc(draftProject || '(未选择)') + '</strong>。</div>' +
        '<div class="mp-actions">' +
        '<button class="primary" data-act="switch">切到该项目</button>' +
        '<button data-act="keep">保留当前项目</button></div>';
      badge.appendChild(pop);
      pop.querySelector('[data-act="switch"]').onclick = () => {
        draftProject = activeProject;
        applyProjectContext();
        renderProjectSwitcher();
        updateUploadBar();
        refreshSessions();
        badge.hidden = true;
        badge.replaceChildren();
      };
      pop.querySelector('[data-act="keep"]').onclick = () => {
        try {
          skipped[sid] = activeProject;
          sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
        } catch (er) { /* ignore */ }
        badge.hidden = true;
        badge.replaceChildren();
      };
    }
    pop.classList.toggle('open');
  };
  // 点击外部关闭 popover
  document.addEventListener('click', function _closeMismatch(e) {
    if (!badge.contains(e.target)) {
      const p = badge.querySelector('.mismatch-popover');
      if (p) p.classList.remove('open');
    }
  });
}
  • Step 4: 验证

启动服务器,浏览器:

  1. 选项目 A 创建一个会话
  2. 切换顶栏项目为 B
  3. 顶栏出现黄色徽章 ⚠ 项目不匹配:A ≠ B
  4. 点击徽章 → 弹出 popover,含「切到该项目」和「保留当前项目」按钮
  5. 点击「切到该项目」→ draftProject 变 A,徽章消失
  6. 重新切换到 B → 徽章再次出现
  7. 点击「保留当前项目」→ 徽章消失;刷新页面不再提示
  • Step 5: 提交
git add src/genesis/server/static/chat.html \
        src/genesis/server/static/chat.js \
        src/genesis/server/static/chat.css
git commit -m "feat(chat): 顶栏项目不匹配徽章化(popover 切/保留)"

Task 8: 交互改造 - 快捷键 Esc + Cmd/Ctrl+Kspec §4.6

Files:

  • Modify: src/genesis/server/static/chat.js(改造 keydown 监听 + 加 Cmd+K

  • Step 1: 找到原 keydown 监听

chat.js 找到原 chat.html:1103 附近的:

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
    else if (drawerOpen) closeProjectDrawer();
    if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
  }
});

替换为:

document.addEventListener('keydown', (e) => {
  // Esc: 关闭项目切换器 / 抽屉 / popover
  if (e.key === 'Escape') {
    // 1. 关闭 mismatch popover
    document.querySelectorAll('.mismatch-popover.open').forEach(p => p.classList.remove('open'));
    // 2. 关闭项目切换器
    if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
    // 3. 关闭抽屉
    else if (drawerOpen) closeProjectDrawer();
    if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
    return;
  }
  // Cmd/Ctrl + K: 切换项目切换器
  if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
    e.preventDefault();
    const switcherBtn = document.getElementById('ps-current');
    if (switcherBtn) { lastTriggerEl = switcherBtn; toggleProjectSwitcher(); }
  }
});
  • Step 2: chat.css 新增项目切换器 hover 时显示 ⌘K 提示(仅 mac

.ps-current 块之后追加:

.ps-current::after {
  content: "⌘K";
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-muted);
  margin-left: 4px;
  padding: 1px 4px;
  border: 1px solid var(--border-subtle);
  border-radius: 4px;
  opacity: 0;
  transition: opacity .15s var(--ease);
}
/* 仅 mac 显示(mac 平台 UA 包含 Mac */
@supports (-webkit-touch-callout: none) {
  .ps-current:hover::after { opacity: 1; }
}

: @supports 探测在 mac Safari 上不一定准;简化方案是用 UA 嗅探(JS),但本轮保持纯 CSS,win/linux 不显示是符合 spec §四-6 的("win/linux 省略")。

  • Step 3: 验证

启动服务器,浏览器:

  1. Cmd+Kmac)或 Ctrl+K(win/linux)→ 项目切换器打开
  2. 再按 Cmd+K → 项目切换器关闭
  3. Esc → 切换器关闭
  4. 抽屉打开时按 Esc → 抽屉关闭
  5. 焦点在输入框时按 Cmd+K → 切换器仍能打开
  6. 徽章 popover 打开时按 Esc → popover 关闭
  • Step 4: 提交
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 快捷键 Esc + Cmd/Ctrl+K(项目切换器)"

Task 9: 交互改造 - 骨架屏 + 空状态快捷指令 + 错误分级(spec §4.7 / §4.8 / §五)

Files:

  • Modify: src/genesis/server/static/chat.js(新增 renderSkeleton / renderEmptyQuickActions / addInfo / addWarn,改造启动 IIFE 和 renderEmptyOrWelcome

  • Modify: src/genesis/server/static/chat.css(加 .skel / .quick-actions 样式)

  • Step 1: chat.css 新增骨架屏 + 快捷指令 + 错误分级样式

/* 骨架屏 */
.skel { display: flex; margin: var(--sp-3) 0; gap: 10px; }
.skel-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--bg-elevated); flex-shrink: 0; }
.skel-bars { flex: 1; display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.skel-bar {
  height: 12px; border-radius: 6px;
  background: linear-gradient(90deg, var(--bg-elevated) 0%, var(--border-strong) 50%, var(--bg-elevated) 100%);
  background-size: 200% 100%;
  animation: skelShimmer 1.4s ease-in-out infinite;
}
.skel-bar:nth-child(1) { width: 60%; }
.skel-bar:nth-child(2) { width: 80%; }
.skel-bar:nth-child(3) { width: 45%; }
@keyframes skelShimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* 空状态快捷指令 */
.quick-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.quick-actions button {
  background: var(--bg-elevated);
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  border-radius: 999px;
  padding: 6px 14px;
  font-size: 13px; font-weight: 500;
  cursor: pointer;
  transition: all var(--dur) var(--ease);
  font-family: var(--font-ui);
}
.quick-actions button:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 12px var(--accent-soft); }

/* 错误分级 */
.msg.info .bubble { background: rgba(96,165,250,.12); border: 1px solid var(--info); color: var(--info); }
.msg.warn .bubble { background: rgba(251,191,36,.12); border: 1px solid var(--warning); color: var(--warning); }
  • Step 2: chat.js 新增 renderSkeleton / renderEmptyQuickActions / addInfo / addWarn

chat.js 中(任意位置)追加:

function renderSkeleton() {
  const wrap = document.createElement('div');
  wrap.id = 'skeleton';
  for (let i = 0; i < 3; i++) {
    const row = document.createElement('div');
    row.className = 'skel';
    row.innerHTML = '<div class="skel-avatar"></div><div class="skel-bars"><div class="skel-bar"></div><div class="skel-bar"></div><div class="skel-bar"></div></div>';
    wrap.appendChild(row);
  }
  chatEl.appendChild(wrap);
  // 5 秒超时
  setTimeout(() => { const s = document.getElementById('skeleton'); if (s) s.remove(); }, 5000);
  return wrap;
}

function removeSkeleton() {
  const s = document.getElementById('skeleton');
  if (s) s.remove();
}

function renderEmptyQuickActions() {
  const wrap = document.createElement('div');
  wrap.className = 'quick-actions';
  const actions = [
    { label: '上传要件定义', act: () => document.getElementById('file-input').click() },
    { label: '查看项目状态', act: () => send('现在什么状态?') },
    { label: '开始生成概要设计书', act: () => send('生成概要设计书') },
  ];
  for (const a of actions) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.textContent = a.label;
    btn.onclick = a.act;
    wrap.appendChild(btn);
  }
  return wrap;
}

function addInfo(text) { return addMsg('info', esc(text), text); }
function addWarn(text) { return addMsg('warn', esc(text), text); }
function addError(text) { return addMsg('error', esc(text), text); }

注意: 原 chat.js 中所有 addMsg('error', ...) 调用保留不变(已通过新签名 addMsg(role, html, text) 兼容)。addError 是 helper 便于未来替换,但本轮不强制替换。

  • Step 3: 改造启动 IIFE

找到启动 IIFE(async () => { 开头),把:

if (saved) {
  addMsg('assistant', '正在恢复上次会话…');
  try { await loadSession(saved); ... }
}

替换为:

if (saved) {
  renderSkeleton();
  try { await loadSession(saved); removeSkeleton(); ... }
  catch (e) { removeSkeleton(); }
}
  • Step 4: 改造 renderEmptyOrWelcome

找到:

function renderEmptyOrWelcome() {
  chatEl.innerHTML = '';
  addMsg('assistant', GenesisState.buildWelcome(draftProject));
}

替换为:

function renderEmptyOrWelcome() {
  chatEl.innerHTML = '';
  const welcome = GenesisState.buildWelcome(draftProject);
  const m = addMsg('assistant', esc(welcome), welcome);
  // 有项目时显示快捷指令
  if (draftProject) {
    m.querySelector('.bubble').appendChild(renderEmptyQuickActions());
  }
}
  • Step 5: 验证

启动服务器,浏览器:

  1. 清空 localStorage 刷新 → 无项目时显示欢迎语 + 没有快捷指令
  2. 选一个项目(无 sid)→ 显示欢迎语 + 3 个快捷指令按钮
  3. 点击「上传要件定义」→ 触发文件选择对话框
  4. 点击「开始生成概要设计书」→ 自动 send('生成概要设计书')
  5. localStorage 留有上次会话 → 刷新 → 显示 3 行骨架屏 → 加载完成骨架消失
  6. 手动断网触发 addMsg('error', '请求失败')(断网后 send)→ 错误气泡用红边红字
  7. 触发 addWarn('类型不匹配') → 黄色边
  • Step 6: 提交
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 骨架屏 + 空状态快捷指令 + 错误分级(info/warn/error)"

Task 10: 文档同步 + 端到端回归

Files:

  • Modify: docs/design.md(追加"前端 UI 设计规范"一节)

  • Modify: _AI_USAGE_LOG.md(追加 3 条记录)

  • Test: 手动回归清单(spec §七)

  • Step 1: 读 docs/design.md 现有结构

# 用 read 工具读 docs/design.md 末尾 100 行,确认追加位置
  • Step 2: 在 docs/design.md 末尾追加「前端 UI 设计规范」

在文件末尾追加:

## 前端 UI 设计规范(2026-08-30

### 设计系统

- **风格**:深色指挥中心 + 文档工作台混合
- **强调色**`#5EEAD4`(青绿,唯一)
- **背景色阶**`--bg-base: #0B0F17` / `--bg-surface: #141A24` / `--bg-elevated: #1B2330` / `--bg-input: #0F141C`
- **文本色阶**`--text-primary: #E5EAF0` / `--text-secondary: #8B95A7` / `--text-muted: #5C6677`
- **边框色阶**`--border-subtle: rgba(255,255,255,.08)` / `--border-strong: rgba(255,255,255,.14)`
- **状态色**`--success: #34D399` / `--warning: #FBBF24` / `--danger: #F87171` / `--info: #60A5FA`
- **字体策略**:系统字体栈(不引入 Google Fonts / JetBrains Mono
  - UI`-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif`
  - 等宽:`ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`
- **间距阶梯**4 / 8 / 12 / 16 / 20 / 24
- **圆角阶梯**6 (sm) / 10 (md) / 14 (lg) / 9999 (胶囊)
- **动效**`cubic-bezier(.2, 0, 0, 1)` × `0.18s`

### 文件结构

- `src/genesis/server/static/chat.html`:仅 DOM 结构
- `src/genesis/server/static/chat.css`:所有视觉样式(约 600 行)
- `src/genesis/server/static/chat.js`:业务逻辑 + 8 项交互(约 900 行)
- `chat_state.js` / `chat_ws.js`:协议层,不动

### 兼容约束

- 所有 DOM id 保留(业务逻辑依赖)
- 所有 localStorage / sessionStorage 键保留
- 所有 API 端点不变
- 移动端改造(< 768px 抽屉化)不在本轮
- 快捷键仅 Esc + Cmd/Ctrl+K
- 消息气泡"重新生成"按钮不在本轮

### 设计规范来源

完整 spec`docs/superpowers/specs/2026-08-30-frontend-beautify-design.md`
实施计划:`docs/superpowers/plans/2026-08-30-frontend-beautify.md`
  • Step 3: 在 _AI_USAGE_LOG.md 追加 3 条记录

_AI_USAGE_LOG.md 末尾 20 行确认格式,然后追加(按 AGENTS.md 字段):

| 2026-08-30 16:00 | 架构设计 | 完成前端美化设计 spec(深色指挥中心 + 文档工作台) | docs/superpowers/specs/2026-08-30-frontend-beautify-design.md | minimax/minimax-m3:free |
| 2026-08-30 17:00 | Agent 实现 | 实施 chat.html 拆分 + chat.css / chat.js 改写 + 8 项交互 | src/genesis/server/static/chat.html, chat.css, chat.js | minimax/minimax-m3:free |
| 2026-08-30 18:00 | 反馈迭代 | 同步 docs/design.md + _AI_USAGE_LOG.md | docs/design.md, _AI_USAGE_LOG.md | minimax/minimax-m3:free |

注意: 日期时间字段需在实施时按实际填,先用占位时间,commit 前修正。

  • Step 4: 端到端手动回归(spec §七清单)

按 spec §七的 7 个流程依次在浏览器手测,全部通过后方可 commit:

  1. 新建会话 → 选项目 → 上传要件 → 发送"生成概要设计书" → 收到下载链接
  2. 切换项目 → 不匹配徽章出现 → 点击"切到该项目" → 徽章消失
  3. 拖拽上传 xlsx → 自动选 requirements → 上传成功
  4. 输入多行(Shift+Enter)→ 发送(Enter)→ 高度重置
  5. hover 助手气泡 → 复制 → 粘贴验证
  6. Esc 关闭抽屉;Cmd+K 打开/关闭项目切换器
  7. 启动恢复会话时显示骨架屏 → 加载完成消失
  • Step 5: 提交
git add docs/design.md _AI_USAGE_LOG.md
git commit -m "docs(frontend): 同步前端 UI 设计规范 + AI 使用日志"

Self-Review

  1. Spec coverage:

    • §零决策(10 条)→ 全部贯穿 Task 1-10 ✓
    • §一设计令牌 → Task 2 Step 1 ✓
    • §二六大区域 → Task 2 Step 3-9 ✓
    • §三关键不变量 → 始终约束;本 plan 严格保持 DOM id / 键 / 端点不变 ✓
    • §四-1 分组 → Task 3 ✓
    • §四-2 拖拽 → Task 4 ✓
    • §四-3 气泡操作(无"重新生成")→ Task 5 ✓
    • §四-4 多行输入 → Task 6 ✓
    • §四-5 顶栏徽章 → Task 7 ✓
    • §四-6 快捷键 Esc+Cmd+K → Task 8 ✓
    • §四-7 骨架屏 → Task 9 ✓
    • §四-8 空状态快捷指令 → Task 9 ✓
    • §五错误分级 → Task 9 ✓
    • §六错误处理 → 全部 Task 包含(try/catch / 静默降级)✓
    • §七测试策略 → Task 10 Step 4 手动回归 ✓
    • §九风险与回滚 → Global Constraints + 提交粒度可控 ✓
    • §十里程碑外(不做项)→ 在 §零-8/9/10 决策 + Task 2/5/8 范围约束 ✓
  2. 占位符扫描:

    • 全文无 TBD / TODO / "implement later" / "similar to Task N"
    • 所有代码块均给出完整实现(CSS / JS / HTML)✓
  3. 类型 / 签名一致性:

    • addMsg(role, html, text) 在 Task 5 定义,Task 9 的 addInfo/addWarn/addError 沿用 ✓
    • bindUploadDrag / bindComposer / autoResizeTextarea / renderSkeleton / renderEmptyQuickActions 命名一致 ✓
    • GenesisState 协议(buildWelcome / autoBindProject / resolveUploadType)未改 ✓
  4. 未覆盖但可接受的点:

    • Task 5 Step 3 的"逐个修改 addMsg 调用点"依赖 grep 工具列清单;本 plan 不展开所有 8 个 call sitechat.html 内联段已完整复制到 chat.js,可直接 grep 替换)。
    • Task 7 Step 3 的 badge.onclick 内绑定 document.addEventListener('click', ...) 在每次 renderProjectMismatchHint 调用时会重复添加全局监听;本轮在每次调用前未清理旧监听,会有多个监听累积。改进: 在 chat.js 启动 IIFE 内一次性绑定全局 click 监听,匹配 .mismatch-badge 时关闭 popover。Task 7 实施时若发现此问题,按此建议修复。
    • Task 9 Step 4 的 m.querySelector('.bubble').appendChild(renderEmptyQuickActions()) 假设 .bubble 存在;addMsg 已保证创建 .bubble
  5. 本 plan 范围外(在 spec §十 列出,不在本 plan 实现):

    • 移动端 < 768px 断点改造
    • 快捷键 Cmd+Shift+O / Cmd+Shift+S
    • 消息气泡"重新生成"按钮
    • dark/light 主题切换

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-08-30-frontend-beautify.md.

包含 10 个 Task

  • Task 1:拆分 chat.html(机械拆分,行为零变更)
  • Task 2:视觉重塑(chat.css 深色主题)
  • Task 3:会话分组
  • Task 4:拖拽上传
  • Task 5:气泡复制/引用
  • Task 6:多行输入
  • Task 7:项目不匹配徽章
  • Task 8:快捷键 Esc+Cmd+K
  • Task 9:骨架屏 + 空状态 + 错误分级
  • Task 10:文档 + 手动回归

两个执行选项

  1. Subagent-Driven(推荐) —— 我为每个 Task 派遣一个全新的子代理,Task 之间做 review,快速迭代
  2. Inline Execution —— 在当前会话中按 executing-plans 执行,带 checkpoint

选哪个?