feat(ui): favicon(svg+204 路由) + 抽屉删左栏/宽度变量520/蒙版0.5(点蒙版关闭带未保存确认)
This commit is contained in:
@@ -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 1:favicon 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: 待用户批准后实施
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/* === 顶栏会话名 === */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user