@yfwu2020/dsh-selection-explain 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,834 @@
1
+ /**
2
+ * 生成「临时输入框(语音卡)→ 解读小窗」交接动效的**方案实验室**页。
3
+ *
4
+ * 用法:node scripts/build-vcard-lab.mjs
5
+ * 产物:docs/vcard-to-panel-lab.html(单文件、离线可开)
6
+ *
7
+ * 为什么用生成器而不是手写页面:
8
+ * · 卡片/小窗的样式必须**和线上一模一样**,否则"哪个方案好看"这个判断本身就不成立 ——
9
+ * 所以 CSS 直接从 lib/client.js 的模块级 `CSS` 常量抽出来注入(与 build-demo-pages.mjs 同一手法,不手抄)。
10
+ * · 几何也照真实规则算:卡片 360 宽、**右缘**贴选区右缘、选区底边 +22;小窗 540 宽、
11
+ * **左缘**贴选区左缘、选区底边 +10(见 src/client/index.js 的 vcardGeometry / showPanel)。
12
+ *
13
+ * 页面上跑的是**六个方案**:0 现状(对照)/1 面板淡入/2 缩放长出/3 几何补间/4 CRT 交接/5 文字搬家。
14
+ * 每个方案都能单独重播、0.25× 慢放、开几何参考线、开并排对比;还能切"减少动效"看各自的降级形态。
15
+ */
16
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
17
+ import { resolve, dirname } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url))
21
+ const ROOT = resolve(HERE, '..')
22
+
23
+ /** 从客户端 bundle 里取出模块级 CSS 常量(不手抄,保证与线上一致)。 */
24
+ function extractCss() {
25
+ const src = readFileSync(resolve(ROOT, 'lib', 'client.js'), 'utf8')
26
+ const m = src.match(/var CSS = \[([\s\S]*?)\]\s*\.join\(''\)/) || src.match(/var CSS = \[([\s\S]*?)\]\.join\('\\n'\)/)
27
+ if (!m) throw new Error('未能在 lib/client.js 里找到 CSS 常量')
28
+ const parts = []
29
+ const re = /'((?:[^'\\]|\\.)*)'/g
30
+ let e
31
+ while ((e = re.exec(m[1])) !== null) parts.push(e[1].replace(/\\'/g, "'").replace(/\\n/g, '\n'))
32
+ return parts.join('\n')
33
+ }
34
+
35
+ /** DSH 主题变量:浅色一套(与真实宿主一致),深色一套由页面上的开关切。 */
36
+ const THEME_VARS = `:root{
37
+ --dsw-alias-bg-layer-1:#ffffff; --dsw-alias-label-primary:#1a1a1a; --dsw-alias-label-secondary:#6b7280;
38
+ --dsw-alias-label-primary-foreground:#ffffff; --dsw-alias-interactive-bg-hover:rgba(140,140,140,.11);
39
+ --dsw-alias-border-l3:rgba(140,140,140,.35); --dsw-alias-border-l4:rgba(140,140,140,.22);
40
+ --dsw-alias-bg-base:#ffffff; --dsw-alias-border-l2:rgba(140,140,140,.28);
41
+ --dsw-alias-button-primary-fill:#3b6ef5; --sel-a1:#0d9488; --sel-a2:#0f766e;
42
+ }`
43
+ const THEME_VARS_DARK = `.lab-dark .dsh-sel-layer, .lab-dark .dsh-sel-vcard{
44
+ --dsw-alias-bg-layer-1:#1c1c1e; --dsw-alias-label-primary:#f2f2f7; --dsw-alias-label-secondary:#9b9ba1;
45
+ --dsw-alias-interactive-bg-hover:rgba(255,255,255,.09);
46
+ --dsw-alias-border-l3:rgba(255,255,255,.22); --dsw-alias-border-l4:rgba(255,255,255,.14);
47
+ --dsw-alias-bg-base:#1c1c1e; --dsw-alias-border-l2:rgba(255,255,255,.18);
48
+ --sel-a1:#4ecdc4; --sel-a2:#4ecdc4;
49
+ }`
50
+
51
+ // ───────────────────────── 舞台里的"演员"(真实结构 + 真实类名)─────────────────────────
52
+
53
+ /** 假文档:一段被划中的话。选区就是交接的锚点。 */
54
+ const DOC = `<div class="lab-doc">
55
+ <div class="lab-doctitle">release-notes.md</div>
56
+ <p>把这次上线的情况同步一下:<span class="lab-sel">the migration ran long</span>,所以后面的
57
+ <code>freeze features</code> 不是随口一提 —— 那是把人力腾出来的实际动作。</p>
58
+ <p class="lab-docdim">(这段是假文档,只为了让"选区 → 浮标 → 卡片 → 小窗"这条链路有真实锚点。)</p>
59
+ </div>`
60
+
61
+ /** 浮标:真实类名与结构(.dsh-sel-btn + spark 图标)。 */
62
+ const FLOAT_BTN = `<button class="dsh-sel-btn lab-float" type="button">
63
+ <svg class="lab-spark" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.6l1.5 4.3 4.3 1.5-4.3 1.5L8 13.2l-1.5-4.3L2.2 7.4l4.3-1.5z"></path></svg>
64
+ <span>解读</span>
65
+ </button>`
66
+
67
+ /** 临时输入框:真实结构(vbar + textarea + 发送键),在听态 data-voiced="1"。 */
68
+ const VCARD = `<div class="dsh-sel-vcard lab-card" data-voiced="1" data-text="1">
69
+ <div class="dsh-sel-vbar"></div>
70
+ <textarea class="dsh-sel-vcard-box" rows="1" spellcheck="false" placeholder="正在听…">这是 在推迟 还是提前</textarea>
71
+ <button class="dsh-sel-vcard-send" type="button" title="直接追问(跳过翻译与详解)">↑</button>
72
+ </div>`
73
+
74
+ /** 关机亮线:真实结构(5 段 + 2 个火花)。 */
75
+ const CRTLINE = `<div class="dsh-sel-crtline lab-crt"><div class="dsh-sel-crtseg"></div><div class="dsh-sel-crtseg"></div><div class="dsh-sel-crtseg"></div><div class="dsh-sel-crtseg"></div><div class="dsh-sel-crtseg"></div><div class="dsh-sel-crtspark"></div><div class="dsh-sel-crtspark"></div></div>`
76
+
77
+ const SEND_ICON = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13.2V3.4"></path><path d="M3.9 7.5 8 3.4l4.1 4.1"></path></svg>`
78
+
79
+ const askRow = (value = '') => `<div class="dsh-sel-ask">
80
+ <div class="dsh-sel-quotes"></div>
81
+ <textarea class="dsh-sel-askbox" rows="1" placeholder="就这段文字继续追问…(Enter 发送,Shift+Enter 换行)">${value}</textarea>
82
+ <div class="dsh-sel-asktools">
83
+ <span class="dsh-sel-pref" data-on="0">
84
+ <span class="dsh-sel-preftext">输出偏好</span>
85
+ <span class="dsh-sel-seg" role="radiogroup" aria-label="输出偏好">
86
+ <span class="dsh-sel-segpill"></span>
87
+ <button class="dsh-sel-segcell" role="radio" aria-label="Markdown" data-on="1">M↓</button>
88
+ <button class="dsh-sel-segcell" role="radio" aria-label="网页">▢</button>
89
+ </span>
90
+ </span>
91
+ <span class="dsh-sel-askspace"></span>
92
+ <button class="dsh-sel-picker" type="button"><span class="dsh-sel-picker-name">Step 5 Preview Free</span><span class="dsh-sel-picker-tier">· 高</span><span class="dsh-sel-picker-caret">▼</span></button>
93
+ <button class="dsh-sel-iconbtn dsh-sel-asksend" type="button" aria-label="发送">${SEND_ICON}</button>
94
+ </div>
95
+ </div>`
96
+
97
+ /** 解读小窗:语音那条路(state.voiceAsk)的形态 —— 没有翻译/详解两节,直接是「选中文字 + 第一条追问」。 */
98
+ const PANEL = `<div class="dsh-sel-panel lab-panel" data-stage="translation" data-theme="light" role="dialog" aria-label="划词解读">
99
+ <div class="dsh-sel-head">
100
+ <span class="dsh-sel-mark"></span><span class="dsh-sel-title">划词解读</span><span class="dsh-sel-headspace"></span>
101
+ <button class="dsh-sel-action">最近</button><button class="dsh-sel-action">升格</button><button class="dsh-sel-icon">✕</button>
102
+ </div>
103
+ <div class="dsh-sel-body">
104
+ <div class="dsh-sel-quote">the migration ran long</div>
105
+ <div class="dsh-sel-chatlog" style="display:flex">
106
+ <div class="dsh-sel-bubble dsh-sel-bubble-user lab-firstask">这是 在推迟 还是提前</div>
107
+ <div class="dsh-sel-bubble dsh-sel-bubble-bot">是「推迟」——<b>ran long</b> 说的是这次迁移已经跑得比预期久,不是"提前"。<br>提前会用 <b>ran ahead of schedule</b>。</div>
108
+ </div>
109
+ ${askRow()}
110
+ </div>
111
+ </div>`
112
+
113
+ /** 几何补间方案专用:一个从卡片矩形长成小窗矩形的"形变箱",里面两套内容按最终宽度钉死、裁切换。 */
114
+ const MORPH = `<div class="lab-morph" hidden>
115
+ <div class="lab-morphcard">
116
+ <div class="dsh-sel-vcard" data-voiced="0" data-text="1" style="position:absolute;right:0;top:0">
117
+ <div class="dsh-sel-vbar"></div>
118
+ <textarea class="dsh-sel-vcard-box" rows="1" spellcheck="false">这是 在推迟 还是提前</textarea>
119
+ <button class="dsh-sel-vcard-send" type="button">↑</button>
120
+ </div>
121
+ </div>
122
+ <div class="lab-morphpanel">
123
+ <div class="dsh-sel-panel" data-stage="translation" data-theme="light">
124
+ <div class="dsh-sel-head"><span class="dsh-sel-mark"></span><span class="dsh-sel-title">划词解读</span><span class="dsh-sel-headspace"></span>
125
+ <button class="dsh-sel-action">最近</button><button class="dsh-sel-action">升格</button><button class="dsh-sel-icon">✕</button></div>
126
+ <div class="dsh-sel-body">
127
+ <div class="dsh-sel-quote">the migration ran long</div>
128
+ <div class="dsh-sel-chatlog" style="display:flex">
129
+ <div class="dsh-sel-bubble dsh-sel-bubble-user">这是 在推迟 还是提前</div>
130
+ <div class="dsh-sel-bubble dsh-sel-bubble-bot">是「推迟」——<b>ran long</b> 说的是这次迁移已经跑得比预期久,不是"提前"。</div>
131
+ </div>
132
+ ${askRow()}
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </div>`
137
+
138
+ /** 文字搬家方案专用:把"你说的那句话"复制一份,飞进小窗成为第一条追问。 */
139
+ const GHOST = `<div class="lab-ghost" hidden>这是 在推迟 还是提前</div>`
140
+ /** CRT 交接方案专用:小窗"开机"那条亮线。 */
141
+ const PANEL_LINE = `<div class="lab-panelline" hidden></div>`
142
+
143
+ const ACTORS = (withExtras) => `${FLOAT_BTN}${VCARD}${PANEL}${CRTLINE}${withExtras ? MORPH + GHOST + PANEL_LINE : ''}`
144
+
145
+ // ───────────────────────── 页面外壳 ─────────────────────────
146
+ const SHELL_CSS = `
147
+ *{box-sizing:border-box}
148
+ body{margin:0;background:#0d1117;color:#e6edf3;font:13.5px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI",sans-serif;padding:18px 20px 40px}
149
+ h1{font-size:17px;margin:0 0 4px;letter-spacing:.2px}
150
+ .sub{color:#8b949e;font-size:12.5px;margin:0 0 14px;max-width:1000px}
151
+ .sub b{color:#e6edf3;font-weight:600}
152
+ .wrap{display:grid;grid-template-columns:minmax(0,1fr) 372px;gap:16px;align-items:start}
153
+ @media (max-width:1120px){.wrap{grid-template-columns:1fr}}
154
+ .card{background:#161b22;border:1px solid #2a3038;border-radius:14px;padding:13px 15px}
155
+ .card + .card{margin-top:12px}
156
+ .card h3{margin:0 0 9px;font-size:13px;color:#79c0ff;font-weight:600;display:flex;justify-content:space-between;align-items:center;gap:8px}
157
+ .card h3 small{color:#6e7681;font-weight:400;font-size:11px}
158
+ .hint{color:#8b949e;font-size:11.5px;line-height:1.6;margin:8px 0 0}
159
+
160
+ /* 舞台:一块浅色"文档",浮层在里面绝对定位(真实是 position:fixed 贴视口) */
161
+ .lab-stage{position:relative;background:#fbfaf7;border:1px solid #2a3038;border-radius:14px;padding:20px 22px;min-height:486px;overflow:hidden}
162
+ .lab-dark .lab-stage{background:#15161a}
163
+ .lab-doc{color:#1d2126;font-size:14.5px;line-height:1.9;max-width:520px;position:relative;z-index:1}
164
+ .lab-dark .lab-doc{color:#dfe3e8}
165
+ .lab-doctitle{font:600 11.5px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#9aa3ad;letter-spacing:.4px;margin-bottom:12px;text-transform:uppercase}
166
+ .lab-doc p{margin:0 0 12px}
167
+ .lab-doc code{background:#eceff3;padding:1px 5px;border-radius:4px;font-size:12.5px}
168
+ .lab-dark .lab-doc code{background:#24262c}
169
+ .lab-docdim{color:#9aa3ad;font-size:12px}
170
+ .lab-sel{background:rgba(13,148,136,.16);box-shadow:inset 0 -1px 0 rgba(13,148,136,.5);border-radius:3px;padding:1px 2px}
171
+
172
+ /* 浮层:真实是 position:fixed,这里改成舞台内绝对定位(几何由 JS 按真实规则算) */
173
+ .lab-stage .dsh-sel-btn,.lab-stage .dsh-sel-vcard,.lab-stage .dsh-sel-panel,
174
+ .lab-stage .dsh-sel-crtline,.lab-stage .lab-morph,.lab-stage .lab-ghost,.lab-stage .lab-panelline{position:absolute !important}
175
+ .lab-stage .dsh-sel-btn{display:inline-flex}
176
+ .lab-stage .dsh-sel-vcard{display:flex}
177
+ .lab-stage .dsh-sel-panel{display:flex;transform-origin:0 0}
178
+ .lab-stage .lab-float{visibility:hidden;transform-origin:100% 100%}
179
+ .lab-stage .lab-card{visibility:hidden}
180
+ .lab-stage .lab-panel{visibility:hidden;opacity:0}
181
+ .lab-stage .lab-crt{visibility:hidden}
182
+ .lab-stage .lab-morph{overflow:hidden;visibility:hidden}
183
+ .lab-stage .lab-ghost{visibility:hidden;font:inherit;white-space:nowrap;pointer-events:none;z-index:2147483002;
184
+ background:rgba(13,148,136,.14);border-radius:3px;padding:1px 2px;color:#1d2126}
185
+ .lab-dark .lab-stage .lab-ghost{color:#dfe3e8}
186
+ .lab-stage .lab-panelline{visibility:hidden;height:2px;border-radius:2px;background:#0d9488;z-index:2147483002;
187
+ box-shadow:0 0 10px rgba(13,148,136,.75)}
188
+ .lab-stage .lab-morph .dsh-sel-panel{position:absolute !important;left:0;top:0;width:540px !important;visibility:visible !important;opacity:1 !important}
189
+ .lab-stage .lab-morph .dsh-sel-vcard{visibility:visible}
190
+ .lab-morphcard{position:absolute;left:0;top:0;right:0;bottom:0;overflow:hidden}
191
+ .lab-morphpanel{position:absolute;left:0;top:0;overflow:hidden}
192
+ .lab-morphpanel{opacity:0}
193
+
194
+ /* 几何参考线 */
195
+ .lab-stage[data-guides="1"] .lab-guide{display:block}
196
+ .lab-guide{display:none;position:absolute;border:1px dashed rgba(13,148,136,.75);border-radius:6px;pointer-events:none;z-index:2147483001}
197
+ .lab-guide::after{content:attr(data-label);position:absolute;left:0;top:-15px;font:600 10px/1 ui-monospace,Menlo,monospace;color:#0d9488;letter-spacing:.3px}
198
+ .lab-guide[data-kind="panel"]{border-color:rgba(59,110,245,.8)}
199
+ .lab-guide[data-kind="panel"]::after{color:#3b6ef5}
200
+
201
+ /* 侧栏控件 */
202
+ .opt{display:flex;gap:9px;align-items:flex-start;padding:8px 9px;border-radius:10px;cursor:pointer;font-size:12.5px;border:1px solid transparent}
203
+ .opt:hover{background:rgba(255,255,255,.04)}
204
+ .opt[data-on="1"]{background:rgba(13,148,136,.12);border-color:rgba(13,148,136,.45)}
205
+ .opt input{margin-top:3px;accent-color:#12a594;flex:0 0 auto}
206
+ .opt b{font-weight:600;display:block}
207
+ .opt small{display:block;color:#8b949e;font-size:11.5px;line-height:1.5;margin-top:1px}
208
+ .opt .tag{display:inline-block;margin-left:6px;font-size:10px;padding:1px 6px;border-radius:999px;background:rgba(126,231,135,.14);color:#7ee787;vertical-align:1px}
209
+ .opt .tag.warn{background:rgba(255,166,87,.14);color:#ffa657}
210
+ .opt .tag.cool{background:rgba(121,192,255,.14);color:#79c0ff}
211
+ .btnrow{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
212
+ button.act{border:1px solid #2a3038;background:#1b2230;color:#e6edf3;font:inherit;font-size:12.5px;padding:7px 13px;border-radius:10px;cursor:pointer}
213
+ button.act:hover{border-color:#3d4652}
214
+ button.act.primary{background:#0f766e;border-color:transparent;color:#fff;font-weight:600}
215
+ button.act[data-on="1"]{background:rgba(13,148,136,.18);border-color:rgba(13,148,136,.55)}
216
+ .switches{display:flex;gap:14px;flex-wrap:wrap;margin-top:11px;font-size:12px;color:#8b949e}
217
+ .switches label{display:flex;gap:6px;align-items:center;cursor:pointer}
218
+ .switches input{accent-color:#12a594}
219
+ table.spec{width:100%;border-collapse:collapse;font-size:12px;margin-top:2px}
220
+ table.spec th,table.spec td{padding:6px 4px;border-bottom:1px solid #232a33;text-align:left;vertical-align:top}
221
+ table.spec th{color:#8b949e;font-weight:500;width:88px}
222
+ table.spec td b{color:#e6edf3;font-weight:600}
223
+ .verdict{margin-top:10px;padding:9px 11px;border-radius:10px;background:rgba(126,231,135,.07);border:1px solid rgba(126,231,135,.22);font-size:12px;line-height:1.6}
224
+ .verdict b{color:#7ee787}
225
+ .verdict.warn{background:rgba(255,166,87,.07);border-color:rgba(255,166,87,.22)}
226
+ .verdict.warn b{color:#ffa657}
227
+ .verdict.cool{background:rgba(121,192,255,.07);border-color:rgba(121,192,255,.22)}
228
+ .verdict.cool b{color:#79c0ff}
229
+
230
+ /* 并排对比 */
231
+ .grid{display:none;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
232
+ .grid[data-on="1"]{display:grid}
233
+ @media (max-width:1120px){.grid[data-on="1"]{grid-template-columns:repeat(2,minmax(0,1fr))}}
234
+ .cell{background:#161b22;border:1px solid #2a3038;border-radius:12px;overflow:hidden}
235
+ .cellh{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 10px;font-size:12px;border-bottom:1px solid #232a33}
236
+ .cellh b{font-weight:600}
237
+ .cellh span{color:#6e7681;font-size:11px;font-variant-numeric:tabular-nums}
238
+ .cellstage{position:relative;height:300px;background:#fbfaf7;overflow:hidden}
239
+ .lab-dark .cellstage{background:#15161a}
240
+ /* 并排对比:整块舞台缩到 60%,让 540 宽的小窗也能完整落在窄格子里。
241
+ 缩放只影响视觉 —— 几何由 data-scale 除回本地坐标,规则与单舞台完全一致。 */
242
+ .lab-scaler{position:absolute;left:50%;top:6px;width:456px;height:296px;margin-left:-228px;overflow:hidden}
243
+ .lab-scaler .lab-stage{width:760px;min-height:486px;transform:scale(.6);transform-origin:0 0;border-radius:20px}
244
+ `
245
+
246
+ // ───────────────────────── 页面脚本(方案引擎 + 控件)─────────────────────────
247
+ const SCRIPT = String.raw`
248
+ (() => {
249
+ const $ = (sel, root) => (root || document).querySelector(sel)
250
+ const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel))
251
+ let TIME = 1 // 播放倍率:0.25 = 慢放到 1/4 速(时长 ×4)
252
+ let LOOP = false
253
+ let MODE = 'intro' // intro = 含前置;solo = 只播交接
254
+ let REDUCED = false // 强制走"减少动效"的降级形态
255
+ let busy = false
256
+ let runToken = 0
257
+
258
+ const rate = () => (REDUCED ? 2.6 : 1 / TIME) // 时长倍率:慢放时把每一段都拉长
259
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms * rate()))
260
+ /** WAAPI 包装:时长/延迟都跟着速度倍率走。 */
261
+ const anim = (el, frames, ms, easing, delay) => el.animate(frames, {
262
+ duration: Math.max(1, ms * rate()),
263
+ delay: Math.max(0, (delay || 0) * rate()),
264
+ easing: easing || 'cubic-bezier(.22,1,.36,1)',
265
+ fill: 'both',
266
+ })
267
+ const done = (a) => a.finished.catch(() => {})
268
+ const all = (list) => Promise.all(list.map(done))
269
+
270
+ // ── 几何:照真实规则算(卡片 360 右贴选区、+22;小窗 540 左贴选区、+10)──
271
+ const CARD_W = 360
272
+ const CARD_H = 42
273
+ const PANEL_W = 540
274
+ function geom(root) {
275
+ // 舞台可能被整体缩放(并排对比里 scale(.6)):getBoundingClientRect 给的是缩放后的值,
276
+ // 而 style.left/top 是在**本地坐标系**里解释的 —— 所以这里统一除回本地坐标。
277
+ const s = parseFloat(root.getAttribute('data-scale') || '1') || 1
278
+ const box = root.getBoundingClientRect()
279
+ const sel = $('.lab-sel', root)
280
+ const sb = sel.getBoundingClientRect()
281
+ const selBox = {
282
+ left: (sb.left - box.left) / s, right: (sb.right - box.left) / s,
283
+ top: (sb.top - box.top) / s, bottom: (sb.bottom - box.top) / s,
284
+ }
285
+ const card = {
286
+ left: Math.max(8, selBox.right - CARD_W), top: selBox.bottom + 22, width: CARD_W, height: CARD_H,
287
+ }
288
+ const panelEl = $('.lab-panel', root)
289
+ const panelH = panelEl.offsetHeight || 186
290
+ const panel = {
291
+ left: Math.min(Math.max(8, selBox.left), Math.max(8, box.width / s - PANEL_W - 8)),
292
+ top: selBox.bottom + 10, width: PANEL_W, height: panelH,
293
+ }
294
+ return { box, sel: selBox, card, panel }
295
+ }
296
+ const place = (el, r, extra) => {
297
+ el.style.left = r.left + 'px'
298
+ el.style.top = r.top + 'px'
299
+ if (r.width) el.style.width = r.width + 'px'
300
+ if (r.height) el.style.height = r.height + 'px'
301
+ if (extra) Object.assign(el.style, extra)
302
+ }
303
+
304
+ // ── 一个舞台的完整装配 ──
305
+ function mount(root) {
306
+ const h = { root }
307
+ h.doc = $('.lab-doc', root)
308
+ h.float = $('.lab-float', root)
309
+ h.card = $('.lab-card', root)
310
+ h.crt = $('.lab-crt', root)
311
+ h.panel = $('.lab-panel', root)
312
+ h.morph = $('.lab-morph', root)
313
+ h.morphCard = $('.lab-morphcard', root)
314
+ h.morphPanel = $('.lab-morphpanel', root)
315
+ h.ghost = $('.lab-ghost', root)
316
+ h.panelLine = $('.lab-panelline', root)
317
+ h.askbox = $('.dsh-sel-askbox', h.panel)
318
+ h.firstAsk = $('.lab-firstask', h.panel)
319
+ h.guides = { card: null, panel: null }
320
+ return h
321
+ }
322
+
323
+ /** 复位到"还没开始":浮标藏、卡片藏、小窗藏。 */
324
+ function reset(h) {
325
+ // ⚠️ 先**取消**上一轮留下的动画:WAAPI 用 fill:'both',跑完会一直占着属性
326
+ // (踩过:方案 2 把卡片 opacity 定格在 0,之后方案 4 的 CRT 卡片整个看不见)。
327
+ // 取消之后属性回到 CSS/内联值,每个方案都从同一个干净起点开始。
328
+ for (const el of [h.float, h.card, h.crt, h.panel, h.morph, h.morphCard, h.morphPanel, h.ghost, h.panelLine]) {
329
+ if (!el || typeof el.getAnimations !== 'function') continue
330
+ for (const a of el.getAnimations()) { try { a.cancel() } catch (error) { /* noop */ } }
331
+ if (el.getAnimations && el.getAnimations().length === 0) el.style.animationDuration = ''
332
+ }
333
+ const g = geom(h.root)
334
+ h.g = g
335
+ h.card.style.transition = 'none'
336
+ h.panel.style.transition = 'none'
337
+ h.card.style.transform = 'none'
338
+ h.card.style.opacity = ''
339
+ h.card.style.borderRadius = ''
340
+ h.card.removeAttribute('data-off')
341
+ h.card.style.visibility = 'hidden'
342
+ h.panel.style.transform = 'none'
343
+ h.panel.style.opacity = '0'
344
+ h.panel.style.visibility = 'hidden'
345
+ h.panel.style.borderRadius = ''
346
+ h.panel.style.filter = ''
347
+ h.crt.style.visibility = 'hidden'
348
+ h.crt.removeAttribute('data-off')
349
+ h.float.style.visibility = 'hidden'
350
+ h.float.style.opacity = ''
351
+ h.float.style.transform = ''
352
+ if (h.morph) { h.morph.hidden = true; h.morph.style.visibility = 'hidden' }
353
+ if (h.ghost) { h.ghost.hidden = true; h.ghost.style.visibility = 'hidden'; h.ghost.style.transform = 'none'; h.ghost.style.opacity = '1' }
354
+ if (h.panelLine) { h.panelLine.hidden = true; h.panelLine.style.visibility = 'hidden' }
355
+ if (h.morphPanel) h.morphPanel.style.opacity = '0'
356
+ if (h.morphCard) h.morphCard.style.opacity = '1'
357
+ if (h.firstAsk) h.firstAsk.style.opacity = ''
358
+ if (h.askbox) h.askbox.value = ''
359
+ // 参考线
360
+ const st = h.root
361
+ st.setAttribute('data-guides', st.getAttribute('data-guides') === '1' ? '1' : '0')
362
+ if (h.guides.card) h.guides.card.remove()
363
+ if (h.guides.panel) h.guides.panel.remove()
364
+ const mk = (kind, r, label) => {
365
+ const d = document.createElement('div')
366
+ d.className = 'lab-guide'
367
+ d.setAttribute('data-kind', kind)
368
+ d.setAttribute('data-label', label)
369
+ d.style.left = r.left + 'px'; d.style.top = r.top + 'px'
370
+ d.style.width = r.width + 'px'; d.style.height = r.height + 'px'
371
+ st.appendChild(d)
372
+ return d
373
+ }
374
+ h.guides.card = mk('card', g.card, '卡片 360×42')
375
+ h.guides.panel = mk('panel', g.panel, '小窗 540×' + Math.round(g.panel.height))
376
+ // 摆好各自的起点
377
+ place(h.float, { left: g.sel.right - 78, top: g.sel.top - 35, width: 0, height: 0 })
378
+ place(h.card, g.card)
379
+ place(h.panel, g.panel)
380
+ place(h.crt, { left: g.card.left, top: g.card.top + CARD_H / 2 - 1.5, width: g.card.width, height: 0 })
381
+ if (h.morph) {
382
+ place(h.morph, g.card)
383
+ h.morph.style.width = g.card.width + 'px'
384
+ h.morph.style.height = g.card.height + 'px'
385
+ h.morph.style.borderRadius = '20px'
386
+ h.morph.style.background = getComputedStyle(h.card).backgroundColor
387
+ h.morph.style.border = getComputedStyle(h.card).border
388
+ h.morph.style.boxShadow = getComputedStyle(h.card).boxShadow
389
+ h.morph.style.overflow = 'hidden'
390
+ h.morphPanel.style.width = PANEL_W + 'px'
391
+ h.morphPanel.style.height = g.panel.height + 'px'
392
+ }
393
+ if (h.ghost) {
394
+ const r = h.card.getBoundingClientRect()
395
+ const b = h.root.getBoundingClientRect()
396
+ h.ghost.style.left = (r.left - b.left + 13) + 'px'
397
+ h.ghost.style.top = (r.top - b.top + 9) + 'px'
398
+ h.ghost.style.fontSize = getComputedStyle($('.dsh-sel-vcard-box', h.card)).fontSize
399
+ }
400
+ return g
401
+ }
402
+
403
+ // ── 前置(六个方案共用):浮标浮现 → 卡片形变出来 → 逐字上屏 → 停一拍 ──
404
+ async function intro(h, stale) {
405
+ const g = h.g
406
+ const gone = () => (stale ? stale() : false)
407
+ h.float.style.visibility = 'visible'
408
+ await done(anim(h.float, [{ opacity: 0, transform: 'scale(.74) translateY(6px)' }, { opacity: 1, transform: 'none' }], 240, 'cubic-bezier(.34,1.56,.64,1)'))
409
+ if (gone()) return
410
+ await wait(220)
411
+ if (gone()) return
412
+ // 形变:先从浮标那一格出发,再走向卡片终态(与插件 openVCard 同一手法:改几何 + 过渡)
413
+ const pill = h.float.getBoundingClientRect()
414
+ const b = h.root.getBoundingClientRect()
415
+ const from = { left: pill.left - b.left, top: pill.top - b.top, width: pill.width, height: pill.height }
416
+ h.card.style.transition = 'none'
417
+ place(h.card, from, { borderRadius: '999px' })
418
+ h.card.style.visibility = 'visible'
419
+ void h.card.offsetWidth
420
+ h.card.style.transition = ''
421
+ place(h.card, g.card, { borderRadius: '20px' })
422
+ h.float.style.transition = 'opacity .16s ease'
423
+ h.float.style.opacity = '0'
424
+ await wait(320)
425
+ if (gone()) return
426
+ h.float.style.visibility = 'hidden'
427
+ // 逐字上屏(真实是输入法上屏,这里只做视觉)
428
+ const box = $('.dsh-sel-vcard-box', h.card)
429
+ const text = box.value
430
+ box.value = ''
431
+ for (let i = 1; i <= text.length; i += 1) {
432
+ box.value = text.slice(0, i)
433
+ await wait(28)
434
+ if (gone()) return
435
+ }
436
+ // 松手:真实行为是 markVCardListening(false) —— 竖条停、占位换成"直接打字也行…"、
437
+ // 卡片描边从"在听"的青转回常规灰(形变箱复制的就是这一版描边)
438
+ h.card.setAttribute('data-voiced', '0')
439
+ box.placeholder = '直接打字也行…'
440
+ await wait(420)
441
+ }
442
+
443
+ // ── 六个方案 ──
444
+ const VARIANTS = [
445
+ {
446
+ id: 'now', name: '0 · 现状(对照)', tag: '对照组', tagKind: 'warn',
447
+ line: '卡片瞬间消失、小窗瞬间出现。零过渡 —— 也是现在线上的行为。',
448
+ spec: [['时长', '<b>0ms</b>(瞬切)'], ['缓动', '—'], ['动了什么', '卡片 <code>display:none</code>;小窗 <code>display:flex</code>'],
449
+ ['实现代价', '<b>零</b>(已经是现状)'], ['代价/风险', '尺寸与对齐同时跳:360 右贴选区 → 540 左贴选区']],
450
+ verdict: '只作为对照。要改的就是它。', kind: 'warn',
451
+ reduced: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; h.panel.style.opacity = '1' },
452
+ run: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; h.panel.style.opacity = '1' },
453
+ },
454
+ {
455
+ id: 'fade', name: '1 · 面板淡入', tag: '最省', tagKind: '',
456
+ line: '卡片立刻收掉,小窗原地淡入(轻微放大 + 上浮 6px)。不追求"连续",只把"啪一下"抹掉。',
457
+ spec: [['时长', '<b>180ms</b>'], ['缓动', 'cubic-bezier(.22,1,.36,1)'],
458
+ ['动了什么', '小窗 <code>opacity</code> + <code>transform</code>(scale .965 → 1、translateY 6 → 0)'],
459
+ ['实现代价', '<b>极低</b>:一个 <code>data-pop</code> 类 + 一段 keyframes'], ['代价/风险', '卡片与小窗仍是两次独立变化,中段有"两个都不在"的一瞬']],
460
+ verdict: '性价比最高的一档:几乎零风险,观感立刻从"跳"变成"淡入"。', kind: '',
461
+ reduced: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; await done(anim(h.panel, [{ opacity: 0 }, { opacity: 1 }], 120, 'ease')) },
462
+ run: async (h, stale) => {
463
+ await wait(70)
464
+ if (stale && stale()) return
465
+ h.card.style.visibility = 'hidden'
466
+ h.panel.style.visibility = 'visible'
467
+ await done(anim(h.panel, [{ opacity: 0, transform: 'scale(.965) translateY(6px)' }, { opacity: 1, transform: 'none' }], 180))
468
+ },
469
+ },
470
+ {
471
+ id: 'grow', name: '2 · 缩放长出', tag: '已实现', tagKind: 'cool',
472
+ line: '小窗从"卡片那一格"缩放着长出来:起手就是卡片的矩形,260ms 长到自己的位置与大小。只动 transform。',
473
+ spec: [['时长', '<b>260ms</b>'], ['缓动', 'cubic-bezier(.2,.8,.2,1)(与插件"浮标→卡片"同一族)'],
474
+ ['动了什么', '小窗 <code>transform: translate+scale</code>(+ 圆角 20 → 16);卡片 90ms 淡出'],
475
+ ['实现代价', '<b>低</b>:量两个矩形,写一条 WAAPI/CSS 动画;<b>只碰合成器属性</b>'],
476
+ ['代价/风险', '起手那几帧内容被压扁(缩放态);内容一开始就是最终排版,不会逐帧重排']],
477
+ verdict: '视觉上最顺、代价又低的一档 —— 用户选定了它,已按这个方案落到插件里(260ms,只动 transform)。', kind: 'cool',
478
+ reduced: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; await done(anim(h.panel, [{ opacity: 0 }, { opacity: 1 }], 120, 'ease')) },
479
+ run: async (h, stale) => {
480
+ const g = h.g
481
+ const sx = g.card.width / g.panel.width
482
+ const sy = g.card.height / g.panel.height
483
+ const dx = g.card.left - g.panel.left
484
+ const dy = g.card.top - g.panel.top
485
+ h.panel.style.visibility = 'visible'
486
+ const a = anim(h.panel, [
487
+ { opacity: .85, transform: 'translate(' + dx + 'px,' + dy + 'px) scale(' + sx + ',' + sy + ')', borderRadius: '20px' },
488
+ { opacity: 1, transform: 'none', borderRadius: '16px' },
489
+ ], 260, 'cubic-bezier(.2,.8,.2,1)')
490
+ const b = anim(h.card, [{ opacity: 1 }, { opacity: 0 }], 90, 'linear')
491
+ await Promise.all([done(a), done(b)])
492
+ if (stale && stale()) return
493
+ h.card.style.visibility = 'hidden'
494
+ },
495
+ },
496
+ {
497
+ id: 'morph', name: '3 · 几何补间', tag: '最连续', tagKind: '',
498
+ line: '一个盒子从卡片矩形真的"长"成小窗矩形:宽/高/左/上/圆角一起补间,两套内容按最终宽度钉死、裁切换。',
499
+ spec: [['时长', '<b>320ms</b>'], ['缓动', 'cubic-bezier(.2,.8,.2,1)(与插件既有的"浮标→卡片"同一族)'],
500
+ ['动了什么', '<code>left/top/width/height/border-radius</code> + 内容交叉淡入'],
501
+ ['实现代价', '<b>中</b>:要一个形变容器 + 两套内容;手法插件里已有(openVCard 就是这么做的)'],
502
+ ['代价/风险', '每帧改的是<b>布局属性</b>(非合成器友好),长内容时可能掉帧;内容必须钉死宽度,否则逐帧重排']],
503
+ verdict: '最"连续"的一档,也最像原生形变;代价是布局动画 + 内容裁切换的实现量。', kind: '',
504
+ reduced: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; await done(anim(h.panel, [{ opacity: 0 }, { opacity: 1 }], 120, 'ease')) },
505
+ run: async (h, stale) => {
506
+ const g = h.g
507
+ h.morph.hidden = false
508
+ h.morph.style.visibility = 'visible'
509
+ h.card.style.visibility = 'hidden'
510
+ const a = anim(h.morph, [
511
+ { left: g.card.left + 'px', top: g.card.top + 'px', width: g.card.width + 'px', height: g.card.height + 'px', borderRadius: '20px' },
512
+ { left: g.panel.left + 'px', top: g.panel.top + 'px', width: g.panel.width + 'px', height: g.panel.height + 'px', borderRadius: '16px' },
513
+ ], 320, 'cubic-bezier(.2,.8,.2,1)')
514
+ // 内容交叉换:卡片那套早退(0–90ms),小窗那套在 70–210ms 之间进来 ——
515
+ // 都落在形变的前 2/3 里,免得盒子长大了里面还是空的(前几版就栽在这)
516
+ const cross = anim(h.morphCard, [{ opacity: 1 }, { opacity: 0 }], 90, 'linear')
517
+ const crossIn = anim(h.morphPanel, [{ opacity: 0 }, { opacity: 1 }], 140, 'ease', 70)
518
+ await all([a, cross, crossIn])
519
+ if (stale && stale()) return
520
+ h.panel.style.visibility = 'visible'
521
+ h.panel.style.opacity = '1'
522
+ h.morph.style.visibility = 'hidden'
523
+ },
524
+ },
525
+ {
526
+ id: 'crt', name: '4 · CRT 交接', tag: '最贴世界观', tagKind: '',
527
+ line: '卡片照插件既有的"老电视关机"塌成一条线,小窗再以一条亮线"开机"展开 —— 和取消时的关机动画同一套语言。',
528
+ spec: [['时长', '<b>≈560ms</b>(关机 460 + 开机 180,有重叠)'], ['缓动', 'cubic-bezier(.3,0,.2,1)(沿用既有 CRT 曲线)'],
529
+ ['动了什么', '卡片 <code>data-off="a"</code>(既有动画)+ 亮线 <code>scaleX</code> + 小窗淡入'],
530
+ ['实现代价', '<b>低</b>:关机动画已存在,只需给发送路径放行 + 一条开机亮线'],
531
+ ['代价/风险', '总时长最长;发送是"高频动作",每次都等 0.5 秒可能嫌慢(可缩短到 ~380ms)']],
532
+ verdict: '风格最统一的一档(和 Esc 取消是同一套语言),但发送是高频动作,我建议压缩到 ~380ms 再考虑。', kind: '',
533
+ reduced: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; await done(anim(h.panel, [{ opacity: 0 }, { opacity: 1 }], 120, 'ease')) },
534
+ run: async (h, stale) => {
535
+ const g = h.g
536
+ // 卡片此刻是"看得见"的(真实场景里它一直在画面上),CRT 是把它塌掉 —— 不是从无到无
537
+ h.card.style.visibility = 'visible'
538
+ h.crt.style.visibility = 'visible'
539
+ // 关键帧时长写死在 CSS 里(460ms):慢放时把它一起拉长,否则动画早就跑完了还在等
540
+ const ms = 460 * rate() + 'ms'
541
+ h.card.style.animationDuration = ms
542
+ h.crt.style.animationDuration = ms
543
+ for (const seg of $$('.dsh-sel-crtseg, .dsh-sel-crtspark', h.crt)) seg.style.animationDuration = ms
544
+ h.card.setAttribute('data-off', 'a')
545
+ h.crt.setAttribute('data-off', 'a')
546
+ await wait(470)
547
+ if (stale && stale()) return
548
+ h.card.style.animationDuration = ''
549
+ h.crt.style.animationDuration = ''
550
+ h.card.style.visibility = 'hidden'
551
+ h.card.removeAttribute('data-off')
552
+ h.crt.style.visibility = 'hidden'
553
+ h.crt.removeAttribute('data-off')
554
+ // 小窗"开机":一条亮线从中间横向展开,随后小窗淡入
555
+ h.panel.style.visibility = 'visible'
556
+ h.panelLine.hidden = false
557
+ h.panelLine.style.visibility = 'visible'
558
+ h.panelLine.style.left = g.panel.left + 'px'
559
+ h.panelLine.style.top = (g.panel.top + g.panel.height / 2 - 1) + 'px'
560
+ h.panelLine.style.width = g.panel.width + 'px'
561
+ const a = anim(h.panelLine, [{ transform: 'scaleX(.02)', opacity: .95 }, { transform: 'scaleX(1)', opacity: .95 }, { transform: 'scaleX(1)', opacity: 0 }], 240, 'cubic-bezier(.3,0,.2,1)')
562
+ const b = anim(h.panel, [{ opacity: 0, filter: 'brightness(1.5)' }, { opacity: 1, filter: 'brightness(1)' }], 180, 'ease', 60)
563
+ await all([a, b])
564
+ if (stale && stale()) return
565
+ h.panelLine.style.visibility = 'hidden'
566
+ },
567
+ },
568
+ {
569
+ id: 'carry', name: '5 · 文字搬家', tag: '最有语义', tagKind: 'cool',
570
+ line: '把"你说的那句话"复制一份,从小卡片飞进小窗、落成第一条追问 —— 强调"这句话变成了问题",而不是"两个框换了换"。',
571
+ spec: [['时长', '<b>≈420ms</b>'], ['缓动', 'cubic-bezier(.3,.9,.25,1)'],
572
+ ['动了什么', '一个文字副本 <code>translate+scale</code> 飞到追问气泡位置;卡片 140ms 收掉;小窗 170ms 淡入'],
573
+ ['实现代价', '<b>中高</b>:要克隆一段文字 + 量气泡位置;好处是只用 transform/opacity'],
574
+ ['代价/风险', '小窗里的第一条追问是"后出现"的,若内容很长会有一拍空窗;文字副本与真实输入框的字号必须对齐']],
575
+ verdict: '语义最好的一档("我说的这句变成了追问"),实现最重。若只想改观感,选 2;想要一个记忆点,选它。', kind: 'cool',
576
+ reduced: async (h, stale) => { if (stale && stale()) return; h.card.style.visibility = 'hidden'; h.panel.style.visibility = 'visible'; await done(anim(h.panel, [{ opacity: 0 }, { opacity: 1 }], 120, 'ease')) },
577
+ run: async (h, stale) => {
578
+ const g = h.g
579
+ const b = h.root.getBoundingClientRect()
580
+ // 终点 = 小窗里第一条追问气泡的位置(先把气泡藏起来,等文字飞到了再显形)
581
+ h.panel.style.visibility = 'visible'
582
+ h.panel.style.opacity = '0'
583
+ h.firstAsk.style.opacity = '0' // 气泡等文字飞到了再显形(否则同一句话会同时出现两次)
584
+ const target = h.firstAsk.getBoundingClientRect()
585
+ const askStyle = getComputedStyle(h.firstAsk)
586
+ const ghost = h.ghost
587
+ ghost.hidden = false
588
+ ghost.style.visibility = 'visible'
589
+ const gb = ghost.getBoundingClientRect()
590
+ // 终点 = 气泡里**文字**的原点(含内边距),这样落点与气泡里的字重合
591
+ const dx = (target.left - b.left + (parseFloat(askStyle.paddingLeft) || 0)) - (gb.left - b.left)
592
+ const dy = (target.top - b.top + (parseFloat(askStyle.paddingTop) || 0)) - (gb.top - b.top)
593
+ const boxFont = parseFloat(getComputedStyle($('.dsh-sel-vcard-box', h.card)).fontSize) || 13
594
+ const targetFont = parseFloat(getComputedStyle(h.firstAsk).fontSize) || 13
595
+ const sc = targetFont / boxFont
596
+ const fly = anim(ghost, [
597
+ { transform: 'none', opacity: 1 },
598
+ { transform: 'translate(' + dx + 'px,' + dy + 'px) scale(' + sc + ')', opacity: .9 },
599
+ ], 300, 'cubic-bezier(.3,.9,.25,1)')
600
+ const collapse = anim(h.card, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'scale(.97)' }], 140, 'ease')
601
+ const fadeIn = anim(h.panel, [{ opacity: 0, transform: 'translateY(4px)' }, { opacity: 1, transform: 'none' }], 180, 'ease-out', 90)
602
+ await Promise.all([done(fly), done(collapse), done(fadeIn)])
603
+ if (stale && stale()) return
604
+ h.card.style.visibility = 'hidden'
605
+ h.panel.style.opacity = '1'
606
+ // 落定:追问气泡显形,文字副本退场
607
+ await done(anim(h.firstAsk, [{ opacity: 0 }, { opacity: 1 }], 120, 'ease'))
608
+ if (stale && stale()) return
609
+ ghost.style.visibility = 'hidden'
610
+ },
611
+ },
612
+ ]
613
+
614
+ // ── 播放 ──
615
+ async function play(h, variant, opts) {
616
+ const token = ++runToken
617
+ const stale = () => token !== runToken
618
+ reset(h)
619
+ if (opts && opts.intro) {
620
+ await intro(h, stale)
621
+ if (stale()) return
622
+ }
623
+ if (stale()) return
624
+ const fn = REDUCED ? variant.reduced : variant.run
625
+ await fn(h, stale)
626
+ }
627
+ /** 让"正在跑的那一次"作废(点别的方案、外部脚本接管时用)。 */
628
+ function stopPlay() { runToken += 1 }
629
+
630
+ // ── 单舞台 ──
631
+ const stage = $('#stage')
632
+ const h = mount(stage)
633
+ let current = VARIANTS[2]
634
+
635
+ function paintSide() {
636
+ $$('#variants .opt').forEach((el) => el.setAttribute('data-on', el.dataset.id === current.id ? '1' : '0'))
637
+ $('#vname').textContent = current.name
638
+ $('#vline').textContent = current.line
639
+ $('#vspec').innerHTML = current.spec.map((row) => '<tr><th>' + row[0] + '</th><td>' + row[1] + '</td></tr>').join('')
640
+ const v = $('#vverdict')
641
+ v.className = 'verdict' + (current.kind ? ' ' + current.kind : '')
642
+ v.innerHTML = '<b>判断 · </b>' + current.verdict
643
+ }
644
+
645
+ async function replay(intro) {
646
+ if (busy) return
647
+ busy = true
648
+ $('#play').disabled = true
649
+ try {
650
+ do {
651
+ await play(h, current, { intro: !!intro })
652
+ if (!LOOP) break
653
+ await wait(700)
654
+ } while (LOOP && !$('#grid').matches('[data-on="1"]'))
655
+ } finally {
656
+ busy = false
657
+ $('#play').disabled = false
658
+ }
659
+ }
660
+
661
+ // 变体列表
662
+ $('#variants').innerHTML = VARIANTS.map((v) => (
663
+ '<label class="opt" data-id="' + v.id + '" data-on="' + (v.id === current.id ? '1' : '0') + '">' +
664
+ '<input type="radio" name="variant" value="' + v.id + '"' + (v.id === current.id ? ' checked' : '') + '>' +
665
+ '<span><b>' + v.name + (v.tag ? '<span class="tag ' + (v.tagKind || '') + '">' + v.tag + '</span>' : '') + '</b>' +
666
+ '<small>' + v.line + '</small></span></label>'
667
+ )).join('')
668
+ $('#variants').addEventListener('change', (e) => {
669
+ const v = VARIANTS.find((x) => x.id === e.target.value)
670
+ if (!v) return
671
+ current = v
672
+ paintSide()
673
+ stopPlay()
674
+ if ($('#grid').matches('[data-on="1"]')) paintGrid()
675
+ else replay(MODE === 'intro')
676
+ })
677
+ $$('#variants .opt').forEach((el) => el.addEventListener('click', () => {
678
+ el.querySelector('input').checked = true
679
+ el.querySelector('input').dispatchEvent(new Event('change', { bubbles: true }))
680
+ }))
681
+
682
+ $('#play').addEventListener('click', () => replay(MODE === 'intro'))
683
+ $('#solo').addEventListener('click', () => replay(false))
684
+ $('#loop').addEventListener('click', () => {
685
+ LOOP = !LOOP
686
+ $('#loop').setAttribute('data-on', LOOP ? '1' : '0')
687
+ if (LOOP && !busy) replay(MODE === 'intro')
688
+ })
689
+ $('#mode').addEventListener('click', () => {
690
+ MODE = MODE === 'intro' ? 'solo' : 'intro'
691
+ $('#mode').setAttribute('data-on', MODE === 'intro' ? '1' : '0')
692
+ $('#mode').textContent = MODE === 'intro' ? '含前置(浮标→卡片→发送)' : '只播交接'
693
+ })
694
+ $('#speed').addEventListener('change', (e) => { TIME = parseFloat(e.target.value) || 1 })
695
+ $('#reduced').addEventListener('change', (e) => { REDUCED = e.target.checked; replay(MODE === 'intro') })
696
+ $('#guides').addEventListener('change', (e) => {
697
+ stage.setAttribute('data-guides', e.target.checked ? '1' : '0')
698
+ })
699
+ $('#theme').addEventListener('change', (e) => {
700
+ document.body.classList.toggle('lab-dark', e.target.checked)
701
+ $$('.dsh-sel-panel').forEach((p) => p.setAttribute('data-theme', e.target.checked ? 'dark' : 'light'))
702
+ $$('.dsh-sel-layer').forEach((l) => l.setAttribute('data-theme', e.target.checked ? 'dark' : 'light'))
703
+ })
704
+
705
+ // ── 并排对比:六个小舞台同时播 ──
706
+ let gridHandles = []
707
+ function buildGrid() {
708
+ const grid = $('#grid')
709
+ grid.innerHTML = VARIANTS.map((v) => (
710
+ '<div class="cell"><div class="cellh"><b>' + v.name + '</b><span data-role="t">—</span></div>' +
711
+ '<div class="cellstage"><div class="lab-scaler">' +
712
+ '<div class="lab-stage" data-guides="0" data-scale="0.6">' + STAGE_HTML + '</div>' +
713
+ '</div></div></div>'
714
+ )).join('')
715
+ gridHandles = $$('.cellstage .lab-stage', grid).map((root, i) => ({ h: mount(root), v: VARIANTS[i] }))
716
+ }
717
+ async function paintGrid() {
718
+ runToken += 1
719
+ const token = runToken
720
+ const jobs = gridHandles.map(async (g) => {
721
+ const cell = g.h.root.parentNode
722
+ const t0 = performance.now()
723
+ await play(g.h, g.v, { intro: true })
724
+ if (token === runToken) {
725
+ cell.querySelector('[data-role="t"]').textContent = Math.round(performance.now() - t0) + 'ms(含前置)'
726
+ }
727
+ })
728
+ await Promise.all(jobs)
729
+ }
730
+ $('#grid-toggle').addEventListener('click', () => {
731
+ const grid = $('#grid')
732
+ const on = !grid.matches('[data-on="1"]')
733
+ grid.setAttribute('data-on', on ? '1' : '0')
734
+ $('#grid-toggle').setAttribute('data-on', on ? '1' : '0')
735
+ $('#stage').style.display = on ? 'none' : ''
736
+ if (on) {
737
+ if (!gridHandles.length) buildGrid()
738
+ paintGrid()
739
+ } else {
740
+ runToken += 1
741
+ replay(MODE === 'intro')
742
+ }
743
+ })
744
+
745
+ // 给外部脚本(抓帧、回归)一个确定的驱动口:不走 UI、不排"忙"队列。
746
+ window.__labPlay = (id, opts) => {
747
+ const v = VARIANTS.find((x) => x.id === id)
748
+ if (!v) return null
749
+ current = v
750
+ paintSide()
751
+ stopPlay()
752
+ return play(h, v, opts || { intro: false })
753
+ }
754
+ window.__labVariantIds = VARIANTS.map((v) => v.id)
755
+
756
+ paintSide()
757
+ replay(true)
758
+ })()
759
+ `
760
+
761
+ const BODY = `<h1>临时输入框 → 解读小窗 · 交接动效实验室</h1>
762
+ <p class="sub">
763
+ 场景:输入法开麦时弹出<b>临时输入框</b>(语音卡),说完发送 → <b>解读小窗</b>。现在这两步是<b>瞬切</b>
764
+ (卡片 <code>display:none</code>、小窗 <code>display:flex</code>,中间零过渡),而且几何本身也换了:
765
+ 卡片 360 宽、<b>右缘</b>贴选区右缘;小窗 540 宽、<b>左缘</b>贴选区左缘 —— 所以看起来是"跳"。
766
+ 下面六个方案都按<b>真实样式</b>(CSS 直接从 lib/client.js 抽)与<b>真实几何</b>跑,可以逐个对比。
767
+ </p>
768
+ <div class="wrap">
769
+ <div>
770
+ <div class="lab-stage" id="stage" data-guides="0" data-scale="1">${DOC}${ACTORS(true)}</div>
771
+ <div class="grid" id="grid" data-on="0"></div>
772
+ </div>
773
+ <aside>
774
+ <div class="card">
775
+ <h3>方案 <small>点一个即可重播</small></h3>
776
+ <div id="variants"></div>
777
+ <div class="btnrow">
778
+ <button class="act primary" id="play">重播</button>
779
+ <button class="act" id="solo">只看交接</button>
780
+ <button class="act" id="loop" data-on="0">循环</button>
781
+ <button class="act" id="mode" data-on="1">含前置(浮标→卡片→发送)</button>
782
+ </div>
783
+ <div class="switches">
784
+ <label>速度
785
+ <select id="speed" style="background:#1b2230;color:#e6edf3;border:1px solid #2a3038;border-radius:8px;padding:3px 6px;font:inherit">
786
+ <option value="1">1×</option><option value="0.5">0.5×</option><option value="0.25">0.25× 慢放</option><option value="0.1">0.1× 极慢</option>
787
+ </select>
788
+ </label>
789
+ <label><input type="checkbox" id="guides">几何参考线</label>
790
+ <label><input type="checkbox" id="reduced">减少动效形态</label>
791
+ <label><input type="checkbox" id="theme">深色宿主</label>
792
+ </div>
793
+ <div class="btnrow"><button class="act" id="grid-toggle" data-on="0">并排对比(六个一起播)</button></div>
794
+ </div>
795
+ <div class="card">
796
+ <h3><span id="vname">—</span> <small>参数</small></h3>
797
+ <p class="hint" id="vline" style="margin:0 0 6px"></p>
798
+ <table class="spec"><tbody id="vspec"></tbody></table>
799
+ <div class="verdict" id="vverdict"></div>
800
+ <p class="hint">
801
+ 说明:卡片与发送键走的是<b>同一条</b>交接(手动点发送、说完自动发送,都进 <code>sendVCard()</code>),
802
+ 所以这套动画一处生效、两条路都受益。「减少动效形态」= 系统开了"减少动态效果"时各自的降级形态
803
+ (插件里已有这条规矩:<code>prefers-reduced-motion</code> 下关掉形变与 CRT)。
804
+ </p>
805
+ </div>
806
+ </aside>
807
+ </div>`
808
+
809
+ const html = `<!doctype html>
810
+ <html lang="zh-CN">
811
+ <head>
812
+ <meta charset="utf-8">
813
+ <meta name="viewport" content="width=device-width,initial-scale=1">
814
+ <title>临时输入框 → 解读小窗 · 交接动效实验室</title>
815
+ <style>
816
+ ${THEME_VARS}
817
+ ${THEME_VARS_DARK}
818
+ ${SHELL_CSS}
819
+ /* ── 以下为真实插件 CSS(从 lib/client.js 抽出,未手抄) ── */
820
+ ${extractCss()}
821
+ </style>
822
+ </head>
823
+ <body>
824
+ ${BODY}
825
+ <script>${'const STAGE_HTML = ' + JSON.stringify(DOC + ACTORS(true)) + ';\n'}${SCRIPT}</script>
826
+ </body>
827
+ </html>
828
+ `
829
+
830
+ mkdirSync(resolve(ROOT, 'docs'), { recursive: true })
831
+ const outPath = resolve(ROOT, 'docs', 'vcard-to-panel-lab.html')
832
+ writeFileSync(outPath, html, 'utf8')
833
+ console.log(`✓ docs/vcard-to-panel-lab.html ${(html.length / 1024).toFixed(1)} kB`)
834
+ console.log(` CSS 来源:lib/client.js(未手抄);方案数:6`)