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,24 @@
1
+ <div class="sidebar" id="screenshots-sidebar">
2
+ <div class="filter-bar" style="flex-wrap:wrap; gap:0.3rem 0.75rem;">
3
+ <span id="total-count" style="font-size:0.75rem; color:#94a3b8;"></span>
4
+ <span style="font-size:0.75rem;"><span id="pass-count" style="color:#4ade80;"></span> <span id="fail-count" style="color:#f87171;"></span></span>
5
+ <span style="margin-left:auto; font-size:0.75rem; display:flex; gap:0.5rem;">
6
+ <a href="#" id="expand-all" style="color:#94a3b8; text-decoration:none;">expand all</a>
7
+ <a href="#" id="collapse-all" style="color:#94a3b8; text-decoration:none;">collapse all</a>
8
+ </span>
9
+ </div>
10
+ <div class="filter-bar" style="gap:0.3rem; padding:0.4rem 1.25rem; border-bottom:1px solid #334155;">
11
+ <button class="filter-btn active" data-status-filter="all">All</button>
12
+ <button class="filter-btn" data-status-filter="passing" style="color:#4ade80;">Passing</button>
13
+ <button class="filter-btn" data-status-filter="failing" style="color:#f87171;">Failing</button>
14
+ <span style="color:#334155;margin:0 0.2rem;">|</span>
15
+ <button class="filter-btn active" data-type-filter="all">All</button>
16
+ <button class="filter-btn" data-type-filter="visual">šŸ“· Visual</button>
17
+ <button class="filter-btn" data-type-filter="code">šŸ’» Code</button>
18
+ </div>
19
+ <div style="padding:0.4rem 1.25rem; border-bottom:1px solid #334155;">
20
+ <input type="text" id="spec-search" placeholder="Search specs..." style="width:100%; padding:0.4rem 0.6rem; background:#0f172a; border:1px solid #475569; border-radius:4px; color:#e2e8f0; font-size:0.9rem; outline:none;" />
21
+ </div>
22
+ <div id="example-list"></div>
23
+ <div id="feature-list" class="feature-sidebar"></div>
24
+ </div>
@@ -0,0 +1,178 @@
1
+ <style>
2
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; overflow: hidden; font-size: 15px; }
4
+
5
+ .top-bar { background: #1e293b; border-bottom: 1px solid #334155; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
6
+ .top-bar h1 { font-size: 1rem; font-weight: 600; white-space: nowrap; }
7
+ .top-bar a { margin-left: auto; font-size: 0.95rem; color: #94a3b8; text-decoration: none; }
8
+ .top-bar a:hover { color: #e2e8f0; }
9
+
10
+ .content-area { display: flex; flex: 1; overflow: hidden; }
11
+ .sidebar { width: 340px; background: #1e293b; border-right: 1px solid #334155; overflow-y: auto; flex-shrink: 0; }
12
+ .filter-bar { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid #334155; }
13
+ .filter-btn { padding: 0.3rem 0.6rem; border-radius: 4px; border: 1px solid #475569; background: transparent; color: #94a3b8; cursor: pointer; font-size: 1rem; }
14
+ .filter-btn.active { background: #334155; color: #e2e8f0; border-color: #60a5fa; }
15
+
16
+ .example-item { padding: 0.5rem 1.25rem 0.5rem 3.4rem; border-bottom: 1px solid #0f172a; cursor: pointer; font-size: 0.95rem; line-height: 1.4; transition: background 0.15s; }
17
+ .depth-3 .example-item { padding-left: 4.6rem; }
18
+ .example-item:hover { background: #334155; }
19
+ .example-item.active { background: #1d4ed8; }
20
+ .example-file { font-size: 1rem; color: #64748b; margin-top: 0.25rem; }
21
+
22
+ .group-header { padding: 0.5rem 1.25rem; border-bottom: 1px solid #334155; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: #94a3b8; display: flex; align-items: center; gap: 0.5rem; transition: background 0.15s; }
23
+ .group-header:hover { background: #334155; color: #e2e8f0; }
24
+ .group-header .chevron { font-size: 0.6rem; display: inline-block; width: 0.8em; }
25
+ .subgroup-header .chevron { font-size: 0.6rem; display: inline-block; width: 0.8em; }
26
+ .group-header .count { margin-left: auto; font-size: 0.95rem; color: #475569; font-weight: 400; }
27
+ .group-header.has-active { color: #60a5fa; }
28
+ .group-body { overflow: hidden; }
29
+ .group-body.collapsed { display: none; }
30
+ .subgroup-header { padding: 0.5rem 1.25rem 0.5rem 2.2rem; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: #94a3b8; display: flex; align-items: center; gap: 0.5rem; transition: background 0.15s; border-bottom: 1px solid #334155; }
31
+ .subgroup-header:hover { background: #334155; color: #e2e8f0; }
32
+ .subgroup-header .count { margin-left: auto; font-size: 0.95rem; color: #475569; font-weight: 400; }
33
+ .subgroup-body { overflow: hidden; }
34
+ .subgroup-body.collapsed { display: none; }
35
+ .example-item.active .example-file { color: #93c5fd; }
36
+ .badge { display: inline-block; font-size: 0.95rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-right: 0.4rem; font-weight: 600; }
37
+ .badge-happy { background: #166534; color: #4ade80; }
38
+ .badge-adversarial { background: #7f1d1d; color: #f87171; }
39
+
40
+ .viewer { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
41
+ .screenshot-wrapper { flex: 1 1 0; display: flex; align-items: center; justify-content: center; background: #0f172a; overflow: hidden; position: relative; min-height: 0; }
42
+ .screenshot-wrapper img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
43
+
44
+ /* Overlays container — positioned over the image */
45
+ #overlays { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
46
+
47
+ /* Highlight box for actions */
48
+ .overlay-box { position: absolute; border: 3px solid; border-radius: 4px; pointer-events: none; animation: pulse-highlight 1.5s ease-in-out infinite; }
49
+ .overlay-box.click { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
50
+ .overlay-box.fill { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
51
+ .overlay-box.select { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
52
+ .overlay-box.check { border-color: #f97316; background: rgba(249, 115, 22, 0.12); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
53
+ /* Tap circle — semi-transparent orange dot indicating click/tap point */
54
+ .tap-circle { position: absolute; border-radius: 50%; background: rgba(249, 115, 22, 0.25); border: 2px solid rgba(249, 115, 22, 0.6); pointer-events: none; z-index: 4; animation: tap-pulse 1.5s ease-in-out infinite; }
55
+ @keyframes tap-pulse { 0%, 100% { transform: scale(0.9); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.6; } }
56
+ .overlay-box.assert { border-color: #22c55e; background: rgba(34, 197, 94, 0.12); box-shadow: 0 0 10px rgba(34, 197, 94, 0.4); }
57
+ .overlay-box.assert-negative { border-color: #ef4444; background: rgba(239, 68, 68, 0.08); box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); border-style: dashed; }
58
+
59
+ @keyframes pulse-highlight { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
60
+
61
+ /* Assertion label */
62
+ .overlay-label { position: absolute; font-size: 1rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 4px; white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis; pointer-events: none; animation: fade-in 0.3s ease; }
63
+ .overlay-label.positive { background: rgba(34, 197, 94, 0.95); color: white; }
64
+ .overlay-label.negative { background: rgba(239, 68, 68, 0.95); color: white; }
65
+
66
+ /* Navigate arrow — red arrow pointing at URL bar area at top of screenshot */
67
+ .nav-arrow { position: absolute; pointer-events: none; animation: arrow-bounce 1s ease-in-out infinite; }
68
+ @keyframes arrow-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
69
+
70
+ /* URL bar overlay */
71
+ .url-overlay { position: absolute; background: rgba(244, 63, 94, 0.12); border: 2px solid #f43f5e; border-radius: 6px; pointer-events: none; display: flex; align-items: center; padding: 0 0.75rem; animation: pulse-highlight 1.5s ease-in-out infinite; }
72
+ .url-overlay-text { color: #f43f5e; font-size: 1rem; font-weight: 700; font-family: monospace; }
73
+
74
+ /* Action badge */
75
+ .action-badge { position: absolute; top: 12px; left: 12px; padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 1rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; pointer-events: none; backdrop-filter: blur(4px); z-index: 10; animation: fade-in 0.3s ease; }
76
+ .action-badge.click { background: rgba(249, 115, 22, 0.9); color: white; }
77
+ .action-badge.fill { background: rgba(249, 115, 22, 0.9); color: white; }
78
+ .action-badge.select { background: rgba(249, 115, 22, 0.9); color: white; }
79
+ .action-badge.check { background: rgba(249, 115, 22, 0.9); color: white; }
80
+ .action-badge.navigate { background: rgba(245, 158, 11, 0.9); color: #1e293b; }
81
+ .action-badge.assert { background: rgba(34, 197, 94, 0.9); color: white; }
82
+ .action-badge.confirm { background: rgba(239, 68, 68, 0.9); color: white; }
83
+
84
+ /* Cursor icon */
85
+ .cursor-icon { position: absolute; pointer-events: none; width: 24px; height: 24px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); animation: click-bounce 0.6s ease; z-index: 5; }
86
+ @keyframes click-bounce { 0% { transform: scale(1); } 30% { transform: scale(0.8); } 60% { transform: scale(1.1); } 100% { transform: scale(1); } }
87
+
88
+ @keyframes fade-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
89
+
90
+ /* Assertion list panel */
91
+ .assertion-list { position: absolute; bottom: 12px; right: 12px; max-width: 350px; pointer-events: none; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
92
+ .assertion-item { padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 1rem; font-weight: 500; backdrop-filter: blur(4px); animation: fade-in 0.3s ease; }
93
+ .assertion-item.positive { background: rgba(34, 197, 94, 0.9); color: white; }
94
+ .assertion-item.negative { background: rgba(239, 68, 68, 0.9); color: white; }
95
+
96
+ .step-info { background: #1e293b; border-top: 1px solid #334155; padding: 0.5rem 1rem; flex-shrink: 0; min-height: 5.5em; max-height: 18em; overflow-y: auto; }
97
+ .step-list { list-style: none; margin: 0; padding: 0; }
98
+ .step-list li { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 1rem; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
99
+ .step-list li:hover { background: #334155; }
100
+ .step-list li.active { background: #1d4ed8; color: #e2e8f0; font-weight: 500; }
101
+ .step-list li .step-icon { display: inline-block; width: 1.2em; text-align: center; margin-right: 0.3em; }
102
+ body.selecting .step-list li, body.selecting .scenario-block, body.selecting .example-item { cursor: text; }
103
+
104
+ .controls { background: #1e293b; border-top: 1px solid #334155; padding: 0.5rem 1.5rem; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
105
+ .controls button { background: #334155; border: 1px solid #475569; color: #e2e8f0; padding: 0.5rem 1.25rem; border-radius: 6px; cursor: pointer; font-size: 1rem; transition: all 0.15s; }
106
+ .controls button:hover:not(:disabled) { background: #475569; }
107
+ .controls button:disabled { opacity: 0.4; cursor: not-allowed; }
108
+ .controls button.primary { background: #2563eb; border-color: #3b82f6; }
109
+ .controls button.primary:hover:not(:disabled) { background: #1d4ed8; }
110
+
111
+ .progress-bar { flex: 1; height: 6px; background: #334155; border-radius: 3px; overflow: hidden; cursor: pointer; }
112
+ .progress-fill { height: 100%; background: #3b82f6; transition: width 0.2s; border-radius: 3px; }
113
+
114
+ .speed-control { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; color: #94a3b8; }
115
+ .speed-control select { background: #334155; border: 1px solid #475569; color: #e2e8f0; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.95rem; }
116
+
117
+ .autoplay-indicator { font-size: 1rem; color: #4ade80; display: none; }
118
+ .autoplay-indicator.active { display: inline; }
119
+
120
+ /* Setup step overlay — shown when no screenshot exists */
121
+ .setup-overlay { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 1.5rem; color: #94a3b8; text-align: center; padding: 2rem; }
122
+ .setup-overlay .setup-icon { font-size: 3rem; opacity: 0.5; }
123
+ .setup-overlay .setup-keyword { color: #60a5fa; font-weight: 700; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.1em; }
124
+ .setup-overlay .setup-text { color: #e2e8f0; font-size: 1.2rem; font-weight: 500; max-width: 500px; line-height: 1.6; }
125
+ .setup-overlay .setup-text .quoted { color: #4ade80; }
126
+ .setup-overlay .setup-note { font-size: 0.95rem; color: #64748b; font-style: italic; }
127
+
128
+ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 1rem; color: #64748b; }
129
+ .empty-state h2 { font-size: 1.3rem; color: #94a3b8; }
130
+ .empty-state p { max-width: 500px; text-align: center; line-height: 1.6; }
131
+ .empty-state code { background: #334155; padding: 0.5rem 1rem; border-radius: 6px; font-size: 1rem; color: #e2e8f0; }
132
+
133
+ /* Full gherkin view — for service/model specs with no screenshots */
134
+ .gherkin-card { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 2rem; }
135
+ .gherkin-card .gherkin-title { font-size: 1rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.15em; margin-bottom: 0.5rem; }
136
+ .gherkin-card .gherkin-scenario-name { font-size: 1.3rem; color: #e2e8f0; font-weight: 600; margin-bottom: 1.5rem; text-align: center; max-width: 600px; }
137
+ .gherkin-card .gherkin-steps-list { list-style: none; padding: 0; margin: 0; max-width: 600px; width: 100%; }
138
+ .gherkin-card .gherkin-steps-list li { padding: 0.4rem 0.75rem; font-size: 1rem; color: #cbd5e1; line-height: 1.5; border-radius: 4px; }
139
+ .gherkin-card .gherkin-steps-list li .kw { color: #60a5fa; font-weight: 600; display: inline-block; min-width: 3.5em; }
140
+ .gherkin-card .gherkin-steps-list li .kw-then { color: #4ade80; }
141
+ .gherkin-card .gherkin-steps-list li .quoted { color: #4ade80; }
142
+ .gherkin-card .gherkin-steps-list .bg-label { color: #64748b; font-size: 1rem; font-style: italic; padding: 0.5rem 0.75rem 0.15rem; }
143
+ .gherkin-card .gherkin-status { margin-top: 1.5rem; font-size: 1rem; font-weight: 500; }
144
+ .gherkin-card .gherkin-status.passed { color: #4ade80; }
145
+ .gherkin-card .gherkin-status.failed { color: #f87171; }
146
+
147
+ /* Step source code block */
148
+ .step-source { margin: 0.2rem 0 0.4rem 3.5em; background: #1e293b; border: 1px solid #334155; border-radius: 4px; padding: 0.4rem 0.6rem; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 1rem; color: #94a3b8; white-space: pre-wrap; line-height: 1.5; max-height: 200px; overflow-y: auto; }
149
+ .step-source .src-file { color: #64748b; font-size: 0.95rem; display: block; margin-bottom: 0.3rem; text-decoration: none; pointer-events: auto; }
150
+ .step-source .src-file:hover { color: #94a3b8; text-decoration: underline; }
151
+ .step-source .src-keyword { color: #c084fc; }
152
+ .step-source .src-string { color: #4ade80; }
153
+ .step-source .src-comment { color: #475569; font-style: italic; }
154
+ .step-source .src-method { color: #60a5fa; }
155
+
156
+ /* Gherkin feature sidebar */
157
+ .feature-sidebar { padding: 0; font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; font-size: 1rem; line-height: 1.6; }
158
+ .feature-header { padding: 1rem 1.25rem 0.5rem; border-bottom: 1px solid #334155; }
159
+ .feature-keyword { color: #c084fc; font-weight: 600; }
160
+ .feature-name { color: #e2e8f0; font-weight: 600; font-size: 1rem; }
161
+ .feature-desc { color: #94a3b8; font-style: italic; padding: 0.25rem 0 0 1rem; font-size: 1rem; }
162
+ .feature-background { padding: 0.75rem 1.25rem; border-bottom: 1px solid #334155; background: #1a2332; }
163
+ .feature-bg-label { color: #94a3b8; font-weight: 600; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
164
+ .gherkin-step { padding: 0.1rem 0 0.1rem 1rem; color: #cbd5e1; }
165
+ .gherkin-step .kw { color: #60a5fa; font-weight: 600; display: inline-block; min-width: 3.5em; }
166
+ .gherkin-step .quoted { color: #4ade80; }
167
+ .scenario-block { border-bottom: 1px solid #334155; cursor: pointer; transition: background 0.15s; }
168
+ .scenario-block:hover { background: #253348; }
169
+ .scenario-block.active { background: #1e3a5f; border-left: 3px solid #3b82f6; }
170
+ .scenario-header { padding: 0.6rem 1.25rem 0.3rem; display: flex; flex-direction: column; gap: 0.1rem; }
171
+ .scenario-tag { color: #f87171; font-size: 1rem; }
172
+ .scenario-name { color: #e2e8f0; font-weight: 500; }
173
+ .scenario-status { margin-left: auto; font-size: 1rem; }
174
+ .scenario-steps { padding: 0 1.25rem 0.6rem; }
175
+ .scenario-steps .gherkin-step { font-size: 1rem; opacity: 0.7; }
176
+ .scenario-block.active .scenario-steps .gherkin-step { opacity: 1; }
177
+ .scenario-block.active .gherkin-step.current-step { background: #1d4ed8; border-radius: 3px; margin: 0 -0.5rem; padding-left: 1.5rem; }
178
+ </style>
@@ -0,0 +1,13 @@
1
+ <div class="top-bar">
2
+ <h1>Specbook</h1>
3
+ <% if @traces.present? && @manifest.present? %>
4
+ <div style="display:flex; gap:0.5rem; margin-left:1rem;">
5
+ <button onclick="switchTab('screenshots')" id="tab-screenshots" style="padding:0.3rem 0.8rem; border-radius:4px; border:1px solid #475569; background:#334155; color:#e2e8f0; cursor:pointer; font-size:0.8rem;">Screenshots (<%= @manifest.size %>)</button>
6
+ <button onclick="switchTab('traces')" id="tab-traces" style="padding:0.3rem 0.8rem; border-radius:4px; border:1px solid #475569; background:transparent; color:#94a3b8; cursor:pointer; font-size:0.8rem;">Traces (<%= @traces.size %>)</button>
7
+ </div>
8
+ <% end %>
9
+ <span class="autoplay-indicator" id="autoplay-indicator">&#9654; Playing</span>
10
+ <% if Specbook.config.back_link %>
11
+ <a href="<%= Specbook.config.back_link[:href] %>"><%= Specbook.config.back_link[:text] || "← Back" %></a>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,15 @@
1
+ <div class="sidebar" id="traces-sidebar" style="display:none;">
2
+ <div style="padding:0.75rem 1.25rem; border-bottom:1px solid #334155;">
3
+ <input type="text" id="trace-search" placeholder="Filter specs..." style="width:100%; background:#0f172a; border:1px solid #475569; color:#e2e8f0; padding:0.4rem 0.6rem; border-radius:4px; font-size:0.8rem;">
4
+ </div>
5
+ <div id="trace-list" style="overflow-y:auto;">
6
+ <% @traces.each_with_index do |t, i| %>
7
+ <div class="example-item trace-item" data-index="<%= i %>" data-name="<%= t["name"].downcase %>" onclick="selectTrace(<%= i %>)">
8
+ <span class="badge badge-<%= t["type"] %>"><%= t["type"] %></span>
9
+ <span style="font-size:0.7rem; padding:0.1rem 0.3rem; border-radius:3px; background:<%= t["status"] == "passed" ? "#166534" : "#7f1d1d" %>; color:<%= t["status"] == "passed" ? "#4ade80" : "#f87171" %>; margin-right:0.3rem;"><%= t["status"] == "passed" ? "āœ“" : "āœ—" %></span>
10
+ <%= t["name"] %>
11
+ <div class="example-file"><%= t["file"] %>:<%= t["line"] %></div>
12
+ </div>
13
+ <% end %>
14
+ </div>
15
+ </div>
@@ -0,0 +1,44 @@
1
+ <div class="viewer">
2
+ <div id="traces-viewer" style="flex:1; display:none; align-items:center; justify-content:center;">
3
+ <div id="trace-detail" style="text-align:center; color:#64748b;">
4
+ <h2 style="font-size:1.2rem; color:#94a3b8; margin-bottom:0.5rem;">Click a spec to view its trace</h2>
5
+ </div>
6
+ </div>
7
+
8
+ <div id="screenshots-viewer" style="flex:1; display:flex; flex-direction:column;">
9
+ <% if @manifest.empty? && @traces.empty? %>
10
+ <div class="empty-state">
11
+ <h2>No recordings yet</h2>
12
+ <p>Run specs with recording enabled:</p>
13
+ <code>RECORD_SPECS=1 CI=1 bundle exec rspec spec/system/</code>
14
+ <p style="margin-top:0.5rem;">Playwright traces give you DOM snapshots, network activity, and action timeline.</p>
15
+ <p>Then refresh this page.</p>
16
+ </div>
17
+ <% elsif @manifest.present? %>
18
+ <div class="screenshot-wrapper" id="screenshot-wrapper">
19
+ <img id="screenshot-img" src="" alt="Spec screenshot">
20
+ <div id="overlays"></div>
21
+ </div>
22
+ <div class="step-info" id="step-info">
23
+ <ol class="step-list" id="step-list"></ol>
24
+ </div>
25
+ <div class="controls">
26
+ <button id="btn-prev" title="Previous (←)">&#9664; Prev</button>
27
+ <button id="btn-next" class="primary" title="Next (→)">Next &#9654;</button>
28
+ <div class="progress-bar" id="progress-bar">
29
+ <div class="progress-fill" id="progress-fill"></div>
30
+ </div>
31
+ <button id="btn-play" title="Play/Pause (Space)">&#9654; Play</button>
32
+ <div class="speed-control">
33
+ <label>Speed:</label>
34
+ <select id="speed-select">
35
+ <option value="3000">Slow (3s)</option>
36
+ <option value="1500" selected>Normal (1.5s)</option>
37
+ <option value="800">Fast (0.8s)</option>
38
+ <option value="400">Very Fast</option>
39
+ </select>
40
+ </div>
41
+ </div>
42
+ <% end %>
43
+ </div>
44
+ </div>
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <%= csrf_meta_tags %>
7
+ <title>Specbook</title>
8
+ <%= render "styles" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= render "top_bar" %>
13
+
14
+ <div class="content-area">
15
+ <%= render "traces_sidebar" %>
16
+ <%= render "screenshots_sidebar" %>
17
+ <%= render "viewer_panel" %>
18
+ </div>
19
+
20
+ <script>
21
+ window.Specbook = window.Specbook || {};
22
+ window.Specbook.config = <%= raw @specbook_js_config.to_json %>;
23
+ </script>
24
+
25
+ <%= render "app_js" %>
26
+ </body>
27
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ Specbook::Engine.routes.draw do
2
+ root to: "viewer#show"
3
+ get "screenshots/:filename", to: "viewer#screenshot", as: :screenshot, constraints: { filename: /[^\/]+/ }
4
+ get "traces/:filename", to: "viewer#trace", as: :trace, constraints: { filename: /[^\/]+/ }
5
+ post "traces/:filename/view", to: "viewer#view_trace", as: :view_trace, constraints: { filename: /[^\/]+/ }
6
+ end
@@ -0,0 +1,25 @@
1
+ Description:
2
+ Installs Specbook: writes config/initializers/specbook.rb and prints
3
+ further setup steps.
4
+
5
+ Example:
6
+ rails generate specbook:install
7
+
8
+ This will create:
9
+ config/initializers/specbook.rb
10
+
11
+ You will then need to:
12
+
13
+ 1. Mount the engine in config/routes.rb:
14
+
15
+ mount Specbook::Engine => "/specs"
16
+
17
+ 2. Add to spec/rails_helper.rb (or test_helper):
18
+
19
+ require "specbook/rspec"
20
+
21
+ 3. Record specs with:
22
+
23
+ RECORD_SPECS=1 bundle exec rspec
24
+
25
+ 4. Visit /specs in your dev server.
@@ -0,0 +1,19 @@
1
+ require "rails/generators"
2
+
3
+ module Specbook
4
+ module Generators
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Install Specbook: copies an initializer and prints next steps."
9
+
10
+ def copy_initializer
11
+ template "specbook.rb", "config/initializers/specbook.rb"
12
+ end
13
+
14
+ def show_readme
15
+ readme "README" if behavior == :invoke
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+
2
+ ==================================================================
3
+ Specbook installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Mount the engine in config/routes.rb:
8
+
9
+ mount Specbook::Engine => "/specs"
10
+
11
+ 2. Add to spec/rails_helper.rb:
12
+
13
+ require "specbook/rspec"
14
+
15
+ 3. Record specs with:
16
+
17
+ RECORD_SPECS=1 bundle exec rspec
18
+
19
+ 4. Visit /specs in your dev server.
20
+
21
+ Configure Specbook in config/initializers/specbook.rb.
22
+ ==================================================================
@@ -0,0 +1,45 @@
1
+ # Specbook configuration.
2
+ #
3
+ # Specbook is a viewer + recorder for Rails system specs. See README for full docs.
4
+ # All options below are optional — defaults are sensible.
5
+ Specbook.configure do |config|
6
+ # Authorization. Default: dev/test environments only. Provide a lambda
7
+ # taking the controller; return truthy to allow access.
8
+ #
9
+ # config.authorize_with = ->(controller) { controller.current_user&.admin? }
10
+
11
+ # A "back" link in the top bar. Default: no link.
12
+ #
13
+ # config.back_link = { href: "/admin", text: "← Back to admin" }
14
+
15
+ # URL prefix for opening files in your editor. Default: nil (file:line is
16
+ # rendered as plain text).
17
+ #
18
+ # config.editor_base = "vscode://file#{Rails.root}"
19
+
20
+ # Per-actor colors for visual differentiation in the viewer.
21
+ #
22
+ # config.actor_colors = {
23
+ # "Alice" => "#3b82f6",
24
+ # "Bob" => "#f59e0b"
25
+ # }
26
+
27
+ # Top-level directory groupings for the sidebar.
28
+ #
29
+ # config.ui_domains = %w[admin public mobile]
30
+
31
+ # Pattern → icon mapping for non-screenshot Gherkin steps. Defaults provide
32
+ # generic login/setup/redirect rules; add domain-specific rules here.
33
+ #
34
+ # config.setup_overlay_rules = [
35
+ # { pattern: /booking exists/i, icon: "šŸ“…", note: "Test setup" }
36
+ # ] + Specbook::Configuration.new.setup_overlay_rules
37
+
38
+ # Where artifacts are written. Defaults below match Rails.root paths.
39
+ #
40
+ # config.screenshot_root = Rails.root.join("tmp/spec_screenshots")
41
+ # config.trace_root = Rails.root.join("tmp/spec_traces")
42
+ # config.feature_root = Rails.root
43
+ # config.max_runs = 20
44
+ # config.trace_viewer_port = 9322
45
+ end
@@ -0,0 +1,50 @@
1
+ module Specbook
2
+ class Configuration
3
+ attr_accessor :max_runs,
4
+ :trace_viewer_port,
5
+ :actor_colors,
6
+ :ui_domains,
7
+ :setup_overlay_rules,
8
+ :back_link,
9
+ :editor_base,
10
+ :authorize_with
11
+
12
+ attr_writer :screenshot_root, :trace_root, :feature_root
13
+
14
+ def initialize
15
+ @max_runs = 20
16
+ @trace_viewer_port = 9322
17
+ @actor_colors = {}
18
+ @ui_domains = []
19
+ @setup_overlay_rules = [
20
+ { pattern: /\b(?:logs in|signs in|signed in)\b/i, icon: "šŸ”‘", note: "Authentication — switching user session" },
21
+ { pattern: /\bexists?\b/i, icon: "šŸ”§", note: "Test setup — creating test data" },
22
+ { pattern: /\bredirected to\b/i, icon: "āœ…", note: "Assertion passed — verified redirect" }
23
+ ]
24
+ @back_link = nil
25
+ @editor_base = nil
26
+ @authorize_with = nil
27
+ @screenshot_root = nil
28
+ @trace_root = nil
29
+ @feature_root = nil
30
+ end
31
+
32
+ def screenshot_root
33
+ @screenshot_root || rails_root.join("tmp/spec_screenshots")
34
+ end
35
+
36
+ def trace_root
37
+ @trace_root || rails_root.join("tmp/spec_traces")
38
+ end
39
+
40
+ def feature_root
41
+ @feature_root || rails_root
42
+ end
43
+
44
+ private
45
+
46
+ def rails_root
47
+ defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,7 @@
1
+ require "rails/engine"
2
+
3
+ module Specbook
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Specbook
6
+ end
7
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Playwright trace recorder for spec presentations.
4
+ # Enable with: RECORD_TRACES=1 CI=1 bundle exec rspec spec/system/
5
+ #
6
+ # Captures Playwright traces (DOM snapshots, network, console, action timeline)
7
+ # for each system spec. View with: npx playwright show-trace tmp/spec_traces/<name>.zip
8
+
9
+ if ENV["RECORD_TRACES"]
10
+ require "fileutils"
11
+
12
+ module Specbook
13
+ module Recorders
14
+ module PlaywrightTrace
15
+ TRACE_DIR = Specbook.config.trace_root
16
+ mattr_accessor :manifest
17
+ self.manifest = []
18
+
19
+ def self.slug_for(example)
20
+ example.full_description
21
+ .gsub(/[^a-zA-Z0-9]+/, "_")
22
+ .gsub(/\A_|_\z/, "")
23
+ .truncate(120, omission: "")
24
+ end
25
+
26
+ def self.write_manifest!
27
+ FileUtils.mkdir_p(TRACE_DIR)
28
+ File.write(
29
+ TRACE_DIR.join("manifest.json"),
30
+ JSON.pretty_generate(manifest)
31
+ )
32
+ end
33
+ end
34
+
35
+ # Patch visit to start tracing on first navigation
36
+ module TraceVisitPatch
37
+ def visit(path, **)
38
+ if !@_trace_started && page.driver.respond_to?(:with_playwright_page)
39
+ page.driver.with_playwright_page do |pw_page|
40
+ pw_page.context.tracing.start(screenshots: true, snapshots: true, sources: false)
41
+ end
42
+ @_trace_started = true
43
+ end
44
+ super
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ RSpec.configure do |config|
51
+ config.include Specbook::Recorders::TraceVisitPatch, type: :system
52
+
53
+ config.before(:each, type: :system) do
54
+ @_trace_started = false
55
+ end
56
+
57
+ config.after(:each, type: :system) do |example|
58
+ next unless @_trace_started
59
+ next unless page.driver.respond_to?(:with_playwright_page)
60
+
61
+ slug = Specbook::Recorders::PlaywrightTrace.slug_for(example)
62
+ filename = "#{slug}.zip"
63
+ filepath = Specbook::Recorders::PlaywrightTrace::TRACE_DIR.join(filename)
64
+ FileUtils.mkdir_p(Specbook::Recorders::PlaywrightTrace::TRACE_DIR)
65
+
66
+ begin
67
+ page.driver.with_playwright_page do |pw_page|
68
+ pw_page.context.tracing.stop(path: filepath.to_s)
69
+ end
70
+
71
+ Specbook::Recorders::PlaywrightTrace.manifest << {
72
+ name: example.full_description,
73
+ file: example.metadata[:file_path].sub("./", ""),
74
+ line: example.metadata[:line_number],
75
+ status: example.exception ? "failed" : "passed",
76
+ type: example.metadata[:adversarial] ? "adversarial" : "happy",
77
+ trace: filename
78
+ }
79
+ rescue => e
80
+ Rails.logger.warn("[Specbook::Recorders::PlaywrightTrace] stop failed: #{e.message}")
81
+ end
82
+ end
83
+
84
+ config.after(:suite) do
85
+ Specbook::Recorders::PlaywrightTrace.write_manifest!
86
+ count = Specbook::Recorders::PlaywrightTrace.manifest.size
87
+ puts "\nšŸŽ¬ Traces saved to tmp/spec_traces/ (#{count} examples recorded)"
88
+ puts " View with: npx playwright show-trace tmp/spec_traces/<name>.zip" if count > 0
89
+ end
90
+ end
91
+ end