capybara-simulated 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,138 +7,311 @@
7
7
  <!--
8
8
  Self-contained trace viewer. The `capybara-simulated trace <file.json>`
9
9
  CLI replaces the data token in the script block below with the trace
10
- JSON (with `</` escaped to `<\/` so an embedded `</script>` in a DOM
11
- snapshot can't close the data block). Inline embedding not fetch /
10
+ JSON, with every `<` escaped to `\u003c` not just `</`: a snapshot
11
+ containing `<!-- <script …` steers the HTML tokenizer into a state a
12
+ real `</script>` no longer ends, and escaping only the closing form
13
+ guarantees it can never leave (see `Trace.render_viewer`). Inline
14
+ embedding — not fetch /
12
15
  `import … with { type: 'json' }` — is what lets the file open straight
13
16
  from `file://` without a server (module/fetch loads are CORS-blocked
14
17
  for `file://` origins). The Load button / drag-and-drop reads any other
15
18
  trace JSON via FileReader, which is also CORS-free.
19
+
20
+ Everything here is inline for the same reason: no webfont, no CDN, no
21
+ second file. A trace is an artifact people open from a CI download,
22
+ often offline, so the design's IBM Plex Mono is served by whatever the
23
+ reader's system mono is instead.
16
24
  -->
17
25
  <script id="csim-trace" type="application/json">__CSIM_TRACE_DATA__</script>
18
26
  <style>
19
27
  :root {
20
- --bg: #f6f7f9; --panel: #fff; --border: #e2e5ea; --text: #1f2329;
21
- --muted: #6b7280; --accent: #2563eb; --accent-soft: #eaf0fe;
22
- --error: #dc2626; --error-soft: #fdecec; --ok: #15803d;
23
- --warn: #b45309; --code: #f3f4f6;
28
+ --bg: #f6f7f8; --panel: #fff; --border: #e3e6ea; --hair: #eef0f3;
29
+ --text: #14171c; --text-2: #3d434c; --muted: #6a7280;
30
+ --accent: oklch(0.52 0.16 259); --accent-soft: oklch(0.97 0.02 259);
31
+ --red: oklch(0.48 0.19 27); --red-deep: oklch(0.42 0.19 27);
32
+ --red-line: oklch(0.9 0.05 27); --red-soft: oklch(0.97 0.02 27);
33
+ --red-tint: oklch(0.94 0.05 27); --amber: oklch(0.55 0.12 70);
34
+ --green: oklch(0.5 0.13 150);
35
+ --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
36
+ --row-pad: 3px;
24
37
  }
25
38
  * { box-sizing: border-box; }
26
39
  html, body { height: 100%; margin: 0; }
27
40
  body {
28
- font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
29
- color: var(--text); background: var(--bg); display: flex; flex-direction: column;
41
+ display: flex; flex-direction: column; overflow: hidden;
42
+ background: var(--bg); color: var(--text);
43
+ font: 12.5px/1.45 system-ui, -apple-system, 'Helvetica Neue', sans-serif;
44
+ -webkit-font-smoothing: antialiased;
30
45
  }
31
- header {
32
- display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
33
- padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
34
- }
35
- header h1 { font-size: 15px; margin: 0; font-weight: 600; }
36
- header .meta { color: var(--muted); font-size: 12px; display: flex; gap: 14px; flex-wrap: wrap; }
37
- header .spacer { flex: 1; }
38
- .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
39
- .badge.passed { background: #e6f4ea; color: var(--ok); }
40
- .badge.failed { background: var(--error-soft); color: var(--error); }
46
+ .mono { font-family: var(--mono); }
47
+ .num { font-variant-numeric: tabular-nums; }
41
48
  button, label.btn {
42
- font: inherit; font-size: 13px; padding: 5px 12px; border: 1px solid var(--border);
43
- background: var(--panel); border-radius: 6px; cursor: pointer; color: var(--text);
49
+ font: inherit; font-size: 11px; padding: 3px 9px; border: 1px solid var(--border);
50
+ background: var(--panel); border-radius: 4px; color: var(--text-2); cursor: pointer;
51
+ }
52
+ button:hover, label.btn:hover { background: #f1f3f5; }
53
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
54
+ ::-webkit-scrollbar-thumb { background: #c9ced6; border: 3px solid #fff; border-radius: 6px; }
55
+ ::-webkit-scrollbar-track { background: #fff; }
56
+
57
+ /* ── header ───────────────────────────────────────────────────────── */
58
+ header {
59
+ flex: none; display: flex; align-items: center; gap: 14px; height: 42px;
60
+ padding: 0 12px; background: var(--panel); border-bottom: 1px solid var(--border);
61
+ }
62
+ .brand { display: flex; align-items: baseline; gap: 7px; flex: none; }
63
+ .brand b { font-size: 11px; font-weight: 600; letter-spacing: .02em; }
64
+ .brand span { font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
65
+ .rule { width: 1px; height: 18px; background: var(--border); flex: none; }
66
+ .ident { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
67
+ .ident .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
68
+ .ident .file { font-family: var(--mono); font-size: 11px; color: var(--muted);
69
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
70
+ .badge {
71
+ flex: none; padding: 1px 7px; border-radius: 3px; font-family: var(--mono);
72
+ font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
73
+ background: #f1f3f5; color: var(--muted);
74
+ }
75
+ .badge.failed { background: var(--red-tint); color: var(--red); }
76
+ .badge.passed { background: oklch(0.94 0.05 150); color: var(--green); }
77
+ .stats { display: flex; align-items: center; gap: 12px; flex: none;
78
+ font-family: var(--mono); font-size: 11px; color: var(--muted); }
79
+ .actions { display: flex; align-items: center; gap: 6px; flex: none; }
80
+
81
+ /* ── layout ───────────────────────────────────────────────────────── */
82
+ main { flex: 1; display: flex; min-height: 0; overflow-x: auto; }
83
+ nav {
84
+ width: 306px; flex: none; display: flex; flex-direction: column;
85
+ background: var(--panel); border-right: 1px solid var(--border); min-height: 0;
86
+ }
87
+ .nav-head, .nav-foot {
88
+ flex: none; display: flex; align-items: center; gap: 10px; padding: 5px 10px;
89
+ }
90
+ .nav-head { justify-content: space-between; border-bottom: 1px solid var(--hair); }
91
+ .nav-foot { padding: 4px 10px; border-top: 1px solid var(--hair);
92
+ font-family: var(--mono); font-size: 10px; color: var(--muted); }
93
+ .cap { font-size: 10px; font-weight: 600; letter-spacing: .07em;
94
+ text-transform: uppercase; color: var(--muted); }
95
+ #steps { flex: 1; overflow-y: auto; min-height: 0; }
96
+ .jump {
97
+ font-family: var(--mono); font-size: 10px; padding: 1px 6px; border-radius: 3px;
98
+ border: 1px solid oklch(0.88 0.06 27); background: var(--red-soft); color: var(--red);
44
99
  }
45
- button:hover, label.btn:hover { background: var(--code); }
46
- main { flex: 1; display: flex; min-height: 0; }
47
- #steps { width: 320px; flex: none; overflow-y: auto; border-right: 1px solid var(--border); background: var(--panel); }
100
+ .jump:hover { background: var(--red-tint); }
101
+ .jump[hidden] { display: none; }
102
+
48
103
  .step {
49
- display: flex; align-items: baseline; gap: 8px; padding: 8px 12px;
50
- border-bottom: 1px solid var(--border); cursor: pointer; border-left: 3px solid transparent;
104
+ display: flex; align-items: baseline; gap: 7px; padding: var(--row-pad) 10px;
105
+ cursor: pointer; border-bottom: 1px solid #f1f3f5; border-left: 2px solid transparent;
51
106
  }
52
107
  .step:hover { background: var(--bg); }
108
+ .step .idx { flex: none; font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
109
+ .step .kind {
110
+ flex: none; width: 82px; font-family: var(--mono); font-size: 11px; font-weight: 600;
111
+ color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
112
+ }
113
+ .step .desc {
114
+ flex: 1; min-width: 0; font-family: var(--mono); font-size: 11.5px;
115
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
116
+ }
117
+ .step .mark { flex: none; width: 8px; text-align: center; font-family: var(--mono);
118
+ font-size: 11px; font-weight: 700; color: var(--amber); }
119
+ .step .dur { flex: none; font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
53
120
  .step.sel { background: var(--accent-soft); border-left-color: var(--accent); }
54
- .step.err { border-left-color: var(--error); }
55
- .step .idx { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; min-width: 22px; }
56
- .step .kind { font-weight: 600; font-size: 12px; }
57
- .step .desc { flex: 1; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
- .step .dur { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
59
- .step.err .kind { color: var(--error); }
60
- #detail { flex: 1; overflow-y: auto; padding: 18px 22px; min-width: 0; }
61
- #detail h2 { margin: 0 0 2px; font-size: 17px; }
62
- #detail .sub { color: var(--muted); font-size: 12px; margin-bottom: 16px; }
63
- section.block { margin: 18px 0; }
64
- section.block > h3 {
65
- font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
66
- color: var(--muted); margin: 0 0 8px; font-weight: 600;
67
- }
68
- code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
69
- .kv { display: grid; grid-template-columns: auto 1fr; gap: 2px 14px; }
70
- .kv dt { color: var(--muted); }
71
- .kv dd { margin: 0; word-break: break-all; }
72
- .url-to { font-weight: 600; }
73
- .panel-error { background: var(--error-soft); border: 1px solid #f3c4c4; border-radius: 6px; padding: 10px 12px; }
74
- .panel-error .cls { font-weight: 600; color: var(--error); }
75
- .panel-error .msg { white-space: pre-wrap; margin-top: 4px; }
76
- .logs { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
77
- .log { display: flex; gap: 10px; padding: 4px 10px; border-top: 1px solid var(--border); font-size: 12.5px; }
121
+ .step.sel .kind { color: var(--accent); }
122
+ .step.err { border-left-color: var(--red); }
123
+ .step.err .kind, .step.err .mark { color: var(--red); }
124
+ .step.err.sel { background: var(--red-soft); }
125
+
126
+ /* ── detail ───────────────────────────────────────────────────────── */
127
+ section.pane { flex: 1; display: flex; flex-direction: column; min-width: 520px; min-height: 0; }
128
+ .banner {
129
+ flex: none; display: flex; align-items: flex-start; gap: 10px; padding: 8px 14px;
130
+ background: var(--red-soft); border-bottom: 1px solid var(--red-line);
131
+ }
132
+ .banner[hidden] { display: none; }
133
+ .banner .lbl { flex: none; margin-top: 1px; font-family: var(--mono); font-size: 10px;
134
+ font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--red); }
135
+ .banner .cls { font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: var(--red-deep); }
136
+ .banner .msg { font-family: var(--mono); font-size: 11.5px; color: var(--text-2);
137
+ white-space: pre-wrap; word-break: break-word; }
138
+ #detail { flex: 1; overflow-y: auto; min-height: 0; padding: 14px 16px 40px; }
139
+ .head { display: flex; align-items: baseline; gap: 9px; }
140
+ .head .idx { font-family: var(--mono); font-size: 12px; color: var(--muted); }
141
+ .head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
142
+ .desc-line { margin-top: 2px; font-family: var(--mono); font-size: 12px;
143
+ color: var(--text-2); word-break: break-word; }
144
+ .facts {
145
+ margin-top: 12px; display: flex; flex-wrap: wrap; gap: 0 22px; padding: 7px 10px;
146
+ background: var(--panel); border: 1px solid var(--border); border-radius: 5px;
147
+ }
148
+ .facts > div { display: flex; gap: 7px; align-items: baseline; min-width: 0; }
149
+ .facts .k { font-size: 10px; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
150
+ .facts .v { font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
151
+ .facts .v.from { color: var(--muted); }
152
+ .facts .v.to { font-weight: 600; }
153
+ .block { margin-top: 16px; }
154
+ .block > h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-weight: 600; }
155
+ .block > h3 .cap { flex: none; }
156
+ .block > h3 .count { font-family: var(--mono); font-size: 10px; color: var(--muted); font-weight: 400; }
157
+ .block > h3 .hair { flex: 1; height: 1px; background: var(--border); }
158
+ .block > h3 .note { font-size: 10.5px; color: var(--muted); font-weight: 400; }
159
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 4px; }
160
+ .panel-error { border-color: var(--red-line); border-left: 2px solid oklch(0.55 0.19 27); padding: 8px 11px; }
161
+ .panel-error .cls { font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: oklch(0.46 0.19 27); }
162
+ .panel-error .msg { margin-top: 3px; font-family: var(--mono); font-size: 11.5px;
163
+ color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
164
+ .log { display: flex; gap: 10px; padding: var(--row-pad) 10px; border-top: 1px solid #f1f3f5; }
78
165
  .log:first-child { border-top: none; }
79
- .log .sev { flex: none; width: 52px; font-weight: 600; font-size: 11px; text-transform: uppercase; }
80
- .log .sev.error { color: var(--error); } .log .sev.warn, .log .sev.warning { color: var(--warn); }
81
- .log .sev.debug { color: var(--muted); }
82
- .log .msg { white-space: pre-wrap; word-break: break-word; }
83
- .net-hint { font-size: 11px; margin-bottom: 6px; }
84
- .net { width: 100%; border-collapse: collapse; font-size: 12.5px; }
85
- .net th, .net td { text-align: left; padding: 4px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
86
- .net th { color: var(--muted); font-weight: 600; }
87
- .net td.method { font-weight: 600; } .net td.url { word-break: break-all; }
88
- .net td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
89
- .net td.status { font-variant-numeric: tabular-nums; }
90
- .net td.status.ok { color: var(--ok); } .net td.status.redir { color: var(--warn); } .net td.status.err { color: var(--error); }
91
- .net-row { cursor: pointer; }
166
+ .log .sev {
167
+ flex: none; width: 44px; padding-top: 2px; font-family: var(--mono); font-size: 10px;
168
+ font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted);
169
+ }
170
+ .log .sev.error { color: var(--red); }
171
+ .log .sev.warn, .log .sev.warning { color: var(--amber); }
172
+ .log .msg { min-width: 0; font-family: var(--mono); font-size: 11.5px;
173
+ white-space: pre-wrap; word-break: break-word; }
174
+
175
+ .net { overflow-x: auto; }
176
+ .net-row, .net-head {
177
+ display: grid; grid-template-columns: 58px minmax(120px, 1fr) 44px 130px 62px 56px;
178
+ gap: 10px; min-width: 560px; padding: var(--row-pad) 10px; align-items: baseline;
179
+ }
180
+ .net-head {
181
+ background: #fafbfc; border-bottom: 1px solid var(--hair); font-size: 10px;
182
+ font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted);
183
+ }
184
+ .net-row { cursor: pointer; border-bottom: 1px solid #f1f3f5; font-family: var(--mono); font-size: 11px; }
92
185
  .net-row:hover { background: var(--bg); }
93
- .net-row.open { background: var(--accent-soft); }
94
- .redir-tag { display: inline-block; font-size: 10px; font-weight: 600; color: var(--warn);
95
- background: #fff5e6; border-radius: 8px; padding: 0 6px; vertical-align: middle; }
96
- .net-detail td { background: var(--bg); }
97
- .net-sub { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase;
98
- letter-spacing: .03em; margin: 8px 0 4px; }
99
- .net-detail .net-sub:first-child { margin-top: 0; }
100
- .kv.hdr { font-size: 12px; grid-template-columns: max-content 1fr; gap: 1px 12px; }
101
- .kv.hdr dt { color: var(--muted); }
102
- .net-body { max-height: 260px; overflow: auto; background: var(--code); border: 1px solid var(--border);
103
- border-radius: 6px; padding: 8px 10px; font-size: 12px; margin: 0; white-space: pre-wrap; word-break: break-word; }
104
- .tabs { display: flex; gap: 6px; margin-bottom: 8px; }
105
- .tabs button.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
106
- iframe.dom { width: 100%; height: 460px; border: 1px solid var(--border); border-radius: 6px; background: #fff; }
107
- pre.dom { max-height: 460px; overflow: auto; background: var(--code); border: 1px solid var(--border);
108
- border-radius: 6px; padding: 12px; font-size: 12px; margin: 0; }
109
- .muted { color: var(--muted); }
110
- #empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: var(--muted); text-align: center; }
111
- #empty.hide { display: none; }
112
- #drop-veil { position: fixed; inset: 0; background: rgba(37,99,235,.08); border: 3px dashed var(--accent);
113
- display: none; align-items: center; justify-content: center; font-size: 18px; color: var(--accent); z-index: 10; }
186
+ .net-row.open { background: #f7f8f9; }
187
+ .net-row .m { font-size: 11px; font-weight: 600; }
188
+ .net-row .u { font-size: 11.5px; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
189
+ .net-row .s { font-size: 11.5px; font-variant-numeric: tabular-nums; }
190
+ .net-row .s.ok { color: var(--green); } .net-row .s.redir { color: var(--amber); } .net-row .s.err { color: var(--red); }
191
+ .net-row .t, .net-row .z, .net-row .d { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
192
+ .net-row .z, .net-row .d { text-align: right; font-variant-numeric: tabular-nums; }
193
+ .net-head .z, .net-head .d { text-align: right; }
194
+ .net-detail {
195
+ padding: 8px 10px 10px 68px; min-width: 560px; background: #fafbfc;
196
+ border-bottom: 1px solid var(--hair); display: grid; gap: 8px;
197
+ }
198
+ .net-detail[hidden] { display: none; }
199
+ .net-sub { font-size: 10px; font-weight: 600; letter-spacing: .05em;
200
+ text-transform: uppercase; color: var(--muted); margin-bottom: 2px; }
201
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 1px 12px;
202
+ font-family: var(--mono); font-size: 11px; margin: 0; }
203
+ .kv dt { color: var(--muted); }
204
+ .kv dd { margin: 0; word-break: break-all; }
205
+ pre.body, pre.dom {
206
+ margin: 0; overflow: auto; padding: 7px 9px; background: var(--panel);
207
+ border: 1px solid var(--border); border-radius: 4px;
208
+ font-family: var(--mono); font-size: 11px; white-space: pre-wrap; word-break: break-word;
209
+ }
210
+ pre.body { max-height: 200px; }
211
+ pre.dom { max-height: 430px; font-size: 11.5px; line-height: 1.5; tab-size: 2; }
212
+ pre.dom.nowrap { white-space: pre; word-break: normal; }
213
+ .empty-note { padding: 9px 11px; border: 1px dashed #dfe3e8; border-radius: 4px;
214
+ color: var(--muted); font-size: 11.5px; }
215
+ .empty-note code { font-family: var(--mono); background: #f1f3f5; padding: 0 4px; border-radius: 3px; }
216
+
217
+ /* ── screenshot rail ──────────────────────────────────────────────── */
218
+ aside {
219
+ width: 348px; flex: none; display: flex; flex-direction: column;
220
+ background: var(--panel); border-left: 1px solid var(--border); min-height: 0;
221
+ }
222
+ aside[hidden] { display: none; }
223
+ .rail-tabs { flex: none; display: flex; align-items: center; gap: 2px;
224
+ padding: 4px 6px; border-bottom: 1px solid var(--hair); }
225
+ .rail-tabs .tab { border-color: transparent; background: transparent; color: var(--muted); }
226
+ .rail-tabs .tab.on { border-color: var(--border); background: #f1f3f5; color: var(--text); font-weight: 600; }
227
+ .rail-tabs .tab[hidden] { display: none; }
228
+ .rail-tabs .dims { flex: 1; text-align: right; padding-right: 4px;
229
+ font-family: var(--mono); font-size: 10px; color: var(--muted); }
230
+ .rail-body { flex: 1; overflow-y: auto; min-height: 0; padding: 10px; }
231
+ .frame {
232
+ border: 1px solid var(--border); border-radius: 4px; overflow: hidden;
233
+ background: repeating-linear-gradient(135deg, #f3f4f6 0 6px, #fafbfc 6px 12px);
234
+ }
235
+ .frame .bar { height: 22px; display: flex; align-items: center; gap: 6px; padding: 0 8px;
236
+ background: var(--panel); border-bottom: 1px solid var(--border); }
237
+ .frame .dot { width: 6px; height: 6px; border-radius: 50%; background: #d6dae0; flex: none; }
238
+ .frame .loc { font-family: var(--mono); font-size: 10px; color: var(--muted);
239
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
240
+ /* Scaled to the rail, never up: a viewport-sized shot is wider than the rail, and a short one
241
+ (a small `full: false` page) should not be blown up into a blur. */
242
+ .shot { display: block; max-width: 100%; width: auto; height: auto; background: #fff; }
243
+ .slot { height: 300px; display: flex; align-items: center; justify-content: center;
244
+ text-align: center; padding: 12px; }
245
+ .slot span { font-family: var(--mono); font-size: 10.5px; color: var(--muted);
246
+ background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 3px 7px; }
247
+ .caption { margin: 8px 0 0; font-size: 11px; color: var(--muted); text-wrap: pretty; }
248
+
249
+ /* ── empty / drop ─────────────────────────────────────────────────── */
250
+ #empty { flex: 1; display: flex; flex-direction: column; align-items: center;
251
+ justify-content: center; gap: 14px; color: var(--muted); text-align: center; }
252
+ #empty[hidden], main[hidden] { display: none; }
253
+ #drop-veil { position: fixed; inset: 0; background: oklch(0.52 0.16 259 / .08);
254
+ border: 3px dashed var(--accent); display: none; align-items: center;
255
+ justify-content: center; font-size: 18px; color: var(--accent); z-index: 10; }
114
256
  #drop-veil.on { display: flex; }
115
257
  </style>
116
258
  </head>
117
259
  <body>
118
260
  <header>
119
- <h1>capybara-simulated trace</h1>
120
- <div class="meta" id="meta"></div>
121
- <span class="spacer"></span>
122
- <label class="btn">Load JSON…<input id="file" type="file" accept=".json,application/json" hidden></label>
123
- </header>
124
- <main>
125
- <div id="steps"></div>
126
- <div id="detail"></div>
127
- <div id="empty">
128
- <div style="font-size:40px">📊</div>
129
- <div>No trace loaded.<br>Drop a <code>*.json</code> trace here or use <b>Load JSON…</b>.</div>
261
+ <div class="brand"><b>capybara-simulated</b><span>trace</span></div>
262
+ <div class="rule" id="rule" hidden></div>
263
+ <div class="ident" id="ident"></div>
264
+ <div class="stats" id="stats"></div>
265
+ <div class="actions">
266
+ <button type="button" id="rail-toggle" hidden>Hide preview</button>
267
+ <label class="btn">Load JSON…<input id="file" type="file" accept=".json,application/json" hidden></label>
130
268
  </div>
269
+ </header>
270
+ <main id="main" hidden>
271
+ <nav>
272
+ <div class="nav-head">
273
+ <span class="cap">Steps</span>
274
+ <button type="button" class="jump" id="jump" hidden></button>
275
+ </div>
276
+ <div id="steps"></div>
277
+ <div class="nav-foot"><span>j / k move</span><span>f failure</span></div>
278
+ </nav>
279
+ <section class="pane">
280
+ <div class="banner" id="banner" hidden></div>
281
+ <div id="detail"></div>
282
+ </section>
283
+ <aside id="rail" hidden>
284
+ <div class="rail-tabs">
285
+ <button type="button" class="tab" id="tab-final">Final state</button>
286
+ <button type="button" class="tab" id="tab-step"></button>
287
+ <span class="dims" id="dims"></span>
288
+ </div>
289
+ <div class="rail-body" id="rail-body"></div>
290
+ </aside>
131
291
  </main>
292
+ <div id="empty">
293
+ <div style="font-size:40px">📊</div>
294
+ <div>No trace loaded.<br>Drop a <code>*.json</code> trace here or use <b>Load JSON…</b>.</div>
295
+ </div>
132
296
  <div id="drop-veil">Drop trace JSON to load</div>
133
297
 
134
298
  <script>
135
299
  (function () {
136
300
  'use strict';
137
- var stepsEl = document.getElementById('steps');
138
- var detailEl = document.getElementById('detail');
139
- var emptyEl = document.getElementById('empty');
140
- var metaEl = document.getElementById('meta');
141
- var trace = null, selected = 0;
301
+ var $ = function (id) { return document.getElementById(id); };
302
+ var stepsEl = $('steps'), detailEl = $('detail'), emptyEl = $('empty'), mainEl = $('main');
303
+ var identEl = $('ident'), statsEl = $('stats'), bannerEl = $('banner'), jumpEl = $('jump');
304
+ var ruleEl = $('rule');
305
+ var railEl = $('rail'), railBodyEl = $('rail-body'), railToggleEl = $('rail-toggle');
306
+ var tabFinalEl = $('tab-final'), tabStepEl = $('tab-step'), dimsEl = $('dims');
307
+
308
+ var trace = null; // the loaded trace
309
+ var rowEls = []; // the step rows, kept so moving the selection is a class toggle
310
+ var selected = 0; // index of the step being shown
311
+ var failureIdx = -1; // first step that errored, or -1
312
+ var railOpen = true; // the user's preference, kept across step changes
313
+ var shotTab = 'final'; // which screenshot the rail is showing
314
+ var wrap = false; // DOM-source soft wrapping
142
315
 
143
316
  // Escapes both text and double-quoted-attribute contexts (severity /
144
317
  // outcome land in class="…"), so a quote in trace data can't break out
@@ -148,8 +321,25 @@
148
321
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
149
322
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
150
323
  }
324
+ function pad2(n) { return (n < 10 ? '0' : '') + n; }
325
+ // Trace data is whatever the app under test produced, and a field of the wrong SHAPE used to
326
+ // throw mid-render and leave a blank pane with nothing in the console.
327
+ function asArray(v) { return Array.isArray(v) ? v : []; }
328
+ // Trace-supplied words that land in `class="…"`, mapped to a KNOWN set rather than sanitised.
329
+ // `esc()` keeps a value inside its attribute, but not inside its own class: stripping the
330
+ // characters that could add a second class still lets a plain word collide with one of the
331
+ // viewer's own (an `outcome` of "hide" picked up a `display: none`). A whitelist cannot.
332
+ function outcomeClass(o) {
333
+ o = String(o == null ? '' : o).toLowerCase();
334
+ return o === 'failed' ? 'failed' : o === 'passed' ? 'passed' : '';
335
+ }
336
+ var SEVERITIES = {error: 1, warn: 1, warning: 1, debug: 1, log: 1, info: 1};
337
+ function sevClass(v) {
338
+ v = String(v == null ? '' : v).toLowerCase();
339
+ return SEVERITIES[v] ? v : '';
340
+ }
151
341
  function readEmbedded() {
152
- var el = document.getElementById('csim-trace');
342
+ var el = $('csim-trace');
153
343
  try {
154
344
  var d = JSON.parse(el.textContent);
155
345
  return (d && Array.isArray(d.steps)) ? d : null;
@@ -158,40 +348,82 @@
158
348
 
159
349
  function load(data) {
160
350
  if (!data || !Array.isArray(data.steps)) { return; }
161
- trace = data; selected = 0;
162
- emptyEl.classList.add('hide');
163
- stepsEl.style.display = ''; detailEl.style.display = '';
164
- renderMeta(); renderSteps(); renderDetail();
165
- }
166
-
167
- function renderMeta() {
168
- var m = trace.metadata || {}, parts = [];
169
- if (m.title) parts.push('<span>' + esc(m.title) + '</span>');
170
- if (m.file) parts.push('<span class="mono">' + esc(m.file) + '</span>');
171
- if (m.outcome) parts.push('<span class="badge ' + esc(m.outcome) + '">' + esc(m.outcome) + '</span>');
172
- parts.push('<span>' + trace.steps.length + ' steps</span>');
351
+ trace = data;
352
+ failureIdx = trace.steps.findIndex(function (s) { return !!s.error; });
353
+ // Open on the failure. That is what the reader came for, and on a long trace it is nowhere
354
+ // near the top; when nothing errored, the first step is the start of the story.
355
+ selected = failureIdx >= 0 ? failureIdx : 0;
356
+ shotTab = 'final';
357
+ emptyEl.hidden = true; mainEl.hidden = false;
358
+ try {
359
+ renderHeader(); renderSteps(); renderDetail();
360
+ } catch (e) {
361
+ // Better a visible complaint than the blank pane a mid-render throw used to leave.
362
+ detailEl.innerHTML = '<div class="empty-note">This trace could not be rendered: ' +
363
+ esc(e && e.message) + '</div>';
364
+ }
365
+ }
366
+
367
+ function renderHeader() {
368
+ var m = trace.metadata || {};
369
+ ruleEl.hidden = false;
370
+ var ident = '';
371
+ if (m.outcome) { ident += '<span class="badge ' + outcomeClass(m.outcome) + '">' + esc(m.outcome) + '</span>'; }
372
+ if (m.title) { ident += '<span class="title">' + esc(m.title) + '</span>'; }
373
+ if (m.file) { ident += '<span class="file">' + esc(m.file) + '</span>'; }
374
+ identEl.innerHTML = ident;
375
+
173
376
  var total = trace.steps.reduce(function (a, s) { return a + (s.duration_ms || 0); }, 0);
174
- parts.push('<span>' + total + ' ms total</span>');
175
- metaEl.innerHTML = parts.join('');
377
+ var stats = '<span>' + trace.steps.length + ' steps</span><span>' + total + ' ms</span>';
378
+ if (m.engine) { stats += '<span>engine ' + esc(m.engine) + '</span>'; }
379
+ statsEl.innerHTML = stats;
380
+
381
+ jumpEl.hidden = failureIdx < 0;
382
+ jumpEl.textContent = failureIdx < 0 ? '' : 'step ' + pad2(failureIdx);
176
383
  }
177
384
 
385
+ // Built ONCE per trace. Rebuilding on every keypress cost 110 ms per `j` on a 5000-step trace
386
+ // (~9 fps held down) — and, worse, emptying the list reset its scroll, so the
387
+ // `scrollIntoView({block: 'nearest'})` that followed always landed the row against the bottom
388
+ // edge and `k` scrolled the wrong way. Moving the selection is two class toggles.
178
389
  function renderSteps() {
179
390
  stepsEl.innerHTML = '';
180
- trace.steps.forEach(function (s, i) {
391
+ rowEls = trace.steps.map(function (s, i) {
392
+ // `!` for the step that failed, `·` for one that only complained — enough to scan a long
393
+ // list for where things started going wrong without opening every step.
394
+ var noisy = asArray(s.console).some(function (c) {
395
+ var sev = String((c && c.severity) || '').toLowerCase();
396
+ return sev === 'error' || sev === 'warn' || sev === 'warning';
397
+ });
181
398
  var row = document.createElement('div');
182
- row.className = 'step' + (i === selected ? ' sel' : '') + (s.error ? ' err' : '');
399
+ row.className = 'step' + (s.error ? ' err' : '');
183
400
  row.innerHTML =
184
- '<span class="idx">' + i + '</span>' +
401
+ '<span class="idx">' + pad2(i) + '</span>' +
185
402
  '<span class="kind">' + esc(s.kind) + '</span>' +
186
403
  '<span class="desc">' + esc(s.description) + '</span>' +
404
+ '<span class="mark">' + (s.error ? '!' : noisy ? '·' : '') + '</span>' +
187
405
  '<span class="dur">' + (s.duration_ms == null ? '' : s.duration_ms + 'ms') + '</span>';
188
- row.onclick = function () { selected = i; renderSteps(); renderDetail(); };
406
+ row.onclick = function () { select(i); };
189
407
  stepsEl.appendChild(row);
408
+ return row;
190
409
  });
410
+ markSelected();
191
411
  }
192
412
 
193
- function section(title, bodyHtml) {
194
- return '<section class="block"><h3>' + title + '</h3>' + bodyHtml + '</section>';
413
+ function markSelected() {
414
+ rowEls.forEach(function (row, i) { row.classList.toggle('sel', i === selected); });
415
+ var row = rowEls[selected];
416
+ // Also on first paint: the viewer opens on the FAILURE, which on a long trace is far below
417
+ // the fold — leaving the reader looking at step 00 with no selected row in sight.
418
+ if (row && row.scrollIntoView) { row.scrollIntoView({block: 'nearest'}); }
419
+ }
420
+
421
+ function select(i) {
422
+ selected = i;
423
+ // A step that errored has its own picture worth seeing (in `full` mode); anything else, the
424
+ // rail stays on the end state rather than blanking out.
425
+ shotTab = trace.steps[i] && trace.steps[i].shot_after ? 'step' : 'final';
426
+ markSelected(); renderDetail();
195
427
  }
196
428
 
197
429
  function fmtBytes(n) {
@@ -204,101 +436,227 @@
204
436
  var c = Math.floor((+st || 0) / 100);
205
437
  return c === 2 ? 'ok' : c === 3 ? 'redir' : (c >= 4 ? 'err' : '');
206
438
  }
439
+ function originOf(u) { var m = /^[a-z]+:\/\/[^/]*/i.exec(String(u == null ? '' : u)); return m ? m[0] : ''; }
440
+ // Usually the origin is the same for every URL in a trace and eats the column, so the path is
441
+ // what tells them apart — but not always: a cross-origin navigation shown as its path alone
442
+ // reads as "/checkout → /checkout", an arrow claiming a change with nothing changed on screen.
443
+ // So the pair decides: same origin, show paths; different, show both in full.
444
+ function pathOf(u) { return String(u == null ? '' : u).replace(/^[a-z]+:\/\/[^/]*/i, '') || '/'; }
445
+ function urlPair(before, after) {
446
+ var same = originOf(before) === originOf(after);
447
+ return same ? {from: pathOf(before), to: pathOf(after)}
448
+ : {from: String(before == null ? '' : before), to: String(after == null ? '' : after)};
449
+ }
450
+ function block(title, extraHead, bodyHtml) {
451
+ return '<div class="block"><h3><span class="cap">' + title + '</span>' + extraHead +
452
+ '<span class="hair"></span></h3>' + bodyHtml + '</div>';
453
+ }
207
454
  function headerTable(h) {
208
455
  var keys = h ? Object.keys(h) : [];
209
- if (!keys.length) { return '<div class="muted">—</div>'; }
210
- return '<dl class="kv hdr">' + keys.map(function (k) {
456
+ if (!keys.length) { return '<div class="net-sub">—</div>'; }
457
+ return '<dl class="kv">' + keys.map(function (k) {
211
458
  return '<dt>' + esc(k) + '</dt><dd>' + esc(h[k]) + '</dd>';
212
459
  }).join('') + '</dl>';
213
460
  }
214
461
  function bodyBlock(label, body) {
215
462
  if (body == null || body === '') { return ''; }
216
- return '<div class="net-sub">' + label + '</div><pre class="net-body">' + esc(body) + '</pre>';
463
+ return '<div><div class="net-sub">' + label + '</div><pre class="body">' + esc(body) + '</pre></div>';
217
464
  }
218
- function wireNetwork() {
219
- detailEl.querySelectorAll('tr.net-row').forEach(function (row) {
220
- row.onclick = function () {
221
- var d = detailEl.querySelector('tr.net-detail[data-i="' + row.getAttribute('data-i') + '"]');
222
- if (d) { d.hidden = !d.hidden; row.classList.toggle('open'); }
223
- };
224
- });
465
+
466
+ function renderBanner() {
467
+ // The failure, kept in view while reading any OTHER step — otherwise the reason the trace
468
+ // exists is one click away and easy to lose track of.
469
+ var show = failureIdx >= 0 && selected !== failureIdx;
470
+ bannerEl.hidden = !show;
471
+ if (!show) { return; }
472
+ var err = trace.steps[failureIdx].error || {};
473
+ bannerEl.innerHTML =
474
+ '<span class="lbl">failed</span>' +
475
+ '<div style="min-width:0;flex:1">' +
476
+ '<div class="cls">' + esc(err.class || 'Error') + '</div>' +
477
+ '<div class="msg">' + esc(err.message) + '</div>' +
478
+ '</div>' +
479
+ '<button type="button" id="banner-jump">step ' + pad2(failureIdx) + '</button>';
480
+ $('banner-jump').onclick = function () { select(failureIdx); };
225
481
  }
226
482
 
227
483
  function renderDetail() {
484
+ renderBanner(); renderRail();
228
485
  var s = trace.steps[selected];
229
- if (!s) { detailEl.innerHTML = ''; return; }
230
- var html = '<h2>#' + selected + ' · ' + esc(s.kind) + '</h2>';
231
- html += '<div class="sub mono">' + esc(s.description) + '</div>';
486
+ if (!s) {
487
+ detailEl.innerHTML = '<div class="empty-note">This trace recorded no steps the example ' +
488
+ 'ended before any Capybara action ran, or tracing was turned off for it.</div>';
489
+ return;
490
+ }
232
491
 
233
- var kv = '<dl class="kv">';
234
- kv += '<dt>elapsed</dt><dd>' + (s.elapsed_ms || 0) + ' ms</dd>';
235
- kv += '<dt>duration</dt><dd>' + (s.duration_ms || 0) + ' ms</dd>';
492
+ var html =
493
+ '<div class="head">' +
494
+ '<span class="idx">#' + pad2(selected) + '</span>' +
495
+ '<h2>' + esc(s.kind) + '</h2>' +
496
+ '<span class="badge' + (s.error ? ' failed' : '') + '">' + (s.error ? 'errored' : 'ok') + '</span>' +
497
+ '</div>' +
498
+ '<div class="desc-line">' + esc(s.description) + '</div>';
499
+
500
+ var facts =
501
+ '<div><span class="k">elapsed</span><span class="v">' + (s.elapsed_ms || 0) + ' ms</span></div>' +
502
+ '<div><span class="k">duration</span><span class="v">' + (s.duration_ms || 0) + ' ms</span></div>';
236
503
  if (s.url_before != null || s.url_after != null) {
237
504
  var changed = s.url_before !== s.url_after;
238
- kv += '<dt>url</dt><dd class="mono">' + esc(s.url_before || '∅') +
239
- (changed ? '<span class="url-to">' + esc(s.url_after || '∅') + '</span>' : '') + '</dd>';
505
+ var u = urlPair(s.url_before, s.url_after);
506
+ facts += '<div><span class="k">url</span><span class="v from" title="' + esc(s.url_before) + '">' + esc(u.from) + '</span>' +
507
+ (changed ? '<span class="v from">→</span><span class="v to" title="' + esc(s.url_after) + '">' + esc(u.to) + '</span>' : '') +
508
+ '</div>';
240
509
  }
241
- kv += '</dl>';
242
- html += section('Step', kv);
510
+ html += '<div class="facts">' + facts + '</div>';
243
511
 
244
512
  if (s.error) {
245
- html += section('Error',
246
- '<div class="panel-error"><div class="cls mono">' + esc(s.error.class || 'Error') +
247
- '</div><div class="msg mono">' + esc(s.error.message) + '</div></div>');
513
+ html += block('Error', '',
514
+ '<div class="card panel-error"><div class="cls">' + esc(s.error.class || 'Error') +
515
+ '</div><div class="msg">' + esc(s.error.message) + '</div></div>');
248
516
  }
249
517
 
250
- if (s.console && s.console.length) {
251
- var logs = s.console.map(function (c) {
518
+ if (asArray(s.console).length) {
519
+ var logs = asArray(s.console).map(function (c) {
520
+ c = c || {};
252
521
  var sev = String(c.severity || 'log').toLowerCase();
253
- return '<div class="log"><span class="sev ' + esc(sev) + '">' + esc(sev) +
522
+ return '<div class="log"><span class="sev ' + sevClass(sev) + '">' + esc(sev) +
254
523
  '</span><span class="msg">' + esc(c.message) + '</span></div>';
255
524
  }).join('');
256
- html += section('Console (' + s.console.length + ')', '<div class="logs">' + logs + '</div>');
525
+ html += block('Console', '<span class="count">' + asArray(s.console).length + '</span>',
526
+ '<div class="card">' + logs + '</div>');
257
527
  }
258
528
 
259
- if (s.network && s.network.length) {
260
- var rows = s.network.map(function (n, i) {
261
- var head =
262
- '<tr class="net-row" data-i="' + i + '">' +
263
- '<td class="method">' + esc(n.method) + '</td>' +
264
- '<td class="url">' + esc(n.url) +
265
- (n.redirected ? ' <span class="redir-tag">redirect</span>' : '') + '</td>' +
266
- '<td class="status ' + statusClass(n.status) + '">' + esc(n.status) + '</td>' +
267
- '<td>' + esc(n.content_type || '') + '</td>' +
268
- '<td class="num">' + fmtBytes(n.size) + '</td>' +
269
- '<td class="num">' + (n.duration_ms == null ? '' : n.duration_ms + ' ms') + '</td>' +
270
- '</tr>';
271
- var detail =
272
- '<tr class="net-detail" data-i="' + i + '" hidden><td colspan="6">' +
273
- '<div class="net-sub">Request headers</div>' + headerTable(n.request_headers) +
274
- bodyBlock('Request body', n.request_body) +
275
- '<div class="net-sub">Response headers</div>' + headerTable(n.response_headers) +
276
- bodyBlock('Response body', n.response_body) +
277
- '</td></tr>';
278
- return head + detail;
529
+ if (asArray(s.network).length) {
530
+ var rows = asArray(s.network).map(function (n, i) {
531
+ n = n || {};
532
+ return '<div class="net-row" data-i="' + i + '">' +
533
+ '<span class="m">' + esc(n.method) + '</span>' +
534
+ '<span class="u">' + esc(n.url) + (n.redirected ? ' ↪' : '') + '</span>' +
535
+ '<span class="s ' + statusClass(n.status) + '">' + esc(n.status) + '</span>' +
536
+ '<span class="t">' + esc(String(n.content_type || '').split(';')[0]) + '</span>' +
537
+ '<span class="z">' + fmtBytes(n.size) + '</span>' +
538
+ '<span class="d">' + (n.duration_ms == null ? '' : n.duration_ms + 'ms') + '</span>' +
539
+ '</div>' +
540
+ '<div class="net-detail" data-i="' + i + '" hidden>' +
541
+ '<div><div class="net-sub">request headers</div>' + headerTable(n.request_headers) + '</div>' +
542
+ bodyBlock('request body', n.request_body) +
543
+ '<div><div class="net-sub">response headers</div>' + headerTable(n.response_headers) + '</div>' +
544
+ bodyBlock('response body', n.response_body) +
545
+ '</div>';
279
546
  }).join('');
280
- html += section('Network (' + s.network.length + ')',
281
- '<div class="muted net-hint">Click a request for headers and bodies.</div>' +
282
- '<table class="net"><tr><th>Method</th><th>URL</th><th>Status</th>' +
283
- '<th>Type</th><th>Size</th><th>Time</th></tr>' + rows + '</table>');
547
+ html += block('Network',
548
+ '<span class="count">' + asArray(s.network).length + '</span>' +
549
+ '<span class="hair"></span><span class="note">click a row for headers &amp; bodies</span>',
550
+ '<div class="card net">' +
551
+ '<div class="net-head"><span>method</span><span>url</span><span>status</span>' +
552
+ '<span>type</span><span class="z">size</span><span class="d">time</span></div>' + rows + '</div>');
284
553
  }
285
554
 
286
- if (s.dom_after) {
287
- html += section('DOM snapshot',
288
- '<div class="tabs"><button id="t-prev" class="on">Preview</button>' +
289
- '<button id="t-src">HTML</button></div><div id="dom-host"></div>');
290
- } else {
291
- html += section('DOM snapshot',
292
- '<div class="muted">Not captured for this step — snapshots are taken only on an action error, ' +
293
- 'or for every step under <code>CSIM_TRACE=full</code>.</div>');
294
- }
555
+ // SOURCE, not a rendered preview. This used to re-render the snapshot in an iframe, which was
556
+ // both redundant beside the screenshot and misleading: the viewer's own browser draws it, and
557
+ // the snapshot's `<link>` stylesheets do not resolve from `file://` — so the "preview" was
558
+ // typically unstyled HTML, resembling neither what the step SAW nor what a browser would show.
559
+ // What the snapshot is uniquely good for is reading and copying.
560
+ var domHead = '<span class="count">' + (s.dom_after ? 'captured' : 'not captured') + '</span>' +
561
+ '<span class="hair"></span>' +
562
+ (s.dom_after ? '<button type="button" id="wrap-toggle">' + (wrap ? 'no wrap' : 'wrap') + '</button>' : '');
563
+ html += block('DOM snapshot', domHead, s.dom_after
564
+ ? '<div id="dom-host"></div>'
565
+ : '<div class="empty-note">Not captured for this step — a snapshot is taken on an action ' +
566
+ 'error, or for every step under <code>CSIM_TRACE=full</code>.</div>');
295
567
 
296
568
  detailEl.innerHTML = html;
297
569
  detailEl.scrollTop = 0;
298
- if (s.network && s.network.length) { wireNetwork(); }
299
- if (s.dom_after) { wireDomTabs(s.dom_after); }
570
+ if (asArray(s.network).length) { wireNetwork(); }
571
+ if (s.dom_after) {
572
+ wireDomSource(s.dom_after);
573
+ $('wrap-toggle').onclick = function (e) {
574
+ // In place: re-rendering the pane for a wrapping toggle scrolled the reader back to the
575
+ // top and closed whichever network row they had open.
576
+ wrap = !wrap;
577
+ var pre = detailEl.querySelector('pre.dom');
578
+ if (pre) { pre.classList.toggle('nowrap', !wrap); }
579
+ e.currentTarget.textContent = wrap ? 'no wrap' : 'wrap';
580
+ };
581
+ }
300
582
  }
301
583
 
584
+ function wireNetwork() {
585
+ detailEl.querySelectorAll('.net-row').forEach(function (row) {
586
+ row.onclick = function () {
587
+ var d = detailEl.querySelector('.net-detail[data-i="' + row.getAttribute('data-i') + '"]');
588
+ if (d) { d.hidden = !d.hidden; row.classList.toggle('open'); }
589
+ };
590
+ });
591
+ }
592
+
593
+ // ── the screenshot rail ────────────────────────────────────────────
594
+ // Two pictures can exist and they mean different things: the state a FAILING example ENDED in
595
+ // (trace-level, painted once after the example) and the state at ONE step (`CSIM_TRACE=full`).
596
+ // The rail keeps them apart rather than letting a step's shot stand in for the ending.
597
+ // …and only ever an inline PNG. `esc()` keeps a value inside the attribute, but `src` is a URL
598
+ // context: `http://…` in a trace would make this file — whose whole contract is that it opens
599
+ // offline with no network — fetch something, which is both a beacon and a way to smuggle data
600
+ // out of a CI artifact. Anything that is not a `data:image/…` URL is treated as no screenshot.
601
+ function shotSrc(u) {
602
+ u = String(u == null ? '' : u);
603
+ return /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]*$/i.test(u) ? u : null;
604
+ }
605
+ function railShots() {
606
+ var s = trace.steps[selected] || {};
607
+ return {final: shotSrc((trace.metadata || {}).screenshot), step: shotSrc(s.shot_after)};
608
+ }
609
+
610
+ function renderRail() {
611
+ var shots = railShots();
612
+ var any = !!(shots.final || shots.step);
613
+ railToggleEl.hidden = !any;
614
+ railEl.hidden = !any || !railOpen;
615
+ if (railEl.hidden) { return; }
616
+
617
+ if (shotTab === 'step' && !shots.step) { shotTab = 'final'; }
618
+ if (shotTab === 'final' && !shots.final) { shotTab = 'step'; }
619
+ tabFinalEl.hidden = !shots.final;
620
+ tabStepEl.hidden = !shots.step;
621
+ tabStepEl.textContent = 'Step ' + pad2(selected);
622
+ tabFinalEl.classList.toggle('on', shotTab === 'final');
623
+ tabStepEl.classList.toggle('on', shotTab === 'step');
624
+
625
+ var s = trace.steps[selected] || {};
626
+ var src = shotTab === 'step' ? shots.step : shots.final;
627
+ var loc = pathOf(shotTab === 'step' ? s.url_after : (trace.steps[trace.steps.length - 1] || {}).url_after);
628
+ railBodyEl.innerHTML =
629
+ '<div class="frame">' +
630
+ '<div class="bar"><span class="dot"></span><span class="loc">' + esc(loc) + '</span></div>' +
631
+ (src ? '<img class="shot" id="shot" alt="the page as the driver painted it" src="' + esc(src) + '">'
632
+ : '<div class="slot"><span>no screenshot</span></div>') +
633
+ '</div>' +
634
+ '<p class="caption">' + (shotTab === 'step'
635
+ ? 'The page as the driver painted it at this step — the same boxes every geometry query ' +
636
+ 'reads, so this is what the step saw. Per-step shots require <code>CSIM_TRACE=full</code>.'
637
+ : 'The state this example ended in, painted once after the test. Recorded for failures; ' +
638
+ 'carried inline in the trace JSON.') + '</p>';
639
+
640
+ // The real pixel size, read off the decoded image rather than assumed.
641
+ dimsEl.textContent = '';
642
+ var img = $('shot');
643
+ if (img) {
644
+ var show = function () {
645
+ if (img.naturalWidth) { dimsEl.textContent = img.naturalWidth + ' × ' + img.naturalHeight; }
646
+ };
647
+ img.complete ? show() : (img.onload = show);
648
+ }
649
+ }
650
+
651
+ railToggleEl.onclick = function () {
652
+ railOpen = !railOpen;
653
+ railToggleEl.textContent = railOpen ? 'Hide preview' : 'Show preview';
654
+ renderRail();
655
+ };
656
+ tabFinalEl.onclick = function () { shotTab = 'final'; renderRail(); };
657
+ tabStepEl.onclick = function () { shotTab = 'step'; renderRail(); };
658
+ jumpEl.onclick = function () { if (failureIdx >= 0) { select(failureIdx); } };
659
+
302
660
  // Pretty-print a DOM-snapshot HTML string for the source view: the
303
661
  // driver serializes flat (one line), so we re-parse with the browser's
304
662
  // own DOMParser and re-emit indented. Returns null on any parse trouble
@@ -314,9 +672,14 @@
314
672
  if (!doc || !doc.documentElement) { return null; }
315
673
  var out = [];
316
674
  var pad = function (d) { return ' '.repeat(d); };
675
+ // Attribute values and text are re-emitted as HTML, so they need HTML escaping — this block
676
+ // is offered for reading AND copying, and `title="say "hi""` does not paste back as the tree
677
+ // it came from. (The display `esc()` the caller applies is a separate layer.)
678
+ var attrEsc = function (v) { return String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;'); };
679
+ var textEsc = function (v) { return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;'); };
317
680
  var attrs = function (el) {
318
681
  return Array.prototype.map.call(el.attributes, function (a) {
319
- return a.value === '' ? ' ' + a.name : ' ' + a.name + '="' + a.value + '"';
682
+ return a.value === '' ? ' ' + a.name : ' ' + a.name + '="' + attrEsc(a.value) + '"';
320
683
  }).join('');
321
684
  };
322
685
  function emit(el, depth) {
@@ -340,11 +703,11 @@
340
703
  if (kids.length === 0) {
341
704
  out.push(pad(depth) + open + '</' + tag + '>');
342
705
  } else if (kids.length === 1 && kids[0].nodeType === 3) {
343
- out.push(pad(depth) + open + kids[0].nodeValue.trim() + '</' + tag + '>');
706
+ out.push(pad(depth) + open + textEsc(kids[0].nodeValue.trim()) + '</' + tag + '>');
344
707
  } else {
345
708
  out.push(pad(depth) + open);
346
709
  kids.forEach(function (k) {
347
- if (k.nodeType === 3) { out.push(pad(depth + 1) + k.nodeValue.trim()); }
710
+ if (k.nodeType === 3) { out.push(pad(depth + 1) + textEsc(k.nodeValue.trim())); }
348
711
  else if (k.nodeType === 8) { out.push(pad(depth + 1) + '<!--' + k.nodeValue + '-->'); }
349
712
  else { emit(k, depth + 1); }
350
713
  });
@@ -356,24 +719,10 @@
356
719
  return out.join('\n');
357
720
  }
358
721
 
359
- function wireDomTabs(domHtml) {
360
- var host = document.getElementById('dom-host');
361
- var bPrev = document.getElementById('t-prev');
362
- var bSrc = document.getElementById('t-src');
363
- function preview() {
364
- bPrev.classList.add('on'); bSrc.classList.remove('on');
365
- var f = document.createElement('iframe');
366
- f.className = 'dom'; f.setAttribute('sandbox', ''); // no scripts: a static visual snapshot
367
- host.innerHTML = ''; host.appendChild(f);
368
- f.srcdoc = domHtml;
369
- }
370
- function source() {
371
- bSrc.classList.add('on'); bPrev.classList.remove('on');
372
- var pretty = formatHtml(domHtml);
373
- host.innerHTML = '<pre class="dom">' + esc(pretty != null ? pretty : domHtml) + '</pre>';
374
- }
375
- bPrev.onclick = preview; bSrc.onclick = source;
376
- preview();
722
+ function wireDomSource(domHtml) {
723
+ var pretty = formatHtml(domHtml);
724
+ $('dom-host').innerHTML = '<pre class="dom' + (wrap ? '' : ' nowrap') + '">' +
725
+ esc(pretty != null ? pretty : domHtml) + '</pre>';
377
726
  }
378
727
 
379
728
  // ── loading other traces (FileReader → CORS-free, works on file://) ──
@@ -385,10 +734,10 @@
385
734
  };
386
735
  r.readAsText(file);
387
736
  }
388
- document.getElementById('file').addEventListener('change', function (e) {
737
+ $('file').addEventListener('change', function (e) {
389
738
  if (e.target.files[0]) { readFile(e.target.files[0]); }
390
739
  });
391
- var veil = document.getElementById('drop-veil');
740
+ var veil = $('drop-veil');
392
741
  window.addEventListener('dragover', function (e) { e.preventDefault(); veil.classList.add('on'); });
393
742
  window.addEventListener('dragleave', function (e) { if (e.relatedTarget === null) { veil.classList.remove('on'); } });
394
743
  window.addEventListener('drop', function (e) {
@@ -397,8 +746,13 @@
397
746
  });
398
747
  window.addEventListener('keydown', function (e) {
399
748
  if (!trace) { return; }
400
- if (e.key === 'ArrowDown' || e.key === 'j') { selected = Math.min(selected + 1, trace.steps.length - 1); renderSteps(); renderDetail(); }
401
- if (e.key === 'ArrowUp' || e.key === 'k') { selected = Math.max(selected - 1, 0); renderSteps(); renderDetail(); }
749
+ if (e.ctrlKey || e.metaKey || e.altKey) { return; } // Ctrl/Cmd+F is find-in-page, not `f`
750
+ var last = trace.steps.length - 1;
751
+ if (e.key === 'ArrowDown' || e.key === 'j') { select(Math.min(selected + 1, last)); }
752
+ else if (e.key === 'ArrowUp' || e.key === 'k') { select(Math.max(selected - 1, 0)); }
753
+ else if (e.key === 'f' && failureIdx >= 0) { select(failureIdx); }
754
+ else { return; }
755
+ e.preventDefault();
402
756
  });
403
757
 
404
758
  load(readEmbedded());