diff --git a/scripts/vt-real-page-sampler.mjs b/scripts/vt-real-page-sampler.mjs deleted file mode 100644 index 465b70d..0000000 --- a/scripts/vt-real-page-sampler.mjs +++ /dev/null @@ -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); -}); diff --git a/scripts/vt-theme-harness.html b/scripts/vt-theme-harness.html deleted file mode 100644 index 8e6cfdc..0000000 --- a/scripts/vt-theme-harness.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - VT theme harness - - - - - - -
ready
- -
body-probe
-
cssvar-probe
-
xterm-mount
- -
-
-
- - - - - - diff --git a/scripts/vt-theme-sampler.mjs b/scripts/vt-theme-sampler.mjs deleted file mode 100644 index af3c0b6..0000000 --- a/scripts/vt-theme-sampler.mjs +++ /dev/null @@ -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); -}); diff --git a/scripts/vt-xterm-real-sampler.mjs b/scripts/vt-xterm-real-sampler.mjs deleted file mode 100644 index 2ada73f..0000000 --- a/scripts/vt-xterm-real-sampler.mjs +++ /dev/null @@ -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); }); diff --git a/src/components/code_runner/runner.rs b/src/components/code_runner/runner.rs index 625a824..204c7d7 100644 --- a/src/components/code_runner/runner.rs +++ b/src/components/code_runner/runner.rs @@ -177,8 +177,27 @@ pub fn CodeRunner( }); // 主题切换(含 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 || { 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() { h.instance() .set_theme(if r() == ResolvedTheme::Dark { @@ -257,8 +276,20 @@ pub fn CodeRunner( }); // 主题切换时同步终端主题。 + // VT 动画期间跳过(同 CodeMirror 的 use_effect,见上方注释)。 use_effect(move || { 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() { h.instance() .set_theme(if r() == ResolvedTheme::Dark {