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:
@@ -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()
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user