flower-trellis 0.4.0-beta.2 → 0.4.0-beta.4

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.
Files changed (65) hide show
  1. package/README.md +4 -0
  2. package/enhancements/MANIFEST.json +21 -3
  3. package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +377 -0
  4. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +2102 -0
  5. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +767 -0
  6. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +255 -0
  7. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +258 -0
  8. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package-lock.json +59 -0
  9. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +13 -0
  10. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +722 -0
  11. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +274 -0
  12. package/enhancements/common/.common/.claude/skills/craft-slides/SKILL.md +178 -0
  13. package/enhancements/common/.common/.claude/skills/craft-slides/reference/syntax.md +163 -0
  14. package/enhancements/common/.common/.claude/skills/craft-slides/scripts/slidev.sh +345 -0
  15. package/enhancements/common/.common/.claude/skills/craft-slides/templates/slides.apple-basic.md +113 -0
  16. package/enhancements/common/.common/.claude/skills/craft-slides/templates/slides.dracula.md +111 -0
  17. package/enhancements/common/.common/.claude/skills/craft-slides/templates/slides.geist.md +112 -0
  18. package/enhancements/common/.common/.claude/skills/craft-slides/templates/slides.md +103 -0
  19. package/enhancements/common/.common/.claude/skills/craft-slides/templates/slides.nord.md +123 -0
  20. package/enhancements/common/.common/.claude/skills/craft-slides/templates/slides.seriph.md +115 -0
  21. package/enhancements/common/.common/.claude/skills/humanize-writing/SKILL.md +628 -0
  22. package/enhancements/common/.common/.claude/skills/open-idea/SKILL.md +103 -0
  23. package/enhancements/common/.common/.claude/skills/open-idea/scripts/open_idea.py +657 -0
  24. package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/SKILL.md +47 -0
  25. package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/env/push.env.example +8 -0
  26. package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/scripts/run.sh +27 -0
  27. package/enhancements/common/.common/.claude/skills/torrent-analyze/SKILL.md +190 -0
  28. package/enhancements/common/.common/.claude/skills/torrent-analyze/config/default.env +39 -0
  29. package/enhancements/common/.common/.claude/skills/torrent-analyze/scripts/torrent_analyze.py +910 -0
  30. package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +377 -0
  31. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +2102 -0
  32. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +767 -0
  33. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +255 -0
  34. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +258 -0
  35. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package-lock.json +59 -0
  36. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +13 -0
  37. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +722 -0
  38. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +274 -0
  39. package/enhancements/common/.common/.codex/skills/craft-slides/SKILL.md +178 -0
  40. package/enhancements/common/.common/.codex/skills/craft-slides/reference/syntax.md +163 -0
  41. package/enhancements/common/.common/.codex/skills/craft-slides/scripts/slidev.sh +345 -0
  42. package/enhancements/common/.common/.codex/skills/craft-slides/templates/slides.apple-basic.md +113 -0
  43. package/enhancements/common/.common/.codex/skills/craft-slides/templates/slides.dracula.md +111 -0
  44. package/enhancements/common/.common/.codex/skills/craft-slides/templates/slides.geist.md +112 -0
  45. package/enhancements/common/.common/.codex/skills/craft-slides/templates/slides.md +103 -0
  46. package/enhancements/common/.common/.codex/skills/craft-slides/templates/slides.nord.md +123 -0
  47. package/enhancements/common/.common/.codex/skills/craft-slides/templates/slides.seriph.md +115 -0
  48. package/enhancements/common/.common/.codex/skills/humanize-writing/SKILL.md +628 -0
  49. package/enhancements/common/.common/.codex/skills/open-idea/SKILL.md +103 -0
  50. package/enhancements/common/.common/.codex/skills/open-idea/agents/openai.yaml +4 -0
  51. package/enhancements/common/.common/.codex/skills/open-idea/scripts/open_idea.py +657 -0
  52. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/SKILL.md +126 -0
  53. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/agents/openai.yaml +4 -0
  54. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/env/push.env.example +8 -0
  55. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/scripts/fix_exported_account_json.py +890 -0
  56. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/scripts/run.sh +26 -0
  57. package/enhancements/common/.common/.codex/skills/torrent-analyze/SKILL.md +190 -0
  58. package/enhancements/common/.common/.codex/skills/torrent-analyze/config/default.env +39 -0
  59. package/enhancements/common/.common/.codex/skills/torrent-analyze/scripts/torrent_analyze.py +910 -0
  60. package/package.json +1 -1
  61. package/src/cli.js +5 -0
  62. package/src/commands/skill.js +261 -0
  63. package/src/lib/apply-enhancements.js +5 -23
  64. package/src/lib/enhancement-catalog.js +86 -0
  65. package/src/lib/skill-catalog.js +444 -0
@@ -0,0 +1,2102 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RECORDER · Browser Telemetry</title>
7
+ <style>
8
+ /* ============================================================
9
+ RESET
10
+ ============================================================ */
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+
13
+ /* ============================================================
14
+ DESIGN TOKENS — Theme-aware (Dark default + Light variant)
15
+
16
+ Industrial telemetry palette. Sharp borders, warm surfaces,
17
+ amber + cyan dual-tone signaling. Both themes preserve the
18
+ same instrument-like character.
19
+
20
+ 颜色用 RGB 三元组形式(--amber-rgb 等)存储,这样在 rgba()
21
+ 表达式里也能通过 var() 引用,主题切换时颜色透明度变体也自动跟进。
22
+ ============================================================ */
23
+ :root {
24
+ /* === DARK MODE (default) === */
25
+ /* Surfaces — layered warm darks */
26
+ --bg-0: #0a0a0a;
27
+ --bg-1: #0f0f0f;
28
+ --bg-2: #141414;
29
+ --bg-3: #1a1a1a;
30
+ --bg-hover: #1f1f1f;
31
+ --bg-active: #252525;
32
+
33
+ /* Lines — three weights of subtle borders */
34
+ --line: #1c1c1c;
35
+ --line-strong: #2a2a2a;
36
+ --line-bright: #3a3a3a;
37
+
38
+ /* Text — warm off-white descending */
39
+ --fg-0: #e8e6e1;
40
+ --fg-1: #b8b6b1;
41
+ --fg-2: #7a7872;
42
+ --fg-3: #4a4844;
43
+
44
+ /* Accent colors as RGB triplets (用于 rgba(var(--xxx-rgb), opacity)) */
45
+ --amber-rgb: 255, 168, 56;
46
+ --cyan-rgb: 77, 208, 225;
47
+ --green-rgb: 92, 209, 129;
48
+ --red-rgb: 241, 90, 90;
49
+ --yellow-rgb: 255, 213, 79;
50
+ --purple-rgb: 186, 137, 224;
51
+
52
+ /* Surface overlay tint —— 在 dark mode 是白色微叠加,light mode 是黑色微叠加
53
+ 用于 hover 高亮、点阵纹理、细分割线等 */
54
+ --overlay-rgb: 255, 255, 255;
55
+
56
+ /* btn-primary 文字色:dark 模式下 amber 按钮配深字最清晰 */
57
+ --btn-primary-fg: #0a0a0a;
58
+
59
+ /* Dim 变体(用于次级文本/border) */
60
+ --amber-dim: #c87f2c;
61
+ --cyan-dim: #3a9eab;
62
+ --green-dim: #46a566;
63
+ --red-dim: #b94545;
64
+
65
+ /* 顶部 carrier signal 透明度 */
66
+ --carrier-opacity: 0.35;
67
+ --toast-shadow: rgba(0, 0, 0, 0.5);
68
+
69
+ /* === Derived named colors (在 component CSS 里直接用) === */
70
+ --amber: rgb(var(--amber-rgb));
71
+ --cyan: rgb(var(--cyan-rgb));
72
+ --green: rgb(var(--green-rgb));
73
+ --red: rgb(var(--red-rgb));
74
+ --yellow: rgb(var(--yellow-rgb));
75
+ --purple: rgb(var(--purple-rgb));
76
+ --amber-glow: rgba(var(--amber-rgb), 0.18);
77
+
78
+ /* Typography */
79
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
80
+ --font-mono: ui-monospace, "SF Mono", "Menlo", "Cascadia Code", Consolas, "Liberation Mono", monospace;
81
+
82
+ /* Spacing rhythm */
83
+ --space-0: 2px;
84
+ --space-1: 4px;
85
+ --space-2: 8px;
86
+ --space-3: 12px;
87
+ --space-4: 16px;
88
+ --space-5: 24px;
89
+ --space-6: 32px;
90
+ }
91
+
92
+ /* === LIGHT MODE OVERRIDE ===
93
+ 暖白纸张感的明亮模式。accent 用更深的色相保证对比度,
94
+ 保持和暗色模式同样的工业仪表盘气质。 */
95
+ :root[data-theme="light"] {
96
+ --bg-0: #fcfbf7; /* 暖白主背景 */
97
+ --bg-1: #ffffff; /* 纯白面板 */
98
+ --bg-2: #f5f3ee; /* 浅米色次级面板 */
99
+ --bg-3: #ebe8e0; /* 三级面板(chip 背景等) */
100
+ --bg-hover: #f0ede5;
101
+ --bg-active: #e5e2d8;
102
+
103
+ --line: #ebe8e0;
104
+ --line-strong: #d4d0c6;
105
+ --line-bright: #a8a39a;
106
+
107
+ --fg-0: #1a1612; /* 深棕黑,warm 配色 */
108
+ --fg-1: #4a4540;
109
+ --fg-2: #7a756e;
110
+ --fg-3: #b0aba0;
111
+
112
+ /* Light mode 用更深的 accent,保证 4.5:1+ 对比度 */
113
+ --amber-rgb: 196, 110, 14; /* #c46e0e */
114
+ --cyan-rgb: 14, 116, 144; /* #0e7490 */
115
+ --green-rgb: 21, 128, 61; /* #15803d */
116
+ --red-rgb: 185, 28, 28; /* #b91c1c */
117
+ --yellow-rgb: 161, 98, 7; /* #a16207 */
118
+ --purple-rgb: 126, 34, 206; /* #7e22ce */
119
+
120
+ /* 在白底上用深色叠加 */
121
+ --overlay-rgb: 0, 0, 0;
122
+
123
+ /* amber 按钮在 light mode 下是深 amber,文字要白 */
124
+ --btn-primary-fg: #ffffff;
125
+
126
+ --amber-dim: #a05a0a;
127
+ --cyan-dim: #0a5e75;
128
+ --green-dim: #126638;
129
+ --red-dim: #991717;
130
+
131
+ --carrier-opacity: 0.55;
132
+ --toast-shadow: rgba(26, 22, 18, 0.18);
133
+ }
134
+
135
+ /* ============================================================
136
+ BASE
137
+ ============================================================ */
138
+ html, body { height: 100%; overflow: hidden; }
139
+ body {
140
+ background: var(--bg-0);
141
+ color: var(--fg-0);
142
+ font-family: var(--font-sans);
143
+ font-size: 12px;
144
+ line-height: 1.45;
145
+ -webkit-font-smoothing: antialiased;
146
+ -moz-osx-font-smoothing: grayscale;
147
+ text-rendering: geometricPrecision;
148
+ font-variant-numeric: tabular-nums;
149
+
150
+ display: grid;
151
+ grid-template-rows: auto auto auto auto 1fr auto;
152
+ height: 100vh;
153
+ }
154
+
155
+ /* Carrier signal — 1px amber bar at the very top, faint pulse */
156
+ body::before {
157
+ content: '';
158
+ position: fixed;
159
+ top: 0; left: 0; right: 0;
160
+ height: 1px;
161
+ background: var(--amber);
162
+ opacity: var(--carrier-opacity);
163
+ z-index: 1000;
164
+ animation: carrierWave 4s ease-in-out infinite;
165
+ pointer-events: none;
166
+ }
167
+ @keyframes carrierWave {
168
+ 0%, 100% { opacity: calc(var(--carrier-opacity) * 0.6); }
169
+ 50% { opacity: calc(var(--carrier-opacity) * 1.4); }
170
+ }
171
+
172
+ /* Scrollbars — minimal, instrument-style */
173
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
174
+ ::-webkit-scrollbar-track { background: transparent; }
175
+ ::-webkit-scrollbar-thumb { background: var(--line-strong); }
176
+ ::-webkit-scrollbar-thumb:hover { background: var(--line-bright); }
177
+ ::-webkit-scrollbar-corner { background: transparent; }
178
+
179
+ /* Selection */
180
+ ::selection { background: var(--amber); color: var(--bg-0); }
181
+
182
+ /* Common type classes */
183
+ .mono { font-family: var(--font-mono); }
184
+ .label {
185
+ font-size: 9.5px;
186
+ font-weight: 700;
187
+ letter-spacing: 0.12em;
188
+ text-transform: uppercase;
189
+ color: var(--fg-2);
190
+ }
191
+
192
+ /* ============================================================
193
+ STAGGERED LOAD REVEAL — feels like a system booting up
194
+ ============================================================ */
195
+ .topbar, .controlbar, .tabsbar, .filterbar, .main, .statusbar {
196
+ animation: bandIn 320ms cubic-bezier(0.4, 0, 0.2, 1) backwards;
197
+ }
198
+ .controlbar { animation-delay: 50ms; }
199
+ .tabsbar { animation-delay: 100ms; }
200
+ .filterbar { animation-delay: 150ms; }
201
+ .main { animation-delay: 200ms; }
202
+ .statusbar { animation-delay: 280ms; }
203
+
204
+ @keyframes bandIn {
205
+ from { opacity: 0; transform: translateY(-6px); }
206
+ to { opacity: 1; transform: translateY(0); }
207
+ }
208
+
209
+ /* ============================================================
210
+ THEME TOGGLE BUTTON — sun/moon switch in top-right
211
+ ============================================================ */
212
+ .theme-toggle {
213
+ width: 30px; height: 30px;
214
+ display: inline-flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ background: var(--bg-2);
218
+ border: 1px solid var(--line-strong);
219
+ color: var(--fg-1);
220
+ cursor: pointer;
221
+ transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
222
+ position: relative;
223
+ overflow: hidden;
224
+ }
225
+ .theme-toggle:hover {
226
+ color: var(--amber);
227
+ border-color: var(--line-bright);
228
+ background: var(--bg-hover);
229
+ }
230
+ .theme-toggle:active { transform: translateY(1px); }
231
+ .theme-toggle .theme-icon {
232
+ width: 14px; height: 14px;
233
+ position: absolute;
234
+ transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1),
235
+ opacity 200ms ease-out;
236
+ }
237
+ /* Dark mode: show sun (click to switch to light) */
238
+ :root[data-theme="dark"] .theme-toggle .icon-moon {
239
+ transform: rotate(-90deg) scale(0);
240
+ opacity: 0;
241
+ }
242
+ :root[data-theme="dark"] .theme-toggle .icon-sun {
243
+ transform: rotate(0) scale(1);
244
+ opacity: 1;
245
+ }
246
+ /* Light mode: show moon (click to switch to dark) */
247
+ :root[data-theme="light"] .theme-toggle .icon-sun {
248
+ transform: rotate(90deg) scale(0);
249
+ opacity: 0;
250
+ }
251
+ :root[data-theme="light"] .theme-toggle .icon-moon {
252
+ transform: rotate(0) scale(1);
253
+ opacity: 1;
254
+ }
255
+
256
+ /* ============================================================
257
+ TOP BAR — brand · status pill · stats strip
258
+ ============================================================ */
259
+ .topbar {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: var(--space-5);
263
+ padding: 0 var(--space-4);
264
+ height: 44px;
265
+ border-bottom: 1px solid var(--line);
266
+ background: var(--bg-1);
267
+ }
268
+
269
+ .brand {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: var(--space-2);
273
+ }
274
+ .brand-rec {
275
+ position: relative;
276
+ width: 9px;
277
+ height: 9px;
278
+ }
279
+ .brand-rec::before {
280
+ content: '';
281
+ position: absolute;
282
+ inset: 0;
283
+ border-radius: 50%;
284
+ background: var(--red);
285
+ box-shadow: 0 0 6px var(--red), 0 0 12px var(--red-dim);
286
+ animation: recPulse 1.6s ease-in-out infinite;
287
+ }
288
+ @keyframes recPulse {
289
+ 0%, 100% { opacity: 1; transform: scale(1); }
290
+ 50% { opacity: 0.55; transform: scale(0.78); }
291
+ }
292
+ .brand-text {
293
+ font-weight: 700;
294
+ font-size: 11px;
295
+ letter-spacing: 0.18em;
296
+ color: var(--fg-0);
297
+ }
298
+ .brand-meta {
299
+ font-family: var(--font-mono);
300
+ font-size: 9.5px;
301
+ color: var(--fg-3);
302
+ letter-spacing: 0.08em;
303
+ text-transform: uppercase;
304
+ border-left: 1px solid var(--line-strong);
305
+ padding-left: var(--space-2);
306
+ margin-left: var(--space-1);
307
+ }
308
+
309
+ .status-pill {
310
+ display: inline-flex;
311
+ align-items: center;
312
+ gap: 7px;
313
+ padding: 4px 10px;
314
+ border: 1px solid var(--line-strong);
315
+ background: var(--bg-2);
316
+ font-size: 10px;
317
+ letter-spacing: 0.12em;
318
+ text-transform: uppercase;
319
+ color: var(--fg-1);
320
+ font-family: var(--font-mono);
321
+ transition: border-color 200ms;
322
+ }
323
+ .status-pill .status-dot {
324
+ width: 6px; height: 6px;
325
+ border-radius: 50%;
326
+ background: var(--green);
327
+ box-shadow: 0 0 6px var(--green);
328
+ animation: livePulse 1.4s ease-in-out infinite;
329
+ }
330
+ .status-pill.paused {
331
+ border-color: rgba(var(--yellow-rgb), 0.4);
332
+ }
333
+ .status-pill.paused .status-dot {
334
+ background: var(--yellow);
335
+ box-shadow: 0 0 6px var(--yellow);
336
+ animation: none;
337
+ }
338
+ .status-pill.error {
339
+ border-color: rgba(var(--red-rgb), 0.5);
340
+ }
341
+ .status-pill.error .status-dot {
342
+ background: var(--red);
343
+ box-shadow: 0 0 6px var(--red);
344
+ animation: errBlink 0.7s steps(2, end) infinite;
345
+ }
346
+ @keyframes livePulse {
347
+ 0%, 100% { opacity: 1; }
348
+ 50% { opacity: 0.35; }
349
+ }
350
+ @keyframes errBlink {
351
+ 0%, 100% { opacity: 1; }
352
+ 50% { opacity: 0.15; }
353
+ }
354
+
355
+ .stats-strip {
356
+ display: flex;
357
+ align-items: center;
358
+ gap: var(--space-5);
359
+ margin-left: auto;
360
+ font-family: var(--font-mono);
361
+ }
362
+ .stat {
363
+ display: flex;
364
+ flex-direction: column;
365
+ align-items: flex-end;
366
+ line-height: 1.1;
367
+ min-width: 36px;
368
+ }
369
+ .stat .stat-value {
370
+ color: var(--fg-0);
371
+ font-weight: 600;
372
+ font-size: 13px;
373
+ }
374
+ .stat .stat-label {
375
+ font-size: 9px;
376
+ color: var(--fg-3);
377
+ letter-spacing: 0.12em;
378
+ text-transform: uppercase;
379
+ margin-top: 1px;
380
+ }
381
+ .stat.s-int .stat-label { color: var(--cyan-dim); }
382
+ .stat.s-net .stat-label { color: var(--green-dim); }
383
+ .stat.s-nav .stat-label { color: var(--amber-dim); }
384
+ .stat.s-err .stat-label { color: var(--red-dim); }
385
+
386
+ /* ============================================================
387
+ CONTROL BAR — back · forward · reload · URL · open
388
+ ============================================================ */
389
+ .controlbar {
390
+ display: flex;
391
+ align-items: center;
392
+ gap: var(--space-2);
393
+ padding: var(--space-3) var(--space-4);
394
+ border-bottom: 1px solid var(--line);
395
+ background: var(--bg-1);
396
+ }
397
+
398
+ .nav-btn {
399
+ width: 30px; height: 30px;
400
+ display: inline-flex;
401
+ align-items: center;
402
+ justify-content: center;
403
+ background: var(--bg-2);
404
+ border: 1px solid var(--line-strong);
405
+ color: var(--fg-1);
406
+ cursor: pointer;
407
+ transition: all 120ms cubic-bezier(0.4, 0, 0.2, 1);
408
+ }
409
+ .nav-btn:hover:not(:disabled) {
410
+ background: var(--bg-hover);
411
+ color: var(--fg-0);
412
+ border-color: var(--line-bright);
413
+ }
414
+ .nav-btn:active:not(:disabled) {
415
+ background: var(--bg-active);
416
+ transform: translateY(1px);
417
+ }
418
+ .nav-btn:disabled { opacity: 0.4; cursor: not-allowed; }
419
+ .nav-btn svg { width: 14px; height: 14px; }
420
+
421
+ .url-input-wrap {
422
+ flex: 1;
423
+ display: flex;
424
+ align-items: center;
425
+ background: var(--bg-2);
426
+ border: 1px solid var(--line-strong);
427
+ padding: 0 var(--space-3);
428
+ height: 30px;
429
+ transition: border-color 150ms, box-shadow 150ms;
430
+ }
431
+ .url-input-wrap:focus-within {
432
+ border-color: var(--amber);
433
+ box-shadow: 0 0 0 1px var(--amber-glow);
434
+ }
435
+ .url-prefix {
436
+ color: var(--fg-3);
437
+ font-family: var(--font-mono);
438
+ font-size: 11px;
439
+ margin-right: var(--space-2);
440
+ letter-spacing: 0.05em;
441
+ }
442
+ .url-input-wrap:focus-within .url-prefix {
443
+ color: var(--amber);
444
+ }
445
+ #url-input {
446
+ flex: 1;
447
+ background: transparent;
448
+ border: 0;
449
+ outline: 0;
450
+ color: var(--fg-0);
451
+ font-family: var(--font-mono);
452
+ font-size: 12px;
453
+ letter-spacing: 0.01em;
454
+ }
455
+ #url-input::placeholder { color: var(--fg-3); }
456
+
457
+ .btn {
458
+ height: 30px;
459
+ padding: 0 var(--space-3);
460
+ background: var(--bg-2);
461
+ border: 1px solid var(--line-strong);
462
+ color: var(--fg-0);
463
+ font-family: var(--font-sans);
464
+ font-size: 11px;
465
+ font-weight: 500;
466
+ cursor: pointer;
467
+ transition: all 120ms ease-out;
468
+ display: inline-flex;
469
+ align-items: center;
470
+ gap: var(--space-2);
471
+ white-space: nowrap;
472
+ }
473
+ .btn:hover {
474
+ background: var(--bg-hover);
475
+ border-color: var(--line-bright);
476
+ }
477
+ .btn:active { transform: translateY(1px); }
478
+ .btn-primary {
479
+ background: var(--amber);
480
+ border-color: var(--amber);
481
+ color: var(--btn-primary-fg);
482
+ font-weight: 600;
483
+ }
484
+ .btn-primary:hover {
485
+ background: var(--amber-dim);
486
+ border-color: var(--amber-dim);
487
+ }
488
+
489
+ /* ============================================================
490
+ TABS BAR — open browser pages
491
+ ============================================================ */
492
+ .tabsbar {
493
+ display: flex;
494
+ align-items: stretch;
495
+ height: 34px;
496
+ background: var(--bg-1);
497
+ border-bottom: 1px solid var(--line);
498
+ overflow-x: auto;
499
+ overflow-y: hidden;
500
+ scrollbar-width: thin;
501
+ }
502
+ .tabsbar::-webkit-scrollbar { height: 3px; }
503
+
504
+ .tab {
505
+ display: flex;
506
+ align-items: center;
507
+ gap: var(--space-2);
508
+ padding: 0 var(--space-3);
509
+ min-width: 160px;
510
+ max-width: 260px;
511
+ border-right: 1px solid var(--line);
512
+ background: var(--bg-1);
513
+ color: var(--fg-2);
514
+ cursor: pointer;
515
+ transition: background 120ms, color 120ms;
516
+ position: relative;
517
+ font-size: 11px;
518
+ }
519
+ .tab:hover {
520
+ background: var(--bg-2);
521
+ color: var(--fg-1);
522
+ }
523
+ .tab.active {
524
+ background: var(--bg-0);
525
+ color: var(--fg-0);
526
+ }
527
+ .tab.active::after {
528
+ content: '';
529
+ position: absolute;
530
+ top: 0; left: 0; right: 0;
531
+ height: 2px;
532
+ background: var(--amber);
533
+ box-shadow: 0 0 6px var(--amber-glow);
534
+ }
535
+
536
+ .tab-icon {
537
+ width: 5px; height: 5px;
538
+ border-radius: 50%;
539
+ background: var(--cyan-dim);
540
+ flex-shrink: 0;
541
+ }
542
+ .tab.active .tab-icon {
543
+ background: var(--amber);
544
+ box-shadow: 0 0 6px var(--amber);
545
+ }
546
+
547
+ .tab-title {
548
+ flex: 1;
549
+ overflow: hidden;
550
+ text-overflow: ellipsis;
551
+ white-space: nowrap;
552
+ font-size: 11.5px;
553
+ }
554
+ .tab-host {
555
+ font-family: var(--font-mono);
556
+ font-size: 10px;
557
+ color: var(--fg-3);
558
+ flex-shrink: 0;
559
+ }
560
+ .tab-close {
561
+ width: 16px; height: 16px;
562
+ display: none;
563
+ align-items: center;
564
+ justify-content: center;
565
+ color: var(--fg-2);
566
+ border: 0;
567
+ background: transparent;
568
+ cursor: pointer;
569
+ padding: 0;
570
+ transition: color 120ms;
571
+ }
572
+ .tab:hover .tab-close { display: inline-flex; }
573
+ .tab-close:hover { color: var(--red); }
574
+ .tab-close svg { width: 11px; height: 11px; }
575
+
576
+ .tabs-empty {
577
+ display: flex;
578
+ align-items: center;
579
+ padding: 0 var(--space-4);
580
+ color: var(--fg-3);
581
+ font-size: 10.5px;
582
+ letter-spacing: 0.1em;
583
+ text-transform: uppercase;
584
+ font-family: var(--font-mono);
585
+ }
586
+ .tabs-empty::before {
587
+ content: '○';
588
+ margin-right: var(--space-2);
589
+ color: var(--line-bright);
590
+ }
591
+
592
+ /* ============================================================
593
+ FILTER BAR — kind chips · search · actions
594
+ ============================================================ */
595
+ .filterbar {
596
+ display: flex;
597
+ align-items: center;
598
+ gap: var(--space-1);
599
+ padding: var(--space-2) var(--space-4);
600
+ background: var(--bg-1);
601
+ border-bottom: 1px solid var(--line);
602
+ height: 38px;
603
+ }
604
+
605
+ .filter-chip {
606
+ padding: 4px 10px;
607
+ background: transparent;
608
+ border: 1px solid transparent;
609
+ color: var(--fg-2);
610
+ font-size: 11px;
611
+ font-weight: 500;
612
+ cursor: pointer;
613
+ transition: all 120ms;
614
+ display: inline-flex;
615
+ align-items: center;
616
+ gap: var(--space-2);
617
+ font-family: var(--font-sans);
618
+ }
619
+ .filter-chip:hover {
620
+ color: var(--fg-0);
621
+ background: var(--bg-2);
622
+ }
623
+ .filter-chip.active {
624
+ color: var(--amber);
625
+ border-color: var(--line-strong);
626
+ background: var(--bg-2);
627
+ }
628
+ .filter-chip .count {
629
+ font-family: var(--font-mono);
630
+ font-size: 10px;
631
+ color: var(--fg-3);
632
+ padding: 0 5px;
633
+ background: var(--bg-3);
634
+ border: 1px solid var(--line);
635
+ min-width: 18px;
636
+ text-align: center;
637
+ }
638
+ .filter-chip.active .count {
639
+ color: var(--amber);
640
+ border-color: var(--line-strong);
641
+ }
642
+
643
+ .search-input {
644
+ flex: 1;
645
+ max-width: 320px;
646
+ margin-left: var(--space-4);
647
+ padding: 4px 10px;
648
+ height: 28px;
649
+ background: var(--bg-2);
650
+ border: 1px solid var(--line-strong);
651
+ color: var(--fg-0);
652
+ font-family: var(--font-mono);
653
+ font-size: 11px;
654
+ outline: 0;
655
+ transition: border-color 150ms;
656
+ }
657
+ .search-input:focus {
658
+ border-color: var(--cyan);
659
+ }
660
+ .search-input::placeholder { color: var(--fg-3); }
661
+
662
+ .filter-actions {
663
+ display: flex;
664
+ align-items: center;
665
+ gap: var(--space-1);
666
+ margin-left: auto;
667
+ }
668
+ .icon-btn {
669
+ width: 28px; height: 28px;
670
+ display: inline-flex;
671
+ align-items: center;
672
+ justify-content: center;
673
+ background: transparent;
674
+ border: 1px solid transparent;
675
+ color: var(--fg-2);
676
+ cursor: pointer;
677
+ transition: all 120ms;
678
+ }
679
+ .icon-btn:hover {
680
+ color: var(--fg-0);
681
+ background: var(--bg-2);
682
+ border-color: var(--line-strong);
683
+ }
684
+ .icon-btn.active {
685
+ color: var(--amber);
686
+ background: var(--bg-2);
687
+ border-color: var(--line-strong);
688
+ }
689
+ .icon-btn.danger:hover { color: var(--red); border-color: rgba(var(--red-rgb), 0.3); }
690
+ .icon-btn svg { width: 13px; height: 13px; }
691
+
692
+ /* ============================================================
693
+ MAIN — event list (split with detail panel)
694
+ ============================================================ */
695
+ .main {
696
+ display: grid;
697
+ grid-template-columns: minmax(0, 1fr);
698
+ overflow: hidden;
699
+ background: var(--bg-0);
700
+ }
701
+ .main.detail-open {
702
+ grid-template-columns: minmax(0, 1fr) minmax(420px, 45%);
703
+ }
704
+
705
+ /* === EVENT LIST === */
706
+ .event-list {
707
+ overflow-y: auto;
708
+ overflow-x: hidden;
709
+ border-right: 1px solid var(--line);
710
+ position: relative;
711
+
712
+ /* Subtle dot-grid texture — graph paper feel for telemetry
713
+ overlay-rgb 在 dark 模式是白色(微显),light 模式是黑色(微显) */
714
+ background-image: radial-gradient(circle, rgba(var(--overlay-rgb), 0.04) 1px, transparent 1px);
715
+ background-size: 12px 12px;
716
+ background-position: 0 0;
717
+ }
718
+
719
+ .empty-state {
720
+ padding: 80px var(--space-4);
721
+ text-align: center;
722
+ }
723
+ .empty-state .empty-title {
724
+ font-size: 10.5px;
725
+ letter-spacing: 0.15em;
726
+ color: var(--fg-2);
727
+ text-transform: uppercase;
728
+ font-family: var(--font-mono);
729
+ }
730
+ .empty-state .empty-subtitle {
731
+ margin-top: 14px;
732
+ font-family: var(--font-mono);
733
+ color: var(--fg-3);
734
+ font-size: 11px;
735
+ letter-spacing: 0.02em;
736
+ }
737
+ .empty-state .cursor-blink {
738
+ display: inline-block;
739
+ width: 8px;
740
+ height: 12px;
741
+ background: var(--amber);
742
+ margin-left: 6px;
743
+ vertical-align: text-bottom;
744
+ animation: cursorBlink 1.05s steps(2) infinite;
745
+ }
746
+ @keyframes cursorBlink {
747
+ 0%, 50% { opacity: 1; }
748
+ 51%, 100% { opacity: 0; }
749
+ }
750
+
751
+ /* Event rows — dense, instrument-data look */
752
+ .event-row {
753
+ display: grid;
754
+ grid-template-columns: 52px 90px 110px minmax(0, 1fr);
755
+ align-items: baseline;
756
+ gap: var(--space-3);
757
+ padding: 6px var(--space-4);
758
+ border-bottom: 1px solid rgba(var(--overlay-rgb), 0.04);
759
+ cursor: pointer;
760
+ font-size: 11.5px;
761
+ transition: background 80ms;
762
+ position: relative;
763
+ }
764
+ .event-row::before {
765
+ content: '';
766
+ position: absolute;
767
+ left: 0; top: 0; bottom: 0;
768
+ width: 2px;
769
+ background: transparent;
770
+ transition: background 150ms;
771
+ }
772
+ .event-row:hover { background: rgba(var(--overlay-rgb), 0.03); }
773
+ .event-row.selected {
774
+ background: var(--bg-2);
775
+ }
776
+ .event-row.selected::before { background: var(--amber); }
777
+
778
+ .event-row.new {
779
+ animation: rowReveal 500ms cubic-bezier(0.4, 0, 0.2, 1);
780
+ }
781
+ @keyframes rowReveal {
782
+ 0% {
783
+ background: var(--amber-glow);
784
+ transform: translateX(-4px);
785
+ opacity: 0;
786
+ }
787
+ 20% {
788
+ opacity: 1;
789
+ transform: translateX(0);
790
+ }
791
+ 100% {
792
+ background: transparent;
793
+ }
794
+ }
795
+
796
+ .event-row .idx {
797
+ font-family: var(--font-mono);
798
+ font-size: 10px;
799
+ color: var(--fg-3);
800
+ text-align: right;
801
+ letter-spacing: 0.02em;
802
+ }
803
+ .event-row .time {
804
+ font-family: var(--font-mono);
805
+ font-size: 10.5px;
806
+ color: var(--fg-1);
807
+ white-space: nowrap;
808
+ }
809
+ .event-row .time .ms { color: var(--fg-3); }
810
+
811
+ /* === KIND BADGES === */
812
+ .kind-badge {
813
+ display: inline-flex;
814
+ align-items: center;
815
+ gap: 5px;
816
+ padding: 2px 7px;
817
+ font-size: 9.5px;
818
+ font-weight: 700;
819
+ letter-spacing: 0.08em;
820
+ text-transform: uppercase;
821
+ border: 1px solid;
822
+ background: transparent;
823
+ font-family: var(--font-mono);
824
+ white-space: nowrap;
825
+ line-height: 1.3;
826
+ }
827
+ .kind-badge .dot {
828
+ width: 4px; height: 4px;
829
+ border-radius: 50%;
830
+ }
831
+ .kind-badge.interaction {
832
+ color: var(--cyan);
833
+ border-color: rgba(var(--cyan-rgb), 0.35);
834
+ background: rgba(var(--cyan-rgb), 0.06);
835
+ }
836
+ .kind-badge.interaction .dot { background: var(--cyan); }
837
+ .kind-badge.network {
838
+ color: var(--green);
839
+ border-color: rgba(var(--green-rgb), 0.35);
840
+ background: rgba(var(--green-rgb), 0.06);
841
+ }
842
+ .kind-badge.network .dot { background: var(--green); }
843
+ .kind-badge.navigation {
844
+ color: var(--amber);
845
+ border-color: rgba(var(--amber-rgb), 0.35);
846
+ background: rgba(var(--amber-rgb), 0.06);
847
+ }
848
+ .kind-badge.navigation .dot { background: var(--amber); }
849
+ .kind-badge.error {
850
+ color: var(--red);
851
+ border-color: rgba(var(--red-rgb), 0.45);
852
+ background: rgba(var(--red-rgb), 0.07);
853
+ }
854
+ .kind-badge.error .dot { background: var(--red); }
855
+
856
+ /* === SUMMARY === */
857
+ .summary {
858
+ overflow: hidden;
859
+ white-space: nowrap;
860
+ text-overflow: ellipsis;
861
+ color: var(--fg-1);
862
+ font-size: 11.5px;
863
+ line-height: 1.4;
864
+ }
865
+ .summary .tag {
866
+ font-family: var(--font-mono);
867
+ color: var(--purple);
868
+ font-weight: 600;
869
+ margin-right: 2px;
870
+ }
871
+ .summary .text {
872
+ color: var(--fg-0);
873
+ }
874
+ .summary .sel {
875
+ font-family: var(--font-mono);
876
+ color: var(--fg-2);
877
+ font-size: 10.5px;
878
+ }
879
+ .summary .url {
880
+ font-family: var(--font-mono);
881
+ color: var(--fg-2);
882
+ font-size: 10.5px;
883
+ }
884
+ .summary .method {
885
+ font-family: var(--font-mono);
886
+ color: var(--purple);
887
+ font-weight: 700;
888
+ margin-right: 6px;
889
+ }
890
+ .summary .duration {
891
+ font-family: var(--font-mono);
892
+ color: var(--fg-3);
893
+ margin-left: 6px;
894
+ font-size: 10px;
895
+ }
896
+ .summary .arrow {
897
+ color: var(--amber);
898
+ margin-right: 6px;
899
+ font-family: var(--font-mono);
900
+ }
901
+ .summary .err {
902
+ color: var(--red);
903
+ font-family: var(--font-mono);
904
+ font-size: 11px;
905
+ }
906
+ .summary .key-chip {
907
+ display: inline-block;
908
+ padding: 0 5px;
909
+ margin-left: 4px;
910
+ border: 1px solid var(--line-strong);
911
+ border-radius: 2px;
912
+ font-family: var(--font-mono);
913
+ font-size: 10px;
914
+ color: var(--cyan);
915
+ }
916
+
917
+ .status-2xx { color: var(--green); font-family: var(--font-mono); font-weight: 700; }
918
+ .status-3xx { color: var(--purple); font-family: var(--font-mono); font-weight: 700; }
919
+ .status-4xx { color: var(--amber); font-family: var(--font-mono); font-weight: 700; }
920
+ .status-5xx { color: var(--red); font-family: var(--font-mono); font-weight: 700; }
921
+ .status-0 { color: var(--fg-3); font-family: var(--font-mono); }
922
+
923
+ /* ============================================================
924
+ DETAIL PANEL
925
+ ============================================================ */
926
+ .detail-panel {
927
+ overflow-y: auto;
928
+ background: var(--bg-1);
929
+ display: flex;
930
+ flex-direction: column;
931
+ }
932
+
933
+ .detail-header {
934
+ display: flex;
935
+ align-items: center;
936
+ gap: var(--space-3);
937
+ padding: var(--space-3) var(--space-4);
938
+ border-bottom: 1px solid var(--line);
939
+ background: var(--bg-1);
940
+ position: sticky;
941
+ top: 0;
942
+ z-index: 10;
943
+ min-height: 44px;
944
+ }
945
+ .detail-title {
946
+ font-size: 11px;
947
+ color: var(--fg-0);
948
+ flex: 1;
949
+ display: flex;
950
+ align-items: center;
951
+ gap: var(--space-2);
952
+ }
953
+ .detail-title .detail-idx {
954
+ color: var(--fg-2);
955
+ font-family: var(--font-mono);
956
+ font-size: 11px;
957
+ }
958
+ .detail-time {
959
+ font-family: var(--font-mono);
960
+ font-size: 10px;
961
+ color: var(--fg-3);
962
+ letter-spacing: 0.02em;
963
+ }
964
+
965
+ .close-btn {
966
+ width: 24px; height: 24px;
967
+ background: transparent;
968
+ border: 1px solid var(--line-strong);
969
+ color: var(--fg-2);
970
+ cursor: pointer;
971
+ transition: all 120ms;
972
+ display: inline-flex;
973
+ align-items: center;
974
+ justify-content: center;
975
+ flex-shrink: 0;
976
+ }
977
+ .close-btn:hover { color: var(--red); border-color: rgba(var(--red-rgb), 0.4); }
978
+ .close-btn svg { width: 11px; height: 11px; }
979
+
980
+ .detail-section {
981
+ padding: var(--space-4);
982
+ border-bottom: 1px solid var(--line);
983
+ }
984
+ .detail-section:last-child { border-bottom: 0; }
985
+
986
+ .section-label {
987
+ font-size: 9.5px;
988
+ font-weight: 700;
989
+ letter-spacing: 0.14em;
990
+ text-transform: uppercase;
991
+ color: var(--fg-2);
992
+ margin-bottom: var(--space-3);
993
+ display: flex;
994
+ align-items: center;
995
+ gap: var(--space-2);
996
+ }
997
+ .section-label::before {
998
+ content: '';
999
+ width: 4px; height: 4px;
1000
+ background: var(--amber);
1001
+ border-radius: 50%;
1002
+ flex-shrink: 0;
1003
+ }
1004
+ .section-label .accent {
1005
+ color: var(--amber);
1006
+ margin-left: auto;
1007
+ font-size: 9px;
1008
+ letter-spacing: 0.05em;
1009
+ }
1010
+
1011
+ /* === SELECTOR LIST (RPA focus) === */
1012
+ .selector-list {
1013
+ display: flex;
1014
+ flex-direction: column;
1015
+ gap: 3px;
1016
+ font-family: var(--font-mono);
1017
+ font-size: 11px;
1018
+ }
1019
+ .selector-row {
1020
+ display: grid;
1021
+ grid-template-columns: 78px minmax(0, 1fr) auto;
1022
+ align-items: center;
1023
+ gap: var(--space-2);
1024
+ padding: 7px 10px;
1025
+ background: var(--bg-2);
1026
+ border-left: 2px solid var(--line-strong);
1027
+ transition: all 150ms;
1028
+ }
1029
+ .selector-row:hover {
1030
+ border-left-color: var(--amber);
1031
+ background: var(--bg-3);
1032
+ }
1033
+ .sel-key {
1034
+ font-size: 9.5px;
1035
+ color: var(--cyan);
1036
+ font-weight: 700;
1037
+ text-transform: uppercase;
1038
+ letter-spacing: 0.08em;
1039
+ }
1040
+ .sel-val {
1041
+ color: var(--fg-0);
1042
+ word-break: break-all;
1043
+ white-space: pre-wrap;
1044
+ line-height: 1.4;
1045
+ }
1046
+ .copy-btn {
1047
+ width: 24px; height: 24px;
1048
+ background: transparent;
1049
+ border: 1px solid var(--line-strong);
1050
+ color: var(--fg-2);
1051
+ cursor: pointer;
1052
+ display: inline-flex;
1053
+ align-items: center;
1054
+ justify-content: center;
1055
+ transition: all 150ms;
1056
+ flex-shrink: 0;
1057
+ }
1058
+ .copy-btn:hover {
1059
+ color: var(--amber);
1060
+ border-color: var(--amber);
1061
+ }
1062
+ .copy-btn.copied {
1063
+ color: var(--green);
1064
+ border-color: var(--green);
1065
+ }
1066
+ .copy-btn svg { width: 11px; height: 11px; }
1067
+
1068
+ /* === STATE BADGES === */
1069
+ .state-badges {
1070
+ display: flex;
1071
+ flex-wrap: wrap;
1072
+ gap: 6px;
1073
+ }
1074
+ .state-badge {
1075
+ padding: 3px 9px;
1076
+ border: 1px solid var(--line-strong);
1077
+ background: var(--bg-2);
1078
+ font-size: 10px;
1079
+ font-family: var(--font-mono);
1080
+ color: var(--fg-1);
1081
+ display: inline-flex;
1082
+ align-items: center;
1083
+ gap: 5px;
1084
+ }
1085
+ .state-badge.on {
1086
+ color: var(--green);
1087
+ border-color: rgba(var(--green-rgb), 0.3);
1088
+ background: rgba(var(--green-rgb), 0.06);
1089
+ }
1090
+ .state-badge.off { color: var(--fg-3); }
1091
+ .state-badge .v {
1092
+ color: inherit;
1093
+ opacity: 0.85;
1094
+ border-left: 1px solid currentColor;
1095
+ padding-left: 5px;
1096
+ margin-left: 2px;
1097
+ opacity: 0.6;
1098
+ }
1099
+
1100
+ /* === META GRID === */
1101
+ .meta-grid {
1102
+ display: grid;
1103
+ grid-template-columns: max-content minmax(0, 1fr);
1104
+ gap: 6px var(--space-4);
1105
+ font-family: var(--font-mono);
1106
+ font-size: 11px;
1107
+ align-items: baseline;
1108
+ }
1109
+ .meta-grid .k {
1110
+ color: var(--fg-2);
1111
+ text-transform: uppercase;
1112
+ font-size: 9.5px;
1113
+ letter-spacing: 0.08em;
1114
+ font-weight: 600;
1115
+ }
1116
+ .meta-grid .v {
1117
+ color: var(--fg-0);
1118
+ word-break: break-all;
1119
+ line-height: 1.4;
1120
+ }
1121
+
1122
+ /* === JSON BLOCK === */
1123
+ .json-block {
1124
+ background: var(--bg-0);
1125
+ border: 1px solid var(--line);
1126
+ padding: var(--space-3);
1127
+ font-family: var(--font-mono);
1128
+ font-size: 10.5px;
1129
+ line-height: 1.55;
1130
+ overflow-x: auto;
1131
+ color: var(--fg-1);
1132
+ white-space: pre-wrap;
1133
+ word-break: break-word;
1134
+ max-height: 480px;
1135
+ overflow-y: auto;
1136
+ }
1137
+ .json-block .jk { color: var(--cyan); }
1138
+ .json-block .js { color: var(--green); }
1139
+ .json-block .jn { color: var(--amber); }
1140
+ .json-block .jb { color: var(--purple); }
1141
+ .json-block .jl { color: var(--fg-3); font-style: italic; }
1142
+
1143
+ /* ============================================================
1144
+ STATUS BAR
1145
+ ============================================================ */
1146
+ .statusbar {
1147
+ display: grid;
1148
+ grid-template-columns: 1fr auto 1fr;
1149
+ align-items: center;
1150
+ gap: var(--space-4);
1151
+ height: 24px;
1152
+ padding: 0 var(--space-4);
1153
+ background: var(--bg-1);
1154
+ border-top: 1px solid var(--line);
1155
+ font-family: var(--font-mono);
1156
+ font-size: 10px;
1157
+ color: var(--fg-2);
1158
+ letter-spacing: 0.02em;
1159
+ }
1160
+ .statusbar .left {
1161
+ display: flex;
1162
+ align-items: center;
1163
+ gap: var(--space-4);
1164
+ }
1165
+ .statusbar .center { color: var(--fg-3); text-align: center; }
1166
+ .statusbar .right { text-align: right; color: var(--fg-3); }
1167
+
1168
+ .statusbar .live-text { color: var(--green); }
1169
+ .statusbar .live-text::before {
1170
+ content: '●';
1171
+ margin-right: 5px;
1172
+ animation: livePulse 1.4s ease-in-out infinite;
1173
+ }
1174
+ .statusbar .paused-text { color: var(--yellow); }
1175
+ .statusbar .paused-text::before { content: '◼'; margin-right: 5px; }
1176
+ .statusbar .error-text { color: var(--red); }
1177
+ .statusbar .error-text::before { content: '✕'; margin-right: 5px; }
1178
+
1179
+ /* ============================================================
1180
+ TOAST
1181
+ ============================================================ */
1182
+ .toast {
1183
+ position: fixed;
1184
+ bottom: var(--space-5);
1185
+ left: 50%;
1186
+ transform: translateX(-50%) translateY(40px);
1187
+ padding: 8px 16px;
1188
+ background: var(--bg-3);
1189
+ border: 1px solid var(--line-bright);
1190
+ color: var(--fg-0);
1191
+ font-size: 11px;
1192
+ font-family: var(--font-mono);
1193
+ pointer-events: none;
1194
+ opacity: 0;
1195
+ transition: all 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
1196
+ z-index: 1000;
1197
+ letter-spacing: 0.02em;
1198
+ box-shadow: 0 4px 24px var(--toast-shadow), 0 0 0 1px rgba(var(--amber-rgb), 0.08);
1199
+ }
1200
+ .toast.show {
1201
+ opacity: 1;
1202
+ transform: translateX(-50%) translateY(0);
1203
+ }
1204
+ .toast.success { border-color: rgba(var(--green-rgb), 0.4); }
1205
+ .toast.error { border-color: rgba(var(--red-rgb), 0.4); }
1206
+ </style>
1207
+ </head>
1208
+ <body>
1209
+
1210
+ <!-- ====================================================================
1211
+ TOP BAR
1212
+ ==================================================================== -->
1213
+ <header class="topbar">
1214
+ <div class="brand">
1215
+ <span class="brand-rec"></span>
1216
+ <span class="brand-text">RECORDER</span>
1217
+ <span class="brand-meta">v1.0 · TELEMETRY</span>
1218
+ </div>
1219
+
1220
+ <div class="status-pill" id="status-pill">
1221
+ <span class="status-dot"></span>
1222
+ <span id="status-text">LIVE</span>
1223
+ </div>
1224
+
1225
+ <div class="stats-strip">
1226
+ <div class="stat"><div class="stat-value" id="stat-total">0</div><div class="stat-label">total</div></div>
1227
+ <div class="stat s-int"><div class="stat-value" id="stat-int">0</div><div class="stat-label">int</div></div>
1228
+ <div class="stat s-net"><div class="stat-value" id="stat-net">0</div><div class="stat-label">net</div></div>
1229
+ <div class="stat s-nav"><div class="stat-value" id="stat-nav">0</div><div class="stat-label">nav</div></div>
1230
+ <div class="stat s-err"><div class="stat-value" id="stat-err">0</div><div class="stat-label">err</div></div>
1231
+ </div>
1232
+
1233
+ <button class="theme-toggle" id="btn-theme" title="切换明暗主题 (T)" aria-label="切换主题">
1234
+ <svg class="theme-icon icon-sun" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
1235
+ <circle cx="8" cy="8" r="3"/>
1236
+ <line x1="8" y1="1.5" x2="8" y2="3"/>
1237
+ <line x1="8" y1="13" x2="8" y2="14.5"/>
1238
+ <line x1="1.5" y1="8" x2="3" y2="8"/>
1239
+ <line x1="13" y1="8" x2="14.5" y2="8"/>
1240
+ <line x1="3.4" y1="3.4" x2="4.5" y2="4.5"/>
1241
+ <line x1="11.5" y1="11.5" x2="12.6" y2="12.6"/>
1242
+ <line x1="3.4" y1="12.6" x2="4.5" y2="11.5"/>
1243
+ <line x1="11.5" y1="4.5" x2="12.6" y2="3.4"/>
1244
+ </svg>
1245
+ <svg class="theme-icon icon-moon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
1246
+ <path d="M13.5 9.5A5.5 5.5 0 1 1 6.5 2.5a4.5 4.5 0 0 0 7 7z"/>
1247
+ </svg>
1248
+ </button>
1249
+ </header>
1250
+
1251
+ <!-- ====================================================================
1252
+ CONTROL BAR
1253
+ ==================================================================== -->
1254
+ <div class="controlbar">
1255
+ <button class="nav-btn" id="btn-back" title="后退">
1256
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1257
+ <path d="M10 4L6 8l4 4" stroke-linecap="round" stroke-linejoin="round"/>
1258
+ </svg>
1259
+ </button>
1260
+ <button class="nav-btn" id="btn-forward" title="前进">
1261
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1262
+ <path d="M6 4l4 4-4 4" stroke-linecap="round" stroke-linejoin="round"/>
1263
+ </svg>
1264
+ </button>
1265
+ <button class="nav-btn" id="btn-reload" title="刷新">
1266
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1267
+ <path d="M3 8a5 5 0 0 1 8.5-3.5L13 6M3 8a5 5 0 0 0 8.5 3.5L13 10M13 3v3h-3M3 13v-3h3" stroke-linecap="round" stroke-linejoin="round"/>
1268
+ </svg>
1269
+ </button>
1270
+
1271
+ <div class="url-input-wrap">
1272
+ <span class="url-prefix">→</span>
1273
+ <input type="text" id="url-input" placeholder="输入 URL 后回车 / 或拖放链接到此处...">
1274
+ </div>
1275
+
1276
+ <button class="btn btn-primary" id="btn-open">打开</button>
1277
+ <button class="btn" id="btn-newtab">
1278
+ <svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M7 3v8M3 7h8" stroke-linecap="round"/></svg>
1279
+ 新标签
1280
+ </button>
1281
+ </div>
1282
+
1283
+ <!-- ====================================================================
1284
+ TABS BAR
1285
+ ==================================================================== -->
1286
+ <div class="tabsbar" id="tabsbar">
1287
+ <div class="tabs-empty">没有打开的标签页</div>
1288
+ </div>
1289
+
1290
+ <!-- ====================================================================
1291
+ FILTER BAR
1292
+ ==================================================================== -->
1293
+ <div class="filterbar">
1294
+ <button class="filter-chip active" data-filter="all">全部 <span class="count" id="cnt-all">0</span></button>
1295
+ <button class="filter-chip" data-filter="interaction">交互 <span class="count" id="cnt-int">0</span></button>
1296
+ <button class="filter-chip" data-filter="network">网络 <span class="count" id="cnt-net">0</span></button>
1297
+ <button class="filter-chip" data-filter="navigation">导航 <span class="count" id="cnt-nav">0</span></button>
1298
+ <button class="filter-chip" data-filter="error">错误 <span class="count" id="cnt-err">0</span></button>
1299
+
1300
+ <input type="text" class="search-input" id="search-input" placeholder="搜索 URL / 文字 / 选择器 ⌘ + F">
1301
+
1302
+ <div class="filter-actions">
1303
+ <button class="icon-btn" id="btn-pause" title="暂停 / 恢复 (Space)">
1304
+ <svg viewBox="0 0 16 16" fill="currentColor"><rect x="4" y="4" width="2.5" height="8"/><rect x="9.5" y="4" width="2.5" height="8"/></svg>
1305
+ </button>
1306
+ <button class="icon-btn active" id="btn-autoscroll" title="自动滚动到底部">
1307
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 3v9M4 8l4 4 4-4M3 14h10" stroke-linecap="round" stroke-linejoin="round"/></svg>
1308
+ </button>
1309
+ <button class="icon-btn" id="btn-export" title="导出 JSONL">
1310
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M5 7l3 3 3-3M3 13h10" stroke-linecap="round" stroke-linejoin="round"/></svg>
1311
+ </button>
1312
+ <button class="icon-btn danger" id="btn-clear" title="清空显示">
1313
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 5h10M6 5V3h4v2M4.5 5l.5 8h6l.5-8" stroke-linecap="round" stroke-linejoin="round"/></svg>
1314
+ </button>
1315
+ </div>
1316
+ </div>
1317
+
1318
+ <!-- ====================================================================
1319
+ MAIN
1320
+ ==================================================================== -->
1321
+ <div class="main" id="main">
1322
+ <div class="event-list" id="event-list">
1323
+ <div class="empty-state">
1324
+ <div class="empty-title">AWAITING TELEMETRY<span class="cursor-blink"></span></div>
1325
+ <div class="empty-subtitle">在浏览器里随便点点,或在上方地址栏打开网址 — 事件流会实时滚动到这里</div>
1326
+ </div>
1327
+ </div>
1328
+
1329
+ <div class="detail-panel" id="detail-panel" style="display:none">
1330
+ <div class="detail-header">
1331
+ <div class="detail-title" id="detail-title"></div>
1332
+ <div class="detail-time" id="detail-time"></div>
1333
+ <button class="close-btn" id="btn-close-detail" title="关闭详情 (Esc)">
1334
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4l8 8M12 4l-8 8" stroke-linecap="round"/></svg>
1335
+ </button>
1336
+ </div>
1337
+ <div id="detail-content"></div>
1338
+ </div>
1339
+ </div>
1340
+
1341
+ <!-- ====================================================================
1342
+ STATUS BAR
1343
+ ==================================================================== -->
1344
+ <footer class="statusbar">
1345
+ <div class="left">
1346
+ <span id="conn-status" class="live-text">实时录制</span>
1347
+ <span id="last-update"></span>
1348
+ </div>
1349
+ <div class="center" id="filter-info">显示 0 / 0</div>
1350
+ <div class="right" id="endpoint-info">localhost:7777 · session.jsonl</div>
1351
+ </footer>
1352
+
1353
+ <div class="toast" id="toast"></div>
1354
+
1355
+ <script>
1356
+ /* ============================================================
1357
+ THEME — light / dark toggle,持久化到 localStorage
1358
+ 初次进入:优先读取 localStorage;没有则跟随系统 prefers-color-scheme
1359
+ ============================================================ */
1360
+ const THEME_KEY = 'recorder-theme';
1361
+
1362
+ /**
1363
+ * 应用主题到 <html> 上
1364
+ * @param {'dark'|'light'} theme
1365
+ */
1366
+ function applyTheme(theme) {
1367
+ document.documentElement.setAttribute('data-theme', theme);
1368
+ try { localStorage.setItem(THEME_KEY, theme); } catch (e) {}
1369
+ }
1370
+
1371
+ /**
1372
+ * 切换主题
1373
+ */
1374
+ function toggleTheme() {
1375
+ const cur = document.documentElement.getAttribute('data-theme') || 'dark';
1376
+ applyTheme(cur === 'dark' ? 'light' : 'dark');
1377
+ }
1378
+
1379
+ // 初始化:优先 localStorage,其次系统偏好,默认 dark
1380
+ (function initTheme() {
1381
+ let theme = null;
1382
+ try { theme = localStorage.getItem(THEME_KEY); } catch (e) {}
1383
+ if (!theme) {
1384
+ const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
1385
+ theme = prefersLight ? 'light' : 'dark';
1386
+ }
1387
+ applyTheme(theme);
1388
+ })();
1389
+
1390
+ /* ============================================================
1391
+ STATE — Single source of truth for the dashboard.
1392
+ ============================================================ */
1393
+ const state = {
1394
+ events: [], // 全部已收事件,按 serverTime 顺序
1395
+ pages: [], // 浏览器当前打开的 tab 列表
1396
+ activeTabIdx: 0, // 当前活跃 tab(focus 后更新)
1397
+ lastSeen: '', // 增量拉取游标(最后一条 serverTime)
1398
+ filter: 'all', // kind 过滤
1399
+ search: '', // 关键词搜索
1400
+ paused: false, // 是否暂停轮询
1401
+ autoScroll: true, // 自动滚动到底部
1402
+ selectedIdx: null, // 当前选中事件在 events 数组中的索引
1403
+ connected: true, // 后端连接状态
1404
+ newEventIdxs: new Set(), // 标记新事件(用于"新事件入场动画")
1405
+ };
1406
+
1407
+ const POLL_INTERVAL = 800;
1408
+
1409
+ /* ============================================================
1410
+ DOM REFERENCES
1411
+ ============================================================ */
1412
+ const $ = id => document.getElementById(id);
1413
+ const els = {
1414
+ statusPill: $('status-pill'),
1415
+ statusText: $('status-text'),
1416
+ statTotal: $('stat-total'),
1417
+ statInt: $('stat-int'),
1418
+ statNet: $('stat-net'),
1419
+ statNav: $('stat-nav'),
1420
+ statErr: $('stat-err'),
1421
+ cntAll: $('cnt-all'),
1422
+ cntInt: $('cnt-int'),
1423
+ cntNet: $('cnt-net'),
1424
+ cntNav: $('cnt-nav'),
1425
+ cntErr: $('cnt-err'),
1426
+ urlInput: $('url-input'),
1427
+ tabsbar: $('tabsbar'),
1428
+ searchInput: $('search-input'),
1429
+ eventList: $('event-list'),
1430
+ detailPanel: $('detail-panel'),
1431
+ detailTitle: $('detail-title'),
1432
+ detailTime: $('detail-time'),
1433
+ detailContent: $('detail-content'),
1434
+ main: $('main'),
1435
+ connStatus: $('conn-status'),
1436
+ lastUpdate: $('last-update'),
1437
+ filterInfo: $('filter-info'),
1438
+ toast: $('toast'),
1439
+ };
1440
+
1441
+ /* ============================================================
1442
+ UTILITIES
1443
+ ============================================================ */
1444
+
1445
+ /**
1446
+ * HTML 转义
1447
+ * @param {*} s
1448
+ * @return {string}
1449
+ */
1450
+ function escapeHtml(s) {
1451
+ return String(s == null ? '' : s)
1452
+ .replace(/&/g, '&amp;')
1453
+ .replace(/</g, '&lt;')
1454
+ .replace(/>/g, '&gt;')
1455
+ .replace(/"/g, '&quot;');
1456
+ }
1457
+
1458
+ function escapeAttr(s) {
1459
+ return escapeHtml(s).replace(/'/g, '&#39;');
1460
+ }
1461
+
1462
+ /**
1463
+ * 把 ISO 时间格式化为 HH:MM:SS.mmm(毫秒部分淡灰)
1464
+ * @param {string} iso
1465
+ */
1466
+ function fmtTime(iso) {
1467
+ if (!iso) return '';
1468
+ const t = iso.slice(11, 23);
1469
+ return `${t.slice(0, 8)}<span class="ms">.${t.slice(9)}</span>`;
1470
+ }
1471
+
1472
+ /**
1473
+ * 从 URL 提取 host
1474
+ * @param {string} url
1475
+ */
1476
+ function hostOf(url) {
1477
+ try { return new URL(url).host; }
1478
+ catch (e) { return url.length > 30 ? url.slice(0, 30) + '...' : url; }
1479
+ }
1480
+
1481
+ /**
1482
+ * Toast 提示
1483
+ * @param {string} msg
1484
+ * @param {'default'|'success'|'error'} [kind]
1485
+ * @param {number} [duration]
1486
+ */
1487
+ function toast(msg, kind = 'default', duration = 1600) {
1488
+ els.toast.textContent = msg;
1489
+ els.toast.className = 'toast show ' + (kind === 'default' ? '' : kind);
1490
+ clearTimeout(toast._t);
1491
+ toast._t = setTimeout(() => els.toast.classList.remove('show'), duration);
1492
+ }
1493
+
1494
+ /**
1495
+ * 元素是否在容器底部(用于"靠近底部时仍自动滚动")
1496
+ */
1497
+ function isAtBottom(el) {
1498
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 20;
1499
+ }
1500
+
1501
+ /* ============================================================
1502
+ POLLING — 每 800ms 并行拉 events + pages
1503
+ ============================================================ */
1504
+
1505
+ async function pollOnce() {
1506
+ if (state.paused) return;
1507
+ try {
1508
+ const [eventsResp, pagesResp] = await Promise.all([
1509
+ fetch(state.lastSeen ? `/events?since=${encodeURIComponent(state.lastSeen)}` : '/events'),
1510
+ fetch('/control/pages').catch(() => null),
1511
+ ]);
1512
+
1513
+ if (!eventsResp.ok) throw new Error('events HTTP ' + eventsResp.status);
1514
+ const events = await eventsResp.json();
1515
+
1516
+ if (events.length > 0) {
1517
+ const startIdx = state.events.length;
1518
+ state.events.push(...events);
1519
+ state.lastSeen = events[events.length - 1].serverTime || state.lastSeen;
1520
+ // 标记本批为"新事件"用于入场动画
1521
+ for (let i = 0; i < events.length; i++) {
1522
+ state.newEventIdxs.add(startIdx + i);
1523
+ }
1524
+ renderList();
1525
+ updateStats();
1526
+ }
1527
+
1528
+ if (pagesResp && pagesResp.ok) {
1529
+ const pages = await pagesResp.json();
1530
+ // 只在变化时重渲染,避免无谓闪烁
1531
+ if (JSON.stringify(pages) !== JSON.stringify(state.pages)) {
1532
+ state.pages = pages;
1533
+ renderTabs();
1534
+ }
1535
+ }
1536
+
1537
+ setConnected(true);
1538
+ els.lastUpdate.textContent = `同步于 ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`;
1539
+ } catch (err) {
1540
+ setConnected(false, err.message);
1541
+ }
1542
+ }
1543
+
1544
+ function setConnected(ok, errMsg = '') {
1545
+ if (state.paused) {
1546
+ els.statusPill.className = 'status-pill paused';
1547
+ els.statusText.textContent = 'PAUSED';
1548
+ els.connStatus.className = 'paused-text';
1549
+ els.connStatus.textContent = '已暂停';
1550
+ } else if (ok) {
1551
+ els.statusPill.className = 'status-pill';
1552
+ els.statusText.textContent = 'LIVE';
1553
+ els.connStatus.className = 'live-text';
1554
+ els.connStatus.textContent = '实时录制';
1555
+ } else {
1556
+ els.statusPill.className = 'status-pill error';
1557
+ els.statusText.textContent = 'OFFLINE';
1558
+ els.connStatus.className = 'error-text';
1559
+ els.connStatus.textContent = '断连 · ' + (errMsg || 'unknown');
1560
+ }
1561
+ state.connected = ok;
1562
+ }
1563
+
1564
+ /* ============================================================
1565
+ STATS
1566
+ ============================================================ */
1567
+
1568
+ function updateStats() {
1569
+ const counts = { interaction: 0, network: 0, navigation: 0, error: 0 };
1570
+ for (const e of state.events) {
1571
+ if (counts[e.kind] !== undefined) counts[e.kind]++;
1572
+ }
1573
+ const total = state.events.length;
1574
+ els.statTotal.textContent = total;
1575
+ els.statInt.textContent = counts.interaction;
1576
+ els.statNet.textContent = counts.network;
1577
+ els.statNav.textContent = counts.navigation;
1578
+ els.statErr.textContent = counts.error;
1579
+ els.cntAll.textContent = total;
1580
+ els.cntInt.textContent = counts.interaction;
1581
+ els.cntNet.textContent = counts.network;
1582
+ els.cntNav.textContent = counts.navigation;
1583
+ els.cntErr.textContent = counts.error;
1584
+ }
1585
+
1586
+ /* ============================================================
1587
+ TABS RENDERING
1588
+ ============================================================ */
1589
+
1590
+ function renderTabs() {
1591
+ if (!state.pages.length) {
1592
+ els.tabsbar.innerHTML = '<div class="tabs-empty">没有打开的标签页</div>';
1593
+ return;
1594
+ }
1595
+ els.tabsbar.innerHTML = state.pages.map((p, i) => {
1596
+ const active = i === state.activeTabIdx ? 'active' : '';
1597
+ const title = p.title || '无标题';
1598
+ return `
1599
+ <div class="tab ${active}" data-idx="${i}" title="${escapeAttr(p.url)}">
1600
+ <span class="tab-icon"></span>
1601
+ <span class="tab-title">${escapeHtml(title)}</span>
1602
+ <span class="tab-host">${escapeHtml(hostOf(p.url))}</span>
1603
+ <button class="tab-close" data-close-idx="${i}" title="关闭">
1604
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
1605
+ <path d="M4 4l8 8M12 4l-8 8" stroke-linecap="round"/>
1606
+ </svg>
1607
+ </button>
1608
+ </div>
1609
+ `;
1610
+ }).join('');
1611
+ }
1612
+
1613
+ /* ============================================================
1614
+ EVENT LIST RENDERING
1615
+ ============================================================ */
1616
+
1617
+ /**
1618
+ * 按当前 filter + search 过滤事件
1619
+ */
1620
+ function getFiltered() {
1621
+ let filtered = state.events;
1622
+ if (state.filter !== 'all') {
1623
+ filtered = filtered.filter(e => e.kind === state.filter);
1624
+ }
1625
+ if (state.search) {
1626
+ const kw = state.search.toLowerCase();
1627
+ filtered = filtered.filter(e => {
1628
+ try { return JSON.stringify(e).toLowerCase().includes(kw); }
1629
+ catch (err) { return false; }
1630
+ });
1631
+ }
1632
+ return filtered;
1633
+ }
1634
+
1635
+ function renderList() {
1636
+ const filtered = getFiltered();
1637
+ els.filterInfo.textContent = `显示 ${filtered.length} / ${state.events.length}`;
1638
+
1639
+ if (filtered.length === 0) {
1640
+ if (state.events.length === 0) {
1641
+ els.eventList.innerHTML = `
1642
+ <div class="empty-state">
1643
+ <div class="empty-title">AWAITING TELEMETRY<span class="cursor-blink"></span></div>
1644
+ <div class="empty-subtitle">在浏览器里随便点点,或在上方地址栏打开网址 — 事件流会实时滚动到这里</div>
1645
+ </div>`;
1646
+ } else {
1647
+ els.eventList.innerHTML = `
1648
+ <div class="empty-state">
1649
+ <div class="empty-title">NO MATCHING EVENTS</div>
1650
+ <div class="empty-subtitle">调整过滤条件或清空搜索关键词试试</div>
1651
+ </div>`;
1652
+ }
1653
+ return;
1654
+ }
1655
+
1656
+ const wasAtBottom = isAtBottom(els.eventList);
1657
+
1658
+ const html = filtered.map(e => renderRow(e, state.events.indexOf(e))).join('');
1659
+ els.eventList.innerHTML = html;
1660
+
1661
+ // 动画播完就清除标记
1662
+ setTimeout(() => state.newEventIdxs.clear(), 600);
1663
+
1664
+ if (state.autoScroll || wasAtBottom) {
1665
+ els.eventList.scrollTop = els.eventList.scrollHeight;
1666
+ }
1667
+ }
1668
+
1669
+ function renderRow(e, idx) {
1670
+ const isSelected = idx === state.selectedIdx ? 'selected' : '';
1671
+ const isNew = state.newEventIdxs.has(idx) ? 'new' : '';
1672
+ return `
1673
+ <div class="event-row ${isSelected} ${isNew}" data-idx="${idx}">
1674
+ <span class="idx">#${idx + 1}</span>
1675
+ <span class="time">${fmtTime(e.clientTime)}</span>
1676
+ <span class="kind-badge ${e.kind}"><span class="dot"></span>${escapeHtml((e.type || '').toUpperCase())}</span>
1677
+ <span class="summary">${renderSummary(e)}</span>
1678
+ </div>
1679
+ `;
1680
+ }
1681
+
1682
+ /**
1683
+ * 按事件类型生成单行摘要
1684
+ */
1685
+ function renderSummary(e) {
1686
+ if (e.kind === 'interaction') {
1687
+ const t = e.target || {};
1688
+ const tagPart = `<span class="tag">${escapeHtml((t.tag || '?').toUpperCase())}</span>`;
1689
+ const textRaw = t.accessibleName || t.innerText || '';
1690
+ const textPart = textRaw ? ` <span class="text">"${escapeHtml(textRaw.slice(0, 50))}"</span>` : '';
1691
+ const selRaw = t.selectors
1692
+ ? (t.selectors.id || t.selectors.testId || t.selectors.role || t.selectors.css || '')
1693
+ : '';
1694
+ const selPart = selRaw ? ` <span class="sel">${escapeHtml(selRaw.slice(0, 80))}</span>` : '';
1695
+ let extra = '';
1696
+ if (e.type === 'input' && e.value != null) {
1697
+ extra = ` <span class="sel">=</span> <span class="text">"${escapeHtml(String(e.value).slice(0, 40))}"</span>`;
1698
+ } else if (e.type === 'keydown' && e.key) {
1699
+ const mods = e.modifiers ? Object.entries(e.modifiers).filter(([k, v]) => v).map(([k]) => k) : [];
1700
+ const key = mods.length ? mods.join('+') + '+' + e.key : e.key;
1701
+ extra = ` <span class="key-chip">⌨ ${escapeHtml(key)}</span>`;
1702
+ } else if (e.type === 'change' && e.value != null) {
1703
+ extra = ` <span class="sel">→</span> <span class="text">${escapeHtml(String(e.value).slice(0, 40))}</span>`;
1704
+ }
1705
+ return `${tagPart}${textPart}${selPart}${extra}`;
1706
+ }
1707
+ if (e.kind === 'network') {
1708
+ const status = e.status || 0;
1709
+ const sc = status >= 500 ? '5xx' : status >= 400 ? '4xx' : status >= 300 ? '3xx' : status >= 200 ? '2xx' : '0';
1710
+ return `<span class="method">${escapeHtml(e.method || '?')}</span><span class="status-${sc}">${status || 'ERR'}</span><span class="url"> ${escapeHtml((e.requestUrl || e.url || '').slice(0, 140))}</span>${e.durationMs ? `<span class="duration">${e.durationMs}ms</span>` : ''}`;
1711
+ }
1712
+ if (e.kind === 'navigation') {
1713
+ return `<span class="arrow">→</span><span class="url">${escapeHtml((e.to || '').slice(0, 160))}</span>`;
1714
+ }
1715
+ if (e.kind === 'error') {
1716
+ return `<span class="err">${escapeHtml((e.message || e.reason || '').slice(0, 160))}</span>`;
1717
+ }
1718
+ return '';
1719
+ }
1720
+
1721
+ /* ============================================================
1722
+ DETAIL PANEL
1723
+ ============================================================ */
1724
+
1725
+ function selectEvent(idx) {
1726
+ state.selectedIdx = idx;
1727
+ const e = state.events[idx];
1728
+ if (!e) return;
1729
+
1730
+ els.main.classList.add('detail-open');
1731
+ els.detailPanel.style.display = 'flex';
1732
+
1733
+ els.detailTitle.innerHTML = `
1734
+ <span class="kind-badge ${e.kind}"><span class="dot"></span>${escapeHtml((e.type || '').toUpperCase())}</span>
1735
+ <span class="detail-idx">#${idx + 1}</span>
1736
+ `;
1737
+ els.detailTime.textContent = e.clientTime ? e.clientTime.replace('T', ' ').slice(0, 23) + 'Z' : '';
1738
+
1739
+ let html = '';
1740
+
1741
+ // === SELECTORS (RPA 重点) ===
1742
+ if (e.target && e.target.selectors) {
1743
+ const sels = Object.entries(e.target.selectors);
1744
+ html += `
1745
+ <div class="detail-section">
1746
+ <div class="section-label">
1747
+ RPA 选择器 · 按稳定性排序
1748
+ <span class="accent">${sels.length} 种策略</span>
1749
+ </div>
1750
+ <div class="selector-list">
1751
+ ${sels.map(([k, v]) => `
1752
+ <div class="selector-row">
1753
+ <div class="sel-key">${escapeHtml(k)}</div>
1754
+ <div class="sel-val">${escapeHtml(v)}</div>
1755
+ <button class="copy-btn" data-copy="${escapeAttr(v)}" title="复制到剪贴板">
1756
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
1757
+ <rect x="5" y="5" width="8" height="8" rx="0.5"/>
1758
+ <path d="M3 11V3.5C3 3.22 3.22 3 3.5 3H11" stroke-linecap="round"/>
1759
+ </svg>
1760
+ </button>
1761
+ </div>
1762
+ `).join('')}
1763
+ </div>
1764
+ </div>
1765
+ `;
1766
+ }
1767
+
1768
+ // === ELEMENT STATE ===
1769
+ if (e.target && e.target.state) {
1770
+ const stateEntries = Object.entries(e.target.state).filter(([k, v]) => v !== undefined && v !== null);
1771
+ if (stateEntries.length) {
1772
+ html += `
1773
+ <div class="detail-section">
1774
+ <div class="section-label">元素状态</div>
1775
+ <div class="state-badges">
1776
+ ${stateEntries.map(([k, v]) => `
1777
+ <span class="state-badge ${v ? 'on' : 'off'}">
1778
+ ${escapeHtml(k)} <span class="v">${escapeHtml(String(v))}</span>
1779
+ </span>
1780
+ `).join('')}
1781
+ </div>
1782
+ </div>
1783
+ `;
1784
+ }
1785
+ }
1786
+
1787
+ // === ELEMENT META ===
1788
+ if (e.target) {
1789
+ const t = e.target;
1790
+ const items = [];
1791
+ if (t.tag) items.push(['TAG', t.tag.toUpperCase()]);
1792
+ if (t.role) items.push(['ROLE', t.role]);
1793
+ if (t.accessibleName) items.push(['NAME', t.accessibleName]);
1794
+ if (t.innerText && t.innerText !== t.accessibleName) items.push(['TEXT', t.innerText.slice(0, 200)]);
1795
+ if (t.boundingBox) {
1796
+ const b = t.boundingBox;
1797
+ items.push(['BOX', `${b.width}×${b.height} @ (${b.x}, ${b.y})`]);
1798
+ }
1799
+ if (t.classes && t.classes.length) items.push(['CLASS', t.classes.join(' ')]);
1800
+ if (items.length) {
1801
+ html += `
1802
+ <div class="detail-section">
1803
+ <div class="section-label">元素信息</div>
1804
+ <div class="meta-grid">
1805
+ ${items.map(([k, v]) => `<div class="k">${escapeHtml(k)}</div><div class="v">${escapeHtml(v)}</div>`).join('')}
1806
+ </div>
1807
+ </div>
1808
+ `;
1809
+ }
1810
+ }
1811
+
1812
+ // === INTERACTION CONTEXT ===
1813
+ if (e.mouse || e.modifiers || e.viewport) {
1814
+ const items = [];
1815
+ if (e.mouse) {
1816
+ const m = e.mouse;
1817
+ const btnName = m.button === 0 ? 'LEFT' : m.button === 1 ? 'MIDDLE' : m.button === 2 ? 'RIGHT' : 'BTN' + m.button;
1818
+ items.push(['BUTTON', btnName]);
1819
+ items.push(['PAGE', `(${m.pageX}, ${m.pageY})`]);
1820
+ items.push(['CLIENT', `(${m.clientX}, ${m.clientY})`]);
1821
+ items.push(['OFFSET', `(${m.offsetX}, ${m.offsetY})`]);
1822
+ }
1823
+ if (e.modifiers) {
1824
+ const mods = Object.entries(e.modifiers).filter(([k, v]) => v).map(([k]) => k.toUpperCase());
1825
+ if (mods.length) items.push(['MODIFIER', mods.join(' + ')]);
1826
+ }
1827
+ if (e.viewport) {
1828
+ items.push(['VIEWPORT', `${e.viewport.width}×${e.viewport.height} · DPR ${e.viewport.dpr}`]);
1829
+ if (e.viewport.scrollX || e.viewport.scrollY) {
1830
+ items.push(['SCROLL', `(${e.viewport.scrollX}, ${e.viewport.scrollY})`]);
1831
+ }
1832
+ }
1833
+ if (items.length) {
1834
+ html += `
1835
+ <div class="detail-section">
1836
+ <div class="section-label">交互上下文</div>
1837
+ <div class="meta-grid">
1838
+ ${items.map(([k, v]) => `<div class="k">${escapeHtml(k)}</div><div class="v">${escapeHtml(v)}</div>`).join('')}
1839
+ </div>
1840
+ </div>
1841
+ `;
1842
+ }
1843
+ }
1844
+
1845
+ // === NETWORK META ===
1846
+ if (e.kind === 'network') {
1847
+ const items = [];
1848
+ items.push(['METHOD', e.method || '?']);
1849
+ items.push(['STATUS', String(e.status || 'ERR')]);
1850
+ if (e.requestUrl) items.push(['URL', e.requestUrl]);
1851
+ if (e.durationMs) items.push(['DURATION', e.durationMs + ' ms']);
1852
+ if (e.error) items.push(['ERROR', e.error]);
1853
+ html += `
1854
+ <div class="detail-section">
1855
+ <div class="section-label">请求信息</div>
1856
+ <div class="meta-grid">
1857
+ ${items.map(([k, v]) => `<div class="k">${escapeHtml(k)}</div><div class="v">${escapeHtml(v)}</div>`).join('')}
1858
+ </div>
1859
+ </div>
1860
+ `;
1861
+ }
1862
+
1863
+ // === RAW JSON ===
1864
+ html += `
1865
+ <div class="detail-section">
1866
+ <div class="section-label">原始事件数据 (JSON)</div>
1867
+ <div class="json-block">${syntaxHighlight(JSON.stringify(e, null, 2))}</div>
1868
+ </div>
1869
+ `;
1870
+
1871
+ els.detailContent.innerHTML = html;
1872
+
1873
+ // 高亮当前行
1874
+ renderList();
1875
+ }
1876
+
1877
+ function closeDetail() {
1878
+ state.selectedIdx = null;
1879
+ els.detailPanel.style.display = 'none';
1880
+ els.main.classList.remove('detail-open');
1881
+ renderList();
1882
+ }
1883
+
1884
+ /**
1885
+ * 简易 JSON 语法高亮(基于 escapeHtml 后的字符串做替换)
1886
+ */
1887
+ function syntaxHighlight(json) {
1888
+ return escapeHtml(json)
1889
+ .replace(/(&quot;[^&]*?&quot;)(?=\s*:)/g, '<span class="jk">$1</span>')
1890
+ .replace(/(:\s*)(&quot;[^&]*?&quot;)/g, '$1<span class="js">$2</span>')
1891
+ .replace(/(:\s*)(true|false)/g, '$1<span class="jb">$2</span>')
1892
+ .replace(/(:\s*)(null)/g, '$1<span class="jl">$2</span>')
1893
+ .replace(/(:\s*)(-?\d+\.?\d*)(?=[,\n\s\]])/g, '$1<span class="jn">$2</span>');
1894
+ }
1895
+
1896
+ /* ============================================================
1897
+ BROWSER CONTROL
1898
+ ============================================================ */
1899
+
1900
+ /**
1901
+ * 调用后端 /control/* 接口
1902
+ * @param {string} path 动作名(如 'open' / 'close' / 'focus')
1903
+ * @param {Object} [body] POST body
1904
+ */
1905
+ async function doControl(path, body) {
1906
+ try {
1907
+ const opts = body ? {
1908
+ method: 'POST',
1909
+ headers: { 'Content-Type': 'application/json' },
1910
+ body: JSON.stringify(body),
1911
+ } : { method: 'POST' };
1912
+ const resp = await fetch('/control/' + path, opts);
1913
+ if (!resp.ok) {
1914
+ const err = await resp.json().catch(() => ({}));
1915
+ throw new Error(err.error || `HTTP ${resp.status}`);
1916
+ }
1917
+ return await resp.json();
1918
+ } catch (e) {
1919
+ toast('✕ ' + e.message, 'error');
1920
+ throw e;
1921
+ }
1922
+ }
1923
+
1924
+ async function openUrl(newTab = false) {
1925
+ const raw = els.urlInput.value.trim();
1926
+ if (!raw) return;
1927
+ const url = /^https?:\/\//.test(raw) ? raw : 'https://' + raw;
1928
+ toast(`→ 正在打开 ${hostOf(url)}...`);
1929
+ try {
1930
+ await doControl('open', { url, newTab });
1931
+ toast(`✓ 已打开 ${hostOf(url)}`, 'success');
1932
+ // 立刻刷新一次拉取最新 tabs
1933
+ pollOnce();
1934
+ } catch (e) { /* toast already shown */ }
1935
+ }
1936
+
1937
+ /* ============================================================
1938
+ EVENT WIRING
1939
+ ============================================================ */
1940
+
1941
+ // 导航按钮
1942
+ $('btn-back').onclick = () => doControl('back', { index: state.activeTabIdx }).then(() => toast('← 后退')).catch(() => {});
1943
+ $('btn-forward').onclick = () => doControl('forward', { index: state.activeTabIdx }).then(() => toast('→ 前进')).catch(() => {});
1944
+ $('btn-reload').onclick = () => doControl('reload', { index: state.activeTabIdx }).then(() => toast('↻ 刷新')).catch(() => {});
1945
+
1946
+ // URL 输入框
1947
+ els.urlInput.addEventListener('keydown', e => {
1948
+ if (e.key === 'Enter') {
1949
+ e.preventDefault();
1950
+ openUrl(e.metaKey || e.ctrlKey); // Cmd+Enter / Ctrl+Enter 开新标签
1951
+ }
1952
+ });
1953
+ $('btn-open').onclick = () => openUrl(false);
1954
+ $('btn-newtab').onclick = () => openUrl(true);
1955
+
1956
+ // Tabs 交互(事件委托)
1957
+ els.tabsbar.addEventListener('click', async e => {
1958
+ const closeBtn = e.target.closest('.tab-close');
1959
+ if (closeBtn) {
1960
+ e.stopPropagation();
1961
+ const idx = parseInt(closeBtn.dataset.closeIdx, 10);
1962
+ await doControl('close', { index: idx }).catch(() => {});
1963
+ toast('✕ 已关闭标签');
1964
+ pollOnce();
1965
+ return;
1966
+ }
1967
+ const tab = e.target.closest('.tab');
1968
+ if (tab) {
1969
+ const idx = parseInt(tab.dataset.idx, 10);
1970
+ await doControl('focus', { index: idx }).catch(() => {});
1971
+ state.activeTabIdx = idx;
1972
+ renderTabs();
1973
+ }
1974
+ });
1975
+
1976
+ // 过滤 chips
1977
+ document.querySelectorAll('.filter-chip').forEach(chip => {
1978
+ chip.addEventListener('click', () => {
1979
+ document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
1980
+ chip.classList.add('active');
1981
+ state.filter = chip.dataset.filter;
1982
+ renderList();
1983
+ });
1984
+ });
1985
+
1986
+ // 搜索
1987
+ els.searchInput.addEventListener('input', e => {
1988
+ state.search = e.target.value.trim();
1989
+ renderList();
1990
+ });
1991
+
1992
+ // 暂停 / 恢复
1993
+ $('btn-pause').onclick = function () {
1994
+ state.paused = !state.paused;
1995
+ this.classList.toggle('active', state.paused);
1996
+ setConnected(state.connected);
1997
+ if (!state.paused) pollOnce();
1998
+ };
1999
+
2000
+ // 自动滚动开关
2001
+ $('btn-autoscroll').onclick = function () {
2002
+ state.autoScroll = !state.autoScroll;
2003
+ this.classList.toggle('active', state.autoScroll);
2004
+ toast(state.autoScroll ? '↓ 自动滚动 已开启' : '↓ 自动滚动 已关闭');
2005
+ };
2006
+
2007
+ // 导出 JSONL
2008
+ $('btn-export').onclick = () => {
2009
+ if (state.events.length === 0) {
2010
+ toast('暂无事件可导出');
2011
+ return;
2012
+ }
2013
+ const jsonl = state.events.map(e => JSON.stringify(e)).join('\n');
2014
+ const blob = new Blob([jsonl], { type: 'application/x-ndjson' });
2015
+ const a = document.createElement('a');
2016
+ a.href = URL.createObjectURL(blob);
2017
+ a.download = `recorder-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`;
2018
+ a.click();
2019
+ URL.revokeObjectURL(a.href);
2020
+ toast(`⬇ 已导出 ${state.events.length} 条事件`, 'success');
2021
+ };
2022
+
2023
+ // 清空显示(仅前端,磁盘文件不变;保留 lastSeen 以免刷出旧事件)
2024
+ $('btn-clear').onclick = () => {
2025
+ if (!confirm('清空当前显示?\n\n磁盘文件不受影响,只有新事件会出现。')) return;
2026
+ state.events = [];
2027
+ state.selectedIdx = null;
2028
+ closeDetail();
2029
+ renderList();
2030
+ updateStats();
2031
+ toast('⌫ 已清空显示');
2032
+ };
2033
+
2034
+ // 事件行点击
2035
+ els.eventList.addEventListener('click', e => {
2036
+ const row = e.target.closest('.event-row');
2037
+ if (!row) return;
2038
+ const idx = parseInt(row.dataset.idx, 10);
2039
+ selectEvent(idx);
2040
+ });
2041
+
2042
+ // 详情面板关闭
2043
+ $('btn-close-detail').onclick = closeDetail;
2044
+
2045
+ // 详情面板内的"复制选择器"按钮(事件委托)
2046
+ els.detailContent.addEventListener('click', e => {
2047
+ const btn = e.target.closest('.copy-btn');
2048
+ if (!btn) return;
2049
+ const text = btn.dataset.copy;
2050
+ navigator.clipboard.writeText(text).then(() => {
2051
+ btn.classList.add('copied');
2052
+ setTimeout(() => btn.classList.remove('copied'), 1200);
2053
+ toast(`✓ 已复制 ${text.slice(0, 50)}${text.length > 50 ? '…' : ''}`, 'success');
2054
+ }).catch(() => toast('✕ 复制失败', 'error'));
2055
+ });
2056
+
2057
+ // 主题切换按钮
2058
+ $('btn-theme').onclick = () => {
2059
+ toggleTheme();
2060
+ const cur = document.documentElement.getAttribute('data-theme');
2061
+ toast(cur === 'light' ? '☀ 明亮模式' : '☾ 暗色模式');
2062
+ };
2063
+
2064
+ // 键盘快捷键
2065
+ document.addEventListener('keydown', e => {
2066
+ const inputFocused = ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName);
2067
+ // Escape 关闭详情
2068
+ if (e.key === 'Escape' && state.selectedIdx !== null) {
2069
+ e.preventDefault();
2070
+ closeDetail();
2071
+ return;
2072
+ }
2073
+ // Space 切暂停(仅在非输入框聚焦时)
2074
+ if (e.key === ' ' && !inputFocused) {
2075
+ e.preventDefault();
2076
+ $('btn-pause').click();
2077
+ return;
2078
+ }
2079
+ // T 切主题
2080
+ if ((e.key === 't' || e.key === 'T') && !inputFocused && !e.metaKey && !e.ctrlKey) {
2081
+ e.preventDefault();
2082
+ $('btn-theme').click();
2083
+ return;
2084
+ }
2085
+ // Cmd/Ctrl + F 聚焦搜索
2086
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
2087
+ e.preventDefault();
2088
+ els.searchInput.focus();
2089
+ els.searchInput.select();
2090
+ }
2091
+ });
2092
+
2093
+ /* ============================================================
2094
+ BOOT
2095
+ ============================================================ */
2096
+ pollOnce();
2097
+ setInterval(pollOnce, POLL_INTERVAL);
2098
+ renderList();
2099
+ updateStats();
2100
+ </script>
2101
+ </body>
2102
+ </html>