diff --git a/libs/codemirror-editor/src/index.ts b/libs/codemirror-editor/src/index.ts index 445b3c9..a5da10f 100644 --- a/libs/codemirror-editor/src/index.ts +++ b/libs/codemirror-editor/src/index.ts @@ -1,4 +1,16 @@ import { CodeMirrorInstance, EditorOptions } from './editor'; +import type { ThemeName } from './themes'; + +/** + * 主题切换事件名——与 yggdrasil-core 的 THEME_CHANGE_EVENT 保持一致。 + * + * 本包是独立 IIFE,不 import yggdrasil-core,故用同名 string literal 订阅。 + * yggdrasil-core 在 VT 回调内(NEW 快照捕获前)同步 dispatch 此事件, + * 让 CodeMirror 同步 reconfigure 主题——否则圆形展开扫过编辑器区域时 + * OLD/NEW 快照同色(背景由 catppuccin Extension 注入,不随 .dark 翻转), + * 看不到变化,动画结束后才瞬切。 + */ +const THEME_CHANGE_EVENT = 'yggdrasil:theme-change'; /** * 模块入口:暴露对象字面量 { create } 作为默认导出。 @@ -28,4 +40,26 @@ const CodeMirrorEditor = { }, }; +/** + * 订阅主题切换事件:VT 回调内同步 dispatch 时,遍历所有存活实例调 setTheme。 + * + * 必须在模块加载时注册一次(IIFE 顶层),确保任何时刻 dispatch 都能命中。 + * 单实例异常用 try/catch 隔离,避免一个实例失败中断其他实例换肤。 + * 与 Dioxus use_effect 驱动的 set_theme 幂等共存(reconfigure 相同主题是 no-op)。 + */ +if (typeof window !== 'undefined') { + window.addEventListener(THEME_CHANGE_EVENT, (event) => { + const detail = (event as CustomEvent).detail as { isDark: boolean } | undefined; + if (!detail) return; + const theme: ThemeName = detail.isDark ? 'dark' : 'light'; + CodeMirrorEditor._instances.forEach((instance) => { + try { + instance.setTheme(theme); + } catch (e) { + console.error('[CodeMirrorEditor] setTheme failed during theme change:', e); + } + }); + }); +} + export default CodeMirrorEditor; diff --git a/libs/xterm-terminal/src/index.ts b/libs/xterm-terminal/src/index.ts index 9f611ad..fb2c426 100644 --- a/libs/xterm-terminal/src/index.ts +++ b/libs/xterm-terminal/src/index.ts @@ -1,4 +1,16 @@ import { TerminalInstance, XtermOptions } from './terminal'; +import type { ThemeName } from './themes'; + +/** + * 主题切换事件名——与 yggdrasil-core 的 THEME_CHANGE_EVENT 保持一致。 + * + * 本包是独立 IIFE,不 import yggdrasil-core,故用同名 string literal 订阅。 + * yggdrasil-core 在 VT 回调内(NEW 快照捕获前)同步 dispatch 此事件, + * 让 xterm 同步 setTheme——否则圆形展开扫过终端区域时 OLD/NEW 快照同色 + * (背景由 .xterm-scrollable-element 的 inline background-color 注入,不随 + * .dark 翻转),看不到变化,动画结束后才瞬切。 + */ +const THEME_CHANGE_EVENT = 'yggdrasil:theme-change'; /** * 模块入口:暴露对象字面量 { create } 作为默认导出。 @@ -25,4 +37,26 @@ const XtermTerminal = { }, }; +/** + * 订阅主题切换事件:VT 回调内同步 dispatch 时,遍历所有存活实例调 setTheme。 + * + * 必须在模块加载时注册一次(IIFE 顶层),确保任何时刻 dispatch 都能命中。 + * 单实例异常用 try/catch 隔离,避免一个实例失败中断其他实例换肤。 + * 与 Dioxus use_effect 驱动的 set_theme 幂等共存(setTheme 相同主题是 no-op)。 + */ +if (typeof window !== 'undefined') { + window.addEventListener(THEME_CHANGE_EVENT, (event) => { + const detail = (event as CustomEvent).detail as { isDark: boolean } | undefined; + if (!detail) return; + const theme: ThemeName = detail.isDark ? 'dark' : 'light'; + XtermTerminal._instances.forEach((instance) => { + try { + instance.setTheme(theme); + } catch (e) { + console.error('[XtermTerminal] setTheme failed during theme change:', e); + } + }); + }); +} + export default XtermTerminal; diff --git a/libs/yggdrasil-core/src/theme-transition.test.ts b/libs/yggdrasil-core/src/theme-transition.test.ts index bcbb2fd..3ef7463 100644 --- a/libs/yggdrasil-core/src/theme-transition.test.ts +++ b/libs/yggdrasil-core/src/theme-transition.test.ts @@ -6,8 +6,12 @@ * * 注意:startThemeTransition 只接收 (x, y),目标主题(亮/暗)从 DOM 的 dark class * 现状推导(取反),不依赖外部传入——避免与调用方状态不同步。 + * + * 主题变更事件(THEME_CHANGE_EVENT):验证 VT 回调 + 降级路径都同步 dispatch, + * 且事件在 applyDarkClass 之前触发(编辑器换肤先于 class 翻转,同一 reflow 捕获)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { THEME_CHANGE_EVENT } from './theme-transition'; import './index'; describe('startThemeTransition', () => { @@ -112,4 +116,86 @@ describe('startThemeTransition', () => { delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; }); + + it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => { + const cbRef: { cb: (() => void) | null } = { cb: null }; + Object.defineProperty(document, 'startViewTransition', { + value: (cb: () => void) => { + cbRef.cb = cb; + return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} }; + }, + configurable: true, + writable: true, + }); + + // 记录事件触发时刻的 dark class 状态——验证事件在 applyDarkClass 之前 dispatch + const eventSnapshots: { isDark: boolean; darkClassAtDispatch: boolean }[] = []; + const listener = (e: Event) => { + const detail = (e as CustomEvent).detail as { isDark: boolean }; + eventSnapshots.push({ + isDark: detail.isDark, + darkClassAtDispatch: document.documentElement.classList.contains('dark'), + }); + }; + window.addEventListener(THEME_CHANGE_EVENT, listener); + + // 亮→暗:无 dark class,isDark=true + window.__startThemeTransition(0, 0); + cbRef.cb?.(); + + expect(eventSnapshots).toHaveLength(1); + expect(eventSnapshots[0].isDark).toBe(true); + // 事件触发时 dark class 尚未翻转(仍是 light)——证明事件先于 applyDarkClass + expect(eventSnapshots[0].darkClassAtDispatch).toBe(false); + // callback 执行完后 dark class 已翻转 + expect(document.documentElement.classList.contains('dark')).toBe(true); + + window.removeEventListener(THEME_CHANGE_EVENT, listener); + delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; + }); + + it('降级路径:无 VT 时也 dispatch 主题变更事件(亮→暗)', () => { + const calls: boolean[] = []; + const listener = (e: Event) => { + calls.push((e as CustomEvent).detail.isDark); + }; + window.addEventListener(THEME_CHANGE_EVENT, listener); + + // 亮→暗 + window.__startThemeTransition(0, 0); + expect(calls).toEqual([true]); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + // 暗→亮 + window.__startThemeTransition(0, 0); + expect(calls).toEqual([true, false]); + expect(document.documentElement.classList.contains('dark')).toBe(false); + + window.removeEventListener(THEME_CHANGE_EVENT, listener); + }); + + it('applyResolvedTheme:同步 dispatch 主题变更事件 + 翻 dark class', () => { + const calls: { isDark: boolean; darkClassAtDispatch: boolean }[] = []; + const listener = (e: Event) => { + const detail = (e as CustomEvent).detail as { isDark: boolean }; + calls.push({ + isDark: detail.isDark, + darkClassAtDispatch: document.documentElement.classList.contains('dark'), + }); + }; + window.addEventListener(THEME_CHANGE_EVENT, listener); + + window.__applyResolvedTheme(true); + expect(calls).toEqual([{ isDark: true, darkClassAtDispatch: false }]); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + window.__applyResolvedTheme(false); + expect(calls).toEqual([ + { isDark: true, darkClassAtDispatch: false }, + { isDark: false, darkClassAtDispatch: true }, + ]); + expect(document.documentElement.classList.contains('dark')).toBe(false); + + window.removeEventListener(THEME_CHANGE_EVENT, listener); + }); }); diff --git a/libs/yggdrasil-core/src/theme-transition.ts b/libs/yggdrasil-core/src/theme-transition.ts index 61c9e92..d39938f 100644 --- a/libs/yggdrasil-core/src/theme-transition.ts +++ b/libs/yggdrasil-core/src/theme-transition.ts @@ -10,6 +10,20 @@ * API 优先级 bug,是目前最稳定的 VT 主题切换方案。 */ +/** + * 主题切换自定义事件名。 + * + * 在 VT 回调内(NEW 快照捕获前)同步 dispatch,通知 CodeMirror / xterm 等 + * 命令式换肤的组件同步调 setTheme——它们的背景色不随 .dark class 翻转, + * 必须在快照前显式换肤,否则圆形展开扫过时看不到变化(OLD/NEW 同色)。 + * + * 事件 detail: `{ isDark: boolean }`。 + * + * 各编辑器包(codemirror-editor / xterm-terminal)是独立 IIFE,不 import 本包, + * 故各自用同名 string literal 订阅;本常量仅用于本包内部 + 测试断言一致性。 + */ +export const THEME_CHANGE_EVENT = 'yggdrasil:theme-change'; + function prefersReducedMotion(): boolean { return !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } @@ -40,6 +54,19 @@ function applyDarkClass(isDark: boolean): void { } } +/** + * 同步通知命令式换肤的组件(CodeMirror / xterm)切换主题。 + * + * CustomEvent 的 dispatch 是同步的:listener 在本函数返回前执行完毕, + * 故编辑器的 setTheme(reconfigure / options.theme =) 在调用方继续前已完成。 + * 这对 VT 至关重要——必须在 NEW 快照捕获前完成换肤,否则快照里仍是旧色。 + * + * 幂等:与 Dioxus use_effect 驱动的 set_theme 并存,重复设置相同主题是 no-op。 + */ +function notifyThemeChange(isDark: boolean): void { + window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { isDark } })); +} + /** * 直接设置 的 dark class(设置语义,非翻转)。 * @@ -47,8 +74,12 @@ function applyDarkClass(isDark: boolean): void { * 在此上下文下动画不可靠(实测圆形展开不显示,仅瞬切),故跟随系统场景不走 * startThemeTransition 的 VT 路径,改用此函数直接同步 class,做无动画的瞬切。 * 手动点击主题按钮仍走 startThemeTransition,保留圆形展开动画。 + * + * 同步 dispatch 主题变更事件,让命令式换肤的编辑器跟随系统偏好瞬切 + * (与 Dioxus use_effect 幂等共存,后者作兜底)。 */ export function applyResolvedTheme(isDark: boolean): void { + notifyThemeChange(isDark); applyDarkClass(isDark); } @@ -60,6 +91,9 @@ export function startThemeTransition(x: number, y: number): void { const reduced = prefersReducedMotion(); if (!hasVT || reduced) { + // 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。 + // 同样 dispatch 事件,保持与主路径对称(编辑器不依赖动画存在与否)。 + notifyThemeChange(isDark); applyDarkClass(isDark); return; } @@ -75,8 +109,13 @@ export function startThemeTransition(x: number, y: number): void { html.classList.add('is-theme-transitioning'); const vt = document.startViewTransition(() => { + // ★ 关键:先 dispatch 事件让编辑器同步换肤,再翻 .dark class。 + // 顺序不能反——编辑器换肤 + class 翻转必须被同一个 getComputedStyle + // reflow 捕获进 NEW 快照。若先翻 class 后换肤,reflow 可能漏掉编辑器。 + notifyThemeChange(isDark); applyDarkClass(isDark); - // 强制同步样式重算:确保 body 的 background-color 解析为目标值 + // 强制同步样式重算:确保 body 的 background-color 解析为目标值, + // 同时 flush 编辑器的同步换肤(CodeMirror + + + +
ready
+ +
body-probe
+
cssvar-probe
+
xterm-mount
+ +
+
+
+ + + + + + diff --git a/scripts/vt-theme-sampler.mjs b/scripts/vt-theme-sampler.mjs new file mode 100644 index 0000000..af3c0b6 --- /dev/null +++ b/scripts/vt-theme-sampler.mjs @@ -0,0 +1,412 @@ +// 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); +});