feat(ui): favicon(svg+204 路由) + 抽屉删左栏/宽度变量520/蒙版0.5(点蒙版关闭带未保存确认)

This commit is contained in:
lhl
2026-09-12 17:50:32 +08:00
parent 71ecc177ee
commit b25ed4809c
7 changed files with 110 additions and 48 deletions
@@ -0,0 +1,67 @@
# favicon 修复 + 抽屉(删左栏/宽度变量/蒙版)— 实施计划
目标文件:`src/genesis/server/{app.py}``src/genesis/server/static/{chat.html,chat.css,chat.js}`、新增 `static/favicon.svg`
后端改动仅 favicon 相关;其余为前端。
## 已确认决策
- D1 抽屉宽度 `--drawer-w: 520px`
- D2 蒙版 `rgba(0,0,0,.5)`
- D3 点蒙版关闭抽屉(带未保存确认)。
- D4 删除抽屉左侧项目列表,抽屉只留表单。
---
## Task 1favicon 404
- 新增 `src/genesis/server/static/favicon.svg`:品牌「G」(深底 `#0B0F17` + accent `#5EEAD4`)。
- `chat.html <head>` 增:`<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=__ASSET_VER__">`
- `app.py`
- `from fastapi.responses import FileResponse, HTMLResponse, Response`
-`index` 附近新增 `@app.get("/favicon.ico")``Response(status_code=204)`
- `_asset_names` 加入 `"favicon.svg"`
## Task 2:抽屉删左栏 + 宽度变量 + 蒙版
- `chat.html`
- 删除 `<div class="drawer-list" id="drawer-list"></div>`
- 新增 `<div id="drawer-mask" class="drawer-mask"></div>`(与 `#proj-drawer` 同级)。
- `chat.js`
- 删除 3 处 `renderDrawerList();``loadProjects`/`openProjectDrawer`/`loadDrawerForm`)与 `renderDrawerList()` 函数定义。
- `openProjectDrawer` → mask `show``closeProjectDrawer` → 过 `guardDrawerDirty()` 后 mask 隐藏。
- 点蒙版 → `closeProjectDrawer()`
- `chat.css`
- `:root``--drawer-w: 520px; --mask-bg: rgba(0,0,0,.5);``#proj-drawer { width: var(--drawer-w); }`
- 删除 `.drawer-list` 规则(`#drawer-list` 不再存在)。
- 新增 `.drawer-mask`fixed/inset/z-index 99/opacity+visibility 过渡/`.show`)与 `@media (max-width:860px) .drawer-mask{display:none}`
## Task 3:验证与提交
- `node --check``node --test``python -m pytest`(≥99%)。
- 手动:favicon 无 404;抽屉只留表单、宽 520px;蒙版 0.5;点蒙版/×/Esc 关闭(带未保存确认)。
- 更新 `_AI_USAGE_LOG.md` 并提交。
## 风险
- 蒙版使抽屉从"非模态"变"模态"(打开时点侧边栏会被蒙版拦截并关闭抽屉)。
- 窄屏(≤860px)抽屉全屏,蒙版隐藏,仅 ×/Esc 关闭。
---
## 评审结论与计划调整(plan-eng-review
### 采纳的修正
- **F5(真实缺陷,修正计划)**:**不要**把 `.drawer-body` 改成 `display:block`。保持 `flex:1; display:flex; min-height:0`,仅删除 `.drawer-list` 规则——否则 `.drawer-form { flex:1; overflow-y:auto }` 失去高度约束,长表单溢出且不滚动。
- **F6(测试缺口,采纳)**:新增 favicon 回归测试(`tests/test_server_api.py`):`GET /favicon.ico` == 204 且 `GET /` 文本含 `rel="icon"`
- **窄屏蒙版**:保留 `@media (max-width:860px) .drawer-mask { display:none }`(全屏抽屉时仅 ×/Esc 关闭)。
### 记录(接受,不改)
- F3 行为变更:加蒙版后抽屉转为**模态**——打开时点侧边栏/空白会被蒙版拦截并触发关闭(未保存先确认)。
- F4:蒙版不加 `aria-hidden`,作为纯装饰层。
- F7/F8/F9:层叠、幂等、资源版本一致性均按计划成立。
### 受影响任务更新
- Task 2:删除 `.drawer-list` 规则即可,`.drawer-body` **保持不变**
- Task 3:新增 favicon 回归测试。
## GSTACK REVIEW REPORT
- STATUS: DONE
- REVIEWED: docs/superpowers/plans/2026-09-12-drawer-favicon.md
- FINDINGS: F5 缺陷(已修正) / F6 测试缺口(已采纳) / F3 行为变更(记录) / F4 a11y(记录)F7-F9 接受
- KEY DECISION: `.drawer-body` 保持 flex;新增 favicon 回归测试;窄屏隐藏蒙版
- NEXT: 待用户批准后实施
+7 -2
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -117,7 +117,7 @@ def create_app(
static_dir = Path(__file__).parent / "static"
# 静态资源版本号:按 mtime 计算,随任何前端文件变更而变化(重启后生效)
_asset_names = ("chat.html", "chat.css", "chat.js", "chat_state.js", "chat_ws.js")
_asset_names = ("chat.html", "chat.css", "chat.js", "chat_state.js", "chat_ws.js", "favicon.svg")
try:
_asset_ver = str(max(int((static_dir / n).stat().st_mtime_ns)
for n in _asset_names if (static_dir / n).exists()))
@@ -138,6 +138,11 @@ def create_app(
def index():
return HTMLResponse(chat_html, headers={"Cache-Control": "no-store", "Pragma": "no-cache"})
@app.get("/favicon.ico")
def favicon():
# 浏览器默认请求;返回 204 避免 404(真实图标由 <link rel="icon"> 指向 favicon.svg
return Response(status_code=204)
# 静态资源(chat.css / chat_state.js / chat_ws.js / 未来的 chat.js
# 统一通过 /static/<file> 访问,避免逐个文件注册
app.mount("/static", StaticFiles(directory=str(static_dir), html=False), name="static")
+12 -26
View File
@@ -42,6 +42,10 @@
--send-size: 40px;
--composer-gap: 10px;
--composer-gutter: calc(var(--send-size) + var(--composer-gap));
/* 抽屉 */
--drawer-w: 520px;
--mask-bg: rgba(0, 0, 0, 0.5);
}
/* === 全局 === */
@@ -351,7 +355,7 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
/* === 项目抽屉 (spec §2.6) === */
#proj-drawer {
position: fixed; top: 0; right: 0; height: 100vh; width: 460px;
position: fixed; top: 0; right: 0; height: 100vh; width: var(--drawer-w);
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);
@@ -372,32 +376,13 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
}
.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-mask {
position: fixed; inset: 0; z-index: 99; background: var(--mask-bg);
opacity: 0; visibility: hidden;
transition: opacity .3s var(--ease), visibility .3s var(--ease);
}
.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-mask.show { opacity: 1; visibility: visible; }
.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);
@@ -435,6 +420,7 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
#sidebar.collapsed { width: 48px; }
#sid-badge { max-width: 24%; }
#proj-drawer { width: 100vw; }
.drawer-mask { display: none; }
}
/* === 顶栏会话名 === */
+2 -1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Genesis — 概要设计书自动生成(对话)</title>
<link rel="stylesheet" href="/static/chat.css?v=__ASSET_VER__">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=__ASSET_VER__">
</head>
<body>
<div id="sidebar">
@@ -80,13 +81,13 @@
</div>
</div>
<div id="drawer-mask" class="drawer-mask"></div>
<div id="proj-drawer" role="dialog" aria-label="项目配置">
<div class="drawer-head">
<h2>项目配置</h2>
<button class="drawer-close" id="drawer-close" title="关闭">×</button>
</div>
<div class="drawer-body">
<div class="drawer-list" id="drawer-list"></div>
<div class="drawer-form" id="drawer-form">
<div class="drawer-hint">选择左侧项目以查看与编辑,或点 <strong>+ 新建项目</strong> 创建;新建时请设置该项目的资料目录。</div>
<div class="field">
+5 -19
View File
@@ -211,7 +211,6 @@ async function loadProjects() {
draftProject = GenesisState.autoBindProject(draftProject, projects, readExplicitNone());
applyProjectContext();
renderProjectSwitcher();
renderDrawerList();
if (drawerOpen) loadDrawerForm(drawerSelected);
}
@@ -251,15 +250,18 @@ function openProjectDrawer(projectName) {
drawerOpen = true;
toggleProjectSwitcher(false);
drawer.classList.add('open');
const mask = document.getElementById('drawer-mask');
if (mask) mask.classList.add('show');
const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName;
try { loadDrawerForm(sel); } catch (e) { console.error(e); }
renderDrawerList(); // 始终渲染左侧列表(含 +新建项目)
}
function closeProjectDrawer() {
if (!guardDrawerDirty()) return;
drawerOpen = false;
drawer.classList.remove('open');
const mask = document.getElementById('drawer-mask');
if (mask) mask.classList.remove('show');
}
function loadDrawerForm(projectName) {
@@ -292,7 +294,6 @@ function loadDrawerForm(projectName) {
}
// B6:记录字段快照用于脏检测
drawerSnapshot = drawerSnapshotNow();
renderDrawerList();
}
// B6:抽屉未保存变更守卫
@@ -345,22 +346,6 @@ function flagPfInvalidByMessage(message) {
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
.forEach(id => document.getElementById(id).addEventListener('input', (e) => e.target.classList.remove('invalid')));
function renderDrawerList() {
let html = '';
for (const p of projects) {
html += '<div class="drawer-item' + (p.name === drawerSelected ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span class="di-name">' + esc(p.display_name || p.name) + '</span></div>';
}
const box = document.getElementById('drawer-list');
box.innerHTML = html;
box.querySelectorAll('.drawer-item').forEach(it => {
it.onclick = () => {
if (!guardDrawerDirty()) return;
const n = it.dataset.name === '' ? null : it.dataset.name;
loadDrawerForm(n);
};
});
}
async function saveDrawerProject() {
clearPfInvalid();
@@ -683,6 +668,7 @@ document.getElementById('new-chat').addEventListener('click', () => newSession()
document.getElementById('sidebar-toggle').addEventListener('click', () => toggleSidebar());
document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); });
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
document.getElementById('drawer-mask').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
document.getElementById('side-add-project').addEventListener('click', () => {
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Genesis">
<rect width="64" height="64" rx="14" fill="#0B0F17"/>
<text x="32" y="44" text-anchor="middle"
font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Microsoft YaHei, sans-serif"
font-size="36" font-weight="700" fill="#5EEAD4">G</text>
</svg>
+11
View File
@@ -377,3 +377,14 @@ def test_api_rename_session_not_found(client):
r = client.patch("/api/sessions/nope", json={"name": "x"})
assert r.status_code == 404
assert r.json()["detail"]["code"] == "SESSION_NOT_FOUND"
def test_favicon_served(client):
# 浏览器默认请求 /favicon.ico,应返回 204 而非 404
assert client.get("/favicon.ico").status_code == 204
def test_root_has_icon_link(client):
r = client.get("/")
assert r.status_code == 200
assert 'rel="icon"' in r.text
assert "/static/favicon.svg" in r.text