fix(theme): sync CodeMirror/xterm theme in VT callback for circular-expand animation
主题切换的圆形展开 VT 动画对可运行代码块不生效:圆形扫过 CodeMirror/xterm
区域时看不到颜色变化,动画结束后才瞬切。
根因(逐帧像素验证确认):VT 回调只 toggle .dark class,但 CodeMirror 背景
由 catppuccin Extension 注入(.cm-editor 的 EditorView.theme 规则),xterm
背景由 .xterm-scrollable-element 的 inline background-color 注入——两者都
不随 .dark 翻转。set_theme 由 Dioxus use_effect 在 theme.set() 之后异步触发,
晚于 NEW 快照捕获,导致 OLD/NEW 快照在编辑器区域同色,圆形展开无可揭示的变化。
修复:在 VT 回调内(NEW 快照捕获前)同步 dispatch 'yggdrasil:theme-change'
CustomEvent,CodeMirror/xterm 各自的 index.ts 在模块加载时订阅并遍历
_instances 同步调 setTheme。事件先于 applyDarkClass dispatch,确保编辑器换肤
+ class 翻转被同一个 getComputedStyle reflow 捕获进 NEW 快照。
与现有 Dioxus use_effect 幂等共存:setTheme 对相同主题是 no-op(CodeMirror
Compartment.reconfigure + xterm options.theme = 均幂等),use_effect 作兜底
覆盖初始挂载 / 非 VT 场景。
验证:
- yggdrasil-core 测试扩展:断言 VT 回调 / 降级路径 / applyResolvedTheme 都
dispatch 事件,且事件先于 dark class 翻转
- scripts/vt-theme-sampler.mjs(Playwright 逐帧像素采样):修复前 xterm 比
等距的 cssvar 探针晚 100ms 变色(bug);修复后同帧变色(lag=0ms)
改动文件:
- libs/yggdrasil-core/src/theme-transition.ts: THEME_CHANGE_EVENT 常量 +
notifyThemeChange 辅助函数,VT 回调 / 降级路径 / applyResolvedTheme 三处 dispatch
- libs/codemirror-editor/src/index.ts: 订阅事件,forEach _instances.setTheme
- libs/xterm-terminal/src/index.ts: 同样模式
- libs/yggdrasil-core/src/theme-transition.test.ts: 3 个新用例
- public/xterm/terminal.js: 重建产物(含事件订阅)
- scripts/vt-theme-{harness,sampler}: 逐帧像素验证工具(throwaway,可作回归)
This commit is contained in:
parent
e50941ef24
commit
bbb77954e9
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接设置 <html> 的 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 <style> / xterm inline bg)。
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
getComputedStyle(document.body).backgroundColor;
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
147
scripts/vt-theme-harness.html
Normal file
147
scripts/vt-theme-harness.html
Normal file
@ -0,0 +1,147 @@
|
||||
<!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>
|
||||
412
scripts/vt-theme-sampler.mjs
Normal file
412
scripts/vt-theme-sampler.mjs
Normal file
@ -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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user