@matware/e2e-runner 1.2.1 → 1.3.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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
@@ -7,6 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
9
9
  <style>
10
+ /* ── base.css ── */
11
+ /* ── Reset & Variables ── */
10
12
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
13
  :root{
12
14
  --bg:#090a10;--surface:#11131b;--surface2:#181b26;--surface3:#1f2333;
@@ -51,6 +53,24 @@ a{color:var(--accent);text-decoration:none}
51
53
  .pool-info{font-size:11px;color:var(--text2);line-height:1.7}
52
54
  .pool-info strong{color:var(--text)}
53
55
  .ws-dot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:4px}
56
+ .pool-list{margin-top:6px;display:flex;flex-direction:column;gap:3px}
57
+ .pool-item{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--text2);font-family:var(--mono);padding:2px 0}
58
+ .pool-item .pool-dot{width:6px;height:6px;margin-right:0}
59
+ .pool-item strong{color:var(--text);font-weight:500}
60
+ .pool-item .pool-sessions{margin-left:auto;color:var(--text3);font-size:9px}
61
+
62
+ /* ── Sync Status ── */
63
+ .sync-status{padding:12px 16px;border-top:1px solid var(--border)}
64
+ .sync-status .sync-header{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}
65
+ .sync-status .sync-mode{font-size:9px;padding:2px 6px;border-radius:3px;text-transform:uppercase;font-weight:600}
66
+ .sync-status .sync-mode.hub{background:var(--purple-dim);color:var(--purple)}
67
+ .sync-status .sync-mode.agent{background:var(--accent-dim);color:var(--accent)}
68
+ .sync-status .sync-mode.standalone{background:var(--surface3);color:var(--text3)}
69
+ .sync-status .sync-details{margin-top:6px;font-size:10px;color:var(--text3)}
70
+ .sync-status .sync-instances{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px}
71
+ .sync-status .sync-inst{display:inline-flex;align-items:center;gap:3px;padding:2px 6px;border-radius:10px;font-size:9px;background:var(--surface3);border:1px solid var(--border)}
72
+ .sync-status .sync-inst.online{border-color:var(--green);color:var(--green)}
73
+ .sync-status .sync-inst.offline{color:var(--text3)}
54
74
 
55
75
  /* ── Main ── */
56
76
  .main{margin-left:232px;flex:1;min-height:100vh;display:flex;flex-direction:column}
@@ -61,6 +81,18 @@ a{color:var(--accent);text-decoration:none}
61
81
  .view.active{display:block}
62
82
  #view-live.active{display:flex;flex-direction:column;flex:1;min-height:calc(100vh - 0px);padding:16px}
63
83
 
84
+ /* ── Responsive ── */
85
+ @media(max-width:768px){
86
+ .sidebar{width:60px}.sidebar-logo h1,.sidebar-section-label,.nav-item span:not(.icon):not(.badge),.pool-info,.sidebar select,.sidebar-logo .ver{display:none}
87
+ .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
88
+ .main{margin-left:60px}
89
+ .suite-grid,.gallery,.module-grid{grid-template-columns:1fr}
90
+ .lr-test-grid{grid-template-columns:1fr}
91
+ .toast-container{right:12px;bottom:12px}
92
+ }
93
+
94
+
95
+ /* ── components.css ── */
64
96
  /* ── Buttons ── */
65
97
  .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:var(--r);font-family:var(--mono);font-size:11px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;white-space:nowrap}
66
98
  .btn:hover{background:var(--surface3);border-color:var(--border-hi)}
@@ -84,24 +116,6 @@ a{color:var(--accent);text-decoration:none}
84
116
  .stat-val.accent{color:var(--accent)}
85
117
  .stat-val.purple{color:var(--purple)}
86
118
 
87
- /* ── Learnings ── */
88
- .learn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:20px}
89
- .learn-stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px;text-align:center}
90
- .learn-stat-val{font-size:24px;font-weight:700;margin-bottom:2px}
91
- .learn-stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
92
- .learn-section{margin-bottom:20px}
93
- .learn-section-title{font-family:var(--sans);font-size:13px;font-weight:600;margin-bottom:10px;color:var(--text)}
94
- .learn-table{width:100%;border-collapse:collapse;font-size:11px}
95
- .learn-table th{text-align:left;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;padding:6px 10px;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
96
- .learn-table th:hover{color:var(--text2)}
97
- .learn-table th.sorted::after{content:' \\25B2';font-size:8px}
98
- .learn-table th.sorted.desc::after{content:' \\25BC'}
99
- .learn-table td{padding:6px 10px;border-bottom:1px solid var(--border);color:var(--text2)}
100
- .learn-table td code{background:var(--surface3);padding:1px 5px;border-radius:3px;font-size:10px;color:var(--text)}
101
- .learn-table tbody tr:hover td{background:var(--surface2);color:var(--text)}
102
- .learn-trend-chart{width:100%;height:100px;margin-bottom:20px}
103
- .learn-trend-chart svg{width:100%;height:100%}
104
-
105
119
  /* ── Tables ── */
106
120
  .tbl-wrap{overflow-x:auto}
107
121
  table{width:100%;border-collapse:collapse;font-size:12px}
@@ -110,32 +124,144 @@ td{padding:8px 12px;border-bottom:1px solid var(--border)}
110
124
  tbody tr{cursor:pointer;transition:background .1s}
111
125
  tbody tr:hover td{background:var(--surface2)}
112
126
  tbody tr.selected td{background:var(--accent-dim)}
127
+
128
+ /* ── Badges ── */
113
129
  .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
114
130
  .badge.pass{background:var(--green-dim);color:var(--green)}
115
131
  .badge.fail{background:var(--red-dim);color:var(--red)}
116
132
  .badge.flaky{background:var(--amber-dim);color:var(--amber)}
117
133
  .badge.run{background:var(--purple-dim);color:var(--purple)}
118
134
 
119
- /* ── Trend Chart ── */
120
- .chart{display:flex;align-items:flex-end;gap:3px;height:60px}
121
- .chart-bar{flex:1;min-width:3px;max-width:16px;border-radius:2px 2px 0 0;cursor:pointer;position:relative;transition:opacity .15s}
122
- .chart-bar:hover{opacity:.75}
123
- .chart-bar .tip{display:none;position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border);padding:4px 8px;border-radius:4px;font-size:10px;white-space:nowrap;z-index:10;pointer-events:none}
124
- .chart-bar:hover .tip{display:block}
135
+ /* ── Empty ── */
136
+ .empty{text-align:center;padding:48px 24px;color:var(--text3)}
137
+ .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
125
138
 
126
- /* ── Project Accordion ── */
127
- .project-accordion{margin-bottom:2px}
128
- .project-accordion-header{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);cursor:pointer;transition:all .15s;user-select:none}
129
- .project-accordion-header:hover{background:var(--surface2);border-color:var(--border-hi)}
130
- .project-accordion.open>.project-accordion-header{border-radius:var(--r) var(--r) 0 0;border-bottom-color:transparent;background:var(--surface2)}
131
- .project-accordion-chevron{font-size:10px;color:var(--text3);transition:transform .2s ease;flex-shrink:0;width:16px;text-align:center}
132
- .project-accordion.open>.project-accordion-header .project-accordion-chevron{transform:rotate(90deg);color:var(--accent)}
133
- .project-accordion-name{font-family:var(--sans);font-size:13px;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
134
- .project-accordion-meta{display:flex;align-items:center;gap:10px;flex-shrink:0}
135
- .project-accordion-badge{font-size:10px;font-weight:600;padding:2px 8px;border-radius:10px;background:var(--surface3);color:var(--text2)}
136
- .project-accordion-body{overflow:hidden;max-height:0;transition:max-height .3s ease;border:1px solid var(--border);border-top:none;border-radius:0 0 var(--r) var(--r);background:var(--bg)}
137
- .project-accordion.open>.project-accordion-body{max-height:5000px}
139
+ /* ── Modal ── */
140
+ .modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200;display:none;align-items:center;justify-content:center;padding:24px;cursor:pointer}
141
+ .modal.open{display:flex}
142
+ .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
138
143
 
144
+ /* ── Toast Notifications ── */
145
+ .toast-container{position:fixed;bottom:24px;right:24px;z-index:300;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none}
146
+ .toast{padding:10px 16px;border-radius:var(--r);font-family:var(--mono);font-size:11px;font-weight:500;color:#fff;pointer-events:auto;animation:toastIn .3s ease;min-width:200px;max-width:380px;box-shadow:0 8px 24px rgba(0,0,0,.4);display:flex;align-items:center;gap:8px}
147
+ .toast.success{background:var(--green);border:1px solid rgba(255,255,255,.15)}
148
+ .toast.error{background:var(--red);border:1px solid rgba(255,255,255,.15)}
149
+ .toast.info{background:var(--accent);border:1px solid rgba(255,255,255,.15)}
150
+ .toast.fade-out{animation:toastOut .3s ease forwards}
151
+ @keyframes toastIn{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}
152
+ @keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(24px)}}
153
+ .toast.clickable{cursor:pointer}
154
+ .toast.clickable:hover{filter:brightness(1.1)}
155
+
156
+ /* ── Copy Button ── */
157
+ .copy-btn{display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:4px;font-size:9px;font-family:var(--mono);font-weight:500;color:var(--text3);background:transparent;border:1px solid transparent;cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;flex-shrink:0}
158
+ .copy-btn:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-dim)}
159
+ .copy-btn.copied{color:var(--green);border-color:var(--green);background:var(--green-dim)}
160
+
161
+ /* ── Screenshot Hash Badge ── */
162
+ .ss-hash{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:10px;font-family:var(--mono);font-size:9px;font-weight:500;background:var(--surface3);border:1px solid var(--border);color:var(--text2);cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;vertical-align:middle}
163
+ .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
164
+ .ss-hash.copied{border-color:var(--green);color:var(--green);background:var(--green-dim)}
165
+ .ss-hash .ss-icon{font-size:10px;line-height:1}
166
+
167
+ /* ── Trigger Source Badges ── */
168
+ .trigger-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;font-family:var(--mono);white-space:nowrap}
169
+ .trigger-badge.src-dashboard{background:rgba(127,140,162,.10);color:var(--text2)}
170
+ .trigger-badge.src-mcp{background:var(--purple-dim);color:var(--purple)}
171
+ .trigger-badge.src-cli{background:var(--accent-dim);color:var(--accent)}
172
+ .trigger-badge.src-unknown{background:rgba(70,75,98,.15);color:var(--text3)}
173
+ .trigger-badge .trig-icon{font-size:11px;line-height:1}
174
+
175
+ /* ── Filter Bar ── */
176
+ .filter-bar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
177
+ .filter-btn{padding:5px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-family:var(--mono);font-size:11px;cursor:pointer;transition:all .15s}
178
+ .filter-btn:hover{background:var(--surface3);border-color:var(--border-hi)}
179
+ .filter-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
180
+ .filter-bar input{padding:5px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px;max-width:200px}
181
+ .filter-bar input:focus{outline:none;border-color:var(--accent)}
182
+ .filter-bar input::placeholder{color:var(--text3)}
183
+
184
+ /* ── Inner Tabs ── */
185
+ .tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:20px}
186
+ .tab-btn{padding:8px 16px;font-family:var(--mono);font-size:11px;font-weight:500;color:var(--text3);cursor:pointer;border:none;background:transparent;border-bottom:2px solid transparent;transition:all .15s}
187
+ .tab-btn:hover{color:var(--text2);background:var(--surface2)}
188
+ .tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
189
+ .tab-pane{display:none}
190
+ .tab-pane.active{display:block}
191
+
192
+ /* ── Keyboard Shortcuts Modal ── */
193
+ .kb-modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:250;display:none;align-items:center;justify-content:center;padding:24px}
194
+ .kb-modal.open{display:flex}
195
+ .kb-modal-content{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:24px;max-width:420px;width:100%;max-height:80vh;overflow-y:auto}
196
+ .kb-modal-content h2{font-family:var(--sans);font-size:16px;font-weight:700;margin-bottom:16px;color:var(--text)}
197
+ .kb-row{display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)}
198
+ .kb-row:last-child{border-bottom:none}
199
+ .kb-key{display:inline-flex;align-items:center;justify-content:center;min-width:24px;padding:2px 8px;border-radius:4px;background:var(--surface3);border:1px solid var(--border);font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent)}
200
+ .kb-desc{font-size:12px;color:var(--text2)}
201
+
202
+ /* ── Serial / Pool Badges ── */
203
+ .serial-badge{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:600;background:var(--amber-dim);color:var(--amber);vertical-align:middle;margin-left:4px}
204
+ .pool-badge{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:600;background:rgba(99,102,241,.15);color:#818cf8;vertical-align:middle;margin-left:4px;font-family:var(--mono);letter-spacing:-.3px}
205
+ .pool-badge::before{content:'\1F517';font-size:8px}
206
+
207
+
208
+ /* ── view-watch.css ── */
209
+ /* ── Watch View: Project Cards ── */
210
+ .watch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:24px}
211
+ .watch-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;transition:border-color .2s,box-shadow .2s}
212
+ .watch-card:hover{border-color:var(--border-hi);box-shadow:0 2px 12px rgba(0,0,0,.25)}
213
+ .watch-card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
214
+ .watch-card-name{font-family:var(--sans);font-size:14px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
215
+ .watch-card-icons{display:flex;gap:6px;flex-shrink:0;margin-left:8px}
216
+ .watch-card-icons .btn{padding:3px 8px;font-size:10px}
217
+
218
+ /* ── Sparkline ── */
219
+ .watch-sparkline{height:40px;margin-bottom:10px}
220
+ .watch-sparkline svg{width:100%;height:100%;display:block}
221
+
222
+ /* ── Card Footer ── */
223
+ .watch-card-footer{display:flex;align-items:center;justify-content:space-between;font-size:11px}
224
+ .watch-card-status{display:flex;align-items:center;gap:6px}
225
+ .watch-card-status .status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
226
+ .watch-card-status .status-dot.green{background:var(--green);box-shadow:0 0 6px var(--green)}
227
+ .watch-card-status .status-dot.red{background:var(--red);box-shadow:0 0 6px var(--red)}
228
+ .watch-card-status .status-dot.amber{background:var(--amber);box-shadow:0 0 6px var(--amber)}
229
+ .watch-card-status .status-dot.dim{background:var(--text3)}
230
+ .watch-card-rate{font-weight:600}
231
+ .watch-card-rate.green{color:var(--green)}
232
+ .watch-card-rate.amber{color:var(--amber)}
233
+ .watch-card-rate.red{color:var(--red)}
234
+
235
+ .watch-card-meta{display:flex;flex-direction:column;gap:4px;margin-top:10px;font-size:10px;color:var(--text3)}
236
+ .watch-card-meta span{display:flex;align-items:center;gap:6px}
237
+ .watch-card-countdown{color:var(--accent);font-weight:500;font-variant-numeric:tabular-nums}
238
+ .watch-card-commit{font-family:var(--mono);color:var(--text3)}
239
+
240
+ /* ── Event Log ── */
241
+ .watch-event-log{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
242
+ .watch-event-log-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);background:var(--surface2)}
243
+ .watch-event-log-header .title{font-family:var(--sans);font-size:13px;font-weight:600}
244
+ .watch-event-log-body{max-height:400px;overflow-y:auto}
245
+ .watch-event-row{display:grid;grid-template-columns:120px 130px minmax(80px,1fr) 46px 74px 40px 52px 60px;align-items:center;gap:0;padding:7px 16px;border-bottom:1px solid var(--border);font-size:11px;transition:background .1s}
246
+ .watch-event-row:last-child{border-bottom:none}
247
+ .watch-event-row:hover{background:var(--surface2)}
248
+ .watch-event-row.we-header{font-size:9px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;padding:6px 16px;background:var(--surface2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:1}
249
+ .watch-event-row.we-header:hover{background:var(--surface2)}
250
+ .watch-event-time{color:var(--text3);font-variant-numeric:tabular-nums;font-family:var(--mono);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
251
+ .watch-event-project{color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px}
252
+ .watch-event-suite{color:var(--accent);font-family:var(--mono);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px}
253
+ .watch-event-result{justify-self:center}
254
+ .watch-event-counts{font-family:var(--mono);font-size:10px;color:var(--text2);white-space:nowrap;text-align:center}
255
+ .watch-event-counts .we-counts-ok{color:var(--green)}
256
+ .watch-event-rate{font-weight:600;color:var(--text2);font-variant-numeric:tabular-nums;text-align:right}
257
+ .watch-event-duration{color:var(--text3);font-family:var(--mono);font-size:10px;text-align:right}
258
+ .we-trigger{color:var(--text3);font-size:9px;padding:1px 6px;border-radius:8px;background:var(--surface3);white-space:nowrap;text-align:center;justify-self:end}
259
+
260
+ /* ── Watch Table (legacy) ── */
261
+ .watch-jobs-table{width:100%;border-collapse:collapse;font-size:11px}
262
+
263
+
264
+ /* ── view-tests.css ── */
139
265
  /* ── Suite Cards ── */
140
266
  .suite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:16px}
141
267
  .suite-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;transition:border-color .2s,box-shadow .2s}
@@ -198,94 +324,49 @@ tbody tr.selected td{background:var(--accent-dim)}
198
324
  .suite-modal-expect{padding:10px 24px 10px 48px;background:var(--green-dim);border-bottom:1px solid var(--border);font-size:11px;color:var(--green);display:flex;align-items:flex-start;gap:8px}
199
325
  .suite-modal-expect-label{font-weight:600;flex-shrink:0}
200
326
 
201
- /* ── Live Execution ── */
202
- .live-panel{display:none;background:var(--surface);border:1px solid var(--purple);border-radius:var(--r);overflow:hidden;animation:fadeSlide .3s ease;flex-direction:column}
203
- .live-panel.active{display:flex;flex:1;min-height:0}
204
- @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
205
- .live-header{padding:14px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);background:var(--purple-dim)}
206
- .live-header .label{font-weight:600;color:var(--purple);font-size:12px;display:flex;align-items:center;gap:8px}
207
- .live-header .label .dot{width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
208
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
209
- .live-project{display:flex;align-items:center;padding:2px 10px;background:rgba(255,255,255,.05);border-radius:4px;border:1px solid var(--border)}
210
- .live-stats{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-left:auto}
211
- .live-stats span strong{color:var(--text)}
212
- .live-progress{height:3px;background:var(--surface3)}
213
- .live-progress-fill{height:100%;background:var(--purple);transition:width .4s;border-radius:0 2px 2px 0}
214
- .live-tests{padding:12px 16px;display:flex;flex-direction:column;gap:2px;flex:1;overflow-y:auto;min-height:0}
215
- .live-test{padding:10px 12px;border-radius:var(--r);border-left:3px solid var(--text3);background:var(--surface2);font-size:11px;transition:border-color .2s,padding .25s,max-height .35s cubic-bezier(.4,0,.2,1)}
216
- .live-test.running{border-left-color:var(--purple)}
217
- .live-test.passed{border-left-color:var(--green)}
218
- .live-test.failed{border-left-color:var(--red)}
219
- .live-test.collapsed{cursor:pointer;padding:6px 12px}
220
- .live-test.collapsed:hover{background:var(--surface3)}
221
- .live-test.collapsed .lt-meta,.live-test.collapsed .lt-actions,.live-test.collapsed .lt-screenshots{display:none}
222
- .live-test.collapsed .lt-name{margin-bottom:0}
223
- .live-test.collapsed .lt-summary{display:flex}
224
- .live-test .lt-name{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px}
225
- .live-test .lt-summary{display:none;align-items:center;gap:8px;margin-left:auto;font-size:10px;color:var(--text3);font-family:var(--mono)}
226
- .live-test .lt-summary .lt-dur{color:var(--text2)}
227
- .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
228
- .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
229
- .live-test .lt-icon{font-size:12px}
230
- .lt-actions{overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
231
- .lt-step{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:10px;font-family:var(--mono);line-height:1.4}
232
- .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
233
- .lt-step .step-icon.ok{color:var(--green)}
234
- .lt-step .step-icon.fail{color:var(--red)}
235
- .lt-step .step-icon.run{color:var(--purple)}
236
- .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
237
- .lt-step .step-detail{color:var(--text2);flex:1;min-width:0;white-space:pre-wrap;word-break:break-word}
238
- .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
239
- .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
240
- .lt-screenshots-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:10px;color:var(--text3);font-family:var(--mono);padding:2px 0;user-select:none}
241
- .lt-screenshots-toggle:hover{color:var(--text)}
242
- .lt-screenshots-toggle .ss-arrow{transition:transform .2s;font-size:8px}
243
- .lt-screenshots-toggle.open .ss-arrow{transform:rotate(90deg)}
244
- .lt-screenshots-grid{display:none;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding-top:6px}
245
- .lt-screenshots-toggle.open+.lt-screenshots-grid{display:grid}
246
- .lt-ss-thumb{position:relative;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;aspect-ratio:16/10;background:var(--surface2)}
247
- .lt-ss-thumb img{width:100%;height:100%;object-fit:cover;display:block}
248
- .lt-ss-thumb:hover{border-color:var(--purple);box-shadow:0 0 0 1px var(--purple)}
249
- .lt-ss-label{font-size:8px;color:var(--text3);font-family:var(--mono);text-align:center;padding:2px 2px 0;display:flex;align-items:center;justify-content:center;gap:3px;flex-wrap:wrap}
250
- .lr-section-header{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;margin:6px 0 2px;border-radius:6px;font-family:var(--mono);font-size:12px;font-weight:700;border-left:3px solid var(--purple)}
251
- .lr-section-header.running{border-color:var(--purple);background:rgba(139,92,246,.08);color:var(--purple)}
252
- .lr-section-header.pass{border-color:var(--green);background:rgba(52,211,153,.08);color:var(--green)}
253
- .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
254
- .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
255
- .lr-project-name{letter-spacing:.5px}
256
- .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
257
- .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
258
- .live-done.has-failures{background:var(--red-dim);color:var(--red)}
259
- .live-close{padding:4px 10px;font-size:10px;background:transparent;border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer}
260
- .live-close:hover{color:var(--text);border-color:var(--border-hi)}
261
- .live-clear-btn{padding:5px 12px;font-size:10px;font-family:var(--mono);font-weight:500;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer;display:none;transition:all .15s}
262
- .live-clear-btn:hover{color:var(--text);border-color:var(--border-hi);background:var(--surface3)}
263
- .lr-dismiss{padding:2px 6px;font-size:9px;font-family:var(--mono);background:transparent;border:1px solid transparent;border-radius:4px;color:var(--text3);cursor:pointer;transition:all .15s;margin-left:auto}
264
- .lr-dismiss:hover{color:var(--red);border-color:rgba(239,68,68,.3);background:var(--red-dim)}
327
+ /* ── Project Accordion ── */
328
+ .project-accordion{margin-bottom:2px}
329
+ .project-accordion-header{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);cursor:pointer;transition:all .15s;user-select:none}
330
+ .project-accordion-header:hover{background:var(--surface2);border-color:var(--border-hi)}
331
+ .project-accordion.open>.project-accordion-header{border-radius:var(--r) var(--r) 0 0;border-bottom-color:transparent;background:var(--surface2)}
332
+ .project-accordion-chevron{font-size:10px;color:var(--text3);transition:transform .2s ease;flex-shrink:0;width:16px;text-align:center}
333
+ .project-accordion.open>.project-accordion-header .project-accordion-chevron{transform:rotate(90deg);color:var(--accent)}
334
+ .project-accordion-name{font-family:var(--sans);font-size:13px;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
335
+ .project-accordion-meta{display:flex;align-items:center;gap:10px;flex-shrink:0}
336
+ .project-accordion-badge{font-size:10px;font-weight:600;padding:2px 8px;border-radius:10px;background:var(--surface3);color:var(--text2)}
337
+ .project-accordion-body{overflow:hidden;max-height:0;transition:max-height .3s ease;border:1px solid var(--border);border-top:none;border-radius:0 0 var(--r) var(--r);background:var(--bg)}
338
+ .project-accordion.open>.project-accordion-body{max-height:5000px}
265
339
 
266
- .live-nav-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
267
- .spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
268
- .spinner-small{display:inline-block;width:8px;height:8px;border:1.5px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
269
- @keyframes spin{to{transform:rotate(360deg)}}
340
+ /* ── Module Cards ── */
341
+ .module-section-title{font-family:var(--sans);font-size:14px;font-weight:600;margin:24px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--text2);display:flex;align-items:center;gap:8px}
342
+ .module-section-title .mod-icon{color:var(--purple)}
343
+ .module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
344
+ .module-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;border-left:3px solid var(--purple);transition:border-color .15s}
345
+ .module-card:hover{border-color:var(--border-hi);border-left-color:var(--purple)}
346
+ .module-card-name{font-weight:600;font-size:13px;color:var(--purple);margin-bottom:4px}
347
+ .module-card-desc{font-size:11px;color:var(--text2);margin-bottom:8px}
348
+ .module-card-meta{font-size:10px;color:var(--text3);display:flex;gap:12px}
349
+ .module-card-params{list-style:none;font-size:10px;color:var(--text2);margin-top:6px}
350
+ .module-card-params li{padding:2px 0}
351
+ .module-card-params li::before{content:'$';color:var(--purple);margin-right:4px}
352
+
353
+ /* ── Variables ── */
354
+ .var-table{width:100%;border-collapse:collapse;font-size:12px}
355
+ .var-table th{text-align:left;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;padding:8px 12px;border-bottom:1px solid var(--border)}
356
+ .var-table td{padding:8px 12px;border-bottom:1px solid var(--border)}
357
+ .var-table td code{background:var(--surface3);padding:1px 5px;border-radius:3px;font-size:11px}
358
+ .var-add-form{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;margin-bottom:16px}
359
+ .var-add-form input,.var-add-form select{padding:6px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
360
+ .var-add-form input:focus,.var-add-form select:focus{outline:none;border-color:var(--accent)}
270
361
 
271
- /* ── Screenshots ── */
272
- .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
273
- .gallery-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer;transition:border-color .15s}
274
- .gallery-item:hover{border-color:var(--accent)}
275
- .gallery-item img{width:100%;height:150px;object-fit:cover;display:block}
276
- .gallery-item .cap{padding:6px 10px;font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px}
277
- .gallery-item .cap .cap-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
278
- .gallery-item .cap .ss-hash{flex-shrink:0}
279
- .ss-search{display:flex;gap:8px;margin-bottom:16px;align-items:center}
280
- .ss-search input{flex:1;max-width:320px;padding:8px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
281
- .ss-search input:focus{outline:none;border-color:var(--accent)}
282
- .ss-search input::placeholder{color:var(--text3)}
283
- .ss-search button{padding:8px 16px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px;cursor:pointer;transition:background .15s,border-color .15s}
284
- .ss-search button:hover{background:var(--surface3);border-color:var(--accent)}
285
- .ss-search-result{margin-bottom:20px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r)}
286
- .ss-search-result img{max-width:100%;max-height:500px;border-radius:var(--r);cursor:pointer;display:block;margin-top:8px}
287
- .ss-search-result .ss-result-label{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px}
288
- .ss-search-error{font-size:11px;color:var(--red);margin-bottom:12px}
362
+
363
+ /* ── view-runs.css ── */
364
+ /* ── Trend Chart ── */
365
+ .chart{display:flex;align-items:flex-end;gap:3px;height:60px}
366
+ .chart-bar{flex:1;min-width:3px;max-width:16px;border-radius:2px 2px 0 0;cursor:pointer;position:relative;transition:opacity .15s}
367
+ .chart-bar:hover{opacity:.75}
368
+ .chart-bar .tip{display:none;position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border);padding:4px 8px;border-radius:4px;font-size:10px;white-space:nowrap;z-index:10;pointer-events:none}
369
+ .chart-bar:hover .tip{display:block}
289
370
 
290
371
  /* ── Inline Run Detail ── */
291
372
  .run-detail-row td{padding:0!important;border-bottom:2px solid var(--purple)}
@@ -312,6 +393,8 @@ tbody tr.selected td{background:var(--accent-dim)}
312
393
  .rd-test-body{padding:16px}
313
394
  .rd-retries{font-size:11px;color:var(--amber);margin-bottom:10px;display:flex;align-items:center;gap:6px}
314
395
  .rd-error-msg{font-size:12px;color:var(--red);padding:10px 14px;background:var(--red-dim);border-radius:var(--r);margin-bottom:12px;word-break:break-all;border-left:3px solid var(--red);line-height:1.5;position:relative;padding-right:60px}
396
+ .rd-error-msg .copy-btn{position:absolute;top:8px;right:8px;opacity:0}
397
+ .rd-error-msg:hover .copy-btn{opacity:1}
315
398
  .rd-shots{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px}
316
399
  .rd-shot{width:140px;border-radius:6px;overflow:hidden;border:1px solid var(--border);cursor:pointer;transition:all .2s;background:var(--surface2)}
317
400
  .rd-shot:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 6px 16px rgba(0,0,0,.35)}
@@ -326,6 +409,8 @@ tbody tr.selected td{background:var(--accent-dim)}
326
409
  .rd-log-item{font-size:11px;padding:4px 10px;border-left:2px solid var(--border);margin-bottom:2px;color:var(--text2);word-break:break-all;line-height:1.5}
327
410
  .rd-log-item.error{border-left-color:var(--red);color:var(--red)}
328
411
  .rd-log-item.warning,.rd-log-item.warn{border-left-color:var(--amber);color:var(--amber)}
412
+
413
+ /* ── Network Panel ── */
329
414
  .rd-net-panel{margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface)}
330
415
  .rd-net-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);cursor:pointer;user-select:none;transition:background .15s}
331
416
  .rd-net-head:hover{background:var(--surface3)}
@@ -359,10 +444,19 @@ tbody tr.selected td{background:var(--accent-dim)}
359
444
  .rd-net-status.s2xx{color:var(--green)}
360
445
  .rd-net-status.s3xx{color:var(--amber)}
361
446
  .rd-net-status.s4xx,.rd-net-status.s5xx{color:var(--red)}
447
+ .rd-net-op{flex-shrink:0;padding:1px 7px;border-radius:3px;font-size:9px;font-weight:600;background:var(--purple-dim,rgba(168,85,247,.12));color:var(--purple,#a855f7);font-family:var(--sans);letter-spacing:.01em;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
362
448
  .rd-net-url{flex:1;min-width:0;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
363
449
  .rd-net-dur{width:60px;flex-shrink:0;text-align:right;color:var(--text3);font-variant-numeric:tabular-nums}
364
450
  .rd-net-detail{display:none;border-bottom:1px solid var(--border);background:var(--bg);overflow:hidden}
365
451
  .rd-net-row.open+.rd-net-detail{display:block}
452
+ .rd-net-row .copy-btn{opacity:0;padding:1px 6px}
453
+ .rd-net-row:hover .copy-btn{opacity:1}
454
+ .rd-net-body .rd-log-item{padding:6px 14px;margin-bottom:0;border-left:3px solid var(--border);border-bottom:1px solid var(--border);font-size:11px}
455
+ .rd-net-body .rd-log-item:last-child{border-bottom:none}
456
+ .rd-net-body .rd-log-item.error{border-left-color:var(--red)}
457
+ .rd-net-body .rd-log-item.warn,.rd-net-body .rd-log-item.warning{border-left-color:var(--amber)}
458
+
459
+ /* ── Network Detail Sections ── */
366
460
  .rd-nd-section{border-bottom:1px solid var(--border)}
367
461
  .rd-nd-section:last-child{border-bottom:none}
368
462
  .rd-nd-toggle{display:flex;align-items:center;gap:8px;padding:8px 16px;cursor:pointer;user-select:none;transition:background .15s;font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.06em}
@@ -379,87 +473,12 @@ tbody tr.selected td{background:var(--accent-dim)}
379
473
  .rd-hdr-key{padding:3px 12px 3px 0;color:var(--accent);font-weight:500;border-bottom:1px solid var(--border);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
380
474
  .rd-hdr-val{padding:3px 0;color:var(--text2);border-bottom:1px solid var(--border);word-break:break-all}
381
475
  .rd-nd-empty{color:var(--text3);font-size:11px;padding:4px 0;font-style:italic}
382
- .copy-btn{display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:4px;font-size:9px;font-family:var(--mono);font-weight:500;color:var(--text3);background:transparent;border:1px solid transparent;cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;flex-shrink:0}
383
- .copy-btn:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-dim)}
384
- .copy-btn.copied{color:var(--green);border-color:var(--green);background:var(--green-dim)}
385
- .rd-error-msg .copy-btn{position:absolute;top:8px;right:8px;opacity:0}
386
- .rd-error-msg:hover .copy-btn{opacity:1}
387
- .rd-net-row .copy-btn{opacity:0;padding:1px 6px}
388
- .rd-net-row:hover .copy-btn{opacity:1}
389
- .rd-net-body .rd-log-item{padding:6px 14px;margin-bottom:0;border-left:3px solid var(--border);border-bottom:1px solid var(--border);font-size:11px}
390
- .rd-net-body .rd-log-item:last-child{border-bottom:none}
391
- .rd-net-body .rd-log-item.error{border-left-color:var(--red)}
392
- .rd-net-body .rd-log-item.warn,.rd-net-body .rd-log-item.warning{border-left-color:var(--amber)}
476
+
393
477
  tr.expanded td{background:var(--surface2)!important}
394
478
  tr.expanded td:first-child{position:relative}
395
479
  tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--purple)}
396
480
 
397
- /* ── Screenshot Hash Badge ── */
398
- .ss-hash{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:10px;font-family:var(--mono);font-size:9px;font-weight:500;background:var(--surface3);border:1px solid var(--border);color:var(--text2);cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;vertical-align:middle}
399
- .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
400
- .ss-hash.copied{border-color:var(--green);color:var(--green);background:var(--green-dim)}
401
- .ss-hash .ss-icon{font-size:10px;line-height:1}
402
-
403
- /* ── Trigger Source Badges ── */
404
- .trigger-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;font-family:var(--mono);white-space:nowrap}
405
- .trigger-badge.src-dashboard{background:rgba(127,140,162,.10);color:var(--text2)}
406
- .trigger-badge.src-mcp{background:var(--purple-dim);color:var(--purple)}
407
- .trigger-badge.src-cli{background:var(--accent-dim);color:var(--accent)}
408
- .trigger-badge.src-unknown{background:rgba(70,75,98,.15);color:var(--text3)}
409
- .trigger-badge .trig-icon{font-size:11px;line-height:1}
410
-
411
- /* ── Empty ── */
412
- .empty{text-align:center;padding:48px 24px;color:var(--text3)}
413
- .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
414
-
415
- /* ── Modal ── */
416
- .modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200;display:none;align-items:center;justify-content:center;padding:24px;cursor:pointer}
417
- .modal.open{display:flex}
418
- .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
419
-
420
- /* ── Toast Notifications (NEW) ── */
421
- .toast-container{position:fixed;bottom:24px;right:24px;z-index:300;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none}
422
- .toast{padding:10px 16px;border-radius:var(--r);font-family:var(--mono);font-size:11px;font-weight:500;color:#fff;pointer-events:auto;animation:toastIn .3s ease;min-width:200px;max-width:380px;box-shadow:0 8px 24px rgba(0,0,0,.4);display:flex;align-items:center;gap:8px}
423
- .toast.success{background:var(--green);border:1px solid rgba(255,255,255,.15)}
424
- .toast.error{background:var(--red);border:1px solid rgba(255,255,255,.15)}
425
- .toast.info{background:var(--accent);border:1px solid rgba(255,255,255,.15)}
426
- .toast.fade-out{animation:toastOut .3s ease forwards}
427
- @keyframes toastIn{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}
428
- @keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(24px)}}
429
-
430
- /* ── Filter Bar (NEW) ── */
431
- .filter-bar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
432
- .filter-btn{padding:5px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-family:var(--mono);font-size:11px;cursor:pointer;transition:all .15s}
433
- .filter-btn:hover{background:var(--surface3);border-color:var(--border-hi)}
434
- .filter-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
435
- .filter-bar input{padding:5px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px;max-width:200px}
436
- .filter-bar input:focus{outline:none;border-color:var(--accent)}
437
- .filter-bar input::placeholder{color:var(--text3)}
438
-
439
- /* ── Module Cards (NEW) ── */
440
- .module-section-title{font-family:var(--sans);font-size:14px;font-weight:600;margin:24px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--text2);display:flex;align-items:center;gap:8px}
441
- .module-section-title .mod-icon{color:var(--purple)}
442
- .module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
443
- .module-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;border-left:3px solid var(--purple);transition:border-color .15s}
444
- .module-card:hover{border-color:var(--border-hi);border-left-color:var(--purple)}
445
- .module-card-name{font-weight:600;font-size:13px;color:var(--purple);margin-bottom:4px}
446
- .module-card-desc{font-size:11px;color:var(--text2);margin-bottom:8px}
447
- .module-card-meta{font-size:10px;color:var(--text3);display:flex;gap:12px}
448
- .module-card-params{list-style:none;font-size:10px;color:var(--text2);margin-top:6px}
449
- .module-card-params li{padding:2px 0}
450
- .module-card-params li::before{content:'$';color:var(--purple);margin-right:4px}
451
-
452
- /* ── Keyboard Shortcuts Modal (NEW) ── */
453
- .kb-modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:250;display:none;align-items:center;justify-content:center;padding:24px}
454
- .kb-modal.open{display:flex}
455
- .kb-modal-content{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:24px;max-width:420px;width:100%;max-height:80vh;overflow-y:auto}
456
- .kb-modal-content h2{font-family:var(--sans);font-size:16px;font-weight:700;margin-bottom:16px;color:var(--text)}
457
- .kb-row{display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)}
458
- .kb-row:last-child{border-bottom:none}
459
- .kb-key{display:inline-flex;align-items:center;justify-content:center;min-width:24px;padding:2px 8px;border-radius:4px;background:var(--surface3);border:1px solid var(--border);font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent)}
460
- .kb-desc{font-size:12px;color:var(--text2)}
461
-
462
- /* ── Actions Panel in Run Detail (NEW) ── */
481
+ /* ── Actions Panel in Run Detail ── */
463
482
  .rd-actions-panel{margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface)}
464
483
  .rd-actions-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);cursor:pointer;user-select:none;transition:background .15s}
465
484
  .rd-actions-head:hover{background:var(--surface3)}
@@ -468,18 +487,164 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
468
487
  .rd-actions-body{display:none;max-height:500px;overflow-y:auto;padding:8px 14px}
469
488
  .rd-actions-head.open~.rd-actions-body{display:block}
470
489
 
471
- /* ── Serial Badge (NEW) ── */
472
- .serial-badge{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:600;background:var(--amber-dim);color:var(--amber);vertical-align:middle;margin-left:4px}
490
+ /* ── Run Detail Insights ── */
491
+ .rd-insights{margin-bottom:16px;display:flex;flex-direction:column;gap:6px}
492
+ .rd-insights:empty{display:none}
493
+ .rd-ins-health{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);font-size:12px}
494
+ .rd-ins-rate{font-size:18px;font-weight:700}
495
+ .rd-ins-rate.green{color:var(--green)}
496
+ .rd-ins-rate.amber{color:var(--amber)}
497
+ .rd-ins-rate.red{color:var(--red)}
498
+ .rd-ins-trend{font-size:11px;color:var(--text2)}
499
+ .rd-ins-trend.green{color:var(--green)}
500
+ .rd-ins-trend.red{color:var(--red)}
501
+ .rd-ins-tag{padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
502
+ .rd-ins-tag.amber{background:var(--amber-dim);color:var(--amber)}
503
+ .rd-ins-tag.red{background:var(--red-dim);color:var(--red)}
504
+ .rd-ins-item{display:flex;align-items:flex-start;gap:8px;padding:6px 12px;border-radius:var(--r);font-size:11px;color:var(--text2);border-left:3px solid var(--border)}
505
+ .rd-ins-item.red{border-left-color:var(--red);background:var(--red-dim)}
506
+ .rd-ins-item.green{border-left-color:var(--green);background:var(--green-dim)}
507
+ .rd-ins-item.amber{border-left-color:var(--amber);background:var(--amber-dim)}
508
+ .rd-ins-icon{font-size:12px;flex-shrink:0;width:14px;text-align:center}
509
+
510
+ /* ── Health Banner ── */
511
+ .health-banner{display:flex;align-items:center;gap:1px;background:var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:20px}
512
+ .health-banner:empty{display:none}
513
+ .hb-item{flex:1;background:var(--surface);padding:12px 16px;text-align:center;min-width:0;transition:background .15s}
514
+ .hb-item:hover{background:var(--surface2)}
515
+ .hb-val{font-size:20px;font-weight:700;margin-bottom:2px}
516
+ .hb-val.green{color:var(--green)}
517
+ .hb-val.amber{color:var(--amber)}
518
+ .hb-val.red{color:var(--red)}
519
+ .hb-val.accent{color:var(--accent)}
520
+ .hb-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
521
+ .hb-trend{font-size:10px;margin-top:2px}
522
+ .hb-trend.green{color:var(--green)}
523
+ .hb-trend.red{color:var(--red)}
524
+ .hb-trend.dim{color:var(--text3)}
525
+ .hb-link{background:var(--surface);padding:12px 16px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s;min-width:100px}
526
+ .hb-link:hover{background:var(--accent-dim)}
527
+ .hb-link span{font-size:11px;color:var(--accent);font-weight:600}
473
528
 
474
- /* ── Responsive ── */
475
- @media(max-width:768px){
476
- .sidebar{width:60px}.sidebar-logo h1,.sidebar-section-label,.nav-item span:not(.icon):not(.badge),.pool-info,.sidebar select,.sidebar-logo .ver{display:none}
477
- .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
478
- .main{margin-left:60px}
479
- .suite-grid,.gallery,.module-grid{grid-template-columns:1fr}
480
- .lr-test-grid{grid-template-columns:1fr}
481
- .toast-container{right:12px;bottom:12px}
482
- }
529
+ /* ── Screenshots ── */
530
+ .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
531
+ .gallery-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer;transition:border-color .15s}
532
+ .gallery-item:hover{border-color:var(--accent)}
533
+ .gallery-item img{width:100%;height:150px;object-fit:cover;display:block}
534
+ .gallery-item .cap{padding:6px 10px;font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px}
535
+ .gallery-item .cap .cap-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
536
+ .gallery-item .cap .ss-hash{flex-shrink:0}
537
+ .ss-search{display:flex;gap:8px;margin-bottom:16px;align-items:center}
538
+ .ss-search input{flex:1;max-width:320px;padding:8px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
539
+ .ss-search input:focus{outline:none;border-color:var(--accent)}
540
+ .ss-search input::placeholder{color:var(--text3)}
541
+ .ss-search button{padding:8px 16px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px;cursor:pointer;transition:background .15s,border-color .15s}
542
+ .ss-search button:hover{background:var(--surface3);border-color:var(--accent)}
543
+ .ss-search-result{margin-bottom:20px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r)}
544
+ .ss-search-result img{max-width:100%;max-height:500px;border-radius:var(--r);cursor:pointer;display:block;margin-top:8px}
545
+ .ss-search-result .ss-result-label{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px}
546
+ .ss-search-error{font-size:11px;color:var(--red);margin-bottom:12px}
547
+
548
+ /* ── Learnings ── */
549
+ .learn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:20px}
550
+ .learn-stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px;text-align:center}
551
+ .learn-stat-val{font-size:24px;font-weight:700;margin-bottom:2px}
552
+ .learn-stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
553
+ .learn-section{margin-bottom:20px}
554
+ .learn-section-title{font-family:var(--sans);font-size:13px;font-weight:600;margin-bottom:10px;color:var(--text)}
555
+ .learn-table{width:100%;border-collapse:collapse;font-size:11px}
556
+ .learn-table th{text-align:left;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;padding:6px 10px;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
557
+ .learn-table th:hover{color:var(--text2)}
558
+ .learn-table th.sorted::after{content:' \25B2';font-size:8px}
559
+ .learn-table th.sorted.desc::after{content:' \25BC'}
560
+ .learn-table td{padding:6px 10px;border-bottom:1px solid var(--border);color:var(--text2)}
561
+ .learn-table td code{background:var(--surface3);padding:1px 5px;border-radius:3px;font-size:10px;color:var(--text)}
562
+ .learn-table tbody tr:hover td{background:var(--surface2);color:var(--text)}
563
+ .learn-trend-chart{width:100%;height:100px;margin-bottom:20px}
564
+ .learn-trend-chart svg{width:100%;height:100%}
565
+
566
+ /* ── Pool Distribution ── */
567
+ .pool-dist{display:flex;align-items:stretch;gap:0;border-radius:6px;overflow:hidden;height:22px;margin:8px 0;font-size:10px;font-weight:600;font-family:var(--mono)}
568
+ .pool-dist-seg{display:flex;align-items:center;justify-content:center;gap:4px;padding:0 8px;color:#fff;white-space:nowrap;min-width:40px;transition:flex .3s}
569
+ .pool-dist-legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin:4px 0 8px;font-size:10px;font-family:var(--mono);color:var(--text2)}
570
+ .pool-dist-legend span::before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px;vertical-align:middle}
571
+
572
+
573
+ /* ── view-live.css ── */
574
+ /* ── Live Execution ── */
575
+ .live-panel{display:none;background:var(--surface);border:1px solid var(--purple);border-radius:var(--r);overflow:hidden;animation:fadeSlide .3s ease;flex-direction:column}
576
+ .live-panel.active{display:flex;flex:1;min-height:0}
577
+ @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
578
+ .live-header{padding:14px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);background:var(--purple-dim)}
579
+ .live-header .label{font-weight:600;color:var(--purple);font-size:12px;display:flex;align-items:center;gap:8px}
580
+ .live-header .label .dot{width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
581
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
582
+ .live-project{display:flex;align-items:center;padding:2px 10px;background:rgba(255,255,255,.05);border-radius:4px;border:1px solid var(--border)}
583
+ .live-stats{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-left:auto}
584
+ .live-stats span strong{color:var(--text)}
585
+ .live-progress{height:3px;background:var(--surface3)}
586
+ .live-progress-fill{height:100%;background:var(--purple);transition:width .4s;border-radius:0 2px 2px 0}
587
+ .live-tests{padding:12px 16px;display:flex;flex-direction:column;gap:2px;flex:1;overflow-y:auto;min-height:0}
588
+ .live-test{padding:10px 12px;border-radius:var(--r);border-left:3px solid var(--text3);background:var(--surface2);font-size:11px;transition:border-color .2s,padding .25s,max-height .35s cubic-bezier(.4,0,.2,1)}
589
+ .live-test.running{border-left-color:var(--purple)}
590
+ .live-test.passed{border-left-color:var(--green)}
591
+ .live-test.failed{border-left-color:var(--red)}
592
+ .live-test.collapsed{cursor:pointer;padding:6px 12px}
593
+ .live-test.collapsed:hover{background:var(--surface3)}
594
+ .live-test.collapsed .lt-meta,.live-test.collapsed .lt-actions,.live-test.collapsed .lt-screenshots{display:none}
595
+ .live-test.collapsed .lt-name{margin-bottom:0}
596
+ .live-test.collapsed .lt-summary{display:flex}
597
+ .live-test .lt-name{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px}
598
+ .live-test .lt-summary{display:none;align-items:center;gap:8px;margin-left:auto;font-size:10px;color:var(--text3);font-family:var(--mono)}
599
+ .live-test .lt-summary .lt-dur{color:var(--text2)}
600
+ .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
601
+ .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
602
+ .live-test .lt-icon{font-size:12px}
603
+ .lt-actions{overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
604
+ .lt-step{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:10px;font-family:var(--mono);line-height:1.4}
605
+ .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
606
+ .lt-step .step-icon.ok{color:var(--green)}
607
+ .lt-step .step-icon.fail{color:var(--red)}
608
+ .lt-step .step-icon.run{color:var(--purple)}
609
+ .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
610
+ .lt-step .step-detail{color:var(--text2);flex:1;min-width:0;white-space:pre-wrap;word-break:break-word}
611
+ .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
612
+ .lt-step.pool-log{background:rgba(99,102,241,.06);border-left:2px solid rgba(99,102,241,.3);padding-left:8px}
613
+ .lt-step.pool-log .step-icon{color:#818cf8}
614
+ .lt-step.pool-log .step-type{color:#818cf8;font-weight:600}
615
+ .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
616
+ .lt-screenshots-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:10px;color:var(--text3);font-family:var(--mono);padding:2px 0;user-select:none}
617
+ .lt-screenshots-toggle:hover{color:var(--text)}
618
+ .lt-screenshots-toggle .ss-arrow{transition:transform .2s;font-size:8px}
619
+ .lt-screenshots-toggle.open .ss-arrow{transform:rotate(90deg)}
620
+ .lt-screenshots-grid{display:none;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding-top:6px}
621
+ .lt-screenshots-toggle.open+.lt-screenshots-grid{display:grid}
622
+ .lt-ss-thumb{position:relative;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;aspect-ratio:16/10;background:var(--surface2)}
623
+ .lt-ss-thumb img{width:100%;height:100%;object-fit:cover;display:block}
624
+ .lt-ss-thumb:hover{border-color:var(--purple);box-shadow:0 0 0 1px var(--purple)}
625
+ .lt-ss-label{font-size:8px;color:var(--text3);font-family:var(--mono);text-align:center;padding:2px 2px 0;display:flex;align-items:center;justify-content:center;gap:3px;flex-wrap:wrap}
626
+
627
+ /* ── Live Run Sections ── */
628
+ .lr-section-header{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;margin:6px 0 2px;border-radius:6px;font-family:var(--mono);font-size:12px;font-weight:700;border-left:3px solid var(--purple)}
629
+ .lr-section-header.running{border-color:var(--purple);background:rgba(139,92,246,.08);color:var(--purple)}
630
+ .lr-section-header.pass{border-color:var(--green);background:rgba(52,211,153,.08);color:var(--green)}
631
+ .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
632
+ .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
633
+ .lr-project-name{letter-spacing:.5px}
634
+ .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
635
+ .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
636
+ .live-done.has-failures{background:var(--red-dim);color:var(--red)}
637
+ .live-close{padding:4px 10px;font-size:10px;background:transparent;border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer}
638
+ .live-close:hover{color:var(--text);border-color:var(--border-hi)}
639
+ .live-clear-btn{padding:5px 12px;font-size:10px;font-family:var(--mono);font-weight:500;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer;display:none;transition:all .15s}
640
+ .live-clear-btn:hover{color:var(--text);border-color:var(--border-hi);background:var(--surface3)}
641
+ .lr-dismiss{padding:2px 6px;font-size:9px;font-family:var(--mono);background:transparent;border:1px solid transparent;border-radius:4px;color:var(--text3);cursor:pointer;transition:all .15s;margin-left:auto}
642
+ .lr-dismiss:hover{color:var(--red);border-color:rgba(239,68,68,.3);background:var(--red-dim)}
643
+
644
+ .live-nav-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
645
+ .spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
646
+ .spinner-small{display:inline-block;width:8px;height:8px;border:1.5px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
647
+ @keyframes spin{to{transform:rotate(360deg)}}
483
648
 
484
649
  </style>
485
650
  </head>
@@ -501,20 +666,20 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
501
666
  <div class="sidebar-section">
502
667
  <div class="sidebar-section-label">Navigation</div>
503
668
  </div>
504
- <div class="nav-item" data-view="live" id="navLive" style="display:none">
505
- <i class="icon"><span class="live-nav-dot"></span></i><span>Live</span><span class="badge" id="liveBadge" style="background:var(--purple-dim);color:var(--purple)">0</span>
669
+ <div class="nav-item active" data-view="watch">
670
+ <i class="icon">&#9202;</i><span>Watch</span>
506
671
  </div>
507
- <div class="nav-item active" data-view="suites">
508
- <i class="icon">&#9655;</i><span>Suites</span><span class="badge" id="badgeSuites">-</span>
672
+ <div class="nav-item" data-view="tests">
673
+ <i class="icon">&#9655;</i><span>Tests</span><span class="badge" id="badgeSuites">-</span>
509
674
  </div>
510
675
  <div class="nav-item" data-view="runs">
511
676
  <i class="icon">&#9776;</i><span>Runs</span><span class="badge" id="badgeRuns">-</span>
512
677
  </div>
513
- <div class="nav-item" data-view="screenshots">
514
- <i class="icon">&#9635;</i><span>Screenshots</span><span class="badge" id="badgeScreenshots">-</span>
678
+ <div class="nav-item" data-view="live" id="navLive" style="display:none">
679
+ <i class="icon"><span class="live-nav-dot"></span></i><span>Live</span><span class="badge" id="liveBadge" style="background:var(--purple-dim);color:var(--purple)">0</span>
515
680
  </div>
516
- <div class="nav-item" data-view="learnings">
517
- <i class="icon">&#9733;</i><span>Learnings</span><span class="badge" id="badgeLearnings">-</span>
681
+ <div class="nav-item" data-view="instances" id="navInstances" style="display:none">
682
+ <i class="icon">&#9673;</i><span>Instances</span><span class="badge" id="badgeInstances">-</span>
518
683
  </div>
519
684
 
520
685
  <div class="pool-status" id="poolStatus">
@@ -523,16 +688,214 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
523
688
  <strong>Pool</strong> <span id="poolLabel">--</span>
524
689
  </div>
525
690
  <div class="pool-info">Sessions: <strong id="poolSessions">-/-</strong></div>
691
+ <div class="pool-list" id="poolList" style="display:none"></div>
526
692
  <div class="pool-info" style="margin-top:6px">
527
693
  <span class="ws-dot" id="wsDot" style="background:var(--red)"></span>
528
694
  <span id="wsLabel" style="font-size:10px;color:var(--text3)">ws: connecting</span>
529
695
  </div>
530
696
  </div>
697
+
698
+ <!-- Sync Status -->
699
+ <div class="sync-status" id="syncStatus" style="display:none">
700
+ <div class="sync-header">
701
+ <span class="pool-dot" id="syncDot"></span>
702
+ <strong>Sync</strong>
703
+ <span class="sync-mode" id="syncMode">--</span>
704
+ </div>
705
+ <div class="sync-details" id="syncDetails"></div>
706
+ <div class="sync-instances" id="syncInstances"></div>
707
+ </div>
531
708
  </aside>
532
709
 
533
710
  <div class="main">
534
711
 
535
- <!-- Live View -->
712
+ <!-- ════════════════ Watch View (default) ════════════════ -->
713
+ <div class="view active" id="view-watch">
714
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
715
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Watch</div>
716
+ </div>
717
+ <div class="watch-grid" id="watchCards"></div>
718
+ <div class="watch-event-log">
719
+ <div class="watch-event-log-header">
720
+ <span class="title">Recent Runs</span>
721
+ </div>
722
+ <div class="watch-event-log-body" id="watchEventLog"></div>
723
+ </div>
724
+ <div class="empty" id="watchEmpty" style="display:none">
725
+ <div class="empty-icon">&#9202;</div>
726
+ <p>No projects registered yet. Run some tests to see project cards here.</p>
727
+ </div>
728
+ </div>
729
+
730
+ <!-- ════════════════ Tests View (inner tabs: Suites / Modules / Variables) ════════════════ -->
731
+ <div class="view" id="view-tests">
732
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
733
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Tests</div>
734
+ <div style="display:flex;gap:8px">
735
+ <button class="btn sm primary" id="btnRunAll">&#9655; Run All</button>
736
+ </div>
737
+ </div>
738
+ <div class="tab-bar">
739
+ <button class="tab-btn active" data-tab="testsTabSuites">Suites</button>
740
+ <button class="tab-btn" data-tab="testsTabModules">Modules</button>
741
+ <button class="tab-btn" data-tab="testsTabVariables">Variables</button>
742
+ </div>
743
+ <!-- Suites tab -->
744
+ <div class="tab-pane active" id="testsTabSuites">
745
+ <div id="suiteAccordionContainer"></div>
746
+ <div class="suite-grid" id="suiteGrid"></div>
747
+ <div class="empty" id="suitesEmpty" style="display:none">
748
+ <div class="empty-icon">&#9655;</div>
749
+ <p>No test suites found.</p>
750
+ </div>
751
+ </div>
752
+ <!-- Modules tab -->
753
+ <div class="tab-pane" id="testsTabModules">
754
+ <div id="moduleSection"></div>
755
+ </div>
756
+ <!-- Variables tab -->
757
+ <div class="tab-pane" id="testsTabVariables">
758
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
759
+ <div style="font-family:var(--sans);font-size:14px;font-weight:600;color:var(--text2)">Variables</div>
760
+ <button class="btn sm primary" id="btnAddVar">+ Add Variable</button>
761
+ </div>
762
+ <div id="varAddForm" style="display:none"></div>
763
+ <div id="variablesContainer"></div>
764
+ <div class="empty" id="variablesEmpty" style="display:none">
765
+ <div class="empty-icon">&#9881;</div>
766
+ <p>No variables set. Add variables to use <code>{{var.KEY}}</code> in your tests.</p>
767
+ </div>
768
+ </div>
769
+ </div>
770
+
771
+ <!-- ════════════════ Runs View (inner tabs: History / Screenshots / Learnings) ════════════════ -->
772
+ <div class="view" id="view-runs">
773
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
774
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Runs</div>
775
+ </div>
776
+ <div class="tab-bar">
777
+ <button class="tab-btn active" data-tab="runsTabHistory">History</button>
778
+ <button class="tab-btn" data-tab="runsTabScreenshots">Screenshots<span class="badge" id="badgeScreenshots" style="margin-left:6px">-</span></button>
779
+ <button class="tab-btn" data-tab="runsTabLearnings" id="runsTabLearnings">Learnings<span class="badge" id="badgeLearnings" style="margin-left:6px">-</span></button>
780
+ </div>
781
+ <!-- History tab -->
782
+ <div class="tab-pane active" id="runsTabHistory">
783
+ <div class="health-banner" id="runsHealthBanner"></div>
784
+ <div class="card">
785
+ <div class="card-label">Pass Rate Trend</div>
786
+ <div class="chart" id="trendChart"></div>
787
+ </div>
788
+ <div class="filter-bar" id="filterBar">
789
+ <button class="filter-btn active" data-filter="all">All</button>
790
+ <button class="filter-btn" data-filter="pass">Pass</button>
791
+ <button class="filter-btn" data-filter="fail">Fail</button>
792
+ <button class="filter-btn" data-filter="mixed">Mixed</button>
793
+ <input type="text" id="runSearchInput" placeholder="Search suite..." spellcheck="false">
794
+ </div>
795
+ <div class="card" style="padding:0">
796
+ <div class="tbl-wrap">
797
+ <table>
798
+ <thead id="runsHead"><tr></tr></thead>
799
+ <tbody id="runsBody"></tbody>
800
+ </table>
801
+ </div>
802
+ </div>
803
+ <div class="empty" id="runsEmpty" style="display:none">
804
+ <div class="empty-icon">&#9776;</div>
805
+ <p>No runs recorded yet.</p>
806
+ </div>
807
+ </div>
808
+ <!-- Screenshots tab -->
809
+ <div class="tab-pane" id="runsTabScreenshots">
810
+ <div class="ss-search">
811
+ <input type="text" id="ssHashInput" placeholder="Search by hash (e.g. ss:a3f2b1c9)" spellcheck="false">
812
+ <button id="ssHashBtn">Search</button>
813
+ </div>
814
+ <div id="ssSearchResult"></div>
815
+ <div class="gallery" id="screenshotGallery"></div>
816
+ <div class="empty" id="screenshotsEmpty" style="display:none">
817
+ <div class="empty-icon">&#9635;</div>
818
+ <p>Select a project to view screenshots.</p>
819
+ </div>
820
+ </div>
821
+ <!-- Learnings tab -->
822
+ <div class="tab-pane" id="runsTabLearnings">
823
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
824
+ <div style="font-family:var(--sans);font-size:14px;font-weight:600;color:var(--text2)">Learnings</div>
825
+ <div style="display:flex;gap:8px;align-items:center">
826
+ <select id="learningsDays" style="padding:5px 8px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px">
827
+ <option value="7">7 days</option>
828
+ <option value="14">14 days</option>
829
+ <option value="30" selected>30 days</option>
830
+ <option value="90">90 days</option>
831
+ </select>
832
+ <button class="btn sm" id="btnExportLearnings">Export MD</button>
833
+ <button class="btn sm" id="btnRefreshLearnings">Refresh</button>
834
+ </div>
835
+ </div>
836
+ <div id="learningsOverview"></div>
837
+ <div id="learningsTrend"></div>
838
+ <div id="learningsFlaky"></div>
839
+ <div id="learningsSelectors"></div>
840
+ <div id="learningsPages"></div>
841
+ <div id="learningsApis"></div>
842
+ <div id="learningsErrors"></div>
843
+ <div class="empty" id="learningsEmpty" style="display:none">
844
+ <div class="empty-icon">&#9733;</div>
845
+ <p>No learnings data yet. Run some tests to start building knowledge.</p>
846
+ </div>
847
+ </div>
848
+ </div>
849
+
850
+ <!-- ════════════════ Instances View (Hub Mode) ════════════════ -->
851
+ <div class="view" id="view-instances">
852
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
853
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Connected Instances</div>
854
+ <div style="display:flex;gap:8px">
855
+ <select id="instanceFilter" style="padding:6px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px">
856
+ <option value="all">All Status</option>
857
+ <option value="active">Active</option>
858
+ <option value="pending">Pending</option>
859
+ <option value="suspended">Suspended</option>
860
+ </select>
861
+ <button class="btn sm" id="refreshInstances">Refresh</button>
862
+ </div>
863
+ </div>
864
+
865
+ <div class="stats" id="instanceStats">
866
+ <div class="stat-block"><div class="stat-val accent" id="statInstancesTotal">-</div><div class="stat-lbl">Total</div></div>
867
+ <div class="stat-block"><div class="stat-val green" id="statInstancesOnline">-</div><div class="stat-lbl">Online</div></div>
868
+ <div class="stat-block"><div class="stat-val purple" id="statInstancesActive">-</div><div class="stat-lbl">Active</div></div>
869
+ <div class="stat-block"><div class="stat-val" id="statInstancesPending" style="color:var(--amber)">-</div><div class="stat-lbl">Pending</div></div>
870
+ </div>
871
+
872
+ <div class="card" style="padding:0">
873
+ <div class="tbl-wrap">
874
+ <table>
875
+ <thead>
876
+ <tr>
877
+ <th>Status</th>
878
+ <th>Instance ID</th>
879
+ <th>Display Name</th>
880
+ <th>Role</th>
881
+ <th>Environment</th>
882
+ <th>Last Seen</th>
883
+ <th>Actions</th>
884
+ </tr>
885
+ </thead>
886
+ <tbody id="instancesBody"></tbody>
887
+ </table>
888
+ </div>
889
+ </div>
890
+
891
+ <div class="empty" id="instancesEmpty" style="display:none">
892
+ <div class="empty-icon">&#9673;</div>
893
+ <p>No instances registered yet.</p>
894
+ <p style="margin-top:8px;font-size:11px;color:var(--text3)">Use <code>npx e2e-runner sync add-instance</code> to register agents.</p>
895
+ </div>
896
+ </div>
897
+
898
+ <!-- ════════════════ Live View ════════════════ -->
536
899
  <div class="view" id="view-live">
537
900
  <div class="live-panel active" id="livePanel">
538
901
  <div class="live-header">
@@ -555,110 +918,40 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
555
918
  </div>
556
919
  <div class="empty" id="liveEmpty">
557
920
  <div class="empty-icon" style="font-size:48px;opacity:.3">&#9679;</div>
558
- <p>No tests running. Start a test from the Suites view or another console.</p>
921
+ <p>No tests running. Start a test from the Tests view or another console.</p>
559
922
  <p style="margin-top:8px;font-size:11px;color:var(--text3)">This view activates automatically when tests are detected.</p>
560
923
  </div>
561
924
  </div>
562
925
 
563
- <!-- Suites View -->
564
- <div class="view active" id="view-suites">
565
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
566
- <div style="font-family:var(--sans);font-size:16px;font-weight:600">Test Suites</div>
567
- </div>
568
- <div id="suiteAccordionContainer"></div>
569
- <div class="suite-grid" id="suiteGrid"></div>
570
- <div id="moduleSection"></div>
571
- <div class="empty" id="suitesEmpty" style="display:none">
572
- <div class="empty-icon">&#9655;</div>
573
- <p>No test suites found.</p>
574
- </div>
575
- </div>
576
-
577
- <!-- Runs View -->
578
- <div class="view" id="view-runs">
579
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
580
- <div style="font-family:var(--sans);font-size:16px;font-weight:600">Run History</div>
581
- </div>
582
- <div class="card">
583
- <div class="card-label">Pass Rate Trend</div>
584
- <div class="chart" id="trendChart"></div>
585
- </div>
586
- <div class="filter-bar" id="filterBar">
587
- <button class="filter-btn active" data-filter="all">All</button>
588
- <button class="filter-btn" data-filter="pass">Pass</button>
589
- <button class="filter-btn" data-filter="fail">Fail</button>
590
- <button class="filter-btn" data-filter="mixed">Mixed</button>
591
- <input type="text" id="runSearchInput" placeholder="Search suite..." spellcheck="false">
592
- </div>
593
- <div class="card" style="padding:0">
594
- <div class="tbl-wrap">
595
- <table>
596
- <thead id="runsHead"><tr></tr></thead>
597
- <tbody id="runsBody"></tbody>
598
- </table>
599
- </div>
600
- </div>
601
- <div class="empty" id="runsEmpty" style="display:none">
602
- <div class="empty-icon">&#9776;</div>
603
- <p>No runs recorded yet.</p>
604
- </div>
605
- </div>
926
+ </div>
606
927
 
607
- <!-- Learnings View -->
608
- <div class="view" id="view-learnings">
609
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
610
- <div style="font-family:var(--sans);font-size:16px;font-weight:600">Learnings</div>
611
- <div style="display:flex;gap:8px;align-items:center">
612
- <select id="learningsDays" style="padding:5px 8px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px">
613
- <option value="7">7 days</option>
614
- <option value="14">14 days</option>
615
- <option value="30" selected>30 days</option>
616
- <option value="90">90 days</option>
617
- </select>
618
- <button class="btn sm" id="btnExportLearnings">Export MD</button>
619
- <button class="btn sm" id="btnRefreshLearnings">Refresh</button>
928
+ <div class="suite-modal-overlay" id="suiteModalOverlay">
929
+ <div class="suite-modal" id="suiteModal">
930
+ <div class="suite-modal-header">
931
+ <div class="suite-card-icon">&#9655;</div>
932
+ <div class="suite-modal-title">
933
+ <h2 id="suiteModalName"></h2>
934
+ <span id="suiteModalFile"></span>
935
+ </div>
936
+ <div class="suite-modal-actions">
937
+ <button class="btn sm primary" id="suiteModalRun">&#9655; Run</button>
938
+ <button class="suite-modal-close" id="suiteModalClose">&times;</button>
620
939
  </div>
621
940
  </div>
622
- <div id="learningsOverview"></div>
623
- <div id="learningsTrend"></div>
624
- <div id="learningsFlaky"></div>
625
- <div id="learningsSelectors"></div>
626
- <div id="learningsPages"></div>
627
- <div id="learningsApis"></div>
628
- <div id="learningsErrors"></div>
629
- <div class="empty" id="learningsEmpty" style="display:none">
630
- <div class="empty-icon">&#9733;</div>
631
- <p>No learnings data yet. Run some tests to start building knowledge.</p>
632
- </div>
633
- </div>
634
-
635
- <!-- Screenshots View -->
636
- <div class="view" id="view-screenshots">
637
- <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Screenshots</div>
638
- <div class="ss-search">
639
- <input type="text" id="ssHashInput" placeholder="Search by hash (e.g. ss:a3f2b1c9)" spellcheck="false">
640
- <button id="ssHashBtn">Search</button>
641
- </div>
642
- <div id="ssSearchResult"></div>
643
- <div class="gallery" id="screenshotGallery"></div>
644
- <div class="empty" id="screenshotsEmpty" style="display:none">
645
- <div class="empty-icon">&#9635;</div>
646
- <p>Select a project to view screenshots.</p>
941
+ <div class="suite-modal-body" id="suiteModalBody">
942
+ <div class="suite-modal-loading">Loading...</div>
647
943
  </div>
648
944
  </div>
649
-
650
945
  </div>
651
-
652
946
  <div class="modal" id="modal"><img id="modalImg" src="" alt=""></div>
653
947
  <div class="toast-container" id="toastContainer"></div>
654
948
  <div class="kb-modal" id="kbModal">
655
949
  <div class="kb-modal-content">
656
950
  <h2>Keyboard Shortcuts</h2>
657
- <div class="kb-row"><span class="kb-key">1</span><span class="kb-desc">Suites view</span></div>
658
- <div class="kb-row"><span class="kb-key">2</span><span class="kb-desc">Runs view</span></div>
659
- <div class="kb-row"><span class="kb-key">3</span><span class="kb-desc">Screenshots view</span></div>
660
- <div class="kb-row"><span class="kb-key">4</span><span class="kb-desc">Learnings view</span></div>
661
- <div class="kb-row"><span class="kb-key">5</span><span class="kb-desc">Live view</span></div>
951
+ <div class="kb-row"><span class="kb-key">1</span><span class="kb-desc">Watch view</span></div>
952
+ <div class="kb-row"><span class="kb-key">2</span><span class="kb-desc">Tests view</span></div>
953
+ <div class="kb-row"><span class="kb-key">3</span><span class="kb-desc">Runs view</span></div>
954
+ <div class="kb-row"><span class="kb-key">4</span><span class="kb-desc">Live view</span></div>
662
955
  <div class="kb-row"><span class="kb-key">j / k</span><span class="kb-desc">Navigate runs (next / previous)</span></div>
663
956
  <div class="kb-row"><span class="kb-key">Enter</span><span class="kb-desc">Expand / collapse selected run</span></div>
664
957
  <div class="kb-row"><span class="kb-key">Esc</span><span class="kb-desc">Close modal / collapse run</span></div>
@@ -670,6 +963,8 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
670
963
  <script>
671
964
  (function(){
672
965
  'use strict';
966
+ /* ── utils.js ── */
967
+ /* ── DOM Helpers ── */
673
968
  var $=function(s){return document.querySelector(s)};
674
969
  var $$=function(s){return document.querySelectorAll(s)};
675
970
 
@@ -740,20 +1035,37 @@ function buildNdSection(title,contentEl,count,copyText){
740
1035
  return el('div',{className:'rd-nd-section'},[toggle,contentWrap]);
741
1036
  }
742
1037
 
1038
+ function gqlOp(n){
1039
+ if(n.requestBody){
1040
+ try{
1041
+ var b=JSON.parse(n.requestBody);
1042
+ if(b.operationName)return b.operationName;
1043
+ if(b.query){var m=b.query.match(/^(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/);if(m)return m[1]}
1044
+ }catch(e){}
1045
+ }
1046
+ if(n.url){
1047
+ try{var u=new URL(n.url,location.href);var op=u.searchParams.get('operationName');if(op)return op}catch(e){}
1048
+ }
1049
+ return null;
1050
+ }
1051
+
743
1052
  function buildNetRow(n){
744
1053
  var mCls='rd-net-method '+(n.method||'GET').toLowerCase();
745
1054
  var sCode=n.status||0;
746
1055
  var sCls='rd-net-status '+(sCode<300?'s2xx':sCode<400?'s3xx':sCode<500?'s4xx':'s5xx');
747
1056
  var hasDetail=n.requestBody||n.responseBody||n.requestHeaders||n.responseHeaders;
748
1057
  var rowCls='rd-net-row'+(sCode>=400?' has-error':'');
749
- var row=el('div',{className:rowCls},[
1058
+ var opName=gqlOp(n);
1059
+ var children=[
750
1060
  el('span',{className:'rd-net-expand'},hasDetail?'\u25B6':''),
751
1061
  el('span',{className:mCls},n.method||'GET'),
752
- el('span',{className:sCls},String(sCode)),
753
- el('span',{className:'rd-net-url'},n.url||''),
754
- makeCopyBtn(n.url||''),
755
- el('span',{className:'rd-net-dur'},dur(n.duration))
756
- ]);
1062
+ el('span',{className:sCls},String(sCode))
1063
+ ];
1064
+ if(opName)children.push(el('span',{className:'rd-net-op'},opName));
1065
+ children.push(el('span',{className:'rd-net-url'},n.url||''));
1066
+ children.push(makeCopyBtn(n.url||''));
1067
+ children.push(el('span',{className:'rd-net-dur'},dur(n.duration)));
1068
+ var row=el('div',{className:rowCls},children);
757
1069
  var detail=null;
758
1070
  if(hasDetail){
759
1071
  var sections=[];
@@ -816,11 +1128,89 @@ function createTriggerBadge(source){
816
1128
  return badge;
817
1129
  }
818
1130
 
819
- /* ══════════════════════════════════════════════════════════════════
820
- Toast Notifications (Improvement 4)
821
- ══════════════════════════════════════════════════════════════════ */
822
- function showToast(message,type){
1131
+ /* ── Pool Distribution Summary ── */
1132
+ var POOL_COLORS=['#6366f1','#22d3ee','#f59e0b','#10b981','#ef4444','#8b5cf6','#ec4899','#14b8a6'];
1133
+ function buildPoolDistribution(tests){
1134
+ var pools={};var total=0;
1135
+ Object.keys(tests).forEach(function(n){
1136
+ if(n==='__error')return;var t=tests[n];
1137
+ if(!t.poolUrl)return;
1138
+ var label=t.poolUrl.replace('ws://','').replace('wss://','');
1139
+ if(!pools[label])pools[label]={count:0,passed:0,failed:0};
1140
+ pools[label].count++;total++;
1141
+ if(t.status==='passed'||t.success)pools[label].passed++;
1142
+ if(t.status==='failed'||t.success===false)pools[label].failed++;
1143
+ });
1144
+ var keys=Object.keys(pools);
1145
+ if(keys.length<2)return null;
1146
+ var bar=el('div',{className:'pool-dist'});
1147
+ var legend=el('div',{className:'pool-dist-legend'});
1148
+ keys.forEach(function(k,i){
1149
+ var pct=Math.round(pools[k].count/total*100);
1150
+ var color=POOL_COLORS[i%POOL_COLORS.length];
1151
+ var seg=el('div',{className:'pool-dist-seg'});
1152
+ seg.style.flex=pools[k].count;seg.style.background=color;
1153
+ seg.textContent=k+' ('+pools[k].count+')';
1154
+ bar.appendChild(seg);
1155
+ var lg=el('span',{},k+': '+pools[k].count+' tests ('+pct+'%)');
1156
+ lg.style.cssText='display:inline-flex;align-items:center;gap:4px';
1157
+ var dot=el('span',{});dot.style.cssText='width:8px;height:8px;border-radius:2px;background:'+color+';flex-shrink:0';
1158
+ lg.insertBefore(dot,lg.firstChild);
1159
+ legend.appendChild(lg);
1160
+ });
1161
+ return el('div',{style:'padding:4px 12px'},[bar,legend]);
1162
+ }
1163
+
1164
+
1165
+ /* ── state.js ── */
1166
+ /* ── Global State ── */
1167
+ var S={
1168
+ ws:null,project:null,view:'watch',selectedRun:null,
1169
+ liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
1170
+ runFilter:{status:'all',search:''},
1171
+ lastLearningsData:null,
1172
+ highlightedRunIdx:-1
1173
+ };
1174
+
1175
+ /* ── Navigation ── */
1176
+ $$('.nav-item').forEach(function(n){
1177
+ n.addEventListener('click',function(){
1178
+ showView(n.dataset.view);
1179
+ });
1180
+ });
1181
+ function showView(v){
1182
+ S.view=v;
1183
+ $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
1184
+ $$('.view').forEach(function(x){x.classList.remove('active')});
1185
+ var viewEl=$('#view-'+v);
1186
+ if(viewEl)viewEl.classList.add('active');
1187
+ if(v==='watch'&&typeof startWatchPolling==='function')startWatchPolling();
1188
+ else if(typeof stopWatchPolling==='function')stopWatchPolling();
1189
+ if(v==='instances'&&typeof refreshInstances==='function')refreshInstances();
1190
+ }
1191
+
1192
+ /* ── Inner Tabs ── */
1193
+ function initTabs(){
1194
+ $$('.tab-bar').forEach(function(bar){
1195
+ var container=bar.parentElement;
1196
+ bar.querySelectorAll('.tab-btn').forEach(function(btn){
1197
+ btn.addEventListener('click',function(){
1198
+ bar.querySelectorAll('.tab-btn').forEach(function(b){b.classList.remove('active')});
1199
+ btn.classList.add('active');
1200
+ container.querySelectorAll('.tab-pane').forEach(function(p){p.classList.remove('active')});
1201
+ var pane=container.querySelector('#'+btn.dataset.tab);
1202
+ if(pane)pane.classList.add('active');
1203
+ });
1204
+ });
1205
+ });
1206
+ }
1207
+
1208
+
1209
+ /* ── toast.js ── */
1210
+ /* ── Toast Notifications ── */
1211
+ function showToast(message,type,timeout){
823
1212
  type=type||'info';
1213
+ timeout=timeout||5000;
824
1214
  var container=$('#toastContainer');
825
1215
  var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
826
1216
  var t=el('div',{className:'toast '+type},[
@@ -831,12 +1221,24 @@ function showToast(message,type){
831
1221
  setTimeout(function(){
832
1222
  t.classList.add('fade-out');
833
1223
  setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
834
- },5000);
1224
+ },timeout);
835
1225
  }
836
1226
 
837
- /* ══════════════════════════════════════════════════════════════════
838
- Download helper (Improvement 8)
839
- ══════════════════════════════════════════════════════════════════ */
1227
+ function showEnrichedToast(message,type){
1228
+ var container=$('#toastContainer');
1229
+ var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
1230
+ var t=el('div',{className:'toast clickable '+type,onclick:function(){showView('runs');var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
1231
+ el('span',null,icons[type]||''),
1232
+ el('span',null,message)
1233
+ ]);
1234
+ container.appendChild(t);
1235
+ setTimeout(function(){
1236
+ t.classList.add('fade-out');
1237
+ setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
1238
+ },7000);
1239
+ }
1240
+
1241
+ /* ── Download helper ── */
840
1242
  function downloadFile(filename,content,mimeType){
841
1243
  var blob=new Blob([content],{type:mimeType||'text/plain'});
842
1244
  var url=URL.createObjectURL(blob);
@@ -847,35 +1249,235 @@ function downloadFile(filename,content,mimeType){
847
1249
  URL.revokeObjectURL(url);
848
1250
  }
849
1251
 
850
- /* ── State ── */
851
- var S={
852
- ws:null,project:null,view:'suites',selectedRun:null,
853
- liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
854
- runFilter:{status:'all',search:''},
855
- lastLearningsData:null,
856
- highlightedRunIdx:-1
857
- };
858
1252
 
859
- /* ── Navigation ── */
860
- $$('.nav-item').forEach(function(n){
861
- n.addEventListener('click',function(){
862
- $$('.nav-item').forEach(function(x){x.classList.remove('active')});
863
- n.classList.add('active');
864
- S.view=n.dataset.view;
865
- $$('.view').forEach(function(v){v.classList.remove('active')});
866
- $('#view-'+S.view).classList.add('active');
1253
+ /* ── api.js ── */
1254
+ /* ── API & Pool ── */
1255
+ function api(p){return fetch(p).then(function(r){return r.json()})}
1256
+ function triggerRun(suite,projectId){
1257
+ if(anyLiveRunning())return;
1258
+ var body={};
1259
+ if(suite)body.suite=suite;
1260
+ if(projectId)body.projectId=projectId;
1261
+ else if(S.project)body.projectId=S.project;
1262
+ fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1263
+ }
1264
+
1265
+ function renderPool(d){
1266
+ if(!d)return;
1267
+ var poolList=$('#poolList');
1268
+ if(d.pools&&d.pools.length>1){
1269
+ var anyAvail=d.availableCount>0;
1270
+ $('#poolDot').className='pool-dot '+(anyAvail?'on':'off');
1271
+ $('#poolLabel').textContent=anyAvail?d.availableCount+'/'+d.totalPools+' ready':'all busy';
1272
+ $('#poolSessions').textContent=(d.totalRunning||0)+'/'+(d.totalMaxConcurrent||0);
1273
+ poolList.textContent='';poolList.style.display='';
1274
+ d.pools.forEach(function(p){
1275
+ var label=(p.url||'').replace('ws://','').replace('wss://','');
1276
+ var ok=!p.error&&p.available;
1277
+ var dot=el('span',{className:'pool-dot '+(ok?'on':'off')});
1278
+ var name=el('strong',{},label);
1279
+ var status=el('span',{},p.error?'offline':p.available?'ready':'busy');
1280
+ var sess=el('span',{className:'pool-sessions'},(p.running||0)+'/'+(p.maxConcurrent||0));
1281
+ poolList.appendChild(el('div',{className:'pool-item'},[dot,name,status,sess]));
1282
+ });
1283
+ }else if(d.pools&&d.pools.length===1){
1284
+ var p=d.pools[0];
1285
+ $('#poolDot').className='pool-dot '+(p.error||!p.available?'off':'on');
1286
+ $('#poolLabel').textContent=p.error?'offline':p.available?'ready':'busy';
1287
+ $('#poolSessions').textContent=(p.running||0)+'/'+(p.maxConcurrent||0);
1288
+ poolList.style.display='none';
1289
+ }else{
1290
+ $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
1291
+ $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
1292
+ $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
1293
+ poolList.style.display='none';
1294
+ }
1295
+ }
1296
+ function refreshStatus(){
1297
+ api('/api/status').then(function(d){
1298
+ renderPool(d.pool);
1299
+ // Check if sync is enabled and update UI
1300
+ if(d.config && d.config.sync){
1301
+ renderSyncStatus(d.config.sync);
1302
+ }
1303
+ }).catch(function(){});
1304
+ }
1305
+
1306
+ /* ── Sync ── */
1307
+ var syncMode = null;
1308
+
1309
+ function renderSyncStatus(sync){
1310
+ var status=$('#syncStatus');
1311
+ var dot=$('#syncDot');
1312
+ var mode=$('#syncMode');
1313
+ var details=$('#syncDetails');
1314
+ var instances=$('#syncInstances');
1315
+
1316
+ if(!sync || sync.mode === 'standalone'){
1317
+ status.style.display='none';
1318
+ $('#navInstances').style.display='none';
1319
+ syncMode = null;
1320
+ return;
1321
+ }
1322
+
1323
+ status.style.display='block';
1324
+ syncMode = sync.mode;
1325
+
1326
+ mode.textContent = sync.mode;
1327
+ mode.className = 'sync-mode ' + sync.mode;
1328
+
1329
+ if(sync.mode === 'hub'){
1330
+ dot.className = 'pool-dot on';
1331
+ dot.style.background = 'var(--purple)';
1332
+ details.textContent = 'Accepting agent connections';
1333
+ $('#navInstances').style.display = 'flex';
1334
+ refreshInstances();
1335
+ } else if(sync.mode === 'agent'){
1336
+ var hubUrl = sync.agent && sync.agent.hubUrl;
1337
+ if(hubUrl){
1338
+ dot.className = 'pool-dot on';
1339
+ dot.style.background = 'var(--accent)';
1340
+ details.innerHTML = 'Hub: <strong>' + hubUrl + '</strong>';
1341
+ } else {
1342
+ dot.className = 'pool-dot off';
1343
+ details.textContent = 'Not connected';
1344
+ }
1345
+ $('#navInstances').style.display = 'none';
1346
+ }
1347
+ }
1348
+
1349
+ function refreshInstances(){
1350
+ if(syncMode !== 'hub') return;
1351
+
1352
+ api('/api/sync/instances').then(function(d){
1353
+ var instances = d.instances || [];
1354
+ var online = 0;
1355
+ var active = 0;
1356
+ var pending = 0;
1357
+ var now = Date.now();
1358
+
1359
+ instances.forEach(function(inst){
1360
+ if(inst.status === 'active') active++;
1361
+ if(inst.status === 'pending') pending++;
1362
+ if(inst.lastSeen){
1363
+ var lastSeen = new Date(inst.lastSeen + 'Z').getTime();
1364
+ if(now - lastSeen < 5 * 60 * 1000) online++;
1365
+ }
1366
+ });
1367
+
1368
+ $('#statInstancesTotal').textContent = instances.length;
1369
+ $('#statInstancesOnline').textContent = online;
1370
+ $('#statInstancesActive').textContent = active;
1371
+ $('#statInstancesPending').textContent = pending;
1372
+ $('#badgeInstances').textContent = online + '/' + instances.length;
1373
+
1374
+ var tbody = $('#instancesBody');
1375
+ tbody.innerHTML = '';
1376
+
1377
+ if(instances.length === 0){
1378
+ $('#instancesEmpty').style.display = 'block';
1379
+ return;
1380
+ }
1381
+ $('#instancesEmpty').style.display = 'none';
1382
+
1383
+ instances.forEach(function(inst){
1384
+ var isOnline = inst.lastSeen && (now - new Date(inst.lastSeen + 'Z').getTime() < 5 * 60 * 1000);
1385
+ var statusClass = inst.status === 'active' ? 'pass' : inst.status === 'pending' ? 'flaky' : 'fail';
1386
+
1387
+ var tr = el('tr', null, [
1388
+ el('td', null, [
1389
+ el('span', {className: 'pool-dot ' + (isOnline ? 'on' : 'off'), style: 'margin-right:6px'}),
1390
+ el('span', {className: 'badge ' + statusClass}, inst.status)
1391
+ ]),
1392
+ el('td', {style: 'font-family:var(--mono)'}, inst.instanceId),
1393
+ el('td', null, inst.displayName),
1394
+ el('td', null, inst.role),
1395
+ el('td', null, inst.environment || '-'),
1396
+ el('td', {style: 'color:var(--text3);font-size:11px'}, inst.lastSeen ? fdate(inst.lastSeen) : 'never'),
1397
+ el('td', null, [
1398
+ inst.status === 'pending' ? el('button', {className: 'btn sm', onclick: function(e){
1399
+ e.stopPropagation();
1400
+ approveInstance(inst.instanceId);
1401
+ }}, 'Approve') : null,
1402
+ inst.status === 'active' ? el('button', {className: 'btn sm danger', onclick: function(e){
1403
+ e.stopPropagation();
1404
+ revokeInstance(inst.instanceId);
1405
+ }}, 'Suspend') : null
1406
+ ])
1407
+ ]);
1408
+ tbody.appendChild(tr);
1409
+ });
1410
+
1411
+ // Update sync status sidebar with online instances
1412
+ var syncInst = $('#syncInstances');
1413
+ syncInst.innerHTML = '';
1414
+ instances.slice(0, 5).forEach(function(inst){
1415
+ var isOnline = inst.lastSeen && (now - new Date(inst.lastSeen + 'Z').getTime() < 5 * 60 * 1000);
1416
+ var span = el('span', {className: 'sync-inst ' + (isOnline ? 'online' : 'offline')}, [
1417
+ el('span', {className: 'pool-dot ' + (isOnline ? 'on' : 'off'), style: 'width:5px;height:5px'}),
1418
+ document.createTextNode(inst.instanceId.slice(0, 12))
1419
+ ]);
1420
+ syncInst.appendChild(span);
1421
+ });
1422
+ if(instances.length > 5){
1423
+ syncInst.appendChild(el('span', {style: 'font-size:9px;color:var(--text3)'}, '+' + (instances.length - 5) + ' more'));
1424
+ }
1425
+
1426
+ }).catch(function(err){
1427
+ console.error('Failed to load instances:', err);
867
1428
  });
1429
+ }
1430
+
1431
+ function approveInstance(instanceId){
1432
+ api('/api/sync/instances/' + instanceId, {
1433
+ method: 'PATCH',
1434
+ body: JSON.stringify({status: 'active'})
1435
+ }).then(function(){
1436
+ showToast('Instance approved', 'success');
1437
+ refreshInstances();
1438
+ }).catch(function(err){
1439
+ showToast('Failed to approve: ' + err.message, 'error');
1440
+ });
1441
+ }
1442
+
1443
+ function revokeInstance(instanceId){
1444
+ api('/api/sync/instances/' + instanceId, {
1445
+ method: 'PATCH',
1446
+ body: JSON.stringify({status: 'suspended'})
1447
+ }).then(function(){
1448
+ showToast('Instance suspended', 'success');
1449
+ refreshInstances();
1450
+ }).catch(function(err){
1451
+ showToast('Failed to suspend: ' + err.message, 'error');
1452
+ });
1453
+ }
1454
+
1455
+ $('#refreshInstances').addEventListener('click', refreshInstances);
1456
+ $('#instanceFilter').addEventListener('change', function(){
1457
+ // For now just refresh - could add client-side filtering
1458
+ refreshInstances();
868
1459
  });
869
- function showView(v){
870
- S.view=v;
871
- $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
872
- $$('.view').forEach(function(x){x.classList.remove('active')});
873
- $('#view-'+v).classList.add('active');
1460
+
1461
+ /* ── Projects ── */
1462
+ function refreshProjects(){
1463
+ api('/api/db/projects').then(function(projects){
1464
+ var sel=$('#projectSelect'),prev=sel.value;
1465
+ while(sel.options.length>1)sel.remove(1);
1466
+ if(Array.isArray(projects))projects.forEach(function(p){
1467
+ var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
1468
+ });
1469
+ sel.value=prev||'';
1470
+ }).catch(function(){});
874
1471
  }
1472
+ $('#projectSelect').addEventListener('change',function(){
1473
+ S.project=this.value?parseInt(this.value,10):null;
1474
+ S.selectedRun=null;
1475
+ refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();refreshWatch();
1476
+ });
875
1477
 
876
- /* ══════════════════════════════════════════════════════════════════
877
- WebSocket
878
- ══════════════════════════════════════════════════════════════════ */
1478
+
1479
+ /* ── websocket.js ── */
1480
+ /* ── WebSocket ── */
879
1481
  function connectWS(){
880
1482
  var proto=location.protocol==='https:'?'wss:':'ws:';
881
1483
  S.ws=new WebSocket(proto+'//'+location.host);
@@ -929,6 +1531,11 @@ function handleWS(m){
929
1531
  r2.active=m.activeCount;
930
1532
  r2.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[],serial:m.serial||false};
931
1533
  renderLive();break;
1534
+ case 'test:pool':
1535
+ var rp=getLiveRun(m);if(!rp||!rp.tests[m.name])break;
1536
+ rp.tests[m.name].poolUrl=m.poolUrl||null;
1537
+ rp.tests[m.name].actionLog.unshift({type:'pool',narrative:'\uD83D\uDD17 '+m.name+' \u2192 '+(m.poolUrl||'').replace('ws://','').replace('wss://',''),success:true,duration:null,isPoolLog:true});
1538
+ renderLive();break;
932
1539
  case 'test:action':
933
1540
  var r3=getLiveRun(m);if(!r3||!r3.tests[m.name])break;
934
1541
  var t=r3.tests[m.name];
@@ -950,66 +1557,286 @@ function handleWS(m){
950
1557
  if(m.screenshots&&m.screenshots.length)r5.tests[m.name].screenshots=m.screenshots;
951
1558
  if(m.errorScreenshot)r5.tests[m.name].errorScreenshot=m.errorScreenshot;
952
1559
  if(m.networkLogs&&m.networkLogs.length)r5.tests[m.name].networkLogs=m.networkLogs;
1560
+ if(m.poolUrl)r5.tests[m.name].poolUrl=m.poolUrl;
953
1561
  }
954
1562
  r5.active=Math.max(0,r5.active-1);
955
1563
  renderLive();break;
956
1564
  case 'run:complete':
957
1565
  var r6=getLiveRun(m);if(r6){r6.on=false;r6.done=true;r6.active=0}
958
1566
  var summary=m.summary||{};
959
- if(summary.failed>0){showToast('Run complete: '+summary.failed+' failed','error')}
960
- else{showToast('Run complete: all '+(summary.total||0)+' passed','success')}
961
- renderLive();refreshRuns();refreshProjects();break;
1567
+ var baseMsg='Run complete: '+(summary.failed>0?summary.failed+' failed':'all '+(summary.total||0)+' passed');
1568
+ var baseType=summary.failed>0?'error':'success';
1569
+ var healthUrl=S.project?'/api/db/projects/'+S.project+'/health':'/api/db/health';
1570
+ fetch(healthUrl).then(function(r){return r.json()}).then(function(h){
1571
+ if(h&&h.passRate!==undefined){
1572
+ var extra='. Pass rate: '+h.passRate+'%';
1573
+ if(h.passRateTrend==='declining')extra+=' (declining, '+h.trendDelta+'%)';
1574
+ else if(h.passRateTrend==='improving')extra+=' (improving, +'+h.trendDelta+'%)';
1575
+ if(h.flakyCount>0)extra+='. '+h.flakyCount+' flaky test(s)';
1576
+ showEnrichedToast(baseMsg+extra,baseType);
1577
+ } else {
1578
+ showToast(baseMsg,baseType);
1579
+ }
1580
+ }).catch(function(){showToast(baseMsg,baseType)});
1581
+ renderLive();refreshRuns();refreshProjects();refreshWatch();break;
962
1582
  case 'run:error':
963
1583
  var r7=getLiveRun(m);if(r7){r7.on=false;r7.done=true;r7.tests.__error={status:'failed',error:m.error}}
964
1584
  showToast('Run error: '+m.error,'error');
965
1585
  renderLive();break;
966
1586
  case 'db:updated':
967
- refreshRuns();refreshProjects();refreshScreenshots();refreshLearnings();break;
1587
+ refreshRuns();refreshProjects();refreshScreenshots();refreshLearnings();refreshWatch();break;
968
1588
  }
969
1589
  }
970
1590
 
1591
+
1592
+ /* ── view-watch.js ── */
971
1593
  /* ══════════════════════════════════════════════════════════════════
972
- API & Pool
1594
+ Watch View — Project Cards + Sparklines + Event Log
973
1595
  ══════════════════════════════════════════════════════════════════ */
974
- function api(p){return fetch(p).then(function(r){return r.json()})}
975
- function triggerRun(suite,projectId){
976
- if(anyLiveRunning())return;
977
- var body={};
978
- if(suite)body.suite=suite;
979
- if(projectId)body.projectId=projectId;
980
- else if(S.project)body.projectId=S.project;
981
- fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1596
+ var _watchInterval=null;
1597
+ var _countdownInterval=null;
1598
+ var _watchData=null;
1599
+
1600
+ function refreshWatch(){
1601
+ // Fetch projects overview (sparklines)
1602
+ api('/api/db/projects/overview').then(function(projects){
1603
+ if(!Array.isArray(projects)||!projects.length){
1604
+ $('#watchCards').textContent='';
1605
+ $('#watchEmpty').style.display='block';
1606
+ return;
1607
+ }
1608
+ $('#watchEmpty').style.display='none';
1609
+ _watchData=projects;
1610
+ renderWatchCards(projects);
1611
+ }).catch(function(){
1612
+ // Fallback: use regular projects list
1613
+ api('/api/db/projects').then(function(projects){
1614
+ if(!Array.isArray(projects)||!projects.length){$('#watchEmpty').style.display='block';return}
1615
+ $('#watchEmpty').style.display='none';
1616
+ _watchData=projects.map(function(p){return Object.assign({},p,{sparkline:[]})});
1617
+ renderWatchCards(_watchData);
1618
+ }).catch(function(){});
1619
+ });
1620
+
1621
+ // Fetch event log (recent runs)
1622
+ var runsUrl=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
1623
+ api(runsUrl).then(function(runs){
1624
+ renderEventLog(runs);
1625
+ }).catch(function(){});
1626
+
1627
+ // Fetch watch jobs status for countdown
1628
+ fetch('/api/watch/status').then(function(r){
1629
+ if(!r.ok)throw new Error('not running');
1630
+ return r.json();
1631
+ }).then(function(jobs){
1632
+ applyWatchJobData(jobs);
1633
+ }).catch(function(){
1634
+ // Watch engine not running — that's fine, cards still show
1635
+ });
982
1636
  }
983
1637
 
984
- function renderPool(d){
985
- if(!d)return;
986
- $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
987
- $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
988
- $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
1638
+ function renderWatchCards(projects){
1639
+ var container=$('#watchCards');
1640
+ container.textContent='';
1641
+
1642
+ projects.forEach(function(p){
1643
+ var sparkline=p.sparkline||[];
1644
+ var lastRate=sparkline.length?sparkline[sparkline.length-1]:null;
1645
+ var rateColor=lastRate===null?'dim':lastRate>=90?'green':lastRate>=70?'amber':'red';
1646
+ var dotColor=rateColor;
1647
+
1648
+ var sparkEl=el('div',{className:'watch-sparkline'});
1649
+ if(sparkline.length>=2){
1650
+ sparkEl.appendChild(buildSparkline(sparkline));
1651
+ } else {
1652
+ sparkEl.style.cssText='height:40px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:10px';
1653
+ sparkEl.textContent=sparkline.length?'1 run':'No runs yet';
1654
+ }
1655
+
1656
+ var triggerBtn=el('button',{className:'btn sm',onclick:function(e){e.stopPropagation();triggerRun(null,p.id)}},'\u25B6');
1657
+ var detailBtn=el('button',{className:'btn sm',onclick:function(e){
1658
+ e.stopPropagation();
1659
+ S.project=p.id;$('#projectSelect').value=p.id;
1660
+ showView('runs');
1661
+ refreshRuns();refreshSuites();
1662
+ }},'\uD83D\uDD0D');
1663
+
1664
+ var card=el('div',{className:'watch-card',id:'watch-card-'+p.id},[
1665
+ el('div',{className:'watch-card-header'},[
1666
+ el('div',{className:'watch-card-name'},p.name),
1667
+ el('div',{className:'watch-card-icons'},[triggerBtn,detailBtn])
1668
+ ]),
1669
+ sparkEl,
1670
+ el('div',{className:'watch-card-footer'},[
1671
+ el('div',{className:'watch-card-status'},[
1672
+ el('span',{className:'status-dot '+dotColor}),
1673
+ el('span',{className:'watch-card-rate '+rateColor},lastRate!==null?lastRate+'%':'—')
1674
+ ]),
1675
+ el('span',{style:'color:var(--text3);font-size:10px'},p.runCount?p.runCount+' runs':'')
1676
+ ]),
1677
+ el('div',{className:'watch-card-meta'},[
1678
+ el('span',{className:'watch-card-countdown',id:'watch-countdown-'+p.id},''),
1679
+ p.lastCommit?el('span',{className:'watch-card-commit'},'\u{1F4CB} '+p.lastCommit.slice(0,8)):null
1680
+ ])
1681
+ ]);
1682
+
1683
+ container.appendChild(card);
1684
+ });
989
1685
  }
990
- function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
991
1686
 
992
- /* ══════════════════════════════════════════════════════════════════
993
- Projects
994
- ══════════════════════════════════════════════════════════════════ */
995
- function refreshProjects(){
996
- api('/api/db/projects').then(function(projects){
997
- var sel=$('#projectSelect'),prev=sel.value;
998
- while(sel.options.length>1)sel.remove(1);
999
- if(Array.isArray(projects))projects.forEach(function(p){
1000
- var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
1001
- });
1002
- sel.value=prev||'';
1003
- }).catch(function(){});
1687
+ function buildSparkline(data){
1688
+ var ns='http://www.w3.org/2000/svg';
1689
+ var svg=document.createElementNS(ns,'svg');
1690
+ svg.setAttribute('viewBox','0 0 200 40');
1691
+ svg.setAttribute('preserveAspectRatio','none');
1692
+
1693
+ var n=data.length;
1694
+ var w=200/(n-1||1);
1695
+ var pts=data.map(function(v,i){return (i*w)+','+(40-v*0.4)}).join(' ');
1696
+
1697
+ // Gradient fill
1698
+ var poly=document.createElementNS(ns,'polygon');
1699
+ poly.setAttribute('points','0,40 '+pts+' '+((n-1)*w)+',40');
1700
+ poly.setAttribute('fill','var(--accent-dim)');
1701
+ svg.appendChild(poly);
1702
+
1703
+ // Line
1704
+ var pl=document.createElementNS(ns,'polyline');
1705
+ pl.setAttribute('points',pts);
1706
+ pl.setAttribute('fill','none');
1707
+ pl.setAttribute('stroke','var(--accent)');
1708
+ pl.setAttribute('stroke-width','1.5');
1709
+ svg.appendChild(pl);
1710
+
1711
+ // End dot
1712
+ if(n>0){
1713
+ var lastVal=data[n-1];
1714
+ var dotColor=lastVal>=90?'var(--green)':lastVal>=70?'var(--amber)':'var(--red)';
1715
+ var circle=document.createElementNS(ns,'circle');
1716
+ circle.setAttribute('cx',''+(n-1)*w);
1717
+ circle.setAttribute('cy',''+(40-lastVal*0.4));
1718
+ circle.setAttribute('r','3');
1719
+ circle.setAttribute('fill',dotColor);
1720
+ svg.appendChild(circle);
1721
+ }
1722
+
1723
+ return svg;
1004
1724
  }
1005
- $('#projectSelect').addEventListener('change',function(){
1006
- S.project=this.value?parseInt(this.value,10):null;
1007
- S.selectedRun=null;
1008
- refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();
1009
- });
1010
1725
 
1726
+ function applyWatchJobData(jobs){
1727
+ if(!jobs||!jobs.length)return;
1728
+ jobs.forEach(function(j){
1729
+ // Find matching card by project name
1730
+ if(!_watchData)return;
1731
+ var match=_watchData.find(function(p){return p.name===j.name||p.cwd===j.cwd});
1732
+ if(!match)return;
1733
+ var cdEl=$('#watch-countdown-'+match.id);
1734
+ if(cdEl&&j.nextRunAt){
1735
+ cdEl.dataset.nextRunAt=j.nextRunAt;
1736
+ updateCountdown(cdEl);
1737
+ }
1738
+ });
1739
+ startCountdownTimer();
1740
+ }
1741
+
1742
+ function startCountdownTimer(){
1743
+ if(_countdownInterval)return;
1744
+ _countdownInterval=setInterval(function(){
1745
+ $$('.watch-card-countdown[data-next-run-at]').forEach(updateCountdown);
1746
+ },1000);
1747
+ }
1748
+
1749
+ function updateCountdown(cdEl){
1750
+ var next=cdEl.dataset.nextRunAt;
1751
+ if(!next){cdEl.textContent='';return}
1752
+ var diff=new Date(next)-Date.now();
1753
+ if(diff<=0){cdEl.textContent='\u23F1 Running...';return}
1754
+ var m=Math.floor(diff/60000);
1755
+ var s=Math.floor((diff%60000)/1000);
1756
+ cdEl.textContent='\u23F1 Next: '+m+'m '+String(s).padStart(2,'0')+'s';
1757
+ }
1758
+
1759
+ function renderEventLog(runs){
1760
+ var container=$('#watchEventLog');
1761
+ if(!container)return;
1762
+ container.textContent='';
1763
+
1764
+ if(!Array.isArray(runs)||!runs.length){
1765
+ container.appendChild(el('div',{style:'padding:16px;text-align:center;color:var(--text3);font-size:11px'},'No runs recorded yet.'));
1766
+ return;
1767
+ }
1768
+
1769
+ // Column header row
1770
+ container.appendChild(el('div',{className:'watch-event-row we-header'},[
1771
+ el('span',null,'Time'),
1772
+ el('span',null,'Project'),
1773
+ el('span',null,'Suite'),
1774
+ el('span',{style:'justify-self:center'},'Status'),
1775
+ el('span',{style:'text-align:center'},'Tests'),
1776
+ el('span',{style:'text-align:right'},'Rate'),
1777
+ el('span',{style:'text-align:right'},'Duration'),
1778
+ el('span',{style:'text-align:right'},'Source')
1779
+ ]));
1780
+
1781
+ var recent=runs.slice(0,30);
1782
+ recent.forEach(function(r){
1783
+ var rate=parseFloat(r.pass_rate)||0;
1784
+ var badgeCls=r.failed>0?'fail':'pass';
1785
+ var badgeText=r.failed>0?'FAIL':'PASS';
1786
+
1787
+ // Test counts: "5/5" or "3/5 (2 fail)"
1788
+ var countsText=r.passed+'/'+r.total;
1789
+ var countsParts=[el('span',{className:'we-counts-ok'},String(r.passed))];
1790
+ countsParts.push(document.createTextNode('/'+r.total));
1791
+ if(r.failed>0){
1792
+ countsParts.push(document.createTextNode(' ('));
1793
+ countsParts.push(el('span',{style:'color:var(--red)'},r.failed+' fail'));
1794
+ countsParts.push(document.createTextNode(')'));
1795
+ }
1796
+
1797
+ // Trigger badge
1798
+ var triggerIcon={'cli':'\u2318','dashboard':'\uD83D\uDCBB','mcp':'\u2699','watch':'\u23F1','api':'\u26A1'};
1799
+ var trigSrc=r.triggered_by||'cli';
1800
+ var trigEl=el('span',{className:'we-trigger',title:'Triggered by: '+trigSrc},(triggerIcon[trigSrc]||'\u2318')+' '+trigSrc);
1801
+
1802
+ var row=el('div',{className:'watch-event-row',style:'cursor:pointer'},[
1803
+ el('span',{className:'watch-event-time'},fdate(r.generated_at)),
1804
+ el('span',{className:'watch-event-project'},r.project_name||'—'),
1805
+ el('span',{className:'watch-event-suite'},r.suite_name||'all'),
1806
+ el('span',{className:'watch-event-result'},[el('span',{className:'badge '+badgeCls},badgeText)]),
1807
+ el('span',{className:'watch-event-counts'},countsParts),
1808
+ el('span',{className:'watch-event-rate'},rate>0?rate.toFixed(0)+'%':'—'),
1809
+ el('span',{className:'watch-event-duration'},r.duration?dur(r.duration):'—'),
1810
+ trigEl
1811
+ ]);
1812
+
1813
+ // Click to navigate to run detail
1814
+ (function(run){
1815
+ row.addEventListener('click',function(){
1816
+ S.project=run.project_id;$('#projectSelect').value=run.project_id;
1817
+ showView('runs');
1818
+ refreshRuns();
1819
+ });
1820
+ })(r);
1821
+
1822
+ container.appendChild(row);
1823
+ });
1824
+ }
1825
+
1826
+ function startWatchPolling(){
1827
+ if(_watchInterval)return;
1828
+ refreshWatch();
1829
+ _watchInterval=setInterval(refreshWatch,10000);
1830
+ }
1831
+ function stopWatchPolling(){
1832
+ if(_watchInterval){clearInterval(_watchInterval);_watchInterval=null}
1833
+ if(_countdownInterval){clearInterval(_countdownInterval);_countdownInterval=null}
1834
+ }
1835
+
1836
+
1837
+ /* ── view-tests.js ── */
1011
1838
  /* ══════════════════════════════════════════════════════════════════
1012
- Suites (+ Serial badges, Modules)
1839
+ Tests View — Suites + Modules + Variables (inner tabs)
1013
1840
  ══════════════════════════════════════════════════════════════════ */
1014
1841
  function refreshSuites(){
1015
1842
  var grid=$('#suiteGrid'),empty=$('#suitesEmpty'),accordion=$('#suiteAccordionContainer');
@@ -1067,15 +1894,75 @@ function renderProjectAccordion(container,project,suites){
1067
1894
  ]);
1068
1895
 
1069
1896
  var wrapper=el('div',{className:'project-accordion'},[header,body]);
1070
-
1071
- header.addEventListener('click',function(){
1072
- wrapper.classList.toggle('open');
1073
- });
1074
-
1897
+ header.addEventListener('click',function(){wrapper.classList.toggle('open')});
1075
1898
  container.appendChild(wrapper);
1076
1899
  }
1077
1900
 
1901
+ /* ── Suite Modal ── */
1078
1902
  var _suiteCache={};
1903
+
1904
+ function openSuiteModal(suiteName,projectId){
1905
+ var overlay=$('#suiteModalOverlay');
1906
+ var body=$('#suiteModalBody');
1907
+ $('#suiteModalName').textContent=suiteName;
1908
+ $('#suiteModalFile').textContent=suiteName+'.json';
1909
+ body.textContent='';
1910
+ body.appendChild(el('div',{className:'suite-modal-loading'},'Loading\u2026'));
1911
+ overlay.classList.add('open');
1912
+
1913
+ $('#suiteModalRun').onclick=function(){triggerRun(suiteName,projectId)};
1914
+ $('#suiteModalClose').onclick=function(){overlay.classList.remove('open')};
1915
+ overlay.addEventListener('click',function(e){if(e.target===overlay)overlay.classList.remove('open')});
1916
+
1917
+ var cacheKey=projectId+'::'+suiteName;
1918
+ var p=_suiteCache[cacheKey]||api('/api/db/projects/'+projectId+'/suites/'+encodeURIComponent(suiteName));
1919
+ _suiteCache[cacheKey]=p;
1920
+ p.then(function(data){
1921
+ body.textContent='';
1922
+ if(!data||!data.tests||!data.tests.length){
1923
+ body.appendChild(el('div',{className:'suite-modal-loading'},'No tests found'));
1924
+ return;
1925
+ }
1926
+ data.tests.forEach(function(test){
1927
+ var actionsDiv=el('div',{className:'suite-modal-test-actions'});
1928
+ (test.actions||[]).forEach(function(a,i){
1929
+ var detailContent;
1930
+ if(a.selector&&(a.value||a.text)){
1931
+ detailContent=[el('span',{className:'step-sel'},a.selector),el('span',{className:'step-arrow'},'\u2192'),el('span',{className:'step-val'},a.text||a.value)];
1932
+ } else {
1933
+ detailContent=a.selector||a.value||a.text||'';
1934
+ }
1935
+ actionsDiv.appendChild(el('div',{className:'suite-modal-step'},[
1936
+ el('span',{className:'suite-modal-step-num'},String(i+1)),
1937
+ el('span',{className:'suite-modal-step-type'},a.type),
1938
+ el('span',{className:'suite-modal-step-detail'},detailContent)
1939
+ ]));
1940
+ });
1941
+
1942
+ var header=el('div',{className:'suite-modal-test-header'},[
1943
+ el('span',{className:'suite-modal-test-chevron'},'\u25B6'),
1944
+ el('span',{className:'suite-modal-test-name'},test.name),
1945
+ el('span',{className:'suite-modal-test-badge'},(test.actions||[]).length+' actions')
1946
+ ]);
1947
+
1948
+ var testEl=el('div',{className:'suite-modal-test'},[header,actionsDiv]);
1949
+ if(test.expect){
1950
+ var expectText=Array.isArray(test.expect)?test.expect.join(', '):test.expect;
1951
+ var expectEl=el('div',{className:'suite-modal-expect'},[
1952
+ el('span',{className:'suite-modal-expect-label'},'Expect:'),
1953
+ document.createTextNode(expectText)
1954
+ ]);
1955
+ testEl.insertBefore(expectEl,actionsDiv);
1956
+ }
1957
+ header.addEventListener('click',function(){testEl.classList.toggle('open')});
1958
+ body.appendChild(testEl);
1959
+ });
1960
+ }).catch(function(){
1961
+ body.textContent='';
1962
+ body.appendChild(el('div',{className:'suite-modal-loading',style:'color:var(--red)'},'Failed to load suite'));
1963
+ });
1964
+ }
1965
+
1079
1966
  function renderSuiteCards(container,suites,projectId){
1080
1967
  suites.forEach(function(s){
1081
1968
  var tests=el('ul',{className:'suite-card-tests'});
@@ -1177,9 +2064,79 @@ function renderModules(container,modules){
1177
2064
  container.appendChild(grid);
1178
2065
  }
1179
2066
 
2067
+ /* ── Variables ── */
2068
+ function refreshVariables(){
2069
+ var container=$('#variablesContainer'),empty=$('#variablesEmpty');
2070
+ container.textContent='';
2071
+ if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to manage variables.';return}
2072
+ api('/api/db/projects/'+S.project+'/variables').then(function(vars){
2073
+ if(!Array.isArray(vars)||!vars.length){empty.style.display='block';empty.querySelector('p').textContent='No variables set. Add variables to use {{var.KEY}} in your tests.';return}
2074
+ empty.style.display='none';
2075
+ renderVariables(vars);
2076
+ }).catch(function(){empty.style.display='block'});
2077
+ }
2078
+
2079
+ function renderVariables(vars){
2080
+ var container=$('#variablesContainer');
2081
+ var tbl=el('table',{className:'var-table'});
2082
+ var thead=document.createElement('thead');
2083
+ var hr=document.createElement('tr');
2084
+ ['Key','Value','Scope','Actions'].forEach(function(h){hr.appendChild(el('th',null,h))});
2085
+ thead.appendChild(hr);tbl.appendChild(thead);
2086
+ var tbody=document.createElement('tbody');
2087
+ vars.forEach(function(v){
2088
+ var tr=document.createElement('tr');
2089
+ tr.appendChild(el('td',null,[el('code',null,v.key)]));
2090
+ tr.appendChild(el('td',{style:'max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'},v.is_secret?'\u2022\u2022\u2022\u2022\u2022\u2022':v.value));
2091
+ tr.appendChild(el('td',{style:'color:var(--text3)'},v.scope||'project'));
2092
+ var delBtn=el('button',{className:'btn sm danger',onclick:function(){
2093
+ if(!confirm('Delete variable "'+v.key+'"?'))return;
2094
+ fetch('/api/db/projects/'+S.project+'/variables/'+encodeURIComponent(v.key),{method:'DELETE'}).then(function(){refreshVariables();showToast('Variable deleted','success')}).catch(function(){showToast('Delete failed','error')});
2095
+ }},'\u2715');
2096
+ tr.appendChild(el('td',null,[delBtn]));
2097
+ tbody.appendChild(tr);
2098
+ });
2099
+ tbl.appendChild(tbody);
2100
+ container.appendChild(tbl);
2101
+ }
2102
+
2103
+ /* ── Variable Add Form ── */
2104
+ $('#btnAddVar').addEventListener('click',function(){
2105
+ var form=$('#varAddForm');
2106
+ if(form.style.display==='none'){
2107
+ form.style.display='';
2108
+ form.textContent='';
2109
+ var keyInput=el('input',{type:'text',placeholder:'KEY',style:'margin-right:8px;width:120px'});
2110
+ var valInput=el('input',{type:'text',placeholder:'Value',style:'margin-right:8px;width:200px'});
2111
+ var secretCheck=el('input',{type:'checkbox',style:'margin-right:4px'});
2112
+ var saveBtn=el('button',{className:'btn sm primary',onclick:function(){
2113
+ var k=keyInput.value.trim(),v=valInput.value;
2114
+ if(!k){showToast('Key is required','error');return}
2115
+ if(!S.project){showToast('Select a project first','error');return}
2116
+ fetch('/api/db/projects/'+S.project+'/variables',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:k,value:v,is_secret:secretCheck.checked})}).then(function(r){return r.json()}).then(function(){
2117
+ form.style.display='none';refreshVariables();showToast('Variable saved','success');
2118
+ }).catch(function(){showToast('Save failed','error')});
2119
+ }},'Save');
2120
+ var cancelBtn=el('button',{className:'btn sm',onclick:function(){form.style.display='none'}},'Cancel');
2121
+ form.appendChild(el('div',{className:'var-add-form',style:'display:flex;align-items:center;gap:8px;flex-wrap:wrap'},[
2122
+ keyInput,valInput,
2123
+ el('label',{style:'font-size:11px;color:var(--text2);display:flex;align-items:center;gap:4px'},[secretCheck,document.createTextNode('Secret')]),
2124
+ saveBtn,cancelBtn
2125
+ ]));
2126
+ } else {
2127
+ form.style.display='none';
2128
+ }
2129
+ });
2130
+
2131
+ $('#btnRunAll').addEventListener('click',function(){triggerRun()});
2132
+
2133
+
2134
+ /* ── view-runs.js ── */
1180
2135
  /* ══════════════════════════════════════════════════════════════════
1181
- Runs (+ Filters)
2136
+ Runs View — History + Screenshots + Learnings (inner tabs)
1182
2137
  ══════════════════════════════════════════════════════════════════ */
2138
+
2139
+ /* ── Filters ── */
1183
2140
  $$('.filter-btn').forEach(function(btn){
1184
2141
  btn.addEventListener('click',function(){
1185
2142
  $$('.filter-btn').forEach(function(b){b.classList.remove('active')});
@@ -1214,7 +2171,44 @@ function applyRunFilters(){
1214
2171
  });
1215
2172
  }
1216
2173
 
2174
+ function renderRunsHealthBanner(){
2175
+ var banner=$('#runsHealthBanner');
2176
+ banner.textContent='';
2177
+ var url=S.project?'/api/db/projects/'+S.project+'/health':'/api/db/health';
2178
+ fetch(url).then(function(r){return r.json()}).then(function(h){
2179
+ if(!h||!h.passRate)return;
2180
+ var rateColor=h.passRate>=90?'green':h.passRate>=70?'amber':'red';
2181
+ var trendIcon=h.passRateTrend==='improving'?'\u25B2':h.passRateTrend==='declining'?'\u25BC':'=';
2182
+ var trendCls=h.passRateTrend==='improving'?'green':h.passRateTrend==='declining'?'red':'dim';
2183
+ var deltaStr=h.trendDelta!==0?(h.trendDelta>0?'+':'')+h.trendDelta+'%':'';
2184
+
2185
+ banner.appendChild(el('div',{className:'hb-item'},[
2186
+ el('div',{className:'hb-val '+rateColor},h.passRate+'%'),
2187
+ el('div',{className:'hb-lbl'},'Pass Rate'),
2188
+ el('div',{className:'hb-trend '+trendCls},trendIcon+' '+h.passRateTrend+(deltaStr?' ('+deltaStr+')':''))
2189
+ ]));
2190
+ if(h.flakyCount>0){
2191
+ banner.appendChild(el('div',{className:'hb-item'},[
2192
+ el('div',{className:'hb-val amber'},String(h.flakyCount)),
2193
+ el('div',{className:'hb-lbl'},'Flaky Tests')
2194
+ ]));
2195
+ }
2196
+ if(h.topErrorPattern){
2197
+ var cat=h.topErrorPattern.category||h.topErrorPattern.pattern||'unknown';
2198
+ var pat=cat.replace(/-/g,' ').replace(/\b\w/g,function(c){return c.toUpperCase()});
2199
+ banner.appendChild(el('div',{className:'hb-item'},[
2200
+ el('div',{className:'hb-val red',style:'font-size:13px'},pat),
2201
+ el('div',{className:'hb-lbl'},'Top Error ('+h.topErrorPattern.count+'x)')
2202
+ ]));
2203
+ }
2204
+ banner.appendChild(el('div',{className:'hb-link',onclick:function(){var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
2205
+ el('span',null,'\u2192 View Learnings')
2206
+ ]));
2207
+ }).catch(function(){});
2208
+ }
2209
+
1217
2210
  function refreshRuns(){
2211
+ renderRunsHealthBanner();
1218
2212
  var url=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
1219
2213
  api(url).then(function(rows){
1220
2214
  var chart=$('#trendChart'),body=$('#runsBody'),empty=$('#runsEmpty'),head=$('#runsHead');
@@ -1325,9 +2319,7 @@ function toggleDetail(id,clickedTr,colSpan){
1325
2319
  loadDetailInline(id,detailTr);
1326
2320
  }
1327
2321
 
1328
- /* ══════════════════════════════════════════════════════════════════
1329
- Run Detail (+ Action Narratives, Retry badges, Export)
1330
- ══════════════════════════════════════════════════════════════════ */
2322
+ /* ── Run Detail ── */
1331
2323
  function loadDetailInline(id,detailTr){
1332
2324
  api('/api/db/runs/'+id).then(function(d){
1333
2325
  if(d.error)return;
@@ -1356,6 +2348,43 @@ function loadDetailInline(id,detailTr){
1356
2348
  ]);
1357
2349
  inner.appendChild(summ);
1358
2350
 
2351
+ // Insights
2352
+ var insightsContainer=el('div',{className:'rd-insights'});
2353
+ inner.appendChild(insightsContainer);
2354
+ fetch('/api/db/runs/'+id+'/insights').then(function(r){return r.json()}).then(function(ins){
2355
+ if(!ins||ins.error)return;
2356
+ var items=[];
2357
+ var h=ins.health;
2358
+ if(h){
2359
+ var rateColor=h.passRate>=90?'green':h.passRate>=70?'amber':'red';
2360
+ var trendIcon=h.passRateTrend==='improving'?'\u25B2':h.passRateTrend==='declining'?'\u25BC':'=';
2361
+ var trendCls=h.passRateTrend==='improving'?'green':h.passRateTrend==='declining'?'red':'';
2362
+ items.push(el('div',{className:'rd-ins-health'},[
2363
+ el('span',{className:'rd-ins-rate '+rateColor},h.passRate+'%'),
2364
+ el('span',{className:'rd-ins-trend '+trendCls},trendIcon+' '+h.passRateTrend),
2365
+ h.flakyCount>0?el('span',{className:'rd-ins-tag amber'},h.flakyCount+' flaky'):null,
2366
+ h.unstableSelectorCount>0?el('span',{className:'rd-ins-tag red'},h.unstableSelectorCount+' unstable sel.'):null
2367
+ ]));
2368
+ }
2369
+ var insights=ins.insights||[];
2370
+ insights.forEach(function(i){
2371
+ var icon=i.type==='new-failure'?'\u2718':i.type==='recovered'?'\u2714':i.type==='flaky'?'\u223C':'!';
2372
+ var cls=i.type==='new-failure'?'red':i.type==='recovered'?'green':i.type==='flaky'?'amber':'';
2373
+ items.push(el('div',{className:'rd-ins-item '+cls},[
2374
+ el('span',{className:'rd-ins-icon'},icon),
2375
+ el('span',null,i.message)
2376
+ ]));
2377
+ });
2378
+ if(items.length>0){items.forEach(function(it){insightsContainer.appendChild(it)})}
2379
+ else{insightsContainer.style.display='none'}
2380
+ }).catch(function(){insightsContainer.style.display='none'});
2381
+
2382
+ // Pool distribution bar
2383
+ var histPoolTests={};
2384
+ results.forEach(function(r){if(!r.poolUrl)return;histPoolTests[r.name]={poolUrl:r.poolUrl,success:r.success}});
2385
+ var histPoolDist=buildPoolDistribution(histPoolTests);
2386
+ if(histPoolDist)inner.appendChild(histPoolDist);
2387
+
1359
2388
  results.forEach(function(r){
1360
2389
  var d2=r.durationMs?dur(r.durationMs):r.endTime&&r.startTime?dur(new Date(r.endTime)-new Date(r.startTime)):'-';
1361
2390
  var flaky=r.success&&r.attempt>1;
@@ -1365,7 +2394,8 @@ function loadDetailInline(id,detailTr){
1365
2394
  badges.appendChild(el('span',{className:'badge '+(r.success?'pass':'fail')},r.success?'PASS':'FAIL'));
1366
2395
  if(flaky)badges.appendChild(el('span',{className:'badge flaky'},'FLAKY'));
1367
2396
 
1368
- var head=el('div',{className:'rd-test-head'},[badges,el('div',{className:'rd-test-name'},r.name),el('div',{className:'rd-test-dur'},d2)]);
2397
+ var poolEl=r.poolUrl?el('span',{className:'pool-badge'},r.poolUrl.replace('ws://','').replace('wss://','')) :null;
2398
+ var head=el('div',{className:'rd-test-head'},[badges,el('div',{className:'rd-test-name'},[document.createTextNode(r.name),poolEl]),el('div',{className:'rd-test-dur'},d2)]);
1369
2399
  var body=el('div',{className:'rd-test-body'});
1370
2400
 
1371
2401
  if(r.maxAttempts>1){body.appendChild(el('div',{className:'rd-retries'},'Attempt '+r.attempt+' of '+r.maxAttempts))}
@@ -1487,10 +2517,8 @@ function loadDetailInline(id,detailTr){
1487
2517
  if(inner)inner.textContent='Failed to load run detail';
1488
2518
  });
1489
2519
  }
1490
-
1491
- /* ══════════════════════════════════════════════════════════════════
1492
- Screenshots
1493
- ══════════════════════════════════════════════════════════════════ */
2520
+
2521
+ /* ── Screenshots ── */
1494
2522
  function refreshScreenshots(){
1495
2523
  var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
1496
2524
  gal.textContent='';
@@ -1535,8 +2563,152 @@ function searchByHash(){
1535
2563
  $('#ssHashBtn').addEventListener('click',searchByHash);
1536
2564
  $('#ssHashInput').addEventListener('keydown',function(e){if(e.key==='Enter')searchByHash()});
1537
2565
 
2566
+ /* ── Learnings ── */
2567
+ function refreshLearnings(){
2568
+ var days=$('#learningsDays').value||30;
2569
+ var url=S.project?'/api/db/projects/'+S.project+'/learnings?days='+days:'/api/db/learnings?days='+days;
2570
+ fetch(url).then(function(r){return r.json()}).then(function(data){
2571
+ if(!data||data.totalRuns===0){
2572
+ $('#learningsEmpty').style.display='block';
2573
+ $('#learningsOverview').textContent='';$('#learningsTrend').textContent='';
2574
+ $('#learningsFlaky').textContent='';$('#learningsSelectors').textContent='';
2575
+ $('#learningsPages').textContent='';$('#learningsApis').textContent='';
2576
+ $('#learningsErrors').textContent='';
2577
+ $('#badgeLearnings').textContent='-';
2578
+ return;
2579
+ }
2580
+ $('#learningsEmpty').style.display='none';
2581
+ S.lastLearningsData=data;
2582
+ var flakyCount=data.flakyTests?data.flakyTests.length:0;
2583
+ var passRate=data.overallPassRate||0;
2584
+ var declining=data.recentTrend&&Array.isArray(data.recentTrend.data||data.recentTrend)&&(function(){
2585
+ var td=data.recentTrend.data||data.recentTrend;
2586
+ if(td.length<2)return false;
2587
+ var last=td[td.length-1].pass_rate;
2588
+ var prior=td.slice(0,-1).reduce(function(s,t){return s+t.pass_rate},0)/(td.length-1);
2589
+ return last-prior<-2;
2590
+ })();
2591
+ if(passRate<70){
2592
+ $('#badgeLearnings').textContent='\u26A0';
2593
+ $('#badgeLearnings').style.background='var(--red-dim)';$('#badgeLearnings').style.color='var(--red)';
2594
+ } else if(flakyCount>0||declining){
2595
+ $('#badgeLearnings').textContent=flakyCount>0?flakyCount:(declining?'\u25BC':'\u2714');
2596
+ $('#badgeLearnings').style.background='var(--amber-dim)';$('#badgeLearnings').style.color='var(--amber)';
2597
+ } else {
2598
+ $('#badgeLearnings').textContent='\u2714';
2599
+ $('#badgeLearnings').style.background='var(--green-dim)';$('#badgeLearnings').style.color='var(--green)';
2600
+ }
2601
+ renderLearnOverview(data);
2602
+ renderLearnTrend(data.recentTrend||[]);
2603
+ renderLearnFlaky(data.flakyTests||[]);
2604
+ renderLearnSelectors(data.unstableSelectors||[]);
2605
+ renderLearnPages(data.failingPages||[]);
2606
+ renderLearnApis(data.apiIssues||[]);
2607
+ renderLearnErrors(data.topErrors||[]);
2608
+ }).catch(function(){$('#learningsEmpty').style.display='block'});
2609
+ }
2610
+
2611
+ function renderLearnOverview(d){
2612
+ var container=$('#learningsOverview');container.textContent='';
2613
+ var grid=document.createElement('div');grid.className='learn-grid';
2614
+ [{val:d.totalRuns,lbl:'Runs',cls:'accent'},{val:d.totalTests,lbl:'Tests',cls:'accent'},
2615
+ {val:d.overallPassRate+'%',lbl:'Pass Rate',cls:d.overallPassRate>=90?'green':d.overallPassRate>=70?'':'red'},
2616
+ {val:d.avgDurationMs<1000?d.avgDurationMs+'ms':(d.avgDurationMs/1000).toFixed(1)+'s',lbl:'Avg Duration',cls:'purple'},
2617
+ {val:(d.flakyTests?d.flakyTests.length:0),lbl:'Flaky Tests',cls:d.flakyTests&&d.flakyTests.length>0?'red':'green'},
2618
+ {val:(d.unstableSelectors?d.unstableSelectors.length:0),lbl:'Unstable Selectors',cls:d.unstableSelectors&&d.unstableSelectors.length>0?'red':'green'}
2619
+ ].forEach(function(item){
2620
+ var stat=document.createElement('div');stat.className='learn-stat';
2621
+ var valEl=document.createElement('div');valEl.className='learn-stat-val '+item.cls;valEl.textContent=item.val;
2622
+ var lblEl=document.createElement('div');lblEl.className='learn-stat-lbl';lblEl.textContent=item.lbl;
2623
+ stat.appendChild(valEl);stat.appendChild(lblEl);grid.appendChild(stat);
2624
+ });
2625
+ container.appendChild(grid);
2626
+ }
2627
+
2628
+ function renderLearnTrend(trend){
2629
+ var container=$('#learningsTrend');container.textContent='';
2630
+ if(!trend.length)return;
2631
+ var card=document.createElement('div');card.className='card';
2632
+ var label=document.createElement('div');label.className='card-label';label.textContent='Pass Rate Trend (7 days)';card.appendChild(label);
2633
+ var chartDiv=document.createElement('div');chartDiv.className='learn-trend-chart';
2634
+ var w=100/trend.length;var ns='http://www.w3.org/2000/svg';
2635
+ var svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 100 100');svg.setAttribute('preserveAspectRatio','none');
2636
+ var bg=document.createElementNS(ns,'rect');bg.setAttribute('x','0');bg.setAttribute('y','0');bg.setAttribute('width','100');bg.setAttribute('height','100');bg.setAttribute('fill','var(--surface2)');bg.setAttribute('rx','2');svg.appendChild(bg);
2637
+ var gridLine=document.createElementNS(ns,'line');gridLine.setAttribute('x1','0');gridLine.setAttribute('y1','50');gridLine.setAttribute('x2','100');gridLine.setAttribute('y2','50');gridLine.setAttribute('stroke','var(--border)');gridLine.setAttribute('stroke-width','0.3');gridLine.setAttribute('stroke-dasharray','2,2');svg.appendChild(gridLine);
2638
+ var pts=trend.map(function(t,i){return(i*w+w/2)+','+(100-t.pass_rate)}).join(' ');
2639
+ var poly=document.createElementNS(ns,'polygon');poly.setAttribute('points',(0*w+w/2)+',100 '+pts+' '+((trend.length-1)*w+w/2)+',100');poly.setAttribute('fill','var(--accent-dim)');svg.appendChild(poly);
2640
+ var pl=document.createElementNS(ns,'polyline');pl.setAttribute('points',pts);pl.setAttribute('fill','none');pl.setAttribute('stroke','var(--accent)');pl.setAttribute('stroke-width','1.5');svg.appendChild(pl);
2641
+ trend.forEach(function(t,i){
2642
+ var circle=document.createElementNS(ns,'circle');circle.setAttribute('cx',''+(i*w+w/2));circle.setAttribute('cy',''+(100-t.pass_rate));circle.setAttribute('r','2');circle.setAttribute('fill','var(--accent)');
2643
+ var title=document.createElementNS(ns,'title');title.textContent=t.date+': '+t.pass_rate+'% ('+t.total_tests+' tests)';circle.appendChild(title);svg.appendChild(circle);
2644
+ });
2645
+ chartDiv.appendChild(svg);card.appendChild(chartDiv);
2646
+ var dates=document.createElement('div');dates.style.cssText='display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:4px';
2647
+ dates.appendChild(el('span',null,trend[0].date));dates.appendChild(el('span',null,trend[trend.length-1].date));
2648
+ card.appendChild(dates);container.appendChild(card);
2649
+ }
2650
+
2651
+ function buildLearnTable(title,headers,rows){
2652
+ var card=document.createElement('div');card.className='card learn-section';
2653
+ var h=document.createElement('div');h.className='learn-section-title';h.textContent=title;card.appendChild(h);
2654
+ var wrap=document.createElement('div');wrap.className='tbl-wrap';
2655
+ var tbl=document.createElement('table');tbl.className='learn-table';
2656
+ var thead=document.createElement('thead');var hr=document.createElement('tr');
2657
+ headers.forEach(function(hdr){var th=document.createElement('th');th.textContent=hdr;hr.appendChild(th)});
2658
+ thead.appendChild(hr);tbl.appendChild(thead);
2659
+ var tbody=document.createElement('tbody');
2660
+ rows.forEach(function(cells){
2661
+ var tr=document.createElement('tr');
2662
+ cells.forEach(function(cell){
2663
+ var td=document.createElement('td');
2664
+ if(cell.code){var code=document.createElement('code');code.textContent=cell.code;td.appendChild(code)}
2665
+ else if(cell.badge){var span=document.createElement('span');span.className='badge '+cell.cls;span.textContent=cell.badge;td.appendChild(span)}
2666
+ else{td.textContent=cell.text!==undefined&&cell.text!==null?cell.text:(typeof cell==='object'?'-':cell)}
2667
+ tr.appendChild(td);
2668
+ });
2669
+ tbody.appendChild(tr);
2670
+ });
2671
+ tbl.appendChild(tbody);wrap.appendChild(tbl);card.appendChild(wrap);return card;
2672
+ }
2673
+
2674
+ function renderLearnFlaky(flaky){var c=$('#learningsFlaky');c.textContent='';if(!flaky.length)return;c.appendChild(buildLearnTable('Flaky Tests',['Test','Flaky Rate','Occurrences','Total Runs','Last Flaky','Avg Attempts'],flaky.map(function(f){return[{code:f.test_name},{badge:f.flaky_rate+'%',cls:f.flaky_rate>30?'fail':'flaky'},{text:f.flaky_count},{text:f.total_runs},{text:(f.last_flaky||'-').split('T')[0]},{text:f.avg_attempts}]})))}
2675
+ function renderLearnSelectors(sels){var c=$('#learningsSelectors');c.textContent='';if(!sels.length)return;c.appendChild(buildLearnTable('Unstable Selectors',['Selector','Action','Fail Rate','Uses','Tests','Page'],sels.map(function(s){var sel=s.selector.length>45?s.selector.slice(0,42)+'...':s.selector;return[{code:sel},{text:s.action_type},{badge:s.fail_rate+'%',cls:s.fail_rate>30?'fail':'flaky'},{text:s.total_uses},{text:s.used_by_tests},{text:s.page_url||'-'}]})))}
2676
+ function renderLearnPages(pages){var c=$('#learningsPages');c.textContent='';if(!pages.length)return;c.appendChild(buildLearnTable('Failing Pages',['Page','Fail Rate','Visits','Console Errors','Network Errors'],pages.map(function(p){return[{code:p.url_path},{badge:p.fail_rate+'%',cls:p.fail_rate>30?'fail':'flaky'},{text:p.total_visits},{text:p.console_errors},{text:p.network_errors}]})))}
2677
+ function renderLearnApis(apis){var c=$('#learningsApis');c.textContent='';if(!apis.length)return;c.appendChild(buildLearnTable('API Issues',['Endpoint','Error Rate','Calls','Avg Duration','Status Codes'],apis.map(function(a){var ep=a.endpoint.length>45?a.endpoint.slice(0,42)+'...':a.endpoint;var d=a.avg_duration_ms<1000?Math.round(a.avg_duration_ms)+'ms':(a.avg_duration_ms/1000).toFixed(1)+'s';return[{code:ep},{badge:a.error_rate+'%',cls:a.error_rate>20?'fail':'flaky'},{text:a.total_calls},{text:d},{text:a.status_codes||'-'}]})))}
2678
+ function renderLearnErrors(errors){var c=$('#learningsErrors');c.textContent='';if(!errors.length)return;c.appendChild(buildLearnTable('Error Patterns',['Pattern','Category','Count','First Seen','Last Seen','Example Test'],errors.map(function(e){var pat=e.pattern.length>50?e.pattern.slice(0,47)+'...':e.pattern;return[{text:pat},{badge:e.category,cls:'run'},{text:e.occurrence_count},{text:(e.first_seen||'-').split('T')[0]},{text:(e.last_seen||'-').split('T')[0]},{code:e.example_test||'-'}]})))}
2679
+
2680
+ $('#btnRefreshLearnings').addEventListener('click',refreshLearnings);
2681
+ $('#learningsDays').addEventListener('change',refreshLearnings);
2682
+
2683
+ $('#btnExportLearnings').addEventListener('click',function(){
2684
+ var data=S.lastLearningsData;
2685
+ if(!data){showToast('No learnings data to export','error');return}
2686
+ var md='# E2E Learnings Report\n\n';
2687
+ md+='| Metric | Value |\n|--------|-------|\n';
2688
+ md+='| Total Runs | '+data.totalRuns+' |\n';
2689
+ md+='| Total Tests | '+data.totalTests+' |\n';
2690
+ md+='| Pass Rate | '+data.overallPassRate+'% |\n';
2691
+ md+='| Avg Duration | '+dur(data.avgDurationMs)+' |\n\n';
2692
+ if(data.flakyTests&&data.flakyTests.length){
2693
+ md+='## Flaky Tests\n\n| Test | Flaky Rate | Occurrences |\n|------|-----------|-------------|\n';
2694
+ data.flakyTests.forEach(function(f){md+='| '+f.test_name+' | '+f.flaky_rate+'% | '+f.flaky_count+' |\n'});md+='\n';
2695
+ }
2696
+ if(data.unstableSelectors&&data.unstableSelectors.length){
2697
+ md+='## Unstable Selectors\n\n| Selector | Action | Fail Rate |\n|----------|--------|-----------|\n';
2698
+ data.unstableSelectors.forEach(function(s){md+='| `'+s.selector+'` | '+s.action_type+' | '+s.fail_rate+'% |\n'});md+='\n';
2699
+ }
2700
+ downloadFile('learnings-report.md',md,'text/markdown');
2701
+ showToast('Learnings exported','success');
2702
+ });
2703
+
2704
+ /* ── Modal ── */
2705
+ function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
2706
+ $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
2707
+
2708
+
2709
+ /* ── view-live.js ── */
1538
2710
  /* ══════════════════════════════════════════════════════════════════
1539
- Live Execution (+ Retry badges, Serial badges)
2711
+ Live Execution View
1540
2712
  ══════════════════════════════════════════════════════════════════ */
1541
2713
  function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}renderLive()}
1542
2714
  function dismissLiveRun(rid){delete S.liveRuns[rid];renderLive()}
@@ -1591,6 +2763,9 @@ function renderLive(){
1591
2763
  dismissBtn
1592
2764
  ]));
1593
2765
 
2766
+ var poolDist=buildPoolDistribution(L.tests);
2767
+ if(poolDist)grid.appendChild(poolDist);
2768
+
1594
2769
  var testGrid=el('div',{className:'lr-test-grid'});
1595
2770
  Object.keys(L.tests).forEach(function(name){
1596
2771
  if(name==='__error')return;
@@ -1609,12 +2784,13 @@ function renderLive(){
1609
2784
  var durText=a.duration!=null?(a.duration<1000?a.duration+'ms':(a.duration/1000).toFixed(1)+'s'):'';
1610
2785
  var retryBadge=null;
1611
2786
  if(a.actionRetries&&a.actionRetries>0){retryBadge=el('span',{className:'badge flaky',style:'font-size:9px;padding:1px 5px;margin-left:4px'},'\u21BB x'+a.actionRetries)}
1612
- stepsEl.appendChild(el('div',{className:'lt-step'},[
1613
- el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
1614
- el('span',{className:'step-type'},a.type),
1615
- el('span',{className:'step-detail'},detail),
2787
+ var stepCls='lt-step'+(a.isPoolLog?' pool-log':'');
2788
+ stepsEl.appendChild(el('div',{className:stepCls},[
2789
+ el('span',{className:'step-icon '+(a.isPoolLog?'':a.success?'ok':'fail')},a.isPoolLog?'\uD83D\uDD17':a.success?'\u2714':'\u2718'),
2790
+ el('span',{className:'step-type'},a.isPoolLog?'pool':a.type),
2791
+ el('span',{className:'step-detail'},a.isPoolLog?a.narrative:detail),
1616
2792
  retryBadge,
1617
- el('span',{className:'step-dur'},durText)
2793
+ a.isPoolLog?null:el('span',{className:'step-dur'},durText)
1618
2794
  ]));
1619
2795
  });
1620
2796
  if(t.status==='running'&&t.actions<t.totalActions){stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'waiting...')]))}
@@ -1649,10 +2825,11 @@ function renderLive(){
1649
2825
  }
1650
2826
 
1651
2827
  var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
2828
+ var poolBadge=t.poolUrl?el('span',{className:'pool-badge'},t.poolUrl.replace('ws://','').replace('wss://','')):null;
1652
2829
  var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
1653
2830
  el('div',{className:'lt-name'},[
1654
2831
  t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
1655
- document.createTextNode(' '+name),serialBadge,summaryEl
2832
+ document.createTextNode(' '+name),serialBadge,poolBadge,summaryEl
1656
2833
  ]),
1657
2834
  el('div',{className:'lt-meta'},meta),stepsEl
1658
2835
  ]);
@@ -1674,140 +2851,10 @@ function renderLive(){
1674
2851
  });
1675
2852
  }
1676
2853
 
1677
- $('#btnRunAll').addEventListener('click',function(){triggerRun()});
1678
-
1679
- /* ── Modal ── */
1680
- function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
1681
- $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
1682
-
1683
- /* ══════════════════════════════════════════════════════════════════
1684
- Learnings (+ Cross-project, Export)
1685
- ══════════════════════════════════════════════════════════════════ */
1686
- function refreshLearnings(){
1687
- var days=$('#learningsDays').value||30;
1688
- var url=S.project?'/api/db/projects/'+S.project+'/learnings?days='+days:'/api/db/learnings?days='+days;
1689
- fetch(url).then(function(r){return r.json()}).then(function(data){
1690
- if(!data||data.totalRuns===0){
1691
- $('#learningsEmpty').style.display='block';
1692
- $('#learningsOverview').textContent='';$('#learningsTrend').textContent='';
1693
- $('#learningsFlaky').textContent='';$('#learningsSelectors').textContent='';
1694
- $('#learningsPages').textContent='';$('#learningsApis').textContent='';
1695
- $('#learningsErrors').textContent='';
1696
- $('#badgeLearnings').textContent='-';
1697
- return;
1698
- }
1699
- $('#learningsEmpty').style.display='none';
1700
- S.lastLearningsData=data;
1701
- var flakyCount=data.flakyTests?data.flakyTests.length:0;
1702
- $('#badgeLearnings').textContent=flakyCount>0?flakyCount:'\u2714';
1703
- if(flakyCount>0){$('#badgeLearnings').style.background='var(--amber-dim)';$('#badgeLearnings').style.color='var(--amber)'}
1704
- else{$('#badgeLearnings').style.background='';$('#badgeLearnings').style.color=''}
1705
- renderLearnOverview(data);
1706
- renderLearnTrend(data.recentTrend||[]);
1707
- renderLearnFlaky(data.flakyTests||[]);
1708
- renderLearnSelectors(data.unstableSelectors||[]);
1709
- renderLearnPages(data.failingPages||[]);
1710
- renderLearnApis(data.apiIssues||[]);
1711
- renderLearnErrors(data.topErrors||[]);
1712
- }).catch(function(){$('#learningsEmpty').style.display='block'});
1713
- }
1714
-
1715
- function renderLearnOverview(d){
1716
- var container=$('#learningsOverview');container.textContent='';
1717
- var grid=document.createElement('div');grid.className='learn-grid';
1718
- [{val:d.totalRuns,lbl:'Runs',cls:'accent'},{val:d.totalTests,lbl:'Tests',cls:'accent'},
1719
- {val:d.overallPassRate+'%',lbl:'Pass Rate',cls:d.overallPassRate>=90?'green':d.overallPassRate>=70?'':'red'},
1720
- {val:d.avgDurationMs<1000?d.avgDurationMs+'ms':(d.avgDurationMs/1000).toFixed(1)+'s',lbl:'Avg Duration',cls:'purple'},
1721
- {val:(d.flakyTests?d.flakyTests.length:0),lbl:'Flaky Tests',cls:d.flakyTests&&d.flakyTests.length>0?'red':'green'},
1722
- {val:(d.unstableSelectors?d.unstableSelectors.length:0),lbl:'Unstable Selectors',cls:d.unstableSelectors&&d.unstableSelectors.length>0?'red':'green'}
1723
- ].forEach(function(item){
1724
- var stat=document.createElement('div');stat.className='learn-stat';
1725
- var valEl=document.createElement('div');valEl.className='learn-stat-val '+item.cls;valEl.textContent=item.val;
1726
- var lblEl=document.createElement('div');lblEl.className='learn-stat-lbl';lblEl.textContent=item.lbl;
1727
- stat.appendChild(valEl);stat.appendChild(lblEl);grid.appendChild(stat);
1728
- });
1729
- container.appendChild(grid);
1730
- }
1731
-
1732
- function renderLearnTrend(trend){
1733
- var container=$('#learningsTrend');container.textContent='';
1734
- if(!trend.length)return;
1735
- var card=document.createElement('div');card.className='card';
1736
- var label=document.createElement('div');label.className='card-label';label.textContent='Pass Rate Trend (7 days)';card.appendChild(label);
1737
- var chartDiv=document.createElement('div');chartDiv.className='learn-trend-chart';
1738
- var w=100/trend.length;var ns='http://www.w3.org/2000/svg';
1739
- var svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 100 100');svg.setAttribute('preserveAspectRatio','none');
1740
- var bg=document.createElementNS(ns,'rect');bg.setAttribute('x','0');bg.setAttribute('y','0');bg.setAttribute('width','100');bg.setAttribute('height','100');bg.setAttribute('fill','var(--surface2)');bg.setAttribute('rx','2');
1741
- svg.appendChild(bg);
1742
- var gridLine=document.createElementNS(ns,'line');gridLine.setAttribute('x1','0');gridLine.setAttribute('y1','50');gridLine.setAttribute('x2','100');gridLine.setAttribute('y2','50');gridLine.setAttribute('stroke','var(--border)');gridLine.setAttribute('stroke-width','0.3');gridLine.setAttribute('stroke-dasharray','2,2');svg.appendChild(gridLine);
1743
- var pts=trend.map(function(t,i){return(i*w+w/2)+','+(100-t.pass_rate)}).join(' ');
1744
- var poly=document.createElementNS(ns,'polygon');poly.setAttribute('points',(0*w+w/2)+',100 '+pts+' '+((trend.length-1)*w+w/2)+',100');poly.setAttribute('fill','var(--accent-dim)');svg.appendChild(poly);
1745
- var pl=document.createElementNS(ns,'polyline');pl.setAttribute('points',pts);pl.setAttribute('fill','none');pl.setAttribute('stroke','var(--accent)');pl.setAttribute('stroke-width','1.5');svg.appendChild(pl);
1746
- trend.forEach(function(t,i){
1747
- var circle=document.createElementNS(ns,'circle');circle.setAttribute('cx',''+(i*w+w/2));circle.setAttribute('cy',''+(100-t.pass_rate));circle.setAttribute('r','2');circle.setAttribute('fill','var(--accent)');
1748
- var title=document.createElementNS(ns,'title');title.textContent=t.date+': '+t.pass_rate+'% ('+t.total_tests+' tests)';circle.appendChild(title);svg.appendChild(circle);
1749
- });
1750
- chartDiv.appendChild(svg);card.appendChild(chartDiv);
1751
- var dates=document.createElement('div');dates.style.cssText='display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:4px';
1752
- dates.appendChild(el('span',null,trend[0].date));dates.appendChild(el('span',null,trend[trend.length-1].date));
1753
- card.appendChild(dates);container.appendChild(card);
1754
- }
1755
-
1756
- function buildLearnTable(title,headers,rows){
1757
- var card=document.createElement('div');card.className='card learn-section';
1758
- var h=document.createElement('div');h.className='learn-section-title';h.textContent=title;card.appendChild(h);
1759
- var wrap=document.createElement('div');wrap.className='tbl-wrap';
1760
- var tbl=document.createElement('table');tbl.className='learn-table';
1761
- var thead=document.createElement('thead');var hr=document.createElement('tr');
1762
- headers.forEach(function(hdr){var th=document.createElement('th');th.textContent=hdr;hr.appendChild(th)});
1763
- thead.appendChild(hr);tbl.appendChild(thead);
1764
- var tbody=document.createElement('tbody');
1765
- rows.forEach(function(cells){
1766
- var tr=document.createElement('tr');
1767
- cells.forEach(function(cell){
1768
- var td=document.createElement('td');
1769
- if(cell.code){var code=document.createElement('code');code.textContent=cell.code;td.appendChild(code)}
1770
- else if(cell.badge){var span=document.createElement('span');span.className='badge '+cell.cls;span.textContent=cell.badge;td.appendChild(span)}
1771
- else{td.textContent=cell.text!==undefined?cell.text:cell}
1772
- tr.appendChild(td);
1773
- });
1774
- tbody.appendChild(tr);
1775
- });
1776
- tbl.appendChild(tbody);wrap.appendChild(tbl);card.appendChild(wrap);return card;
1777
- }
1778
-
1779
- function renderLearnFlaky(flaky){var c=$('#learningsFlaky');c.textContent='';if(!flaky.length)return;c.appendChild(buildLearnTable('Flaky Tests',['Test','Flaky Rate','Occurrences','Total Runs','Last Flaky','Avg Attempts'],flaky.map(function(f){return[{code:f.test_name},{badge:f.flaky_rate+'%',cls:f.flaky_rate>30?'fail':'flaky'},{text:f.flaky_count},{text:f.total_runs},{text:(f.last_flaky||'-').split('T')[0]},{text:f.avg_attempts}]})))}
1780
- function renderLearnSelectors(sels){var c=$('#learningsSelectors');c.textContent='';if(!sels.length)return;c.appendChild(buildLearnTable('Unstable Selectors',['Selector','Action','Fail Rate','Uses','Tests','Page'],sels.map(function(s){var sel=s.selector.length>45?s.selector.slice(0,42)+'...':s.selector;return[{code:sel},{text:s.action_type},{badge:s.fail_rate+'%',cls:s.fail_rate>30?'fail':'flaky'},{text:s.total_uses},{text:s.used_by_tests},{text:s.page_url||'-'}]})))}
1781
- function renderLearnPages(pages){var c=$('#learningsPages');c.textContent='';if(!pages.length)return;c.appendChild(buildLearnTable('Failing Pages',['Page','Fail Rate','Visits','Console Errors','Network Errors'],pages.map(function(p){return[{code:p.url_path},{badge:p.fail_rate+'%',cls:p.fail_rate>30?'fail':'flaky'},{text:p.total_visits},{text:p.console_errors},{text:p.network_errors}]})))}
1782
- function renderLearnApis(apis){var c=$('#learningsApis');c.textContent='';if(!apis.length)return;c.appendChild(buildLearnTable('API Issues',['Endpoint','Error Rate','Calls','Avg Duration','Status Codes'],apis.map(function(a){var ep=a.endpoint.length>45?a.endpoint.slice(0,42)+'...':a.endpoint;var d=a.avg_duration_ms<1000?Math.round(a.avg_duration_ms)+'ms':(a.avg_duration_ms/1000).toFixed(1)+'s';return[{code:ep},{badge:a.error_rate+'%',cls:a.error_rate>20?'fail':'flaky'},{text:a.total_calls},{text:d},{text:a.status_codes||'-'}]})))}
1783
- function renderLearnErrors(errors){var c=$('#learningsErrors');c.textContent='';if(!errors.length)return;c.appendChild(buildLearnTable('Error Patterns',['Pattern','Category','Count','First Seen','Last Seen','Example Test'],errors.map(function(e){var pat=e.pattern.length>50?e.pattern.slice(0,47)+'...':e.pattern;return[{text:pat},{badge:e.category,cls:'run'},{text:e.occurrence_count},{text:(e.first_seen||'-').split('T')[0]},{text:(e.last_seen||'-').split('T')[0]},{code:e.example_test||'-'}]})))}
1784
-
1785
- $('#btnRefreshLearnings').addEventListener('click',refreshLearnings);
1786
- $('#learningsDays').addEventListener('change',refreshLearnings);
1787
-
1788
- $('#btnExportLearnings').addEventListener('click',function(){
1789
- var data=S.lastLearningsData;
1790
- if(!data){showToast('No learnings data to export','error');return}
1791
- var md='# E2E Learnings Report\n\n';
1792
- md+='| Metric | Value |\n|--------|-------|\n';
1793
- md+='| Total Runs | '+data.totalRuns+' |\n';
1794
- md+='| Total Tests | '+data.totalTests+' |\n';
1795
- md+='| Pass Rate | '+data.overallPassRate+'% |\n';
1796
- md+='| Avg Duration | '+dur(data.avgDurationMs)+' |\n\n';
1797
- if(data.flakyTests&&data.flakyTests.length){
1798
- md+='## Flaky Tests\n\n| Test | Flaky Rate | Occurrences |\n|------|-----------|-------------|\n';
1799
- data.flakyTests.forEach(function(f){md+='| '+f.test_name+' | '+f.flaky_rate+'% | '+f.flaky_count+' |\n'});md+='\n';
1800
- }
1801
- if(data.unstableSelectors&&data.unstableSelectors.length){
1802
- md+='## Unstable Selectors\n\n| Selector | Action | Fail Rate |\n|----------|--------|-----------|\n';
1803
- data.unstableSelectors.forEach(function(s){md+='| `'+s.selector+'` | '+s.action_type+' | '+s.fail_rate+'% |\n'});md+='\n';
1804
- }
1805
- downloadFile('learnings-report.md',md,'text/markdown');
1806
- showToast('Learnings exported','success');
1807
- });
1808
2854
 
2855
+ /* ── keyboard.js ── */
1809
2856
  /* ══════════════════════════════════════════════════════════════════
1810
- Keyboard Shortcuts
2857
+ Keyboard Shortcuts (Updated: 1=Watch, 2=Tests, 3=Runs, 4=Live)
1811
2858
  ══════════════════════════════════════════════════════════════════ */
1812
2859
  document.addEventListener('keydown',function(e){
1813
2860
  var tag=document.activeElement.tagName;
@@ -1815,6 +2862,7 @@ document.addEventListener('keydown',function(e){
1815
2862
  if(e.key==='Escape'){
1816
2863
  if($('#kbModal').classList.contains('open')){$('#kbModal').classList.remove('open');return}
1817
2864
  if($('#modal').classList.contains('open')){$('#modal').classList.remove('open');return}
2865
+ if($('#suiteModalOverlay').classList.contains('open')){$('#suiteModalOverlay').classList.remove('open');return}
1818
2866
  if(S.selectedRun!==null){
1819
2867
  var expanded=document.querySelector('#runsBody tr.expanded');
1820
2868
  if(expanded){
@@ -1827,11 +2875,13 @@ document.addEventListener('keydown',function(e){
1827
2875
  return;
1828
2876
  }
1829
2877
  if(e.key==='?'){$('#kbModal').classList.toggle('open');return}
1830
- var viewMap={'1':'suites','2':'runs','3':'screenshots','4':'learnings','5':'live'};
2878
+ var viewMap={'1':'watch','2':'tests','3':'runs','4':'live','5':'instances'};
1831
2879
  if(viewMap[e.key]){showView(viewMap[e.key]);return}
1832
2880
  if(e.key==='r'){
1833
- if(S.view==='suites')refreshSuites();else if(S.view==='runs')refreshRuns();
1834
- else if(S.view==='screenshots')refreshScreenshots();else if(S.view==='learnings')refreshLearnings();
2881
+ if(S.view==='watch')refreshWatch();
2882
+ else if(S.view==='tests'){refreshSuites();refreshVariables()}
2883
+ else if(S.view==='runs'){refreshRuns();refreshScreenshots();refreshLearnings()}
2884
+ else if(S.view==='live')renderLive();
1835
2885
  return;
1836
2886
  }
1837
2887
  if(S.view==='runs'&&(e.key==='j'||e.key==='k')){
@@ -1850,9 +2900,12 @@ document.addEventListener('keydown',function(e){
1850
2900
  });
1851
2901
  $('#kbModal').addEventListener('click',function(e){if(e.target===$('#kbModal'))$('#kbModal').classList.remove('open')});
1852
2902
 
2903
+
2904
+ /* ── init.js ── */
1853
2905
  /* ══════════════════════════════════════════════════════════════════
1854
- Init
2906
+ Init — startup sequence
1855
2907
  ══════════════════════════════════════════════════════════════════ */
2908
+ initTabs();
1856
2909
  connectWS();
1857
2910
  refreshStatus();
1858
2911
  refreshProjects();
@@ -1860,8 +2913,10 @@ refreshSuites();
1860
2913
  refreshRuns();
1861
2914
  refreshScreenshots();
1862
2915
  refreshLearnings();
1863
- })();
2916
+ refreshVariables();
2917
+ startWatchPolling();
1864
2918
 
2919
+ })();
1865
2920
  </script>
1866
2921
  </body>
1867
2922
  </html>