specbook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1335 @@
1
+ <script>
2
+ const traces = <%= raw @traces.to_json %>;
3
+ const traceBase = "<%= @traces.present? ? specbook.trace_path(filename: '__PH__').sub('__PH__', '') : '' %>";
4
+
5
+ function switchTab(tab) {
6
+ var tSidebar = document.getElementById('traces-sidebar');
7
+ var sSidebar = document.getElementById('screenshots-sidebar');
8
+ var tViewer = document.getElementById('traces-viewer');
9
+ var sViewer = document.getElementById('screenshots-viewer');
10
+ var tBtn = document.getElementById('tab-traces');
11
+ var sBtn = document.getElementById('tab-screenshots');
12
+ if (tab === 'traces') {
13
+ tSidebar.style.display = ''; sSidebar.style.display = 'none';
14
+ tViewer.style.display = ''; sViewer.style.display = 'none';
15
+ tBtn.style.background = '#334155'; tBtn.style.color = '#e2e8f0';
16
+ sBtn.style.background = 'transparent'; sBtn.style.color = '#94a3b8';
17
+ } else {
18
+ tSidebar.style.display = 'none'; sSidebar.style.display = '';
19
+ tViewer.style.display = 'none'; sViewer.style.display = '';
20
+ tBtn.style.background = 'transparent'; tBtn.style.color = '#94a3b8';
21
+ sBtn.style.background = '#334155'; sBtn.style.color = '#e2e8f0';
22
+ }
23
+ }
24
+
25
+ function selectTrace(index) {
26
+ var items = document.querySelectorAll('.trace-item');
27
+ items.forEach(function(el) { el.classList.remove('active'); });
28
+ items[index].classList.add('active');
29
+
30
+ var t = traces[index];
31
+ var detail = document.getElementById('trace-detail');
32
+ detail.innerHTML =
33
+ '<div style="max-width:500px; text-align:left;">' +
34
+ '<h2 style="font-size:1.1rem; margin-bottom:0.3rem; color:#e2e8f0;">' + t.name + '</h2>' +
35
+ '<div style="font-size:0.8rem; color:#64748b; margin-bottom:1.5rem;">' + t.file + ':' + t.line + '</div>' +
36
+ '<button onclick="openTrace(\'' + t.trace + '\', this)" style="padding:0.75rem 2rem; background:#1d4ed8; color:white; border:none; border-radius:8px; font-size:1rem; cursor:pointer; font-weight:600;">Open in Trace Viewer</button>' +
37
+ '<span id="trace-status" style="margin-left:1rem; font-size:0.85rem; color:#4ade80;"></span>' +
38
+ '</div>';
39
+ }
40
+
41
+ function openTrace(filename, btn) {
42
+ var status = document.getElementById('trace-status');
43
+ status.textContent = 'Launching...';
44
+ btn.disabled = true;
45
+
46
+ var csrfToken = document.querySelector('meta[name="csrf-token"]');
47
+ fetch(traceBase + filename + '/view', {
48
+ method: 'POST',
49
+ headers: { 'X-CSRF-Token': csrfToken ? csrfToken.content : '' }
50
+ }).then(function(r) { return r.json(); })
51
+ .then(function(data) {
52
+ status.textContent = '';
53
+ btn.disabled = false;
54
+ window.open(data.url, '_blank');
55
+ }).catch(function(e) {
56
+ status.textContent = 'Error: ' + e.message;
57
+ status.style.color = '#f87171';
58
+ btn.disabled = false;
59
+ });
60
+ }
61
+
62
+ // Trace search filter
63
+ var traceSearch = document.getElementById('trace-search');
64
+ if (traceSearch) {
65
+ traceSearch.addEventListener('input', function() {
66
+ var q = this.value.toLowerCase();
67
+ document.querySelectorAll('.trace-item').forEach(function(el) {
68
+ el.style.display = el.dataset.name.includes(q) ? '' : 'none';
69
+ });
70
+ });
71
+ }
72
+
73
+ const manifest = <%= raw @manifest.to_json %>;
74
+ const features = <%= raw @features.to_json %>;
75
+ const screenshotBase = "<%= specbook.screenshot_path(filename: '__PLACEHOLDER__').sub('__PLACEHOLDER__', '') %>";
76
+
77
+ let currentExampleIdx = 0;
78
+ let currentStepIdx = 0;
79
+ let currentGherkinIdx = 0; // For gherkin features: index into bg+scenario steps
80
+ let autoplayTimer = null;
81
+ let filter = 'all';
82
+
83
+ // Don't navigate on click if user is selecting text
84
+ function hasTextSelection() {
85
+ var sel = window.getSelection();
86
+ return sel && sel.toString().length > 0;
87
+ }
88
+
89
+ function isGherkinExample(ex) {
90
+ return ex && ex.gherkin && ex.gherkin.scenario;
91
+ }
92
+
93
+ function gherkinStepCount(ex) {
94
+ if (!isGherkinExample(ex)) return 0;
95
+ // +2 for intro and end cards
96
+ return (ex.gherkin.background || []).length + ex.gherkin.scenario.length + 2;
97
+ }
98
+
99
+ function filteredManifest() {
100
+ if (filter === 'all') return manifest;
101
+ return manifest.filter(ex => ex.type === filter);
102
+ }
103
+
104
+ function stepSummary(steps) {
105
+ var parts = [];
106
+ steps.forEach(function(s) {
107
+ var a = s.action || 'navigate';
108
+ var desc = s.description || '';
109
+ if (a === 'navigate') parts.push('→ ' + desc.replace('Navigate to ', ''));
110
+ else if (a === 'click') parts.push('🖱 ' + desc.replace(/^Click (button |link )?/, ''));
111
+ else if (a === 'fill') parts.push('⌨ ' + desc.replace(/^Type /, ''));
112
+ else if (a === 'select') parts.push('▼ ' + desc.replace(/^Select /, ''));
113
+ else if (a === 'assert') {
114
+ var count = (s.assertions || []).length;
115
+ parts.push('✓ ' + count + ' assertion' + (count !== 1 ? 's' : ''));
116
+ }
117
+ });
118
+ return parts.slice(0, 4).join(' › ') + (parts.length > 4 ? ' …' : '');
119
+ }
120
+
121
+ function highlightQuotes(text) {
122
+ return text.replace(/"([^"]*)"/g, '"<span class="quoted">$1</span>"');
123
+ }
124
+
125
+ var featureCollapsed = {};
126
+
127
+ function renderFeatureSidebar(featureFile, activeScenarioName) {
128
+ var featureList = document.getElementById('feature-list');
129
+ var exampleList = document.getElementById('example-list');
130
+ exampleList.style.display = 'none';
131
+ featureList.style.display = '';
132
+
133
+ var filtered = filteredManifest();
134
+
135
+ // Build map of all feature files → their scenarios in manifest
136
+ var allFeatureFiles = [];
137
+ var fileScenarios = {};
138
+ filtered.forEach(function(ex, i) {
139
+ if (!ex.file.endsWith('.feature') || !features[ex.file]) return;
140
+ if (!fileScenarios[ex.file]) { fileScenarios[ex.file] = []; allFeatureFiles.push(ex.file); }
141
+ var sName = (ex.groups || []).length >= 2 ? ex.groups[ex.groups.length - 1] : ex.description;
142
+ fileScenarios[ex.file].push({ name: sName, idx: i, status: ex.status });
143
+ });
144
+
145
+ // Group by top-level category (UI Features vs non-UI), then by domain
146
+ var uiDomains = (window.Specbook && window.Specbook.config && window.Specbook.config.uiDomains) || [];
147
+ var categories = { 'UI Features': {}, 'Services': {}, 'Models': {}, 'Jobs': {}, 'Policies': {}, 'Other': {} };
148
+ var categoryOrder = ['UI Features', 'Services', 'Models', 'Jobs', 'Policies', 'Other'];
149
+ var catDomainOrder = {};
150
+ categoryOrder.forEach(function(c) { catDomainOrder[c] = []; });
151
+
152
+ allFeatureFiles.forEach(function(f) {
153
+ var parts = f.replace(/^spec\/features\//, '').split('/');
154
+ var topDir = parts[0];
155
+ var cat, domain;
156
+ if (uiDomains.indexOf(topDir) >= 0) {
157
+ cat = 'UI Features';
158
+ domain = titleCase(topDir);
159
+ } else if (topDir === 'services') {
160
+ cat = 'Services';
161
+ domain = parts.length > 2 ? titleCase(parts[1]) : 'General';
162
+ } else if (topDir === 'models') {
163
+ cat = 'Models';
164
+ domain = 'Models';
165
+ } else if (topDir === 'jobs') {
166
+ cat = 'Jobs';
167
+ domain = 'Jobs';
168
+ } else if (topDir === 'policies') {
169
+ cat = 'Policies';
170
+ domain = 'Policies';
171
+ } else {
172
+ cat = 'Other';
173
+ domain = titleCase(topDir);
174
+ }
175
+ if (!categories[cat][domain]) { categories[cat][domain] = []; catDomainOrder[cat].push(domain); }
176
+ categories[cat][domain].push(f);
177
+ });
178
+
179
+ function chevron(key) { return featureCollapsed[key] ? '&#9654;' : '&#9660;'; }
180
+
181
+ var html = '';
182
+
183
+ categoryOrder.forEach(function(cat) {
184
+ var catDomains = catDomainOrder[cat];
185
+ if (catDomains.length === 0) return;
186
+ var catKey = 'cat_' + cat;
187
+ if (featureCollapsed[catKey] === undefined) featureCollapsed[catKey] = false;
188
+ var catCount = 0;
189
+ catDomains.forEach(function(d) { catCount += categories[cat][d].reduce(function(sum, f) { return sum + (fileScenarios[f] || []).length; }, 0); });
190
+
191
+ html += '<div class="group-header" data-ftoggle="' + catKey + '" style="font-size:1rem;"><span class="chevron">' + chevron(catKey) + '</span>' + cat + '<span class="count">' + catCount + '</span></div>';
192
+ html += '<div class="group-body' + (featureCollapsed[catKey] ? ' collapsed' : '') + '" data-fbody="' + catKey + '">';
193
+
194
+ catDomains.forEach(function(domain) {
195
+ var domKey = 'domain_' + cat + '_' + domain;
196
+ if (featureCollapsed[domKey] === undefined) featureCollapsed[domKey] = (cat !== 'UI Features');
197
+ var domFiles = categories[cat][domain];
198
+ var domCount = domFiles.reduce(function(sum, f) { return sum + (fileScenarios[f] || []).length; }, 0);
199
+
200
+ // Only show domain sub-header if category has multiple domains
201
+ if (catDomains.length > 1) {
202
+ html += '<div class="group-header" data-ftoggle="' + domKey + '" style="padding-left:1.8rem;font-size:0.95rem;"><span class="chevron">' + chevron(domKey) + '</span>' + domain + '<span class="count">' + domCount + '</span></div>';
203
+ html += '<div class="group-body' + (featureCollapsed[domKey] ? ' collapsed' : '') + '" data-fbody="' + domKey + '">';
204
+ }
205
+
206
+ domFiles.forEach(function(f) {
207
+ var feat = features[f];
208
+ var isCurrentFeature = f === featureFile;
209
+ var fKey = 'feat_' + f;
210
+ if (featureCollapsed[fKey] === undefined) featureCollapsed[fKey] = !isCurrentFeature;
211
+
212
+ var scenarios = fileScenarios[f] || [];
213
+ var passCount = scenarios.filter(function(s) { return s.status !== 'failed'; }).length;
214
+ var failCount = scenarios.length - passCount;
215
+ var statusSummary = failCount > 0 ? '<span style="color:#f87171;">' + failCount + '✗</span>' : '<span style="color:#4ade80;">' + passCount + '✓</span>';
216
+
217
+ html += '<div class="subgroup-header' + (isCurrentFeature ? ' has-active' : '') + '" data-ftoggle="' + fKey + '" style="padding-left:2.2rem;"><span class="chevron">' + chevron(fKey) + '</span>' + feat.name + '<span class="count">' + statusSummary + '</span></div>';
218
+ html += '<div class="subgroup-body' + (featureCollapsed[fKey] ? ' collapsed' : '') + '" data-fbody="' + fKey + '">';
219
+
220
+ // Feature description
221
+ if (feat.description && feat.description.length > 0) {
222
+ feat.description.forEach(function(line) {
223
+ html += '<div style="padding:0.1rem 1.25rem 0.1rem 3.4rem;color:#94a3b8;font-style:italic;font-size:0.72rem;">' + line + '</div>';
224
+ });
225
+ }
226
+
227
+ scenarios.forEach(function(s) {
228
+ var isActive = isCurrentFeature && s.name === activeScenarioName;
229
+ var cls = 'scenario-block' + (isActive ? ' active' : '');
230
+ var icon = s.status === 'failed' ? '<span style="color:#f87171;">✗</span>' : '<span style="color:#4ade80;">✓</span>';
231
+ var ex = filtered[s.idx];
232
+ var screenshotCount = ex && ex.steps ? ex.steps.filter(function(st) { return st.file; }).length : 0;
233
+ var gherkinSteps = ex && ex.gherkin ? (ex.gherkin.background || []).length + (ex.gherkin.scenario || []).length : 0;
234
+ var stepCount = gherkinSteps || (ex && ex.steps ? ex.steps.length : 0);
235
+ var nameColor = s.status === 'failed' ? 'color:#f87171;' : 'color:#4ade80;';
236
+ var meta = '<div style="color:#94a3b8;font-size:0.8em;margin-top:0.15em;">';
237
+ meta += stepCount + ' steps';
238
+ if (screenshotCount > 0) meta += ' · 📷 ' + screenshotCount;
239
+ meta += '</div>';
240
+ html += '<div class="' + cls + '" data-scenario-idx="' + s.idx + '" style="padding-left:3.4rem;"><div class="scenario-header"><div class="scenario-name" style="' + nameColor + '">' + s.name + '</div>' + meta + '</div></div>';
241
+ });
242
+
243
+ html += '</div>';
244
+ });
245
+
246
+ if (catDomains.length > 1) {
247
+ html += '</div>'; // close domain group-body
248
+ }
249
+ }); // end domain loop
250
+
251
+ html += '</div>'; // close category group-body
252
+ }); // end category loop
253
+
254
+ featureList.innerHTML = html;
255
+
256
+ // Toggle handlers
257
+ featureList.querySelectorAll('[data-ftoggle]').forEach(function(el) {
258
+ el.addEventListener('click', function() {
259
+ var key = el.dataset.ftoggle;
260
+ featureCollapsed[key] = !featureCollapsed[key];
261
+ var body = featureList.querySelector('[data-fbody="' + key + '"]');
262
+ var chev = el.querySelector('.chevron');
263
+ if (featureCollapsed[key]) { body.classList.add('collapsed'); chev.innerHTML = '&#9654;'; }
264
+ else { body.classList.remove('collapsed'); chev.innerHTML = '&#9660;'; }
265
+ });
266
+ });
267
+
268
+ // Scenario click handlers
269
+ featureList.querySelectorAll('[data-scenario-idx]').forEach(function(el) {
270
+ el.addEventListener('click', function() {
271
+ if (hasTextSelection()) return;
272
+ selectExample(parseInt(el.dataset.scenarioIdx));
273
+ });
274
+ });
275
+
276
+ // Scroll active into view
277
+ var activeBlock = featureList.querySelector('.scenario-block.active');
278
+ if (activeBlock) activeBlock.scrollIntoView({ block: 'nearest' });
279
+ }
280
+
281
+ function titleCase(s) { return s.split('_').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' '); }
282
+
283
+ function domainFromFile(file) {
284
+ // Returns [topLevel, subArea] e.g. ["Admin", "Devices"] or ["Mobile", null]
285
+ var path = file.replace(/^spec\/(system|features)\//, '');
286
+ var parts = path.split('/');
287
+ if (parts.length >= 3) return [titleCase(parts[0]), titleCase(parts[1])];
288
+ return [titleCase(parts[0]), null];
289
+ }
290
+
291
+ // Track collapsed state for groups and subgroups
292
+ var collapsed = {};
293
+
294
+ function renderExampleList() {
295
+ const list = document.getElementById('example-list');
296
+ const filtered = filteredManifest();
297
+
298
+ // Build 3-level tree: domain → subArea → describeGroup → items
299
+ var domains = {};
300
+ var domainOrder = [];
301
+ filtered.forEach(function(ex, i) {
302
+ var d = domainFromFile(ex.file);
303
+ var domain = d[0], subArea = d[1] || '(root)';
304
+ if (!domains[domain]) { domains[domain] = { subAreas: {}, subOrder: [], total: 0 }; domainOrder.push(domain); }
305
+ if (!domains[domain].subAreas[subArea]) { domains[domain].subAreas[subArea] = { groups: {}, groupOrder: [], items: [], total: 0 }; domains[domain].subOrder.push(subArea); }
306
+ domains[domain].total++;
307
+ domains[domain].subAreas[subArea].total++;
308
+
309
+ var descGroups = (ex.groups || []).slice(1);
310
+ var groupKey = descGroups.length > 0 ? descGroups.join(' › ') : '';
311
+ var groupLabel = descGroups.length > 0 ? descGroups[descGroups.length - 1] : '';
312
+ var sa = domains[domain].subAreas[subArea];
313
+ if (groupKey) {
314
+ if (!sa.groups[groupKey]) { sa.groups[groupKey] = { label: groupLabel, items: [] }; sa.groupOrder.push(groupKey); }
315
+ sa.groups[groupKey].items.push({ ex: ex, idx: i });
316
+ } else {
317
+ sa.items.push({ ex: ex, idx: i });
318
+ }
319
+ });
320
+
321
+ // Active tracking
322
+ var activeEx = filtered[currentExampleIdx];
323
+ var activeDomain = activeEx ? domainFromFile(activeEx.file)[0] : null;
324
+ var activeSubArea = activeEx ? (domainFromFile(activeEx.file)[1] || '(root)') : null;
325
+
326
+ // Auto-expand: domains/subAreas with failures or active item
327
+ var failDomains = {}, failSubAreas = {};
328
+ filtered.forEach(function(ex) {
329
+ if (ex.status === 'failed') {
330
+ var d = domainFromFile(ex.file);
331
+ failDomains[d[0]] = true;
332
+ failSubAreas[d[0] + '::' + (d[1] || '(root)')] = true;
333
+ }
334
+ });
335
+ domainOrder.forEach(function(dom) {
336
+ if (collapsed[dom] === undefined) collapsed[dom] = !(dom === activeDomain || failDomains[dom]);
337
+ domains[dom].subOrder.forEach(function(sa) {
338
+ var k = dom + '::' + sa;
339
+ if (collapsed[k] === undefined) collapsed[k] = !(sa === activeSubArea && dom === activeDomain) && !failSubAreas[k];
340
+ });
341
+ });
342
+
343
+ function renderItem(it) {
344
+ var ex = it.ex;
345
+ var displayName = ex.description || ex.name;
346
+ if (displayName.length > 0) displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
347
+ if (ex.type === 'adversarial') displayName += ' <span style="color:#f87171;font-size:0.7rem;">(adversarial)</span>';
348
+ var active = it.idx === currentExampleIdx ? 'active' : '';
349
+ var statusColor = ex.status === 'failed' ? 'color:#f87171' : 'color:#4ade80';
350
+ var editorBase = (window.Specbook && window.Specbook.config && window.Specbook.config.editorBase) || null;
351
+ var openLink = editorBase
352
+ ? '<a href="' + editorBase + '/' + ex.file + ':' + ex.line + '" style="color:inherit;text-decoration:underline;" onclick="event.stopPropagation()">open</a>'
353
+ : '<span style="color:inherit;">' + ex.file + ':' + ex.line + '</span>';
354
+ return '<div class="example-item ' + active + '" style="' + statusColor + '" data-idx="' + it.idx + '">' +
355
+ displayName + '<div class="example-file">' + ex.steps.length + ' steps · ' + openLink + '</div></div>';
356
+ }
357
+
358
+ function chevron(key) { return collapsed[key] ? '&#9654;' : '&#9660;'; }
359
+ function bodyClass(key) { return collapsed[key] ? ' collapsed' : ''; }
360
+
361
+ var html = '';
362
+ domainOrder.forEach(function(dom) {
363
+ var d = domains[dom];
364
+ var domCls = 'group-header' + (dom === activeDomain ? ' has-active' : '');
365
+ html += '<div class="' + domCls + '" data-toggle="' + dom + '"><span class="chevron">' + chevron(dom) + '</span>' + dom + '<span class="count">' + d.total + '</span></div>';
366
+ html += '<div class="group-body' + bodyClass(dom) + '" data-body="' + dom + '">';
367
+
368
+ d.subOrder.forEach(function(sa) {
369
+ var sub = d.subAreas[sa];
370
+ var saKey = dom + '::' + sa;
371
+ if (sa === '(root)') {
372
+ // No sub-area header, render items directly
373
+ sub.items.forEach(function(it) { html += renderItem(it); });
374
+ sub.groupOrder.forEach(function(gk) { var g = sub.groups[gk]; g.items.forEach(function(it) { html += renderItem(it); }); });
375
+ } else {
376
+ html += '<div class="subgroup-header" data-toggle="' + saKey + '"><span class="chevron">' + chevron(saKey) + '</span>' + sa + '<span class="count">' + sub.total + '</span></div>';
377
+ html += '<div class="subgroup-body' + bodyClass(saKey) + '" data-body="' + saKey + '">';
378
+
379
+ sub.items.forEach(function(it) { html += renderItem(it); });
380
+ var lastGroup = '';
381
+ sub.groupOrder.forEach(function(gk) {
382
+ var g = sub.groups[gk];
383
+ if (g.label !== lastGroup) {
384
+ var gKey = saKey + '::' + g.label;
385
+ if (collapsed[gKey] === undefined) collapsed[gKey] = false;
386
+ html += '<div class="subgroup-header" style="padding-left:3.4rem;" data-toggle="' + gKey + '"><span class="chevron">' + chevron(gKey) + '</span>' + g.label + '<span class="count">' + g.items.length + '</span></div>';
387
+ html += '<div class="subgroup-body depth-3' + bodyClass(gKey) + '" data-body="' + gKey + '">';
388
+ lastGroup = g.label;
389
+ }
390
+ g.items.forEach(function(it) { html += renderItem(it); });
391
+ // Check if next group is different — close the div
392
+ var nextGk = sub.groupOrder[sub.groupOrder.indexOf(gk) + 1];
393
+ var nextLabel = nextGk ? sub.groups[nextGk].label : null;
394
+ if (nextLabel !== g.label) {
395
+ html += '</div>';
396
+ }
397
+ });
398
+ html += '</div>';
399
+ }
400
+ });
401
+ html += '</div>';
402
+ });
403
+
404
+ list.innerHTML = html;
405
+
406
+ // Summary counts
407
+ var passCount = filtered.filter(function(ex) { return ex.status !== 'failed'; }).length;
408
+ var failCount = filtered.filter(function(ex) { return ex.status === 'failed'; }).length;
409
+ document.getElementById('total-count').textContent = filtered.length + ' specs';
410
+ document.getElementById('pass-count').textContent = passCount + ' passing';
411
+ document.getElementById('fail-count').textContent = failCount > 0 ? failCount + ' failing' : '';
412
+
413
+ // Click handlers for items
414
+ list.querySelectorAll('.example-item').forEach(function(el) {
415
+ el.addEventListener('click', function() { if (hasTextSelection()) return; selectExample(parseInt(el.dataset.idx)); });
416
+ });
417
+
418
+ // Click handlers for all toggle headers (groups + subgroups)
419
+ list.querySelectorAll('[data-toggle]').forEach(function(el) {
420
+ el.addEventListener('click', function() {
421
+ var key = el.dataset.toggle;
422
+ collapsed[key] = !collapsed[key];
423
+ var body = list.querySelector('[data-body="' + key + '"]');
424
+ var chevron = el.querySelector('.chevron');
425
+ if (collapsed[key]) {
426
+ body.classList.add('collapsed');
427
+ chevron.innerHTML = '&#9654;';
428
+ } else {
429
+ body.classList.remove('collapsed');
430
+ chevron.innerHTML = '&#9660;';
431
+ }
432
+ });
433
+ });
434
+
435
+ }
436
+
437
+ // Filter state — two orthogonal radio groups + search
438
+ var statusFilter = 'all'; // all | passing | failing
439
+ var typeFilter = 'all'; // all | visual | code
440
+ var searchQuery = '';
441
+
442
+ function scenarioVisible(ex) {
443
+ // Status filter
444
+ if (statusFilter === 'passing' && ex.status === 'failed') return false;
445
+ if (statusFilter === 'failing' && ex.status !== 'failed') return false;
446
+ // Type filter
447
+ var hasScreenshots = ex.steps && ex.steps.some(function(st) { return st.file; });
448
+ if (typeFilter === 'visual' && !hasScreenshots) return false;
449
+ if (typeFilter === 'code' && hasScreenshots) return false;
450
+ // Search filter
451
+ if (searchQuery) {
452
+ var q = searchQuery.toLowerCase();
453
+ var searchText = (ex.name || '') + ' ' + (ex.description || '') + ' ' + (ex.file || '');
454
+ // Include gherkin steps in search
455
+ if (ex.gherkin) {
456
+ (ex.gherkin.background || []).forEach(function(s) { searchText += ' ' + s.keyword + ' ' + s.text; });
457
+ (ex.gherkin.scenario || []).forEach(function(s) { searchText += ' ' + s.keyword + ' ' + s.text; });
458
+ }
459
+ // Include feature description from features object
460
+ var feat = typeof features !== 'undefined' && features[ex.file];
461
+ if (feat) {
462
+ searchText += ' ' + (feat.name || '');
463
+ (feat.description || []).forEach(function(d) { searchText += ' ' + d; });
464
+ }
465
+ if (searchText.toLowerCase().indexOf(q) === -1) return false;
466
+ }
467
+ return true;
468
+ }
469
+
470
+ function applyScenarioFilter() {
471
+ var flist = document.getElementById('feature-list');
472
+
473
+ flist.querySelectorAll('[data-scenario-idx]').forEach(function(el) {
474
+ var idx = parseInt(el.dataset.scenarioIdx);
475
+ var ex = manifest[idx];
476
+ el.style.display = (ex && scenarioVisible(ex)) ? '' : 'none';
477
+ });
478
+
479
+ // Update counts and hide empty headers
480
+ var allBodies = Array.from(flist.querySelectorAll('[data-fbody]'));
481
+ allBodies.forEach(function(body) {
482
+ var key = body.dataset.fbody;
483
+ var header = flist.querySelector('[data-ftoggle="' + key + '"]');
484
+ if (!header) return;
485
+ var visibleCount = 0;
486
+ body.querySelectorAll('[data-scenario-idx]').forEach(function(el) {
487
+ if (el.style.display !== 'none') visibleCount++;
488
+ });
489
+ var countEl = header.querySelector('.count');
490
+ if (countEl) countEl.textContent = visibleCount;
491
+ header.style.display = visibleCount === 0 ? 'none' : '';
492
+ body.style.display = visibleCount === 0 ? 'none' : '';
493
+ });
494
+
495
+ // Update top-level stats
496
+ var visibleTotal = 0, visiblePassing = 0;
497
+ manifest.forEach(function(ex) {
498
+ if (scenarioVisible(ex)) {
499
+ visibleTotal++;
500
+ if (ex.status !== 'failed') visiblePassing++;
501
+ }
502
+ });
503
+ var failCount = visibleTotal - visiblePassing;
504
+ document.getElementById('total-count').textContent = visibleTotal + ' specs';
505
+ document.getElementById('pass-count').textContent = visiblePassing + ' passing';
506
+ document.getElementById('fail-count').textContent = failCount > 0 ? failCount + ' failing' : '';
507
+ }
508
+
509
+ // Attach filter listeners — two radio groups
510
+ (function attachFilterListeners() {
511
+ var statusBtns = document.querySelectorAll('[data-status-filter]');
512
+ var typeBtns = document.querySelectorAll('[data-type-filter]');
513
+ if (statusBtns.length === 0) { setTimeout(attachFilterListeners, 50); return; }
514
+
515
+ statusBtns.forEach(function(btn) {
516
+ btn.addEventListener('click', function() {
517
+ statusBtns.forEach(function(b) { b.classList.remove('active'); });
518
+ btn.classList.add('active');
519
+ statusFilter = btn.dataset.statusFilter;
520
+ setTimeout(applyScenarioFilter, 10);
521
+ });
522
+ });
523
+
524
+ typeBtns.forEach(function(btn) {
525
+ btn.addEventListener('click', function() {
526
+ typeBtns.forEach(function(b) { b.classList.remove('active'); });
527
+ btn.classList.add('active');
528
+ typeFilter = btn.dataset.typeFilter;
529
+ setTimeout(applyScenarioFilter, 10);
530
+ });
531
+ });
532
+ })();
533
+
534
+ // Search input — debounced, filters + auto-expands matching sections
535
+ (function() {
536
+ var searchInput = document.getElementById('spec-search');
537
+ var debounceTimer;
538
+ if (searchInput) {
539
+ searchInput.addEventListener('input', function() {
540
+ clearTimeout(debounceTimer);
541
+ debounceTimer = setTimeout(function() {
542
+ searchQuery = searchInput.value.trim();
543
+ applyScenarioFilter();
544
+ // Auto-expand all sections when searching, collapse when cleared
545
+ if (searchQuery) setAllCollapsed(false);
546
+ }, 200);
547
+ });
548
+ // Clear on Escape
549
+ searchInput.addEventListener('keydown', function(e) {
550
+ if (e.key === 'Escape') {
551
+ searchInput.value = '';
552
+ searchQuery = '';
553
+ applyScenarioFilter();
554
+ }
555
+ });
556
+ }
557
+ })();
558
+
559
+ // Expand/collapse all — works for both sidebar types
560
+ function setAllCollapsed(state) {
561
+ var flist = document.getElementById('feature-list');
562
+ flist.querySelectorAll('[data-ftoggle]').forEach(function(el) {
563
+ var key = el.dataset.ftoggle;
564
+ featureCollapsed[key] = state;
565
+ var body = flist.querySelector('[data-fbody="' + key + '"]');
566
+ var chevron = el.querySelector('.chevron');
567
+ if (body) {
568
+ if (state) { body.classList.add('collapsed'); if (chevron) chevron.innerHTML = '&#9654;'; }
569
+ else { body.classList.remove('collapsed'); if (chevron) chevron.innerHTML = '&#9660;'; }
570
+ }
571
+ });
572
+ var list = document.getElementById('example-list');
573
+ list.querySelectorAll('[data-toggle]').forEach(function(el) {
574
+ var key = el.dataset.toggle;
575
+ var body = list.querySelector('[data-body="' + key + '"]');
576
+ var chevron = el.querySelector('.chevron');
577
+ if (body) {
578
+ if (state) { body.classList.add('collapsed'); if (chevron) chevron.innerHTML = '&#9654;'; }
579
+ else { body.classList.remove('collapsed'); if (chevron) chevron.innerHTML = '&#9660;'; }
580
+ }
581
+ });
582
+ }
583
+ document.getElementById('expand-all').onclick = function(e) { e.preventDefault(); setAllCollapsed(false); };
584
+ document.getElementById('collapse-all').onclick = function(e) { e.preventDefault(); setAllCollapsed(true); };
585
+
586
+ function selectExample(idx) {
587
+ const filtered = filteredManifest();
588
+ if (idx < 0 || idx >= filtered.length) return;
589
+ currentExampleIdx = idx;
590
+ currentStepIdx = 0;
591
+ currentGherkinIdx = 0;
592
+
593
+ var ex = filtered[idx];
594
+ if (ex.file.endsWith('.feature') && features[ex.file]) {
595
+ var scenarioName = (ex.groups || []).length >= 2 ? ex.groups[ex.groups.length - 1] : ex.description;
596
+ renderFeatureSidebar(ex.file, scenarioName);
597
+ renderGherkinAtIndex(ex);
598
+ } else {
599
+ document.getElementById('feature-list').style.display = 'none';
600
+ document.getElementById('example-list').style.display = '';
601
+ renderExampleList();
602
+ renderStep();
603
+ }
604
+ }
605
+
606
+ // Calculate image position/scale relative to its container
607
+ function getImageMetrics() {
608
+ const img = document.getElementById('screenshot-img');
609
+ const wrapper = document.getElementById('screenshot-wrapper');
610
+ const imgRect = img.getBoundingClientRect();
611
+ const wrapperRect = wrapper.getBoundingClientRect();
612
+ return {
613
+ scaleX: imgRect.width / (img.naturalWidth || 1400),
614
+ scaleY: imgRect.height / (img.naturalHeight || 900),
615
+ offsetX: imgRect.left - wrapperRect.left,
616
+ offsetY: imgRect.top - wrapperRect.top,
617
+ imgWidth: imgRect.width,
618
+ imgHeight: imgRect.height
619
+ };
620
+ }
621
+
622
+ var stepIcons = { navigate: '→', click: '🖱', fill: '⌨', select: '▼', check: '☑', assert: '✓', confirm: '⚠' };
623
+
624
+ function showOverlayCard(content) {
625
+ var wrapper = document.getElementById('screenshot-wrapper');
626
+ var img = document.getElementById('screenshot-img');
627
+ var overlays = document.getElementById('overlays');
628
+ img.style.display = 'none';
629
+ overlays.innerHTML = '';
630
+ var oldOverlay = wrapper.querySelector('.setup-overlay');
631
+ if (oldOverlay) oldOverlay.remove();
632
+
633
+ var div = document.createElement('div');
634
+ div.className = 'setup-overlay';
635
+ div.innerHTML = content;
636
+ wrapper.appendChild(div);
637
+ }
638
+
639
+ function showIntroOverlay(scenarioName, status) {
640
+ var statusText = status === 'failed' ? '<span style="color:#f87171;">✗ Failed</span>' : '<span style="color:#4ade80;">✓ Passed</span>';
641
+ showOverlayCard(
642
+ '<div style="font-size:0.8rem;color:#64748b;text-transform:uppercase;letter-spacing:0.15em;">Scenario</div>' +
643
+ '<div style="color:#e2e8f0;font-size:1.5rem;font-weight:600;max-width:600px;line-height:1.4;margin:0.5rem 0;">' + scenarioName + '</div>' +
644
+ '<div style="margin-top:1rem;">' + statusText + '</div>'
645
+ );
646
+ }
647
+
648
+ function showEndOverlay(scenarioName, status) {
649
+ var icon = status === 'failed' ? '✗' : '✓';
650
+ var color = status === 'failed' ? '#f87171' : '#4ade80';
651
+ var label = status === 'failed' ? 'Scenario Failed' : 'Scenario Passed';
652
+ showOverlayCard(
653
+ '<div style="font-size:3rem;color:' + color + ';">' + icon + '</div>' +
654
+ '<div style="color:' + color + ';font-size:1.2rem;font-weight:600;margin:0.5rem 0;">' + label + '</div>' +
655
+ '<div style="color:#94a3b8;font-size:0.9rem;">' + scenarioName + '</div>'
656
+ );
657
+ }
658
+
659
+ function showSetupOverlay(gi, bgSteps, scSteps, bgLen) {
660
+ var s = gi < bgLen ? bgSteps[gi] : scSteps[gi - bgLen];
661
+ if (!s) return;
662
+
663
+ var text = s.text;
664
+ var icon, note;
665
+
666
+ // Login step — show key icon with actor colour and full name
667
+ var loginActorPattern = actorNames.length > 0
668
+ ? new RegExp('^(' + actorNames.map(function(n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ') logs in$')
669
+ : null;
670
+ var loginMatch = loginActorPattern ? text.match(loginActorPattern) : null;
671
+ if (loginMatch) {
672
+ var actor = loginMatch[1];
673
+ var color = actorColors[actor] || '#e2e8f0';
674
+ // Find full name from users
675
+ var fullName = text;
676
+ if (typeof manifest !== 'undefined') {
677
+ // Look up from background steps
678
+ var bgSteps = (manifest[0] || {}).gherkin ? manifest[0].gherkin.background : [];
679
+ bgSteps.forEach(function(bs) {
680
+ var m = bs.text.match(new RegExp('(contractor|foreman|admin).*"(' + actor + '[^"]*)"', 'i'));
681
+ if (m) fullName = m[2];
682
+ });
683
+ }
684
+ showOverlayCard(
685
+ '<div class="setup-icon">🔑</div>' +
686
+ '<div class="setup-keyword" style="color:' + color + ';">' + s.keyword.toUpperCase() + '</div>' +
687
+ '<div class="setup-text"><span style="color:' + color + ';font-weight:700;">' + fullName + '</span> logs in</div>' +
688
+ '<div class="setup-note">Authentication — switching user session</div>'
689
+ );
690
+ return;
691
+ }
692
+
693
+ // Host-configured overlay rules — first match wins
694
+ var rules = (window.Specbook && window.Specbook.config && window.Specbook.config.setupOverlayRules) || [];
695
+ var matched = false;
696
+ for (var ri = 0; ri < rules.length; ri++) {
697
+ var rule = rules[ri];
698
+ try {
699
+ var re = new RegExp(rule.pattern, rule.flags || '');
700
+ if (text.match(re)) {
701
+ icon = rule.icon;
702
+ note = rule.note;
703
+ matched = true;
704
+ break;
705
+ }
706
+ } catch (e) {
707
+ // Skip invalid pattern
708
+ }
709
+ }
710
+ if (!matched) {
711
+ icon = '⚙️';
712
+ note = 'No browser interaction';
713
+ }
714
+
715
+ var displayText = text.replace(/"([^"]*)"/g, function(match, inner) {
716
+ for (var name in actorColors) {
717
+ if (inner.indexOf(name) === 0) {
718
+ return '"<span style="color:' + actorColors[name] + ';font-weight:700;">' + inner + '</span>"';
719
+ }
720
+ }
721
+ return '"<span class="quoted">' + inner + '</span>"';
722
+ });
723
+ var sourceHtml = s.source ? renderStepSource(s.source) : '';
724
+ showOverlayCard(
725
+ '<div class="setup-icon">' + icon + '</div>' +
726
+ '<div class="setup-keyword">' + s.keyword + '</div>' +
727
+ '<div class="setup-text">' + displayText + '</div>' +
728
+ '<div class="setup-note">' + note + '</div>' +
729
+ sourceHtml
730
+ );
731
+ }
732
+
733
+ function highlightGherkinStep(stepList, activeGi) {
734
+ stepList.querySelectorAll('li[data-gi]').forEach(function(el) {
735
+ var gi = parseInt(el.dataset.gi);
736
+ el.classList.toggle('active', gi === activeGi);
737
+ el.style.opacity = gi > activeGi ? '0.35' : '';
738
+ });
739
+ var activeLi = stepList.querySelector('li.active');
740
+ if (activeLi) activeLi.scrollIntoView({ block: 'nearest' });
741
+ }
742
+
743
+ // Actor colours — host-configured via Specbook.config.actorColors
744
+ var actorColors = (window.Specbook && window.Specbook.config && window.Specbook.config.actorColors) || {};
745
+ var actorNames = Object.keys(actorColors);
746
+ var actorPattern = actorNames.length > 0
747
+ ? new RegExp('^(' + actorNames.map(function(n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ')\\b')
748
+ : null;
749
+
750
+ function actorFromText(text) {
751
+ if (!actorPattern) return null;
752
+ var m = text.match(actorPattern);
753
+ return m ? m[1] : null;
754
+ }
755
+
756
+ function colorizeStep(rawText) {
757
+ // Highlight quoted strings, but colour actor names within quotes
758
+ var text = rawText.replace(/"([^"]*)"/g, function(match, inner) {
759
+ // Check if the quoted string contains an actor name
760
+ for (var name in actorColors) {
761
+ if (inner.indexOf(name) === 0) {
762
+ return '&ldquo;<span style=color:' + actorColors[name] + ';font-weight:600>' + inner + '</span>&rdquo;';
763
+ }
764
+ }
765
+ return '&ldquo;<span style=color:#4ade80>' + inner + '</span>&rdquo;';
766
+ });
767
+ // Colour actor names at start of step text (named-actor style)
768
+ var actor = actorFromText(rawText);
769
+ if (actor && actorColors[actor]) {
770
+ text = text.replace(new RegExp('^' + actor + '\\b'), '<span style=color:' + actorColors[actor] + ';font-weight:600>' + actor + '</span>');
771
+ }
772
+ return text;
773
+ }
774
+
775
+ function renderStepSource(src) {
776
+ if (!src || !src.body) return '';
777
+ // Body is already stripped of `step '...' do` / `end` by the recorder.
778
+ // Just clean up any stray end lines from older recordings.
779
+ var lines = src.body.split('\n');
780
+ if (lines[0] && lines[0].match(/^\s*step\s/)) lines.shift();
781
+ while (lines.length > 0 && lines[lines.length - 1].match(/^\s*end\s*$/)) lines.pop();
782
+ // Dedent
783
+ var minIndent = 999;
784
+ lines.forEach(function(l) { if (l.trim().length > 0) { var m = l.match(/^(\s*)/); if (m[1].length < minIndent) minIndent = m[1].length; }});
785
+ var body = lines.map(function(l) { return l.substring(minIndent); }).join('\n').trim();
786
+ if (!body) return '';
787
+ // HTML escape
788
+ body = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
789
+
790
+ // Tokenize then reassemble to avoid nested replacements
791
+ // 1. Extract strings and comments into placeholders
792
+ var tokens = [];
793
+ body = body.replace(/#[^\n]*/g, function(m) { tokens.push('<span class="src-comment">' + m + '</span>'); return '__TK' + (tokens.length - 1) + '%%'; });
794
+ body = body.replace(/"[^"]*"/g, function(m) { tokens.push('<span class="src-string">' + m + '</span>'); return '__TK' + (tokens.length - 1) + '%%'; });
795
+ body = body.replace(/'[^']*'/g, function(m) { tokens.push('<span class="src-string">' + m + '</span>'); return '__TK' + (tokens.length - 1) + '%%'; });
796
+
797
+ // 2. Ruby keywords
798
+ body = body.replace(/\b(def|do|end|if|unless|else|elsif|return|nil|true|false|rescue|begin|yield|raise|class|module|require|require_relative|include|extend|prepend|attr_accessor|attr_reader|new|self)\b/g, '<span class="src-keyword">$1</span>');
799
+
800
+ // 3. RSpec/test keywords
801
+ body = body.replace(/\b(expect|allow|receive|have_received|to|not_to|eq|be_nil|be_present|be_a|have_css|have_text|have_link|have_button|have_field|described_class|subject|let!?|before|after|context|describe|it)\b/g, '<span style="color:#7dd3fc;">$1</span>');
802
+
803
+ // 4. Symbols
804
+ body = body.replace(/:(\w+)/g, '<span style="color:#fbbf24;">:$1</span>');
805
+
806
+ // 5. Instance variables
807
+ body = body.replace(/@\w+/g, '<span style="color:#f9a8d4;">$&</span>');
808
+
809
+ // 6. Constants (PascalCase)
810
+ body = body.replace(/\b([A-Z][a-zA-Z0-9]*(::[A-Z][a-zA-Z0-9]*)*)\b/g, '<span style="color:#c4b5fd;">$1</span>');
811
+
812
+ // 7. Method calls after dot
813
+ body = body.replace(/\.(\w+[!?]?)/g, '.<span style="color:#93c5fd;">$1</span>');
814
+
815
+ // 8. Numbers
816
+ body = body.replace(/\b(\d+)\b/g, '<span style="color:#fbbf24;">$1</span>');
817
+
818
+ // Restore tokens
819
+ body = body.replace(/__TK(\d+)%%/g, function(_, i) { return tokens[parseInt(i)]; });
820
+
821
+ var file = src.file + ':' + src.line;
822
+ var editorBase = (window.Specbook && window.Specbook.config && window.Specbook.config.editorBase) || null;
823
+ var fileHtml = editorBase
824
+ ? '<a href="' + editorBase + '/' + src.file + ':' + src.line + '" class="src-file">' + file + '</a>'
825
+ : '<span class="src-file">' + file + '</span>';
826
+ return '<div class="step-source">' + fileHtml + body + '</div>';
827
+ }
828
+
829
+ function renderStep() {
830
+ const filtered = filteredManifest();
831
+ if (filtered.length === 0) return;
832
+ const example = filtered[currentExampleIdx];
833
+ const step = example.steps[currentStepIdx];
834
+
835
+ const img = document.getElementById('screenshot-img');
836
+ const wrapper = document.getElementById('screenshot-wrapper');
837
+ // Remove any previous overlay
838
+ var oldOverlay = wrapper.querySelector('.setup-overlay');
839
+ if (oldOverlay) oldOverlay.remove();
840
+ var oldCard = wrapper.querySelector('.gherkin-card');
841
+ if (oldCard) oldCard.remove();
842
+
843
+ // No-screenshot specs (services, models): show gherkin steps as main content
844
+ if (!step && example.gherkin) {
845
+ img.style.display = 'none';
846
+ var card = document.createElement('div');
847
+ card.className = 'gherkin-card';
848
+ var scenarioName = example.description || example.name;
849
+ var html = '<div class="gherkin-title">Scenario</div>';
850
+ html += '<div class="gherkin-scenario-name">' + scenarioName + '</div>';
851
+ html += '<ul class="gherkin-steps-list">';
852
+ var bg = example.gherkin.background || [];
853
+ var sc = example.gherkin.scenario || [];
854
+ if (bg.length > 0) {
855
+ html += '<li class="bg-label">Background</li>';
856
+ bg.forEach(function(s) {
857
+ html += '<li><span class="kw">' + s.keyword + '</span> ' + colorizeStep(s.text) + '</li>';
858
+ if (s.source) html += renderStepSource(s.source);
859
+ });
860
+ html += '<li class="bg-label">Scenario</li>';
861
+ }
862
+ sc.forEach(function(s) {
863
+ var kwClass = s.keyword === 'Then' ? 'kw kw-then' : 'kw';
864
+ html += '<li><span class="' + kwClass + '">' + s.keyword + '</span> ' + colorizeStep(s.text) + '</li>';
865
+ if (s.source) html += renderStepSource(s.source);
866
+ });
867
+ html += '</ul>';
868
+ var statusClass = example.status === 'failed' ? 'failed' : 'passed';
869
+ var statusIcon = example.status === 'failed' ? '✗ Failed' : '✓ Passed';
870
+ html += '<div class="gherkin-status ' + statusClass + '">' + statusIcon + '</div>';
871
+ card.innerHTML = html;
872
+ wrapper.appendChild(card);
873
+
874
+ // Clear the step list pane
875
+ document.getElementById('step-list').innerHTML = '';
876
+ document.getElementById('progress-bar').style.width = '100%';
877
+ return;
878
+ }
879
+
880
+ if (!step) return;
881
+
882
+ img.style.display = '';
883
+
884
+ if (step.file) {
885
+ img.src = screenshotBase + step.file;
886
+ }
887
+
888
+ // Build step list — Gherkin steps for features, low-level steps for system specs
889
+ const stepList = document.getElementById('step-list');
890
+ if (example.gherkin && example.gherkin.scenario) {
891
+ var bgSteps = example.gherkin.background || [];
892
+ var scSteps = example.gherkin.scenario;
893
+ // Current screenshot's gherkin index (gi field, offset by background length)
894
+ var currentGi = (step.gi !== undefined) ? step.gi : -1;
895
+ var bgLen = bgSteps.length;
896
+
897
+ // Build map: for each gherkin index, find the first screenshot step
898
+ var giToScreenshot = {};
899
+ example.steps.forEach(function(s, si) {
900
+ if (s.gi !== undefined) {
901
+ // Prefer steps with bbox (visible element) over those without
902
+ if (giToScreenshot[s.gi] === undefined || (s.bbox && !example.steps[giToScreenshot[s.gi]].bbox)) giToScreenshot[s.gi] = si;
903
+ }
904
+ });
905
+
906
+ function renderGherkinStep(s, gi, label) {
907
+ var isCurrent = gi === currentGi;
908
+ var isTodo = gi > currentGi;
909
+ var cls = isCurrent ? 'active' : '';
910
+ var opacity = isTodo ? 'opacity:0.35;' : '';
911
+ var kwColor = s.keyword === 'Then' ? '#4ade80' : '#60a5fa';
912
+ var text = colorizeStep(s.text);
913
+ return '<li class="' + cls + '" data-gi="' + gi + '" style="' + opacity + '"><span class="step-icon" style="color:' + kwColor + ';min-width:3.5em;display:inline-block;font-weight:600;">' + s.keyword + '</span> ' + text + '</li>';
914
+ }
915
+
916
+ var html = '';
917
+ // Background steps
918
+ if (bgLen > 0) {
919
+ html += '<li style="color:#64748b;font-size:0.7rem;padding:0.15rem 0.5rem;font-style:italic;">Background</li>';
920
+ bgSteps.forEach(function(s, i) { html += renderGherkinStep(s, i, 'bg'); });
921
+ html += '<li style="border-bottom:1px solid #334155;padding:0;margin:0.25rem 0;"></li>';
922
+ }
923
+ // Scenario label + steps
924
+ html += '<li style="color:#c084fc;font-size:0.72rem;padding:0.3rem 0.5rem 0.15rem;font-weight:600;letter-spacing:0.03em;border-bottom:1px solid #334155;margin-bottom:0.2rem;"><span style="opacity:0.6;">Scenario:</span> ' + (example.description || '') + '</li>';
925
+ scSteps.forEach(function(s, i) { html += renderGherkinStep(s, bgLen + i, 'sc'); });
926
+
927
+ stepList.innerHTML = html;
928
+
929
+ stepList.querySelectorAll('li[data-gi]').forEach(function(el) {
930
+ el.addEventListener('click', function() {
931
+ if (hasTextSelection()) return;
932
+ currentGherkinIdx = parseInt(el.dataset.gi) + 1; // +1 for intro offset
933
+ renderGherkinAtIndex(example);
934
+ });
935
+ });
936
+ } else {
937
+ stepList.innerHTML = example.steps.map((s, i) => {
938
+ var icon = stepIcons[s.action] || '·';
939
+ var desc = s.description || s.action;
940
+ var cls = i === currentStepIdx ? 'active' : '';
941
+ return `<li class="${cls}" data-step="${i}"><span class="step-icon">${icon}</span>${desc}</li>`;
942
+ }).join('');
943
+
944
+ stepList.querySelectorAll('li[data-step]').forEach(el => {
945
+ el.addEventListener('click', () => { if (hasTextSelection()) return; currentStepIdx = parseInt(el.dataset.step); renderStep(); });
946
+ });
947
+ }
948
+
949
+ // Scroll active step into view
950
+ var activeLi = stepList.querySelector('li.active');
951
+ if (activeLi) activeLi.scrollIntoView({ block: 'nearest' });
952
+
953
+ if (isGherkinExample(example)) {
954
+ // Sync gherkin index from screenshot's gi (+1 for intro offset)
955
+ if (step.gi !== undefined) currentGherkinIdx = step.gi + 1;
956
+ var total = gherkinStepCount(example);
957
+ var pct = ((currentGherkinIdx + 1) / total * 100).toFixed(1);
958
+ document.getElementById('progress-fill').style.width = pct + '%';
959
+ document.getElementById('btn-prev').disabled = (currentGherkinIdx === 0 && currentExampleIdx === 0);
960
+ document.getElementById('btn-next').disabled = (currentGherkinIdx === total - 1 && currentExampleIdx === filtered.length - 1);
961
+ } else {
962
+ var pct = ((currentStepIdx + 1) / example.steps.length * 100).toFixed(1);
963
+ document.getElementById('progress-fill').style.width = pct + '%';
964
+ document.getElementById('btn-prev').disabled = (currentStepIdx === 0 && currentExampleIdx === 0);
965
+ document.getElementById('btn-next').disabled = (currentStepIdx === example.steps.length - 1 && currentExampleIdx === filtered.length - 1);
966
+ }
967
+
968
+ // Draw overlays after image loads
969
+ const drawOverlays = () => updateOverlays(step);
970
+ if (img.complete && img.naturalWidth) {
971
+ drawOverlays();
972
+ } else {
973
+ img.addEventListener('load', drawOverlays, { once: true });
974
+ }
975
+ }
976
+
977
+ function updateOverlays(step) {
978
+ const container = document.getElementById('overlays');
979
+ container.innerHTML = '';
980
+
981
+ const m = getImageMetrics();
982
+ const action = step.action || 'navigate';
983
+ const actionLabels = { click: '🖱 Click', fill: '⌨ Type', select: '▼ Select', check: '☑ Check', navigate: '→ Navigate', assert: '✓ Assertions', confirm: '⚠ Confirm' };
984
+
985
+ // Action badge (always)
986
+ const badge = document.createElement('div');
987
+ badge.className = `action-badge ${action}`;
988
+ badge.textContent = actionLabels[action] || action;
989
+ container.appendChild(badge);
990
+
991
+ if (action === 'assert' && step.assertions) {
992
+ // Show ALL assertion highlights simultaneously
993
+ const assertionList = document.createElement('div');
994
+ assertionList.className = 'assertion-list';
995
+
996
+ step.assertions.forEach((assertion, i) => {
997
+ // Draw bounding boxes for each assertion
998
+ (assertion.bboxes || []).forEach(bbox => {
999
+ const box = document.createElement('div');
1000
+ box.className = `overlay-box ${assertion.negative ? 'assert-negative' : 'assert'}`;
1001
+ box.style.left = `${m.offsetX + bbox.x * m.scaleX - 4}px`;
1002
+ box.style.top = `${m.offsetY + bbox.y * m.scaleY - 4}px`;
1003
+ box.style.width = `${bbox.width * m.scaleX + 8}px`;
1004
+ box.style.height = `${bbox.height * m.scaleY + 8}px`;
1005
+ box.style.animationDelay = `${i * 0.1}s`;
1006
+ container.appendChild(box);
1007
+
1008
+ // Label above the box
1009
+ const label = document.createElement('div');
1010
+ label.className = `overlay-label ${assertion.negative ? 'negative' : 'positive'}`;
1011
+ label.textContent = assertion.text;
1012
+ label.style.left = `${m.offsetX + bbox.x * m.scaleX - 4}px`;
1013
+ label.style.top = `${m.offsetY + bbox.y * m.scaleY - 22}px`;
1014
+ label.style.animationDelay = `${i * 0.1}s`;
1015
+ container.appendChild(label);
1016
+ });
1017
+
1018
+ // Add to assertion list panel
1019
+ const item = document.createElement('div');
1020
+ item.className = `assertion-item ${assertion.negative ? 'negative' : 'positive'}`;
1021
+ item.textContent = `${assertion.negative ? '✗' : '✓'} ${assertion.text}`;
1022
+ item.style.animationDelay = `${i * 0.08}s`;
1023
+ assertionList.appendChild(item);
1024
+ });
1025
+
1026
+ container.appendChild(assertionList);
1027
+
1028
+ } else if (step.bbox) {
1029
+ // Single element highlight (click, fill, select, check)
1030
+ const box = document.createElement('div');
1031
+ box.className = `overlay-box ${action}`;
1032
+ const x = m.offsetX + step.bbox.x * m.scaleX;
1033
+ const y = m.offsetY + step.bbox.y * m.scaleY;
1034
+ const w = step.bbox.width * m.scaleX;
1035
+ const h = step.bbox.height * m.scaleY;
1036
+ box.style.left = `${x - 4}px`;
1037
+ box.style.top = `${y - 4}px`;
1038
+ box.style.width = `${w + 8}px`;
1039
+ box.style.height = `${h + 8}px`;
1040
+ container.appendChild(box);
1041
+
1042
+ // Tap circle for clicks — sized slightly larger than element
1043
+ if (action === 'click') {
1044
+ const tap = document.createElement('div');
1045
+ tap.className = 'tap-circle';
1046
+ const diameter = Math.min(w, h) * 1.6 + 16;
1047
+ tap.style.width = `${diameter}px`;
1048
+ tap.style.height = `${diameter}px`;
1049
+ tap.style.left = `${x + w / 2 - diameter / 2}px`;
1050
+ tap.style.top = `${y + h / 2 - diameter / 2}px`;
1051
+ container.appendChild(tap);
1052
+
1053
+ // Cursor icon
1054
+ const cursor = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1055
+ cursor.setAttribute('viewBox', '0 0 24 24');
1056
+ cursor.setAttribute('width', '24');
1057
+ cursor.setAttribute('height', '24');
1058
+ cursor.className = 'cursor-icon';
1059
+ cursor.style.left = `${x + w / 2}px`;
1060
+ cursor.style.top = `${y + h / 2}px`;
1061
+ const cPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1062
+ cPath.setAttribute('d', 'M4 2L4 20L8.5 15.5L12.5 22L15 21L11 14L17 14L4 2Z');
1063
+ cPath.setAttribute('fill', 'white');
1064
+ cPath.setAttribute('stroke', '#1e293b');
1065
+ cPath.setAttribute('stroke-width', '1.5');
1066
+ cursor.appendChild(cPath);
1067
+ container.appendChild(cursor);
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ function nextStep() {
1073
+ const filtered = filteredManifest();
1074
+ const example = filtered[currentExampleIdx];
1075
+ if (isGherkinExample(example)) {
1076
+ var total = gherkinStepCount(example);
1077
+ if (currentGherkinIdx < total - 1) {
1078
+ currentGherkinIdx++;
1079
+ renderGherkinAtIndex(example);
1080
+ } else if (currentExampleIdx < filtered.length - 1) {
1081
+ selectExample(currentExampleIdx + 1);
1082
+ } else {
1083
+ stopAutoplay();
1084
+ }
1085
+ } else {
1086
+ if (currentStepIdx < example.steps.length - 1) {
1087
+ currentStepIdx++;
1088
+ renderStep();
1089
+ } else if (currentExampleIdx < filtered.length - 1) {
1090
+ selectExample(currentExampleIdx + 1);
1091
+ } else {
1092
+ stopAutoplay();
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ function prevStep() {
1098
+ const filtered = filteredManifest();
1099
+ const example = filtered[currentExampleIdx];
1100
+ if (isGherkinExample(example)) {
1101
+ if (currentGherkinIdx > 0) {
1102
+ currentGherkinIdx--;
1103
+ renderGherkinAtIndex(example);
1104
+ } else if (currentExampleIdx > 0) {
1105
+ // Jump to end of previous scenario
1106
+ var prevIdx = currentExampleIdx - 1;
1107
+ var prevEx = filtered[prevIdx];
1108
+ if (isGherkinExample(prevEx)) {
1109
+ currentGherkinIdx = gherkinStepCount(prevEx) - 1;
1110
+ } else {
1111
+ currentStepIdx = prevEx.steps.length - 1;
1112
+ }
1113
+ currentExampleIdx = prevIdx;
1114
+ // Update sidebar + viewer
1115
+ var scenarioName = (prevEx.groups || []).length >= 2 ? prevEx.groups[prevEx.groups.length - 1] : prevEx.description;
1116
+ if (prevEx.file.endsWith('.feature') && features[prevEx.file]) {
1117
+ renderFeatureSidebar(prevEx.file, scenarioName);
1118
+ }
1119
+ renderGherkinAtIndex(prevEx);
1120
+ }
1121
+ } else {
1122
+ if (currentStepIdx > 0) {
1123
+ currentStepIdx--;
1124
+ renderStep();
1125
+ } else if (currentExampleIdx > 0) {
1126
+ var prevIdx = currentExampleIdx - 1;
1127
+ var prevEx = filtered[prevIdx];
1128
+ if (isGherkinExample(prevEx)) {
1129
+ currentGherkinIdx = gherkinStepCount(prevEx) - 1;
1130
+ } else {
1131
+ currentStepIdx = prevEx.steps.length - 1;
1132
+ }
1133
+ currentExampleIdx = prevIdx;
1134
+ var scenarioName = (prevEx.groups || []).length >= 2 ? prevEx.groups[prevEx.groups.length - 1] : prevEx.description;
1135
+ if (prevEx.file.endsWith('.feature') && features[prevEx.file]) {
1136
+ renderFeatureSidebar(prevEx.file, scenarioName);
1137
+ } else {
1138
+ renderExampleList();
1139
+ }
1140
+ renderGherkinAtIndex(prevEx);
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ // Navigate to a specific gherkin index — show screenshot or overlay
1146
+ // Index 0 = intro card, 1..N = gherkin steps (offset by 1), last = end card
1147
+ function renderGherkinAtIndex(example) {
1148
+ var bgSteps = example.gherkin.background || [];
1149
+ var scSteps = example.gherkin.scenario;
1150
+ var bgLen = bgSteps.length;
1151
+ var total = gherkinStepCount(example);
1152
+ var gi = currentGherkinIdx;
1153
+
1154
+ // Intro card
1155
+ if (gi === 0) {
1156
+ showIntroOverlay(example.description || '', example.status);
1157
+ renderGherkinStepList(example, -1);
1158
+ updateGherkinProgress(total, gi);
1159
+ return;
1160
+ }
1161
+ // End card
1162
+ if (gi === total - 1) {
1163
+ showEndOverlay(example.description || '', example.status);
1164
+ renderGherkinStepList(example, -2);
1165
+ updateGherkinProgress(total, gi);
1166
+ return;
1167
+ }
1168
+
1169
+ // Actual gherkin step (offset by 1 for intro)
1170
+ var realGi = gi - 1;
1171
+
1172
+ // Build gi→screenshot map (prefer bbox > real actions > gherkin captures)
1173
+ var giToScreenshot = {};
1174
+ example.steps.forEach(function(s, si) {
1175
+ if (s.gi !== undefined) {
1176
+ var existing = giToScreenshot[s.gi] !== undefined ? example.steps[giToScreenshot[s.gi]] : null;
1177
+ if (!existing) {
1178
+ giToScreenshot[s.gi] = si;
1179
+ } else if (s.bbox && !existing.bbox) {
1180
+ giToScreenshot[s.gi] = si;
1181
+ } else if (s.action !== 'gherkin' && existing.action === 'gherkin') {
1182
+ giToScreenshot[s.gi] = si;
1183
+ }
1184
+ }
1185
+ });
1186
+
1187
+ var screenshotIdx = giToScreenshot[realGi];
1188
+ var screenshotStep = screenshotIdx !== undefined ? example.steps[screenshotIdx] : null;
1189
+
1190
+ // Show overlay for: no screenshot, or "gherkin" action (stale page capture from non-interactive step)
1191
+ if (!screenshotStep || screenshotStep.action === 'gherkin') {
1192
+ showSetupOverlay(realGi, bgSteps, scSteps, bgLen);
1193
+ renderGherkinStepList(example, realGi);
1194
+ updateGherkinProgress(total, gi);
1195
+ } else {
1196
+ currentStepIdx = screenshotIdx;
1197
+ renderStep();
1198
+ }
1199
+ }
1200
+
1201
+ function updateGherkinProgress(total, gi) {
1202
+ var pct = ((gi + 1) / total * 100).toFixed(1);
1203
+ document.getElementById('progress-fill').style.width = pct + '%';
1204
+ document.getElementById('btn-prev').disabled = (gi === 0 && currentExampleIdx === 0);
1205
+ document.getElementById('btn-next').disabled = (gi === total - 1 && currentExampleIdx === filteredManifest().length - 1);
1206
+ }
1207
+
1208
+ // Render just the gherkin step list for a given active gi (used by overlay mode)
1209
+ function renderGherkinStepList(example, activeGi) {
1210
+ var bgSteps = example.gherkin.background || [];
1211
+ var scSteps = example.gherkin.scenario;
1212
+ var bgLen = bgSteps.length;
1213
+ var stepList = document.getElementById('step-list');
1214
+
1215
+ var giToScreenshot = {};
1216
+ example.steps.forEach(function(s, si) {
1217
+ if (s.gi !== undefined) {
1218
+ if (giToScreenshot[s.gi] === undefined || (s.bbox && !example.steps[giToScreenshot[s.gi]].bbox)) giToScreenshot[s.gi] = si;
1219
+ }
1220
+ });
1221
+
1222
+ function renderG(s, gi) {
1223
+ var isCurrent = gi === activeGi;
1224
+ var isTodo = gi > activeGi;
1225
+ var cls = isCurrent ? 'active' : '';
1226
+ var opacity = isTodo ? 'opacity:0.35;' : '';
1227
+ var kwColor = s.keyword === 'Then' ? '#4ade80' : '#60a5fa';
1228
+ var text = colorizeStep(s.text);
1229
+ return '<li class="' + cls + '" data-gi="' + gi + '" style="' + opacity + '"><span class="step-icon" style="color:' + kwColor + ';min-width:3.5em;display:inline-block;font-weight:600;">' + s.keyword + '</span> ' + text + '</li>';
1230
+ }
1231
+
1232
+ var html = '';
1233
+ if (bgLen > 0) {
1234
+ html += '<li style="color:#64748b;font-size:0.7rem;padding:0.15rem 0.5rem;font-style:italic;">Background</li>';
1235
+ bgSteps.forEach(function(s, i) { html += renderG(s, i); });
1236
+ html += '<li style="border-bottom:1px solid #334155;padding:0;margin:0.25rem 0;"></li>';
1237
+ }
1238
+ html += '<li style="color:#c084fc;font-size:0.7rem;padding:0.15rem 0.5rem;font-style:italic;">Scenario: ' + (example.description || '') + '</li>';
1239
+ scSteps.forEach(function(s, i) { html += renderG(s, bgLen + i); });
1240
+ stepList.innerHTML = html;
1241
+
1242
+ stepList.querySelectorAll('li[data-gi]').forEach(function(el) {
1243
+ el.addEventListener('click', function() {
1244
+ if (hasTextSelection()) return;
1245
+ currentGherkinIdx = parseInt(el.dataset.gi) + 1; // +1 for intro offset
1246
+ renderGherkinAtIndex(example);
1247
+ });
1248
+ });
1249
+
1250
+ var activeLi = stepList.querySelector('li.active');
1251
+ if (activeLi) activeLi.scrollIntoView({ block: 'nearest' });
1252
+ }
1253
+
1254
+ function toggleAutoplay() { autoplayTimer ? stopAutoplay() : startAutoplay(); }
1255
+
1256
+ function startAutoplay() {
1257
+ const speed = parseInt(document.getElementById('speed-select').value);
1258
+ document.getElementById('btn-play').innerHTML = '⏸ Pause';
1259
+ document.getElementById('autoplay-indicator').classList.add('active');
1260
+ autoplayTimer = setInterval(nextStep, speed);
1261
+ }
1262
+
1263
+ function stopAutoplay() {
1264
+ clearInterval(autoplayTimer);
1265
+ autoplayTimer = null;
1266
+ document.getElementById('btn-play').innerHTML = '▶ Play';
1267
+ document.getElementById('autoplay-indicator').classList.remove('active');
1268
+ }
1269
+
1270
+ document.getElementById('btn-next')?.addEventListener('click', nextStep);
1271
+ document.getElementById('btn-prev')?.addEventListener('click', prevStep);
1272
+ document.getElementById('btn-play')?.addEventListener('click', toggleAutoplay);
1273
+ document.getElementById('speed-select')?.addEventListener('change', () => {
1274
+ if (autoplayTimer) { stopAutoplay(); startAutoplay(); }
1275
+ });
1276
+
1277
+ document.getElementById('progress-bar')?.addEventListener('click', (e) => {
1278
+ const filtered = filteredManifest();
1279
+ const example = filtered[currentExampleIdx];
1280
+ const rect = e.target.getBoundingClientRect();
1281
+ const pct = (e.clientX - rect.left) / rect.width;
1282
+ currentStepIdx = Math.min(Math.floor(pct * example.steps.length), example.steps.length - 1);
1283
+ renderStep();
1284
+ });
1285
+
1286
+ // Switch to text cursor while dragging to select text
1287
+ document.addEventListener('mousemove', (e) => {
1288
+ if (e.buttons === 1 && window.getSelection().toString().length > 0) {
1289
+ document.body.classList.add('selecting');
1290
+ }
1291
+ });
1292
+ document.addEventListener('mouseup', () => {
1293
+ document.body.classList.remove('selecting');
1294
+ });
1295
+
1296
+ document.addEventListener('keydown', (e) => {
1297
+ if (e.key === 'ArrowRight' || e.key === 'l') nextStep();
1298
+ if (e.key === 'ArrowLeft' || e.key === 'h') prevStep();
1299
+ if (e.key === ' ') { e.preventDefault(); toggleAutoplay(); }
1300
+ if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); selectExample(currentExampleIdx + 1); }
1301
+ if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); selectExample(currentExampleIdx - 1); }
1302
+ });
1303
+
1304
+ document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
1305
+ btn.addEventListener('click', () => {
1306
+ document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active'));
1307
+ btn.classList.add('active');
1308
+ filter = btn.dataset.filter;
1309
+ currentExampleIdx = 0;
1310
+ currentStepIdx = 0;
1311
+ renderExampleList();
1312
+ if (filteredManifest().length > 0) renderStep();
1313
+ });
1314
+ });
1315
+
1316
+ // Redraw overlays on window resize
1317
+ window.addEventListener('resize', () => {
1318
+ const filtered = filteredManifest();
1319
+ if (filtered.length > 0) {
1320
+ const step = filtered[currentExampleIdx]?.steps[currentStepIdx];
1321
+ if (step) updateOverlays(step);
1322
+ }
1323
+ });
1324
+
1325
+ if (manifest.length > 0) {
1326
+ // Update counts
1327
+ var passCount = manifest.filter(function(ex) { return ex.status !== 'failed'; }).length;
1328
+ var failCount = manifest.filter(function(ex) { return ex.status === 'failed'; }).length;
1329
+ document.getElementById('total-count').textContent = manifest.length + ' specs';
1330
+ document.getElementById('pass-count').textContent = passCount + ' passing';
1331
+ document.getElementById('fail-count').textContent = failCount > 0 ? failCount + ' failing' : '';
1332
+
1333
+ selectExample(0);
1334
+ }
1335
+ </script>