HEL-771: 修齐主题卡悬停底色、副标题和默认徽标间距 #9

Open
leefer wants to merge 3 commits from fix/hel-771-theme-tile-details into fix/hel-769-theme-preview
5 changed files with 323 additions and 5 deletions
@@ -57,10 +57,14 @@
>
<span class="copy"
><strong
>{item.name}{#if tiles && item.id === "night"}
<span class="mg-tag theme-default">默认</span>{/if}</strong
><small>{tiles ? item.description.split(" · ")[0] : item.english}</small
>{#if !tiles}<span>{item.description}</span>{/if}</span
>{tiles && item.id === "night"
? item.name + " "
: item.name}{#if tiles && item.id === "night"}<span
class="mg-tag theme-default">默认</span
>{/if}</strong
><small>{tiles ? item.tone : item.english}</small>{#if !tiles}<span
>{item.description}</span
>{/if}</span
>
<span class="check" class:tile-check={tiles} aria-hidden="true"
>{$theme === item.id ? "✓" : ""}</span
@@ -80,6 +84,7 @@
}
.choices.tiles {
--choice-check-width: 0px;
--choice-hover-background: var(--bg-2);
--choice-display: flex;
--choice-box-sizing: border-box;
--choice-gap: 6px;
@@ -129,7 +134,7 @@
}
.choice:hover {
border-color: var(--line-3);
background: var(--bg-3);
background: var(--choice-hover-background, var(--bg-3));
}
.choice.selected {
border-color: var(--choice-selected, var(--gold));
+4
View File
@@ -7,24 +7,28 @@ export const THEMES = [
id: "night",
name: "夜片场",
english: "Nocturne",
tone: "深色 · 默认",
description: "墨黑与钨丝金 · 沉浸式创作",
},
{
id: "day",
name: "日光棚",
english: "Daylight",
tone: "浅色",
description: "暖白纸面 · 白天办公与长时间审稿",
},
{
id: "grade",
name: "调色间",
english: "Grading",
tone: "中性深灰",
description: "中性深灰 · 判断作品色彩",
},
{
id: "bluehour",
name: "蓝调时刻",
english: "Blue Hour",
tone: "深蓝",
description: "靛蓝与珊瑚 · 提案与灵感发散",
},
] as const;
@@ -0,0 +1,24 @@
# HEL-771 · 主题卡三处对齐
基于 `b2bcbc10df97f448cfa3f3f17bf3febad8c7a8ed`,分支 `fix/hel-771-theme-tile-details`,PR base `fix/hel-769-theme-preview`。不修改 PR #8、不合并、不部署。
产品仅修改 `src/lib/theme.ts` 和 `src/lib/components/shell/ThemeChoices.svelte`(均在 calliope-web 下):主题定义增加 tiles 专用 tone;复用 `.choice:hover` 的单一背景规则,以 tiles 变量保持 bg-2,compact 默认仍为 bg-3;夜卡默认徽标前在名称文本节点末尾补一个空格,避免格式化工具消除 HTML 空白,同时避免分离文本节点的逐段小数舍入。不改预览几何、字号、行高、卡高或 compact 文案和布局,不加依赖或检查豁免。
纠正 HEL-769 记录中的定性:悬停整卡差异来源是 `.choice:hover` 将背景由 bg-2 改为 bg-3 的计算样式差异,不是父卡背景抗锯齿。HEL-770 原始测量、定位 JSON 和图片保留在本卡附件的 inputs/review 中,并核对原清单全部 18 项大小及 SHA256。
复现:将未经编辑的原 HTML 放在仓库同级 `materials/夜片场-多主题交互样本.html`,哈希必须为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。
- `cd calliope-web`,运行 `npm ci`、`npm run lint`、`npm run check`、`npm test`、`npm run build`。
- 仓库根运行 `node scripts/verify-hel771.mjs` 和 `python3 scripts/compare-hel771.py`。Playwright 通过已有环境提供(`PLAYWRIGHT_MODULE`),浏览器库通过 `LD_LIBRARY_PATH` 提供,不改项目依赖。
- 对照 1440×900、1366×768、1280×720 × 四页面主题 × 四卡 × 普通/选中/悬停,共 144 对。选中态为在固定页面主题下仅切换卡片选中 class 的视觉夹具,真实点击、键盘和保存另由既有交互守护验证。
- 复用 HEL-770 DOM 测量函数,增加默认徽标几何;记录每张卡、预览内部、文字、背景/边框、徽标的原始 JSON 和未经修改的 PNG。比较脚本按文件计算整卡逐像素差异,生成并排图与三倍近景。
- `HEL769_SKIP_VISUAL=1 python3 scripts/verify-hel769.py` 验证现有 121 项交互和布局守护;完整运行同脚本验证原管理页矩阵及搜索框/侧栏/任务八列/工作流三栏,保留原始结果。
所有服务在 localhost 随机端口,API 为隔离假服务,外部来源被拦截;未触真实 GPU、付费生成、用户库/配置或 HEL-731~733。临时 SQLite/媒体由既有脚本临时目录清理,浏览器及服务在 finally 关闭。没有独立架构文档,施工前按 README 的前后端目录边界、既有主题/组件结构和 HEL-767/769 交付记录核对,未扩展模块范围。
## 最终自测结果
- npm ci(268 包)、lint、check(0 错误 0 警告)、四组既有前端守护与 build 通过;build 保留既有第三方未使用导入与大 chunk 提示。
- 144 对卡片状态的文字、徽标位置、卡高、字号/行高、背景/边框与预览内部几何/样式全部零差异。主画面39px、底条9px、角标14×5px、短文字线20×3px及四条列表线保持。
- 整卡原始像素:138/144 对完全一致;其余6对仅圆角边缘1~8像素有差异,最大单通道2/255,均为普通态。选中态48/48、悬停态48/48均整卡逐像素一致。原图和逐项统计完整保留,不以裁边、改图或容差过滤隐藏差异;像素统计供独立复核,未宣称所有截图零差异。
- 最终版本交互守护121项通过;管理页60组/11,809项断言通过,零pageerror。证据范围仅本卡三项返工与既有定向守护,未宣称整套样本完成。
+46
View File
@@ -0,0 +1,46 @@
"""Compare the unmodified captures and DOM measurements from verify-hel771.mjs."""
import json
import os
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
OUT = Path(os.environ.get('HEL771_OUT', Path(__file__).resolve().parents[2] / 'evidence' / 'tiles'))
r = json.loads((OUT / 'measurements.json').read_text())
summary = {'groups': len(r['groups']), 'differences': [], 'pixels': []}
def compare(a,b,prefix):
if isinstance(a,dict):
for k in a:
if k in ('cardClass','ariaChecked'): continue
compare(a[k],b[k],prefix+'.'+k)
elif isinstance(a,list):
if len(a)!=len(b): summary['differences'].append([prefix,a,b])
else:
for i,(x,y) in enumerate(zip(a,b)): compare(x,y,f'{prefix}.{i}')
elif a!=b:
summary['differences'].append([prefix,a,b])
for g in r['groups']:
compare(g['app'],g['sample'],g['name'])
a=Image.open(OUT/(g['name']+'-app.png')).convert('RGB')
b=Image.open(OUT/(g['name']+'-sample.png')).convert('RGB')
assert a.size==b.size
d=ImageChops.difference(a,b)
pixels=sum(1 for p in d.get_flattened_data() if p!=(0,0,0))
summary['pixels'].append({'name':g['name'],'different_pixels':pixels,'total_pixels':a.width*a.height,'max_channel_delta':max(x[1] for x in d.getextrema())})
if g['width']==1440 and g['theme']=='night' and g['card'] in (0,1):
pair=Image.new('RGB',(a.width*2,a.height+24),'#151515')
ImageDraw.Draw(pair).text((5,5),'Original (left) / App (right)',fill='white')
pair.paste(b,(0,24));pair.paste(a,(a.width,24))
pair.resize((pair.width*3,pair.height*3),Image.Resampling.NEAREST).save(OUT/(g['name']+'-pair-3x.png'))
for p in OUT.glob('*-full-app.png'):
a=Image.open(p);b=Image.open(str(p).replace('-app.png','-sample.png'))
pair=Image.new('RGB',(a.width*2,a.height+24),'#151515')
ImageDraw.Draw(pair).text((8,5),'Original (left) / App (right)',fill='white')
pair.paste(b,(0,24));pair.paste(a,(a.width,24));pair.save(str(p).replace('-app.png','-pair.png'))
(OUT/'comparison.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
print('groups:',len(r['groups']),'DOM differences:',len(summary['differences']),'pixel-identical:',sum(p['different_pixels']==0 for p in summary['pixels']))
print(json.dumps(summary['differences'][:8],ensure_ascii=False,indent=2))
assert not summary['differences'], 'DOM mismatch'
# Pixel statistics are evidence, not a hidden tolerance gate: retain every raw pair.
print('Non-identical raw pairs:', json.dumps([p for p in summary['pixels'] if p['different_pixels']], ensure_ascii=False))
+239
View File
@@ -0,0 +1,239 @@
/** HEL-771 targeted verification; measurement helper adapted from HEL-770 review.
* Serves the frozen build locally, fakes the API (no backend, no user data),
* renders app settings theme tiles and the original sample side by side,
* and records geometry, computed styles and screenshots for offline comparison.
*/
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright");
const ROOT = process.cwd();
const BUILD = path.join(ROOT, "calliope-web/build");
const SAMPLE = path.join(ROOT, "../materials/夜片场-多主题交互样本.html");
const OUT = process.env.HEL771_OUT || path.join(ROOT, "../evidence/tiles");
await fs.mkdir(OUT, { recursive: true });
const THEMES = ["night", "day", "grade", "bluehour"];
const VIEWPORTS = [1280, 1366, 1440];
const apiLog = [];
const server = http.createServer(async (req, res) => {
try {
const p = decodeURIComponent(new URL(req.url, "http://local").pathname);
if (p.startsWith("/api/")) {
apiLog.push(`${req.method} ${p}`);
let body = [];
if (p === "/api/settings")
body = {
queue_concurrency: 2,
queue_poll_timeout_sec: 1800,
comfyui_base_url: "http://127.0.0.1:8188",
projects_root: "D:\\AIGC\\projects",
dry_run: false,
};
else if (
p === "/api/projects" ||
p === "/api/workflows" ||
p === "/api/library/media" ||
p === "/api/agent/sessions" ||
p === "/api/agent/agents" ||
p === "/api/canvas" ||
p.startsWith("/api/tasks") ||
p.startsWith("/api/models")
)
body = [];
else if (p === "/api/jobs/queue-status") body = { paused: false };
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(body));
return;
}
if (p.startsWith("/sample")) {
const bytes = await fs.readFile(SAMPLE);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(bytes);
return;
}
let f = path.join(BUILD, p);
if (!path.extname(f)) f = path.join(BUILD, "index.html");
const bytes = await fs.readFile(f);
res.setHeader(
"Content-Type",
f.endsWith(".js")
? "text/javascript"
: f.endsWith(".css")
? "text/css"
: f.endsWith(".html")
? "text/html"
: "application/octet-stream",
);
res.end(bytes);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => (server.listing = server.listen(0, "127.0.0.1", r)));
const base = `http://127.0.0.1:${server.address().port}`;
console.log("serving at", base);
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
async function freshContext(width) {
const ctx = await browser.newContext({
viewport: { width, height: width === 1440 ? 900 : width === 1366 ? 768 : 720 },
timezoneId: "Asia/Shanghai",
deviceScaleFactor: 1,
});
await ctx.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-reduce-motion", "1");
window.__sse = [];
window.EventSource = class {
constructor() {
window.__sse.push(this);
setTimeout(() => this.onopen?.(), 0);
}
addEventListener(t, f) {
(this.handlers ??= {})[t] = f;
}
close() {}
};
});
await ctx.route("**/*", async (route) => {
const u = new URL(route.request().url());
if (["data:", "blob:", "file:"].includes(u.protocol)) return route.continue();
if (u.origin !== base) return route.abort();
route.continue();
});
return ctx;
}
/** Shared DOM measurement: given a card element handle's root, measure all internals. */
const MEASURE = (cardSel) => {
const card = document.querySelector(cardSel);
if (!card) return null;
const num = (v) => parseFloat(v) || 0;
const r = (el) => {
const b = el.getBoundingClientRect();
return { x: +b.x.toFixed(3), y: +b.y.toFixed(3), w: +b.width.toFixed(3), h: +b.height.toFixed(3) };
};
const st = (el) => {
const s = getComputedStyle(el);
return {
rect: r(el),
background: s.backgroundColor,
backgroundImage: s.backgroundImage,
borderTopWidth: s.borderTopWidth,
borderRightWidth: s.borderRightWidth,
borderBottomWidth: s.borderBottomWidth,
borderLeftWidth: s.borderLeftWidth,
borderTopColor: s.borderTopColor,
borderRightColor: s.borderRightColor,
borderBottomColor: s.borderBottomColor,
borderLeftColor: s.borderLeftColor,
borderTopLeftRadius: s.borderTopLeftRadius,
borderTopRightRadius: s.borderTopRightRadius,
borderBottomLeftRadius: s.borderBottomLeftRadius,
borderBottomRightRadius: s.borderBottomRightRadius,
boxShadow: s.boxShadow,
paddingTop: s.paddingTop,
paddingRight: s.paddingRight,
paddingBottom: s.paddingBottom,
paddingLeft: s.paddingLeft,
opacity: s.opacity,
color: s.color,
overflow: s.overflow,
position: s.position,
};
};
const cardS = st(card);
const tag = card.querySelector(".theme-default, .tag");
const prev = card.querySelector(".preview") || card.querySelector(".tprev");
const rail = prev?.querySelector(".rail") || prev?.querySelector(".r");
const list = prev?.querySelector(".list") || prev?.querySelector(".l");
const items = list ? [...list.querySelectorAll("i")] : [];
const stage = prev?.querySelector(".stage") || prev?.querySelector(".m");
const art = stage?.querySelector("i") || stage?.querySelector(".art");
const dock = stage?.querySelector("b") || stage?.querySelector(".dk");
const badge = dock?.querySelector("em") || dock?.querySelector("span");
const textLine = prev?.querySelector(".preview-text") || prev?.querySelector(".tx");
const strong = card.querySelector(".copy strong") || card.querySelector("b");
const small = card.querySelector(".copy small") || card.querySelector("small");
const dot = rail ? getComputedStyle(rail, "::before") : null;
return {
card: cardS,
defaultTag: tag ? st(tag) : null,
cardClass: card.className,
ariaChecked: card.getAttribute("aria-checked") ?? card.getAttribute("aria-pressed"),
dataTheme: prev?.getAttribute("data-theme"),
preview: prev ? st(prev) : null,
rail: rail ? st(rail) : null,
railDot: dot
? { w: dot.width, h: dot.height, bg: dot.backgroundColor, mt: dot.marginTop, mr: dot.marginRight, ml: dot.marginLeft, mb: dot.marginBottom, radius: dot.borderRadius }
: null,
list: list ? st(list) : null,
listItemCount: items.length,
listItems: items.map((i) => st(i)),
stage: stage ? st(stage) : null,
art: art ? st(art) : null,
dock: dock ? st(dock) : null,
badge: badge ? st(badge) : null,
textLine: textLine ? st(textLine) : null,
copyStrong: strong
? { text: strong.textContent.trim().replace(/\s+/g, " "), fontSize: getComputedStyle(strong).fontSize, lineHeight: getComputedStyle(strong).lineHeight, fontWeight: getComputedStyle(strong).fontWeight }
: null,
copySmall: small
? { text: small.textContent.trim(), fontSize: getComputedStyle(small).fontSize, lineHeight: getComputedStyle(small).lineHeight, color: getComputedStyle(small).color, fontFamily: getComputedStyle(small).fontFamily.slice(0, 40) }
: null,
};
};
const results = { groups: [], errors: [], apiCalls: [] };
try {
for (const width of VIEWPORTS) {
const ctx = await freshContext(width);
const app = await ctx.newPage(), ref = await ctx.newPage();
for (const p of [app, ref]) p.on("pageerror", e => results.errors.push(e.message));
await app.goto(`${base}/settings`);
await app.locator(".choices.tiles").waitFor();
await ref.goto(`${base}/sample`);
await ref.evaluate(() => { go("settings"); document.documentElement.classList.add("capture", "reduce"); });
for (const theme of THEMES) {
await app.evaluate(t => { localStorage.setItem("aigc-theme", t); window.dispatchEvent(new StorageEvent("storage", {key:"aigc-theme", newValue:t})); }, theme);
await ref.evaluate(t => setTheme(t), theme);
await app.mouse.move(1200, 600); await ref.mouse.move(1200, 600);
await app.waitForTimeout(350);
const key = `${theme}-${width}`;
await app.screenshot({path:path.join(OUT, `${key}-full-app.png`)});
await ref.screenshot({path:path.join(OUT, `${key}-full-sample.png`)});
for (let i=0;i<4;i++) {
const a = `.choices.tiles .choice:nth-child(${i+1})`, b = `#themeTiles .ttile:nth-child(${i+1})`;
for (const state of ["normal", "selected", "hover"]) {
// Hold page theme fixed to test every card/state. Class changes are fixture-only;
// natural click/selection/persistence are exercised by verify-hel769.py separately.
await app.locator(a).evaluate((e,on)=>e.classList.toggle("selected",on), state === "selected");
await ref.locator(b).evaluate((e,on)=>e.classList.toggle("on",on), state === "selected");
await app.mouse.move(1200,600); await ref.mouse.move(1200,600);
if (state === "hover") { await app.locator(a).hover(); await ref.locator(b).hover(); }
await app.waitForTimeout(300);
const name = `${key}-card${i}-${state}`;
results.groups.push({name, theme, width, card:i, state, app:await app.evaluate(MEASURE,a), sample:await ref.evaluate(MEASURE,b)});
await app.locator(a).screenshot({path:path.join(OUT,`${name}-app.png`)});
await ref.locator(b).screenshot({path:path.join(OUT,`${name}-sample.png`)});
}
await app.locator(a).evaluate((e,on)=>e.classList.toggle("selected",on), THEMES[i]===theme);
await ref.locator(b).evaluate((e,on)=>e.classList.toggle("on",on), THEMES[i]===theme);
}
}
await ctx.close();
}
} finally {
results.apiCalls = [...new Set(apiLog)];
await fs.writeFile(path.join(OUT,"measurements.json"), JSON.stringify(results,null,2));
await browser.close();
await new Promise(r=>server.close(r));
}
if(results.errors.length || results.groups.length !== 144) throw Error("Incomplete matrix or page errors");
console.log(`Collected ${results.groups.length} card/state pairs; zero page errors; browser and server closed.`);