HEL-771: 修齐主题卡悬停底色、副标题和默认徽标间距 #9
@@ -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));
|
||||
|
||||
@@ -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。证据范围仅本卡三项返工与既有定向守护,未宣称整套样本完成。
|
||||
@@ -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))
|
||||
@@ -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.`);
|
||||
Reference in New Issue
Block a user