feat(theme): 全局装饰遮罩 + 尺寸统一;全局审阅修复对比度/暗色/平板布局

用户反馈的两点:
1. 遮罩看不出效果 → 人物与单车统一全局半透明 settings.decoOpacity(默认 .78)
   (此前 0.92/0.80 不一致且几乎无感),各自保留边缘渐隐
2. 切视图时人物比例变化 → 删掉卡片视图专用的 300px 高度,两种视图尺寸一致
3. 保留原始素材 → 原图归档 assets-src/,处理流程固化为 scripts/prepare-artwork.py

全局审阅发现并修复:
- 无障碍对比度:两套主题的状态色当文字用时远低于 AA(最低 2.56:1)→ 双色体系
  (填充色 ≥3:1 保持鲜亮 / 文字色 ≥4.5:1 同色系加深),暗色模式同步
- 暗色模式主题失效:顶栏/卡片/表头/分组行是硬编码白色 → 改为随模式切换的变量
- 平板竖屏 768px 表格需内部横滚 242px → 自动切卡片阈值 700 → 1024
- Cloudflare Worker 快照补齐 dataUpdatedAt/updateIntervalSec/dataFilter
- 清理失效 CSS(.logo .halo)

测试脚本修掉两个自身 bug(均为误报):color(srgb/…) 与半透明底的解析、
渐变背景被误判为透明。verify-views 新增对比度断言 8 条,四套合计 89 项全通过。
This commit is contained in:
tamakyi
2026-10-13 21:55:44 +08:00
parent 6f9a2d33ce
commit a7408e2bb5
26 changed files with 581 additions and 324 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""从 assets-src/ 的原始插画生成 public/assets/ 下的透明素材。
为什么要这么处理:
图像生成返回的是**白底**图,需要抠成透明。抠白底分两步,缺一不可——
1) 边界洪泛:把与图片边界连通的白色变透明(背景主体)
2) 封闭区域补抠:被图形围起来的白色区域与边界不连通,第 1 步碰不到它们。
光环环内、单车车架三角、轮圈内扇形都会因此留成白块(v0.8.3 之前就踩过)
注意:不能简单「全局抠白」,那会把角色的脸/袜子/衣服白一起抠掉。
本脚本按连通域面积处理,并跳过角色躯干区域,保留服装白。
用法(PyPI 的 Pillow):
python scripts/prepare-artwork.py
"""
from collections import deque
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "assets-src"
DST = ROOT / "public" / "assets"
SENTINEL = (255, 0, 255)
WHITE_TOLERANCE = 18 # 与纯白的最大通道差
def is_white(px):
return px[3] > 128 and px[0] > 255 - WHITE_TOLERANCE and px[1] > 255 - WHITE_TOLERANCE and px[2] > 255 - WHITE_TOLERANCE
def connected_regions(im, w, h):
"""列出所有「近白且连通」的区域:[(面积, [(x, y), ...]), ...]"""
px = im.load()
seen = [[False] * w for _ in range(h)]
regions = []
for y in range(h):
for x in range(w):
if seen[y][x] or not is_white(px[x, y]):
continue
queue = deque([(x, y)])
seen[y][x] = True
cells = []
while queue:
cx, cy = queue.popleft()
cells.append((cx, cy))
for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and is_white(px[nx, ny]):
seen[ny][nx] = True
queue.append((nx, ny))
regions.append((len(cells), cells))
return regions
def cut_out(src: Path, dst: Path, target_height: int, *, min_area: int, square: bool = False,
skip_below: float | None = None, quality: int = 92):
"""抠白底 -> 裁边 -> 按高度缩放 -> 存 webp。
square: 裁边后补成正方形(头像/图标用,避免圆形裁切与 favicon 变形)
skip_below: 只清理位于该纵向比例(0~1)以上的封闭区域;None 表示全部清理
"""
im = Image.open(src).convert("RGBA")
width, height = im.size
# 第 1 步:边界洪泛(连通到边界的背景)
rgb = im.convert("RGB")
for x, y in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1),
(width // 2, 0), (0, height // 2), (width - 1, height // 2), (width // 2, height - 1)):
if rgb.getpixel((x, y)) != SENTINEL:
ImageDraw.floodfill(rgb, (x, y), SENTINEL, thresh=WHITE_TOLERANCE + 24)
sentinel_px = rgb.load()
px = im.load()
for y in range(height):
for x in range(width):
if sentinel_px[x, y] == SENTINEL:
px[x, y] = (0, 0, 0, 0)
# 第 2 步:封闭区域补抠(按面积阈值 + 可选纵向范围)
removed = 0
for area, cells in connected_regions(im, width, height):
if area < min_area:
continue
if skip_below is not None and min(cy for _, cy in cells) > height * skip_below:
continue # 疑似服装白,保留
for x, y in cells:
px[x, y] = (0, 0, 0, 0)
removed += area
box = im.getbbox()
if box:
im = im.crop(box)
if square:
side = max(im.size)
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(im, ((side - im.width) // 2, (side - im.height) // 2))
im = canvas
scale = target_height / im.height
im = im.resize((round(im.width * scale), target_height), Image.LANCZOS)
im.save(dst, "WEBP", quality=quality, method=6)
print(f"{dst.name}: {im.size} 补抠封闭白区 {removed}px,体积 {dst.stat().st_size / 1024:.1f}KB")
return im
def main():
# 立绘:560 高(与 CSS 里 min(40vh, 340px) 的显示尺寸对应,留 1.6 倍余量给高 DPI)
figure = cut_out(SRC / "figure-original.png", DST / "shiroko-figure.webp", 560, min_area=150)
# 图标:补成正方形再缩到 180,供圆形头像与 favicon 使用
icon = cut_out(SRC / "icon-original.png", DST / "shiroko-icon.webp", 180, min_area=15, square=True)
icon.resize((64, 64), Image.LANCZOS).save(DST / "favicon-shiroko.png", "PNG", optimize=True)
print(f"favicon-shiroko.png: 64x64,体积 {(DST / 'favicon-shiroko.png').stat().st_size / 1024:.1f}KB")
# 单车:200 高,通体无服装白,全部清理
cut_out(SRC / "bike-original.png", DST / "shiroko-bike.webp", 200, min_area=20)
print("完成。视觉上请确认:光环环内、车架三角、轮圈内扇形均为透明,且角色的脸/袜子/衣服完好。")
if __name__ == "__main__":
main()
+70 -1
View File
@@ -71,7 +71,13 @@ async function main() {
const hits = [...document.querySelectorAll('table.monitor-table tbody tr, .card')]
.map((el) => ({ r: el.getBoundingClientRect(), el }))
.filter(({ r }) => r.right > a.left + 8 && r.bottom > a.top && r.top < a.bottom)
.map(({ el }) => opaque(getComputedStyle(el).backgroundColor) || opaque(getComputedStyle(el.closest('.table-wrap, .grid') ?? el).backgroundColor));
.map(({ el }) => {
// 不透明判定要同时看背景色与背景图(卡片用的是渐变背景,backgroundColor 仍是透明)
const cs = getComputedStyle(el);
if (cs.backgroundImage !== 'none') return true;
const chain = [cs, getComputedStyle(el.closest('.table-wrap, .grid') ?? el)];
return chain.some((s) => s.backgroundImage !== 'none' || opaque(s.backgroundColor));
});
return { count: hits.length, allOpaque: hits.every(Boolean) };
});
if (artOverlap) check(`${view} 视图立绘不影响阅读(不重叠或位于面板之下)`, artOverlap.count === 0 || artOverlap.allOpaque, `重叠 ${artOverlap.count} 个,均在面板之下=${artOverlap.allOpaque}`);
@@ -175,6 +181,69 @@ async function main() {
await mp.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-mobile.png'), fullPage: true });
await mCtx.close();
// ---------- I. 无障碍:文字与图形对比度(WCAG AA) ----------
console.log('\n[I] 对比度(WCAG AA:正文 ≥4.5,图形 ≥3.0)');
// 对比度需在表格视图下采样(在线/离线状态文字在表格行里)
await page.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await page.waitForTimeout(1000);
const contrast = await page.evaluate(() => {
// 颜色可能是 rgb()/rgba() 或 color(srgb r g b / a)(color-mix 的结果),统一解析后逐层合成,
// 否则半透明底色会被当成不透明、0~1 分量会被当成 0~255,算出的比值是错的
const parseColor = (c) => {
const nums = (c.match(/[\d.]+/g) ?? []).map(Number);
const srgb = c.startsWith('color(srgb');
const [r, g, b] = srgb ? nums.slice(0, 3).map((v) => v * 255) : nums.slice(0, 3);
const a = srgb ? (nums.length > 3 ? nums[3] : 1) : (nums.length > 3 ? nums[3] : 1);
return { r, g, b, a };
};
const effectiveBg = (el) => {
const chain = [];
for (let n = el; n; n = n.parentElement) chain.push(n);
let bg = { r: 255, g: 255, b: 255 };
for (const n of chain.reverse()) {
const c = parseColor(getComputedStyle(n).backgroundColor);
if (!c.a) continue;
bg = { r: bg.r * (1 - c.a) + c.r * c.a, g: bg.g * (1 - c.a) + c.g * c.a, b: bg.b * (1 - c.a) + c.b * c.a };
}
return `rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)})`;
};
const luminance = ([r, g, b]) => {
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (fg, bg) => {
const a = luminance([parseColor(fg).r, parseColor(fg).g, parseColor(fg).b]);
const b = luminance([parseColor(bg).r, parseColor(bg).g, parseColor(bg).b]);
const [hi, lo] = a > b ? [a, b] : [b, a];
return (hi + 0.05) / (lo + 0.05);
};
const sample = (sel, label) => {
const el = document.querySelector(sel);
if (!el) return null;
return { label, text: el.textContent.trim().slice(0, 8), r: +ratio(getComputedStyle(el).color, effectiveBg(el)).toFixed(2) };
};
const out = [
sample('tr[data-status="up"] .t-state', '在线状态文字'),
sample('tr[data-status="down"] .t-state', '离线状态文字'),
sample('tr[data-status="pending"] .t-state', '等待状态文字'),
sample('#stat-up', '在线统计数字'),
sample('#stat-down', '离线统计数字'),
sample('#overall-pill', '顶部状态胶囊'),
sample('.t-target', '目标地址'),
sample('.footer-item', '页脚文字'),
].filter(Boolean);
// 图形:状态点边框色 vs 其所在卡片底色(图形标准 3:1)
const dot = document.querySelector('.dot');
if (dot) out.push({ label: '状态点图形', r: +ratio(getComputedStyle(dot).borderTopColor, effectiveBg(dot)).toFixed(2), graphic: true });
return out;
});
for (const c of contrast) {
if (c.r == null) continue;
const need = c.graphic ? 3 : 4.5;
check(`${c.label}对比度 ≥${need}`, c.r >= need, `${c.r}:1`);
}
// 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据;
// 这里只把 429 以外的错误视为失败
const hardErrors = errors.filter((e) => !/429/.test(e));