yggdrasil/scripts/vt-theme-sampler.mjs
xfy bbb77954e9 fix(theme): sync CodeMirror/xterm theme in VT callback for circular-expand animation
主题切换的圆形展开 VT 动画对可运行代码块不生效:圆形扫过 CodeMirror/xterm
区域时看不到颜色变化,动画结束后才瞬切。

根因(逐帧像素验证确认):VT 回调只 toggle .dark class,但 CodeMirror 背景
由 catppuccin Extension 注入(.cm-editor 的 EditorView.theme 规则),xterm
背景由 .xterm-scrollable-element 的 inline background-color 注入——两者都
不随 .dark 翻转。set_theme 由 Dioxus use_effect 在 theme.set() 之后异步触发,
晚于 NEW 快照捕获,导致 OLD/NEW 快照在编辑器区域同色,圆形展开无可揭示的变化。

修复:在 VT 回调内(NEW 快照捕获前)同步 dispatch 'yggdrasil:theme-change'
CustomEvent,CodeMirror/xterm 各自的 index.ts 在模块加载时订阅并遍历
_instances 同步调 setTheme。事件先于 applyDarkClass dispatch,确保编辑器换肤
+ class 翻转被同一个 getComputedStyle reflow 捕获进 NEW 快照。

与现有 Dioxus use_effect 幂等共存:setTheme 对相同主题是 no-op(CodeMirror
Compartment.reconfigure + xterm options.theme = 均幂等),use_effect 作兜底
覆盖初始挂载 / 非 VT 场景。

验证:
- yggdrasil-core 测试扩展:断言 VT 回调 / 降级路径 / applyResolvedTheme 都
  dispatch 事件,且事件先于 dark class 翻转
- scripts/vt-theme-sampler.mjs(Playwright 逐帧像素采样):修复前 xterm 比
  等距的 cssvar 探针晚 100ms 变色(bug);修复后同帧变色(lag=0ms)

改动文件:
- libs/yggdrasil-core/src/theme-transition.ts: THEME_CHANGE_EVENT 常量 +
  notifyThemeChange 辅助函数,VT 回调 / 降级路径 / applyResolvedTheme 三处 dispatch
- libs/codemirror-editor/src/index.ts: 订阅事件,forEach _instances.setTheme
- libs/xterm-terminal/src/index.ts: 同样模式
- libs/yggdrasil-core/src/theme-transition.test.ts: 3 个新用例
- public/xterm/terminal.js: 重建产物(含事件订阅)
- scripts/vt-theme-{harness,sampler}: 逐帧像素验证工具(throwaway,可作回归)
2026-07-13 11:20:08 +08:00

413 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// VT 主题切换动画逐帧像素采样器。
//
// 验证目标:暗/亮切换的圆形展开 VT 动画对 xterm 终端区域是否生效。
// 预期 bug:xterm canvas 背景在 NEW 快照里仍是旧色,圆形扫过时终端区域
// 「不动」,动画结束后才瞬切;而 CSS 变量驱动的 body-probe / cssvar-probe
// 应随圆形同步变色。
//
// 做法:
// 1. 起一个静态文件服务,serve 项目 public/ + harness.html(用真实的
// yggdrasil-core.js + terminal.js 构建产物)。
// 2. Playwright 打开 harness,挂载真实 xterm。
// 3. 在点击 toggle 按钮的瞬间启动逐帧采样循环:每帧(rAF 节拍)对三个
// 探针的中心点做 page.screenshot({clip: 1×1}),PNG 解码读像素。
// 4. 采样持续 ~1s(覆盖 0.4s 动画 + 余量),打印每帧三点的 RGB 时间线。
// 5. 判定:比较「动画进行中(mid 帧)」与「动画前(pre)」「动画后(post)」
// 各探针的颜色变化轨迹。
//
// PNG 解码:对 1×1 截图自实现一个最小解码器(过滤 IEND 之前的 IDAT,
// 解 zlib,做 PNG 过滤逆运算)。1×1 的 IDAT 极小,这套解码器足够。
// 不引第三方依赖(避免给项目加 devDep)。
//
// 运行:node scripts/vt-theme-sampler.mjs
// (依赖 npx playwright,chromium 已装在 ~/Library/Caches/ms-playwright)
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { existsSync, readdirSync } from "node:fs";
import { extname, join, normalize, resolve as pathResolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { inflateSync } from "node:zlib";
import { execFileSync } from "node:child_process";
const __require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const REPO_ROOT = pathResolve(__dirname, "..");
const PUBLIC_DIR = join(REPO_ROOT, "public");
const HARNESS = join(__dirname, "vt-theme-harness.html");
// ---------- 静态文件服务 ----------
const MIME = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".map": "application/json",
".webp": "image/webp",
".svg": "image/svg+xml",
};
function startStaticServer() {
return new Promise((startResolve) => {
const server = createServer(async (req, res) => {
try {
// 把 URL 映射到 public/ 下;harness.html 单独映射到根。
let urlPath = decodeURIComponent(req.url.split("?")[0]);
let filePath;
if (urlPath === "/" || urlPath === "/index.html") {
filePath = HARNESS;
} else {
// 防路径穿越
const safe = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
filePath = join(PUBLIC_DIR, safe);
}
const data = await readFile(filePath);
res.writeHead(200, {
"Content-Type": MIME[extname(filePath)] ?? "application/octet-stream",
});
res.end(data);
} catch (e) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("404: " + req.url + " (" + (e.code || e.message) + ")");
}
});
server.listen(0, "127.0.0.1", () => startResolve(server));
});
}
// ---------- 最小 PNG 解码器(仅支持 8-bit RGBA/RGB,单或多个 IDAT) ----------
// 对 1×1 截图足够;不做完整 PNG 规范,只覆盖 Playwright 输出格式。
function decodePng(buf) {
// 验证签名
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (buf.subarray(0, 8).toString("hex") !== SIG.toString("hex")) {
throw new Error("not a PNG");
}
let off = 8;
let width = 0, height = 0, bitDepth = 0, colorType = 0;
const idatChunks = [];
while (off < buf.length) {
const len = buf.readUInt32BE(off); off += 4;
const type = buf.toString("ascii", off, off + 4); off += 4;
const data = buf.subarray(off, off + len); off += len;
off += 4; // CRC
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
} else if (type === "IDAT") {
idatChunks.push(data);
} else if (type === "IEND") {
break;
}
}
const inflated = inflateSync(Buffer.concat(idatChunks));
// 计算每像素字节数
const channels =
colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 4;
const bpp = channels; // 8-bit only
const stride = width * bpp;
const raw = Buffer.alloc(height * stride);
let prevLine = Buffer.alloc(stride); // 上一行(初始全 0)
let inOff = 0;
for (let y = 0; y < height; y++) {
const filter = inflated[inOff++];
const line = inflated.subarray(inOff, inOff + stride);
inOff += stride;
const out = Buffer.alloc(stride);
for (let x = 0; x < stride; x++) {
const cur = line[x];
const left = x >= bpp ? out[x - bpp] : 0;
const up = prevLine[x];
const upLeft = x >= bpp ? prevLine[x - bpp] : 0;
let v;
switch (filter) {
case 0: v = cur; break; // None
case 1: v = (cur + left) & 0xff; break; // Sub
case 2: v = (cur + up) & 0xff; break; // Up
case 3: v = (cur + ((left + up) >> 1)) & 0xff; break; // Average
case 4: v = (cur + paeth(left, up, upLeft)) & 0xff; break; // Paeth
default: throw new Error("unknown filter " + filter);
}
out[x] = v;
}
out.copy(raw, y * stride);
prevLine = out;
}
// 取 (0,0) 像素(我们的 clip 是 1×1,所以 width=height=1)
return { r: raw[0], g: raw[1], b: raw[2], a: channels === 4 ? raw[3] : 255, width, height };
}
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
// inflateSync 直接用 node:zlib 的同步解压(1×1 PNG 的 IDAT 极小,同步足够)
// (留此注释说明:曾误用异步 inflate,已改回 inflateSync)
// ---------- 颜色工具 ----------
function rgbHex({ r, g, b }) {
return (
"#" +
[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")
);
}
// 期望色(取自 input.css,与 harness 内联变量一致)
const EXPECT = {
light: { theme: "#eff1f5", codeblock: "#dce0e8" },
dark: { theme: "#1e1e2e", codeblock: "#313244" },
};
// ---------- 定位 playwright ----------
// playwright 不在本项目 deps 里(避免给项目加 devDep)。从 npx 缓存里找。
// 用 createRequire 加载 CJS 版(playwright-core 的 chromium 是延迟赋值,
// ESM 动态 import 的命名导出快照拿不到它,必须走 require)。
function loadPlaywrightChromium() {
const candidates = [];
// 1. npx 缓存(每个 hash 一个隔离 node_modules)
const npxCache = join(homedir(), ".npm", "_npx");
if (existsSync(npxCache)) {
for (const hash of readdirSync(npxCache)) {
candidates.push(join(npxCache, hash, "node_modules", "playwright-core"));
candidates.push(join(npxCache, hash, "node_modules", "playwright"));
}
}
// 2. 全局 node_modules
let globalRoot = "";
try {
globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim();
} catch {
// npm 不在 PATH,跳过
}
if (globalRoot) {
candidates.push(join(globalRoot, "playwright-core"));
candidates.push(join(globalRoot, "playwright"));
}
// 3. 项目内(以防将来加了 dep)
candidates.push(join(REPO_ROOT, "node_modules", "playwright-core"));
candidates.push(join(REPO_ROOT, "node_modules", "playwright"));
candidates.push(join(REPO_ROOT, "libs", "node_modules", "playwright-core"));
candidates.push(join(REPO_ROOT, "libs", "node_modules", "playwright"));
for (const dir of candidates) {
if (!existsSync(dir)) continue;
try {
const require = __require;
const pw = require(dir);
if (pw?.chromium?.launch) {
return pw.chromium;
}
// playwright 包(非 core):chromium 在 default 里
if (pw?.default?.chromium?.launch) {
return pw.default.chromium;
}
} catch {
// 试下一个
}
}
throw new Error(
"找不到 playwright。请先运行: npx playwright@latest install chromium"
);
}
// ---------- 主流程 ----------
async function main() {
const chromium = loadPlaywrightChromium();
console.log("[info] playwright chromium loaded");
const server = await startStaticServer();
const port = server.address().port;
const url = `http://127.0.0.1:${port}/`;
console.log(`[info] serving on ${url}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 600, height: 400 },
deviceScaleFactor: 1, // 1:1 像素,避免 retina 缩放干扰
});
const page = await context.newPage();
// VT 动画需要 reduced-motion 关闭。显式设为 no-preference,
// 避免被系统偏好带偏(系统若开了 reduce 则 __startThemeTransition 会走无动画分支)。
await page.emulateMedia({ reducedMotion: "no-preference" });
page.on("console", (m) => {
if (m.type() === "error") console.log("[page error]", m.text());
});
await page.goto(url, { waitUntil: "networkidle" });
// 等 xterm 挂载
await page.waitForFunction(() => window.__harnessReady === true, { timeout: 5000 });
console.log("[info] xterm mounted, ready");
// 探针中心坐标(与 harness .probe 的 top/left + 30 居中一致;probe 60×60)。
// xterm 采样点取右下角(left+50, top+50)避开文字(DOM renderer 文字从左上排开)。
const probes = {
body: { x: 200 + 30, y: 120 + 30 },
cssvar: { x: 270 + 30, y: 120 + 30 },
xterm: { x: 340 + 50, y: 120 + 50 }, // 右下角,避开文字
};
async function samplePoint(name, x, y) {
const png = await page.screenshot({
clip: { x, y, width: 1, height: 1 },
omitBackground: false,
});
const px = decodePng(png);
return { name, ...px, hex: rgbHex(px) };
}
async function sampleAll(label) {
const out = {};
for (const [k, p] of Object.entries(probes)) {
out[k] = await samplePoint(k, p.x, p.y);
}
return { label, t: Date.now(), ...out };
}
// ---- baseline:light 态 ----
const pre = await sampleAll("pre(light)");
// ---- 触发 VT 动画并逐帧采样 ----
// 点击 toggle 按钮(按钮在左上,圆形从按钮中心向右展开扫过探针)。
// harness 的 click handler 会:① 调 __startThemeTransition(同步 VT)
// ② setTimeout(0) 调 set_theme(模拟 Dioxus use_effect 延迟)。
// 采样在点击后立即开始,紧密循环 ~1.2s 覆盖 0.4s 动画 + set_theme 后续。
const frames = [];
// 记录点击时刻(用 performance.now 在 page 内打点,避免 round-trip 偏差)
await page.evaluate(() => { window.__clickAt = performance.now(); });
const t0Real = Date.now();
await page.click("#toggle-btn");
// 紧密采样 ~1.2s
const SAMPLE_MS = 1200;
while (Date.now() - t0Real < SAMPLE_MS) {
const t = Date.now() - t0Real;
const f = await sampleAll(String(t));
f.t = t;
frames.push(f);
}
// ---- 终态 ----
await page.waitForTimeout(300); // 确保动画完全结束 + vt.finished 清理
const post = await sampleAll("post(dark)");
// 调试:确认 .dark class 与 xterm inline bg 的最终状态
const dbgFinal = await page.evaluate(() => {
const html = document.documentElement;
const el = document.querySelector("#xterm-mount .xterm-scrollable-element");
return {
htmlHasDark: html.classList.contains("dark"),
xtermInlineBg: el ? el.style.backgroundColor : "(no element)",
setThemeCalled: !!window.__setThemeCalledAt,
};
});
console.log("[debug] final:", JSON.stringify(dbgFinal));
await browser.close();
server.close();
// ---------- 分析 ----------
console.log("\n========== 基线(light) ==========");
for (const k of ["body", "cssvar", "xterm"]) {
const v = pre[k];
console.log(` ${k.padEnd(7)} ${v.hex} (r=${v.r} g=${v.g} b=${v.b})`);
}
console.log("\n========== 终态(dark) ==========");
for (const k of ["body", "cssvar", "xterm"]) {
const v = post[k];
console.log(` ${k.padEnd(7)} ${v.hex} (r=${v.r} g=${v.g} b=${v.b})`);
}
// 抽帧打印时间线(每 ~50ms 取一帧,避免刷屏)
console.log("\n========== 逐帧时间线 ==========");
console.log(
"t(ms)".padEnd(8) +
"body".padEnd(10) +
"cssvar".padEnd(10) +
"xterm".padEnd(10)
);
let lastT = -100;
for (const f of frames) {
if (f.t - lastT < 45) continue; // ~45ms 一帧
lastT = f.t;
console.log(
String(f.t).padEnd(8) +
f.body.hex.padEnd(10) +
f.cssvar.hex.padEnd(10) +
f.xterm.hex.padEnd(10)
);
}
// ---------- 判定 ----------
// 三个探针的「首次跳变时刻」(相对 t0Real,即点击后多久颜色变了):
// - body / cssvar:CSS 变量驱动,VT 的 NEW 快照里已是 dark,圆形扫过采样点时
// 从 light 瞬跳 dark。应在动画窗口(~400ms)内发生(取决于圆形半径何时覆盖探针)。
// - xterm:修复前,背景 inline style 不随 .dark 翻转,set_theme 在 VT 回调后
// 异步跑 → NEW 快照里 xterm 仍是 light,圆形扫过看不到变化,动画后才瞬切。
// 修复后,VT 回调内 dispatch 事件 → xterm 同步 setTheme → NEW 快照里已是 dark,
// 与 body 同帧跳变。
// 判据:xterm 跳变时刻 - body 跳变时刻 的差值(lag)。
// - lag < 15ms(同帧):动画对 xterm 生效,修复成功。
// - lag > 15ms:xterm 在 VT 动画期间保持旧色,修复未生效。
function transitionFrame(key) {
const startHex = pre[key].hex;
const endHex = post[key].hex;
if (startHex === endHex) return -1; // pre==post,全程无变化
for (let i = 0; i < frames.length; i++) {
if (frames[i][key].hex !== startHex) return frames[i].t;
}
return -2; // 帧序列里没观察到跳变(但 pre≠post,说明跳变在采样窗口外)
}
const tBody = transitionFrame("body");
const tCss = transitionFrame("cssvar");
const tXterm = transitionFrame("xterm");
console.log("\n========== 判定 ==========");
console.log(`body 首次跳变: t=${tBody}ms (pre=${pre.body.hex} → post=${post.body.hex})`);
console.log(`cssvar 首次跳变: t=${tCss}ms (pre=${pre.cssvar.hex} → post=${post.cssvar.hex})`);
console.log(`xterm 首次跳变: t=${tXterm}ms (pre=${pre.xterm.hex} → post=${post.xterm.hex})`);
// 判据:比较 xterm 与 cssvar 的跳变时刻(两者几何距离相近,圆形同时覆盖)。
// - 若同帧(lag < 15ms):xterm 与 CSS 变量驱动的 cssvar 同步变色 → 修复成功,
// xterm 的 inline bg 已进入 NEW 快照,圆形展开对终端区域生效。
// - 若 xterm 明显晚于 cssvar:xterm 在 NEW 快照里仍是旧色 → 修复未生效。
// (不与 body 比:body 离 toggle 原点更近,圆形更早覆盖,几何延迟会污染判据。)
let verdict, detail;
if (tXterm === -1) {
verdict = "?";
detail = `xterm 全程未变色(pre=${pre.xterm.hex}==post=${post.xterm.hex}),set_theme 未生效或采样点未命中纯背景区。`;
} else if (tCss < 0) {
verdict = "?";
detail = "cssvar 未观察到跳变,无法建立对照基准。";
} else {
const lag = tXterm - tCss;
if (Math.abs(lag) > 15) {
verdict = "✗";
detail = `BUG 仍存在:xterm 比 cssvar ${lag > 0 ? "晚" : "早"} ${Math.abs(lag)}ms 变色。`;
detail += `cssvar 在 t=${tCss}ms 变色,xterm 在 t=${tXterm}ms——两者几何等距却不同步,`;
detail += `说明 xterm 的 inline bg 未进入 NEW 快照。`;
} else {
verdict = "✓";
detail = `修复成功:xterm 与 cssvar 同帧变色(lag=${lag}ms,均 t=${tCss}ms)。`;
detail += `VT 回调内的事件让 xterm 在 NEW 快照前同步 setTheme,`;
detail += `圆形展开对终端区域生效(cssvar 是 CSS 变量驱动的等距对照点)。`;
}
}
console.log("\n---------- 结论 ----------");
console.log(verdict + " " + detail);
}
main().catch((e) => {
console.error("[fatal]", e);
process.exit(1);
});