fix(theme): skip editor set_theme during VT animation to prevent direct jump
主题切换时,可运行代码块(CodeMirror + xterm)整体瞬切,不受 VT 圆形展开动画 控制——圆形还没展开到代码块,代码块就整体变色了。 根因(真实页面逐帧像素采样 + 事件追踪确认): ThemeToggle::onclick 先调 __startThemeTransition(同步截 OLD 快照,VT 回调异步), 再调 theme.set(next)。theme.set 触发 Dioxus use_effect → set_theme,这个调用 在 OLD 快照截取后、VT 回调执行前的时间窗内,直接改了实时 DOM 的编辑器背景。 VT 动画播的是伪元素快照(::view-transition-old/new),但实时 DOM 的改动会穿透 伪元素,表现为代码块在圆形展开到达前就整体瞬切。 上一个提交(theme-change 事件)已在 VT 回调内同步 setTheme(进入 NEW 快照), 但 use_effect 的冗余 set_theme 仍在动画期间改实时 DOM,抵消了快照内的正确状态。 修复:CodeMirror + xterm 的 use_effect 在 is-theme-transitioning 期间跳过 set_theme 调用。VT 事件已在快照前同步换肤;动画结束后 is-theme-transitioning 被移除,use_effect 会因 resolved 信号变化重跑,做幂等兜底同步(覆盖非 VT 场景: 系统偏好变化、初始挂载)。 验证(scripts/vt-editor-sweep.mjs,真实 Dioxus 点击路径): - 修复前:编辑器整体瞬切(t=111ms 全部变 dark,无渐变) - 修复后:编辑器从右向左被圆形逐步揭示(t=228ms 右侧先 dark,t=313ms 左侧 dark, 中间点显示圆形边缘抗锯齿色),与页面其他部分同步
This commit is contained in:
parent
f984c0c3e7
commit
093940a0d7
@ -1,322 +0,0 @@
|
|||||||
// 真实页面 VT 主题动画采样器 —— 针对 dx serve 运行中的真实 app。
|
|
||||||
//
|
|
||||||
// 目的:在真实 Dioxus 环境里验证「可运行代码块颜色是否随 VT 圆形展开动画变色」,
|
|
||||||
// 还是像用户报告的那样「直接瞬切,不受 VT 动画控制」。
|
|
||||||
//
|
|
||||||
// 做法:
|
|
||||||
// 1. 打开 http://localhost:8080/(首页有 runnable 代码块)。
|
|
||||||
// 2. 找到 CodeMirror 编辑器容器(.code-runner-editor)和主题切换按钮(.theme-toggle)。
|
|
||||||
// 3. 记录编辑器中心点 + 一个普通页面元素(div.bg-paper 之类)的中心点作为对照。
|
|
||||||
// 4. 点击主题按钮,启动逐帧采样循环(~1.2s),每帧对两个点截图读像素。
|
|
||||||
// 5. 打印时间线 + 判定:编辑器是否与对照点同帧变色(修复成功),还是滞后/瞬切(bug)。
|
|
||||||
//
|
|
||||||
// 运行:node scripts/vt-real-page-sampler.mjs
|
|
||||||
// 前提:make dev 已启动,首页有 runnable 代码块。
|
|
||||||
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { inflateSync } from "node:zlib";
|
|
||||||
|
|
||||||
const __require = createRequire(import.meta.url);
|
|
||||||
const REPO_ROOT = __require("node:path").resolve(
|
|
||||||
__require("node:url").fileURLToPath(new URL(".", import.meta.url)),
|
|
||||||
"..",
|
|
||||||
);
|
|
||||||
|
|
||||||
function loadPlaywrightChromium() {
|
|
||||||
const candidates = [];
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidates.push(join(REPO_ROOT, "node_modules", "playwright-core"));
|
|
||||||
candidates.push(join(REPO_ROOT, "node_modules", "playwright"));
|
|
||||||
for (const dir of candidates) {
|
|
||||||
if (!existsSync(dir)) continue;
|
|
||||||
try {
|
|
||||||
const pw = __require(dir);
|
|
||||||
if (pw?.chromium?.launch) return pw.chromium;
|
|
||||||
if (pw?.default?.chromium?.launch) return pw.default.chromium;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
throw new Error("找不到 playwright。运行: npx playwright@latest install chromium");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- PNG 解码(1×1) ----------
|
|
||||||
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 PNG");
|
|
||||||
let off = 8;
|
|
||||||
let w = 0, h = 0, ct = 0;
|
|
||||||
const idat = [];
|
|
||||||
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 + 4;
|
|
||||||
if (type === "IHDR") { w = data.readUInt32BE(0); h = data.readUInt32BE(4); ct = data[9]; }
|
|
||||||
else if (type === "IDAT") idat.push(data);
|
|
||||||
else if (type === "IEND") break;
|
|
||||||
}
|
|
||||||
const inf = inflateSync(Buffer.concat(idat));
|
|
||||||
const ch = ct === 6 ? 4 : ct === 2 ? 3 : ct === 0 ? 1 : 4;
|
|
||||||
const bpp = ch;
|
|
||||||
const stride = w * bpp;
|
|
||||||
let prev = Buffer.alloc(stride);
|
|
||||||
let io = 0;
|
|
||||||
const raw = Buffer.alloc(h * stride);
|
|
||||||
for (let y = 0; y < h; y++) {
|
|
||||||
const f = inf[io++];
|
|
||||||
const line = inf.subarray(io, io + stride);
|
|
||||||
io += stride;
|
|
||||||
const out = Buffer.alloc(stride);
|
|
||||||
for (let x = 0; x < stride; x++) {
|
|
||||||
const c = line[x];
|
|
||||||
const l = x >= bpp ? out[x - bpp] : 0;
|
|
||||||
const u = prev[x];
|
|
||||||
const ul = x >= bpp ? prev[x - bpp] : 0;
|
|
||||||
let v;
|
|
||||||
switch (f) {
|
|
||||||
case 0: v = c; break;
|
|
||||||
case 1: v = (c + l) & 0xff; break;
|
|
||||||
case 2: v = (c + u) & 0xff; break;
|
|
||||||
case 3: v = (c + ((l + u) >> 1)) & 0xff; break;
|
|
||||||
case 4: {
|
|
||||||
const p = l + u - ul;
|
|
||||||
const pa = Math.abs(p - l), pb = Math.abs(p - u), pc = Math.abs(p - ul);
|
|
||||||
v = (c + (pa <= pb && pa <= pc ? l : pb <= pc ? u : ul)) & 0xff;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: throw new Error("filter " + f);
|
|
||||||
}
|
|
||||||
out[x] = v;
|
|
||||||
}
|
|
||||||
out.copy(raw, y * stride);
|
|
||||||
prev = out;
|
|
||||||
}
|
|
||||||
return { r: raw[0], g: raw[1], b: raw[2] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hex({ r, g, b }) {
|
|
||||||
return "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const chromium = loadPlaywrightChromium();
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: { width: 1280, height: 900 },
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
||||||
|
|
||||||
const errors = [];
|
|
||||||
page.on("pageerror", (e) => errors.push(e.message));
|
|
||||||
page.on("console", (m) => {
|
|
||||||
if (m.type() === "error") errors.push(m.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[info] navigating to http://localhost:8080/post/rust");
|
|
||||||
await page.goto("http://localhost:8080/post/rust", { waitUntil: "networkidle", timeout: 30000 });
|
|
||||||
|
|
||||||
// 等 CodeMirror 编辑器挂载
|
|
||||||
console.log("[info] waiting for CodeMirror editor...");
|
|
||||||
try {
|
|
||||||
await page.waitForSelector(".code-runner-editor .cm-editor", { timeout: 10000 });
|
|
||||||
} catch {
|
|
||||||
console.log("[warn] 没找到 .code-runner-editor .cm-editor,首页可能没有 runnable 代码块");
|
|
||||||
await browser.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 额外等一帧确保 CodeMirror 完全渲染
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// 滚动到第一个编辑器,使其在视口内可见
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const editor = document.querySelector(".code-runner-editor .cm-editor");
|
|
||||||
if (editor) editor.scrollIntoView({ block: "center" });
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// 找到编辑器中心点 + 主题按钮中心点 + 一个普通背景对照点
|
|
||||||
// 采样坐标基于 scrollIntoView 后的 getBoundingClientRect(视口相对)
|
|
||||||
const geom = await page.evaluate(() => {
|
|
||||||
const editor = document.querySelector(".code-runner-editor .cm-editor");
|
|
||||||
const toggle = document.querySelector(".theme-toggle");
|
|
||||||
const er = editor.getBoundingClientRect();
|
|
||||||
const tr = toggle.getBoundingClientRect();
|
|
||||||
// 对照点:编辑器附近的普通页面背景(取编辑器左侧 60px 外,同行高度)
|
|
||||||
const ctrlX = Math.max(20, er.left - 60);
|
|
||||||
const ctrlY = er.top + er.height / 2;
|
|
||||||
return {
|
|
||||||
editor: { x: Math.round(er.left + er.width / 2), y: Math.round(er.top + er.height / 2) },
|
|
||||||
toggle: { x: Math.round(tr.left + tr.width / 2), y: Math.round(tr.top + tr.height / 2) },
|
|
||||||
control: { x: Math.round(ctrlX), y: Math.round(ctrlY) },
|
|
||||||
editorRect: { left: er.left, top: er.top, w: er.width, h: er.height },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
console.log("[info] editor center:", geom.editor, "toggle center:", geom.toggle, "control:", geom.control);
|
|
||||||
|
|
||||||
// 当前主题
|
|
||||||
const isDarkBefore = await page.evaluate(() => document.documentElement.classList.contains("dark"));
|
|
||||||
console.log("[info] current theme (dark?):", isDarkBefore);
|
|
||||||
|
|
||||||
// 主题循环:System → Light → Dark → System。
|
|
||||||
// 我们需要一次「实际明暗翻转」来触发 VT 动画。先预点击把主题设到 Light(若当前
|
|
||||||
// 是 System 或 Dark),确保下一次点击是 Light→Dark 的真实翻转。
|
|
||||||
if (isDarkBefore) {
|
|
||||||
// Dark → System(light resolved):先翻到浅色基线
|
|
||||||
await page.click(".theme-toggle");
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
}
|
|
||||||
// 现在确保是 Light(若仍 System,再点一次到 Light)
|
|
||||||
const themeLabel1 = await page.evaluate(() => document.querySelector(".theme-toggle")?.getAttribute("title") || "");
|
|
||||||
if (themeLabel1.includes("跟随系统")) {
|
|
||||||
await page.click(".theme-toggle");
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
}
|
|
||||||
// 滚动回顶部让 toggle 可见,再滚回编辑器位置准备采样
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
// 确认现在是 Light(浅色,dark class 无)
|
|
||||||
const confirmedLight = await page.evaluate(() => !document.documentElement.classList.contains("dark"));
|
|
||||||
console.log("[info] baseline set to Light:", confirmedLight);
|
|
||||||
|
|
||||||
// 滚回编辑器位置
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const editor = document.querySelector(".code-runner-editor .cm-editor");
|
|
||||||
if (editor) editor.scrollIntoView({ block: "center" });
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// 重新读取坐标(scrollIntoView 后)
|
|
||||||
// 关键:对照点必须与编辑器到 toggle 原点等距,否则圆形展开到达两者的时间
|
|
||||||
// 不同(几何延迟),会被误判为 bug。对照点取编辑器正上方/下方等距的页面背景。
|
|
||||||
const geom2 = await page.evaluate(() => {
|
|
||||||
const editor = document.querySelector(".code-runner-editor .cm-editor");
|
|
||||||
const toggle = document.querySelector(".theme-toggle");
|
|
||||||
const er = editor.getBoundingClientRect();
|
|
||||||
const tr = toggle.getBoundingClientRect();
|
|
||||||
const ex = er.left + er.width / 2;
|
|
||||||
const ey = er.top + er.height / 2;
|
|
||||||
const tx = tr.left + tr.width / 2;
|
|
||||||
const ty = tr.top + tr.height / 2;
|
|
||||||
const distEditor = Math.hypot(ex - tx, ey - ty);
|
|
||||||
// 对照点:在 toggle 正下方(同 x),距离 = 编辑器到 toggle 的距离
|
|
||||||
// 这样对照点与编辑器等距,圆形同时覆盖两者
|
|
||||||
const ctrlX = Math.round(tx);
|
|
||||||
const ctrlY = Math.round(ty + distEditor);
|
|
||||||
// 确保对照点在视口内且在页面背景上(非编辑器区域)
|
|
||||||
return {
|
|
||||||
editor: { x: Math.round(ex), y: Math.round(ey) },
|
|
||||||
control: { x: ctrlX, y: ctrlY },
|
|
||||||
distEditor: Math.round(distEditor),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
Object.assign(geom, geom2);
|
|
||||||
console.log("[info] editor center:", geom.editor, "control:", geom.control, "dist:", geom.distEditor);
|
|
||||||
|
|
||||||
async function sample(x, y) {
|
|
||||||
const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 }, omitBackground: false });
|
|
||||||
const px = decodePng(png);
|
|
||||||
return { ...px, hex: hex(px) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// baseline (Light)
|
|
||||||
const preEditor = await sample(geom.editor.x, geom.editor.y);
|
|
||||||
const preControl = await sample(geom.control.x, geom.control.y);
|
|
||||||
console.log("[info] baseline editor:", preEditor.hex, "control:", preControl.hex);
|
|
||||||
|
|
||||||
// 点击主题按钮(Light → Dark,真实翻转,触发 VT 动画),开始逐帧采样。
|
|
||||||
// toggle 在顶部(y=40),需先滚到顶部点击,再立即滚回编辑器位置采样。
|
|
||||||
// 但滚动会打断 VT 动画截图——改用 JS 直接调 __startThemeTransition(toggle 坐标),
|
|
||||||
// 这样不需要滚动,且与真实 onclick 行为一致(传 toggle 坐标作圆心)。
|
|
||||||
const frames = [];
|
|
||||||
const t0 = Date.now();
|
|
||||||
// 直接在 page 内调 __startThemeTransition,圆心用 toggle 坐标(顶部),
|
|
||||||
// 圆形从顶部展开扫向编辑器。
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const toggle = document.querySelector(".theme-toggle");
|
|
||||||
const r = toggle.getBoundingClientRect();
|
|
||||||
const x = r.left + r.width / 2;
|
|
||||||
const y = r.top + r.height / 2;
|
|
||||||
window.__startThemeTransition(x, y);
|
|
||||||
});
|
|
||||||
while (Date.now() - t0 < 1500) {
|
|
||||||
const t = Date.now() - t0;
|
|
||||||
const e = await sample(geom.editor.x, geom.editor.y);
|
|
||||||
const c = await sample(geom.control.x, geom.control.y);
|
|
||||||
frames.push({ t, editor: e.hex, control: c.hex });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 终态
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const postEditor = await sample(geom.editor.x, geom.editor.y);
|
|
||||||
const postControl = await sample(geom.control.x, geom.control.y);
|
|
||||||
const isDarkAfter = await page.evaluate(() => document.documentElement.classList.contains("dark"));
|
|
||||||
// 注意:直接调 __startThemeTransition 只翻 DOM class,不更新 Dioxus theme signal,
|
|
||||||
// 所以 toggle 的 title 不变,但 dark class 已翻转——这正是我们要测的 VT 动画效果。
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
|
|
||||||
// 分析
|
|
||||||
console.log("\n========== 基线 ==========");
|
|
||||||
console.log(" editor :", preEditor.hex, " control:", preControl.hex);
|
|
||||||
console.log("\n========== 终态 ==========");
|
|
||||||
console.log(" editor :", postEditor.hex, " control:", postControl.hex);
|
|
||||||
console.log(" theme dark?:", isDarkBefore, "->", isDarkAfter);
|
|
||||||
|
|
||||||
console.log("\n========== 逐帧时间线 ==========");
|
|
||||||
console.log("t(ms)".padEnd(8) + "editor".padEnd(12) + "control".padEnd(12));
|
|
||||||
let lastT = -100;
|
|
||||||
for (const f of frames) {
|
|
||||||
if (f.t - lastT < 40) continue;
|
|
||||||
lastT = f.t;
|
|
||||||
console.log(String(f.t).padEnd(8) + f.editor.padEnd(12) + f.control.padEnd(12));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判定:editor 与 control 首次跳变时刻
|
|
||||||
function firstChange(key, startHex) {
|
|
||||||
for (const f of frames) {
|
|
||||||
if (f[key] !== startHex) return f.t;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
const tEditor = firstChange("editor", preEditor.hex);
|
|
||||||
const tControl = firstChange("control", preControl.hex);
|
|
||||||
console.log("\n========== 判定 ==========");
|
|
||||||
console.log(`editor 首次跳变: t=${tEditor}ms (${preEditor.hex} -> ${postEditor.hex})`);
|
|
||||||
console.log(`control 首次跳变: t=${tControl}ms (${preControl.hex} -> ${postControl.hex})`);
|
|
||||||
|
|
||||||
if (tEditor < 0 && tControl < 0) {
|
|
||||||
console.log("? 两者均未观察到跳变(可能采样点未命中或主题未翻)");
|
|
||||||
} else if (tEditor < 0) {
|
|
||||||
console.log("? editor 全程未变色,control 变了——编辑器可能没参与主题切换");
|
|
||||||
} else if (tControl < 0) {
|
|
||||||
console.log("? control 全程未变色,editor 变了——对照点选错");
|
|
||||||
} else {
|
|
||||||
const lag = Math.abs(tEditor - tControl);
|
|
||||||
if (lag <= 50) {
|
|
||||||
console.log(`✓ editor 与对照点同帧变色(lag=${lag}ms):编辑器参与了 VT 动画。`);
|
|
||||||
} else {
|
|
||||||
const later = tEditor > tControl ? "editor" : "control";
|
|
||||||
console.log(`✗ ${later} 滞后 ${lag}ms:编辑器与界面其他部分不同步——`);
|
|
||||||
console.log(` 这正是「代码块直接瞬切,不受 VT 动画控制」的表现。`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length) {
|
|
||||||
console.log("\n========== 页面错误 ==========");
|
|
||||||
errors.slice(0, 5).forEach((e) => console.log(" " + e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("[fatal]", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<!--
|
|
||||||
VT 主题切换动画验证 harness —— 逐帧像素采样。
|
|
||||||
|
|
||||||
目的:验证「暗/亮切换的圆形展开 VT 动画」是否对可运行代码块的
|
|
||||||
xterm 终端区域生效。预期 bug:VT 动画期间(0.4s),xterm canvas 背景仍是
|
|
||||||
旧色(因为 set_theme 在 VT 回调之后才由 Dioxus use_effect 触发,而 NEW 快照
|
|
||||||
在回调里就拍好了),圆形扫过时终端区域「不动」,动画结束后才瞬切。
|
|
||||||
|
|
||||||
本 harness 加载项目真实的构建产物(不做任何 JS 改写),用真实的
|
|
||||||
yggdrasil-core __startThemeTransition + 真实的 XtermTerminal.create +
|
|
||||||
真实的 terminal.css。额外放三个采样探针 div,由 Playwright 按坐标读色:
|
|
||||||
|
|
||||||
- body-probe : 背景 = var(--color-paper-theme) (随 .dark 翻转)
|
|
||||||
- cssvar-probe : 背景 = var(--color-paper-code-block) (随 .dark 翻转)
|
|
||||||
- xterm-mount : XtermTerminal.create 挂载点,canvas 渲染 (命令式 set_theme)
|
|
||||||
|
|
||||||
关键:模拟真实时序——
|
|
||||||
1. __startThemeTransition(x, y) ← 同步:截 OLD → 回调翻 .dark → 截 NEW
|
|
||||||
2. 之后(用 setTimeout 模拟 Dioxus use_effect 的延迟)调 set_theme('dark')
|
|
||||||
这样 VT 的 NEW 快照里 xterm 仍是旧色,而 body/cssvar(随 CSS 变量)已是新色。
|
|
||||||
圆形展开用 body/cssvar 的 NEW 覆盖 OLD;但 xterm 的 NEW 快照=旧色,故圆形
|
|
||||||
扫过 xterm 区域时看不到变化——直到 set_theme 跑完才瞬切。
|
|
||||||
|
|
||||||
探针布局:toggle 原点在左侧,三个探针排成一行在右侧,使圆形从左向右扫过,
|
|
||||||
便于观察时序差异(理想:三探针同时变;bug 态:xterm 滞后)。
|
|
||||||
-->
|
|
||||||
<html lang="zh">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>VT theme harness</title>
|
|
||||||
<link rel="stylesheet" href="/yggdrasil-core/yggdrasil-core.css" />
|
|
||||||
<link rel="stylesheet" href="/xterm/terminal.css" />
|
|
||||||
<style>
|
|
||||||
/* ===== 复刻 input.css 里与本次验证相关的变量定义 =====
|
|
||||||
只抄验证需要的两对变量(theme / code-block),不引整个 input.css。 */
|
|
||||||
:root {
|
|
||||||
--color-paper-theme: #eff1f5; /* input.css light, Latte Base */
|
|
||||||
--color-paper-code-block: #dce0e8; /* input.css light, Latte Surface0 */
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
--color-paper-theme: #1e1e2e; /* input.css dark, Mocha Base */
|
|
||||||
--color-paper-code-block: #313244;/* input.css dark, Mocha Surface0 */
|
|
||||||
}
|
|
||||||
html, body { margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
background: var(--color-paper-theme);
|
|
||||||
color: #4c4f69;
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
/* 三个探针:固定大小 60×60,采样中心像素。
|
|
||||||
关键布局:三个探针放在同一垂直列(top 相同),水平紧邻——使它们到 toggle
|
|
||||||
原点的距离几乎相同,圆形 clip-path 同时覆盖三者。这样任何「xterm 比 body 晚变色」
|
|
||||||
的 lag 都只能来自快照不同步(真 bug),而非几何距离差(假阳性)。 */
|
|
||||||
.probe {
|
|
||||||
position: fixed;
|
|
||||||
top: 120px;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border: 1px solid rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
#body-probe {
|
|
||||||
left: 200px;
|
|
||||||
background: var(--color-paper-theme);
|
|
||||||
}
|
|
||||||
#cssvar-probe {
|
|
||||||
left: 270px;
|
|
||||||
background: var(--color-paper-code-block);
|
|
||||||
}
|
|
||||||
#xterm-mount {
|
|
||||||
left: 340px;
|
|
||||||
/* 容器底色与生产一致(runner.rs 输出区 div 用 var(--color-paper-code-block)),
|
|
||||||
xterm canvas 画在其上。采样点取 canvas 中心,读的是 xterm 画的背景色。 */
|
|
||||||
background: var(--color-paper-code-block);
|
|
||||||
}
|
|
||||||
#xterm-mount .xterm { height: 100%; }
|
|
||||||
#xterm-mount .xterm-viewport { height: 100% !important; }
|
|
||||||
.label {
|
|
||||||
position: fixed; top: 10px; font-size: 10px;
|
|
||||||
color: #888; pointer-events: none; white-space: nowrap;
|
|
||||||
}
|
|
||||||
#toggle-btn {
|
|
||||||
position: fixed; left: 40px; top: 40px;
|
|
||||||
padding: 12px 20px; font-size: 14px; cursor: pointer;
|
|
||||||
background: #fff; border: 1px solid #999;
|
|
||||||
}
|
|
||||||
#status {
|
|
||||||
position: fixed; left: 40px; top: 90px;
|
|
||||||
font-size: 12px; color: #555;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="">
|
|
||||||
<button id="toggle-btn" type="button">toggle theme</button>
|
|
||||||
<div id="status">ready</div>
|
|
||||||
|
|
||||||
<div class="label" style="left:200px">body-probe</div>
|
|
||||||
<div class="label" style="left:270px">cssvar-probe</div>
|
|
||||||
<div class="label" style="left:340px">xterm-mount</div>
|
|
||||||
|
|
||||||
<div id="body-probe" class="probe"></div>
|
|
||||||
<div id="cssvar-probe" class="probe"></div>
|
|
||||||
<div id="xterm-mount" class="probe"></div>
|
|
||||||
|
|
||||||
<script src="/yggdrasil-core/yggdrasil-core.js"></script>
|
|
||||||
<script src="/xterm/terminal.js"></script>
|
|
||||||
<script>
|
|
||||||
// 挂载真实 xterm 实例,写几行字让 canvas 有可见像素。
|
|
||||||
// XtermOptions 构建产物里是空 class;直接赋值 JS 属性(theme/fontSize/onReady)。
|
|
||||||
// create 直接返回 TerminalInstance;onReady 在构造函数末尾同步触发(create 返回前),
|
|
||||||
// 故 onReady 只置标志,返回后再写内容。
|
|
||||||
window.__harnessReady = false;
|
|
||||||
(function () {
|
|
||||||
var opts = new window.XtermOptions();
|
|
||||||
opts.theme = 'light';
|
|
||||||
opts.fontSize = 13;
|
|
||||||
opts.onReady = function () { window.__xtermConstructed = true; };
|
|
||||||
var inst = window.XtermTerminal.create('xterm-mount', opts);
|
|
||||||
window.__harnessTerm = inst;
|
|
||||||
if (inst) {
|
|
||||||
inst.writeAll('hello\nline2\nline3', '');
|
|
||||||
window.__harnessReady = true;
|
|
||||||
document.getElementById('status').textContent = 'xterm mounted';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// toggle:模拟真实 ThemeToggle::onclick 时序。
|
|
||||||
// __startThemeTransition(x, y) ← VT:截OLD → 回调(翻.dark + dispatch 事件) → 截NEW
|
|
||||||
//
|
|
||||||
// 修复后,CodeMirror/xterm 的 set_theme 由 VT 回调内的 'yggdrasil:theme-change'
|
|
||||||
// 事件同步触发(在 NEW 快照捕获前),不再需要外部 setTimeout 模拟 Dioxus use_effect。
|
|
||||||
// 真实 Dioxus use_effect 仍会跑(兜底),但本 harness 不模拟它——只验证 VT 事件路径
|
|
||||||
// 是否让 xterm 在快照前换肤(这才是修复的核心)。
|
|
||||||
//
|
|
||||||
// 原点放在按钮中心(左上),圆形向右展开扫过三个探针。
|
|
||||||
document.getElementById('toggle-btn').addEventListener('click', function (e) {
|
|
||||||
var fn = window.__startThemeTransition;
|
|
||||||
if (typeof fn === 'function') {
|
|
||||||
fn(e.clientX, e.clientY);
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.toggle('dark');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,412 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
// 真实页面 xterm 终端区域 VT 采样器。
|
|
||||||
//
|
|
||||||
// 验证 xterm 终端输出区域(运行后挂载)是否随 VT 圆形展开动画变色。
|
|
||||||
// 与编辑器采样器不同:xterm 在用户点「运行」后才挂载,且背景是 inline style。
|
|
||||||
//
|
|
||||||
// 运行:node scripts/vt-xterm-real-sampler.mjs
|
|
||||||
// 前提:make dev 已启动。
|
|
||||||
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { inflateSync } from "node:zlib";
|
|
||||||
|
|
||||||
const __require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
function loadChromium() {
|
|
||||||
const candidates = [];
|
|
||||||
const npxCache = join(homedir(), ".npm", "_npx");
|
|
||||||
if (existsSync(npxCache)) {
|
|
||||||
for (const hash of readdirSync(npxCache)) {
|
|
||||||
candidates.push(join(npxCache, hash, "node_modules", "playwright-core"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const dir of candidates) {
|
|
||||||
if (!existsSync(dir)) continue;
|
|
||||||
try {
|
|
||||||
const pw = __require(dir);
|
|
||||||
if (pw?.chromium?.launch) return pw.chromium;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
throw new Error("playwright not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodePng(buf) {
|
|
||||||
let off = 8, w = 0, h = 0, ct = 0;
|
|
||||||
const idat = [];
|
|
||||||
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 + 4;
|
|
||||||
if (type === "IHDR") { w = data.readUInt32BE(0); h = data.readUInt32BE(4); ct = data[9]; }
|
|
||||||
else if (type === "IDAT") idat.push(data);
|
|
||||||
else if (type === "IEND") break;
|
|
||||||
}
|
|
||||||
const inf = inflateSync(Buffer.concat(idat));
|
|
||||||
const ch = ct === 6 ? 4 : 3;
|
|
||||||
const bpp = ch, stride = w * bpp;
|
|
||||||
let prev = Buffer.alloc(stride), io = 0;
|
|
||||||
const raw = Buffer.alloc(h * stride);
|
|
||||||
for (let y = 0; y < h; y++) {
|
|
||||||
const f = inf[io++], line = inf.subarray(io, io + stride); io += stride;
|
|
||||||
const out = Buffer.alloc(stride);
|
|
||||||
for (let x = 0; x < stride; x++) {
|
|
||||||
const c = line[x], l = x >= bpp ? out[x - bpp] : 0, u = prev[x], ul = x >= bpp ? prev[x - bpp] : 0;
|
|
||||||
let v;
|
|
||||||
switch (f) {
|
|
||||||
case 0: v = c; break;
|
|
||||||
case 1: v = (c + l) & 0xff; break;
|
|
||||||
case 2: v = (c + u) & 0xff; break;
|
|
||||||
case 3: v = (c + ((l + u) >> 1)) & 0xff; break;
|
|
||||||
case 4: { const pp = l + u - ul, pa = Math.abs(pp-l), pb = Math.abs(pp-u), pc = Math.abs(pp-ul); v = (c + (pa<=pb&&pa<=pc?l:pb<=pc?u:ul)) & 0xff; } break;
|
|
||||||
default: throw new Error("f" + f);
|
|
||||||
}
|
|
||||||
out[x] = v;
|
|
||||||
}
|
|
||||||
out.copy(raw, y * stride); prev = out;
|
|
||||||
}
|
|
||||||
return { r: raw[0], g: raw[1], b: raw[2] };
|
|
||||||
}
|
|
||||||
const hex = ({ r, g, b }) => "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const chromium = loadChromium();
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
|
|
||||||
const page = await context.newPage();
|
|
||||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
||||||
|
|
||||||
console.log("[info] navigating to /post/rust");
|
|
||||||
await page.goto("http://localhost:8080/post/rust", { waitUntil: "networkidle", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
// Set theme to Light (cycle if needed)
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const st = await page.evaluate(() => {
|
|
||||||
const tl = document.querySelector(".theme-toggle")?.getAttribute("title") || "";
|
|
||||||
const dark = document.documentElement.classList.contains("dark");
|
|
||||||
return { isSystem: tl.includes("跟随系统"), isDark: dark, label: tl };
|
|
||||||
});
|
|
||||||
if (!st.isSystem && !st.isDark) break;
|
|
||||||
await page.click(".theme-toggle");
|
|
||||||
await page.waitForTimeout(700);
|
|
||||||
}
|
|
||||||
// Scroll to editor, click Run
|
|
||||||
await page.evaluate(() => { const e = document.querySelector(".code-runner-editor .cm-editor"); if (e) e.scrollIntoView({ block: "start" }); });
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await page.click("button:has-text(\"运行\")");
|
|
||||||
await page.waitForSelector(".xterm", { timeout: 10000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
console.log("[info] xterm mounted, code executed");
|
|
||||||
|
|
||||||
// Scroll xterm output into view (center of viewport)
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const xterm = document.querySelector(".xterm-scrollable-element") || document.querySelector(".xterm");
|
|
||||||
if (xterm) xterm.scrollIntoView({ block: "center" });
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Find xterm output area + a control point equidistant from toggle origin.
|
|
||||||
// Toggle is at top of page (y≈40), but after scrolling it may be off-screen.
|
|
||||||
// VT animation uses toggle coords as circle origin regardless of scroll position
|
|
||||||
// (coords are viewport-relative from getBoundingClientRect). After scrollIntoView,
|
|
||||||
// toggle scrolls off top — its rect.top becomes negative. The VT circle origin
|
|
||||||
// will be at that negative y, meaning the circle starts above the viewport.
|
|
||||||
// For equidistant sampling: pick control point at same distance from toggle as xterm,
|
|
||||||
// placed horizontally adjacent (same scroll position, different x).
|
|
||||||
const geom = await page.evaluate(() => {
|
|
||||||
const xterm = document.querySelector(".xterm-scrollable-element") || document.querySelector(".xterm");
|
|
||||||
const toggle = document.querySelector(".theme-toggle");
|
|
||||||
if (!xterm || !toggle) return null;
|
|
||||||
const xr = xterm.getBoundingClientRect();
|
|
||||||
const tr = toggle.getBoundingClientRect();
|
|
||||||
// xterm sample: right edge, mid-height (pure bg, avoid text on left)
|
|
||||||
const xx = xr.right - 10;
|
|
||||||
const xy = xr.top + xr.height / 2;
|
|
||||||
const tx = tr.left + tr.width / 2;
|
|
||||||
const ty = tr.top + tr.height / 2;
|
|
||||||
const dist = Math.hypot(xx - tx, xy - ty);
|
|
||||||
// control: same distance from toggle, placed to the LEFT of xterm at same y
|
|
||||||
// (horizontal mirror preserves distance if toggle is centered; approximate)
|
|
||||||
// Better: place control at angle that keeps it in viewport. Use x = tx - (xx-tx),
|
|
||||||
// y = xy (mirror across toggle x). Distance = same.
|
|
||||||
const cx = Math.round(tx - (xx - tx));
|
|
||||||
const cy = Math.round(xy);
|
|
||||||
return {
|
|
||||||
xterm: { x: Math.round(xx), y: Math.round(xy) },
|
|
||||||
toggle: { x: Math.round(tx), y: Math.round(ty) },
|
|
||||||
control: { x: cx, y: cy },
|
|
||||||
dist: Math.round(dist),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (!geom) { console.log("[fatal] no xterm found"); await browser.close(); return; }
|
|
||||||
console.log("[info] xterm sample:", geom.xterm, "control:", geom.control, "dist:", geom.dist);
|
|
||||||
|
|
||||||
async function sample(x, y) {
|
|
||||||
const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 }, omitBackground: false });
|
|
||||||
const px = decodePng(png);
|
|
||||||
return { ...px, hex: hex(px) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const preX = await sample(geom.xterm.x, geom.xterm.y);
|
|
||||||
const preC = await sample(geom.control.x, geom.control.y);
|
|
||||||
console.log("[info] baseline xterm:", preX.hex, "control:", preC.hex);
|
|
||||||
|
|
||||||
// Trigger VT (Light → Dark) via __startThemeTransition
|
|
||||||
const frames = [];
|
|
||||||
const t0 = Date.now();
|
|
||||||
await page.evaluate((coords) => {
|
|
||||||
window.__startThemeTransition(coords.x, coords.y);
|
|
||||||
}, geom.toggle);
|
|
||||||
while (Date.now() - t0 < 1500) {
|
|
||||||
const t = Date.now() - t0;
|
|
||||||
const x = await sample(geom.xterm.x, geom.xterm.y);
|
|
||||||
const c = await sample(geom.control.x, geom.control.y);
|
|
||||||
frames.push({ t, xterm: x.hex, control: c.hex });
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const postX = await sample(geom.xterm.x, geom.xterm.y);
|
|
||||||
const postC = await sample(geom.control.x, geom.control.y);
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
|
|
||||||
console.log("\n========== 基线 ==========");
|
|
||||||
console.log(" xterm :", preX.hex, " control:", preC.hex);
|
|
||||||
console.log("\n========== 终态 ==========");
|
|
||||||
console.log(" xterm :", postX.hex, " control:", postC.hex);
|
|
||||||
|
|
||||||
console.log("\n========== 逐帧时间线 ==========");
|
|
||||||
console.log("t(ms)".padEnd(8) + "xterm".padEnd(12) + "control".padEnd(12));
|
|
||||||
let lastT = -100;
|
|
||||||
for (const f of frames) {
|
|
||||||
if (f.t - lastT < 40) continue;
|
|
||||||
lastT = f.t;
|
|
||||||
console.log(String(f.t).padEnd(8) + f.xterm.padEnd(12) + f.control.padEnd(12));
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstChange(key, start) {
|
|
||||||
for (const f of frames) if (f[key] !== start) return f.t;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
const tX = firstChange("xterm", preX.hex);
|
|
||||||
const tC = firstChange("control", preC.hex);
|
|
||||||
console.log("\n========== 判定 ==========");
|
|
||||||
console.log(`xterm 首次跳变: t=${tX}ms (${preX.hex} -> ${postX.hex})`);
|
|
||||||
console.log(`control 首次跳变: t=${tC}ms (${preC.hex} -> ${postC.hex})`);
|
|
||||||
if (tX < 0 || tC < 0) {
|
|
||||||
console.log("? 未观察到完整跳变");
|
|
||||||
} else {
|
|
||||||
const lag = Math.abs(tX - tC);
|
|
||||||
console.log(lag <= 50
|
|
||||||
? `✓ xterm 与等距对照点同帧变色(lag=${lag}ms):终端区域参与 VT 动画。`
|
|
||||||
: `✗ ${tX > tC ? "xterm" : "control"} 滞后 ${lag}ms:终端区域与界面不同步。`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error("[fatal]", e); process.exit(1); });
|
|
||||||
@ -177,8 +177,27 @@ pub fn CodeRunner(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
|
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
|
||||||
|
//
|
||||||
|
// VT 动画期间跳过:手动点击主题按钮时,__startThemeTransition 已在 VT 回调内
|
||||||
|
// 通过 'yggdrasil:theme-change' 事件同步调了 setTheme(出现在 NEW 快照里)。
|
||||||
|
// 但 use_effect 在 theme.set(next) 后立即触发——早于 VT 回调(异步),会直接改
|
||||||
|
// 实时 DOM 的编辑器背景。VT 动画播的是伪元素快照,实时 DOM 改动会穿透伪元素,
|
||||||
|
// 表现为「圆形还没展开到代码块,代码块就整体瞬切」。is-theme-transitioning
|
||||||
|
// 期间跳过,让 VT 事件负责换肤;动画结束后此 effect 会因 resolved 信号变化重跑
|
||||||
|
// (此时 is-theme-transitioning 已移除),做一次幂等的兜底同步。
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let r = use_resolved_theme();
|
let r = use_resolved_theme();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let transitioning = web_sys::window()
|
||||||
|
.and_then(|w| w.document())
|
||||||
|
.and_then(|d| d.document_element())
|
||||||
|
.map(|el| el.class_list().contains("is-theme-transitioning"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if transitioning {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(h) = editor_handle.read().as_ref() {
|
if let Some(h) = editor_handle.read().as_ref() {
|
||||||
h.instance()
|
h.instance()
|
||||||
.set_theme(if r() == ResolvedTheme::Dark {
|
.set_theme(if r() == ResolvedTheme::Dark {
|
||||||
@ -257,8 +276,20 @@ pub fn CodeRunner(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 主题切换时同步终端主题。
|
// 主题切换时同步终端主题。
|
||||||
|
// VT 动画期间跳过(同 CodeMirror 的 use_effect,见上方注释)。
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let r = use_resolved_theme();
|
let r = use_resolved_theme();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let transitioning = web_sys::window()
|
||||||
|
.and_then(|w| w.document())
|
||||||
|
.and_then(|d| d.document_element())
|
||||||
|
.map(|el| el.class_list().contains("is-theme-transitioning"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if transitioning {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(h) = term_handle.read().as_ref() {
|
if let Some(h) = term_handle.read().as_ref() {
|
||||||
h.instance()
|
h.instance()
|
||||||
.set_theme(if r() == ResolvedTheme::Dark {
|
.set_theme(if r() == ResolvedTheme::Dark {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user